diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1d4ba7b22..74210a159 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 18.0 + 16.0 diff --git a/ios/Podfile b/ios/Podfile index 28d4c2f7d..ad48460a9 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Define a global platform for your project -platform :ios, '18.0' +platform :ios, '16.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -44,7 +44,7 @@ post_install do |installer| # Ensure all pods use the same iOS deployment target target.build_configurations.each do |config| - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '18.0' + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ '$(inherited)', diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 4ef33cfa7..ad056d922 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3,16 +3,12 @@ PODS: - AMapNavi (~> 10.0.900) - Flutter - AMapFoundation (1.8.2) - - AMapLocation (2.10.0): - - AMapFoundation (>= 1.8.0) - AMapNavi (10.0.900): - AMapFoundation (>= 1.8.2) - audio_waveforms (0.0.1): - Flutter - classic_bluetooth (0.0.1): - Flutter - - device_info_plus (0.0.1): - - Flutter - FBAEMKit (18.0.0): - FBSDKCoreKit_Basics (= 18.0.0) - FBSDKCoreKit (18.0.0): @@ -44,6 +40,8 @@ PODS: - WechatOpenSDK-XCFramework (~> 2.0.4) - haptic_feedback (0.5.1): - Flutter + - keyboard_height_plugin (0.0.1): + - Flutter - libwebp (1.5.0): - libwebp/demux (= 1.5.0) - libwebp/mux (= 1.5.0) @@ -56,9 +54,6 @@ PODS: - libwebp/sharpyuv (1.5.0) - libwebp/webp (1.5.0): - libwebp/sharpyuv - - location_service (0.0.1): - - AMapLocation (~> 2.10.0) - - Flutter - Mantle (2.2.0): - Mantle/extobjc (= 2.2.0) - Mantle/extobjc (2.2.0) @@ -89,12 +84,13 @@ PODS: - Flutter - QCloudCOSXML (= 6.4.7) - WechatOpenSDK-XCFramework (2.0.4) + - wifi_iot (0.0.1): + - Flutter DEPENDENCIES: - amap_service (from `.symlinks/plugins/amap_service/ios`) - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - classic_bluetooth (from `.symlinks/plugins/classic_bluetooth/ios`) - - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) - flutter_facebook_auth (from `.symlinks/plugins/flutter_facebook_auth/ios`) - flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`) @@ -103,17 +99,17 @@ DEPENDENCIES: - flutter_tts (from `.symlinks/plugins/flutter_tts/ios`) - fluwx (from `.symlinks/plugins/fluwx/ios`) - haptic_feedback (from `.symlinks/plugins/haptic_feedback/ios`) - - location_service (from `.symlinks/plugins/location_service/ios`) + - keyboard_height_plugin (from `.symlinks/plugins/keyboard_height_plugin/ios`) - ota (from `.symlinks/plugins/ota/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - record_darwin (from `.symlinks/plugins/record_darwin/ios`) - spotify_sdk (from `.symlinks/plugins/spotify_sdk/ios`) - tencentcloud_cos_sdk_plugin (from `.symlinks/plugins/tencentcloud_cos_sdk_plugin/ios`) + - wifi_iot (from `.symlinks/plugins/wifi_iot/ios`) SPEC REPOS: trunk: - AMapFoundation - - AMapLocation - AMapNavi - FBAEMKit - FBSDKCoreKit @@ -135,8 +131,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/audio_waveforms/ios" classic_bluetooth: :path: ".symlinks/plugins/classic_bluetooth/ios" - device_info_plus: - :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter flutter_facebook_auth: @@ -153,8 +147,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/fluwx/ios" haptic_feedback: :path: ".symlinks/plugins/haptic_feedback/ios" - location_service: - :path: ".symlinks/plugins/location_service/ios" + keyboard_height_plugin: + :path: ".symlinks/plugins/keyboard_height_plugin/ios" ota: :path: ".symlinks/plugins/ota/ios" permission_handler_apple: @@ -165,15 +159,15 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/spotify_sdk/ios" tencentcloud_cos_sdk_plugin: :path: ".symlinks/plugins/tencentcloud_cos_sdk_plugin/ios" + wifi_iot: + :path: ".symlinks/plugins/wifi_iot/ios" SPEC CHECKSUMS: amap_service: 1c3e7e07f51cbbdff03719a4a079c60d015c1c0b AMapFoundation: 9885c48fc3a78fdfb84a0299a2293e56ea3c9fec - AMapLocation: 5248aec2455ebb5d104b367813c946430a2ee033 AMapNavi: fceb4715f9a3b5a25f330facebee2aa2bdba42dd audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf - classic_bluetooth: c146e1618e867a47fa7ab79a3bf262e7d69d557d - device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 + classic_bluetooth: 29f392f1ab7c52862a9ef036c94f2713b02e0c50 FBAEMKit: e34530df538b8eb8aeb53c35867715ba6c63ef0c FBSDKCoreKit: d3f479a69127acebb1c6aad91c1a33907bcf6c2f FBSDKCoreKit_Basics: 017b6dc2a1862024815a8229e75661e627ac1e29 @@ -186,8 +180,8 @@ SPEC CHECKSUMS: flutter_tts: b88dbc8655d3dc961bc4a796e4e16a4cc1795833 fluwx: 6bf9c5a3a99ad31b0de137dd92370a0d10a60f4b haptic_feedback: fd1d8509833f58f164fc12122c0357fa9b2750e0 + keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 - location_service: fab4ef23d473296a215c49e12c129e466574d94e Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d ota: 20c3e522c57e7ba70ce118109e73650acc9fec76 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d @@ -200,7 +194,8 @@ SPEC CHECKSUMS: spotify_sdk: a48400bb29f70c4fe251ebfdc9135c37097ac5ca tencentcloud_cos_sdk_plugin: 7bed564dbe72df23e7b5cacb1e51b1a20cb1639c WechatOpenSDK-XCFramework: 36fb2bea0754266c17184adf4963d7e6ff98b69f + wifi_iot: f645260a2be8608517b2a9bf4c39b98e97003acc -PODFILE CHECKSUM: 517ecd5ce5153c9609017e05f4e64ab72605e805 +PODFILE CHECKSUM: e69e629768e642fde97676ea149350c61d57751e COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index bad551a70..2bd851492 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -252,7 +252,7 @@ ); mainGroup = 97C146E51CF9000F007C117D; packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, D013B7972DFA83E400B2B69B /* XCRemoteSwiftPackageReference "flutterfire" */, ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; @@ -526,15 +526,16 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 42C82SZUVX; + DEVELOPMENT_TEAM = 537SFY28XS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + INFOPLIST_KEY_CFBundleDisplayName = "Deep Voice"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; + PRODUCT_BUNDLE_IDENTIFIER = com.chuangxiangyunqi.deapsound; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -549,6 +550,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 537SFY28XS; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; MARKETING_VERSION = 1.0; @@ -716,15 +718,16 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 42C82SZUVX; + DEVELOPMENT_TEAM = 537SFY28XS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + INFOPLIST_KEY_CFBundleDisplayName = "Deep Voice"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; + PRODUCT_BUNDLE_IDENTIFIER = com.chuangxiangyunqi.deapsound; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -741,15 +744,16 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 42C82SZUVX; + DEVELOPMENT_TEAM = 537SFY28XS; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + INFOPLIST_KEY_CFBundleDisplayName = "Deep Voice"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; + PRODUCT_BUNDLE_IDENTIFIER = com.chuangxiangyunqi.deapsound; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -793,7 +797,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c3fedb29c..95d6e55f2 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 5be33da0e..71516c17e 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -39,7 +39,7 @@ - CFBundleVersion + CFBundleVersion $(FLUTTER_BUILD_NUMBER) FacebookAdvertiserIDCollectionEnabled @@ -72,50 +72,12 @@ LSRequiresIPhoneOS - NSAccessorySetupBluetoothCompanyIdentifiers - - EFAB - - NSAccessorySetupDescriptions - - - AccessoryMediaType - Headphones - LocalizedAccessoryName - 语音助理耳机 - - - NSAccessorySetupKitSupports - - Bluetooth - NSAppTransportSecurity NSAllowsArbitraryLoads NSAllowsArbitraryLoadsInWebContent - NSExceptionDomains - - mcp.amap.com - - NSExceptionAllowsInsecureHTTPLoads - - NSExceptionMinimumTLSVersion - TLSv1.0 - NSIncludesSubdomains - - - mcp.ideapsound.com - - NSExceptionAllowsInsecureHTTPLoads - - NSExceptionMinimumTLSVersion - TLSv1.0 - NSIncludesSubdomains - - - NSBluetoothAlwaysUsageDescription 需要蓝牙权限用于连接和管理音频设备 @@ -160,5 +122,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UIViewControllerBasedStatusBarAppearance + diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 032f72d37..0286ef500 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -2,11 +2,15 @@ + com.apple.developer.associated-domains + + applinks:deepsoud.com + + com.apple.developer.homekit + com.apple.developer.networking.wifi-info com.apple.external-accessory.wireless-configuration - com.apple.developer.homekit - diff --git a/lib/data/models/user_Info.dart b/lib/data/models/user_Info.dart index 3f3f9fa86..04fa89610 100644 --- a/lib/data/models/user_Info.dart +++ b/lib/data/models/user_Info.dart @@ -1,3 +1,5 @@ +import 'dart:ffi'; + class User { // 单例实例 static User? _instance; @@ -21,15 +23,7 @@ class User { String phonemac; String password; int viplv = 0; //添加会员等级字段,0表示普通用户 - String earphonename; - String earphonemac; - String earphonelicense; - String earphoneauthcode; - // final Int64 lastbettime; - // final Int64 createtime; - // final String wxopenid; - // final String googleopenid; - // final String facebookopenid; + List devices; // 私有构造函数 User._({ @@ -42,21 +36,14 @@ class User { required this.phone, required this.phonemac, required this.password, - required this.viplv, // 添加默认值 - required this.earphonename, - required this.earphonemac, - required this.earphonelicense, - required this.earphoneauthcode, - // required this.lastbettime, - // required this.createtime, - // required this.wxopenid, - // required this.googleopenid, - // required this.facebookopenid, + required this.viplv, + required this.devices, }); factory User.fromJson(Map json) { // 修复1:安全获取嵌套的 user 字段 final userData = json['user'] as Map?; // 允许空值 + final devicesData = json['devices'] as Map?; // 允许空值 //final token = json['data']['token'] as String? ?? ''; //final token = ''; // 更新单例实例 @@ -74,21 +61,17 @@ class User { phonemac: (userData?['phonemac'] as String?) ?? '', password: (userData?['password'] as String?) ?? '', viplv: (userData?['viplv'] as int?) ?? 0, // 提供默认值 - earphonename: (userData?['earphonename'] as String?) ?? '', - earphonemac: (userData?['earphonemac'] as String?) ?? '', - earphonelicense: (userData?['earphonelicense'] as String?) ?? '', - earphoneauthcode: (userData?['earphoneauthcode'] as String?) ?? '', -// lastbettime: _parseTimestamp(userData['lastbettime']), -// createtime: _parseTimestamp(userData['createtime']), -// wxopenid: (userData?['wxopenid'] as String?) ?? '', -// googleopenid: (userData?['googleopenid'] as String?) ?? '', -// facebookopenid: (userData?['facebookopenid'] as String?) ?? '', + devices: (devicesData as List?) + ?.map((e) => UserDevice.fromJson(e as Map)) + .toList() ?? + [], // 处理设备列表 ); } factory User.fronTokenJson(Map json) { // 修复1:安全获取嵌套的 user 字段 final userData = json['user'] as Map?; // 允许空值 - print("数据获取fronTokenJson${userData}"); + final devicesData = json['devices'] as Map?; // 允许空值 + print("数据获取fronTokenJson${userData} ${devicesData}"); // 更新单例实例 return _instance = User._( token: '', @@ -101,17 +84,12 @@ class User { phone: (userData?['phone'] as String?) ?? '', phonemac: (userData?['phonemac'] as String?) ?? '', password: (userData?['password'] as String?) ?? '', - viplv: (userData?['viplv'] as int?) ?? 0, // 提供默认值 - earphonename: (userData?['earphonename'] as String?) ?? '', - earphonemac: (userData?['earphonemac'] as String?) ?? '', - earphonelicense: (userData?['earphonelicense'] as String?) ?? '', - earphoneauthcode: (userData?['earphoneauthcode'] as String?) ?? '', -// lastbettime: _parseTimestamp(userData['lastbettime']), -// createtime: _parseTimestamp(userData['createtime']), -// wxopenid: (userData?['wxopenid'] as String?) ?? '', -// googleopenid: (userData?['googleopenid'] as String?) ?? '', -// facebookopenid: (userData?['facebookopenid'] as String?) ?? '', + devices: devicesData != null + ? (devicesData as List) + .map((e) => UserDevice.fromJson(e as Map)) + .toList() + : [], // 处理设备列表 ); } @@ -121,7 +99,6 @@ class User { 'name': name, 'uid': uid, 'avatar': avatar, - 'language': language, 'token': token, 'mail': mail, @@ -129,17 +106,52 @@ class User { 'phonemac': phonemac, 'password': password, 'viplv': viplv, + 'devices': devices.map((device) => device.toJson()).toList(), + }, + }; + + // 添加设备 + addUserDevice(UserDevice device) { + devices.add(device); + } + + // 查找设备 + removeUserDevice(int id) { + devices.removeWhere((device) => device.id == id); + } +} + +class UserDevice { + int id; + String uid; + int devicetype; + String devicename; + String devicemac; + String license; + UserDevice({ + required this.id, + required this.uid, + required this.devicetype, + required this.devicename, + required this.devicemac, + required this.license, + }); - 'earphonename': earphonename, - 'earphonemac': earphonemac, - "earphonelicense": earphonelicense, - 'earphoneauthcode': earphoneauthcode, + factory UserDevice.fromJson(Map json) => UserDevice( + id: json['id'] as int, + uid: json['uid'] as String, + devicetype: (json['devicetype'] as num).toInt(), + devicename: json['devicename'] as String, + devicemac: json['devicemac'] as String, + license: json['license'] as String, + ); - // 'lastbettime': lastbettime, - // 'createtime': createtime, - // 'wxopenid': wxopenid, - // 'googleopenid': googleopenid, - // 'facebookopenid': facebookopenid, - } + toJson() => { + 'id': id, + 'uid': uid, + 'devicetype': devicetype, + 'devicename': devicename, + 'devicemac': devicemac, + 'license': license, }; } diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index 175ca4d9e..96bea2fc6 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -782,9 +782,28 @@ class BleManager extends GetxService { } } - Future?> getDeviceInfo(String address) async { + /// 验证设备是否可以配对 + Future stopScan() async { + try { + await _bleService.stopScan(); + } catch (e) { + Logger.error('停止扫描设备: ${e.toString()}'); + } + } + + Future?> getDeviceInfo(String deviceName) async { + try { + final result = _bleService.getDeviceInfo(deviceName); + return result; + } catch (e) { + Logger.error('获取设备信息: ${e.toString()}'); + return null; + } + } + + Future>?> getDeviceList(String deviceName) async { try { - final result = _bleService.getDeviceInfo(address); + final result = _bleService.getDeviceList(deviceName); return result; } catch (e) { Logger.error('获取设备信息: ${e.toString()}'); @@ -865,4 +884,14 @@ class BleManager extends GetxService { } return Future.value(Get.find()); } + + /// 请求蓝牙权限 + Future requestBluetoothPermission() async { + try { + return await _bleService.requestBluetoothPermission(); + } catch (e) { + Logger.error('请求蓝牙权限失败: ${e.toString()}'); + return false; + } + } } diff --git a/lib/data/services/login_service.dart b/lib/data/services/login_service.dart index 2844ef0f0..acc39ad1b 100644 --- a/lib/data/services/login_service.dart +++ b/lib/data/services/login_service.dart @@ -8,7 +8,7 @@ class LoginService { FlWX().register( appSecret: '4cfde33bc22b6cc6b1f8b225dd97d255', appId: 'wx5bbb7be9324f3e2d', - universalLink: 'https://yunqiinnovation.com/app/', + universalLink: 'https://deepsoud.com/app/', params: FlWXCallbackParams(onHttp: (url) async { return ''; })); diff --git a/lib/data/services/network/api.dart b/lib/data/services/network/api.dart index c44f1b391..0301daa87 100644 --- a/lib/data/services/network/api.dart +++ b/lib/data/services/network/api.dart @@ -137,10 +137,10 @@ class Api { } //绑定设备 - static bindearphone([params]) { + static binddevice([params]) { return DioManager().request( NWMethod.post, - '/api/home/user_bindearphone', + '/api/home/user_binddevice', params: params, ); } diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index c853d98f1..309dd2ae0 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -81,11 +81,11 @@ class AzureAsrService extends GetxService implements AsrService { try { final List languages = supportedLanguages ?? _defaultSupportedLanguages; - - // 检查是否需要重新初始化 - if (_isInitialized) { - await dispose(); - } +//底层会初始化前释放 + // // 检查是否需要重新初始化 + // if (_isInitialized) { + // await dispose(); + // } // 设置音频源类型 _audioSourceType = useExternalAudio diff --git a/lib/modules/ble/bindings/ble_binding.dart b/lib/modules/ble/bindings/ble_binding.dart new file mode 100644 index 000000000..4fc2cc5c7 --- /dev/null +++ b/lib/modules/ble/bindings/ble_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import '../controllers/ble_controller.dart'; + +class BleBinding extends Bindings { + @override + void dependencies() { + Get.put(BleController()); + } +} \ No newline at end of file diff --git a/lib/modules/ble/controllers/ble_controller.dart b/lib/modules/ble/controllers/ble_controller.dart new file mode 100644 index 000000000..91ce3f8dd --- /dev/null +++ b/lib/modules/ble/controllers/ble_controller.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp; +import 'package:permission_handler/permission_handler.dart'; +import 'package:app_settings/app_settings.dart'; + +class BleController extends GetxController { + // 扫描状态 + final RxBool isScanning = false.obs; + final RxBool isBluetoothOn = false.obs; + final RxBool hasPermissions = false.obs; + + // 发现的设备列表 + final RxList scanResults = [].obs; + + // 连接的设备 + final Rx connectedDevice = + Rx(null); + + // 错误信息 + final RxString errorMessage = ''.obs; + + @override + void onInit() { + super.onInit(); + _initBluetooth(); + } + + @override + void onClose() { + stopScan(); + super.onClose(); + } + + // 初始化蓝牙 + Future _initBluetooth() async { + try { + // 先检查权限 + await _checkPermissions(); + + // 检查蓝牙适配器状态 + fbp.FlutterBluePlus.adapterState.listen((state) { + isBluetoothOn.value = state == fbp.BluetoothAdapterState.on; + if (state == fbp.BluetoothAdapterState.on) { + errorMessage.value = ''; + } else { + errorMessage.value = '请开启蓝牙'; + stopScan(); + } + }); + + // 监听扫描结果 + fbp.FlutterBluePlus.scanResults.listen((results) { + scanResults.value = results; + }); + + // 监听扫描状态 + fbp.FlutterBluePlus.isScanning.listen((scanning) { + isScanning.value = scanning; + }); + } catch (e) { + errorMessage.value = '蓝牙初始化失败: $e'; + } + } + + // 检查权限(不请求) + Future _checkPermissions() async { + try { + Map statuses = await [ + Permission.bluetoothScan, + Permission.bluetoothConnect, + Permission.location, + ].request(); + + hasPermissions.value = + statuses.values.every((status) => status == PermissionStatus.granted); + + if (!hasPermissions.value) { + errorMessage.value = '需要蓝牙和位置权限才能扫描设备'; + } + } catch (e) { + print('检查权限失败: $e'); + hasPermissions.value = false; + } + } + + // 请求权限 + Future _requestPermissions() async { + try { + Map statuses = await [ + Permission.bluetoothScan, + Permission.bluetoothConnect, + Permission.location, + ].request(); + + bool allGranted = + statuses.values.every((status) => status == PermissionStatus.granted); + hasPermissions.value = allGranted; + + if (!allGranted) { + // 检查是否有权限被永久拒绝 + bool hasPermanentlyDenied = statuses.values + .any((status) => status == PermissionStatus.permanentlyDenied); + + if (hasPermanentlyDenied) { + _showPermissionDialog(); + } else { + errorMessage.value = '需要蓝牙和位置权限才能扫描设备'; + } + } + + return allGranted; + } catch (e) { + print('请求权限失败: $e'); + errorMessage.value = '权限请求失败: $e'; + return false; + } + } + + // 显示权限对话框 + void _showPermissionDialog() { + Get.dialog( + AlertDialog( + title: Text('需要权限'), + content: Text('应用需要蓝牙和位置权限才能扫描设备。请在设置中手动开启权限。'), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text('取消'), + ), + TextButton( + onPressed: () { + Get.back(); + AppSettings.openAppSettings(); + }, + child: Text('去设置'), + ), + ], + ), + ); + } + + // 显示蓝牙开启对话框 + void _showBluetoothDialog() { + Get.dialog( + AlertDialog( + title: Text('需要开启蓝牙'), + content: Text('请开启蓝牙以扫描和连接设备。'), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text('取消'), + ), + TextButton( + onPressed: () { + Get.back(); + // 尝试开启蓝牙 + fbp.FlutterBluePlus.turnOn(); + }, + child: Text('开启蓝牙'), + ), + ], + ), + ); + } + + // 开始扫描 + Future startScan() async { + try { + errorMessage.value = ''; + + // 检查权限 + if (!hasPermissions.value) { + if (!await _requestPermissions()) { + return; + } + } + + // 检查蓝牙状态 + if (!isBluetoothOn.value) { + _showBluetoothDialog(); + return; + } + + // 清空之前的结果 + scanResults.clear(); + + // 开始扫描 + await fbp.FlutterBluePlus.startScan( + timeout: const Duration(seconds: 30), + androidUsesFineLocation: true, + ); + } catch (e) { + errorMessage.value = '扫描失败: $e'; + } + } + + // 停止扫描 + Future stopScan() async { + try { + await fbp.FlutterBluePlus.stopScan(); + } catch (e) { + print('停止扫描失败: $e'); + } + } + + // 连接设备 + Future connectToDevice(fbp.BluetoothDevice device) async { + try { + errorMessage.value = ''; + + // 停止扫描 + await stopScan(); + + // 连接设备 + await device.connect(timeout: const Duration(seconds: 15)); + connectedDevice.value = device; + + Get.snackbar( + '连接成功', + '已连接到 ${device.platformName.isNotEmpty ? device.platformName : '未知设备'}', + backgroundColor: Colors.green, + colorText: Colors.white, + ); + } catch (e) { + errorMessage.value = '连接失败: $e'; + Get.snackbar( + '连接失败', + e.toString(), + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + + // 断开连接 + Future disconnectDevice() async { + try { + if (connectedDevice.value != null) { + await connectedDevice.value!.disconnect(); + connectedDevice.value = null; + + Get.snackbar( + '已断开连接', + '设备已断开连接', + backgroundColor: Colors.orange, + colorText: Colors.white, + ); + } + } catch (e) { + errorMessage.value = '断开连接失败: $e'; + } + } + + // 获取设备名称 + String getDeviceName(fbp.ScanResult result) { + if (result.device.platformName.isNotEmpty) { + return result.device.platformName; + } + if (result.advertisementData.localName.isNotEmpty) { + return result.advertisementData.localName; + } + return '未知设备'; + } + + // 获取信号强度描述 + String getSignalStrengthText(int rssi) { + if (rssi >= -50) return '信号强'; + if (rssi >= -70) return '信号中等'; + if (rssi >= -90) return '信号弱'; + return '信号很弱'; + } + + // 获取信号强度图标 + IconData getSignalStrengthIcon(int rssi) { + if (rssi >= -50) return Icons.signal_wifi_4_bar; + if (rssi >= -70) return Icons.signal_wifi_4_bar; + if (rssi >= -90) return Icons.signal_wifi_4_bar; + return Icons.signal_wifi_4_bar; + } +} diff --git a/lib/modules/ble/views/ble_view.dart b/lib/modules/ble/views/ble_view.dart new file mode 100644 index 000000000..08398c95f --- /dev/null +++ b/lib/modules/ble/views/ble_view.dart @@ -0,0 +1,355 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp; +import '../controllers/ble_controller.dart'; + +class BleView extends GetView { + const BleView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return Scaffold( + backgroundColor: isDarkMode ? Colors.black : Colors.white, + appBar: AppBar( + title: Text( + 'BLE设备扫描', + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black, + fontSize: 18.sp, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, + elevation: 0, + iconTheme: IconThemeData( + color: isDarkMode ? Colors.white : Colors.black, + ), + actions: [ + Obx(() => IconButton( + onPressed: controller.isScanning.value + ? controller.stopScan + : controller.startScan, + icon: Icon( + controller.isScanning.value ? Icons.stop : Icons.refresh, + color: controller.isScanning.value + ? Colors.red + : (isDarkMode ? Colors.white : Colors.black), + ), + )), + ], + ), + body: SafeArea( + child: Column( + children: [ + // 状态栏 + _buildStatusBar(isDarkMode), + + // 连接的设备信息 + _buildConnectedDevice(isDarkMode), + + // 设备列表 + Expanded( + child: _buildDeviceList(isDarkMode), + ), + ], + ), + ), + floatingActionButton: Obx(() => FloatingActionButton( + onPressed: controller.isScanning.value + ? controller.stopScan + : controller.startScan, + backgroundColor: + controller.isScanning.value ? Colors.red : Colors.blue, + child: Icon( + controller.isScanning.value + ? Icons.stop + : Icons.bluetooth_searching, + color: Colors.white, + ), + )), + ); + } + + // 状态栏 + Widget _buildStatusBar(bool isDarkMode) { + return Obx(() => Container( + width: double.infinity, + padding: EdgeInsets.all(16.w), + margin: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[800] : Colors.grey[100], + borderRadius: BorderRadius.circular(12.r), + ), + child: Column( + children: [ + Row( + children: [ + Icon( + controller.isBluetoothOn.value + ? Icons.bluetooth + : Icons.bluetooth_disabled, + color: controller.isBluetoothOn.value + ? Colors.blue + : Colors.red, + size: 24.w, + ), + SizedBox(width: 8.w), + Text( + controller.isBluetoothOn.value ? '蓝牙已开启' : '蓝牙已关闭', + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black, + fontSize: 16.sp, + fontWeight: FontWeight.w500, + ), + ), + Spacer(), + if (controller.isScanning.value) + SizedBox( + width: 20.w, + height: 20.w, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.blue), + ), + ), + ], + ), + if (controller.errorMessage.value.isNotEmpty) ...[ + SizedBox(height: 8.h), + Text( + controller.errorMessage.value, + style: TextStyle( + color: Colors.red, + fontSize: 14.sp, + ), + ), + ], + if (controller.isScanning.value) ...[ + SizedBox(height: 8.h), + Text( + '正在扫描设备...', + style: TextStyle( + color: Colors.blue, + fontSize: 14.sp, + ), + ), + ], + ], + ), + )); + } + + // 已连接设备信息 + Widget _buildConnectedDevice(bool isDarkMode) { + return Obx(() { + if (controller.connectedDevice.value == null) { + return SizedBox.shrink(); + } + + final device = controller.connectedDevice.value!; + return Container( + width: double.infinity, + padding: EdgeInsets.all(16.w), + margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), + decoration: BoxDecoration( + color: Colors.green.withOpacity(0.1), + border: Border.all(color: Colors.green, width: 1), + borderRadius: BorderRadius.circular(12.r), + ), + child: Row( + children: [ + Icon( + Icons.bluetooth_connected, + color: Colors.green, + size: 24.w, + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '已连接设备', + style: TextStyle( + color: Colors.green, + fontSize: 12.sp, + fontWeight: FontWeight.w500, + ), + ), + Text( + device.platformName.isNotEmpty + ? device.platformName + : '未知设备', + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black, + fontSize: 16.sp, + fontWeight: FontWeight.bold, + ), + ), + Text( + device.remoteId.toString(), + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black54, + fontSize: 12.sp, + ), + ), + ], + ), + ), + IconButton( + onPressed: controller.disconnectDevice, + icon: Icon( + Icons.close, + color: Colors.red, + size: 20.w, + ), + ), + ], + ), + ); + }); + } + + // 设备列表 + Widget _buildDeviceList(bool isDarkMode) { + return Obx(() { + if (controller.scanResults.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.bluetooth_searching, + size: 64.w, + color: isDarkMode ? Colors.white54 : Colors.black54, + ), + SizedBox(height: 16.h), + Text( + controller.isScanning.value ? '正在搜索设备...' : '点击扫描按钮开始搜索BLE设备', + style: TextStyle( + color: isDarkMode ? Colors.white54 : Colors.black54, + fontSize: 16.sp, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + return ListView.builder( + padding: EdgeInsets.symmetric(horizontal: 16.w), + itemCount: controller.scanResults.length, + itemBuilder: (context, index) { + final result = controller.scanResults[index]; + return _buildDeviceItem(result, isDarkMode); + }, + ); + }); + } + + // 设备项 + Widget _buildDeviceItem(fbp.ScanResult result, bool isDarkMode) { + final deviceName = controller.getDeviceName(result); + final isConnected = + controller.connectedDevice.value?.remoteId == result.device.remoteId; + + return Container( + margin: EdgeInsets.only(bottom: 12.h), + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[800] : Colors.white, + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: isConnected + ? Colors.green + : (isDarkMode ? Colors.grey[700]! : Colors.grey[300]!), + width: isConnected ? 2 : 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: Offset(0, 2), + ), + ], + ), + child: ListTile( + contentPadding: EdgeInsets.all(16.w), + leading: Container( + width: 48.w, + height: 48.w, + decoration: BoxDecoration( + color: isConnected + ? Colors.green.withOpacity(0.1) + : Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(24.r), + ), + child: Icon( + isConnected ? Icons.bluetooth_connected : Icons.bluetooth, + color: isConnected ? Colors.green : Colors.blue, + size: 24.w, + ), + ), + title: Text( + deviceName, + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black, + fontSize: 16.sp, + fontWeight: FontWeight.bold, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 4.h), + Text( + result.device.remoteId.toString(), + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black54, + fontSize: 12.sp, + ), + ), + SizedBox(height: 4.h), + Row( + children: [ + Icon( + controller.getSignalStrengthIcon(result.rssi), + size: 16.w, + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + SizedBox(width: 4.w), + Text( + '${result.rssi} dBm (${controller.getSignalStrengthText(result.rssi)})', + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black54, + fontSize: 12.sp, + ), + ), + ], + ), + ], + ), + trailing: isConnected + ? Icon( + Icons.check_circle, + color: Colors.green, + size: 24.w, + ) + : IconButton( + onPressed: () => controller.connectToDevice(result.device), + icon: Icon( + Icons.connect_without_contact, + color: Colors.blue, + size: 24.w, + ), + ), + onTap: isConnected + ? null + : () => controller.connectToDevice(result.device), + ), + ); + } +} diff --git a/lib/modules/devices/controllers/devices_controller.dart b/lib/modules/devices/controllers/devices_controller.dart index 1104cdb29..6121ed7f8 100644 --- a/lib/modules/devices/controllers/devices_controller.dart +++ b/lib/modules/devices/controllers/devices_controller.dart @@ -1,3 +1,5 @@ +import 'dart:ffi'; + import '/core/utils/logger.dart'; import '/data/models/user_Info.dart'; import '/data/services/ble_manager.dart'; @@ -12,50 +14,33 @@ import 'package:get/get_state_manager/src/simple/get_controllers.dart'; */ class DevicesController extends GetxController { late final BleManager _bleManager; - //耳机绑定 - final earphonestate = 0.obs; - final earphoneName = "".obs; - final earphoneMac = "".obs; - final earphoneLicense = "".obs; - final earphoneCode = "".obs; - - //录音笔 - final voicepenstate = 0.obs; - final isvoicepen = false.obs; - final voicepenName = "".obs; - final voicepenMac = "".obs; + late final Map> devices; @override void onInit() { super.onInit(); _bleManager = Get.find(); - earphoneName.value = User.instance.earphonename; - earphoneMac.value = User.instance.earphonemac; - earphoneLicense.value = User.instance.earphonelicense; - earphoneCode.value = User.instance.earphoneauthcode; - if (earphoneCode.value != "") { - earphonestate.value = 1; - } - if (earphoneLicense.value != "") { - earphonestate.value = 2; - } } @override void onClose() { super.onClose(); + for (var device in User.instance.devices) { + if (!devices.containsKey(device.id)) { + devices[device.devicetype] = []; + } else { + devices[device.devicetype]!.add(device); + } + } } - void unbindearphone() async { + void unbindearphone(UserDevice device) async { final response = await Api.unbindearphone({ - "code": earphoneLicense.value, - "earphonname": earphoneMac.value, + "id": device.id, }); if (response != null) { - User.instance.earphonename = ""; - User.instance.earphonemac = ""; - User.instance.earphonelicense = ""; - earphonestate.value = 0; + devices[device.devicetype]!.remove(device); + User.instance.removeUserDevice(device.id); _bleManager.clearPairedDevices(); _bleManager.disconnect(); Get.toNamed(Routes.pairing); diff --git a/lib/modules/devices/views/devices_view.dart b/lib/modules/devices/views/devices_view.dart index 6b30d165b..0d5c20779 100644 --- a/lib/modules/devices/views/devices_view.dart +++ b/lib/modules/devices/views/devices_view.dart @@ -4,286 +4,328 @@ import '/modules/devices/controllers/devices_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; +import '/core/theme/app_colors.dart'; +import '/data/models/user_Info.dart'; +import '../controllers/devices_controller.dart'; + +// 设备类型枚举 +enum DeviceType { + Unknown(0, "未知设备"), + Classic_bluetooth_Headset(1, "经典蓝牙耳机"), + BLE_Bluetooth_Headset(2, "BLE蓝牙耳机"), + Smart_Voice_Recorder(3, "智能录音笔"), + SmartEye(4, "智能眼镜"); + + const DeviceType(this.value, this.displayName); + final int value; + final String displayName; + + static DeviceType fromValue(int value) { + return DeviceType.values.firstWhere( + (type) => type.value == value, + orElse: () => DeviceType.Unknown, + ); + } + + IconData get icon { + switch (this) { + case DeviceType.Classic_bluetooth_Headset: + case DeviceType.BLE_Bluetooth_Headset: + return Icons.headphones; + case DeviceType.Smart_Voice_Recorder: + return Icons.mic; + case DeviceType.SmartEye: + return Icons.visibility; + default: + return Icons.device_unknown; + } + } +} class DevicesView extends GetView { - const DevicesView({Key? key}) : super(key: key); + const DevicesView({super.key}); @override Widget build(BuildContext context) { - // 直接使用 Theme.of(context) 获取当前主题 final isDarkMode = Theme.of(context).brightness == Brightness.dark; return Scaffold( - backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], - appBar: AppBar( - automaticallyImplyLeading: false, - centerTitle: true, - elevation: 0, - backgroundColor: isDarkMode ? Colors.black : Colors.white, - leading: IconButton( - icon: Icon( - Icons.arrow_back_ios, - size: 20.sp, - color: isDarkMode ? Colors.white : Colors.black, + backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.white, + body: Column( + children: [ + // 顶部标题栏 + Container( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 10.h, + left: 20.w, + right: 20.w, + bottom: 20.h, + ), + decoration: BoxDecoration( + color: isDarkMode ? Colors.black : Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + GestureDetector( + onTap: () => Get.back(), + child: Icon( + Icons.arrow_back_ios, + color: isDarkMode ? Colors.white : Colors.black, + size: 24.sp, + ), + ), + SizedBox(width: 16.w), + Text( + "deviceManagement".tr, + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.white : Colors.black, + ), + ), + ], + ), ), - onPressed: () { - Get.back(); // 正常返回 - }, - ), - title: Text( - 'deviceManagement'.tr, - style: TextStyle( - fontSize: 20.sp, - color: isDarkMode ? Colors.white : Colors.black, + // 设备列表内容 + Expanded( + child: _buildDevicesList(isDarkMode), ), - ), - ), - body: ListView( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), - children: [ - _buildEarphoneCard(isDarkMode), - SizedBox(height: 20.h), - _buildVoiceRecorderCard(isDarkMode), - SizedBox(height: 20.h), ], ), ); } - // 耳机 - Widget _buildEarphoneCard(bool isDarkMode) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + Widget _buildDevicesList(bool isDarkMode) { + final devices = User.instance.devices; + + if (devices.isEmpty) { + return _buildEmptyState(isDarkMode); + } + + // 按设备类型分组 + final Map> groupedDevices = {}; + for (var device in devices) { + final deviceType = DeviceType.fromValue(device.devicetype); + if (!groupedDevices.containsKey(deviceType)) { + groupedDevices[deviceType] = []; + } + groupedDevices[deviceType]!.add(device); + } + + return ListView( + padding: EdgeInsets.all(20.w), children: [ - Padding( - padding: EdgeInsets.only(left: 4.w, bottom: 8.h), - child: Text( - 'earphone'.tr, // 耳机 - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w500, - color: isDarkMode ? Colors.blue[400] : Colors.blue[600], - letterSpacing: 1, - ), - ), - ), - Container( - decoration: BoxDecoration( - color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, - borderRadius: BorderRadius.circular(16.r), - boxShadow: [ - BoxShadow( - color: isDarkMode - ? Colors.black.withOpacity(0.2) - : Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), + // 遍历每个设备类型 + ...groupedDevices.entries.map((entry) { + final deviceType = entry.key; + final deviceList = entry.value; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 设备类型标题 + Padding( + padding: EdgeInsets.only(bottom: 12.h), + child: Text( + deviceType.displayName, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), ), + // 该类型下的设备列表 + ...deviceList.map((device) => _buildDeviceCard( + device: device, + deviceType: deviceType, + isDarkMode: isDarkMode, + )), + SizedBox(height: 20.h), ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16.r), - child: BackdropFilter( - filter: isDarkMode - ? ImageFilter.blur(sigmaX: 5, sigmaY: 5) - : ImageFilter.blur(sigmaX: 0, sigmaY: 0), - child: Column( - children: [ - Obx( - () => - // 耳机列表 - _buildSimpleNavigationSetting( - title: controller.earphonestate.value != 0 - ? controller.earphoneName.value - : "notBound".tr, // 未绑定 - subtitle: controller.earphonestate.value != 0 - ? controller.earphoneMac.value - : "deviceNotBoundYet".tr, // 还未绑定设备 - icon: Icons.headphones, - iconBgColor: isDarkMode - ? const Color.fromARGB(255, 255, 255, 255)! - .withOpacity(0.3) - : const Color.fromARGB(255, 0, 0, 0)!, - iconColor: isDarkMode - ? const Color.fromARGB(255, 0, 0, 0)! - : const Color.fromARGB(255, 255, 255, 255)!, - titleColor: isDarkMode ? Colors.white : null, - subtitleColor: isDarkMode ? Colors.white70 : null, - isDarkMode: isDarkMode, - showbutt: controller.earphonestate == 2, - onPressed: () { - controller.unbindearphone(); - }, - ), - ) - ], - ), - ), - ), - ), + ); + }).toList(), ], ); } - // 录音笔 - Widget _buildVoiceRecorderCard(bool isDarkMode) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(left: 4.w, bottom: 8.h), - child: Text( - 'voicerecorder'.tr, //AI设置 + Widget _buildEmptyState(bool isDarkMode) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.devices_other, + size: 80.sp, + color: isDarkMode ? Colors.white54 : Colors.grey[400], + ), + SizedBox(height: 20.h), + Text( + "暂未绑定任何设备", style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w500, - color: isDarkMode ? Colors.blue[400] : Colors.blue[600], - letterSpacing: 1, + fontSize: 16.sp, + color: isDarkMode ? Colors.white70 : Colors.grey[600], ), ), - ), - Container( - decoration: BoxDecoration( - color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, - borderRadius: BorderRadius.circular(16.r), - boxShadow: [ - BoxShadow( - color: isDarkMode - ? Colors.black.withOpacity(0.2) - : Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16.r), - child: BackdropFilter( - filter: isDarkMode - ? ImageFilter.blur(sigmaX: 5, sigmaY: 5) - : ImageFilter.blur(sigmaX: 0, sigmaY: 0), - child: Column( - children: [ - // 实时对话入口 - Obx( - () => _buildSimpleNavigationSetting( - title: 'notBound'.tr, // 未绑定 - subtitle: 'deviceNotBoundYet'.tr, // 还未绑定设备 - icon: Icons.edit, - iconBgColor: isDarkMode - ? const Color.fromARGB(255, 255, 255, 255)! - .withOpacity(0.3) - : const Color.fromARGB(255, 0, 0, 0)!, - iconColor: isDarkMode - ? const Color.fromARGB(255, 0, 0, 0)! - : const Color.fromARGB(255, 255, 255, 255)!, - titleColor: isDarkMode ? Colors.white : null, - subtitleColor: isDarkMode ? Colors.white70 : null, - isDarkMode: isDarkMode, - showbutt: controller.isvoicepen.value, - onPressed: () {}, - ), - ) - ], - ), + SizedBox(height: 12.h), + Text( + "请先绑定设备后再进行管理", + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white54 : Colors.grey[500], ), ), - ), - ], + ], + ), ); } - // 简单导航设置项 - Widget _buildSimpleNavigationSetting({ - required String title, - String? subtitle, - required IconData icon, - required Color iconBgColor, - required Color iconColor, - Color? titleColor, - Color? subtitleColor, + Widget _buildDeviceCard({ + required UserDevice device, + required DeviceType deviceType, required bool isDarkMode, - required bool showbutt, - required VoidCallback onPressed, }) { - return InkWell( - child: Padding( - padding: EdgeInsets.all(16.w), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Row( - children: [ - Container( - width: 32.w, - height: 32.w, - decoration: BoxDecoration( - color: iconBgColor, - borderRadius: BorderRadius.circular(16.r), - ), - child: Icon( - icon, - size: 16.w, - color: iconColor, - ), + return Container( + margin: EdgeInsets.only(bottom: 12.h), + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: isDarkMode ? AppColors.cardBackground : Colors.white, + borderRadius: BorderRadius.circular(12.r), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + // 设备图标 + Container( + width: 48.w, + height: 48.w, + decoration: BoxDecoration( + color: + isDarkMode ? Colors.white.withOpacity(0.1) : Colors.grey[100], + borderRadius: BorderRadius.circular(12.r), + ), + child: Icon( + deviceType.icon, + color: isDarkMode ? Colors.white : Colors.black87, + size: 24.sp, + ), + ), + SizedBox(width: 16.w), + // 设备信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + device.devicename.isNotEmpty ? device.devicename : "未知设备", + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w500, + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + SizedBox(height: 4.h), + Text( + device.devicemac.isNotEmpty ? device.devicemac : "无MAC地址", + style: TextStyle( + fontSize: 14.sp, + color: isDarkMode ? Colors.white70 : Colors.grey[600], ), - SizedBox(width: 12.w), - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w500, - color: titleColor ?? - (isDarkMode ? Colors.white : Colors.black87), - ), - overflow: TextOverflow.ellipsis, - ), - if (subtitle != null) SizedBox(height: 2.h), - if (subtitle != null) - Text( - subtitle, - style: TextStyle( - fontSize: 12.sp, - color: subtitleColor ?? - (isDarkMode - ? Colors.white70 - : Colors.black54), - ), - overflow: TextOverflow.ellipsis, - ), - ], + ), + if (device.license.isNotEmpty) ...[ + SizedBox(height: 4.h), + Text( + "授权码: ${device.license}", + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white54 : Colors.grey[500], ), ), ], + ], + ), + ), + // 解绑按钮 + GestureDetector( + onTap: () => _showUnbindDialog(device, isDarkMode), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 12.w, + vertical: 6.h, + ), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(6.r), + border: Border.all( + color: Colors.red.withOpacity(0.3), + width: 1, + ), + ), + child: Text( + "unbind".tr, + style: TextStyle( + fontSize: 12.sp, + color: Colors.red, + fontWeight: FontWeight.w500, + ), ), ), - if (showbutt) - Container( - width: 74.w, - height: 40.w, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: EdgeInsets.symmetric(horizontal: 2, vertical: 2), - ), - onPressed: onPressed, - child: Text( - 'unbind'.tr, // 解绑 - style: TextStyle( - color: Colors.white, - ), - ), - )), - ], + ), + ], + ), + ); + } + + void _showUnbindDialog(UserDevice device, bool isDarkMode) { + Get.dialog( + AlertDialog( + backgroundColor: isDarkMode ? AppColors.cardBackground : Colors.white, + title: Text( + "确认解绑", + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + content: Text( + "确定要解绑设备 \"${device.devicename}\" 吗?", + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black54, + ), ), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text( + "取消", + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.grey[600], + ), + ), + ), + TextButton( + onPressed: () { + Get.back(); + controller.unbindearphone(device); + }, + child: const Text( + "确认解绑", + style: TextStyle(color: Colors.red), + ), + ), + ], ), ); } diff --git a/lib/modules/login/views/login_view.dart b/lib/modules/login/views/login_view.dart index 543ba1827..9df8ffb0f 100644 --- a/lib/modules/login/views/login_view.dart +++ b/lib/modules/login/views/login_view.dart @@ -39,13 +39,13 @@ class LoginView extends GetView { SizedBox(height: 32.h), _buildWelcomeText(isDarkMode), - // 输入框和继续按钮 - SizedBox(height: 24.h), - _buildInputField(isDarkMode), - SizedBox(height: 16.h), - _buildVerificationInputField(isDarkMode), - SizedBox(height: 16.h), - _buildContinueButton(isDarkMode), + // // 输入框和继续按钮 + // SizedBox(height: 24.h), + // _buildInputField(isDarkMode), + // SizedBox(height: 16.h), + // _buildVerificationInputField(isDarkMode), + // SizedBox(height: 16.h), + // _buildContinueButton(isDarkMode), // 社交媒体登录 SizedBox(height: 32.h), diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index 21c6d33c8..1fb55a407 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -496,13 +496,14 @@ class PairingController extends GetxController { //连接ble Future connectble(BluetoothDevice device) async { Logger.i(_tag, '设备验证通过'); - _verifiedDeviceAddress = device.address; + // _verifiedDeviceAddress = device.address; // 检查是否已有BLE关联 final pairedMacAddress = await _bleManager.getPairedMacAddress(); Logger.i(_tag, '已关联的MAC地址: $pairedMacAddress'); if (pairedMacAddress != null && - pairedMacAddress.toUpperCase() == device.address.toUpperCase()) { + pairedMacAddress.toUpperCase() == + _verifiedDeviceAddress?.toUpperCase()) { // 已有关联,检查BLE连接状态 Logger.i(_tag, '设备已关联,检查BLE连接状态'); final currentBleState = await _bleManager.getConnectionState(); @@ -597,69 +598,162 @@ class PairingController extends GetxController { //自动绑定设备 0 未合作商 1 合作商 2,已绑定 Future authbindingDevice(BluetoothDevice device) async { - if (User.instance.earphonelicense != "" && - User.instance.earphonemac == device.address) { - // 已经绑定 - return 10; - } - if (User.instance.earphoneauthcode != "" && - User.instance.earphonemac == device.address) { - // 已经绑定 - return 9; - } + // if (User.instance.earphoneauthcode != "" && + // User.instance.earphonemac == device.address) { + // // 已经绑定 + // return 9; + // } + // 检查设备名称是否包含 DeepSound 或 DeapSound final deviceName = device.name; final names = AppConfig.allproductNames(); - print('获取到 deviceName: $deviceName names:$names'); + print( + '获取到 deviceName: $deviceName address: ${device.address} names:$names'); if (names.contains(deviceName)) { await _bleManager.startScan(deviceName); await Future.delayed(const Duration(seconds: 1)); // 尝试最多读取 3 次设备信息,每次间隔 1 秒 - Map? deviceinfo; + List>? deviceinfos; for (int i = 0; i < 3; i++) { - deviceinfo = await _bleManager.getDeviceInfo(device.address); - print('获取到 deviceinfo: $deviceinfo'); - if (deviceinfo != null) { - break; + // 使用设备名称获取设备列表 + deviceinfos = await _bleManager.getDeviceList(deviceName); + print('获取到 deviceinfos: $deviceinfos'); + + if (deviceinfos != null && deviceinfos.isNotEmpty) { + // 循环读取每个设备的hex数据 + for (var deviceInfo in deviceinfos) { + String? hexData = deviceInfo["hex"]; + if (hexData != null && hexData.length >= 28) { + // 确保hex数据长度足够(14字节 = 28个字符) + // 读取9-14字节的MAC地址 + String macFromHex = extractMacFromHex(hexData, device.address); + if (macFromHex.isNotEmpty) { + String code = ""; + // 找到匹配的设备,提取授权码 + if (Platform.isIOS) { + // iOS设备,提取13-22字节的授权码 + code = extractAndReverseBytes(hexData, 13, 22); + print('找到匹配设备,获取到 code: $code'); + if (code != "") { + _bleManager.stopScan(); + deviceCode.value = code; + String? uuids = deviceInfo["id"]; + _verifiedDeviceAddress = uuids; + print('找到匹配设备,获取到 uuids: $uuids'); + // 检查是否已经绑定 + // if (User.instance.earphonelicense != "" && + // User.instance.earphonemac == device.address) { + // // 已经绑定 + // return 10; + // } else { + return 1; + // } + } + } else if (Platform.isAndroid) { + // Android设备,提取15-24字节的授权码 + code = extractAndReverseBytes(hexData, 15, 24); + print('找到匹配设备,获取到 code: $code'); + if (code != "") { + _bleManager.stopScan(); + deviceCode.value = code; + _verifiedDeviceAddress = device.address; + // 检查是否已经绑定 + // if (User.instance.earphonelicense != "" && + // User.instance.earphonemac == device.address) { + // // 已经绑定 + // return 10; + // } else { + return 1; + // } + } + } else { + break; + } + } + } + } } await Future.delayed(Duration(seconds: 1)); } - print('获取到 deviceinfo: $deviceinfo'); - if (deviceinfo != null) { - // final code = processHex(deviceinfo["hex"]!, 15, 25); - String code = extractAndReverseBytes(deviceinfo["hex"]!, 15, 24); - print('获取到 code: $code'); - // 安全检查长度 - if (code != "") { - deviceCode.value = code; - return 1; - } else { - return 0; - } - } return 0; - // 调用BLE管理器的自动绑定方法 } else { return 0; } } + // 新增方法:从hex数据中提取MAC地址并与当前设备MAC对比 + String extractMacFromHex(String hexData, String deviceMac) { + try { + // 将hex字符串转换为字节数组 + List bytes = []; + for (int i = 0; i < hexData.length; i += 2) { + bytes.add(int.parse(hexData.substring(i, i + 2), radix: 16)); + } + + // 读取9-14字节(索引8-13)的MAC地址 + if (bytes.length < 14) { + return ""; + } + + List macBytes = []; + if (Platform.isIOS) { + for (int i = 7; i <= 12; i++) { + // 每个字节异或0x5A + int decodedByte = bytes[i] ^ 0x5A; + macBytes.add(decodedByte); + } + } else if (Platform.isAndroid) { + for (int i = 9; i <= 14; i++) { + // 每个字节异或0x5A + int decodedByte = bytes[i] ^ 0x5A; + macBytes.add(decodedByte); + } + } + + // 将MAC字节转换为MAC地址字符串格式 + String extractedMac = macBytes + .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(':'); + + // 格式化当前设备MAC地址(确保格式一致) + String formattedDeviceMac = deviceMac.replaceAll(':', '').toUpperCase(); + String formattedExtractedMac = + extractedMac.replaceAll(':', '').toUpperCase(); + + print('设备MAC: $deviceMac, 提取的MAC: $extractedMac'); + + // 对比MAC地址 + if (formattedDeviceMac == formattedExtractedMac) { + print('MAC地址匹配成功!'); + return extractedMac; + } else { + print('MAC地址不匹配'); + return ""; + } + } catch (e) { + print('提取MAC地址时出错: $e'); + return ""; + } + } + //确认绑定设备 void confirmBindDevice() async { - final response = await Api.bindearphone({ + final response = await Api.binddevice({ "code": deviceCode.value, - "earphonname": currentDevice?.name, - "earphonemac": currentDevice?.address, + "devicename": currentDevice?.name, + "devicemac": currentDevice?.address, // 使用 address 替代 deviceId }); Logger.i(_tag, '手动授权结果 ${response}'); if (response != null) { - Logger.i(_tag, '绑定设备成功'); - User.instance.earphonemac = currentDevice?.address ?? ''; - User.instance.earphonename = currentDevice?.name ?? ''; - User.instance.earphonelicense = deviceCode.value; - _transition(PairingState.verifying); - _verifyDevice(_currentClassicDevice!); + Logger.i(_tag, '绑定设备成功 $response'); + if (response['device'] != '') { + // 根据API设计调整 + final device = UserDevice.fromJson(response['device']); + User.instance.addUserDevice(device); + _transition(PairingState.verifying); + _verifyDevice(_currentClassicDevice!); + } return; } else { Logger.e(_tag, '绑定设备失败!'); @@ -691,13 +785,13 @@ class PairingController extends GetxController { final response = await Api.bindauthcode({ "code": codeInputController.text.trim(), "earphonname": currentDevice?.name, - "earphonemac": currentDevice?.address, + "earphonemac": currentDevice?.address, // 使用 address 替代 deviceId }); Logger.i(_tag, '手动授权结果 ${response}'); if (response != null) { - User.instance.earphonemac = currentDevice?.address ?? ''; - User.instance.earphonename = currentDevice?.name ?? ''; - User.instance.earphoneauthcode = codeInputController.text.trim(); + // User.instance.earphonemac = currentDevice?.address ?? ''; // 使用 address + // User.instance.earphonename = currentDevice?.name ?? ''; + // User.instance.earphoneauthcode = codeInputController.text.trim(); _transition(PairingState.verifying); _verifyDevice(_currentClassicDevice!); } else { @@ -777,7 +871,16 @@ class PairingController extends GetxController { void onSkipBindingTap() { Logger.i(_tag, '用户选择临时跳过手动绑定'); - User.instance.earphonemac = currentDevice?.address ?? ''; + User.instance.addUserDevice( + UserDevice( + id: 0, // 临时设备ID + uid: User.instance.uid, + devicetype: 1, + devicemac: _currentClassicDevice?.address ?? '', + devicename: _currentClassicDevice?.name ?? 'Unknown Device', + license: "", // 临时授权码 + ), + ); _transition(PairingState.verifying); _verifyDevice(_currentClassicDevice!); } diff --git a/lib/modules/splash/controllers/splash_controller.dart b/lib/modules/splash/controllers/splash_controller.dart index 3e23b4adb..76b7ff177 100644 --- a/lib/modules/splash/controllers/splash_controller.dart +++ b/lib/modules/splash/controllers/splash_controller.dart @@ -122,6 +122,7 @@ class SplashController extends GetxController { print('getLoginToken:进来了'); // 根据API设计调整 final user = User.fromJson(response); + // 存储用户信息(示例) await _storage.write('user_info', user.toJson()); return true; diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 8a84e2f2b..a58ddb678 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -48,7 +48,7 @@ class TranslationController extends GetxController { DateTime? _asrActiveStartTime; // ASR活跃开始时间(用户实际开始语音识别的时间) Duration _asrActiveDuration = Duration.zero; // ASR累计活跃时长 bool _isAsrActive = false; // ASR是否处于活跃状态 - + // 音频输入源 bool _audioSourceType = false; //是否开启播放 @@ -143,6 +143,7 @@ class TranslationController extends GetxController { if (fTFTranslationResult != '' && !isPlayback) { isPlayback = true; + print("松开走了着"); await _ttsService.setAudioOutputDevice(lastActiveSpeaker.value); await Future.delayed(const Duration(milliseconds: 1300)); await playTranslatedText(fTFTranslationResult!); @@ -156,9 +157,11 @@ class TranslationController extends GetxController { } // 禁用蓝牙音频 - void disableBluetoothAudio() => _asrService.disableBluetoothAudio(); + Future disableBluetoothAudio() async => + await _asrService.disableBluetoothAudio(); //恢复原始音频状态 - void restoreOriginalAudioState() => _asrService.restoreOriginalAudioState(); + Future restoreOriginalAudioState() async => + await _asrService.restoreOriginalAudioState(); // 兼容旧版视图的计算属性 Rx get currentItem { @@ -369,6 +372,7 @@ class TranslationController extends GetxController { _translationDebounceTimer?.cancel(); scrollController.dispose(); restoreOtherServices(); + _asrService.dispose(); super.onClose(); } @@ -521,7 +525,6 @@ class TranslationController extends GetxController { if (!isRecognizing.value) return; try { - restoreOriginalAudioState(); await _asrService.stopContinuousRecognition(); // 停止ASR活跃时长计时 @@ -1340,7 +1343,8 @@ class TranslationController extends GetxController { _stopAsrActiveTracking(); final now = DateTime.now(); - final totalDurationSeconds = now.difference(_asrSessionStartTime!).inSeconds; + final totalDurationSeconds = + now.difference(_asrSessionStartTime!).inSeconds; final effectiveDurationSeconds = _asrActiveDuration.inSeconds; _usageService.recordAsrApiCall( diff --git a/lib/modules/translation/views/translation_view.dart b/lib/modules/translation/views/translation_view.dart index 5420854d5..d69278376 100644 --- a/lib/modules/translation/views/translation_view.dart +++ b/lib/modules/translation/views/translation_view.dart @@ -461,8 +461,6 @@ class TranslationView extends GetView { child: Obx(() { // 同时模式显示双按钮 if (controller.currentMode.value == 'faceToFace') { - controller.startRecognition(); - return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -593,11 +591,6 @@ class TranslationView extends GetView { // 设置300ms延迟防止误触 _holdTimer = Timer(const Duration(milliseconds: 200), () { if (activeSpeaker == 0) { - if (speakerId == 1) { - controller.restoreOriginalAudioState(); - } else { - controller.disableBluetoothAudio(); - } controller.setActiveSpeaker(speakerId); } }); diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index 6da9b95fe..724205b30 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -1,3 +1,6 @@ +import 'package:deep_voice/modules/ble/bindings/ble_binding.dart'; +import 'package:deep_voice/modules/ble/views/ble_view.dart'; + import '../../modules/devices/bindings/devices_binding.dart'; import '../../modules/devices/views/devices_view.dart'; import 'package:get/get.dart'; @@ -266,5 +269,10 @@ abstract class AppPages { page: () => const DevicesView(), binding: DevicesBinding(), ), + GetPage( + name: Routes.ble, + page: () => const BleView(), + binding: BleBinding(), + ), ]; } diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 1b677f334..6bd4aab32 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -58,4 +58,7 @@ abstract class Routes { // 设备管理 static const devices = '/devices'; + + // ble设备管理 + static const ble = '/ble'; } diff --git a/local_plugins/agent_service/ios/agent_service/Package.swift b/local_plugins/agent_service/ios/agent_service/Package.swift index 0e95c6bc6..278e827c5 100644 --- a/local_plugins/agent_service/ios/agent_service/Package.swift +++ b/local_plugins/agent_service/ios/agent_service/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "agent_service", - platforms: [.iOS("18.0")], + platforms: [.iOS("16.0")], products: [ .library(name: "agent-service", targets: ["agent_service"]) ], 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 b817d7713..dee0357b1 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 @@ -37,6 +37,7 @@ class AgentServiceImpl: NSObject { private var apiKey: String = "" private var baseUrl: String = "https://api.openai.com/v1/chat/completions" private var model: String = "gpt-3.5-turbo" + private var visionModel = "gpt-4-vision-preview" private var systemPrompt: String = "" private var chatHistory: [[String: Any]] = [] @@ -55,6 +56,8 @@ class AgentServiceImpl: NSObject { private var hasSpeechDetected: Bool = false internal var isAiStreaming: Bool = false + var currsessionId: String = "" + private var idleTimer: DispatchSourceTimer? private let maxIdleSeconds: TimeInterval = 10 @@ -209,6 +212,12 @@ class AgentServiceImpl: NSObject { sendError("初始化语音识别服务失败", code: "ASR_INIT_ERROR") return false } + guard let asrsetupSuccess = azureAsrHelper?.setupEventListeners( + callback: self + ), asrsetupSuccess else { + sendError("初始化语音识别服务失败", code: "ASR_INIT_ERROR") + return false + } guard let ttsSuccess = azureTtsHelper?.initialize( ttsAppId: "", @@ -242,6 +251,7 @@ class AgentServiceImpl: NSObject { apiKey: apiKey, baseUrl: baseUrl, model: model, + visionModel:visionModel, mcpServer: mcpServer ) @@ -293,8 +303,7 @@ class AgentServiceImpl: NSObject { startIdleCheck() } } - - func startRecognition(useBle: Bool = false) -> Bool { + func startRecognition(useBle: Bool = false) -> Bool { if !isInitialized { sendError("服务未初始化", code: "NOT_INITIALIZED") return false @@ -318,9 +327,9 @@ class AgentServiceImpl: NSObject { } else { AudioSessionHub.shared.begin(.voice) } - +print("ai启动语音=\(audioSourceType)=\(useBle)") guard let success = azureAsrHelper?.startContinuousRecognition( - callback: self, + // callback: self, audioSourceType: audioSourceType ), success else { sendError("启动语音识别失败", code: "RECOGNITION_START_ERROR") @@ -329,6 +338,10 @@ class AgentServiceImpl: NSObject { return true } + func recognizeCallback(useBle: Bool = false) -> Bool { + + return true + } func stopRecognition() -> Bool { if !isRecognizing { @@ -346,8 +359,12 @@ class AgentServiceImpl: NSObject { if !isRecognizing || !isInitialized { return false } - - azureAsrHelper?.pushAudioData(data: audioData) + guard let audioStream = azureAsrHelper?.audioStream else { + os_log("音频流未初始化", log: logger,type: .error) + return false +} +audioStream.saveAudioDataTo(data: audioData) + return true } @@ -472,10 +489,10 @@ class AgentServiceImpl: NSObject { displayText: displayText, hasImage: hasImage ) - + currsessionId = UUID().uuidString os_log("设置回调并调用sendMessageStream", log: logger, type: .info) - chatApiService.setStreamCallback(callback) - chatApiService.sendMessageStream(messages: messages) + chatApiService.setStreamCallback(currsessionId,callback) + chatApiService.sendMessageStream(currsessionId,messages: messages) os_log("sendMessageStream调用完成", log: logger, type: .info) } @@ -582,27 +599,28 @@ class AgentServiceImpl: NSObject { /** * 保存聊天消息 */ - internal func saveChatMessage(userMessage: String, assistantMessage: String, metadata: String = "") { + internal func saveChatMessage(userMessage: String, assistantMessage: String, metadata: String = "",userMetadata: String) { DispatchQueue.global(qos: .utility).async { // 保存用户消息 let userMessageId = self.chatStorageHelper.saveMessage( sessionId: self.sessionId, message: userMessage, sender: "user", - metadata: nil as String? + metadata: userMetadata ) if userMessageId != -1 { - // 保存AI回复 - let assistantMessageId = self.chatStorageHelper.saveMessage( - sessionId: self.sessionId, - message: assistantMessage, - sender: "assistant", - metadata: metadata.isEmpty ? nil : metadata - ) - - if assistantMessageId == -1 { - os_log("保存助手消息失败", log: self.logger, type: .error) + if !assistantMessage.isEmpty || !metadata.isEmpty { + // 保存AI回复 + let assistantMessageId = self.chatStorageHelper.saveMessage( + sessionId: self.sessionId, + message: assistantMessage, + sender: "assistant", + metadata: metadata.isEmpty ? nil : metadata + ) + if assistantMessageId == -1 { + os_log("保存助手消息失败", log: self.logger, type: .error) + } } } else { os_log("保存用户消息失败", log: self.logger, type: .error) @@ -610,95 +628,182 @@ class AgentServiceImpl: NSObject { } } - //解析卡片数据 - @discardableResult - internal func autoHandleFunctionCallResult(toolName: String, functionCallResult: [String: Any]) -> (String, Bool) { - if let contentArray = functionCallResult["content"] as? [[String: Any]], - let firstContent = contentArray.first, - let textStr = firstContent["text"] as? String, - let jsonData = textStr.data(using: .utf8) { - do { - if let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] { - print("解析成功:\(jsonObject)") - var metadata: [String: Any] = [:] - metadata[toolName] = jsonObject - // 将 metadata 转为 JSON 字符串 - let metaStr: String - if let jsonData = try? JSONSerialization.data(withJSONObject: metadata, options: [.prettyPrinted]), - let jsonString = String(data: jsonData, encoding: .utf8) { - metaStr = jsonString - } else { - metaStr = "" - } - // 处理 card_music - if let cardMusic = jsonObject["card_music"] as? [String: Any] { - let id = cardMusic["id"] as? String ?? "" - let url = cardMusic["url"] as? String ?? "" - let title = cardMusic["name"] as? String ?? "" - let artist = cardMusic["sgener"] as? String ?? "" - let coverUrl = cardMusic["image"] as? String ?? "" + // 解析卡片数据 +@discardableResult +internal func autoHandleFunctionCallResult(toolName: String, functionCallResult: [String: Any]) -> [String:Any] { + // 1. 从functionCallResult获取context + guard let contextValue = functionCallResult["context"] else { + return [ + "meta": "", + "broadcast": true, + "notreply": false, + ] + } + + var contextStr = "" + + // 处理不同类型的context值 + if let strValue = contextValue as? String { + contextStr = strValue + } else if let data = try? JSONSerialization.data(withJSONObject: contextValue, options: []), + let str = String(data: data, encoding: .utf8) { + contextStr = str + } + + // 尝试解析JSON + if let jsonData = contextStr.data(using: .utf8), + let jsonObject = try? JSONSerialization.jsonObject(with: jsonData, options: .allowFragments) { + + // 处理字典类型的JSON + if let jsonDict = jsonObject as? [String: Any] { + return handleJSONDictionary(jsonDict, toolName: toolName) + } + // 处理数组类型的JSON + else if let jsonArray = jsonObject as? [[String: Any]] { + return handleJSONDictionary(["array": jsonArray], toolName: toolName) + } + // 处理基础类型 + else { + return handlePrimitiveType(jsonObject, toolName: toolName) + } + } + + // 纯文本内容 + return handlePlainText(contextStr, toolName: toolName) +} + +// 处理字典类型的JSON +private func handleJSONDictionary(_ jsonDict: [String: Any], toolName: String) -> [String: Any] { + var broadcast = true + var notreply = false + var metadata: [String: Any] = [:] + metadata[toolName] = jsonDict + + if let _broadcast = jsonDict["broadcast"] as? Bool { + broadcast = _broadcast + } + if let _notreply = jsonDict["notreply"] as? Bool { + notreply = _notreply + } + metadata["notreply"] = notreply + + // 处理卡片数据 + if let cardMusic = jsonDict["card_music"] as? [String: Any] { + processMusicCard(cardMusic) + } else if let cardMusiclist = jsonDict["card_musiclist"] as? [String: Any], + let musics = cardMusiclist["musics"] as? [[String: Any]] { + processMusicListCard(musics) + } else if let cardNavigation = jsonDict["card_navigation"] as? [String: Any] { + processNavigationCard(cardNavigation) + } + + let metaStr: String + if let jsonData = try? JSONSerialization.data(withJSONObject: metadata, options: []), + let jsonString = String(data: jsonData, encoding: .utf8) { + metaStr = jsonString + } else { + metaStr = "" + } + + return [ + "meta": metaStr, + "broadcast": broadcast, + "notreply": notreply + ] +} - processMusicPlay([ - "id": id, - "url": url, - "title": title, - "artist": artist, - "coverUrl": coverUrl - ]) - return (metaStr, false) - // 处理 card_musiclist - } else if let cardMusiclist = jsonObject["card_musiclist"] as? [String: Any], - let musics = cardMusiclist["musics"] as? [[String: Any]] { +// 处理基本类型(字符串、数字等) +private func handlePrimitiveType(_ value: Any, toolName: String) -> [String: Any] { + let metaStr = "{\"\(toolName)\": \"\(value)\"}" + return [ + "meta": metaStr, + "broadcast": true, + "notreply": false + ] +} - var playlist: [[String: String]] = [] +// 处理纯文本内容 +private func handlePlainText(_ text: String, toolName: String) -> [String: Any] { + let metaStr = "{\"\(toolName)\": \"\(text)\"}" + return [ + "meta": metaStr, + "broadcast": true, + "notreply": false + ] +} - for item in musics { - let id = item["id"] as? String ?? "" - let url = item["url"] as? String ?? "" - let title = item["name"] as? String ?? "" - let artist = item["sgener"] as? String ?? "" - let coverUrl = item["image"] as? String ?? "" +/// 辅助方法:将字典转换为JSON字符串 +private func jsonToString(_ json: [String: Any]) -> String? { + do { + let jsonData = try JSONSerialization.data(withJSONObject: json, options: []) + return String(data: jsonData, encoding: .utf8) + } catch { + print("JSON序列化失败: \(error)") + return nil + } +} - let song: [String: String] = [ - "id": id, - "url": url, - "title": title, - "artist": artist, - "coverUrl": coverUrl - ] - playlist.append(song) - } +// 处理音乐卡片 +private func processMusicCard(_ cardMusic: [String: Any]) { + let id = cardMusic["id"] as? Int ?? 0 + let url = cardMusic["url"] as? String ?? "" + let title = cardMusic["name"] as? String ?? "" + let artist = cardMusic["sgener"] as? String ?? "" + let coverUrl = cardMusic["image"] as? String ?? "" + let ts = cardMusic["ts"] as? Int ?? 0 + + processMusicPlay([ + "id": id, + "url": url, + "title": title, + "artist": artist, + "coverUrl": coverUrl, + "ts": ts + ]) +} - if !playlist.isEmpty { - print("自动播放音乐列表: \(playlist)") - processMusicPlayList(playlist) - return (metaStr, false) - } else { - print("card_musiclist 中没有有效的音乐条目") - } - }else if let cardNavigation = jsonObject["card_navigation"] as? [String: Any] { //导航 - let start:String = cardNavigation["start"] as? String ?? "" - let end:String = cardNavigation["end"] as? String ?? "" - // processNavigation(start,end) - return (metaStr, false) - } - return (metaStr, true) - } - } catch { - print("JSON 解析失败: \(error)") - } - } - return ("", true) +// 处理音乐列表卡片 +private func processMusicListCard(_ musics: [[String: Any]]) { + var playlist: [[String: Any]] = [] + + for item in musics { + let id = item["id"] as? Int ?? 0 + let url = item["url"] as? String ?? "" + let title = item["name"] as? String ?? "" + let artist = item["sgener"] as? String ?? "" + let coverUrl = item["image"] as? String ?? "" + let ts = item["ts"] as? Int ?? 0 + + playlist.append([ + "id": id, + "url": url, + "title": title, + "artist": artist, + "coverUrl": coverUrl, + "ts": ts + ]) } + if !playlist.isEmpty { + processMusicPlayList(playlist) + } +} + +// 处理导航卡片 +private func processNavigationCard(_ cardNavigation: [String: Any]) { + let start = cardNavigation["start"] as? String ?? "" + let end = cardNavigation["end"] as? String ?? "" + // 实际处理导航逻辑 + print("启动导航: 从 \(start) 到 \(end)") +} // 后台播放单曲 - private func processMusicPlay(_ song: [String: String]) { + private func processMusicPlay(_ song: [String: Any]) { sendEvent(name: "music_play", data: song) MusicService.shared.play(song: song) } // 后台播放音乐列表 - private func processMusicPlayList(_ songs: [[String: String]]) { + private func processMusicPlayList(_ songs: [[String: Any]]) { // sendEvent(name: "music_playlist", data: songs) MusicService.shared.playPlaylist(songs: songs) } @@ -743,6 +848,7 @@ class AgentServiceImpl: NSObject { } func dispose() -> Bool { + print("ai释放资源") if isRecognizing { stopRecognition() } @@ -784,7 +890,9 @@ class ChatApiStreamCallback: StreamCallback { private let hasImage: Bool private var responseBuilder = "" private var metadata = "" - private var nobroadcast = true + private var userMetadata:[String: Any] = [:] + private var reply = true; //是否回复 + private var broadcast = true //是否播发 init(agentService: AgentServiceImpl, speakResponse: Bool, displayText: String, hasImage: Bool) { self.agentService = agentService @@ -794,31 +902,39 @@ class ChatApiStreamCallback: StreamCallback { } - - func onToken(_ token: String) { - guard let agentService = agentService else { return } - + func onUsage(_ sessionid: String,_ prompt_tokens: Int?,_ completion_tokens: Int?,_ total_tokens: Int?){ - responseBuilder += token - - agentService.sendEvent(name: "assistant_token", data: ["token": token]) - - if speakResponse && nobroadcast{ - agentService.azureTtsHelper?.speakStream(token) + } + func onToken(_ sessionId:String,_ token: String) { + do { + guard let agentService = try agentService else { return } + if (sessionId != agentService.currsessionId) { + return + } + + responseBuilder += token + + if speakResponse && reply && broadcast{ + try agentService.azureTtsHelper?.speakStream(token) + } + if (reply && token != "") { + try agentService.sendEvent(name: "assistant_token", data: ["token": token]) + } + }catch { + print("liwei--------------- AI Call onToken 异常: \(error)") } } - func onComplete() { - guard let agentService = agentService else { return } - - - if speakResponse && nobroadcast{ - agentService.azureTtsHelper?.flushStream() - } - - let response = responseBuilder - - if !response.isEmpty { + func onComplete(_ sessionId:String) { + do { + guard let agentService = try agentService else { return } + + if speakResponse && reply && broadcast && sessionId == agentService.currsessionId{ + agentService.azureTtsHelper?.flushStream() + } + + let response = responseBuilder + var responseData: [String: Any] = [ "text": response, "userInput": displayText @@ -826,77 +942,109 @@ class ChatApiStreamCallback: StreamCallback { if hasImage { responseData["hasImage"] = true } - agentService.sendEvent(name: "assistant_response", data: responseData) + if (reply) { + try agentService.sendEvent(name: "assistant_response", data: responseData) + } let assistantMessage: [String: Any] = [ "role": "assistant", "content": response ] - agentService.addToHistoryMessages(assistantMessage) - + if !response.isEmpty { + try agentService.addToHistoryMessages(assistantMessage) + } // 保存聊天记录 - agentService.saveChatMessage(userMessage: displayText, assistantMessage: response, metadata: metadata) + try agentService.saveChatMessage(userMessage: displayText, assistantMessage: response, metadata: metadata,userMetadata:toJSONString(userMetadata)) + agentService.isAiStreaming = false + os_log("设置AI流式状态为false", log: agentService.logger, type: .info) + }catch{ + print("liwei--------------- AI Call onComplete 异常: \(error)") + } + + } + + func onError(_ sessionId:String,_ error: Error) { + do { + guard let agentService = try agentService else { return } - os_log("保存聊天记录完成", log: agentService.logger, type: .info) - } else { - os_log("警告: 响应内容为空", log: agentService.logger, type: .error) + os_log("ChatAPI错误: %{public}@", log: agentService.logger, type: .error, error.localizedDescription) + if (sessionId != agentService.currsessionId) { + return + } + try agentService.sendEvent(name: "error", data: [ + "code": "AI_ERROR", + "message": error.localizedDescription + ]) + + agentService.isAiStreaming = false + os_log("因错误设置AI流式状态为false", log: agentService.logger, type: .info) + }catch{ + print("liwei--------------- AI Call onError 异常: \(error)") } - - agentService.isAiStreaming = false - os_log("设置AI流式状态为false", log: agentService.logger, type: .info) + } - func onError(_ error: Error) { - guard let agentService = agentService else { return } - - os_log("ChatAPI错误: %{public}@", log: agentService.logger, type: .error, error.localizedDescription) - - agentService.sendEvent(name: "error", data: [ - "code": "AI_ERROR", - "message": error.localizedDescription - ]) - - agentService.isAiStreaming = false - os_log("因错误设置AI流式状态为false", log: agentService.logger, type: .info) + func onFunctionCall(_ sessionId:String,_ functionCall: [String: Any]) { + do { + guard let agentService = try agentService else { return } + if (sessionId != agentService.currsessionId) { + return + } + let name = functionCall["name"] as? String ?? "" + os_log("收到函数调用: %{public}@", log: agentService.logger, type: .info, name) + print("onFunctionCall=functionCall\(functionCall)") + // try agentService.audioPlayer?.playCallingSound() + + try agentService.sendEvent(name: "function_call", data: [ + "name": name, + "arguments": functionCall + ]) + + if name == "exit_interaction" { + os_log("执行退出交互", log: agentService.logger, type: .info) + agentService.stopRecognition() + } + + }catch{ + print("liwei--------------- AI Call onFunctionCall 异常: \(error)") + } + } - func onFunctionCall(_ functionCall: [String: Any]) { - guard let agentService = agentService else { return } - - let name = functionCall["name"] as? String ?? "" - os_log("收到函数调用: %{public}@", log: agentService.logger, type: .info, name) - - agentService.audioPlayer?.playCallingSound() - - agentService.sendEvent(name: "function_call", data: [ - "name": name, - "arguments": functionCall - ]) - - if name == "exit_interaction" { - os_log("执行退出交互", log: agentService.logger, type: .info) - agentService.stopRecognition() + func onFunctionCallResult(_ sessionId:String,_ functionCall: [String: Any], _ functionCallResult: [String: Any]) { + do { + guard let agentService = agentService else { return } + + let name = functionCall["name"] as? String ?? "" + os_log("收到函数调用结果: %{public}@", log: agentService.logger, type: .info, functionCallResult) + + // agentService.audioPlayer?.stopCallingSound() + let resultmap = agentService.autoHandleFunctionCallResult(toolName:name,functionCallResult:functionCallResult) + metadata = resultmap["meta"] as? String ?? "" + broadcast = resultmap["broadcast"] as? Bool ?? true + reply = !(resultmap["notreply"] as? Bool ?? true) + os_log("卡片数据: %{public}@", log: agentService.logger, type: .info, metadata) + agentService.sendEvent(name: "function_call_result", data: [ + "function_call": functionCall, + "result": functionCallResult, + "meta":metadata, + ]) + os_log("处理函数调用结果完成", log: agentService.logger, type: .info) + }catch{ + print("liwei--------------- AI Call onFunctionCallResult 异常: \(error)") } + } - func onFunctionCallResult(_ functionCall: [String: Any], _ functionCallResult: [String: Any]) { - guard let agentService = agentService else { return } - - let name = functionCall["name"] as? String ?? "" - os_log("收到函数调用结果: %{public}@", log: agentService.logger, type: .info, functionCallResult) - - agentService.audioPlayer?.stopCallingSound() - let (metestr, broadcast) = agentService.autoHandleFunctionCallResult(toolName:name,functionCallResult:functionCallResult) - metadata = metestr; - nobroadcast = broadcast - os_log("卡片数据: %{public}@", log: agentService.logger, type: .info, metadata) - agentService.sendEvent(name: "function_call_result", data: [ - "function_call": functionCall, - "result": functionCallResult, - "meta":metadata, - ]) - os_log("处理函数调用结果完成", log: agentService.logger, type: .info) + func toJSONString(_ dict: [String: Any]) -> String { + guard JSONSerialization.isValidJSONObject(dict), + let data = try? JSONSerialization.data(withJSONObject: dict), + let json = String(data: data, encoding: .utf8) else { + return "" + } + return json } + } extension AgentServiceImpl: TtsEventListener { 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 7c3b415f7..e3ad6b106 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 @@ -43,6 +43,10 @@ public class AgentServicePlugin: NSObject, FlutterPlugin { case "startConversation": impl.startRecognition() result(true) + + case "recognizeCallback": + impl.recognizeCallback() + result(true) case "stopConversation": impl.stopRecognition() @@ -157,4 +161,4 @@ extension AgentServiceEventHandler: AgentServiceListener { } } } -} \ No newline at end of file +} diff --git a/local_plugins/amap_service/ios/Classes/AmapServicePlugin.swift b/local_plugins/amap_service/ios/Classes/AmapServicePlugin.swift index 196285394..05e7c0ec4 100644 --- a/local_plugins/amap_service/ios/Classes/AmapServicePlugin.swift +++ b/local_plugins/amap_service/ios/Classes/AmapServicePlugin.swift @@ -2,46 +2,103 @@ import Flutter import UIKit import AMapNaviKit -public class AmapServicePlugin: NSObject, FlutterPlugin { - private static var channel: FlutterMethodChannel? - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "com.yunqiinnovation.amap_service", binaryMessenger: registrar.messenger()) - let instance = AmapServicePlugin() - // 必须先设置隐私合规 - DispatchQueue.main.async { - // 2. API Key - AMapServices.shared()?.apiKey = "aa60a03be9f6531bcc64029e60f95d03" - AMapServices.shared().enableHTTPS = true - // 导航SDK隐私合规(如果使用导航功能) - AMapNaviManagerConfig.shared().updatePrivacyShow(.didShow, privacyInfo: .didContain) - AMapNaviManagerConfig.shared().updatePrivacyAgree(.didAgree) - // 3. 打印验证(调试用) - print("高德SDK初始化完成") +public class AmapServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + private static var channel: FlutterMethodChannel? + private var eventSink: FlutterEventSink? + + public static func register(with registrar: FlutterPluginRegistrar) { + let methodChannel = FlutterMethodChannel( + name: "com.yunqiinnovation.amap_service", + binaryMessenger: registrar.messenger() + ) + + let eventChannel = FlutterEventChannel( + name: "com.yunqiinnovation.amap_service/events", + binaryMessenger: registrar.messenger() + ) + + let instance = AmapServicePlugin() + eventChannel.setStreamHandler(instance) + + // 高德SDK初始化 + DispatchQueue.main.async { + AMapServices.shared()?.apiKey = "aa60a03be9f6531bcc64029e60f95d03" + AMapServices.shared()?.enableHTTPS = true + + if #available(iOS 13.0, *) { + AMapNaviManagerConfig.shared().updatePrivacyShow(.didShow, privacyInfo: .didContain) + AMapNaviManagerConfig.shared().updatePrivacyAgree(.didAgree) + } + print("高德SDK初始化完成") + } + + registrar.addMethodCallDelegate(instance, channel: methodChannel) + self.channel = methodChannel } - registrar.addMethodCallDelegate(instance, channel: channel) - self.channel = channel - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "open": - print("liwei------------------ 打开导航页面") - if let args = call.arguments as? [String: Any], - let startpos = args["startpos"] as? String, - let endpos = args["endpos"] as? String, - let ntype = args["ntype"] as? String{ - print("liwei------------------ 打开导航页面1 \(startpos) \(endpos)") - DispatchQueue.main.async { - let navVC = AmapViewController(startpos: startpos, endpos: endpos) + + // MARK: - FlutterStreamHandler + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + self.eventSink = events + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } + + // MARK: - 事件发送方法 + private func sendEvent(eventName: String, data: [String: Any]) { + guard let sink = eventSink else { return } + let eventData: [String: Any] = [ + "event": eventName, + "data": data + ] + sink(eventData) + } + + // MARK: - 方法处理 + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "open": + guard let args = call.arguments as? [String: Any], + let startpos = args["startpos"] as? String, + let endpos = args["endpos"] as? String, + let ntype = args["ntype"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "Missing arguments", details: nil)) + return + } + + print("打开导航页面: \(startpos) → \(endpos)") + DispatchQueue.main.async { + let navVC = AmapViewController(startpos: startpos, endpos: endpos) navVC.modalPresentationStyle = .fullScreen UIApplication.shared.keyWindow?.rootViewController?.present(navVC, animated: true) } result(nil) - } else { - result(FlutterError(code: "INVALID_ARGUMENT", message: "Missing 'open' map", details: nil)) + + case "start": + guard let args = call.arguments as? [String: Any], + let startpos = args["startpos"] as? String, + let endpos = args["endpos"] as? String, + let ntype = args["ntype"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "Missing arguments", details: nil)) + return + } + + // 这里添加实际导航启动逻辑 + print("开始导航: \(startpos) → \(endpos), 类型: \(ntype)") + sendEvent(eventName: "navigation_started", data: ["status": "success"]) + result(nil) + + case "stop": + // 这里添加停止导航逻辑 + print("停止导航") + sendEvent(eventName: "navigation_stopped", data: ["status": "success"]) + result(nil) + + default: + result(FlutterMethodNotImplemented) } - default: - result(FlutterMethodNotImplemented) } - } -} +} \ No newline at end of file diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index 17e073b0a..35fa385de 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -669,7 +669,9 @@ class AzureAsrHelper(private val context: Context) { } - +/** + * 开启音频写入线程 + */ private fun startWriteThread() { writeThread = Thread { try { @@ -745,6 +747,9 @@ class AzureAsrHelper(private val context: Context) { } + /** + * 开始音频输入 + */ fun startAudioRecord() { isWriting.set(true) diff --git a/local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift b/local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift deleted file mode 100644 index ffb76605c..000000000 --- a/local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift +++ /dev/null @@ -1,618 +0,0 @@ -import Foundation -import AVFoundation -import MicrosoftCognitiveServicesSpeech -// 自定义语音处理组件,提供音频流处理等功能 -import speech -import os.log - - -/** - * Azure ASR Helper - * - * 基于微软Azure语音服务的ASR实现 - * 参考文档: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-recognize-speech - */ -public class AzureAsrHelper: NSObject { - private let tag = "AzureAsrHelper" - // 日志对象 - private let log = OSLog(subsystem: "com.azure.speech", category: "AzureAsrHelper") - - // 核心组件 - private var speechConfig: SPXSpeechConfiguration? - private var recognizer: SPXSpeechRecognizer? - private var audioConfig: SPXAudioConfiguration? - - // 状态管理 - private var _isContinuousRecognitionActive = false - - // 配置参数 - private var currentLanguage = "zh-CN" - private var supportedLanguages = ["zh-CN"] - private var isAutoDetectLanguage = false - private var subscriptionKey = "" - private var region = "" - - // 音频源配置 - public enum AudioSourceType { - /** 使用设备麦克风 */ - case microphone - - /** 使用外部提供的音频数据 */ - case external - } - - private var audioSourceType = AudioSourceType.microphone - - // 音频处理 - private var externalAudioStream: ExternalAudioPullStream? - - /** - * 初始化Azure语音服务 - * - * @param subscriptionKey Azure 订阅密钥 - * @param region Azure 区域 - * @param supportedLanguages 支持的语言数组 - * @param audioSourceType 音频源类型 - * @return 初始化是否成功 - */ - public func initialize( - subscriptionKey: String, - region: String, - supportedLanguages: [String] = ["zh-CN"], - audioSourceType: AudioSourceType = .microphone - ) -> Bool { - do { - // 检查配置是否为空 - if subscriptionKey.isEmpty || region.isEmpty { - os_log("Azure 配置信息不完整", log: log, type: .error) - return false - } - - // 释放之前的资源 - dispose() - - // 保存配置 - self.subscriptionKey = subscriptionKey - self.region = region - self.audioSourceType = audioSourceType - - // 设置语言 - if !supportedLanguages.isEmpty { - self.supportedLanguages = supportedLanguages - } - - // 根据支持的语言数量决定是否启用自动语言检测 - self.isAutoDetectLanguage = supportedLanguages.count >= 2 - - // 如果只有一种语言,设置为当前语言 - if !isAutoDetectLanguage && !supportedLanguages.isEmpty { - self.currentLanguage = supportedLanguages[0] - } - - // 创建语音配置 - speechConfig = try SPXSpeechConfiguration(subscription: subscriptionKey, region: region) - - if isAutoDetectLanguage { - // 直接启用语言检测模式 - speechConfig?.setPropertyTo("Continuous", byName: "SpeechServiceConnection_LanguageIdMode") - os_log("启用语言检测模式", log: log, type: .info) - } else { - // 设置指定的识别语言 - speechConfig?.speechRecognitionLanguage = currentLanguage - } - - // 创建识别器 - return setupRecognizer() - } catch { - os_log("初始化失败: %{public}@", log: log, type: .error, error.localizedDescription) - return false - } - } - - /** - * 向音频流写入音频数据 - * 仅当音频源设置为external时有效 - * - * @param data 音频数据字节数组 - */ - public func pushAudioData(data: Data) { - if audioSourceType != .external { - return - } - - // 使用拉流模式,将数据推入队列 - externalAudioStream?.pushAudio(data) - } - - /** - * 开始连续语音识别 - * - * @param callback 连续识别结果回调 - * @param audioSourceType 音频源类型 - * @return 是否成功开始识别 - */ - public func startContinuousRecognition( - callback: ContinuousRecognizeCallback, - audioSourceType: AudioSourceType = .microphone - ) -> Bool { - guard speechConfig != nil else { - callback.onError("语音服务未初始化") - return false - } - - if _isContinuousRecognitionActive { - return true - } - - self.audioSourceType = audioSourceType - - // 重置识别器 - if !setupRecognizer() { - callback.onError("重置识别器失败") - return false - } - - do { - // 设置各种事件监听 - setupEventListeners(callback: callback) - - // 启动音频处理 - startAudioProcessing() - - // 开始连续识别 - try recognizer?.startContinuousRecognition() - _isContinuousRecognitionActive = true - - os_log("连续识别已启动,音频源: %{public}@", log: log, type: .info, audioSourceType == .microphone ? "麦克风" : "外部") - - return true - } catch { - stopAudioProcessing() - _isContinuousRecognitionActive = false - callback.onError("启动连续识别失败: \(error.localizedDescription)") - return false - } - } - - /** - * 停止连续语音识别 - * - * @return 是否成功停止 - */ - public func stopContinuousRecognition() -> Bool { - guard speechConfig != nil else { - os_log("语音服务未初始化", log: log, type: .error) - return false - } - - if !_isContinuousRecognitionActive { - return true - } - - do { - guard let recognizer = recognizer else { - os_log("识别器为空,重置状态", log: log, type: .info) - _isContinuousRecognitionActive = false - return true - } - - if audioSourceType == .external { - pushAudioData(data: Data()) - } - - // 停止连续识别 - try recognizer.stopContinuousRecognition() - - // 停止音频处理 - stopAudioProcessing() - - // 会话结束事件会设置_isContinuousRecognitionActive = false - return true - } catch { - // 强制重置状态 - _isContinuousRecognitionActive = false - os_log("停止连续识别失败: %{public}@", log: log, type: .error, error.localizedDescription) - - // 停止音频处理 - stopAudioProcessing() - - // 尝试强制关闭识别器 - recognizer = nil - - return false - } - } - - /** - * 检查连续识别是否活跃 - */ - public func isContinuousRecognitionActive() -> Bool { - return self._isContinuousRecognitionActive - } - - /** - * 释放所有资源 - */ - public func dispose() { - do { - // 如果正在进行连续识别,先停止 - if _isContinuousRecognitionActive { - // 直接停止,不等待结果 - try? recognizer?.stopContinuousRecognition() - _isContinuousRecognitionActive = false - } - - // 停止音频处理 - stopAudioProcessing() - - // 释放资源 - recognizer = nil - speechConfig = nil - audioConfig = nil - - // 确保状态被重置 - _isContinuousRecognitionActive = false - externalAudioStream = nil - } catch { - // 确保状态被重置 - _isContinuousRecognitionActive = false - externalAudioStream = nil - audioConfig = nil - recognizer = nil - speechConfig = nil - } - } - - // MARK: - 私有方法 - - /** - * 设置识别器 - */ - private func setupRecognizer() -> Bool { - do { - // 清理旧的识别器 - recognizer = nil - - // 设置音频配置 - switch audioSourceType { - case .microphone: - // 使用默认麦克风输入配置 - audioConfig = try SPXAudioConfiguration() - case .external: - // 使用拉流方式处理外部音频 - setupExternalAudioStream() - } - - // 创建识别器 - if isAutoDetectLanguage { - let autoDetectConfig = try SPXAutoDetectSourceLanguageConfiguration(supportedLanguages) - recognizer = try SPXSpeechRecognizer( - speechConfiguration: speechConfig!, - autoDetectSourceLanguageConfiguration: autoDetectConfig, - audioConfiguration: audioConfig! - ) - } else { - recognizer = try SPXSpeechRecognizer( - speechConfiguration: speechConfig!, - audioConfiguration: audioConfig! - ) - } - - return true - } catch { - os_log("创建识别器失败: %{public}@", log: log, type: .error, error.localizedDescription) - stopAudioProcessing() - return false - } - } - - /** - * 设置外部音频流 - 使用拉流方式 - */ - private func setupExternalAudioStream() { - do { - // 创建外部音频拉流对象 - externalAudioStream = ExternalAudioPullStream() - - // 创建音频配置 - audioConfig = SPXAudioConfiguration(streamInput: externalAudioStream!.pullStream) - } catch { - os_log("设置外部音频流失败: %{public}@", log: log, type: .error, error.localizedDescription) - externalAudioStream = nil - } - } - - /** - * 设置事件监听器 - */ - private func setupEventListeners(callback: ContinuousRecognizeCallback) { - guard let recognizer = recognizer else { return } - - // 识别中事件 - recognizer.addRecognizingEventHandler { [weak self] (sender, event) in - guard let self = self else { return } - - let result = event.result - let detectedLanguage = self.getDetectedLanguage(from: result) - // os_log("识别中: %{public}@", log: self.log, type: .info, result.text ?? "") - // 直接在当前线程调用回调 - callback.onRecognizing(result.text ?? "", detectedLanguage) - } - - // 识别完成事件 - recognizer.addRecognizedEventHandler { [weak self] (sender, event) in - guard let self = self else { return } - - let result = event.result - if result.reason == SPXResultReason.recognizedSpeech { - let detectedLanguage = self.getDetectedLanguage(from: result) - // 直接在当前线程调用回调 - callback.onResult(result.text ?? "", detectedLanguage) - } - } - - // 会话开始事件 - recognizer.addSessionStartedEventHandler { (sender, event) in - // 直接在当前线程调用回调 - callback.onSessionStarted() - } - - // 会话结束事件 - recognizer.addSessionStoppedEventHandler { [weak self] (sender, event) in - guard let self = self else { return } - - // 直接在当前线程调用回调 - callback.onSessionStopped() - self._isContinuousRecognitionActive = false - self.stopAudioProcessing() - } - - // 取消事件 - recognizer.addCanceledEventHandler { [weak self] (sender, event) in - guard let self = self else { return } - - let errorDetails = event.errorDetails ?? "未知错误" - let reason = String(describing: event.reason.rawValue) - - os_log("识别取消: %{public}@", log: self.log, type: .error, errorDetails) - - callback.onCanceled(reason, errorDetails) - } - } - - /** - * 获取检测到的语言 - */ - private func getDetectedLanguage(from result: SPXSpeechRecognitionResult) -> String { - if !isAutoDetectLanguage { - return "" - } - - // 尝试从属性中获取语言 - if let properties = result.properties, - let language = properties.getPropertyByName("SpeechServiceResponse_RecognitionLanguage") { - return language - } - - // 尝试另一种方式获取语言 - do { - let langResult = try SPXAutoDetectSourceLanguageResult(result) - return langResult.language ?? "" - } catch { - return "" - } - } - - /** - * 启动音频处理 - */ - private func startAudioProcessing() { - switch audioSourceType { - case .microphone: - // 拉流模式不需要额外启动,SDK会自动拉取数据 - break - case .external: - // 外部音频数据模式下不需要启动处理,等待外部调用pushAudioData - break - } - } - - /** - * 停止音频处理 - */ - private func stopAudioProcessing() { - if let stream = externalAudioStream { - stream.close() - externalAudioStream = nil - // os_log("外部音频流已关闭", log: log, type: .info) - } - } - - - /** - * 外部音频拉流 - * 实现PullAudioInputStreamCallback,将外部推送的音频数据转换为SDK可拉取的形式 - */ - private class ExternalAudioPullStream: NSObject { - private(set) var pullStream: SPXPullAudioInputStream! - private let queue = LinkedBlockingQueue() - private var closed = false - - override init() { - super.init() - - pullStream = SPXPullAudioInputStream( - readHandler: { [weak self] (data: NSMutableData, size: UInt) -> Int in - guard let self = self else { return 0 } - return self.read(buffer: data, size: Int(size)) - }, - closeHandler: { [weak self] in - self?.close() - } - ) - } - - /** - * 外部调用:推送音频数据到队列 - * @param data 音频数据 - */ - func pushAudio(_ data: Data) { - if !closed { - queue.put(data) - } - } - - /** - * SDK调用:从队列中拉取数据 - * @param buffer SDK提供的缓冲区 - * @param size 缓冲区大小 - * @return 读取的字节数,0表示流结束 - */ - private func read(buffer: NSMutableData, size: Int) -> Int { - // 阻塞等待下一块数据 - guard let chunk = queue.take() else { - return 0 // 队列已关闭 - } - - // 如果是空数据,表示流结束 - if chunk.isEmpty { - return 0 - } - - let toCopy = min(chunk.count, size) - buffer.append(chunk.prefix(toCopy)) - return toCopy - } - - /** - * SDK调用:关闭流 - */ - func close() { - closed = true - queue.close() - } - } - - /** - * iOS版LinkedBlockingQueue实现 - * 与Android LinkedBlockingQueue保持一致的API和行为 - */ - 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() - } - } - } - - /** - * 连续识别回调接口 - */ - public protocol ContinuousRecognizeCallback { - /** - * 返回识别结果 - * - * @param text 识别的文本 - * @param detectedLanguage 检测到的语言 - */ - func onResult(_ text: String, _ detectedLanguage: String) - - /** - * 识别进行中调用 - * - * @param recognizing 正在识别的文本 - * @param detectedLanguage 检测到的语言 - */ - func onRecognizing(_ recognizing: String, _ detectedLanguage: String) - - /** - * 会话开始时调用 - */ - func onSessionStarted() - - /** - * 会话结束时调用 - */ - func onSessionStopped() - - /** - * 识别取消时调用 - * - * @param reason 取消原因 - * @param errorDetails 错误详情 - */ - func onCanceled(_ reason: String, _ errorDetails: String) - - /** - * 识别出错时调用 - * - * @param error 错误信息 - */ - func onError(_ error: String) - } -} \ No newline at end of file diff --git a/local_plugins/azure_speech/ios/Classes/AzureSpeechPlugin.h b/local_plugins/azure_speech/ios/Classes/AzureSpeechPlugin.h deleted file mode 100644 index 7f8606465..000000000 --- a/local_plugins/azure_speech/ios/Classes/AzureSpeechPlugin.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// AzureSpeechPlugin.h -// azure_speech -// -// Created for azure_speech plugin compatibility. -// - -#import - -/** - * Azure Speech Plugin 头文件 - * - * 注:实际实现使用 Swift,此头文件仅用于 Flutter 框架兼容 - */ -@interface AzureSpeechPlugin : NSObject -+ (void)registerWithRegistrar:(NSObject*)registrar; -@end \ No newline at end of file diff --git a/local_plugins/azure_speech/ios/Classes/AzureSpeechPlugin.swift b/local_plugins/azure_speech/ios/Classes/AzureSpeechPlugin.swift deleted file mode 100644 index 66e375647..000000000 --- a/local_plugins/azure_speech/ios/Classes/AzureSpeechPlugin.swift +++ /dev/null @@ -1,422 +0,0 @@ -import Flutter -import UIKit -import AVFoundation -import MicrosoftCognitiveServicesSpeech -// 自定义语音处理组件,提供音频流处理等功能 -import speech -import os.log - -/** - * Azure Speech Plugin - * - * 基于微软Azure语音服务的Flutter插件 - * 提供语音识别(ASR)和语音合成(TTS)功能 - */ -@objc public class AzureSpeechPlugin: NSObject, FlutterPlugin { - // 日志标签 - private let tag = "AzureSpeechPlugin" - // 日志对象 - private let log = OSLog(subsystem: "com.azure.speech", category: "AzureSpeechPlugin") - - // ASR相关 - private var asrChannel: FlutterMethodChannel? - private var asrEventChannel: FlutterEventChannel? - private var asrEventSink: FlutterEventSink? - private let azureAsrHelper = AzureAsrHelper() - - // TTS相关 - private var ttsChannel: FlutterMethodChannel? - private var ttsEventChannel: FlutterEventChannel? - private var ttsEventSink: FlutterEventSink? - private let azureTtsHelper = AzureTtsHelper() - - // 是否已添加TTS事件监听器 - private var isTtsListenerAdded = false - - // 当前的连续识别回调 - private var currentAsrCallback: AsrCallbackWrapper? - - // 插件注册 - public static func register(with registrar: FlutterPluginRegistrar) { - let instance = AzureSpeechPlugin() - - // 初始化ASR通道 - let asrChannel = FlutterMethodChannel(name: "azure_speech/asr", binaryMessenger: registrar.messenger()) - registrar.addMethodCallDelegate(instance, channel: asrChannel) - instance.asrChannel = asrChannel - - // 初始化TTS通道 - let ttsChannel = FlutterMethodChannel(name: "azure_speech/tts", binaryMessenger: registrar.messenger()) - registrar.addMethodCallDelegate(instance, channel: ttsChannel) - instance.ttsChannel = ttsChannel - - // 初始化ASR事件通道 - let asrEventChannel = FlutterEventChannel(name: "azure_speech/asr_events", binaryMessenger: registrar.messenger()) - asrEventChannel.setStreamHandler(instance) - instance.asrEventChannel = asrEventChannel - - // 初始化TTS事件通道 - let ttsEventChannel = FlutterEventChannel(name: "azure_speech/tts_events", binaryMessenger: registrar.messenger()) - ttsEventChannel.setStreamHandler(instance) - instance.ttsEventChannel = ttsEventChannel - } - - // 发送ASR事件方法 - internal func sendAsrEvent(_ event: [String: Any]) { - if asrEventSink == nil { - os_log("无法发送ASR事件:事件通道未准备好", log: log, type: .error) - return - } - - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.asrEventSink?(event) - } - } - - // 发送TTS事件方法 - private func sendTtsEvent(_ event: [String: Any]) { - if ttsEventSink == nil { - os_log("无法发送TTS事件:事件通道未准备好", log: log, type: .error) - return - } - - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.ttsEventSink?(event) - } - } - - // 设置TTS事件监听器 - private func setupTtsEventListener() { - if !isTtsListenerAdded { - azureTtsHelper.addListener(self) - isTtsListenerAdded = true - } - } - - - - // 处理Flutter方法调用 - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - if call.method.hasPrefix("tts_") { - handleTtsMethodCall(call, result) - } else { - handleAsrMethodCall(call, result) - } - } - - // MARK: - ASR 方法处理 - - private func handleAsrMethodCall(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { - switch call.method { - case "initialize": - guard let args = call.arguments as? [String: Any], - let subscriptionKey = args["subscriptionKey"] as? String, - let region = args["region"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "必要的参数不能为空", details: nil)) - return - } - - let supportedLanguages = args["supportedLanguages"] as? [String] ?? ["zh-CN"] - - // 初始化ASR引擎 - let success = azureAsrHelper.initialize( - subscriptionKey: subscriptionKey, - region: region, - supportedLanguages: supportedLanguages, - audioSourceType: .microphone - ) - - result(success) - - case "recognizeOnce": - // iOS版本不支持recognizeOnce - result(FlutterError(code: "NOT_SUPPORTED", message: "iOS版本不支持recognizeOnce", details: nil)) - - case "startContinuousRecognition": - // 确保事件通道已准备好 - guard asrEventSink != nil else { - result(FlutterError(code: "EVENT_CHANNEL_NOT_READY", message: "事件通道未准备好,无法开始连续识别", details: nil)) - return - } - - // 解析音频源类型 - let audioSourceType: AzureAsrHelper.AudioSourceType - if let args = call.arguments as? [String: Any], - let audioSourceString = args["audioSourceType"] as? String { - switch audioSourceString.lowercased() { - case "external": - audioSourceType = .external - default: - audioSourceType = .microphone - } - } else { - audioSourceType = .microphone - } - - // 创建回调包装器 - currentAsrCallback = AsrCallbackWrapper(plugin: self) - - let success = azureAsrHelper.startContinuousRecognition( - callback: currentAsrCallback!, - audioSourceType: audioSourceType - ) - result(success) - - case "stopContinuousRecognition": - let success = azureAsrHelper.stopContinuousRecognition() - currentAsrCallback = nil - result(success) - - case "isContinuousRecognitionActive": - result(azureAsrHelper.isContinuousRecognitionActive()) - - case "dispose": - azureAsrHelper.dispose() - currentAsrCallback = nil - result(true) - - case "pushAudioData": - guard let args = call.arguments as? [String: Any], - let audioBytes = args["data"] as? FlutterStandardTypedData else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "音频数据不能为空", details: nil)) - return - } - - azureAsrHelper.pushAudioData(data: audioBytes.data) - result(true) - - default: - result(FlutterMethodNotImplemented) - } - } - - // MARK: - TTS 方法处理 - - private func handleTtsMethodCall(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { - switch call.method { - case "tts_initialize": - guard let args = call.arguments as? [String: Any], - let subscriptionKey = args["subscriptionKey"] as? String, - let region = args["region"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "必要的参数不能为空", details: nil)) - return - } - - // 初始化TTS引擎 - let language = args["language"] as? String ?? "zh-CN" - let success = azureTtsHelper.initialize(ttsAppId: "", ttsAppToken: subscriptionKey, ttsResource: region, language: language) - - // 设置TTS事件监听器 - setupTtsEventListener() - - result(success) - - case "tts_set_voice": - guard let args = call.arguments as? [String: Any], - let voiceName = args["voiceName"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "语音名称不能为空", details: nil)) - return - } - - let success = azureTtsHelper.setVoice(voiceName) - result(success) - - case "tts_speak_once": - guard let args = call.arguments as? [String: Any], - let text = args["text"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "文本不能为空", details: nil)) - return - } - - let success = azureTtsHelper.speakOnce(text) - result(success) - - case "tts_speak_stream": - guard let args = call.arguments as? [String: Any], - let text = args["text"] as? String else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "文本不能为空", details: nil)) - return - } - - let success = azureTtsHelper.speakStream(text) - result(success) - - case "tts_flush_stream": - let success = azureTtsHelper.flushStream() - result(success) - - case "tts_stop": - let success = azureTtsHelper.stop() - result(success) - - case "tts_isSpeaking": - result(azureTtsHelper.isSpeaking()) - - case "tts_release": - azureTtsHelper.dispose() - result(true) - - default: - result(FlutterMethodNotImplemented) - } - } -} - -// MARK: - ASR回调包装器 -private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback { - private weak var plugin: AzureSpeechPlugin? - - init(plugin: AzureSpeechPlugin) { - self.plugin = plugin - } - - func onResult(_ text: String, _ detectedLanguage: String) { - plugin?.sendAsrEvent([ - "type": "result", - "text": text, - "language": detectedLanguage - ]) - } - - func onRecognizing(_ recognizing: String, _ detectedLanguage: String) { - plugin?.sendAsrEvent([ - "type": "recognizing", - "text": recognizing, - "language": detectedLanguage - ]) - } - - func onSessionStarted() { - plugin?.sendAsrEvent([ - "type": "sessionStarted" - ]) - } - - func onSessionStopped() { - plugin?.sendAsrEvent([ - "type": "sessionStopped" - ]) - } - - func onCanceled(_ reason: String, _ errorDetails: String) { - plugin?.sendAsrEvent([ - "type": "canceled", - "reason": reason, - "errorDetails": errorDetails - ]) - } - - func onError(_ error: String) { - plugin?.sendAsrEvent([ - "type": "error", - "error": error - ]) - } -} - -// MARK: - 事件处理 -extension AzureSpeechPlugin: FlutterStreamHandler { - public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { - // 根据通道类型设置事件接收器 - if let args = arguments as? [String: Any], - let channel = args["channel"] as? String { - - if channel == "asr" { - asrEventSink = events - } else if channel == "tts" { - ttsEventSink = events - setupTtsEventListener() - } - } else if arguments == nil { - // 如果没有指定通道,尝试弄清楚是哪个通道在监听 - if asrEventSink == nil && ttsEventSink != nil { - asrEventSink = events - } else if asrEventSink != nil && ttsEventSink == nil { - ttsEventSink = events - setupTtsEventListener() - } else { - // 无法确定哪个通道,默认设置为ASR - asrEventSink = events - } - } - - return nil - } - - public func onCancel(withArguments arguments: Any?) -> FlutterError? { - // 根据通道类型清除事件接收器 - if let args = arguments as? [String: Any], - let channel = args["channel"] as? String { - - if channel == "asr" { - asrEventSink = nil - } else if channel == "tts" { - ttsEventSink = nil - } - } else { - // 如果没有指定通道,清除所有通道 - asrEventSink = nil - ttsEventSink = nil - } - - return nil - } -} - - - -// MARK: - TTS 事件监听实现 -extension AzureSpeechPlugin: TtsEventListener { - // 使用@objc特性为方法提供一个不同的Objective-C选择器名称 - @objc(onTtsEvent:) - public func onEvent(_ event: TtsEvent) { - var eventMap: [String: Any] = [:] - - // 根据事件类型转换 - switch event.type { - case .synthesisStarted: - eventMap["type"] = "synthesis_started" - case .synthesisCompleted: - eventMap["type"] = "synthesis_completed" - case .synthesisCanceled: - eventMap["type"] = "synthesis_canceled" - if let reason = event.params["reason"] { - eventMap["reason"] = reason - } - if let errorDetails = event.params["errorDetails"] { - eventMap["errorDetails"] = errorDetails - } - case .error: - eventMap["type"] = "error" - if let errorCode = event.params["errorCode"] { - eventMap["errorCode"] = errorCode - } - if let errorMessage = event.params["errorMessage"] { - eventMap["errorMessage"] = errorMessage - } - @unknown default: - eventMap["type"] = "unknown" - for (key, value) in event.params { - eventMap[key] = value - } - } - - sendTtsEvent(eventMap) - } -} - -// MARK: - 音频数据监听实现 -extension AzureSpeechPlugin: AudioDataListener { - public func onAudioData(_ data: Data) { - if ttsEventSink == nil { return } - - let eventMap: [String: Any] = [ - "type": "audio_data", - "data": FlutterStandardTypedData(bytes: data) - ] - - sendTtsEvent(eventMap) - } -} \ No newline at end of file diff --git a/local_plugins/azure_speech/ios/Classes/AzureTtsHelper.swift b/local_plugins/azure_speech/ios/Classes/AzureTtsHelper.swift deleted file mode 100644 index 5a390d707..000000000 --- a/local_plugins/azure_speech/ios/Classes/AzureTtsHelper.swift +++ /dev/null @@ -1,604 +0,0 @@ -import Foundation -import AVFoundation -import MicrosoftCognitiveServicesSpeech -// 自定义语音处理组件,提供音频流处理等功能 -import speech -import os.log - -/** - * Azure TTS Helper - * - * 基于微软Azure语音服务的TTS实现 - * 参考文档: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-speech-synthesis - * - * 特性: - * - 对外接口保持同步,内部异步处理 - * - 使用专用队列保证合成顺序 - * - 避免阻塞主线程和其他后台线程 - * - 事件回调由调用方处理线程切换 - * - * 使用示例: - * let success = ttsHelper.speakOnce("Hello World") // 立即返回,后台异步处理 - */ - public class AzureTtsHelper: NSObject, ITtsService { - private let tag = "AzureTtsHelper" - // 日志对象 - private let log = OSLog(subsystem: "com.azure.speech", category: "AzureTtsHelper") - - private static let DEFAULT_LANGUAGE = "zh-CN" - private static let DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural" - - // Azure语音服务配置 - private var speechConfig: SPXSpeechConfiguration? - private var synthesizer: SPXSpeechSynthesizer? - private var isInitialized = false - private var speaking = false - - // 当前配置 - private var currentVoice = DEFAULT_VOICE - private var currentLanguage = DEFAULT_LANGUAGE - private var currentRate = "+0%" - private var currentPitch = "+0%" - private var currentVolume = "100%" - - // 事件监听器列表 - private var eventListeners = NSHashTable.weakObjects() - - // 流式文本处理的缓冲区 - private var streamBuffer = "" - private var lastSpeakTime: TimeInterval = 0 - - // 内部异步处理队列,保证顺序执行 - private let synthesisQueue = DispatchQueue(label: "com.azure.tts.synthesis", qos: .userInitiated) - private let synthesisGroup = DispatchGroup() - private var pendingTasks: [() -> Void] = [] - private let taskLock = NSLock() - - /** - * 初始化语音合成服务 - * - * @param appId 服务应用ID - * @param token 服务访问令牌/订阅密钥 - * @param resource 服务资源ID/区域(可选) - * @param language 语言代码,如"zh-CN" - * @return 是否初始化成功 - */ - public func initialize(ttsAppId: String, ttsAppToken: String, ttsResource: String, language: String) -> Bool { - do { - // 创建语音配置 - if ttsResource.isEmpty { - speechConfig = try SPXSpeechConfiguration(subscription: ttsAppToken, region: "eastasia") - } else { - speechConfig = try SPXSpeechConfiguration(subscription: ttsAppToken, region: ttsResource) - } - - // 设置语言 - speechConfig?.speechSynthesisLanguage = language - currentLanguage = language - - // 设置音频输出格式 - 使用16k、16位的PCM格式 - speechConfig?.setPropertyTo("Audio16Khz16BitMonoPcm", - byName: "SpeechServiceConnection_SynthOutputFormat") - - // 创建合成器,使用默认音频输出(扬声器) - synthesizer = try SPXSpeechSynthesizer(speechConfig!) - - // 设置事件监听 - setupEventListeners() - - // 设置默认音色 - 中文默认使用晓晓,英文默认使用Jenny - if language.lowercased().starts(with: "zh") { - _ = setVoice("zh-CN-XiaoxiaoNeural") - } else { - _ = setVoice("en-US-JennyNeural") - } - - // 设置初始化完成 - isInitialized = true - - return true - } catch { - os_log("语音合成服务初始化失败: %{public}@", log: log, type: .error, error.localizedDescription) - return false - } - } - - /** - * 设置语音角色 - * - * @param voiceName 语音角色名称(不同服务的语音角色命名可能不同) - * @return 是否设置成功 - */ - public func setVoice(_ voiceName: String) -> Bool { - if !isInitialized { return false } - - do { - currentVoice = voiceName - - // 更新语音名称 - if let config = speechConfig { - config.speechSynthesisVoiceName = voiceName - } - - // 重新创建合成器 - recreateSynthesizer() - return true - } catch { - os_log("设置语音失败: %{public}@", log: log, type: .error, error.localizedDescription) - notifyEvent(eventType: .error, params: [ - "errorCode": "VOICE_SET_FAILED", - "errorMessage": "设置语音失败: \(error.localizedDescription)" - ]) - return false - } - } - - /** - * 单次合成并播放 - * - * @param text 要合成的文本 - * @return 是否成功开始合成 - */ - public func speakOnce(_ text: String) -> Bool { - if !isInitialized { - os_log("语音合成未初始化", log: log, type: .error) - return false - } - - // 立即返回成功,内部异步处理 - enqueueSynthesisTask { - self.performSynthesis(text: text) - } - - return true - } - - /** - * 将合成任务加入队列,保证顺序执行 - */ - private func enqueueSynthesisTask(_ task: @escaping () -> Void) { - taskLock.lock() - defer { taskLock.unlock() } - - pendingTasks.append(task) - - // 如果当前没有任务在执行,开始处理队列 - if pendingTasks.count == 1 { - processNextTask() - } - } - - /** - * 处理队列中的下一个任务 - */ - private func processNextTask() { - synthesisQueue.async { - self.synthesisGroup.enter() - - self.taskLock.lock() - guard !self.pendingTasks.isEmpty else { - self.taskLock.unlock() - self.synthesisGroup.leave() - return - } - let task = self.pendingTasks.removeFirst() - self.taskLock.unlock() - - // 执行任务 - task() - - self.synthesisGroup.leave() - - // 处理下一个任务 - self.taskLock.lock() - if !self.pendingTasks.isEmpty { - self.taskLock.unlock() - self.processNextTask() - } else { - self.taskLock.unlock() - } - } - } - - /** - * 实际执行合成的方法 - */ - private func performSynthesis(text: String) { - // 重置状态 - speaking = true - - // 生成SSML - let ssml = generateSsml(text) - - do { - os_log("开始合成: %{public}@", log: log, type: .debug, ssml) - // 使用同步方法,在后台队列中执行 - let result = try synthesizer?.startSpeakingSsml(ssml) - - // 检查结果 - if let result = result { - os_log("合成完成,结果: %{public}@", log: log, type: .debug, String(describing: result.reason)) - } - } catch { - os_log("语音合成失败: %{public}@", log: log, type: .error, error.localizedDescription) - notifyEvent(eventType: .error, params: [ - "errorCode": "SYNTHESIS_FAILED", - "errorMessage": error.localizedDescription - ]) - speaking = false - } - } - - /** - * 流式合成文本 - * - * @param text 要合成的文本片段 - * @return 是否成功处理 - */ - public func speakStream(_ text: String) -> Bool { - if !isInitialized || text.isEmpty { - if !isInitialized { - notifyEvent(eventType: .error, params: [ - "errorCode": "NOT_INITIALIZED", - "errorMessage": "TTS引擎未初始化" - ]) - } - return false - } - - do { - // 添加新文本到缓冲区 - streamBuffer.append(text) - - // 增加500ms防抖逻辑 - let currentTime = Date().timeIntervalSince1970 - if currentTime - lastSpeakTime < 0.6 { - return true - } - lastSpeakTime = currentTime - - let currentText = streamBuffer - - // 定义标点符号列表 - let punctuationMarks: [Character] = [".", "。", "!", "!", "?", "?", ";", ";", ",", ",", ":", ":", "\n"] - - // 查找最后一个标点符号的位置 - var lastPunctuationIndex = -1 - for (i, char) in currentText.enumerated().reversed() { - if punctuationMarks.contains(char) { - lastPunctuationIndex = i - break - } - } - - // 如果找到标点符号,则播放到该标点符号 - if lastPunctuationIndex >= 0 { - // 提取要播放的文本(包含标点符号) - let textToSpeak = String(currentText.prefix(lastPunctuationIndex + 1)).trimmingCharacters(in: .whitespacesAndNewlines) - - // 剩余的文本保存在缓冲区中 - let startIndex = currentText.index(currentText.startIndex, offsetBy: lastPunctuationIndex + 1) - streamBuffer = String(currentText[startIndex...]) - - // 只有非空文本才播放 - if !textToSpeak.isEmpty { - return speakOnce(textToSpeak) - } - } - - // 如果没有找到标点符号,则等待更多文本 - return true - } catch { - os_log("流式语音合成失败: %{public}@", log: log, type: .error, error.localizedDescription) - notifyEvent(eventType: .error, params: [ - "errorCode": "STREAM_FAILED", - "errorMessage": "流式语音合成失败: \(error.localizedDescription)" - ]) - return false - } - } - - /** - * 刷新并播放流式文本缓冲区中的剩余内容 - * - * @return 是否成功处理 - */ - public func flushStream() -> Bool { - if !isInitialized { - notifyEvent(eventType: .error, params: [ - "errorCode": "NOT_INITIALIZED", - "errorMessage": "TTS引擎未初始化" - ]) - return false - } - - do { - // 获取缓冲区中剩余的文本 - let remainingText = streamBuffer.trimmingCharacters(in: .whitespacesAndNewlines) - - // 清空缓冲区 - streamBuffer = "" - - // 如果缓冲区为空,直接返回成功 - if remainingText.isEmpty { - return true - } - - // 播放剩余文本 - return speakOnce(remainingText) - } catch { - os_log("刷新流式文本失败: %{public}@", log: log, type: .error, error.localizedDescription) - notifyEvent(eventType: .error, params: [ - "errorCode": "FLUSH_FAILED", - "errorMessage": "刷新流式文本失败: \(error.localizedDescription)" - ]) - return false - } - } - - /** - * 停止语音合成和播放 - * - * @return 是否成功停止 - */ - public func stop() -> Bool { - // 立即更新状态 - speaking = false - - // 清除流式缓冲区中的待播放内容 - streamBuffer = "" - - // 清空待处理的任务队列 - taskLock.lock() - pendingTasks.removeAll() - taskLock.unlock() - - // 在后台队列停止合成器,避免阻塞主线程 - synthesisQueue.async { - if let synthesizer = self.synthesizer { - do { - try synthesizer.stopSpeaking() - os_log("语音合成已停止", log: self.log, type: .info) - - // 直接通知停止完成 - self.notifyEvent(eventType: .synthesisCanceled) - } catch { - os_log("停止语音合成失败: %{public}@", log: self.log, type: .error, error.localizedDescription) - self.notifyEvent(eventType: .error, params: [ - "errorCode": "STOP_FAILED", - "errorMessage": error.localizedDescription - ]) - } - } - } - - return true - } - - /** - * 释放资源 - * 在不再需要服务时调用,释放底层资源 - */ - public func dispose() { - // 停止播放 - _ = stop() - - // 等待所有任务完成 - synthesisGroup.wait() - - // 清理资源 - synthesisQueue.async { - // 释放合成器 - self.synthesizer = nil - - // 释放配置 - self.speechConfig = nil - - // 清空流缓冲区 - self.streamBuffer = "" - - // 重置状态 - self.isInitialized = false - self.speaking = false - - // 清空任务队列 - self.taskLock.lock() - self.pendingTasks.removeAll() - self.taskLock.unlock() - } - } - - /** - * 添加TTS事件监听器 - * - * @param listener 事件监听器 - */ - public func addListener(_ listener: TtsEventListener) { - eventListeners.add(listener as AnyObject) - } - - /** - * 移除TTS事件监听器 - * - * @param listener 要移除的事件监听器 - */ - public func removeListener(_ listener: TtsEventListener) { - eventListeners.remove(listener as AnyObject) - } - - /** - * 添加音频数据监听器 - * 由于不再支持自定义音频流,此方法实际上不再有效 - * - * @param listener 音频数据监听器 - */ - public func addAudioDataListener(_ listener: AudioDataListener) { - os_log("警告:不支持音频数据监听器功能", log: log, type: .info) - } - - /** - * 移除音频数据监听器 - * 由于不再支持自定义音频流,此方法实际上不再有效 - * - * @param listener 要移除的音频数据监听器 - */ - public func removeAudioDataListener(_ listener: AudioDataListener) { - // 不做任何操作 - } - - /** - * 当前是否正在播放/合成 - */ - public func isSpeaking() -> Bool { - return speaking - } - - // MARK: - 辅助方法 - - /** - * 设置事件监听器 - */ - private func setupEventListeners() { - guard let synthesizer = synthesizer else { return } - - // 添加合成开始事件处理器 - synthesizer.addSynthesisStartedEventHandler { [weak self] _, _ in - guard let self = self else { return } - self.notifyEvent(eventType: .synthesisStarted) - } - - // 添加合成中事件处理器 - synthesizer.addSynthesizingEventHandler { [weak self] _, _ in - // 可以在这里处理合成中的事件,目前没有特别操作 - } - - // 添加合成完成事件处理器 - synthesizer.addSynthesisCompletedEventHandler { [weak self] _, e in - guard let self = self else { return } - self.speaking = false - self.notifyEvent(eventType: .synthesisCompleted) - } - - // 添加合成取消事件处理器 - synthesizer.addSynthesisCanceledEventHandler { [weak self] _, e in - guard let self = self else { return } - - self.speaking = false - - var params: [String: Any] = [:] - do { - let cancellationDetails = try SPXSpeechSynthesisCancellationDetails(fromCanceledSynthesisResult: e.result) - if cancellationDetails.reason == SPXCancellationReason.error { - params["reason"] = String(describing: cancellationDetails.reason.rawValue) - params["errorDetails"] = cancellationDetails.errorDetails ?? "未知错误" - } - } catch { - params["errorDetails"] = "获取取消详情失败: \(error.localizedDescription)" - } - os_log("语音合成取消, %{public}@", log: self.log, type: .info, String(describing: params["errorDetails"] ?? "未知错误")) - - self.notifyEvent(eventType: .synthesisCanceled, params: params) - } - } - - /** - * 触发事件通知 - */ - private func notifyEvent(eventType: TtsEventType, params: [String: Any] = [:]) { - let event = TtsEvent(type: eventType, params: params) - - // 直接通知事件,由调用方处理线程切换 - for case let listener as TtsEventListener in self.eventListeners.allObjects { - listener.onEvent(event) - } - } - - /** - * 重新创建合成器 - */ - private func recreateSynthesizer() { - do { - // 使用默认音频输出配置创建合成器 - synthesizer = try SPXSpeechSynthesizer(speechConfig!) - - // 设置事件监听 - setupEventListeners() - } catch { - os_log("重新创建合成器失败: %{public}@", log: log, type: .error, error.localizedDescription) - notifyEvent(eventType: .error, params: [ - "errorCode": "RECREATE_FAILED", - "errorMessage": "重新创建合成器失败: \(error.localizedDescription)" - ]) - } - } - - /** - * 设置语音参数 - */ - private func setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100) -> Bool { - if !isInitialized { return false } - - do { - currentRate = formatPercentage(rate) - currentPitch = formatPercentage(pitch) - currentVolume = "\(min(max(volume, 0), 100))%" - return true - } catch { - os_log("设置语音参数失败: %{public}@", log: log, type: .error, error.localizedDescription) - notifyEvent(eventType: .error, params: [ - "errorCode": "PARAMS_SET_FAILED", - "errorMessage": "设置语音参数失败: \(error.localizedDescription)" - ]) - return false - } - } - - /** - * 格式化百分比值 - */ - private func formatPercentage(_ value: Int) -> String { - return value >= 0 ? "+\(value)%" : "\(value)%" - } - - /** - * 生成SSML - */ - private func generateSsml(_ rawText: String) -> String { - // 1. 定义要静音的符号和表情符号列表 - let symbolsToMute = [ - "#", "*", - "😀", "😂", "😊", "😍", "😢", "😎", "😉", "👍", "🙌", "🎉" - ] - - // 2. 转义 XML 保留字符 - var escapedText = rawText - .replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - - // 3. 静音处理特殊符号和表情符号 - // 使用空白替换法,直接将符号替换为空字符串 - var processedText = escapedText - for symbol in symbolsToMute { - processedText = processedText.replacingOccurrences(of: symbol, with: "") - } - - // 4. 构造简化的SSML文档,减少嵌套层级 - let ssml = """ - - - - - \(processedText) - - - - - """ - - return ssml - } -} diff --git a/local_plugins/azure_speech/ios/azure_speech/Package.swift b/local_plugins/azure_speech/ios/azure_speech/Package.swift index 8c64ed5bb..76d294d9c 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Package.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "azure_speech", - platforms: [.iOS("18.0")], + platforms: [.iOS("16.0")], products: [ .library(name: "azure-speech", targets: ["azure_speech"]) ], 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 b0f4bcb02..9425fa62d 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 @@ -5,7 +5,7 @@ import MicrosoftCognitiveServicesSpeech import speech import os.log - +// MARK: - 基于微软Azure语音服务的ASR实现 /** * Azure ASR Helper * @@ -44,8 +44,12 @@ public class AzureAsrHelper: NSObject { private var audioSourceType = AudioSourceType.microphone // 音频处理 - private var externalAudioStream: ExternalAudioPullStream? + // private var externalAudioStream: ExternalAudioPullStream? + // 音频处理 + public var audioStream: AudioStream? + //录音文件 + public var recordfile: RecordFile? /** * 初始化Azure语音服务 * @@ -70,7 +74,6 @@ public class AzureAsrHelper: NSObject { // 释放之前的资源 dispose() - // 保存配置 self.subscriptionKey = subscriptionKey self.region = region @@ -91,11 +94,11 @@ public class AzureAsrHelper: NSObject { // 创建语音配置 speechConfig = try SPXSpeechConfiguration(subscription: subscriptionKey, region: region) - + speechConfig?.setPropertyTo("800", byName: "SpeechServiceConnection_EndSilenceTimeoutMs") speechConfig?.setPropertyTo("800", byName: "Speech_SegmentationSilenceTimeoutMs") speechConfig?.setPropertyTo("Time", byName: "Speech_SegmentationStrategy") - + if isAutoDetectLanguage { // 直接启用语言检测模式 speechConfig?.setPropertyTo("Continuous", byName: "SpeechServiceConnection_LanguageIdMode") @@ -104,29 +107,16 @@ public class AzureAsrHelper: NSObject { // 设置指定的识别语言 speechConfig?.speechRecognitionLanguage = currentLanguage } - + // 录音文件类 + recordfile = RecordFile() // 创建识别器 - return setupRecognizer() + return true } catch { os_log("初始化失败: %{public}@", log: log, type: .error, error.localizedDescription) return false } } - /** - * 向音频流写入音频数据 - * 仅当音频源设置为external时有效 - * - * @param data 音频数据字节数组 - */ - public func pushAudioData(data: Data) { - if audioSourceType != .external { - return - } - - // 使用拉流模式,将数据推入队列 - externalAudioStream?.pushAudio(data) - } /** * 开始连续语音识别 @@ -136,11 +126,10 @@ public class AzureAsrHelper: NSObject { * @return 是否成功开始识别 */ public func startContinuousRecognition( - callback: ContinuousRecognizeCallback, audioSourceType: AudioSourceType = .microphone ) -> Bool { guard speechConfig != nil else { - callback.onError("语音服务未初始化") + //callback.onError("语音服务未初始化") return false } @@ -150,18 +139,17 @@ public class AzureAsrHelper: NSObject { self.audioSourceType = audioSourceType - // 重置识别器 - if !setupRecognizer() { - callback.onError("重置识别器失败") - return false - } do { - // 设置各种事件监听 - setupEventListeners(callback: callback) - + guard recognizer != nil else { + if !setupRecognizer() { + // callback.onError("重置识别器失败") + return false + } + return false + } // 启动音频处理 - startAudioProcessing() + audioStream?.startAudioInput(audioSourceType: audioSourceType) // 开始连续识别 try recognizer?.startContinuousRecognition() @@ -171,9 +159,9 @@ public class AzureAsrHelper: NSObject { return true } catch { - stopAudioProcessing() + audioStream?.stopAudioCapture() _isContinuousRecognitionActive = false - callback.onError("启动连续识别失败: \(error.localizedDescription)") + //callback.onError("启动连续识别失败: \(error.localizedDescription)") return false } } @@ -188,40 +176,35 @@ public class AzureAsrHelper: NSObject { os_log("语音服务未初始化", log: log, type: .error) return false } + guard let recognizer = recognizer else { + os_log("识别器为空,重置状态", log: log, type: .info) + return true + } if !_isContinuousRecognitionActive { return true } do { - guard let recognizer = recognizer else { - os_log("识别器为空,重置状态", log: log, type: .info) - _isContinuousRecognitionActive = false - return true - } + _isContinuousRecognitionActive = false if audioSourceType == .external { - pushAudioData(data: Data()) + //pushAudioData(data: Data()) } - + + audioStream?.stopAudioCapture() // 停止连续识别 try recognizer.stopContinuousRecognition() - - // 停止音频处理 - stopAudioProcessing() - + // 会话结束事件会设置_isContinuousRecognitionActive = false return true } catch { // 强制重置状态 _isContinuousRecognitionActive = false - os_log("停止连续识别失败: %{public}@", log: log, type: .error, error.localizedDescription) + os_log("强制停止识别失败", log: log, type: .error) // 停止音频处理 - stopAudioProcessing() - - // 尝试强制关闭识别器 - recognizer = nil + audioStream?.stopAudioCapture() return false } @@ -239,6 +222,7 @@ public class AzureAsrHelper: NSObject { */ public func dispose() { do { + print("释放所有资源:") // 如果正在进行连续识别,先停止 if _isContinuousRecognitionActive { // 直接停止,不等待结果 @@ -247,46 +231,45 @@ public class AzureAsrHelper: NSObject { } // 停止音频处理 - stopAudioProcessing() - + audioStream?.releaseAudioResources() + // 停止录音 + recordfile?.closeFile(isSave: true) // 释放资源 recognizer = nil speechConfig = nil audioConfig = nil - + audioStream = nil // 确保状态被重置 _isContinuousRecognitionActive = false - externalAudioStream = nil + //externalAudioStream = nil } catch { // 确保状态被重置 _isContinuousRecognitionActive = false - externalAudioStream = nil + //externalAudioStream = nil audioConfig = nil recognizer = nil speechConfig = nil + audioStream = nil } } - - // MARK: - 私有方法 + /** * 设置识别器 */ private func setupRecognizer() -> Bool { do { + print("设置识别器${_isContinuousRecognitionActive}") + // 如果正在进行连续识别,先停止 + if _isContinuousRecognitionActive { + // 直接停止,不等待结果 + try? recognizer?.stopContinuousRecognition() + _isContinuousRecognitionActive = false + } + setupMicrophoneStream() // 清理旧的识别器 recognizer = nil - // 设置音频配置 - switch audioSourceType { - case .microphone: - // 使用默认麦克风输入配置 - audioConfig = try SPXAudioConfiguration() - case .external: - // 使用拉流方式处理外部音频 - setupExternalAudioStream() - } - // 创建识别器 if isAutoDetectLanguage { let autoDetectConfig = try SPXAutoDetectSourceLanguageConfiguration(supportedLanguages) @@ -305,76 +288,95 @@ public class AzureAsrHelper: NSObject { return true } catch { os_log("创建识别器失败: %{public}@", log: log, type: .error, error.localizedDescription) - stopAudioProcessing() + try? recognizer?.stopContinuousRecognition() return false } } + /** - * 设置外部音频流 - 使用拉流方式 + * 设置麦克风流 - 使用推流方式 */ - private func setupExternalAudioStream() { + private func setupMicrophoneStream() { do { - // 创建外部音频拉流对象 - externalAudioStream = ExternalAudioPullStream() + + if (audioStream == nil) { + // 创建外部音频拉流对象 + // 明确指定使用外部音频源(推流模式) + audioStream = AudioStream() + audioStream?.initAudioRecord() + audioStream?.onAudioData = { [weak self] data in + self?.recordfile?.saveAudioDataToWav(data) + } + } // 创建音频配置 - audioConfig = SPXAudioConfiguration(streamInput: externalAudioStream!.pullStream) + if let pushStream = audioStream?.pushAudioStream { // Safely unwrap optional + audioConfig = SPXAudioConfiguration(streamInput: pushStream) + os_log("设置麦克风流:") + } } catch { - os_log("设置外部音频流失败: %{public}@", log: log, type: .error, error.localizedDescription) - externalAudioStream = nil + os_log("设置麦克风流失败: %{public}@", log: log, type: .error, error.localizedDescription) + audioStream = nil } } /** * 设置事件监听器 */ - private func setupEventListeners(callback: ContinuousRecognizeCallback) { - guard let recognizer = recognizer else { return } + public func setupEventListeners(callback: ContinuousRecognizeCallback) -> Bool{ + print("设置ssssss监听器:${speechConfig}") - // 识别中事件 - recognizer.addRecognizingEventHandler { [weak self] (sender, event) in - guard let self = self else { return } - - let result = event.result - let detectedLanguage = self.getDetectedLanguage(from: result) - // os_log("识别中: %{public}@", log: self.log, type: .info, result.text ?? "") - // 直接在当前线程调用回调 + // 重设识别器 + if (!setupRecognizer()) { + return false + } + guard let recognizer = recognizer else { return false} + // 正在识别事件 + recognizer.addRecognizingEventHandler { [weak self] (_, event) in + guard let self = self else { return } + let result = event.result + let detectedLanguage = self.detectLanguage(from: result) + print("识别中事件: \(detectedLanguage)") + DispatchQueue.main.async { callback.onRecognizing(result.text ?? "", detectedLanguage) } - - // 识别完成事件 - recognizer.addRecognizedEventHandler { [weak self] (sender, event) in - guard let self = self else { return } - - let result = event.result - if result.reason == SPXResultReason.recognizedSpeech { - let detectedLanguage = self.getDetectedLanguage(from: result) - // 直接在当前线程调用回调 + } + + // 识别完成事件 + recognizer.addRecognizedEventHandler { [weak self] (_, event) in + guard let self = self else { return } + let result = event.result + if result.reason == .recognizedSpeech { + let detectedLanguage = self.detectLanguage(from: result) + print("识别完成事件: \(detectedLanguage)") + DispatchQueue.main.async { callback.onResult(result.text ?? "", detectedLanguage) } } + } // 会话开始事件 recognizer.addSessionStartedEventHandler { (sender, event) in // 直接在当前线程调用回调 callback.onSessionStarted() + print("会话开始事件:") } // 会话结束事件 recognizer.addSessionStoppedEventHandler { [weak self] (sender, event) in guard let self = self else { return } - + print("会话结束事件:") // 直接在当前线程调用回调 - callback.onSessionStopped() - self._isContinuousRecognitionActive = false - self.stopAudioProcessing() + // callback.onSessionStopped() + // self._isContinuousRecognitionActive = false + //self.stopAudioProcessing() } // 取消事件 recognizer.addCanceledEventHandler { [weak self] (sender, event) in guard let self = self else { return } - + print("取消事件:") let errorDetails = event.errorDetails ?? "未知错误" let reason = String(describing: event.reason.rawValue) @@ -382,8 +384,18 @@ public class AzureAsrHelper: NSObject { callback.onCanceled(reason, errorDetails) } + return true } - + +// 提取的辅助方法 +private func detectLanguage(from result: SPXSpeechRecognitionResult) -> String { + if isAutoDetectLanguage { + let langResult = try SPXAutoDetectSourceLanguageResult(result) + return langResult.language ?? "" + } else { + return currentLanguage + } +} /** * 获取检测到的语言 */ @@ -401,101 +413,369 @@ public class AzureAsrHelper: NSObject { // 尝试另一种方式获取语言 do { let langResult = try SPXAutoDetectSourceLanguageResult(result) + print("获取检测到的语言") return langResult.language ?? "" } catch { return "" } } - + /** - * 启动音频处理 + * 禁用蓝牙音频功能,切换回正常音频模式 */ - private func startAudioProcessing() { - switch audioSourceType { - case .microphone: - // 拉流模式不需要额外启动,SDK会自动拉取数据 - break - case .external: - // 外部音频数据模式下不需要启动处理,等待外部调用pushAudioData - break + public func disableBluetoothAudio() { + + do { + audioStream?.setAudioOutputRoute(.speaker) + } catch { + print("disableBluetoothAudio") } + } + /** + * 恢复原始音频设备状态(通常是重新启用蓝牙) + */ + public func restoreOriginalAudioState() { + + do { + audioStream?.setAudioOutputRoute(.receiver) + } catch { + print("restoreOriginalAudioState") + } + } + + // MARK: - 录音音频文件方法 + + /** + * 开启录音 + */ + public func enableRecord(filePath: String) { + + + recordfile?.closeFile(isSave: true) + recordfile?.creatingFiles(atPath: filePath) // Fixed method call + } /** - * 停止音频处理 + * 移动文件到新路径 */ - private func stopAudioProcessing() { - if let stream = externalAudioStream { - stream.close() - externalAudioStream = nil - // os_log("外部音频流已关闭", log: log, type: .info) - } + public func moveFile(sourcePath: String, destPath: String)-> Bool { + + + recordfile?.moveFile(from: sourcePath, to: destPath) // Fixed method call + return true } - + /** - * 外部音频拉流 - * 实现PullAudioInputStreamCallback,将外部推送的音频数据转换为SDK可拉取的形式 + * 重命名指定路径的音频文件 */ - private class ExternalAudioPullStream: NSObject { - private(set) var pullStream: SPXPullAudioInputStream! - private let queue = LinkedBlockingQueue() - private var closed = false - - override init() { - super.init() - - pullStream = SPXPullAudioInputStream( - readHandler: { [weak self] (data: NSMutableData, size: UInt) -> Int in - guard let self = self else { return 0 } - return self.read(buffer: data, size: Int(size)) - }, - closeHandler: { [weak self] in - self?.close() - } + public func renameFile(filePath: String, newName: String)-> Bool { + + + + recordfile?.renameFile(at: filePath, to: newName) // Fixed method call + return true + } + + /** + * 停止录音 + */ + public func pauseRecord() { + + + recordfile?.isPause = true + } + + + /** + * 关闭录音 + */ + 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)? + /** + * 初始化 + */ + public func initAudioRecord() { + print("初始化了") + audioFormat = getOptimalAudioFormat() + pushAudioStream = SPXPushAudioInputStream() + 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 ) } - + /** + * 开始音频输入 + */ + public func startAudioInput(audioSourceType: AudioSourceType = .microphone) { + isWriting=true + self.audioSourceType = audioSourceType; + print("startAudioInput=audioSourceType\(audioSourceType)") + switch audioSourceType { + case .microphone: + runMicrophoneCapture() + case .external: + runExternalCapture() + } + } + /** - * 外部调用:推送音频数据到队列 - * @param data 音频数据 + * 开启音频写入线程 */ - func pushAudio(_ data: Data) { - if !closed { - queue.put(data) + 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)") + self.pushAudioStream?.write(dataToWrite) + self.onAudioData?(dataToWrite) + } } } - + /** - * SDK调用:从队列中拉取数据 - * @param buffer SDK提供的缓冲区 - * @param size 缓冲区大小 - * @return 读取的字节数,0表示流结束 + * 向音频流写入音频数据 + * 仅当音频源设置为external时有效 + * + * @param data 音频数据字节数组 */ - private func read(buffer: NSMutableData, size: Int) -> Int { - // 阻塞等待下一块数据 - guard let chunk = queue.take() else { - return 0 // 队列已关闭 + public func saveAudioDataTo(data: Data) { + if audioSourceType != .external { + return } + print("外部data=\(data)") + // 放入队列,由写线程写入 + writeQueue.put(data) + } + + private func runMicrophoneCapture() { + do { + audioEngine = AVAudioEngine() + guard let inputNode = audioEngine?.inputNode else { + throw NSError(domain: "AudioSetup", code: 1) + } + // 推荐直接用系统 format + let hardwareFormat = inputNode.inputFormat(forBus: 0) + // // ===== 新增:启用专业级语音处理 ===== + if #available(iOS 13.0, *) { + try inputNode.setVoiceProcessingEnabled(true) + print("Voice processing enabled") + + } + + + // Create converter to target format + 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 } + + // Convert to target format + let convertedBuffer = AVAudioPCMBuffer( + pcmFormat: targetFormat, + frameCapacity: AVAudioFrameCount(targetFormat.sampleRate * Double(buffer.frameLength) / buffer.format.sampleRate) + )! + ///print("进入 tap 回调,frameLength: \(buffer.frameLength)") + var error: NSError? + let status = converter.convert( + to: convertedBuffer, + error: &error, + withInputFrom: { inNumPackets, outStatus in + outStatus.pointee = .haveData + return buffer + } + ) + //print("转换状态: \(status.rawValue), 错误: \(String(describing: error))") + + if status == .haveData, error == nil { + let data = self.audioBufferToData(convertedBuffer) + // print("准备写入数据,大小: \(data.count)") + + self.writeQueue.put(data) + + } + } + // 重新激活 + try audioSession.setActive(true, options: [.notifyOthersOnDeactivation]) - // 如果是空数据,表示流结束 - if chunk.isEmpty { - return 0 + try audioEngine?.start() + } catch { + print("麦克风启动失败: \(error)") + } + } + 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...weakObjects() + private var audioDataListeners = NSHashTable.weakObjects() // 流式文本处理的缓冲区 private var streamBuffer = "" private var lastSpeakTime: TimeInterval = 0 - // 内部异步处理队列,保证顺序执行 + // 内部异步处理队列 private let synthesisQueue = DispatchQueue(label: "com.azure.tts.synthesis", qos: .userInitiated) + private var isProcessing = false // 添加处理状态标志 private let synthesisGroup = DispatchGroup() private var pendingTasks: [() -> Void] = [] private let taskLock = NSLock() + // 自定义音频输出流 + private var customAudioOutputStream: SPXPushAudioOutputStream? + private var useInternalPlayer = true + // 记录最后播放的文本 + private var lastSpokenText: String? /** * 初始化语音合成服务 - * - * @param appId 服务应用ID - * @param token 服务访问令牌/订阅密钥 - * @param resource 服务资源ID/区域(可选) - * @param language 语言代码,如"zh-CN" - * @return 是否初始化成功 */ public func initialize(ttsAppId: String, ttsAppToken: String, ttsResource: String, language: String) -> Bool { do { // 创建语音配置 - if ttsResource.isEmpty { - speechConfig = try SPXSpeechConfiguration(subscription: ttsAppToken, region: "eastasia") - } else { - speechConfig = try SPXSpeechConfiguration(subscription: ttsAppToken, region: ttsResource) - } - - // 设置语言 + speechConfig = try SPXSpeechConfiguration(subscription: ttsAppToken, region: ttsResource) speechConfig?.speechSynthesisLanguage = language currentLanguage = language - // 设置音频输出格式 - 使用16k、16位的PCM格式 - speechConfig?.setPropertyTo("Audio16Khz16BitMonoPcm", - byName: "SpeechServiceConnection_SynthOutputFormat") + // 设置音频输出格式 - 与Android一致 + speechConfig?.setPropertyTo("riff-16khz-16bit-mono-pcm", + byName: "SpeechServiceConnection_SynthOutputFormat") - // 创建合成器,使用默认音频输出(扬声器) - synthesizer = try SPXSpeechSynthesizer(speechConfig!) - - // 设置事件监听 - setupEventListeners() + // 设置低延迟属性 + speechConfig?.setPropertyTo("300", byName: "SpeechServiceConnection_InitialSilenceTimeoutMs") + speechConfig?.setPropertyTo("300", byName: "SpeechServiceConnection_EndSilenceTimeoutMs") - // 设置默认音色 - 中文默认使用晓晓,英文默认使用Jenny - if language.lowercased().starts(with: "zh") { - _ = setVoice("zh-CN-XiaoxiaoNeural") - } else { - _ = setVoice("en-US-JennyNeural") - } + // 创建合成器 + recreateSynthesizer() // 设置初始化完成 isInitialized = true + // 预热TTS引擎 + warmupSynthesizer() + return true } catch { os_log("语音合成服务初始化失败: %{public}@", log: log, type: .error, error.localizedDescription) @@ -103,24 +78,25 @@ import os.log } } + /** + * 设置自定义音频输出流 + */ + public func setCustomAudioOutputStream(_ outputStream: SPXPushAudioOutputStream?) { + customAudioOutputStream = outputStream + if isInitialized { + recreateSynthesizer() + } + } + /** * 设置语音角色 - * - * @param voiceName 语音角色名称(不同服务的语音角色命名可能不同) - * @return 是否设置成功 */ public func setVoice(_ voiceName: String) -> Bool { if !isInitialized { return false } do { currentVoice = voiceName - - // 更新语音名称 - if let config = speechConfig { - config.speechSynthesisVoiceName = voiceName - } - - // 重新创建合成器 + speechConfig?.speechSynthesisVoiceName = voiceName recreateSynthesizer() return true } catch { @@ -135,173 +111,88 @@ import os.log /** * 单次合成并播放 - * - * @param text 要合成的文本 - * @return 是否成功开始合成 */ public func speakOnce(_ text: String) -> Bool { if !isInitialized { os_log("语音合成未初始化", log: log, type: .error) + notifyEvent(eventType: .error, params: [ + "errorCode": "NOT_INITIALIZED", + "errorMessage": "TTS引擎未初始化" + ]) return false } - // 立即返回成功,内部异步处理 + // 清理文本 + let cleanedText = cleanTextForTTS(text) + if cleanedText.isEmpty { + return false + } + + // 异步处理 enqueueSynthesisTask { - self.performSynthesis(text: text) + self.performSynthesis(text: cleanedText) } return true } /** - * 将合成任务加入队列,保证顺序执行 + * 流式合成文本 */ - private func enqueueSynthesisTask(_ task: @escaping () -> Void) { - taskLock.lock() - defer { taskLock.unlock() } - - pendingTasks.append(task) - - // 如果当前没有任务在执行,开始处理队列 - if pendingTasks.count == 1 { - processNextTask() + public func speakStream(_ text: String) -> Bool { + if !isInitialized { + notifyEvent(eventType: .error, params: [ + "errorCode": "NOT_INITIALIZED", + "errorMessage": "TTS引擎未初始化" + ]) + return false } - } - - /** - * 处理队列中的下一个任务 - */ - private func processNextTask() { - synthesisQueue.async { - self.synthesisGroup.enter() - - self.taskLock.lock() - guard !self.pendingTasks.isEmpty else { - self.taskLock.unlock() - self.synthesisGroup.leave() - return - } - let task = self.pendingTasks.removeFirst() - self.taskLock.unlock() - - // 执行任务 - task() - - self.synthesisGroup.leave() - - // 处理下一个任务 - self.taskLock.lock() - if !self.pendingTasks.isEmpty { - self.taskLock.unlock() - self.processNextTask() - } else { - self.taskLock.unlock() - } + + if text.isEmpty { + return true } - } - - /** - * 实际执行合成的方法 - */ - private func performSynthesis(text: String) { - // 重置状态 - speaking = true - // 生成SSML - let ssml = generateSsml(text) + // 清理文本并添加到缓冲区 + let cleanedText = cleanTextForTTS(text) + streamBuffer.append(cleanedText) - do { - os_log("开始合成: %{public}@", log: log, type: .debug, ssml) - // 使用同步方法,在后台队列中执行 - let result = try synthesizer?.startSpeakingSsml(ssml) - - // 检查结果 - if let result = result { - os_log("合成完成,结果: %{public}@", log: log, type: .debug, String(describing: result.reason)) - } - } catch { - os_log("语音合成失败: %{public}@", log: log, type: .error, error.localizedDescription) - notifyEvent(eventType: .error, params: [ - "errorCode": "SYNTHESIS_FAILED", - "errorMessage": error.localizedDescription - ]) - speaking = false + // 防抖逻辑 (150ms) + let currentTime = Date().timeIntervalSince1970 + if currentTime - lastSpeakTime < 0.15 { + return true } - } - - /** - * 流式合成文本 - * - * @param text 要合成的文本片段 - * @return 是否成功处理 - */ - public func speakStream(_ text: String) -> Bool { - if !isInitialized || text.isEmpty { - if !isInitialized { - notifyEvent(eventType: .error, params: [ - "errorCode": "NOT_INITIALIZED", - "errorMessage": "TTS引擎未初始化" - ]) + lastSpeakTime = currentTime + + let currentText = streamBuffer + + // 标点符号列表 (与Android一致) + let punctuationMarks: [Character] = [".", "。", "!", "!", "?", "?", ";", ";", ",", ",", ":", ":", "\n"] + + // 从后往前查找最后一个标点符号 + var lastPunctuationIndex = -1 + for (i, char) in currentText.enumerated().reversed() { + if punctuationMarks.contains(char) { + lastPunctuationIndex = i + break } - return false } - do { - // 添加新文本到缓冲区 - streamBuffer.append(text) - - // 增加500ms防抖逻辑 - let currentTime = Date().timeIntervalSince1970 - if currentTime - lastSpeakTime < 0.6 { - return true - } - lastSpeakTime = currentTime - - let currentText = streamBuffer + // 播放到找到的标点符号 + if lastPunctuationIndex >= 0 { + let textToSpeak = String(currentText.prefix(lastPunctuationIndex + 1)) + let startIndex = currentText.index(currentText.startIndex, offsetBy: lastPunctuationIndex + 1) + streamBuffer = String(currentText[startIndex...]) - // 定义标点符号列表 - let punctuationMarks: [Character] = [".", "。", "!", "!", "?", "?", ";", ";", ",", ",", ":", ":", "\n"] - - // 查找最后一个标点符号的位置 - var lastPunctuationIndex = -1 - for (i, char) in currentText.enumerated().reversed() { - if punctuationMarks.contains(char) { - lastPunctuationIndex = i - break - } - } - - // 如果找到标点符号,则播放到该标点符号 - if lastPunctuationIndex >= 0 { - // 提取要播放的文本(包含标点符号) - let textToSpeak = String(currentText.prefix(lastPunctuationIndex + 1)).trimmingCharacters(in: .whitespacesAndNewlines) - - // 剩余的文本保存在缓冲区中 - let startIndex = currentText.index(currentText.startIndex, offsetBy: lastPunctuationIndex + 1) - streamBuffer = String(currentText[startIndex...]) - - // 只有非空文本才播放 - if !textToSpeak.isEmpty { + if !textToSpeak.isEmpty { return speakOnce(textToSpeak) - } } - - // 如果没有找到标点符号,则等待更多文本 - return true - } catch { - os_log("流式语音合成失败: %{public}@", log: log, type: .error, error.localizedDescription) - notifyEvent(eventType: .error, params: [ - "errorCode": "STREAM_FAILED", - "errorMessage": "流式语音合成失败: \(error.localizedDescription)" - ]) - return false } + + return true } /** - * 刷新并播放流式文本缓冲区中的剩余内容 - * - * @return 是否成功处理 + * 刷新并播放流式文本 */ public func flushStream() -> Bool { if !isInitialized { @@ -312,115 +203,71 @@ import os.log return false } - do { - // 获取缓冲区中剩余的文本 - let remainingText = streamBuffer.trimmingCharacters(in: .whitespacesAndNewlines) - - // 清空缓冲区 - streamBuffer = "" - - // 如果缓冲区为空,直接返回成功 - if remainingText.isEmpty { - return true - } - - // 播放剩余文本 - return speakOnce(remainingText) - } catch { - os_log("刷新流式文本失败: %{public}@", log: log, type: .error, error.localizedDescription) - notifyEvent(eventType: .error, params: [ - "errorCode": "FLUSH_FAILED", - "errorMessage": "刷新流式文本失败: \(error.localizedDescription)" - ]) - return false + let remainingText = streamBuffer + streamBuffer = "" + + if remainingText.isEmpty { + return true } + + return speakOnce(remainingText) } /** - * 停止语音合成和播放 - * - * @return 是否成功停止 + * 停止语音合成 */ public func stop() -> Bool { - // 立即更新状态 speaking = false - - // 清除流式缓冲区中的待播放内容 streamBuffer = "" - // 清空待处理的任务队列 taskLock.lock() pendingTasks.removeAll() taskLock.unlock() - // 在后台队列停止合成器,避免阻塞主线程 synthesisQueue.async { - if let synthesizer = self.synthesizer { - do { - try synthesizer.stopSpeaking() - os_log("语音合成已停止", log: self.log, type: .info) - - // 直接通知停止完成 - self.notifyEvent(eventType: .synthesisCanceled) - } catch { - os_log("停止语音合成失败: %{public}@", log: self.log, type: .error, error.localizedDescription) - self.notifyEvent(eventType: .error, params: [ - "errorCode": "STOP_FAILED", - "errorMessage": error.localizedDescription - ]) - } + do { + try self.synthesizer?.stopSpeaking() + self.notifyEvent(eventType: .synthesisCanceled) + } catch { + os_log("停止语音合成失败: %{public}@", log: self.log, type: .error, error.localizedDescription) + self.notifyEvent(eventType: .error, params: [ + "errorCode": "STOP_FAILED", + "errorMessage": error.localizedDescription + ]) } } return true } - /** * 释放资源 - * 在不再需要服务时调用,释放底层资源 */ public func dispose() { - // 停止播放 _ = stop() - - // 等待所有任务完成 synthesisGroup.wait() - // 清理资源 - synthesisQueue.async { - // 释放合成器 - self.synthesizer = nil - - // 释放配置 - self.speechConfig = nil - - // 清空流缓冲区 - self.streamBuffer = "" - - // 重置状态 - self.isInitialized = false - self.speaking = false + synthesisQueue.sync { + synthesizer = nil + speechConfig = nil + customAudioOutputStream = nil + isInitialized = false + speaking = false - // 清空任务队列 - self.taskLock.lock() - self.pendingTasks.removeAll() - self.taskLock.unlock() + taskLock.lock() + pendingTasks.removeAll() + taskLock.unlock() } } /** - * 添加TTS事件监听器 - * - * @param listener 事件监听器 + * 添加事件监听器 */ public func addListener(_ listener: TtsEventListener) { eventListeners.add(listener as AnyObject) } /** - * 移除TTS事件监听器 - * - * @param listener 要移除的事件监听器 + * 移除事件监听器 */ public func removeListener(_ listener: TtsEventListener) { eventListeners.remove(listener as AnyObject) @@ -428,177 +275,324 @@ import os.log /** * 添加音频数据监听器 - * 由于不再支持自定义音频流,此方法实际上不再有效 - * - * @param listener 音频数据监听器 */ public func addAudioDataListener(_ listener: AudioDataListener) { - os_log("警告:不支持音频数据监听器功能", log: log, type: .info) + audioDataListeners.add(listener as AnyObject) } /** * 移除音频数据监听器 - * 由于不再支持自定义音频流,此方法实际上不再有效 - * - * @param listener 要移除的音频数据监听器 */ public func removeAudioDataListener(_ listener: AudioDataListener) { - // 不做任何操作 + audioDataListeners.remove(listener as AnyObject) } /** - * 当前是否正在播放/合成 + * 设置是否使用内部播放器 */ - public func isSpeaking() -> Bool { - return speaking + public func setUseInternalPlayer(_ useInternalPlayer: Bool) { + self.useInternalPlayer = useInternalPlayer + if isInitialized { + recreateSynthesizer() + } + } + /** + * 设置音频输出设备 + */ + public func setAudioOutputDevice() -> Bool { + // 先暂停当前播放 + let wasSpeaking = speaking + if wasSpeaking { + _ = stop() + } + + // 重新创建合成器以应用新设备 + if isInitialized { + print(" 重新创建合成器以应用新设备") + recreateSynthesizer() + } + + // 如果之前正在播放,恢复播放 + if wasSpeaking, let lastText = lastSpokenText { + speakOnce(lastText) + } + + return true + } + // /** + // * 设置音频输出设备 + // */ + // public func setAudioOutputDevice(_ device: AudioOutputDevice) -> Bool { + // do { + // let session = AVAudioSession.sharedInstance() + // try session.setCategory(.playAndRecord, options: [.defaultToSpeaker, .allowBluetooth]) + // + // switch device { + // case .default: + // try session.overrideOutputAudioPort(.none) + // case .speaker: + // try session.overrideOutputAudioPort(.speaker) + // case .headphones: + // try session.overrideOutputAudioPort(.none) + // } + // + // try session.setActive(true) + // return true + // } catch { + // os_log("设置音频输出设备失败: %{public}@", log: log, type: .error, error.localizedDescription) + // return false + // } + // } + // + // MARK: - 私有方法 + + /** + * 加入合成任务队列 + */ + private func enqueueSynthesisTask(_ task: @escaping () -> Void) { + taskLock.lock() + pendingTasks.append(task) + taskLock.unlock() + + // 确保任务被处理 + processTasksIfNeeded() + } + /** + * 处理任务队列 + */ + private func processTasksIfNeeded() { + taskLock.lock() + // 如果已经在处理中或没有任务,则直接返回 + guard !isProcessing && !pendingTasks.isEmpty else { + taskLock.unlock() + return + } + + isProcessing = true + let task = pendingTasks.removeFirst() + taskLock.unlock() + + synthesisQueue.async { + task() + + // 任务完成后,检查是否有更多任务 + self.taskLock.lock() + self.isProcessing = false + + // 如果还有任务,递归处理 + if !self.pendingTasks.isEmpty { + self.taskLock.unlock() + self.processTasksIfNeeded() + } else { + self.taskLock.unlock() + } + } } - // MARK: - 辅助方法 + /** + * 处理下一个任务 + */ + private func processNextTask() { + synthesisQueue.async { + self.taskLock.lock() + guard !self.pendingTasks.isEmpty else { + self.taskLock.unlock() + return + } + let task = self.pendingTasks.removeFirst() + self.taskLock.unlock() + + task() + self.processNextTask() + } + } + + /** + * 执行语音合成 + */ + private func performSynthesis(text: String) { + speaking = true + lastSpokenText = text + // 生成SSML + let ssml = generateOptimizedSsml(text) + + do { + os_log("开始合成: %{public}@", log: log, type: .debug, ssml) + let result = try synthesizer?.startSpeakingSsml(ssml) + + if let result = result { + os_log("合成完成,结果: %{public}@", log: log, type: .debug, String(describing: result.reason)) + } + } catch { + speaking = false + os_log("语音合成失败: %{public}@", log: log, type: .error, error.localizedDescription) + notifyEvent(eventType: .error, params: [ + "errorCode": "SYNTHESIS_FAILED", + "errorMessage": error.localizedDescription + ]) + } + } + + + /** + * 重新创建合成器 + */ + private func recreateSynthesizer() { + do { + // 创建音频配置 + let audioConfig: SPXAudioConfiguration? + if !useInternalPlayer || customAudioOutputStream != nil { + audioConfig = try SPXAudioConfiguration(streamOutput: customAudioOutputStream ?? SPXPushAudioOutputStream()) + } else { + audioConfig = nil // 使用默认扬声器 + } + + // 创建合成器 + synthesizer = try SPXSpeechSynthesizer(speechConfig!) + setupEventListeners() + } catch { + os_log("重新创建合成器失败: %{public}@", log: log, type: .error, error.localizedDescription) + notifyEvent(eventType: .error, params: [ + "errorCode": "RECREATE_FAILED", + "errorMessage": "重新创建合成器失败: \(error.localizedDescription)" + ]) + } + } /** * 设置事件监听器 */ private func setupEventListeners() { - guard let synthesizer = synthesizer else { return } - - // 添加合成开始事件处理器 - synthesizer.addSynthesisStartedEventHandler { [weak self] _, _ in - guard let self = self else { return } - self.notifyEvent(eventType: .synthesisStarted) + synthesizer?.addSynthesisStartedEventHandler { [weak self] _, _ in + self?.notifyEvent(eventType: .synthesisStarted) + self?.notifyEvent(eventType: .playbackStarted) } - // 添加合成中事件处理器 - synthesizer.addSynthesizingEventHandler { [weak self] _, _ in - // 可以在这里处理合成中的事件,目前没有特别操作 + synthesizer?.addSynthesizingEventHandler { [weak self] _, event in + if let audioData = event.result.audioData { + self?.notifyAudioData(audioData) + } } - // 添加合成完成事件处理器 - synthesizer.addSynthesisCompletedEventHandler { [weak self] _, e in + synthesizer?.addSynthesisCompletedEventHandler { [weak self] _, _ in guard let self = self else { return } self.speaking = false self.notifyEvent(eventType: .synthesisCompleted) + self.notifyEvent(eventType: .playbackCompleted) } - // 添加合成取消事件处理器 - synthesizer.addSynthesisCanceledEventHandler { [weak self] _, e in + synthesizer?.addSynthesisCanceledEventHandler { [weak self] _, event in guard let self = self else { return } - self.speaking = false var params: [String: Any] = [:] - do { - let cancellationDetails = try SPXSpeechSynthesisCancellationDetails(fromCanceledSynthesisResult: e.result) - if cancellationDetails.reason == SPXCancellationReason.error { - params["reason"] = String(describing: cancellationDetails.reason.rawValue) - params["errorDetails"] = cancellationDetails.errorDetails ?? "未知错误" - } - } catch { - params["errorDetails"] = "获取取消详情失败: \(error.localizedDescription)" + if let details = try? SPXSpeechSynthesisCancellationDetails(fromCanceledSynthesisResult: event.result) { + params["errorCode"] = details.errorCode + params["errorDetails"] = details.errorDetails } - os_log("语音合成取消, %{public}@", log: self.log, type: .info, String(describing: params["errorDetails"] ?? "未知错误")) - + self.notifyEvent(eventType: .synthesisCanceled, params: params) } } /** - * 触发事件通知 + * 通知事件 */ private func notifyEvent(eventType: TtsEventType, params: [String: Any] = [:]) { let event = TtsEvent(type: eventType, params: params) - - // 直接通知事件,由调用方处理线程切换 - for case let listener as TtsEventListener in self.eventListeners.allObjects { + for case let listener as TtsEventListener in eventListeners.allObjects { listener.onEvent(event) } } /** - * 重新创建合成器 + * 通知音频数据 */ - private func recreateSynthesizer() { - do { - // 使用默认音频输出配置创建合成器 - synthesizer = try SPXSpeechSynthesizer(speechConfig!) - - // 设置事件监听 - setupEventListeners() - } catch { - os_log("重新创建合成器失败: %{public}@", log: log, type: .error, error.localizedDescription) - notifyEvent(eventType: .error, params: [ - "errorCode": "RECREATE_FAILED", - "errorMessage": "重新创建合成器失败: \(error.localizedDescription)" - ]) + private func notifyAudioData(_ data: Data) { + for case let listener as AudioDataListener in audioDataListeners.allObjects { + listener.onAudioData(data) } } /** - * 设置语音参数 + * 预热TTS引擎 */ - private func setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100) -> Bool { - if !isInitialized { return false } + private func warmupSynthesizer() { + let warmupSsml = """ + + + + . + + + + """ - do { - currentRate = formatPercentage(rate) - currentPitch = formatPercentage(pitch) - currentVolume = "\(min(max(volume, 0), 100))%" - return true - } catch { - os_log("设置语音参数失败: %{public}@", log: log, type: .error, error.localizedDescription) - notifyEvent(eventType: .error, params: [ - "errorCode": "PARAMS_SET_FAILED", - "errorMessage": "设置语音参数失败: \(error.localizedDescription)" - ]) - return false + synthesisQueue.async { + _ = try? self.synthesizer?.startSpeakingSsml(warmupSsml) } } /** - * 格式化百分比值 + * 清理TTS文本 */ - private func formatPercentage(_ value: Int) -> String { - return value >= 0 ? "+\(value)%" : "\(value)%" + private func cleanTextForTTS(_ text: String) -> String { + var cleaned = text + + // 移除URL + if let regex = try? NSRegularExpression(pattern: "https?://\\S+", options: .caseInsensitive) { + cleaned = regex.stringByReplacingMatches(in: cleaned, range: NSRange(location: 0, length: cleaned.count), withTemplate: "") + } + + // 移除emoji + if let regex = try? NSRegularExpression(pattern: "[\\uD83C-\\uDBFF\\uDC00-\\uDFFF]+", options: .caseInsensitive) { + cleaned = regex.stringByReplacingMatches(in: cleaned, range: NSRange(location: 0, length: cleaned.count), withTemplate: "") + } + + // 合并空格 + if let regex = try? NSRegularExpression(pattern: "\\s+", options: .caseInsensitive) { + cleaned = regex.stringByReplacingMatches(in: cleaned, range: NSRange(location: 0, length: cleaned.count), withTemplate: " ") + } + + return cleaned.trimmingCharacters(in: .whitespacesAndNewlines) } /** - * 生成SSML + * 生成优化的SSML */ - private func generateSsml(_ rawText: String) -> String { - // 1. 定义要静音的符号和表情符号列表 - let symbolsToMute = [ - "#", "*", - "😀", "😂", "😊", "😍", "😢", "😎", "😉", "👍", "🙌", "🎉" - ] - - // 2. 转义 XML 保留字符 - var escapedText = rawText + private func generateOptimizedSsml(_ rawText: String) -> String { + // 转义XML保留字符 + let escapedText = rawText .replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") - // 3. 静音处理特殊符号和表情符号 - // 使用空白替换法,直接将符号替换为空字符串 - var processedText = escapedText - for symbol in symbolsToMute { - processedText = processedText.replacingOccurrences(of: symbol, with: "") - } - - // 4. 构造简化的SSML文档,减少嵌套层级 - let ssml = """ - + // 简化SSML结构 + return """ + - - - \(processedText) - - + + \(escapedText) + """ - - return ssml + } + + /** + * 设置语音参数 + */ + private func setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100) -> Bool { + currentRate = rate >= 0 ? "+\(rate)%" : "\(rate)%" + currentPitch = pitch >= 0 ? "+\(pitch)%" : "\(pitch)%" + currentVolume = "\(min(max(volume, 0), 100))%" + return true + } + + /** + * 是否正在播放 + */ + public func isSpeaking() -> Bool { + return speaking } } diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/RecordFile.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/RecordFile.swift new file mode 100644 index 000000000..ba6733fc9 --- /dev/null +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/RecordFile.swift @@ -0,0 +1,233 @@ +import Foundation + +public class RecordFile { + static let shared = RecordFile() + + private var currentAudioFile: URL? + private var fileHandle: FileHandle? + private let writeQueue = DispatchQueue(label: "audio.write.queue") + private let writeGroup = DispatchGroup() + private var isWriting = false + private var dataBuffer = Data() + private var totalBytesWritten = 0 + private let sampleRate: UInt32 = 16000 + private var lastSavedFile: URL? + public var isPause = false + var fileName = "" + public init() {} + // 创建音频文件并初始化 WAV 头 + public func creatingFiles(atPath path: String) { + guard fileHandle == nil else { return } + + let fileURL = URL(fileURLWithPath: path) + let directoryURL = fileURL.deletingLastPathComponent() + print("创建音频文件path: \(path)") + // 创建目录 + do { + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + } catch { + print("创建目录失败: \(error.localizedDescription)") + return + } + + // 创建文件 + FileManager.default.createFile(atPath: path, contents: nil) + + do { + fileHandle = try FileHandle(forWritingTo: fileURL) + currentAudioFile = fileURL + + // 写入初始 WAV 头 + let header = generateWavHeader(dataLength: 0) + try fileHandle?.write(contentsOf: header) + totalBytesWritten = 0 + } catch { + print("文件初始化失败: \(error.localizedDescription)") + fileHandle = nil + currentAudioFile = nil + } + } + + // 异步保存音频数据 + public func saveAudioDataToWav(_ data: Data) { + guard let _ = fileHandle, let _ = currentAudioFile else { return } + + writeQueue.async { [weak self] in + guard let self = self else { return } + self.dataBuffer.append(data) + self.totalBytesWritten += data.count + + if !self.isWriting { + self.isWriting = true + self.writeGroup.enter() + + defer { + self.isWriting = false + self.writeGroup.leave() + } + + while !self.dataBuffer.isEmpty { + let chunk: Data + if self.dataBuffer.count > 8192 { + chunk = self.dataBuffer.prefix(8192) + self.dataBuffer.removeFirst(8192) + } else { + chunk = self.dataBuffer + self.dataBuffer.removeAll() + } + + do { + try self.fileHandle?.seekToEnd() + self.fileHandle?.write(chunk) + } catch { + print("写入数据失败: \(error.localizedDescription)") + } + } + } + } + } + + // 生成 WAV 文件头 + private func generateWavHeader(dataLength: Int) -> Data { + let totalLength = 36 + dataLength + let byteRate = sampleRate * 2 + + var header = Data() + header.append("RIFF".data(using: .ascii)!) + header.append(Data(bytes: [ + UInt8(totalLength & 0xFF), + UInt8((totalLength >> 8) & 0xFF), + UInt8((totalLength >> 16) & 0xFF), + UInt8((totalLength >> 24) & 0xFF) + ])) + header.append("WAVE".data(using: .ascii)!) + header.append("fmt ".data(using: .ascii)!) + header.append(Data(bytes: [16, 0, 0, 0])) // PCM 头长度 + header.append(Data(bytes: [1, 0])) // PCM 格式 + header.append(Data(bytes: [1, 0])) // 单声道 + header.append(Data(bytes: [ + UInt8(sampleRate & 0xFF), + UInt8((sampleRate >> 8) & 0xFF), + UInt8((sampleRate >> 16) & 0xFF), + UInt8((sampleRate >> 24) & 0xFF) + ])) + header.append(Data(bytes: [ + UInt8(byteRate & 0xFF), + UInt8((byteRate >> 8) & 0xFF), + UInt8((byteRate >> 16) & 0xFF), + UInt8((byteRate >> 24) & 0xFF) + ])) + header.append(Data(bytes: [2, 0])) // 块对齐 + header.append(Data(bytes: [16, 0])) // 样本位数 + header.append("data".data(using: .ascii)!) + header.append(Data(bytes: [ + UInt8(dataLength & 0xFF), + UInt8((dataLength >> 8) & 0xFF), + UInt8((dataLength >> 16) & 0xFF), + UInt8((dataLength >> 24) & 0xFF) + ])) + + return header + } + + // 重命名文件 + public func renameFile(at sourcePath: String, to newName: String) -> Bool { + let sourceURL = URL(fileURLWithPath: sourcePath) + + guard FileManager.default.fileExists(atPath: sourcePath) else { + print("重命名失败: 文件不存在 \(sourcePath)") + return false + } + + if let current = currentAudioFile, current == sourceURL { + print("重命名失败: 不能重命名正在录制的文件") + return false + } + + let newFileURL = sourceURL + .deletingLastPathComponent() + .appendingPathComponent(newName) + .appendingPathExtension("wav") + + do { + try FileManager.default.moveItem(at: sourceURL, to: newFileURL) + if lastSavedFile == sourceURL { + lastSavedFile = newFileURL + } + return true + } catch { + print("重命名异常: \(error.localizedDescription)") + return false + } + } + + // 移动文件 + public func moveFile(from sourcePath: String, to destPath: String) -> Bool { + let sourceURL = URL(fileURLWithPath: sourcePath) + let destURL = URL(fileURLWithPath: destPath) + + guard FileManager.default.fileExists(atPath: sourcePath) else { + print("移动失败: 源文件不存在 \(sourcePath)") + return false + } + + let destDir = destURL.deletingLastPathComponent() + do { + try FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) + } catch { + print("创建目录失败: \(error.localizedDescription)") + return false + } + + do { + try FileManager.default.moveItem(at: sourceURL, to: destURL) + if lastSavedFile == sourceURL { + lastSavedFile = destURL + } + return true + } catch { + print("移动文件异常: \(error.localizedDescription)") + return false + } + } + + // 关闭文件 + public func closeFile(isSave: Bool) -> Bool { + defer { + fileHandle = nil + currentAudioFile = nil + dataBuffer.removeAll() + totalBytesWritten = 0 + } + + guard let fileURL = currentAudioFile, let handle = fileHandle else { + return false + } + + // 等待写入完成 + writeGroup.wait() + + do { + try handle.close() + + if !isSave || totalBytesWritten == 0 { + try FileManager.default.removeItem(at: fileURL) + return false + } + + // 更新 WAV 头 + let header = generateWavHeader(dataLength: totalBytesWritten) + let updateHandle = try FileHandle(forUpdating: fileURL) + try updateHandle.seek(toOffset: 0) + updateHandle.write(header) + try updateHandle.close() + + lastSavedFile = fileURL + print("关闭文件path: \(fileURL)") + return true + } catch { + print("关闭文件失败: \(error.localizedDescription)") + return false + } + } +} \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index 999dd7efc..497968ed0 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -357,7 +357,18 @@ object BleService { } return null; } + fun getDeviceInfos(deviceName: String): List>? { + // 更新缓存 + val matchedResults = scanResults.filter { + it.device.name == deviceName + } + Log.d(TAG, "找到 ${matchedResults.size} 个名称为 $deviceName 的设备") + if (matchedResults.isEmpty()) { + return emptyList() + } + return null; + } /** * 打印设备信息和服务UUID */ diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt index c70581d54..0e43d9188 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt @@ -305,6 +305,12 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, Log.d(TAG, "getDeviceInfo $info") result.success(info) } + "getDeviceList" -> { + val deviceName = call.argument("deviceName")?:"" + val infos = BleService.getDeviceInfos(deviceName); + Log.d(TAG, "getDeviceList $infos") + result.success(infos) + } "disconnect" -> { BleService.disconnect(); result.success(null) diff --git a/local_plugins/ble_service/ios/ble_service/Package.swift b/local_plugins/ble_service/ios/ble_service/Package.swift index 35f624ccc..ca43c17a7 100644 --- a/local_plugins/ble_service/ios/ble_service/Package.swift +++ b/local_plugins/ble_service/ios/ble_service/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "ble_service", - platforms: [.iOS("18.0")], + platforms: [.iOS("16.0")], products: [ .library(name: "ble-service", targets: ["ble_service"]) ], diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleAccessorySetup.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleAccessorySetup.swift index 3cf85910e..07f6803d0 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleAccessorySetup.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleAccessorySetup.swift @@ -1,306 +1,306 @@ -import Foundation -import CoreBluetooth -import os.log -import AVFoundation -import AccessorySetupKit -import UIKit +// import Foundation +// import CoreBluetooth +// import os.log +// import AVFoundation +// import AccessorySetupKit +// import UIKit -@available(iOS 18.0, *) -class BleAccessorySetup: NSObject { - // MARK: - 属性 - static let shared = BleAccessorySetup() +// @available(iOS 18.0, *) +// class BleAccessorySetup: NSObject { +// // MARK: - 属性 +// static let shared = BleAccessorySetup() - private var bleService: BleService? - private var session: ASAccessorySession? - private var isSessionActivated = false - private var isInitializing = false - private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleAccessorySetup") +// private var bleService: BleService? +// private var session: ASAccessorySession? +// private var isSessionActivated = false +// private var isInitializing = false +// private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleAccessorySetup") - // 杰理蓝牙设备的公司标识符 - private let deviceCompanyIdentifier: UInt16 = 0xEFAB - private let deviceDisplayName = "语音助理耳机" +// // 杰理蓝牙设备的公司标识符 +// private let deviceCompanyIdentifier: UInt16 = 0xEFAB +// private let deviceDisplayName = "语音助理耳机" - // 用于存储配对设备UUID的UserDefaults键 - private let pairedDeviceUUIDKey = "com.yunqiinnovation.ble_service.paired_device_uuid" +// // 用于存储配对设备UUID的UserDefaults键 +// private let pairedDeviceUUIDKey = "com.yunqiinnovation.ble_service.paired_device_uuid" - typealias DeviceDiscoveredCallback = (UUID) -> Void - private var onDeviceDiscovered: DeviceDiscoveredCallback? +// typealias DeviceDiscoveredCallback = (UUID) -> Void +// private var onDeviceDiscovered: DeviceDiscoveredCallback? - // MARK: - 初始化 - private override init() { super.init() } +// // MARK: - 初始化 +// private override init() { super.init() } - // MARK: - 公共方法 - func setBleService(_ service: Any) { - self.bleService = service as? BleService - } +// // MARK: - 公共方法 +// func setBleService(_ service: Any) { +// self.bleService = service as? BleService +// } - func setDeviceDiscoveredCallback(_ callback: @escaping (UUID) -> Void) { - self.onDeviceDiscovered = callback - } +// func setDeviceDiscoveredCallback(_ callback: @escaping (UUID) -> Void) { +// self.onDeviceDiscovered = callback +// } - // 处理发现的设备并连接 - func connectToAccessory(uuid: UUID) { - guard let service = bleService else { - return - } +// // 处理发现的设备并连接 +// func connectToAccessory(uuid: UUID) { +// guard let service = bleService else { +// return +// } - service.connectToAccessory(uuid: uuid) - } +// service.connectToAccessory(uuid: uuid) +// } - func addToReconnectQueue(uuid: UUID) { - guard let service = bleService else { - return - } +// func addToReconnectQueue(uuid: UUID) { +// guard let service = bleService else { +// return +// } - service.addToReconnectQueue(uuid: uuid) - } +// service.addToReconnectQueue(uuid: uuid) +// } - // 从视图控制器显示设备选择器 - 主要入口点 - func showDevicePickerFromViewController(_ viewController: UIViewController) { - initializeSession() - } +// // 从视图控制器显示设备选择器 - 主要入口点 +// func showDevicePickerFromViewController(_ viewController: UIViewController) { +// initializeSession() +// } - // 清除蓝牙状态(仅用于测试或重置,不会清除系统授权) - func resetBluetoothState() { - if let service = bleService { - service.disconnect() - } - } +// // 清除蓝牙状态(仅用于测试或重置,不会清除系统授权) +// func resetBluetoothState() { +// if let service = bleService { +// service.disconnect() +// } +// } - // MARK: - 会话管理 - func initializeSession() { - if isInitializing { - os_log("会话正在初始化中,跳过", log: logger, type: .info) - return - } +// // MARK: - 会话管理 +// func initializeSession() { +// if isInitializing { +// os_log("会话正在初始化中,跳过", log: logger, type: .info) +// return +// } - // 如果会话已激活,检查是否有配对设备 - if session != nil && isSessionActivated { - os_log("会话已激活,检查配对设备", log: logger, type: .info) - // 会话已激活,检查是否有配对设备 - if let accessories = session?.accessories, !accessories.isEmpty { - os_log("发现 %d 个已配对设备,尝试连接", log: logger, type: .info, accessories.count) - // 有配对设备,尝试连接 - if let service = bleService { - let _ = service.getCentralManager() +// // 如果会话已激活,检查是否有配对设备 +// if session != nil && isSessionActivated { +// os_log("会话已激活,检查配对设备", log: logger, type: .info) +// // 会话已激活,检查是否有配对设备 +// if let accessories = session?.accessories, !accessories.isEmpty { +// os_log("发现 %d 个已配对设备,尝试连接", log: logger, type: .info, accessories.count) +// // 有配对设备,尝试连接 +// if let service = bleService { +// let _ = service.getCentralManager() - for accessory in accessories { - if let uuid = accessory.bluetoothIdentifier { - onDeviceDiscovered?(uuid) - connectToAccessory(uuid: uuid) - } - } - } - } else { - os_log("没有已配对设备,显示配对框", log: logger, type: .info) - // 没有配对设备,直接显示配对框 - showDevicePicker() - } - return - } +// for accessory in accessories { +// if let uuid = accessory.bluetoothIdentifier { +// onDeviceDiscovered?(uuid) +// connectToAccessory(uuid: uuid) +// } +// } +// } +// } else { +// os_log("没有已配对设备,显示配对框", log: logger, type: .info) +// // 没有配对设备,直接显示配对框 +// showDevicePicker() +// } +// return +// } - os_log("开始初始化新会话", log: logger, type: .info) - isInitializing = true +// os_log("开始初始化新会话", log: logger, type: .info) +// isInitializing = true - do { - session = try ASAccessorySession() - session?.activate(on: .main) { [weak self] event in - guard let self = self else { return } - self.handleSessionEvent(event) - } - } catch { - os_log("会话初始化失败: %{public}@", log: logger, type: .error, error.localizedDescription) - handleSessionInitError(error) - } - } +// do { +// session = try ASAccessorySession() +// session?.activate(on: .main) { [weak self] event in +// guard let self = self else { return } +// self.handleSessionEvent(event) +// } +// } catch { +// os_log("会话初始化失败: %{public}@", log: logger, type: .error, error.localizedDescription) +// handleSessionInitError(error) +// } +// } - func resetSession() { - session?.invalidate() - session = nil - isSessionActivated = false - isInitializing = false - } +// func resetSession() { +// session?.invalidate() +// session = nil +// isSessionActivated = false +// isInitializing = false +// } - // MARK: - 设备选择器显示 - private func showDevicePicker() { - guard let session = session, isSessionActivated else { - os_log("无法显示配对框:会话未激活", log: logger, type: .error) - return - } +// // MARK: - 设备选择器显示 +// private func showDevicePicker() { +// guard let session = session, isSessionActivated else { +// os_log("无法显示配对框:会话未激活", log: logger, type: .error) +// return +// } - os_log("准备显示设备配对框", log: logger, type: .info) +// os_log("准备显示设备配对框", log: logger, type: .info) - let descriptor = ASDiscoveryDescriptor() - descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(deviceCompanyIdentifier) +// let descriptor = ASDiscoveryDescriptor() +// descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(deviceCompanyIdentifier) - let deviceImage = UIImage(systemName: "headphones") ?? UIImage() - let displayItem = ASPickerDisplayItem( - name: deviceDisplayName, - productImage: deviceImage, - descriptor: descriptor - ) +// let deviceImage = UIImage(systemName: "headphones") ?? UIImage() +// let displayItem = ASPickerDisplayItem( +// name: deviceDisplayName, +// productImage: deviceImage, +// descriptor: descriptor +// ) - DispatchQueue.main.async { - os_log("正在显示设备配对框", log: self.logger, type: .info) - session.showPicker(for: [displayItem]) { [weak self] error in - guard let self = self else { return } +// DispatchQueue.main.async { +// os_log("正在显示设备配对框", log: self.logger, type: .info) +// session.showPicker(for: [displayItem]) { [weak self] error in +// guard let self = self else { return } - if let error = error { - let nsError = error as NSError - os_log("设备选择器错误: %{public}@", log: self.logger, type: .error, error.localizedDescription) - } else { - os_log("设备选择器完成", log: self.logger, type: .info) - } - } - } - } +// if let error = error { +// let nsError = error as NSError +// os_log("设备选择器错误: %{public}@", log: self.logger, type: .error, error.localizedDescription) +// } else { +// os_log("设备选择器完成", log: self.logger, type: .info) +// } +// } +// } +// } - // MARK: - 私有辅助方法 - private func handleSessionEvent(_ event: ASAccessoryEvent) { - switch event.eventType { - case .activated: - os_log("会话已激活", log: logger, type: .info) - handleSessionActivated() +// // MARK: - 私有辅助方法 +// private func handleSessionEvent(_ event: ASAccessoryEvent) { +// switch event.eventType { +// case .activated: +// os_log("会话已激活", log: logger, type: .info) +// handleSessionActivated() - case .accessoryAdded: - os_log("发现新设备: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") - handleAccessoryAdded(event.accessory) +// case .accessoryAdded: +// os_log("发现新设备: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") +// handleAccessoryAdded(event.accessory) - case .accessoryRemoved: - os_log("设备已移除: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") - handleAccessoryRemoved(event.accessory) +// case .accessoryRemoved: +// os_log("设备已移除: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") +// handleAccessoryRemoved(event.accessory) - case .accessoryChanged: - os_log("设备状态已改变", log: logger, type: .info) - break +// case .accessoryChanged: +// os_log("设备状态已改变", log: logger, type: .info) +// break - case .pickerDidPresent: - os_log("配对框已显示", log: logger, type: .info) - break +// case .pickerDidPresent: +// os_log("配对框已显示", log: logger, type: .info) +// break - case .pickerDidDismiss: - os_log("配对框已关闭", log: logger, type: .info) - break +// case .pickerDidDismiss: +// os_log("配对框已关闭", log: logger, type: .info) +// break - case .pickerSetupFailed: - os_log("选择器设置失败: %{public}@", log: logger, type: .error, - event.error?.localizedDescription ?? "未知错误") +// case .pickerSetupFailed: +// os_log("选择器设置失败: %{public}@", log: logger, type: .error, +// event.error?.localizedDescription ?? "未知错误") - default: - os_log("未处理的会话事件: %d", log: logger, type: .info, event.eventType.rawValue) - break - } - } +// default: +// os_log("未处理的会话事件: %d", log: logger, type: .info, event.eventType.rawValue) +// break +// } +// } - private func handleSessionActivated() { - isSessionActivated = true - isInitializing = false +// private func handleSessionActivated() { +// isSessionActivated = true +// isInitializing = false - if let accessories = session?.accessories, !accessories.isEmpty { - if let service = bleService { - let _ = service.getCentralManager() +// if let accessories = session?.accessories, !accessories.isEmpty { +// if let service = bleService { +// let _ = service.getCentralManager() - for accessory in accessories { - if let uuid = accessory.bluetoothIdentifier { - onDeviceDiscovered?(uuid) - connectToAccessory(uuid: uuid) - } - } - } - } else { - showDevicePicker() - } - } +// for accessory in accessories { +// if let uuid = accessory.bluetoothIdentifier { +// onDeviceDiscovered?(uuid) +// connectToAccessory(uuid: uuid) +// } +// } +// } +// } else { +// showDevicePicker() +// } +// } - private func handleAccessoryAdded(_ accessory: ASAccessory?) { - guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { - return - } +// private func handleAccessoryAdded(_ accessory: ASAccessory?) { +// guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { +// return +// } - // 保存配对设备的UUID - savePairedMacAddress(uuid) +// // 保存配对设备的UUID +// savePairedMacAddress(uuid) - onDeviceDiscovered?(uuid) - connectToAccessory(uuid: uuid) - } +// onDeviceDiscovered?(uuid) +// connectToAccessory(uuid: uuid) +// } - private func handleAccessoryRemoved(_ accessory: ASAccessory?) { - guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { - return - } +// private func handleAccessoryRemoved(_ accessory: ASAccessory?) { +// guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { +// return +// } - if let service = bleService { - service.disconnect() - } - } +// if let service = bleService { +// service.disconnect() +// } +// } - private func handleSessionInitError(_ error: Error) { - isInitializing = false - session = nil - } +// private func handleSessionInitError(_ error: Error) { +// isInitializing = false +// session = nil +// } - // MARK: - 蓝牙状态检查 - func isBluetoothAvailable() -> Bool { - do { - let audioSession = AVAudioSession.sharedInstance() - let outputs = audioSession.currentRoute.outputs +// // MARK: - 蓝牙状态检查 +// func isBluetoothAvailable() -> Bool { +// do { +// let audioSession = AVAudioSession.sharedInstance() +// let outputs = audioSession.currentRoute.outputs - for output in outputs { - if output.portType == .bluetoothA2DP || - output.portType == .bluetoothHFP || - output.portType == .bluetoothLE { - return true - } - } - } catch { - os_log("检查蓝牙状态出错: %{public}@", log: logger, type: .error, error.localizedDescription) - } +// for output in outputs { +// if output.portType == .bluetoothA2DP || +// output.portType == .bluetoothHFP || +// output.portType == .bluetoothLE { +// return true +// } +// } +// } catch { +// os_log("检查蓝牙状态出错: %{public}@", log: logger, type: .error, error.localizedDescription) +// } - return true - } +// return true +// } - // MARK: - 清除配对关系 - func clearAssociations() { - resetBluetoothState() +// // MARK: - 清除配对关系 +// func clearAssociations() { +// resetBluetoothState() - // 清除保存的配对设备UUID - clearPairedMacAddress() +// // 清除保存的配对设备UUID +// clearPairedMacAddress() - #if canImport(AccessorySetupKit) && swift(>=5.9) - if #available(iOS 18.0, *) { - resetSession() - initializeSession() - } - #endif - } +// #if canImport(AccessorySetupKit) && swift(>=5.9) +// if #available(iOS 18.0, *) { +// resetSession() +// initializeSession() +// } +// #endif +// } - // MARK: - 配对设备UUID管理 +// // MARK: - 配对设备UUID管理 - /// 获取已配对设备的UUID字符串 - /// 返回存储的配对设备UUID,如果没有则返回nil - func getPairedMacAddress() -> String? { - let uuidString = UserDefaults.standard.string(forKey: pairedDeviceUUIDKey) - if let uuidString = uuidString, !uuidString.isEmpty { - os_log("获取已配对设备UUID: %{public}@", log: logger, type: .info, uuidString) - return uuidString - } - os_log("未找到已配对设备UUID", log: logger, type: .info) - return nil - } +// /// 获取已配对设备的UUID字符串 +// /// 返回存储的配对设备UUID,如果没有则返回nil +// func getPairedMacAddress() -> String? { +// let uuidString = UserDefaults.standard.string(forKey: pairedDeviceUUIDKey) +// if let uuidString = uuidString, !uuidString.isEmpty { +// os_log("获取已配对设备UUID: %{public}@", log: logger, type: .info, uuidString) +// return uuidString +// } +// os_log("未找到已配对设备UUID", log: logger, type: .info) +// return nil +// } - /// 保存配对设备的UUID - /// - Parameter uuid: 要保存的设备UUID - func savePairedMacAddress(_ uuid: UUID) { - let uuidString = uuid.uuidString - UserDefaults.standard.set(uuidString, forKey: pairedDeviceUUIDKey) - UserDefaults.standard.synchronize() - os_log("已保存配对设备UUID: %{public}@", log: logger, type: .info, uuidString) - } +// /// 保存配对设备的UUID +// /// - Parameter uuid: 要保存的设备UUID +// func savePairedMacAddress(_ uuid: UUID) { +// let uuidString = uuid.uuidString +// UserDefaults.standard.set(uuidString, forKey: pairedDeviceUUIDKey) +// UserDefaults.standard.synchronize() +// os_log("已保存配对设备UUID: %{public}@", log: logger, type: .info, uuidString) +// } - /// 清除已保存的配对设备UUID - private func clearPairedMacAddress() { - UserDefaults.standard.removeObject(forKey: pairedDeviceUUIDKey) - UserDefaults.standard.synchronize() - os_log("已清除配对设备UUID", log: logger, type: .info) - } -} \ No newline at end of file +// /// 清除已保存的配对设备UUID +// private func clearPairedMacAddress() { +// UserDefaults.standard.removeObject(forKey: pairedDeviceUUIDKey) +// UserDefaults.standard.synchronize() +// os_log("已清除配对设备UUID", log: logger, type: .info) +// } +// } \ No newline at end of file diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleScanManager.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleScanManager.swift new file mode 100644 index 000000000..692984773 --- /dev/null +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleScanManager.swift @@ -0,0 +1,586 @@ +// import Foundation +// import CoreBluetooth +// import UIKit // 添加UIKit导入 +// import os.log + +// /// 简化的 BLE 扫描管理器 +// /// 只提供扫描和获取设备列表功能 +// @available(iOS 13.0, *) +// public class BleScanManager: NSObject { + +// // MARK: - 单例 +// public static let shared = BleScanManager() + +// // MARK: - 数据模型 +// public struct BleDeviceInfo { +// public let identifier: UUID +// public let name: String? +// public let rssi: NSNumber +// public let advertisementData: [String: Any] +// public let discoveryMethod: DiscoveryMethod +// public let lastSeen: Date + +// public enum DiscoveryMethod { +// case traditional +// case accessorySetupKit +// } +// } + +// // MARK: - 属性 +// private var centralManager: CBCentralManager! +// private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleScanManager") + +// // 扫描状态 +// private var isScanning = false +// private var scanTimeout: Timer? + +// // 设备列表 +// private var discoveredDevices: [UUID: BleDeviceInfo] = [:] + + +// // MARK: - 初始化 +// private override init() { +// super.init() +// setupCentralManager() +// } + +// // 在setupCentralManager后添加权限检查 +// private func setupCentralManager() { +// let options: [String: Any] = [ +// CBCentralManagerOptionShowPowerAlertKey: true, +// CBCentralManagerOptionRestoreIdentifierKey: "BleScanManager" +// ] + +// centralManager = CBCentralManager(delegate: self, queue: nil, options: options) + +// // 添加权限检查和触发逻辑 +// DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in +// self?.checkAndTriggerPermission() +// } +// } + +// private func checkAndTriggerPermission() { +// // 检查蓝牙权限状态,兼容不同iOS版本 +// let authorization: CBManagerAuthorization +// if #available(iOS 13.1, *) { +// authorization = CBManager.authorization +// } else if #available(iOS 13.0, *) { +// // iOS 13.0 使用实例方法,需要确保centralManager已初始化 +// authorization = centralManager?.authorization ?? .notDetermined +// } else { +// // iOS 13.0以下版本没有权限检查API,默认为未确定 +// authorization = .notDetermined +// } + +// os_log("📊 蓝牙权限状态: %d", log: logger, type: .info, authorization.rawValue) +// os_log("📊 CBCentralManager状态: %d", log: logger, type: .info, centralManager?.state.rawValue ?? -1) + +// // 只在权限未确定且蓝牙状态为关闭时触发扫描 +// if authorization == .notDetermined && centralManager?.state == .poweredOff { +// os_log("📊 主动触发一次扫描来申请权限", log: logger, type: .info) +// // 主动触发一次扫描来申请权限 +// centralManager?.scanForPeripherals(withServices: nil, options: nil) +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in +// self?.centralManager?.stopScan() +// } +// } +// } + + + +// // MARK: - 公共接口 + +// /// 开始扫描 BLE 设备 +// /// - Parameter timeout: 扫描超时时间,默认 10 秒 +// /// - Returns: 是否成功开始扫描 +// public func startScan(timeout: TimeInterval = 30.0) -> Bool { +// os_log("🔍 开始 BLE 扫描,超时时间: %.1f 秒", log: logger, type: .info, timeout) + +// guard !isScanning else { +// os_log("⚠️ 扫描已在进行中", log: logger, type: .info) +// return false +// } + +// // 清空之前的设备列表 +// discoveredDevices.removeAll() + +// // iOS 18 兼容性检查 +// if #available(iOS 18.0, *) { +// // iOS 18 使用新的权限检查方式 +// return startScanForIOS18(timeout: timeout) +// } else { +// // iOS 17 及以下使用传统方式 +// return startScanLegacy(timeout: timeout) +// } +// } + +// @available(iOS 18.0, *) +// private func startScanForIOS18(timeout: TimeInterval) -> Bool { +// // 对于 iOS 18,直接检查 CBCentralManager 状态 +// guard let centralManager = centralManager else { +// os_log("❌ CBCentralManager 未初始化", log: logger, type: .error) +// return false +// } + +// let currentState = centralManager.state +// os_log("📊 iOS 18 蓝牙状态: %d (%{public}@)", log: logger, type: .info, currentState.rawValue, getBluetoothStateString(currentState)) + +// switch currentState { +// case .poweredOn: +// return performScanWithPermission(timeout: timeout) +// case .unauthorized: +// os_log("❌ 蓝牙权限被拒绝", log: logger, type: .error) +// showPermissionAlert() +// return false +// case .unknown: +// os_log("⚠️ 蓝牙状态未知,等待状态更新", log: logger, type: .info) +// // 设置监听器等待状态更新 +// waitForBluetoothState(timeout: timeout) +// return true +// default: +// os_log("❌ 蓝牙不可用: %{public}@", log: logger, type: .error, getBluetoothStateString(currentState)) +// return false +// } +// } + +// private func startScanLegacy(timeout: TimeInterval) -> Bool { +// // 检查并申请蓝牙权限 +// let authorization: CBManagerAuthorization = CBManager.authorization +// os_log("📊 当前蓝牙权限状态: %d", log: logger, type: .info, authorization.rawValue) + +// switch authorization { +// case .allowedAlways: +// return performScanWithPermission(timeout: timeout) +// case .denied, .restricted: +// os_log("❌ 蓝牙权限被拒绝或受限", log: logger, type: .error) +// showPermissionAlert() +// return false +// case .notDetermined: +// // 权限未确定,需要主动触发蓝牙操作来显示权限弹窗 +// if centralManager == nil { +// setupCentralManager() +// } + +// // 等待CBCentralManager状态更新后,主动开始扫描来触发权限弹窗 +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in +// guard let self = self else { return } +// if self.centralManager.state == .poweredOn { +// // 开始一个短暂的扫描来触发权限申请 +// self.centralManager.scanForPeripherals(withServices: nil, options: nil) +// // 立即停止扫描 +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { +// self.centralManager.stopScan() +// } +// } else if self.centralManager.state == .unauthorized { +// // 权限被拒绝,显示设置提示 +// self.showPermissionAlert() +// } +// } +// return true +// @unknown default: +// os_log("⚠️ 未知的蓝牙权限状态", log: logger, type: .info) +// return false +// } +// } + +// private func waitForBluetoothState(timeout: TimeInterval) { +// var checkCount = 0 +// let maxChecks = 20 + +// let timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in +// guard let self = self else { +// timer.invalidate() +// return +// } + +// checkCount += 1 +// let currentState = self.centralManager?.state ?? .unknown + +// os_log("🔍 检查蓝牙状态 (%d/%d): %{public}@", log: self.logger, type: .info, checkCount, maxChecks, self.getBluetoothStateString(currentState)) + +// switch currentState { +// case .poweredOn: +// timer.invalidate() +// _ = self.performScanWithPermission(timeout: timeout) +// case .unauthorized: +// timer.invalidate() +// self.showPermissionAlert() +// case .poweredOff, .unsupported: +// timer.invalidate() +// os_log("❌ 蓝牙不可用", log: self.logger, type: .error) +// default: +// if checkCount >= maxChecks { +// timer.invalidate() +// os_log("⏰ 等待蓝牙状态超时", log: self.logger, type: .error) +// } +// } +// } +// } + +// private func forceTriggerPermissionRequest(timeout: TimeInterval) { +// // 完全清理旧的 centralManager +// centralManager?.delegate = nil +// centralManager = nil + +// // 强制重新初始化,确保触发权限申请 +// let options: [String: Any] = [ +// CBCentralManagerOptionShowPowerAlertKey: true, +// CBCentralManagerOptionRestoreIdentifierKey: "BleScanManager" +// ] + +// centralManager = CBCentralManager(delegate: self, queue: nil, options: options) + +// // 设置一个监听器来等待权限状态更新 +// waitForPermissionResponse(timeout: timeout) +// } + +// private func waitForPermissionResponse(timeout: TimeInterval) { +// // 设置一个定时器来检查权限状态变化 +// var checkCount = 0 +// let maxChecks = 20 // 最多检查20次,每次间隔0.5秒,总共10秒 + +// let timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in +// guard let self = self else { +// timer.invalidate() +// return +// } + +// checkCount += 1 +// let currentAuth = CBManager.authorization + +// os_log("🔍 检查权限状态 (%d/%d): %d", log: self.logger, type: .info, checkCount, maxChecks, currentAuth.rawValue) + +// switch currentAuth { +// case .allowedAlways: +// timer.invalidate() +// os_log("✅ 蓝牙权限已获得,开始扫描", log: self.logger, type: .info) +// _ = self.performScanWithPermission(timeout: timeout) +// case .denied, .restricted: +// timer.invalidate() +// os_log("❌ 蓝牙权限被拒绝", log: self.logger, type: .error) +// self.showPermissionAlert() +// case .notDetermined: +// if checkCount >= maxChecks { +// timer.invalidate() +// os_log("⏰ 等待权限响应超时", log: self.logger, type: .error) +// self.showPermissionAlert() +// } +// // 继续等待 +// @unknown default: +// timer.invalidate() +// os_log("⚠️ 未知权限状态", log: self.logger, type: .error) +// } +// } +// } + +// private func reinitializeCentralManager() { +// // 清理旧的 centralManager +// centralManager?.delegate = nil +// centralManager = nil + +// // 重新初始化 +// setupCentralManager() +// } + +// /// 检查蓝牙权限状态 +// private func checkBluetoothPermissionStatus() -> (hasPermission: Bool, needsRequest: Bool) { +// // 检查权限状态 +// let authorization: CBManagerAuthorization +// if #available(iOS 13.1, *) { +// authorization = CBManager.authorization +// } else if #available(iOS 13.0, *) { +// if centralManager == nil { +// setupCentralManager() +// } +// authorization = centralManager?.authorization ?? .notDetermined +// } else { +// // iOS 13以下没有权限概念 +// return (true, false) +// } + +// os_log("📊 蓝牙权限状态: %d, CBCentralManager状态: %d", log: logger, type: .info, authorization.rawValue, centralManager?.state.rawValue ?? -1) + +// switch authorization { +// case .allowedAlways: +// return (true, false) +// case .denied, .restricted: +// return (false, false) +// case .notDetermined: +// return (false, true) +// @unknown default: +// return (false, true) +// } +// } + +// private func performScanWithPermission(timeout: TimeInterval) -> Bool { +// // 首先检查权限状态 +// let permissionStatus = checkBluetoothPermissionStatus() + +// if !permissionStatus.hasPermission { +// if permissionStatus.needsRequest { +// os_log("🔐 需要申请蓝牙权限", log: logger, type: .info) +// _ = requestBluetoothPermission() +// return false +// } else { +// os_log("❌ 蓝牙权限被拒绝", log: logger, type: .error) +// showPermissionAlert() +// return false +// } +// } + +// // 权限已获得,检查蓝牙硬件状态 +// let currentState = centralManager.state +// os_log("📊 蓝牙硬件状态: %d (%{public}@)", log: logger, type: .info, currentState.rawValue, getBluetoothStateString(currentState)) + +// switch currentState { +// case .poweredOn: +// // 蓝牙已开启,可以扫描 +// break +// case .unknown: +// os_log("⚠️ 蓝牙状态未知,等待状态更新...", log: logger, type: .info) +// DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in +// _ = self?.performScanWithPermission(timeout: timeout) +// } +// return false +// case .poweredOff: +// // 区分是权限问题还是硬件关闭 +// if permissionStatus.hasPermission { +// os_log("❌ 蓝牙硬件已关闭,请在设备设置中开启蓝牙", log: logger, type: .error) +// } else { +// os_log("❌ 可能是权限问题导致的蓝牙状态异常", log: logger, type: .error) +// _ = requestBluetoothPermission() +// } +// return false +// case .unauthorized: +// os_log("❌ 应用未授权使用蓝牙", log: logger, type: .error) +// showPermissionAlert() +// return false +// case .unsupported: +// os_log("❌ 设备不支持蓝牙", log: logger, type: .error) +// return false +// default: +// os_log("⚠️ 其他蓝牙状态: %d", log: logger, type: .error, currentState.rawValue) +// return false +// } + +// // 开始BLE扫描 +// centralManager.scanForPeripherals(withServices: nil, options: [ +// CBCentralManagerScanOptionAllowDuplicatesKey: true +// ]) + +// isScanning = true + +// // 设置扫描超时 +// scanTimeout = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in +// self?.stopScan() +// } + +// os_log("✅ 开始BLE扫描,超时时间: %.1f 秒", log: logger, type: .info, timeout) +// return true +// } + +// private func showPermissionAlert() { +// guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, +// let window = windowScene.windows.first else { +// os_log("❌ 无法获取当前窗口", log: logger, type: .error) +// return +// } + +// let alert = UIAlertController( +// title: "需要蓝牙权限", +// message: "请在设置中开启蓝牙权限以使用设备扫描功能", +// preferredStyle: .alert +// ) + +// alert.addAction(UIAlertAction(title: "去设置", style: .default) { _ in +// if let settingsUrl = URL(string: UIApplication.openSettingsURLString) { +// UIApplication.shared.open(settingsUrl) +// } +// }) + +// alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + +// window.rootViewController?.present(alert, animated: true) +// } + +// /// 停止扫描 +// public func stopScan() { +// guard isScanning else { return } + +// os_log("🛑 停止 BLE 扫描", log: logger, type: .info) + +// centralManager.stopScan() // 直接使用,不需要可选绑定 +// isScanning = false + +// scanTimeout?.invalidate() +// scanTimeout = nil +// } + +// /// 获取已发现的设备列表 +// /// - Returns: 按最后发现时间排序的设备列表 +// public func getDiscoveredDevices() -> [BleDeviceInfo] { +// return Array(discoveredDevices.values).sorted { $0.lastSeen > $1.lastSeen } +// } + +// /// 清除设备列表 +// public func clearDeviceList() { +// discoveredDevices.removeAll() +// os_log("🗑️ 已清除设备列表", log: logger, type: .info) +// } + +// /// 检查是否正在扫描 +// public func isScanningActive() -> Bool { +// return isScanning +// } + +// /// 获取蓝牙状态 +// public func getBluetoothState() -> CBManagerState { +// return centralManager.state // 直接返回,不需要 ?? .unknown +// } + +// // MARK: - 私有方法 + + + +// private func addOrUpdateDevice(_ deviceInfo: BleDeviceInfo) { +// let identifier = deviceInfo.identifier +// discoveredDevices[identifier] = deviceInfo + +// os_log("📱 发现设备: %{public}@", log: logger, type: .debug, deviceInfo.name ?? "Unknown") +// } + +// /// 强制触发蓝牙权限申请 +// public func requestBluetoothPermission() -> Bool { +// os_log("🔐 强制触发蓝牙权限申请", log: logger, type: .info) + +// // 检查当前权限状态 +// let authorization: CBManagerAuthorization +// if #available(iOS 13.1, *) { +// authorization = CBManager.authorization +// } else if #available(iOS 13.0, *) { +// // 确保centralManager已初始化 +// if centralManager == nil { +// setupCentralManager() +// } +// authorization = centralManager.authorization +// } else { +// // iOS 13以下没有权限概念,直接返回true +// return true +// } + +// os_log("📊 当前权限状态: %d", log: logger, type: .info, authorization.rawValue) + +// switch authorization { +// case .notDetermined: +// // 权限未确定,需要主动触发蓝牙操作来显示权限弹窗 +// if centralManager == nil { +// os_log("📊 初始化蓝牙管理对象", log: logger, type: .info, "") +// setupCentralManager() +// } + +// // 等待CBCentralManager状态更新后,主动开始扫描来触发权限弹窗 +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in +// guard let self = self else { return } + +// if self.centralManager.state == .poweredOn { +// // 开始一个短暂的扫描来触发权限申请 +// self.centralManager.scanForPeripherals(withServices: nil, options: nil) +// // 立即停止扫描 +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { +// self.centralManager.stopScan() +// } +// } else if self.centralManager.state == .unauthorized { +// // 权限被拒绝,显示设置提示 +// self.showPermissionAlert() +// } +// } +// return true +// case .allowedAlways: +// os_log("✅ 权限已授予", log: logger, type: .info) +// return true +// case .denied, .restricted: +// os_log("❌ 权限被拒绝,显示设置提示", log: logger, type: .error) +// showPermissionAlert() +// return false +// @unknown default: +// os_log("⚠️ 未知权限状态", log: logger, type: .info) +// return false +// } +// } +// } + +// // MARK: - CBCentralManagerDelegate +// @available(iOS 13.0, *) +// extension BleScanManager: CBCentralManagerDelegate { + +// public func centralManagerDidUpdateState(_ central: CBCentralManager) { +// let stateString = getBluetoothStateString(central.state) +// os_log("🔧 CBCentralManager 状态更新: %{public}@ (原始值: %d)", log: logger, type: .info, stateString, central.state.rawValue) + +// switch central.state { +// case .poweredOn: +// os_log("✅ 蓝牙已开启,可以开始扫描", log: logger, type: .info) +// // 如果有待处理的扫描请求,现在可以执行 +// case .poweredOff: +// os_log("❌ 蓝牙已关闭", log: logger, type: .error) +// if isScanning { +// stopScan() +// } +// case .unauthorized: +// os_log("❌ 蓝牙权限被拒绝", log: logger, type: .error) +// if isScanning { +// stopScan() +// } +// case .unsupported: +// os_log("❌ 设备不支持蓝牙", log: logger, type: .error) +// if isScanning { +// stopScan() +// } +// case .unknown: +// os_log("⚠️ 蓝牙状态未知,等待进一步更新", log: logger, type: .error) +// case .resetting: +// os_log("⚠️ 蓝牙正在重置", log: logger, type: .error) +// if isScanning { +// stopScan() +// } +// @unknown default: +// os_log("⚠️ 未知蓝牙状态: %d", log: logger, type: .error, central.state.rawValue) +// } +// } + +// public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, +// advertisementData: [String : Any], rssi RSSI: NSNumber) { + +// let deviceInfo = BleDeviceInfo( +// identifier: peripheral.identifier, +// name: peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String, +// rssi: RSSI, +// advertisementData: advertisementData, +// discoveryMethod: .traditional, +// lastSeen: Date() +// ) + +// addOrUpdateDevice(deviceInfo) +// } + +// public func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { +// os_log("🔄 恢复 CBCentralManager 状态", log: logger, type: .info) + +// // 恢复扫描状态 +// if dict[CBCentralManagerRestoredStateScanServicesKey] != nil { +// isScanning = true +// } +// } + +// private func getBluetoothStateString(_ state: CBManagerState) -> String { +// switch state { +// case .unknown: return "未知状态" +// case .resetting: return "重置中" +// case .unsupported: return "设备不支持蓝牙" +// case .unauthorized: return "应用未授权使用蓝牙" +// case .poweredOff: return "蓝牙已关闭" +// case .poweredOn: return "蓝牙已开启" +// @unknown default: return "未知状态" +// } +// } +// } diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift index 93673ada8..5c10e0399 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift @@ -3,15 +3,16 @@ import CoreBluetooth import os.log import AVFoundation -/// BLE服务类 - 专为AccessorySetupKit设备设计,支持自动重连和状态恢复 +/// BLE服务类 - 传统BLE扫描和连接实现 /// /// 核心特性: -/// 1. 外设掉电→上电后自动重连(通过pendingReconnectUUIDs队列) -/// 2. App被系统终止后,外设广播或特征通知时自动恢复连接 -/// 3. 使用CBCentralManagerOptionRestoreIdentifierKey实现系统级状态保留 -/// 4. 支持bluetooth-central后台模式,允许后台BLE操作 -/// 5. willRestoreState恢复系统保留的外设、扫描条件 -/// 6. 统一的重连队列管理,适配AccessorySetupKit工作流程 +/// 1. 传统BLE扫描和连接逻辑 +/// 2. 外设掉电→上电后自动重连(通过pendingReconnectUUIDs队列) +/// 3. App被系统终止后,外设广播或特征通知时自动恢复连接 +/// 4. 使用CBCentralManagerOptionRestoreIdentifierKey实现系统级状态保留 +/// 5. 支持bluetooth-central后台模式,允许后台BLE操作 +/// 6. willRestoreState恢复系统保留的外设、扫描条件 +/// 7. 统一的重连队列管理 @available(iOS 13.0, *) public class BleService: NSObject { // MARK: - 单例实现 @@ -43,6 +44,15 @@ public class BleService: NSObject { private var audioCharacteristic: CBCharacteristic? private var isScanning = false + private var scanTimer: Timer? + + // 用于存储配对设备UUID的UserDefaults键 + private let pairedDeviceUUIDKey = "com.yunqiinnovation.ble_service.paired_device_uuid" + // 新增:设备缓存和广播数据记录 + private var discoveredDevices: [String: [String: Any]] = [:] // 以设备ID为key的设备信息缓存 + private var deviceAdvertisementData: [String: [String: Any]] = [:] // 设备广播数据缓存 + private var scanStartTime: Date? + private let maxCacheTime: TimeInterval = 300 // 5分钟缓存时间 // 是否是ota模式 var isEnterOta = false @@ -50,8 +60,6 @@ public class BleService: NSObject { private var callbacks = [Callback]() private var connectionState = BleConst.STATE_DISCONNECTED - - // 状态恢复相关 private var restoredPeripherals: [CBPeripheral] = [] @@ -62,26 +70,28 @@ public class BleService: NSObject { // MARK: - Opus音频处理器 private var opusProcessor: SwiftOpusAudioProcessor? + // MARK: - 新增命令队列相关属性 + private var commandQueue = [Data]() + //是否回复 + private var isReply = true + // 是否回复指令码 +private var cmdReplyType: UInt8 = 0 + + //private var currentCommand: Command? + // 命令结构体 + private struct Command { + let cmd: UInt8 + let data: Data + } // MARK: - 初始化 private override init() { super.init() - - #if canImport(AccessorySetupKit) && swift(>=5.9) - if #available(iOS 18.0, *) { - // iOS 18+下不立即初始化,等待AccessorySetupKit - } else { - initializeCentralManager() - } - #else initializeCentralManager() - #endif - - // 初始化Opus音频处理器 initializeOpusProcessor() } - // 延迟初始化中央管理器 + // 初始化中央管理器 private func initializeCentralManager() { if centralManager != nil { os_log("🔧 CBCentralManager已存在,跳过初始化", log: logger, type: .debug) @@ -169,35 +179,70 @@ public class BleService: NSObject { return centralManager! } + func getPairedMacAddress() -> String? { + let uuidString = UserDefaults.standard.string(forKey: pairedDeviceUUIDKey) + if let uuidString = uuidString, !uuidString.isEmpty { + os_log("获取已配对设备UUID: %{public}@", log: logger, type: .info, uuidString) + return uuidString + } + os_log("未找到已配对设备UUID", log: logger, type: .info) + return nil + } + /// 保存配对设备的UUID + /// - Parameter uuid: 要保存的设备UUID + func savePairedMacAddress(_ uuid: UUID) { + let uuidString = uuid.uuidString + UserDefaults.standard.set(uuidString, forKey: pairedDeviceUUIDKey) + UserDefaults.standard.synchronize() + os_log("已保存配对设备UUID: %{public}@", log: logger, type: .info, uuidString) + } + /// 清除已保存的配对设备UUID + private func clearPairedMacAddress() { + UserDefaults.standard.removeObject(forKey: pairedDeviceUUIDKey) + UserDefaults.standard.synchronize() + os_log("已清除配对设备UUID", log: logger, type: .info) + } + // MARK: - 清除配对关系 + func clearAssociations() { + resetBluetoothState() + // 清除保存的配对设备UUID + clearPairedMacAddress() + } /// 开始扫描BLE设备 - public func startScan() { - #if canImport(AccessorySetupKit) && swift(>=5.9) - if #available(iOS 18.0, *) { - // iOS 18+不使用传统扫描 - return - } - #endif - + public func startScan(timeout: TimeInterval = 30.0) -> Bool { if centralManager == nil { initializeCentralManager() } guard let manager = centralManager, manager.state == .poweredOn else { - return + os_log("❌ 蓝牙未就绪,无法开始扫描", log: logger, type: .error) + return false } if isScanning { - stopScan() + return true } + // 清空之前的扫描结果 + discoveredDevices.removeAll() + deviceAdvertisementData.removeAll() + isScanning = true - let serviceUUIDs = [BleConst.PRIMARY_SERVICE_UUID, BleConst.AUDIO_SERVICE_UUID] - manager.scanForPeripherals(withServices: serviceUUIDs, options: [ - CBCentralManagerScanOptionAllowDuplicatesKey: false + // 移除服务UUID过滤,扫描所有设备 + manager.scanForPeripherals(withServices: nil, options: [ + CBCentralManagerScanOptionAllowDuplicatesKey: true // 允许重复以获取更新的广播数据 ]) - os_log("开始扫描BLE设备...", log: logger, type: .info) + os_log("🔍 开始扫描所有BLE设备(无过滤),超时时间: %.1f秒", log: logger, type: .info, timeout) + + // 设置扫描超时 + scanTimer?.invalidate() + scanTimer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in + self?.stopScan() + os_log("⏰ 扫描超时,自动停止", log: self?.logger ?? OSLog.default, type: .info) + } + return true } /// 停止扫描 @@ -205,8 +250,35 @@ public class BleService: NSObject { if isScanning && centralManager != nil { centralManager?.stopScan() isScanning = false - os_log("停止BLE扫描", log: logger, type: .info) + scanTimer?.invalidate() + scanTimer = nil + } + } + + /// 连接到指定UUID的设备 + public func connectToDevice(uuid: String) -> Bool { + guard let deviceUUID = UUID(uuidString: uuid) else { + os_log("❌ 无效的设备UUID: %@", log: logger, type: .error, uuid) + return false + } + + guard let manager = centralManager else { + os_log("❌ CBCentralManager未初始化", log: logger, type: .error) + return false + } + os_log("连接设备UUID: %@", log: logger, type: .error, uuid) + // 先尝试从已知外设中获取 + let knownPeripherals = manager.retrievePeripherals(withIdentifiers: [deviceUUID]) + if let peripheral = knownPeripherals.first { + connectPeripheral(peripheral) + savePairedMacAddress(deviceUUID) + return true } + + // 如果没有找到,添加到重连队列 + addToReconnectQueue(uuid: deviceUUID) + os_log("📱 设备未找到,已添加到重连队列: %@", log: logger, type: .info, uuid) + return false } /// 连接到指定设备 @@ -231,18 +303,18 @@ public class BleService: NSObject { } if peripheral.state == .connected { - os_log("设备已连接,恢复会话: %@", log: logger, type: .info, peripheral.identifier.uuidString) + os_log("📱 设备已连接,恢复会话: %@", log: logger, type: .info, peripheral.identifier.uuidString) resumeSession(for: peripheral) return } if peripheral.state == .connecting { - os_log("设备正在连接中: %@", log: logger, type: .info, peripheral.identifier.uuidString) + os_log("📱 设备正在连接中: %@", log: logger, type: .info, peripheral.identifier.uuidString) return } if connectionState == BleConst.STATE_CONNECTED && peripheralDevice?.identifier != peripheral.identifier { - os_log("断开当前设备,连接新设备", log: logger, type: .info) + os_log("📱 断开当前设备,连接新设备", log: logger, type: .info) disconnect() } @@ -257,7 +329,7 @@ public class BleService: NSObject { CBConnectPeripheralOptionNotifyOnNotificationKey: true ]) - os_log("开始连接到设备: %@", log: logger, type: .info, peripheral.identifier.uuidString) + os_log("🔗 开始连接到设备: %@", log: logger, type: .info, peripheral.identifier.uuidString) } /// 恢复已连接设备的会话 @@ -278,7 +350,7 @@ public class BleService: NSObject { os_log("🔍 恢复会话时重新发现服务和特征", log: logger, type: .info) peripheral.discoverServices([BleConst.PRIMARY_SERVICE_UUID, BleConst.AUDIO_SERVICE_UUID]) - os_log("已恢复设备会话: %@", log: logger, type: .info, peripheral.identifier.uuidString) + os_log("✅ 已恢复设备会话: %@", log: logger, type: .info, peripheral.identifier.uuidString) } /// 断开当前连接 @@ -291,7 +363,7 @@ public class BleService: NSObject { if centralManager != nil { updateConnectionState(BleConst.STATE_DISCONNECTING) centralManager?.cancelPeripheralConnection(peripheral) - os_log("开始断开设备连接", log: logger, type: .info) + os_log("🔌 开始断开设备连接", log: logger, type: .info) } } @@ -300,8 +372,86 @@ public class BleService: NSObject { return connectionState == BleConst.STATE_CONNECTED } + /// 获取扫描到的设备列表 + public func getDiscoveredDevices() -> [[String: Any]] { + return Array(discoveredDevices.values) + } + + /// 根据设备名称获取设备列表 + public func getDeviceAdvertisementData(deviceName: String) -> [[String: Any]] { + var matchingDevices: [[String: Any]] = [] + + // 遍历所有已发现的设备,查找匹配的设备名 + for (deviceId, deviceInfo) in discoveredDevices { + if let name = deviceInfo["name"] as? String, name == deviceName { + // 获取对应的广播数据 + var deviceData = deviceInfo + if let advData = deviceAdvertisementData[deviceId] { + // 合并设备信息和广播数据 + deviceData.merge(advData) { (current, _) in current } + } + matchingDevices.append(deviceData) + } + } + + os_log("根据设备名 '%{public}@' 找到 %d 个匹配设备", log: logger, type: .debug, deviceName, matchingDevices.count) + return matchingDevices + } + + /// 获取设备的制造商数据(hex格式) + public func getDeviceManufacturerDataHex(deviceId: String) -> String? { + guard let advData = deviceAdvertisementData[deviceId], + let manufacturerData = advData["manufacturerDataRaw"] as? Data else { + return nil + } + return manufacturerData.map { String(format: "%02x", $0) }.joined(separator: " ") + } + + /// 获取所有设备的广播数据摘要 + public func getAllDevicesAdvertisementData() -> [String: [String: Any]] { + return deviceAdvertisementData + } + + /// 获取设备信息 + public func getDeviceInfo(deviceId: String) -> [String: Any]? { + // 直接从缓存获取,与 Android 端逻辑保持一致 + if let cachedInfo = discoveredDevices[deviceId] { + os_log("获取设备信息: %{public}@", log: logger, type: .debug, deviceId) + return cachedInfo + } + + os_log("未找到设备信息: %{public}@", log: logger, type: .error, deviceId) + return nil + } + + /// 清除过期的设备缓存(超过maxCacheTime的设备) + public func clearExpiredDevices() { + let currentTime = Date().timeIntervalSince1970 + let expiredDeviceIds = deviceAdvertisementData.compactMap { (deviceId, advData) -> String? in + if let timestamp = advData["timestamp"] as? TimeInterval, + currentTime - timestamp > maxCacheTime { + return deviceId + } + return nil + } + + for deviceId in expiredDeviceIds { + discoveredDevices.removeValue(forKey: deviceId) + deviceAdvertisementData.removeValue(forKey: deviceId) + os_log("🗑️ 清除过期设备缓存: %{public}@", log: logger, type: .debug, deviceId) + } + + if !expiredDeviceIds.isEmpty { + os_log("🗑️ 已清除%d个过期设备缓存", log: logger, type: .info, expiredDeviceIds.count) + } + } + // MARK: - 回调通知方法 + //清除蓝牙状态(仅用于测试或重置,不会清除系统授权) + func resetBluetoothState() { + + } /// 通知所有回调扫描结果 private func notifyScanResult(devices: [[String: Any]]) { for callback in callbacks { @@ -513,8 +663,20 @@ public class BleService: NSObject { os_log("发送命令: CMD=0x%02X, 长度=%d, 数据=[%@], CRC=0x%02X", log: logger, type: .debug, cmd, cmdLength, dataHexString, crc) + + + // 加入队列并触发发送 + + if isReply{ + peripheralDevice?.writeValue(cmdPacket, for: characteristic, type: .withoutResponse) + isReply = false; + return true + + } else { + commandQueue.append(cmdPacket) + } // 发送数据(使用无响应写入,与Android端保持一致) - peripheralDevice?.writeValue(cmdPacket, for: characteristic, type: .withoutResponse) + return true } catch { os_log("发送命令异常: %@", log: logger, type: .error, error.localizedDescription) @@ -554,6 +716,11 @@ public class BleService: NSObject { /// 处理接收到的响应数据 private func handleResponseData(_ data: Data) { + guard let characteristic = writeCharacteristic, + connectionState == BleConst.STATE_CONNECTED else { + os_log(" 设备未连接", log: logger, type: .error) + return + } guard data.count >= 4 else { os_log("响应数据格式错误:数据长度过短", log: logger, type: .error) return @@ -595,7 +762,13 @@ public class BleService: NSObject { // 提取响应数据 let responseData = length > 0 ? data.subdata(in: 3..<(3 + Int(length))) : Data() - + isReply = true; + cmdReplyType = cmd; + if !commandQueue.isEmpty { + let nextCmd = commandQueue.removeFirst() + peripheralDevice?.writeValue(nextCmd, for: characteristic, type: .withoutResponse) +} + // 根据命令类型处理设备响应 switch cmd { case BleConst.CMD_GET_VERSION: @@ -903,25 +1076,7 @@ public class BleService: NSObject { ] } - // MARK: - AccessorySetupKit - - /// 在接收到AccessorySetupKit的accessoryAdded事件后调用 - func connectToAccessory(uuid: UUID) { - #if canImport(AccessorySetupKit) && swift(>=5.9) - if #available(iOS 18.0, *) { - os_log("开始连接设备: %{public}@", log: logger, type: .info, uuid.uuidString) - - // 确保CBCentralManager已初始化 - if centralManager == nil { - os_log("CBCentralManager未初始化,开始初始化", log: logger, type: .debug) - initializeCentralManager() - } - - // 添加到重连队列 - addToReconnectQueue(uuid: uuid) - } - #endif - } + } // MARK: - SwiftOpusAudioProcessor.AudioDataCallback @@ -943,7 +1098,7 @@ extension BleService: CBCentralManagerDelegate { case .poweredOn: os_log("🔧 蓝牙已开启", log: logger, type: .info) - // 注:AccessorySetupKit应用不需要恢复传统BLE扫描 + // 蓝牙就绪后可以开始扫描 // 恢复外设连接 if !restoredPeripherals.isEmpty { @@ -1040,26 +1195,118 @@ extension BleService: CBCentralManagerDelegate { } } - // 原有方法 public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { - // 将扫描结果转换为字典格式 + let deviceId = peripheral.identifier.uuidString + let deviceName = peripheral.name ?? "Unknown" + + // 简化的设备信息构建 - 参考 Android 端的简洁性 + let deviceInfo = buildDeviceInfo(peripheral: peripheral, advertisementData: advertisementData, rssi: RSSI) + + // 检查是否为新设备或信息有重要变化 + var shouldNotify = false + + if let existingDevice = discoveredDevices[deviceId] { + // 设备已存在,检查是否有重要变化 + let oldRssi = existingDevice["rssi"] as? Int ?? 0 + let newRssi = RSSI.intValue + + // 只有当RSSI变化超过阈值时才上报(避免频繁更新) + if abs(newRssi - oldRssi) > 10 { + shouldNotify = true + os_log("📱 设备RSSI变化较大 - 名称: %{public}@, UUID: %{public}@, RSSI: %d → %d", log: logger, type: .debug, deviceName, deviceId, oldRssi, newRssi) + } + } else { + // 新设备,需要上报 + shouldNotify = true + os_log("📱 发现新设备 - 名称: %{public}@, UUID: %{public}@", log: logger, type: .info, deviceName, deviceId) + + // 打印 hex 数据 - 直接从 deviceInfo 获取 + if let hex = deviceInfo["hex"] as? String { + os_log("📡 设备 hex 数据: %{public}@", log: logger, type: .info, hex) + } + } + + // 更新缓存(无论是否上报都要更新) + discoveredDevices[deviceId] = deviceInfo + + // 只有在需要时才上报 + if shouldNotify { + notifyScanResult(devices: [deviceInfo]) + } + } + + // 新增:统一的设备信息构建方法 + private func buildDeviceInfo(peripheral: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) -> [String: Any] { + let deviceId = peripheral.identifier.uuidString + let deviceName = peripheral.name ?? "Unknown" + + // 基本设备信息 - 与 Android 端格式对齐 var deviceInfo: [String: Any] = [ - "name": peripheral.name ?? "Unknown", - "id": peripheral.identifier.uuidString, - "rssi": RSSI.intValue + "id": deviceId, + "name": deviceName, + "rssi": rssi.intValue, + "discoveredAt": Date().timeIntervalSince1970 ] - // 添加广播数据 + // 服务 UUIDs - 与 Android 端保持一致的字段名 + if let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] { + let uuids = serviceUUIDs.map { $0.uuidString } + deviceInfo["uuids"] = uuids // 使用与 Android 端一致的字段名 + } + + // 只存储 hex 数据 - 简化存储 + if let rawData = extractRawAdvertisementData(from: advertisementData) { + deviceInfo["hex"] = rawData // 直接存储 hex 数据,不使用 advertisementData 包装 + } + + return deviceInfo + } + + // 提取原始广播数据的辅助方法 + private func extractRawAdvertisementData(from advertisementData: [String: Any]) -> String? { + // 尝试从各种数据源构建原始数据的十六进制表示 + var allData = Data() + if let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data { - deviceInfo["manufacturerData"] = manufacturerData + allData.append(manufacturerData) } - if let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] { - deviceInfo["serviceUUIDs"] = serviceUUIDs.map { $0.uuidString } + if let serviceData = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] { + for (_, data) in serviceData { + allData.append(data) + } } - // 报告给回调 - notifyScanResult(devices: [deviceInfo]) + return allData.isEmpty ? nil : allData.map { String(format: "%02X", $0) }.joined() + } + + // 数据清理方法 + private func sanitizeData(_ dict: [String: Any]) -> [String: Any] { + var sanitized: [String: Any] = [:] + for (key, value) in dict { + if value is Data { + // 跳过 Data 对象 + continue + } else if let nestedDict = value as? [String: Any] { + sanitized[key] = sanitizeData(nestedDict) + } else if let array = value as? [Any] { + var filteredArray: [Any] = [] + for item in array { + if item is Data { + // 跳过 Data 对象 + continue + } else if let nestedDict = item as? [String: Any] { + filteredArray.append(sanitizeData(nestedDict)) + } else { + filteredArray.append(item) + } + } + sanitized[key] = filteredArray + } else { + sanitized[key] = value + } + } + return sanitized } public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { @@ -1267,4 +1514,4 @@ extension BleService: CBPeripheralDelegate { os_log("✅ 描述符写入成功: %{public}@", log: logger, type: .debug, descriptor.uuid.uuidString) } } -} +} diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift index 99e8340cc..c9d6b2609 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift @@ -12,79 +12,20 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin { internal var statusEventSink: FlutterEventSink? private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleServicePlugin") - // 标记是否已初始化所有服务 - private var servicesInitialized = false - // MARK: - 初始化方法 init(_ channel: FlutterMethodChannel) { self.methodChannel = channel super.init() - #if canImport(AccessorySetupKit) && swift(>=5.9) - if #available(iOS 18.0, *) { - - // iOS 18环境下,只设置回调但不主动初始化会话 - let bleAccessorySetup = BleAccessorySetup.shared - - // 在accessoryAdded回调中延迟设置BleService - bleAccessorySetup.setDeviceDiscoveredCallback { [weak self] uuid in - // 确保服务已初始化 - self?.ensureServicesInitialized() - // 然后处理发现的设备 - self?.handleDiscoveredDevice(uuid: uuid) - } - - // 注释掉自动初始化,改为等待用户主动调用startDeviceAssociation - // bleAccessorySetup.initializeSession() - - os_log("iOS 18环境:已设置设备发现回调,等待用户主动调用关联", log: logger, type: .info) - } else { - // 低于iOS 18,直接初始化所有服务 - initializeAllServices() - } - #else - // 非AccessorySetupKit环境,直接初始化所有服务 - initializeAllServices() - #endif - } - - - // 确保所有服务都已初始化 - private func ensureServicesInitialized() { - if !servicesInitialized { - // 设置BLE服务回调 - BleService.shared.addCallback(self) - - // 设置BleService到AccessorySetup - #if canImport(AccessorySetupKit) && swift(>=5.9) - if #available(iOS 18.0, *) { - BleAccessorySetup.shared.setBleService(BleService.shared) - } - #endif - - servicesInitialized = true - } + // 直接初始化BLE服务 + initializeBleService() } - // 初始化所有服务(iOS 17及以下版本的标准行为) - private func initializeAllServices() { + // 初始化BLE服务 + private func initializeBleService() { // 设置BLE服务回调 BleService.shared.addCallback(self) - - // 初始化AccessorySetup,但不自动开始会话 - #if canImport(AccessorySetupKit) && swift(>=5.9) - if #available(iOS 18.0, *) { - BleAccessorySetup.shared.setBleService(BleService.shared) - BleAccessorySetup.shared.setDeviceDiscoveredCallback { [weak self] uuid in - self?.handleDiscoveredDevice(uuid: uuid) - } - // 移除自动初始化,统一由startDeviceAssociation方法触发 - // BleAccessorySetup.shared.initializeSession() - } - #endif - - os_log("非iOS 18环境:已设置BLE服务但不自动初始化会话", log: logger, type: .info) - servicesInitialized = true + os_log("✅ BLE服务已初始化", log: logger, type: .info) } // MARK: - FlutterPlugin Protocol @@ -106,9 +47,6 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin { } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - // 检查是否需要初始化服务 - checkAndInitializeServices() - switch call.method { case "isConnected": result(BleService.shared.isConnected()) @@ -123,31 +61,6 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin { details: nil)) } - case "isCompanionSupported": - #if canImport(AccessorySetupKit) && swift(>=5.9) - if #available(iOS 18.0, *) { - result(true) - } else { - result(false) - } - #else - result(false) - #endif - - case "getPairedMacAddress": - #if canImport(AccessorySetupKit) && swift(>=5.9) - if #available(iOS 18.0, *) { - let pairedUUID = BleAccessorySetup.shared.getPairedMacAddress() - result(pairedUUID) - } else { - result(nil) - } - #else - result(nil) - #endif - - - case "isBleConnected": result(BleService.shared.isConnected()) @@ -193,43 +106,67 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin { case "playA2DP": result(BleService.shared.playA2DP()) - // iOS 18+ AccessorySetupKit相关方法 - case "clearAssociations": - // 清除设备配对关系 - #if canImport(AccessorySetupKit) && swift(>=5.9) - if #available(iOS 18.0, *) { - os_log("清除设备配对关系", log: logger, type: .info) - BleAccessorySetup.shared.clearAssociations() - result(true) - } else { - result(true) - } - #else + case "startScan": + let arguments = call.arguments as? [String: Any] + let timeout = (arguments?["timeout"] as? Double) ?? 30.0 + + os_log("🔍 开始传统BLE扫描,超时: %.1f秒", log: logger, type: .info, timeout) + BleService.shared.startScan(timeout: timeout) result(true) - #endif + case "stopScan": + os_log("⏹️ 停止BLE扫描", log: logger, type: .info) + BleService.shared.stopScan() + result(true) + case "getDeviceList": + let arguments = call.arguments as? [String: Any] + let deviceName = (arguments?["deviceName"] as? String) ?? "" + os_log("🔍 查询设备信息,deviceName: %s", log: logger, type: .info, deviceName) + let info = BleService.shared.getDeviceAdvertisementData(deviceName: deviceName) + result(info) + case "isCompanionSupported": + #if canImport(AccessorySetupKit) && swift(>=5.9) + if #available(iOS 18.0, *) { + result(true) + } else { + result(false) + } + #else + result(false) + #endif + + case "getPairedMacAddress": + let pairedUUID = BleService.shared.getPairedMacAddress() + result(nil) + case "clearAssociations": + BleService.shared.clearAssociations() + result(true) case "startDeviceAssociation": - // 用户主动触发设备关联流程 - #if canImport(AccessorySetupKit) && swift(>=5.9) - if #available(iOS 18.0, *) { - os_log("用户主动触发设备关联流程", log: logger, type: .info) - - // 确保服务已初始化 - ensureServicesInitialized() - - // 重新初始化会话 - BleAccessorySetup.shared.initializeSession() - - // 返回成功,实际配对结果会通过回调通知 - result(true) - } else { - os_log("当前iOS版本不支持AccessorySetupKit", log: logger, type: .info) - result(false) + let arguments = call.arguments as? [String: Any] + let deviceNamePattern = (arguments?["deviceNamePattern"] as? String) ?? "" + let serviceUuid = (arguments?["serviceUuid"] as? String) ?? "" + let uuids = (arguments?["macAddress"] as? String) ?? "" + let success = BleService.shared.connectToDevice(uuid: uuids) + result(success) + + case "connectToDevice": + let arguments = call.arguments as? [String: Any] + guard let deviceId = arguments?["deviceId"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENT", + message: "缺少设备ID参数", + details: nil)) + return } - #else - os_log("当前环境不支持AccessorySetupKit", log: logger, type: .info) - result(false) - #endif + os_log("🔗 连接到设备: %@", log: logger, type: .info, deviceId) + let success = BleService.shared.connectToDevice(uuid: deviceId) + result(success) + case "clearExpiredDevices": + BleService.shared.clearExpiredDevices() + result(true) + + case "requestBluetoothPermission": + // 移除这个方法调用,让iOS系统自然处理权限 + result(FlutterMethodNotImplemented) default: result(FlutterMethodNotImplemented) @@ -237,22 +174,6 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin { } // MARK: - 私有方法 - // 检查并按需初始化服务 - private func checkAndInitializeServices() { - #if canImport(AccessorySetupKit) && swift(>=5.9) - if #available(iOS 18.0, *) { - ensureServicesInitialized() - } - #endif - } - - // AccessorySetupKit发现设备的处理 - private func handleDiscoveredDevice(uuid: UUID) { - // BleAccessorySetup内部已进行连接,此处不再重复调用 - // os_log("收到设备发现回调: %{public}@", log: logger, type: .info, uuid.uuidString) - // 可以在这里添加设备记录或其他操作 - } - private func sendEvent(_ eventSink: FlutterEventSink?, data: T, errorPrefix: String) { if let eventSink = eventSink { DispatchQueue.main.async { @@ -260,25 +181,6 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin { } } } - - // 获取顶层视图控制器的辅助方法 - private func getTopViewController(_ viewController: UIViewController) -> UIViewController { - if let presented = viewController.presentedViewController { - return getTopViewController(presented) - } - - if let tabBarController = viewController as? UITabBarController, - let selected = tabBarController.selectedViewController { - return getTopViewController(selected) - } - - if let navigationController = viewController as? UINavigationController, - let visibleViewController = navigationController.visibleViewController { - return getTopViewController(visibleViewController) - } - - return viewController - } } // MARK: - BleService.Callback @@ -394,4 +296,4 @@ class BleStatusStreamHandler: NSObject, FlutterStreamHandler { plugin?.statusEventSink = nil return nil } -} +} diff --git a/local_plugins/ble_service/lib/ble_service.dart b/local_plugins/ble_service/lib/ble_service.dart index a33ea1e94..3cfb0954d 100644 --- a/local_plugins/ble_service/lib/ble_service.dart +++ b/local_plugins/ble_service/lib/ble_service.dart @@ -294,15 +294,29 @@ class BleService { } /// 断开连接 - Future?> getDeviceInfo(String address) async { + Future stopScan() async { + try { + final result = await _methodChannel.invokeMethod('stopScan'); + return result ?? false; + } catch (e) { + print('开启扫描ble设备失败: $e'); + return false; + } + } + + /// 获取设备信息 + Future?> getDeviceInfo(String deviceName) async { try { final result = await _methodChannel - .invokeMethod('getDeviceInfo', {'address': address}); + .invokeMethod('getDeviceInfo', {'deviceName': deviceName}); print('获取设备信息!: $result'); if (result != null) { return { 'name': result['name'] ?? "未知", 'address': result['address'] ?? "", + 'id': result['id'] ?? + result['address'] ?? + "", // iOS 返回 UUID,Android 返回 address 'hex': result['hex'] ?? "", }; } else { @@ -314,6 +328,32 @@ class BleService { } } + /// 获取设备列表(通过设备名) + Future>?> getDeviceList(String deviceName) async { + try { + final result = await _methodChannel + .invokeMethod('getDeviceList', {'deviceName': deviceName}); + print('获取设备列表!: $result'); + if (result != null) { + return result.map>((device) { + return { + 'name': device['name'] ?? "未知", + 'address': device['address'] ?? "", + 'id': device['id'] ?? + device['address'] ?? + "", // iOS 返回 UUID,Android 返回 address + 'hex': device['hex'] ?? "", + }; + }).toList(); + } else { + return null; + } + } catch (e) { + print('获取设备列表失败!: $e'); + return null; + } + } + /// 注册BLE后台扫描 /// /// 注册后,即使应用被杀死,系统也会在检测到设备广播时唤醒应用 @@ -435,4 +475,16 @@ class BleService { return false; } } + + /// 请求蓝牙权限(仅iOS) + Future requestBluetoothPermission() async { + try { + final result = + await _methodChannel.invokeMethod('requestBluetoothPermission'); + return result ?? false; + } catch (e) { + print('请求蓝牙权限失败: $e'); + return false; + } + } } diff --git a/local_plugins/chat_api/ios/chat_api/Package.swift b/local_plugins/chat_api/ios/chat_api/Package.swift index dcd6fe2dc..590fd4a5c 100644 --- a/local_plugins/chat_api/ios/chat_api/Package.swift +++ b/local_plugins/chat_api/ios/chat_api/Package.swift @@ -14,14 +14,16 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/MacPaw/OpenAI.git", from: "0.4.3"), - .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.9.0") + .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.9.0"), + .package(path: "../../../location_service/ios/location_service"), ], targets: [ .target( name: "chat_api", dependencies: [ .product(name: "MCP", package: "swift-sdk"), - .product(name: "OpenAI", package: "OpenAI") + .product(name: "OpenAI", package: "OpenAI"), + .product(name: "location-service", package: "location_service") ], exclude: [] ), diff --git a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiPlugin.swift b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiPlugin.swift index ee4153b65..5bb2c7eab 100644 --- a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiPlugin.swift +++ b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiPlugin.swift @@ -18,25 +18,25 @@ public class ChatApiPlugin: NSObject, FlutterPlugin { let instance = ChatApiPlugin() registrar.addMethodCallDelegate(instance, channel: methodChannel) - eventChannel.setStreamHandler(instance) +// eventChannel.setStreamHandler(instance) } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "initialize": handleInitialize(call: call, result: result) - case "createUserMessage": - handleCreateUserMessage(call: call, result: result) - case "createAssistantMessage": - handleCreateAssistantMessage(call: call, result: result) - case "sendMessage": - handleSendMessage(call: call, result: result) - case "sendMessageStream": - handleSendMessageStream(call: call, result: result) - case "sendFunctionCallResult": - handleSendFunctionCallResult(call: call, result: result) - case "cancelCurrentStream": - handleCancelCurrentStream(result: result) +// case "createUserMessage": +// handleCreateUserMessage(call: call, result: result) +// case "createAssistantMessage": +// handleCreateAssistantMessage(call: call, result: result) +// case "sendMessage": +// handleSendMessage(call: call, result: result) +// case "sendMessageStream": +// handleSendMessageStream(call: call, result: result) +// case "sendFunctionCallResult": +// handleSendFunctionCallResult(call: call, result: result) +// case "cancelCurrentStream": +// handleCancelCurrentStream(result: result) case "registerFunction": handleRegisterFunction(call: call, result: result) case "initializeMcpClient": @@ -63,14 +63,16 @@ public class ChatApiPlugin: NSObject, FlutterPlugin { let baseUrl = args["baseUrl"] as? String ?? "" let model = args["model"] as? String ?? "" + let visionModel = args["modelmedia"] as? String ?? "" let mcpServer = args["mcpServer"] as? String ?? "" - service.setStreamCallback(StreamCallbackImpl(eventSink: eventSink)) +// service.setStreamCallback(StreamCallbackImpl(eventSink: eventSink)) let success = service.initialize( apiKey: apiKey, baseUrl: baseUrl, model: model, + visionModel:visionModel, mcpServer: mcpServer ) @@ -120,16 +122,16 @@ public class ChatApiPlugin: NSObject, FlutterPlugin { } } - private func handleSendMessageStream(call: FlutterMethodCall, result: @escaping FlutterResult) { - guard let args = call.arguments as? [String: Any], - let messages = args["messages"] as? [[String: Any]] else { - result(FlutterError(code: "INVALID_ARGS", message: "缺少必要参数", details: nil)) - return - } - - service.sendMessageStream(messages: messages) - result(true) - } +// private func handleSendMessageStream(call: FlutterMethodCall, result: @escaping FlutterResult) { +// guard let args = call.arguments as? [String: Any], +// let messages = args["messages"] as? [[String: Any]] else { +// result(FlutterError(code: "INVALID_ARGS", message: "缺少必要参数", details: nil)) +// return +// } +// +// service.sendMessageStream(messages: messages) +// result(true) +// } private func handleSendFunctionCallResult(call: FlutterMethodCall, result: @escaping FlutterResult) { result(FlutterError(code: "DEPRECATED", message: "sendFunctionCallResult已废弃,工具调用结果现在自动处理", details: nil)) @@ -199,98 +201,3 @@ public class ChatApiPlugin: NSObject, FlutterPlugin { } } -// MARK: - FlutterStreamHandler -extension ChatApiPlugin: FlutterStreamHandler { - public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { - self.eventSink = events - if let callback = service.streamCallback as? StreamCallbackImpl { - callback.eventSink = events - } - return nil - } - - public func onCancel(withArguments arguments: Any?) -> FlutterError? { - self.eventSink = nil - if let callback = service.streamCallback as? StreamCallbackImpl { - callback.eventSink = nil - } - return nil - } -} - -// MARK: - StreamCallback Implementation -class StreamCallbackImpl: StreamCallback { - var eventSink: FlutterEventSink? - - init(eventSink: FlutterEventSink?) { - self.eventSink = eventSink - } - - func onToken(_ token: String) { - DispatchQueue.main.async { - self.eventSink?([ - "type": "token", - "content": token - ]) - } - } - - func onComplete() { - DispatchQueue.main.async { - self.eventSink?([ - "type": "complete" - ]) - } - } - - func onError(_ error: Error) { - DispatchQueue.main.async { - self.eventSink?([ - "type": "error", - "content": error.localizedDescription - ]) - } - } - - func onFunctionCall(_ functionCall: [String: Any]) { - DispatchQueue.main.async { - do { - let jsonData = try JSONSerialization.data(withJSONObject: functionCall) - let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}" - self.eventSink?([ - "type": "functionCall", - "content": jsonString - ]) - } catch { - self.eventSink?([ - "type": "error", - "content": "Failed to serialize function call" - ]) - } - } - } - - func onFunctionCallResult(_ functionCall: [String: Any], _ functionCallResult: [String: Any]) { - DispatchQueue.main.async { - do { - let meta = [ - "functionCall": functionCall, - "functionCallResult": functionCallResult - ] - let metaData = try JSONSerialization.data(withJSONObject: meta) - let metaString = String(data: metaData, encoding: .utf8) ?? "{}" - - self.eventSink?([ - "type": "functionCall", - "content": functionCallResult["context"] as? String ?? "", - "meta": metaString - ]) - } catch { - self.eventSink?([ - "type": "error", - "content": "Failed to serialize function call result" - ]) - } - } - } -} diff --git a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiService.swift b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiService.swift index cf1c6a3a0..ad6b53219 100644 --- a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiService.swift +++ b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiService.swift @@ -15,13 +15,14 @@ public struct ChatApiException: Error { } } -/// 流式回调协议 +///// 流式回调协议 public protocol StreamCallback { - func onToken(_ token: String) - func onComplete() - func onError(_ error: Error) - func onFunctionCall(_ functionCall: [String: Any]) - func onFunctionCallResult(_ functionCall: [String: Any], _ functionCallResult: [String: Any]) + func onUsage(_ sessionid: String,_ prompt_tokens: Int?,_ completion_tokens: Int?,_ total_tokens: Int?) + func onToken(_ sessionId:String,_ token: String) + func onComplete(_ sessionId:String) + func onError(_ sessionId:String,_ error: Error) + func onFunctionCall(_ sessionId:String,_ functionCall: [String: Any]) + func onFunctionCallResult(_ sessionId:String,_ functionCall: [String: Any], _ functionCallResult: [String: Any]) } /// 工具调用信息 @@ -54,11 +55,10 @@ public class ChatApiService: NSObject { // 流式请求相关 private var currentStreamTask: Task? - var streamCallback: StreamCallback? + private var sessionCallbacks: [String: StreamCallback] = [:] private var currentMessages: [[String: Any]] = [] private var toolCalls: [Int: ToolCallInfo] = [:] - private var isCanceled = false - + private var currSessionId = "" // MARK: - 初始化 public override init() { super.init() @@ -67,12 +67,16 @@ public class ChatApiService: NSObject { // MARK: - 公共方法 /// 设置流式回调 - public func setStreamCallback(_ callback: StreamCallback) { - self.streamCallback = callback + public func setStreamCallback(_ sessionId:String,_ callback: StreamCallback) { + self.sessionCallbacks[sessionId] = callback + } + + public func getSessionCallback(_ sessionId:String)->StreamCallback?{ + return self.sessionCallbacks[sessionId] } /// 初始化ChatAPI服务 - public func initialize(apiKey: String, baseUrl: String, model: String, mcpServer: String) -> Bool { + public func initialize(apiKey: String, baseUrl: String, model: String, visionModel:String, mcpServer: String) -> Bool { self.apiKey = apiKey if !baseUrl.isEmpty { self.baseUrl = baseUrl @@ -80,7 +84,9 @@ public class ChatApiService: NSObject { if !model.isEmpty { self.model = model } - + if !visionModel.isEmpty { + self.visionModel = visionModel + } // 分段解析baseUrl为OpenAI库需要的格式 guard let url = URL(string: self.baseUrl) else { return false @@ -246,191 +252,321 @@ public class ChatApiService: NSObject { } /// 发送消息(流式输出) - public func sendMessageStream(messages: [[String: Any]]) { - guard isInitialized && !apiKey.isEmpty, let openAI = openAI else { - let error = ChatApiException("ChatAPI服务未初始化") - streamCallback?.onError(error) - return - } - - // 重置状态 - isCanceled = false - currentMessages = messages - toolCalls = [:] - - // 检查是否包含图片,决定使用哪个模型 - var currentModel = model - if containsImage(in: messages) { - currentModel = visionModel - } + // MARK: - 流式请求实现 +public func sendMessageStream(_ sessionId: String, messages: [[String: Any]]) { + // 1. 立即中止旧会话 + abortCurrentSession() + + // 2. 创建新会话 + currSessionId = sessionId + + guard isInitialized && !apiKey.isEmpty, let openAI = openAI else { + let error = ChatApiException("ChatAPI服务未初始化") + getSessionCallback(sessionId)?.onError(sessionId, error) + return + } + + // 保存当前消息和回调 + currentMessages = messages + toolCalls = [:] + sessionCallbacks[sessionId] = getSessionCallback(sessionId) + + // 检查是否包含图片,决定模型 + let currentModel = containsImage(in: messages) ? visionModel : model + + currentStreamTask = Task { [weak self] in + guard let self = self else { return } + var replyContext = "" // 收集AI回复内容 - currentStreamTask = Task { [weak self] in - guard let self = self else { return } + do { + // 转换消息格式 + let chatMessages = try self.convertToChatMessages(messages) - do { - // 转换消息格式 - let chatMessages = try self.convertToChatMessages(messages) - - // 获取MCP工具列表 - let tools = self.mcpClient?.getToolMaps() ?? [] - - // 构建请求 - let query: ChatQuery - if !tools.isEmpty { - // 转换工具格式 - let chatTools = self.convertToOpenAITools(tools) - query = ChatQuery( - messages: chatMessages, - model: currentModel, - maxTokens: 2000, - temperature: 0.7, - tools: chatTools - ) - } else { - query = ChatQuery( - messages: chatMessages, - model: currentModel, - maxTokens: 2000, - temperature: 0.7 - ) + // 获取工具列表 + let tools = self.mcpClient?.getToolMaps() ?? [] + + // 构建请求 + let query: ChatQuery + if !tools.isEmpty { + let chatTools = self.convertToOpenAITools(tools) + query = ChatQuery( + messages: chatMessages, + model: currentModel, + maxTokens: 2000, + temperature: 0.7, + tools: chatTools + ) + } else { + query = ChatQuery( + messages: chatMessages, + model: currentModel, + maxTokens: 2000, + temperature: 0.7 + ) + } + + let chatsStream: AsyncThrowingStream = openAI.chatsStream(query: query) + + for try await result in chatsStream { + // 检查会话是否已变更 + if sessionId != self.currSessionId { + return } - let chatsStream: AsyncThrowingStream = openAI.chatsStream(query: query) + guard let choice = result.choices.first else { continue } + let delta = choice.delta + + // 处理文本内容 + if let content = delta.content { + replyContext += content + self.getSessionCallback(sessionId)?.onToken(sessionId, content) + } - for try await result in chatsStream { - if self.isCanceled { - break - } - - if let choice = result.choices.first { - let delta = choice.delta + // 收集工具调用信息 + if let toolCalls = delta.toolCalls { + for toolCall in toolCalls { + let index = toolCall.index + + // 初始化工具调用信息 + if self.toolCalls[index] == nil { + self.toolCalls[index] = ToolCallInfo() + } + + guard let toolCallInfo = self.toolCalls[index] else { continue } - // 处理普通文本内容 - if let content = delta.content { - self.streamCallback?.onToken(content) + // 更新ID + if let id = toolCall.id { + toolCallInfo.id = id } - // 收集工具调用信息 - if let toolCalls = delta.toolCalls { - for toolCall in toolCalls { - let index = toolCall.index - - // 创建或获取现有的工具调用信息 - if self.toolCalls[index] == nil { - self.toolCalls[index] = ToolCallInfo() - } - - let toolCallInfo = self.toolCalls[index]! - - // 更新ID - if let id = toolCall.id { - toolCallInfo.id = id - } - - // 更新函数信息 - if let function = toolCall.function { - if let name = function.name { - toolCallInfo.name = name - } - - if let arguments = function.arguments { - toolCallInfo.arguments += arguments - } - } + // 更新函数信息 + if let function = toolCall.function { + if let name = function.name { + toolCallInfo.name = name + } + + if let arguments = function.arguments { + toolCallInfo.arguments += arguments } } } } + } + + // 流式响应结束 + if sessionId == self.currSessionId { + print("发送消息(流式输出)\(replyContext)") + // 计算token使用量 + let promptTokens = self.estimatePromptTokens(messages: self.currentMessages) + let completionTokens = self.roughTokenCount(text: replyContext) + let totalTokens = promptTokens + completionTokens + self.getSessionCallback(sessionId)?.onUsage(sessionId, promptTokens, completionTokens, totalTokens) - if !self.isCanceled { - // 处理工具调用或完成 - let hasToolCalls = await self.processToolCalls() - if !hasToolCalls { - self.streamCallback?.onComplete() - } + // 处理工具调用 + let hasToolCalls = await self.processToolCalls(sessionId) + if !hasToolCalls { + self.getSessionCallback(sessionId)?.onComplete(sessionId) + self.clearCurrentSession() } - - } catch { - if !self.isCanceled { - let chatApiError = ChatApiException("流式请求失败: \(error.localizedDescription)") - self.streamCallback?.onError(chatApiError) + } + + } catch { + if sessionId == self.currSessionId { + let errorMessage = "流式请求失败: \(error.localizedDescription)" + let chatApiError = ChatApiException(errorMessage) + self.getSessionCallback(sessionId)?.onError(sessionId, chatApiError) + self.clearCurrentSession() + } + } + } +} + +// MARK: - 新增辅助方法 +private func estimatePromptTokens(messages: [[String: Any]]) -> Int { + var totalText = "" + for message in messages { + if let content = message["content"] as? String { + totalText += content + " " + } else if let contentArray = message["content"] as? [[String: Any]] { + for item in contentArray { + if let type = item["type"] as? String, + type == "text", + let text = item["text"] as? String { + totalText += text + " " } } } } + return roughTokenCount(text: totalText) +} + +private func roughTokenCount(text: String) -> Int { + let separators = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters) + let tokens = text.components(separatedBy: separators).filter { !$0.isEmpty } + return tokens.count +} /// 处理工具调用 - private func processToolCalls() async -> Bool { - guard let firstToolCall = toolCalls.values.first, firstToolCall.isValid() else { - return false - } - - // 创建函数调用字典 - let functionCall: [String: Any] = [ - "name": firstToolCall.name, - "arguments": firstToolCall.arguments, - "id": firstToolCall.id - ] - - // 通知上层工具调用事件 - streamCallback?.onFunctionCall(functionCall) - - // 在后台队列处理工具调用 - Task { - do { - if !self.isCanceled { - // 调用工具 - let result: [String: Any] - let args = try self.parseJsonArguments(firstToolCall.arguments) - if let client = self.mcpClient { - result = await client.callTool(name: firstToolCall.name, arguments: args) ?? ["context": "工具调用失败"] - } else { - result = ["context": "MCP客户端未初始化"] - } - - if !self.isCanceled { - // 处理结果 - self.streamCallback?.onFunctionCallResult(functionCall, result) + /// 处理工具调用 +private func processToolCalls(_ sessionId:String) async -> Bool { + // 验证工具调用集合不为空 + if toolCalls.isEmpty { + print("[Session: \(sessionId)] 没有工具调用需要处理") + return false + } + + // 输出所有收集到的工具调用信息(用于调试) + print("[Session: \(sessionId)] 收集到 \(toolCalls.count) 个工具调用") + for (index, toolCall) in toolCalls { + print("[Session: \(sessionId)] 工具调用[\(index)]: name=\(toolCall.name), args.length=\(toolCall.arguments.count), args=\(toolCall.arguments.prefix(100))...") + } + + // 只处理有效的工具调用 + let validToolCalls = toolCalls.values.filter { $0.isValid() } + if validToolCalls.isEmpty { + print("[Session: \(sessionId)] 没有有效的工具调用(参数可能不完整)") + return false + } + + // 处理第一个有效的工具调用 + let firstToolCall = validToolCalls.first! + print("[Session: \(sessionId)] 处理工具调用: \(firstToolCall.name),参数: \(firstToolCall.arguments)") + + // 创建函数调用字典 + let functionCall: [String: Any] = [ + "name": firstToolCall.name, + "arguments": firstToolCall.arguments, + "id": firstToolCall.id + ] + // 转换为JSON对象格式 +let jsonFunctionCall = convertMapToJsonObject(functionCall) +getSessionCallback(sessionId)?.onFunctionCall(sessionId, jsonFunctionCall) + // 通知上层工具调用事件 + // getSessionCallback(sessionId)?.onFunctionCall(sessionId, functionCall) + + // 在后台处理工具调用 + Task { [weak self] in + guard let self = self else { return } + + do { + if sessionId == self.currSessionId { + // 解析参数 + let args = try self.parseJsonArguments(firstToolCall.arguments) + + // 通过MCP客户端处理工具调用 + var result: [String: Any] = [:] + + if let client = self.mcpClient { + // 调用MCP工具 + if let toolResult = await client.callTool(name: firstToolCall.name, arguments: args) { + print("[Session: \(sessionId)] AI调用工具结果: \(firstToolCall.name), 参数: \(args), 结果: \(toolResult)") - // 将result转换为JSON字符串 - let context: String - do { - let jsonData = try JSONSerialization.data(withJSONObject: result, options: .prettyPrinted) - context = String(data: jsonData, encoding: .utf8) ?? "工具调用失败" - } catch { - context = "工具调用失败" + // 统一结果格式 + if toolResult["isError"] as? Bool == true { + if let content = toolResult["content"] as? [[String: Any]], + let firstContent = content.first, + let errorText = firstContent["text"] as? String { + result = ["context": errorText] + } else { + result = ["context": "Tool execution failed"] + } + } else if let context = toolResult["context"] { + // 本地函数结果 + result = ["context": context] + } else if let content = toolResult["content"] as? [[String: Any]], + let firstContent = content.first, + let text = firstContent["text"] as? String { + // MCP工具结果 + result = ["context": text] + } else { + print("[Session: \(sessionId)] MCP工具调用返回无法解析的结果") + result = ["context": "Tool call failed"] } - - // 将结果发送回OpenAI继续对话 - await self.sendFunctionCallResultInternal( - messages: self.currentMessages, - functionCall: functionCall, - functionResult: context - ) + } else { + print("[Session: \(sessionId)] MCP工具调用返回nil") + result = ["context": "Tool call failed"] } + } else { + // 工具不存在 + result = ["context": "Tool not found: \(firstToolCall.name)"] } - } catch { - if !self.isCanceled { - let errorMessage = "工具调用处理失败: \(error.localizedDescription)" + + if sessionId == self.currSessionId { + // 处理结果 + self.getSessionCallback(sessionId)?.onFunctionCallResult( + sessionId, + functionCall, + result + ) + + // 将结果发送回OpenAI继续对话 await self.sendFunctionCallResultInternal( + sessionId: sessionId, messages: self.currentMessages, functionCall: functionCall, - functionResult: errorMessage + functionResult: self.jsonToString(result) ?? "{}" ) } } + } catch { + print("[Session: \(sessionId)] 工具调用处理过程中出错: \(error.localizedDescription)") + if sessionId == self.currSessionId { + let errorMessage = "工具调用处理失败: \(error.localizedDescription)" + await self.sendFunctionCallResultInternal( + sessionId: sessionId, + messages: self.currentMessages, + functionCall: functionCall, + functionResult: errorMessage + ) + } } - - return true } + return true +} + +/// 辅助方法:将字典转换为JSON字符串 +private func jsonToString(_ json: [String: Any]) -> String? { + do { + let jsonData = try JSONSerialization.data(withJSONObject: json, options: []) + return String(data: jsonData, encoding: .utf8) + } catch { + print("JSON序列化失败: \(error)") + return nil + } +} + // MARK: - JSON转换工具 +private func convertMapToJsonObject(_ map: [String: Any]) -> [String: Any] { + var result = [String: Any]() + + for (key, value) in map { + if let nestedMap = value as? [String: Any] { + result[key] = convertMapToJsonObject(nestedMap) + } else if let array = value as? [Any] { + var jsonArray = [Any]() + for item in array { + if let itemMap = item as? [String: Any] { + jsonArray.append(convertMapToJsonObject(itemMap)) + } else { + jsonArray.append(item) + } + } + result[key] = jsonArray + } else { + result[key] = value + } + } + + return result +} /// 发送函数调用结果(内部方法) private func sendFunctionCallResultInternal( + sessionId:String, messages: [[String: Any]], functionCall: [String: Any], functionResult: String ) async { - if isCanceled { + if sessionId != self.currSessionId { return } @@ -459,12 +595,12 @@ public class ChatApiService: NSObject { ]) // 重新发起对话,让AI基于工具调用结果生成回复 - sendMessageStream(messages: fullMessages) + sendMessageStream(sessionId,messages: fullMessages) } /// 取消当前流式请求 public func cancelCurrentStream() -> Bool { - isCanceled = true + currSessionId = "" currentStreamTask?.cancel() currentStreamTask = nil return true @@ -543,6 +679,35 @@ public class ChatApiService: NSObject { return result?["context"] as? String ?? "无法处理MCP工具调用" } + + /// 中止当前会话 + private func abortCurrentSession() { + if !currSessionId.isEmpty { + let oldSessionId = currSessionId + + // 1. 取消当前的协程任务 + currentStreamTask?.cancel() + currentStreamTask = nil + + // 2. 通知旧会话被中止 + getSessionCallback(oldSessionId)?.onComplete(currSessionId) + + // 3. 清理状态 + currSessionId = "" + sessionCallbacks.removeValue(forKey: oldSessionId) + toolCalls.removeAll() + } + } + + /// 清理当前会话 + private func clearCurrentSession() { + let oldSessionId = currSessionId + currSessionId = "" + sessionCallbacks.removeValue(forKey: oldSessionId) + currentStreamTask = nil + toolCalls.removeAll() + } + // MARK: - 工具方法 /// 解析JSON参数 @@ -757,4 +922,4 @@ private class LocalFunctionHandler: FunctionHandler { func handle(arguments: [String: Any]) async throws -> String { return "LOCAL_FUNCTION:\(functionName)" } -} \ No newline at end of file +} diff --git a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift index 1d7b7746d..41c3df391 100644 --- a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift +++ b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift @@ -1,12 +1,11 @@ import Foundation import UIKit -import Contacts import EventKit import CoreLocation -import MediaPlayer -import AVFoundation +import MessageUI +import location_service -/// 操作结果 +/// 结果结构体 public struct Result { let success: Bool let message: String @@ -20,26 +19,14 @@ public struct Result { /// 系统功能处理器 public class SystemFunctionHandler: NSObject { - // 位置管理器 - private var locationManager: CLLocationManager? - private var locationCompletion: ((Result) -> Void)? - // 事件存储 private var eventStore: EKEventStore? public override init() { super.init() - setupLocationManager() setupEventStore() } - /// 设置位置管理器 - private func setupLocationManager() { - locationManager = CLLocationManager() - locationManager?.delegate = self - locationManager?.desiredAccuracy = kCLLocationAccuracyBest - } - /// 设置事件存储 private func setupEventStore() { eventStore = EKEventStore() @@ -67,7 +54,7 @@ public class SystemFunctionHandler: NSObject { registerGetCurrentTimeFunction(client: client) // 注册获取当前位置函数 - // registerGetCurrentLocationFunction(client: client) + registerGetCurrentLocationFunction(client: client) // 注册媒体控制函数 registerMediaPlayFunction(client: client) @@ -112,8 +99,8 @@ public class SystemFunctionHandler: NSObject { let handler = TranslationModeHandler() _ = client.registerLocalFunction( - name: "enter_translation_mode", - description: "用户请求进入实时翻译模式时,启动实时翻译功能", + name: "translation_mode", + description: "进入翻译模式", parameters: parameters, handler: handler ) @@ -130,7 +117,7 @@ public class SystemFunctionHandler: NSObject { ], "message": [ "type": "string", - "description": "短信内容" + "description": "要发送的短信内容" ] ], "required": ["contact", "message"] @@ -182,7 +169,7 @@ public class SystemFunctionHandler: NSObject { ], "time": [ "type": "string", - "description": "提醒时间,格式为'yyyy-MM-dd HH:mm',如'2023-12-31 14:30'" + "description": "提醒时间,格式:yyyy-MM-dd HH:mm" ] ], "required": ["title", "time"] @@ -191,7 +178,7 @@ public class SystemFunctionHandler: NSObject { let handler = SetReminderHandler(eventStore: eventStore) _ = client.registerLocalFunction( name: "set_reminder", - description: "设置提醒事项", + description: "设置提醒", parameters: parameters, handler: handler ) @@ -204,7 +191,7 @@ public class SystemFunctionHandler: NSObject { "properties": [ "format": [ "type": "string", - "description": "时间格式,可选,默认为标准格式" + "description": "时间格式,可选" ] ], "required": [] @@ -213,7 +200,7 @@ public class SystemFunctionHandler: NSObject { let handler = GetCurrentTimeHandler() _ = client.registerLocalFunction( name: "get_current_time", - description: "获取当前日期和时间", + description: "获取当前时间", parameters: parameters, handler: handler ) @@ -227,16 +214,16 @@ public class SystemFunctionHandler: NSObject { "required": [] ] - let handler = GetCurrentLocationHandler(systemHandler: self) + let handler = GetCurrentLocationHandler() _ = client.registerLocalFunction( name: "get_current_location", - description: "获取当前地理位置", + description: "获取当前位置", parameters: parameters, handler: handler ) } - /// 注册媒体播放功能 + /// 注册媒体播放函数 private func registerMediaPlayFunction(client: MCPClient) { let parameters: [String: Any] = [ "type": "object", @@ -253,7 +240,7 @@ public class SystemFunctionHandler: NSObject { ) } - /// 注册媒体暂停功能 + /// 注册媒体暂停函数 private func registerMediaPauseFunction(client: MCPClient) { let parameters: [String: Any] = [ "type": "object", @@ -270,7 +257,7 @@ public class SystemFunctionHandler: NSObject { ) } - /// 注册媒体上一首功能 + /// 注册媒体上一首函数 private func registerMediaPreviousFunction(client: MCPClient) { let parameters: [String: Any] = [ "type": "object", @@ -287,7 +274,7 @@ public class SystemFunctionHandler: NSObject { ) } - /// 注册媒体下一首功能 + /// 注册媒体下一首函数 private func registerMediaNextFunction(client: MCPClient) { let parameters: [String: Any] = [ "type": "object", @@ -304,7 +291,7 @@ public class SystemFunctionHandler: NSObject { ) } - /// 注册打开录音机功能 + /// 注册打开录音机函数 private func registerOpenRecorderFunction(client: MCPClient) { let parameters: [String: Any] = [ "type": "object", @@ -315,122 +302,72 @@ public class SystemFunctionHandler: NSObject { let handler = OpenRecorderHandler() _ = client.registerLocalFunction( name: "open_recorder", - description: "打开系统录音机并开始录音", + description: "打开录音机", parameters: parameters, handler: handler ) } +} + +// MARK: - 功能处理器实现 + +/// 获取当前位置处理器 +private class GetCurrentLocationHandler: FunctionHandler { - // MARK: - 内部方法 - - /// 获取当前位置 - internal func getCurrentLocation() async -> Result { - return await withCheckedContinuation { continuation in - locationCompletion = { result in - continuation.resume(returning: result) - } + func handle(arguments: [String: Any]) async throws -> String { + // 检查位置权限 + guard LocationService.shared.hasLocationPermission() else { + let errorResult: [String: Any] = [ + "success": false, + "message": "位置权限被拒绝。请前往 设置 > 隐私与安全 > 定位服务 > Deep Voice 启用定位权限" + ] + let jsonData = try JSONSerialization.data(withJSONObject: errorResult) + return String(data: jsonData, encoding: .utf8) ?? "{\"error\": \"JSON序列化失败\"}" + } + + // 先尝试获取缓存的位置数据 + if let cachedLocation = LocationService.shared.getCachedLocationData() { + let message = "当前位置:\(cachedLocation.address ?? "未知地址")(纬度 \(cachedLocation.latitude),经度 \(cachedLocation.longitude))" - // 设置15秒超时 - DispatchQueue.global().asyncAfter(deadline: .now() + 15.0) { [weak self] in - if self?.locationCompletion != nil { - self?.locationCompletion?(Result(success: false, message: "位置请求超时")) - self?.locationCompletion = nil + // 异步触发最新定位(不等待结果) + Task { + do { + _ = try await LocationService.shared.getCurrentLocation() + } catch { + print("后台更新位置失败: \(error)") } } - // 检查位置服务是否可用 - guard CLLocationManager.locationServicesEnabled() else { - locationCompletion?(Result(success: false, message: "位置服务未启用。请前往 设置 > 隐私与安全 > 定位服务 启用定位服务")) - locationCompletion = nil - return - } - - // 确保locationManager已初始化 - guard let locationManager = locationManager else { - locationCompletion?(Result(success: false, message: "位置管理器未初始化")) - locationCompletion = nil - return - } - - // 检查位置权限 - let authStatus: CLAuthorizationStatus - if #available(iOS 14.0, *) { - authStatus = locationManager.authorizationStatus - } else { - authStatus = CLLocationManager.authorizationStatus() - } - - print("当前位置权限状态: \(authStatus.rawValue)") - - switch authStatus { - case .notDetermined: - print("请求位置权限...") - // 请求权限 - locationManager.requestWhenInUseAuthorization() - // 权限结果会在 didChangeAuthorization 回调中处理 - case .denied: - locationCompletion?(Result(success: false, message: "位置权限被拒绝。请前往 设置 > 隐私与安全 > 定位服务 > Deep Voice 启用定位权限")) - locationCompletion = nil - case .restricted: - locationCompletion?(Result(success: false, message: "位置权限受限制,可能是由于家长控制或企业配置限制")) - locationCompletion = nil - case .authorizedWhenInUse, .authorizedAlways: - print("已有位置权限,开始获取位置...") - // 已有权限,直接请求位置 - locationManager.requestLocation() - @unknown default: - locationCompletion?(Result(success: false, message: "未知的位置权限状态: \(authStatus.rawValue)")) - locationCompletion = nil - } - } - } -} - -// MARK: - CLLocationManagerDelegate -extension SystemFunctionHandler: CLLocationManagerDelegate { - public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - guard let location = locations.last else { - locationCompletion?(Result(success: false, message: "无法获取位置信息")) - return - } - - let latitude = location.coordinate.latitude - let longitude = location.coordinate.longitude - let message = "当前位置:纬度 \(latitude),经度 \(longitude)" - - locationCompletion?(Result(success: true, message: message)) - locationCompletion = nil - } - - public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - locationCompletion?(Result(success: false, message: "获取位置失败:\(error.localizedDescription)")) - locationCompletion = nil - } - - public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { - // 只有在有位置请求等待时才处理权限变化 - guard locationCompletion != nil else { - return + let result: [String: Any] = [ + "success": true, + "message": message + ] + let jsonData = try JSONSerialization.data(withJSONObject: result) + return String(data: jsonData, encoding: .utf8) ?? "{\"error\": \"JSON序列化失败\"}" } - switch status { - case .authorizedWhenInUse, .authorizedAlways: - manager.requestLocation() - case .denied, .restricted: - locationCompletion?(Result(success: false, message: "位置权限被拒绝。请前往 设置 > 隐私与安全 > 定位服务 > Deep Voice 启用定位权限")) - locationCompletion = nil - case .notDetermined: - // 权限仍未确定,等待用户操作 - break - @unknown default: - locationCompletion?(Result(success: false, message: "位置权限状态异常,请重试")) - locationCompletion = nil + // 如果没有缓存数据,同步获取位置 + do { + let locationData = try await LocationService.shared.getCurrentLocation() + let message = "当前位置:\(locationData.address ?? "未知地址")(纬度 \(locationData.latitude),经度 \(locationData.longitude))" + + let result: [String: Any] = [ + "success": true, + "message": message + ] + let jsonData = try JSONSerialization.data(withJSONObject: result) + return String(data: jsonData, encoding: .utf8) ?? "{\"error\": \"JSON序列化失败\"}" + } catch { + let errorResult: [String: Any] = [ + "success": false, + "message": "获取位置失败:\(error.localizedDescription)" + ] + let jsonData = try JSONSerialization.data(withJSONObject: errorResult) + return String(data: jsonData, encoding: .utf8) ?? "{\"error\": \"JSON序列化失败\"}" } } } -// MARK: - 功能处理器实现 - /// 退出交互处理器 private class ExitInteractionHandler: FunctionHandler { func handle(arguments: [String: Any]) async throws -> String { @@ -460,22 +397,23 @@ private class SendTextMessageHandler: FunctionHandler { } private func sendTextMessage(contact: String, message: String) async -> Result { - guard let url = URL(string: "sms:\(contact)&body=\(message.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")") else { - return Result(success: false, message: "无法创建短信URL") - } - return await withCheckedContinuation { continuation in DispatchQueue.main.async { - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) { success in - if success { - continuation.resume(returning: Result(success: true, message: "已打开短信应用")) - } else { - continuation.resume(returning: Result(success: false, message: "打开短信应用失败")) - } + if MFMessageComposeViewController.canSendText() { + let messageVC = MFMessageComposeViewController() + messageVC.recipients = [contact] + messageVC.body = message + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootViewController = window.rootViewController { + rootViewController.present(messageVC, animated: true) + continuation.resume(returning: Result(success: true, message: "短信界面已打开")) + } else { + continuation.resume(returning: Result(success: false, message: "无法打开短信界面")) } } else { - continuation.resume(returning: Result(success: false, message: "无法打开短信应用")) + continuation.resume(returning: Result(success: false, message: "设备不支持发送短信")) } } } @@ -591,34 +529,16 @@ private class GetCurrentTimeHandler: FunctionHandler { private func getCurrentTime(format: String?) -> Result { let formatter = DateFormatter() - if let format = format, !format.isEmpty { - formatter.dateFormat = format + if let customFormat = format, !customFormat.isEmpty { + formatter.dateFormat = customFormat } else { - formatter.dateStyle = .full - formatter.timeStyle = .full - formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" } - let currentTime = formatter.string(from: Date()) - return Result(success: true, message: currentTime) - } -} - -/// 获取当前位置处理器 -private class GetCurrentLocationHandler: FunctionHandler { - private let systemHandler: SystemFunctionHandler - - init(systemHandler: SystemFunctionHandler) { - self.systemHandler = systemHandler - } - - func handle(arguments: [String: Any]) async throws -> String { - // guard let systemHandler = systemHandler else { - // throw ChatApiException("系统处理器未初始化") - // } + formatter.locale = Locale(identifier: "zh_CN") + let timeString = formatter.string(from: Date()) - let result: Result = await systemHandler.getCurrentLocation() - return "{\"result\": \"\(result.message)\", \"success\": \(result.success)}" + return Result(success: true, message: timeString) } } @@ -666,4 +586,4 @@ private class OpenRecorderHandler: FunctionHandler { return "{\"result\": \"无法打开录音机应用\"}" } -} +} diff --git a/local_plugins/chat_storage/ios/chat_storage/Package.swift b/local_plugins/chat_storage/ios/chat_storage/Package.swift index 09003f501..9c4d00926 100644 --- a/local_plugins/chat_storage/ios/chat_storage/Package.swift +++ b/local_plugins/chat_storage/ios/chat_storage/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "chat_storage", - platforms: [.iOS("18.0")], + platforms: [.iOS("16.0")], products: [ .library(name: "chat-storage", targets: ["chat_storage"]) ], diff --git a/local_plugins/classic_bluetooth/ios/classic_bluetooth.podspec b/local_plugins/classic_bluetooth/ios/classic_bluetooth.podspec index 79fea0113..47914da1b 100644 --- a/local_plugins/classic_bluetooth/ios/classic_bluetooth.podspec +++ b/local_plugins/classic_bluetooth/ios/classic_bluetooth.podspec @@ -13,7 +13,7 @@ Pod::Spec.new do |s| s.author = { 'YunQi Innovation' => 'support@yunqiinnovation.com' } s.source = { :path => '.' } s.source_files = 'Classes/**/*' - s.ios.deployment_target = '18.0' + s.ios.deployment_target = '16.0' s.dependency 'Flutter' s.swift_version = '5.0' diff --git a/local_plugins/location_service/ios/.gitignore b/local_plugins/location_service/ios/.gitignore deleted file mode 100644 index 034771fc9..000000000 --- a/local_plugins/location_service/ios/.gitignore +++ /dev/null @@ -1,38 +0,0 @@ -.idea/ -.vagrant/ -.sconsign.dblite -.svn/ - -.DS_Store -*.swp -profile - -DerivedData/ -build/ -GeneratedPluginRegistrant.h -GeneratedPluginRegistrant.m - -.generated/ - -*.pbxuser -*.mode1v3 -*.mode2v3 -*.perspectivev3 - -!default.pbxuser -!default.mode1v3 -!default.mode2v3 -!default.perspectivev3 - -xcuserdata - -*.moved-aside - -*.pyc -*sync/ -Icon? -.tags* - -/Flutter/Generated.xcconfig -/Flutter/ephemeral/ -/Flutter/flutter_export_environment.sh diff --git a/local_plugins/location_service/ios/Classes/LocationService.swift b/local_plugins/location_service/ios/Classes/LocationService.swift deleted file mode 100644 index d71179ee3..000000000 --- a/local_plugins/location_service/ios/Classes/LocationService.swift +++ /dev/null @@ -1,131 +0,0 @@ -import Foundation -import AMapLocationKit - -extension Notification.Name { - static let locationstartEvent = Notification.Name("locationstartEvent") //启动定位 - static let updatelocationEvent = Notification.Name("updatelocationEvent") //更新位置事件 -} -class LocationService: NSObject { - static let shared = LocationService() - private let TAG = "LocationService" - - private var locationManager: AMapLocationManager! - private var currentLocation: CLLocation? - private var currentReGeocode: AMapLocationReGeocode? - private var initialized = false - - private override init() { - super.init() - } - - // 初始化定位服务 - func initialize() { - if initialized { return } - print("\(TAG)--------- 初始化高德定位服务") - - locationManager = AMapLocationManager() - locationManager.delegate = self - - locationManager.desiredAccuracy = kCLLocationAccuracyBest // 定位精度 - locationManager.distanceFilter = 10.0 // 移动距离阈值(单位:米) - locationManager.locatingWithReGeocode = true // 是否返回逆地理编码信息 - locationManager.allowsBackgroundLocationUpdates = true // 是否允许后台定位 - locationManager.locationTimeout = 10 // 定位超时时间(秒) - locationManager.reGeocodeTimeout = 5 // 逆地理编码超时时间(秒) - - locationManager.startUpdatingLocation() - - initialized = true - } - - // 获取缓存的位置信息 - func getCachedLocation() -> [String: String]? { - guard let location = currentLocation else { return nil } - - var locationInfo: [String: String] = [ - "user_lat": "\(location.coordinate.latitude)", - "user_lng": "\(location.coordinate.longitude)" - ] - - if let regeocode = currentReGeocode { - let address = [ - regeocode.province, // 省 - regeocode.city, // 市 - regeocode.district, // 区 - regeocode.street, // 街道 - regeocode.number // 门牌号 - ].compactMap { $0 }.joined(separator: "") - - locationInfo["user_location"] = address - } - - return locationInfo - } - - // 获取当前位置(带回调) - func getCurrentLocation(completion: @escaping ([String: String]?, Error?) -> Void) { - if let cached = getCachedLocation() { - completion(cached, nil) - } else { - let error = NSError( - domain: "LocationService", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "当前位置还未获取"] - ) - completion(nil, error) - } - } - - // 停止定位服务 - func stop() { - locationManager.stopUpdatingLocation() - locationManager = nil - currentLocation = nil - currentReGeocode = nil - initialized = false - } -} - -// MARK: - AMapLocationManagerDelegate -extension LocationService: AMapLocationManagerDelegate { - // 定位更新回调 - func amapLocationManager(_ manager: AMapLocationManager!, didUpdate location: CLLocation!, reGeocode: AMapLocationReGeocode!) { - currentLocation = location - currentReGeocode = reGeocode - - print("\(TAG)--------- 定位更新: \(location.coordinate.latitude), \(location.coordinate.longitude)") - - var locationInfo: [String: String] = [ - "user_lat": "\(location.coordinate.latitude)", - "user_lng": "\(location.coordinate.longitude)" - ] - if let regeocode = currentReGeocode { - let address = [ - reGeocode.province, // 省 - reGeocode.city, // 市 - reGeocode.district, // 区 - reGeocode.street, // 街道 - reGeocode.number // 门牌号 - ].compactMap { $0 }.joined(separator: "") - print("\(TAG)--------- 定位地址: \(address)") - locationInfo["user_location"] = address - } - - - NotificationCenter.default.post( - name: .updatelocationEvent, - object: nil, - userInfo: locationInfo - ) - } - - // 定位失败回调 - func amapLocationManager(_ manager: AMapLocationManager!, didFailWithError error: Error!) { - print("\(TAG)--------- 定位失败: \(error.localizedDescription)") - } - - // 权限变更回调 - func amapLocationManager(_ manager: AMapLocationManager!, doRequireLocationAuth locationManager: CLLocationManager!) { - locationManager.requestWhenInUseAuthorization() - } -} diff --git a/local_plugins/location_service/ios/Classes/LocationServicePlugin.swift b/local_plugins/location_service/ios/Classes/LocationServicePlugin.swift deleted file mode 100644 index ad30a36f7..000000000 --- a/local_plugins/location_service/ios/Classes/LocationServicePlugin.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Flutter -import UIKit -import AMapLocationKit - -public class LocationServicePlugin: NSObject, FlutterPlugin { - private static var channel: FlutterMethodChannel? - - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "com.yunqiinnovation.location_service", binaryMessenger: registrar.messenger()) - let instance = LocationServicePlugin() - // 必须先设置隐私合规 - DispatchQueue.main.async { - // 2. API Key - AMapServices.shared()?.apiKey = "aa60a03be9f6531bcc64029e60f95d03" - AMapServices.shared().enableHTTPS = true - // 3. 打印验证(调试用) - print("高德SDK初始化完成") - } - registrar.addMethodCallDelegate(instance, channel: channel) - self.channel = channel - instance.addstartlocationmonitor() - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "location": - result(nil) - default: - result(FlutterMethodNotImplemented) - } - } - - private func addstartlocationmonitor(){ - // 通常放在启动后或模块初始化时 - NotificationCenter.default.addObserver( - forName: .locationstartEvent, - object: nil, - queue: .main - ) { notification in - print("📥 收到 启动定位服务事件") - LocationService.shared.initialize() - } - } -} diff --git a/local_plugins/location_service/ios/location_service.podspec b/local_plugins/location_service/ios/location_service.podspec deleted file mode 100644 index 549e6c473..000000000 --- a/local_plugins/location_service/ios/location_service.podspec +++ /dev/null @@ -1,27 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. -# Run `pod lib lint location_service.podspec` to validate before publishing. -# -Pod::Spec.new do |s| - s.name = 'location_service' - s.version = '0.0.1' - s.summary = 'A new Flutter project.' - s.description = <<-DESC -A new Flutter project. - DESC - s.homepage = 'http://example.com' - s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.dependency 'Flutter' - s.dependency 'AMapLocation', '~> 2.10.0' - - s.static_framework = true - s.pod_target_xcconfig = { - 'DEFINES_MODULE' => 'YES', - 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' - } - s.platform = :ios, '12.0' - s.swift_version = '5.0' -end diff --git a/local_plugins/location_service/ios/location_service/Package.swift b/local_plugins/location_service/ios/location_service/Package.swift new file mode 100644 index 000000000..796eef704 --- /dev/null +++ b/local_plugins/location_service/ios/location_service/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "location_service", + platforms: [ + .iOS(.v16), + .macOS(.v13) + ], + products: [ + .library(name: "location-service", targets: ["location_service"]) + ], + dependencies: [ ], + targets: [ + .target( + name: "location_service", + dependencies: [], + path: "Sources/location_service" + ) + ] +) diff --git a/local_plugins/location_service/ios/location_service/Sources/location_service/LocationCallback.swift b/local_plugins/location_service/ios/location_service/Sources/location_service/LocationCallback.swift new file mode 100644 index 000000000..8a778f9ad --- /dev/null +++ b/local_plugins/location_service/ios/location_service/Sources/location_service/LocationCallback.swift @@ -0,0 +1,7 @@ +import Foundation + +/// 定位回调协议 +public protocol LocationCallback: AnyObject { + func onLocationSuccess(latitude: Double, longitude: Double, address: String) + func onLocationError(errorCode: Int, errorMessage: String) +} \ No newline at end of file diff --git a/local_plugins/location_service/ios/location_service/Sources/location_service/LocationData.swift b/local_plugins/location_service/ios/location_service/Sources/location_service/LocationData.swift new file mode 100644 index 000000000..af7f9827c --- /dev/null +++ b/local_plugins/location_service/ios/location_service/Sources/location_service/LocationData.swift @@ -0,0 +1,16 @@ +import Foundation + +/// 位置数据结构 +public struct LocationData: Codable { + public let latitude: Double + public let longitude: Double + public let address: String + public let timestamp: TimeInterval + + public init(latitude: Double, longitude: Double, address: String, timestamp: TimeInterval) { + self.latitude = latitude + self.longitude = longitude + self.address = address + self.timestamp = timestamp + } +} diff --git a/local_plugins/location_service/ios/location_service/Sources/location_service/LocationService.swift b/local_plugins/location_service/ios/location_service/Sources/location_service/LocationService.swift new file mode 100644 index 000000000..04c991abc --- /dev/null +++ b/local_plugins/location_service/ios/location_service/Sources/location_service/LocationService.swift @@ -0,0 +1,357 @@ +import Foundation +import CoreLocation +import UIKit + +public extension Notification.Name { + static let locationStartEvent = Notification.Name("locationStartEvent") + static let updateLocationEvent = Notification.Name("updateLocationEvent") +} + +public class LocationService: NSObject { + private static let TAG = "LocationService" + private static let CACHE_PREFS_NAME = "location_cache" + private static let PERMISSION_REQUEST_CODE = 1001 + + public static let shared = LocationService() + + private var locationManager: CLLocationManager? + private var initialized = false + private var permissionRetryTimer: Timer? + + // 存储当前的定位请求回调(Flutter用) + private var currentLocationCallback: ((Result<[String: Any], Error>) -> Void)? + // 存储权限请求回调(Flutter用) + private var permissionRequestCallback: ((Result<[String: Bool], Error>) -> Void)? + + // 存储服务间调用的回调 + private var serviceCallbacks: [LocationCallback] = [] + + // 缓存的位置数据 + private var cachedLocationData: LocationData? + + private override init() { + super.init() + } + + /// 检查应用是否在前台运行 + public func isAppInForeground() -> Bool { + return UIApplication.shared.applicationState == .active + } + + /// 初始化定位服务 + public func initialize() { + if initialized { return } + + if !checkPermissions() { + print("\(LocationService.TAG): 没有定位权限,等待用户授权中...") + startPermissionMonitor() + return + } + + setupLocationManager() + initialized = true + print("\(LocationService.TAG): 定位服务初始化完成") + } + + private func setupLocationManager() { + locationManager = CLLocationManager() + locationManager?.delegate = self + locationManager?.desiredAccuracy = kCLLocationAccuracyBest + locationManager?.distanceFilter = 10 + } + + private func startPermissionMonitor() { + permissionRetryTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in + guard let self = self else { return } + let hasPermission = self.checkPermissions() + if hasPermission && !self.initialized { + print("\(LocationService.TAG): 权限已授予,重新初始化定位服务") + self.initialize() + self.stopPermissionMonitor() + } + } + } + + private func stopPermissionMonitor() { + permissionRetryTimer?.invalidate() + permissionRetryTimer = nil + } + + /// 检查定位权限 + private func checkPermissions() -> Bool { + return hasForegroundLocationPermission() + } + + private func hasForegroundLocationPermission() -> Bool { + let status = CLLocationManager.authorizationStatus() + return status == .authorizedWhenInUse || status == .authorizedAlways + } + + private func hasBackgroundLocationPermission() -> Bool { + return CLLocationManager.authorizationStatus() == .authorizedAlways + } + + /// 检查定位权限状态(Flutter用) + public func checkLocationPermission(completion: @escaping (Result<[String: Bool], Error>) -> Void) { + let hasPermission = checkPermissions() + let result: [String: Bool] = [ + "hasPermission": hasPermission, + "hasForegroundPermission": hasForegroundLocationPermission(), + "hasBackgroundPermission": hasBackgroundLocationPermission() + ] + completion(.success(result)) + } + + /// 请求定位权限(Flutter用) + public func requestLocationPermission(completion: @escaping (Result<[String: Bool], Error>) -> Void) { + if checkPermissions() { + completion(.success(["granted": true])) + return + } + + permissionRequestCallback = completion + locationManager?.requestWhenInUseAuthorization() + } + + /// 按需获取当前位置(Flutter用) + public func getCurrentLocation(completion: @escaping (Result<[String: Any], Error>) -> Void) { + if !checkPermissions() { + completion(.failure(NSError(domain: "LocationService", code: -2, userInfo: [NSLocalizedDescriptionKey: "需要定位权限"]))) + return + } + + if !initialized { + completion(.failure(NSError(domain: "LocationService", code: -3, userInfo: [NSLocalizedDescriptionKey: "定位服务未初始化"]))) + return + } + + if currentLocationCallback != nil { + completion(.failure(NSError(domain: "LocationService", code: -4, userInfo: [NSLocalizedDescriptionKey: "正在定位中,请稍后再试"]))) + return + } + + // 保存回调,开始定位 + currentLocationCallback = completion + locationManager?.requestLocation() + } + + // MARK: - 供其他iOS服务使用的接口 + + /// 检查是否有定位权限(供其他服务使用) + public func hasLocationPermission() -> Bool { + return checkPermissions() + } + + /// 异步获取当前位置(供其他iOS服务使用) + public func getCurrentLocation() async throws -> LocationData { + return try await withCheckedThrowingContinuation { continuation in + getCurrentLocation { result in + switch result { + case .success(let locationInfo): + // 从返回的字典中提取位置信息 + if let latStr = locationInfo["user_lat"] as? String, + let lngStr = locationInfo["user_lng"] as? String, + let latitude = Double(latStr), + let longitude = Double(lngStr), + let address = locationInfo["user_location"] as? String { + + let locationData = LocationData( + latitude: latitude, + longitude: longitude, + address: address, + timestamp: Date().timeIntervalSince1970 * 1000 + ) + continuation.resume(returning: locationData) + } else { + continuation.resume(throwing: NSError(domain: "LocationService", code: -5, userInfo: [NSLocalizedDescriptionKey: "位置数据格式错误"])) + } + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + /// 获取当前位置(供其他iOS服务使用) + public func requestLocation(callback: LocationCallback) { + if !checkPermissions() { + callback.onLocationError(errorCode: -2, errorMessage: "没有定位权限") + return + } + + if !initialized { + callback.onLocationError(errorCode: -3, errorMessage: "定位服务未初始化") + return + } + + // 添加到回调列表 + serviceCallbacks.append(callback) + + // 如果没有正在进行的定位请求,开始定位 + if currentLocationCallback == nil && serviceCallbacks.count == 1 { + locationManager?.requestLocation() + } + } + + /// 同步获取缓存的位置信息(供其他服务使用) + /// 注意:这个方法返回的可能是过期的位置信息 + public func getCachedLocationData() -> LocationData? { + // 优先返回内存中的缓存 + if let cached = cachedLocationData { + return cached + } + + // 从UserDefaults中读取缓存 + return loadCachedLocationFromUserDefaults() + } + + private func saveCachedLocationToUserDefaults(_ locationData: LocationData) { + do { + let encoder = JSONEncoder() + let data = try encoder.encode(locationData) + UserDefaults.standard.set(data, forKey: "latitude") + UserDefaults.standard.set(String(locationData.latitude), forKey: "latitude") + UserDefaults.standard.set(String(locationData.longitude), forKey: "longitude") + UserDefaults.standard.set(locationData.address, forKey: "address") + UserDefaults.standard.set(Double(locationData.timestamp), forKey: "timestamp") + UserDefaults.standard.synchronize() + } catch { + print("\(LocationService.TAG): 保存位置缓存失败: \(error.localizedDescription)") + } + } + + private func loadCachedLocationFromUserDefaults() -> LocationData? { + do { + guard let latitudeStr = UserDefaults.standard.string(forKey: "latitude"), + let longitudeStr = UserDefaults.standard.string(forKey: "longitude"), + let latitude = Double(latitudeStr), + let longitude = Double(longitudeStr) else { + return nil + } + + let address = UserDefaults.standard.string(forKey: "address") ?? "未知地址" + let timestampDouble = UserDefaults.standard.double(forKey: "timestamp") + + if timestampDouble == 0 { return nil } + + return LocationData( + latitude: latitude, + longitude: longitude, + address: address, + timestamp: TimeInterval(timestampDouble) + ) + } catch { + print("\(LocationService.TAG): 读取位置缓存失败: \(error.localizedDescription)") + return nil + } + } + + /// 停止定位服务 + public func stop() { + locationManager?.stopUpdatingLocation() + locationManager = nil + currentLocationCallback = nil + permissionRequestCallback = nil + serviceCallbacks.removeAll() + initialized = false + stopPermissionMonitor() + } + + private func handleLocationResult(_ location: CLLocation?, error: Error?) { + // 处理Flutter回调 + let flutterCallback = currentLocationCallback + currentLocationCallback = nil + + if let location = location, error == nil { + print("\(LocationService.TAG): 定位成功: \(location.coordinate.latitude), \(location.coordinate.longitude)") + + // 进行逆地理编码获取地址 + let geocoder = CLGeocoder() + geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, geocodeError in + guard let self = self else { return } + + let address = placemarks?.first?.name ?? "未知地址" + + // 缓存位置数据 + let locationData = LocationData( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude, + address: address, + timestamp: TimeInterval(Date().timeIntervalSince1970) * 1000 + ) + self.cachedLocationData = locationData + self.saveCachedLocationToUserDefaults(locationData) + + // 通知Flutter + if let callback = flutterCallback { + let result: [String: Any] = [ + "user_location": address, + "user_lat": String(location.coordinate.latitude), + "user_lng": String(location.coordinate.longitude) + ] + callback(.success(result)) + } + + // 通知其他iOS服务 + self.serviceCallbacks.forEach { callback in + callback.onLocationSuccess( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude, + address: address + ) + } + + // 清空服务回调列表(单次定位) + self.serviceCallbacks.removeAll() + } + } else { + let errorMessage = error?.localizedDescription ?? "未知错误" + print("\(LocationService.TAG): 定位失败: \(errorMessage)") + + // 通知Flutter + if let callback = flutterCallback { + callback(.failure(NSError(domain: "LocationService", code: -1, userInfo: [NSLocalizedDescriptionKey: "定位失败: \(errorMessage)"]))) + } + + // 通知其他iOS服务 + serviceCallbacks.forEach { callback in + callback.onLocationError(errorCode: -1, errorMessage: errorMessage) + } + + // 清空服务回调列表 + serviceCallbacks.removeAll() + } + } +} + +// MARK: - CLLocationManagerDelegate +extension LocationService: CLLocationManagerDelegate { + public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + handleLocationResult(location, error: nil) + } + + public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + handleLocationResult(nil, error: error) + } + + public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + print("\(LocationService.TAG): 定位权限状态变更: \(status.rawValue)") + + switch status { + case .authorizedWhenInUse, .authorizedAlways: + if !initialized { + initialize() + } + permissionRequestCallback?(.success(["granted": true])) + permissionRequestCallback = nil + case .denied, .restricted: + permissionRequestCallback?(.success(["granted": false])) + permissionRequestCallback = nil + case .notDetermined: + break + @unknown default: + break + } + } +} diff --git a/local_plugins/location_service/ios/location_service/Sources/location_service/LocationServicePlugin.swift b/local_plugins/location_service/ios/location_service/Sources/location_service/LocationServicePlugin.swift new file mode 100644 index 000000000..80b383126 --- /dev/null +++ b/local_plugins/location_service/ios/location_service/Sources/location_service/LocationServicePlugin.swift @@ -0,0 +1,72 @@ +import Foundation + +#if canImport(Flutter) +import Flutter +import UIKit + +public class LocationServicePlugin: NSObject, FlutterPlugin { + private static var channel: FlutterMethodChannel? + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "com.yunqiinnovation.location_service", binaryMessenger: registrar.messenger()) + let instance = LocationServicePlugin() + + registrar.addMethodCallDelegate(instance, channel: channel) + self.channel = channel + instance.addStartLocationMonitor() + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "location": + result(nil) + case "initialize": + LocationService.shared.initialize() + result(nil) + case "getCurrentLocation": + LocationService.shared.getCurrentLocation { locationResult in + switch locationResult { + case .success(let locationInfo): + result(locationInfo) + case .failure(let error): + result(FlutterError(code: "LOCATION_ERROR", message: error.localizedDescription, details: nil)) + } + } + case "checkPermission": + LocationService.shared.checkLocationPermission { permissionResult in + switch permissionResult { + case .success(let permissions): + result(permissions) + case .failure(let error): + result(FlutterError(code: "PERMISSION_ERROR", message: error.localizedDescription, details: nil)) + } + } + case "requestPermission": + LocationService.shared.requestLocationPermission { permissionResult in + switch permissionResult { + case .success(let permissions): + result(permissions) + case .failure(let error): + result(FlutterError(code: "PERMISSION_ERROR", message: error.localizedDescription, details: nil)) + } + } + case "stop": + LocationService.shared.stop() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + private func addStartLocationMonitor() { + NotificationCenter.default.addObserver( + forName: .locationStartEvent, + object: nil, + queue: .main + ) { notification in + print("📥 收到启动定位服务事件") + LocationService.shared.initialize() + } + } +} +#endif \ No newline at end of file diff --git a/local_plugins/location_service/pubspec.yaml b/local_plugins/location_service/pubspec.yaml index 04f1a58f4..27ec7a94e 100644 --- a/local_plugins/location_service/pubspec.yaml +++ b/local_plugins/location_service/pubspec.yaml @@ -26,4 +26,5 @@ flutter: pluginClass: LocationServicePlugin ios: pluginClass: LocationServicePlugin - + swiftPackage: + path: ios/location_service diff --git a/local_plugins/music_service/ios/music_service/Package.swift b/local_plugins/music_service/ios/music_service/Package.swift index f8268662a..7f1aa6bba 100644 --- a/local_plugins/music_service/ios/music_service/Package.swift +++ b/local_plugins/music_service/ios/music_service/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "music_service", - platforms: [.iOS("18.0")], + platforms: [.iOS("16.0")], products: [ .library(name: "music-service", targets: ["music_service"]) ], diff --git a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift b/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift index 45b830e4a..ce5ca2ab5 100644 --- a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift +++ b/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift @@ -4,6 +4,7 @@ // // Created by 李伟 on 2025/6/4. // +import Flutter import Foundation import AVFoundation import MediaPlayer @@ -13,71 +14,187 @@ public class MusicService: NSObject { public static let shared: MusicService = MusicService() private var player: AVPlayer? - private var playlist: [[String: String]] = [] + private var playlist: [[String: Any]] = [] private var currentIndex = 0 public var isPlaying = false private var flutterChannel: FlutterMethodChannel? private var progressTimer: Timer? + // 音量控制相关 + private var currentVolume: Float = 1.0 + private var originalVolume: Float = 1.0 + private var isVolumeReduced: Bool = false + + // 当前播放的歌曲ID + private var currentIdInternal: Int64? = nil + private override init() { super.init() setupRemoteCommandCenter() + setupAudioSession() + } + + // MARK: - 音频会话设置 + private func setupAudioSession() { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.allowAirPlay, .allowBluetooth]) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + print("设置音频会话失败: \(error)") + } } + // MARK: - 基础播放控制 func getCurrentUrlString() -> String? { guard let urlAsset = player?.currentItem?.asset as? AVURLAsset else { return nil } return urlAsset.url.absoluteString } + + func getCurrentId() -> Int64? { + return currentIdInternal + } - public func play(song: [String: String]) { - guard let urlString = song["url"], let url = URL(string: urlString) else { return } - player = AVPlayer(url: url) - player?.play() - isPlaying = true - updateNowPlayingInfo(song: song) + public func play(song: [String: Any]) { + guard let urlString = song["url"] as? String, let url = URL(string: urlString) else { return } + + // 获取歌曲ID + if let id = song["id"] as? NSNumber { + currentIdInternal = id.int64Value + } else { + currentIdInternal = 0 + } + + print("开始播放: \(urlString), ID: \(currentIdInternal ?? 0)") + + // 检查是否已在播放列表中 + let existingIndex = playlist.firstIndex { existingSong in + if let existingId = existingSong["id"] as? NSNumber, + let currentId = song["id"] as? NSNumber { + return existingId.int64Value == currentId.int64Value + } + return false + } + + if existingIndex == nil { + playlist.append(song) + currentIndex = playlist.count - 1 + } else { + currentIndex = existingIndex! + } + + // 创建播放项 + let playerItem = AVPlayerItem(url: url) + player = AVPlayer(playerItem: playerItem) + + // 设置播放器观察者 + setupPlayerObservers() + + // 监听播放项状态变化 + setupPlayerItemObservers(playerItem: playerItem, song: song) + + currentVolume = 1.0 + player?.volume = currentVolume + + print("播放器已创建,等待加载完成...") } - public func playPlaylist(songs: [[String: String]]) { + public func playPlaylist(songs: [[String: Any]]) { playlist = songs currentIndex = 0 - play(song: playlist[currentIndex]) + if !playlist.isEmpty { + play(song: playlist[currentIndex]) + } } func pause() { player?.pause() isPlaying = false updatePlaybackState() + updateMediaSessionPlaybackState(isPlaying: false) + stopProgressTimer() + print("已暂停") } func resume() { player?.play() isPlaying = true updatePlaybackState() + updateMediaSessionPlaybackState(isPlaying: true) + startProgressTimer() + print("继续播放") } func next() { - guard currentIndex + 1 < playlist.count else { return } - currentIndex += 1 - play(song: playlist[currentIndex]) + playNext() } func previous() { - guard currentIndex - 1 >= 0 else { return } - currentIndex -= 1 + playPrevious() + } + + private func playNext() { + guard !playlist.isEmpty else { return } + currentIndex = (currentIndex + 1) % playlist.count play(song: playlist[currentIndex]) } + + private func playPrevious() { + guard !playlist.isEmpty else { return } + currentIndex = currentIndex - 1 < 0 ? playlist.count - 1 : currentIndex - 1 + play(song: playlist[currentIndex]) + } + + private func playNextOrStop() { + if currentIndex + 1 < playlist.count { + currentIndex += 1 + play(song: playlist[currentIndex]) + } else { + stop() + } + } + func stop() { player?.pause() player?.replaceCurrentItem(with: nil) isPlaying = false + currentIdInternal = nil updatePlaybackState() + updateMediaSessionPlaybackState(isPlaying: false) + stopProgressTimer() + print("已停止") } + + // MARK: - 音量控制 func setVolume(volume: Float) { - player?.volume = volume + let clampedVolume = max(0.0, min(1.0, volume)) + currentVolume = clampedVolume + player?.volume = clampedVolume + print("设置音量: \(clampedVolume)") + } + + /// 降低音量(AI说话时) + func reduceVolume() { + if !isVolumeReduced { + originalVolume = currentVolume + let reducedVolume = max(0.0, min(1.0, currentVolume * 0.3)) // 降低到30% + setVolume(volume: reducedVolume) + isVolumeReduced = true + print("AI说话开始,音量从 \(originalVolume) 降低到 \(reducedVolume)") + } + } + + /// 恢复原始音量(AI说话结束时) + func restoreVolume() { + if isVolumeReduced { + setVolume(volume: originalVolume) + isVolumeReduced = false + print("AI说话结束,音量恢复到 \(originalVolume)") + } } + // MARK: - 进度控制 func getProgress() -> [String: Any] { guard let player = player, let currentItem = player.currentItem else { return [ @@ -86,27 +203,60 @@ public class MusicService: NSObject { "progress": 0.0 ] } - + let currentTime = CMTimeGetSeconds(player.currentTime()) let duration = CMTimeGetSeconds(currentItem.duration) + + // 更严格的有效性检查 + let currentPosition = (currentTime.isNaN || currentTime.isInfinite || currentTime < 0) ? 0 : Int(currentTime * 1000) + let totalDuration = (duration.isNaN || duration.isInfinite || duration <= 0) ? 0 : Int(duration * 1000) + let progress = (totalDuration > 0 && currentTime >= 0) ? currentTime / duration : 0.0 + + let result: [String: Any] = [ // Add explicit type annotation here + "currentPosition": currentPosition, + "duration": totalDuration, + "progress": progress.isNaN || progress.isInfinite ? 0.0 : progress + ] - // 处理无效值 - let currentPosition = currentTime.isNaN || currentTime.isInfinite ? 0 : Int(currentTime * 1000) - let totalDuration = duration.isNaN || duration.isInfinite ? 0 : Int(duration * 1000) - let progress = totalDuration > 0 ? currentTime / duration : 0.0 - - return [ - "currentPosition": currentPosition, - "duration": totalDuration, - "progress": progress.isNaN || progress.isInfinite ? 0.0 : progress - ] + + // 添加调试日志 + if totalDuration == 0 { + print("警告: 音频时长为0,可能还在加载中。原始duration: \(duration)") + } + + return result } func seekTo(positionMs: Int) { guard let player = player else { return } + + // 记录当前播放状态 + let wasPlaying = isPlaying + let time = CMTime(seconds: Double(positionMs) / 1000.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) - player.seek(to: time) + + player.seek(to: time) { [weak self] completed in + if completed { + print("跳转到位置: \(positionMs)ms") + + // 如果之前是播放状态,确保继续播放 + if wasPlaying { + self?.player?.play() + self?.isPlaying = true + self?.updateMediaSessionPlaybackState(isPlaying: true) + self?.startProgressTimer() + } + + // 立即更新进度信息到系统媒体控制中心 + self?.updateProgressInfoImmediately() + + // 通知Flutter层 + self?.flutterChannel?.invokeMethod("onMediaSessionSeekTo", arguments: ["position": positionMs]) + } + } } + + // MARK: - 媒体会话控制 private func setupRemoteCommandCenter() { let commandCenter = MPRemoteCommandCenter.shared() @@ -129,21 +279,41 @@ public class MusicService: NSObject { self.previous() return .success } + + commandCenter.stopCommand.addTarget { [unowned self] _ in + self.stop() + return .success + } } - private func updateNowPlayingInfo(song: [String: String]) { + private func updateNowPlayingInfo(song: [String: Any]) { + let title = song["title"] as? String ?? song["name"] as? String ?? "未知标题" + let artist = song["artist"] as? String ?? song["sgener"] as? String ?? "未知歌手" + let coverUrl = song["coverUrl"] as? String ?? song["image"] as? String ?? "" + var nowPlayingInfo: [String: Any] = [ - MPMediaItemPropertyTitle: song["title"] ?? "Unknown Title", - MPMediaItemPropertyArtist: song["artist"] ?? "Unknown Artist" + MPMediaItemPropertyTitle: title, + MPMediaItemPropertyArtist: artist ] - - if let coverUrlString = song["coverUrl"], let coverUrl = URL(string: coverUrlString), - let data = try? Data(contentsOf: coverUrl), - let image = UIImage(data: data) { - nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + + // 异步加载封面图片 + if !coverUrl.isEmpty, let url = URL(string: coverUrl) { + DispatchQueue.global(qos: .background).async { + if let data = try? Data(contentsOf: url), + let image = UIImage(data: data) { + DispatchQueue.main.async { + nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image } + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + } + } else { + DispatchQueue.main.async { + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + } + } + } + } else { + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo } - - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo } private func updatePlaybackState() { @@ -169,13 +339,21 @@ public class MusicService: NSObject { flutterChannel = channel setupEnhancedRemoteCommandCenter() startProgressTimer() + print("媒体会话回调已启用") } /// 更新媒体会话元数据 public func updateMediaSessionMetadata(song: [String: Any]) { - let title = song["title"] as? String ?? song["name"] as? String ?? "Unknown Title" - let artist = song["artist"] as? String ?? song["sgener"] as? String ?? "Unknown Artist" + let title = song["title"] as? String ?? song["name"] as? String ?? "未知标题" + let artist = song["artist"] as? String ?? song["sgener"] as? String ?? "未知歌手" let coverUrl = song["coverUrl"] as? String ?? song["image"] as? String ?? "" + + // 更新当前歌曲ID + if let id = song["id"] as? NSNumber { + currentIdInternal = id.int64Value + } + + print("updateMediaSessionMetadata currentIdInternal: \(currentIdInternal ?? 0)") var nowPlayingInfo: [String: Any] = [ MPMediaItemPropertyTitle: title, @@ -200,6 +378,8 @@ public class MusicService: NSObject { } else { MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo } + + print("媒体会话元数据已更新: \(title) - \(artist)") } /// 更新媒体会话播放状态 @@ -214,6 +394,8 @@ public class MusicService: NSObject { } else { stopProgressTimer() } + + print("媒体会话播放状态已更新: \(isPlaying)") } /// 设置增强的远程控制中心 @@ -225,6 +407,7 @@ public class MusicService: NSObject { commandCenter.pauseCommand.removeTarget(nil) commandCenter.nextTrackCommand.removeTarget(nil) commandCenter.previousTrackCommand.removeTarget(nil) + commandCenter.changePlaybackPositionCommand.removeTarget(nil) // 播放命令 commandCenter.playCommand.addTarget { [weak self] _ in @@ -254,13 +437,12 @@ public class MusicService: NSObject { return .success } - // 跳转命令 + // 跳转命令 - 关键升级点 commandCenter.changePlaybackPositionCommand.isEnabled = true commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in if let event = event as? MPChangePlaybackPositionCommandEvent { let positionMs = Int(event.positionTime * 1000) self?.seekTo(positionMs: positionMs) - self?.flutterChannel?.invokeMethod("onMediaSessionSeekTo", arguments: ["position": positionMs]) return .success } return .commandFailed @@ -272,6 +454,8 @@ public class MusicService: NSObject { self?.flutterChannel?.invokeMethod("onMediaSessionStop", arguments: nil) return .success } + + print("增强的远程控制中心已设置,支持进度条滑动") } /// 启动进度定时器 @@ -296,11 +480,148 @@ public class MusicService: NSObject { let duration = CMTimeGetSeconds(currentItem.duration) if !currentTime.isNaN && !currentTime.isInfinite && !duration.isNaN && !duration.isInfinite { - var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] - nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime - nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration - nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + // 更新系统媒体信息 - 关键升级点 + updateSystemMediaInfo(currentTime: currentTime, duration: duration) + + // 通知Flutter层 + let currentPosition = Int(currentTime * 1000) + let totalDuration = Int(duration * 1000) + let progress = totalDuration > 0 ? Float(currentTime / duration) : 0.0 + + flutterChannel?.invokeMethod("onMediaSessionProgress", arguments: [ + "currentIdInternal": currentIdInternal ?? 0, + "currentPosition": currentPosition, + "duration": totalDuration, + "progress": progress + ]) } } + + /// 立即更新进度信息(用于seekTo后) + private func updateProgressInfoImmediately() { + guard let player = player, let currentItem = player.currentItem else { return } + + let currentTime = CMTimeGetSeconds(player.currentTime()) + let duration = CMTimeGetSeconds(currentItem.duration) + + if !currentTime.isNaN && !currentTime.isInfinite && !duration.isNaN && !duration.isInfinite { + updateSystemMediaInfo(currentTime: currentTime, duration: duration) + } + } + + /// 更新系统媒体信息 + private func updateSystemMediaInfo(currentTime: Double, duration: Double) { + var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0 + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + } + + // MARK: - 播放完成处理 + private func setupPlayerObservers() { + guard let player = player else { return } + + NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: player.currentItem, + queue: .main + ) { [weak self] _ in + self?.stopProgressTimer() + self?.playNextOrStop() + } + } + + // MARK: - 播放项观察者设置 + private func setupPlayerItemObservers(playerItem: AVPlayerItem, song: [String: Any]) { + // 移除之前的观察者 + NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil) + NotificationCenter.default.removeObserver(self, name: .AVPlayerItemFailedToPlayToEndTime, object: nil) + + // 监听播放项状态 + playerItem.addObserver(self, forKeyPath: "status", options: [.new, .initial], context: nil) + playerItem.addObserver(self, forKeyPath: "duration", options: [.new], context: nil) + playerItem.addObserver(self, forKeyPath: "loadedTimeRanges", options: [.new], context: nil) + + // 播放完成通知 + NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: playerItem, + queue: .main + ) { [weak self] _ in + print("歌曲播放完成") + self?.stopProgressTimer() + self?.playNextOrStop() + } + + // 播放失败通知 + NotificationCenter.default.addObserver( + forName: .AVPlayerItemFailedToPlayToEndTime, + object: playerItem, + queue: .main + ) { [weak self] notification in + print("播放失败: \(notification)") + self?.isPlaying = false + self?.updateMediaSessionPlaybackState(isPlaying: false) + } + + // 存储歌曲信息,用于状态变化时更新 + objc_setAssociatedObject(playerItem, "songInfo", song, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + +// MARK: - KVO 观察者 +override public func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { + guard let playerItem = object as? AVPlayerItem else { return } + + switch keyPath { + case "status": + switch playerItem.status { + case .readyToPlay: + print("播放项准备就绪,开始播放") + + // 获取关联的歌曲信息 + if let song = objc_getAssociatedObject(playerItem, "songInfo") as? [String: Any] { + updateNowPlayingInfo(song: song) + } + + // 开始播放 + player?.play() + isPlaying = true + updateMediaSessionPlaybackState(isPlaying: true) + startProgressTimer() + + case .failed: + print("播放项加载失败: \(playerItem.error?.localizedDescription ?? "未知错误")") + isPlaying = false + updateMediaSessionPlaybackState(isPlaying: false) + + case .unknown: + print("播放项状态未知") + + @unknown default: + break + } + + case "duration": + let duration = CMTimeGetSeconds(playerItem.duration) + if !duration.isNaN && !duration.isInfinite && duration > 0 { + print("获取到音频时长: \(duration) 秒") + // 更新系统媒体信息 + updateProgressInfoImmediately() + } + + case "loadedTimeRanges": + if let timeRange = playerItem.loadedTimeRanges.first?.timeRangeValue { + let loadedDuration = CMTimeGetSeconds(CMTimeAdd(timeRange.start, timeRange.duration)) + let totalDuration = CMTimeGetSeconds(playerItem.duration) + if !totalDuration.isNaN && !totalDuration.isInfinite && totalDuration > 0 { + let loadedPercentage = loadedDuration / totalDuration * 100 + print("缓冲进度: \(String(format: "%.1f", loadedPercentage))%") + } + } + + default: + break + } } +} \ No newline at end of file diff --git a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift b/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift index 61e1a844e..f4c99cd67 100644 --- a/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift +++ b/local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift @@ -18,7 +18,7 @@ public class MusicServicePlugin: NSObject, FlutterPlugin { switch call.method { case "play": if let args = call.arguments as? [String: Any], - let song = args["song"] as? [String: String] { + let song = args["song"] as? [String: Any] { MusicService.shared.play(song: song) result(nil) } else { @@ -27,8 +27,7 @@ public class MusicServicePlugin: NSObject, FlutterPlugin { case "playlist": if let args = call.arguments as? [String: Any], - let songs = args["songs"] as? [[String: String]], !songs.isEmpty { -// MusicServiceStarter.startServiceWithPlaylist(command: "playlist", songs: songs) + let songs = args["songs"] as? [[String: Any]], !songs.isEmpty { MusicService.shared.playPlaylist(songs: songs) result(nil) } else { @@ -44,7 +43,6 @@ public class MusicServicePlugin: NSObject, FlutterPlugin { result(nil) case "stop": -// MusicServiceStarter.startService(command: "stop") MusicService.shared.stop() result(nil) @@ -62,7 +60,7 @@ public class MusicServicePlugin: NSObject, FlutterPlugin { let currentUrl = MusicService.shared.getCurrentUrlString() let status: [String: Any] = [ "isPlaying": isPlaying, - "url": currentUrl != nil ? currentUrl! : NSNull() + "id": currentUrl ?? NSNull() ] result(status) @@ -76,8 +74,35 @@ public class MusicServicePlugin: NSObject, FlutterPlugin { MusicService.shared.seekTo(positionMs: position) result(nil) } else { - result(FlutterError(code: "INVALID_ARGUMENT", message: "Missing or invalid 'position'", details: nil)) + result(FlutterError(code: "INVALID_ARGUMENT", message: "Missing 'position' parameter", details: nil)) } + + case "updateMediaSessionProgress": + let currentPosition = (call.arguments as? [String: Any])?["currentPosition"] as? Int ?? 0 + let duration = (call.arguments as? [String: Any])?["duration"] as? Int ?? 0 + let progress = (call.arguments as? [String: Any])?["progress"] as? Double ?? 0.0 + MusicService.shared.updateMediaSessionProgress(currentPosition: currentPosition, duration: duration, progress: Float(progress)) + result(nil) + + case "enableMediaSessionCallbacks": + if let channel = MusicServicePlugin.channel { + MusicService.shared.enableMediaSessionCallbacks(channel: channel) + } + result(nil) + + case "updateMediaSessionMetadata": + if let args = call.arguments as? [String: Any], + let song = args["song"] as? [String: Any] { + MusicService.shared.updateMediaSessionMetadata(song: song) + result(nil) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "Song data is required", details: nil)) + } + + case "updateMediaSessionPlaybackState": + let isPlaying = (call.arguments as? [String: Any])?["isPlaying"] as? Bool ?? false + MusicService.shared.updateMediaSessionPlaybackState(isPlaying: isPlaying) + result(nil) default: result(FlutterMethodNotImplemented) diff --git a/local_plugins/ota/ios/Classes/BleManager/JLBleManager.m b/local_plugins/ota/ios/Classes/BleManager/JLBleManager.m index 1147c552d..cb22ecfa8 100644 --- a/local_plugins/ota/ios/Classes/BleManager/JLBleManager.m +++ b/local_plugins/ota/ios/Classes/BleManager/JLBleManager.m @@ -218,6 +218,7 @@ NSString *FLT_BLE_RCSP_R = @"AE02"; //命令“读”通道 } - (void)addPeripheral:(CBPeripheral*)peripheral Info:(NSString*)macStr { + kJLLog(JLLOG_DEBUG, @"BLE addPeripheral ---> Device %@", peripheral.name); if (self.lastAddress && [macStr isEqualToString:self.lastAddress]) { [self stopScanBLE]; [self connectBLE:peripheral]; diff --git a/local_plugins/realtime/ios/realtime/Package.swift b/local_plugins/realtime/ios/realtime/Package.swift index 514fbc1e1..3191b50b3 100644 --- a/local_plugins/realtime/ios/realtime/Package.swift +++ b/local_plugins/realtime/ios/realtime/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "realtime", - platforms: [.iOS("18.0")], + platforms: [.iOS("16.0")], products: [ .library(name: "realtime", targets: ["realtime"]) ], diff --git a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift b/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift index c2a9d424a..93a38fb84 100644 --- a/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift +++ b/local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift @@ -324,21 +324,32 @@ class RealtimeAudioManager { } /// 计算音频缓冲区的RMS值 - private func calculateRMS(buffer: AVAudioPCMBuffer) -> Float { - guard let channelData = buffer.int16ChannelData?[0], - buffer.frameLength > 0 else { - return 0.0 - } - - let frameCount = Int(buffer.frameLength) - var rms: Float = 0.0 - - // 使用Accelerate框架高效计算RMS - vDSP_measqv(channelData, 1, &rms, vDSP_Length(frameCount)) - - // 计算平方根并归一化 - rms = sqrt(rms / Float(frameCount)) / 32768.0 - - return rms +private func calculateRMS(buffer: AVAudioPCMBuffer) -> Float { + guard let channelData = buffer.int16ChannelData?[0], + buffer.frameLength > 0 else { + return 0.0 } + + let frameCount = Int(buffer.frameLength) + let channelDataPointer = UnsafeMutablePointer(mutating: channelData) + + // 创建浮点缓冲区用于转换 + var floatData = [Float](repeating: 0.0, count: frameCount) + + // 1. 将16位整型转换为浮点型 + vDSP_vflt16(channelDataPointer, 1, &floatData, 1, vDSP_Length(frameCount)) + + // 2. 归一化到[-1.0, 1.0]范围 + var normalizedData = [Float](repeating: 0.0, count: frameCount) + var divisor: Float = 32768.0 + vDSP_vsdiv(floatData, 1, &divisor, &normalizedData, 1, vDSP_Length(frameCount)) + + // 3. 计算均方值 + var meanSquare: Float = 0.0 + vDSP_measqv(normalizedData, 1, &meanSquare, vDSP_Length(frameCount)) + + // 4. 计算RMS值 + let rms = sqrt(meanSquare) + return rms +} } \ No newline at end of file diff --git a/local_plugins/speech/ios/speech/Sources/speech/ITtsService.swift b/local_plugins/speech/ios/speech/Sources/speech/ITtsService.swift index 3f784bc20..72ada9274 100644 --- a/local_plugins/speech/ios/speech/Sources/speech/ITtsService.swift +++ b/local_plugins/speech/ios/speech/Sources/speech/ITtsService.swift @@ -13,6 +13,12 @@ import Foundation /** 合成取消 */ case synthesisCanceled + /** 播放开始 */ + case playbackStarted + + /** 播放完成 */ + case playbackCompleted + /** 发生错误 */ case error } @@ -144,4 +150,4 @@ import Foundation * @param listener 要移除的音频数据监听器 */ func removeAudioDataListener(_ listener: AudioDataListener) -} \ No newline at end of file +} diff --git a/local_plugins/user_portrait/ios/user_portrait.podspec b/local_plugins/user_portrait/ios/user_portrait.podspec deleted file mode 100644 index 65cae4b79..000000000 --- a/local_plugins/user_portrait/ios/user_portrait.podspec +++ /dev/null @@ -1,29 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. -# Run `pod lib lint user_portrait.podspec` to validate before publishing. -# -Pod::Spec.new do |s| - s.name = 'user_portrait' - s.version = '0.0.1' - s.summary = '用户画像' - s.description = <<-DESC -用户画像 - DESC - s.homepage = 'http://example.com' - s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } - s.source = { :path => '.' } - s.source_files = 'user_portrait/Sources/user_portrait/**/*' - s.dependency 'Flutter' - s.platform = :ios, '12.0' - - # Flutter.framework does not contain a i386 slice. - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' - - # If your plugin requires a privacy manifest, for example if it uses any - # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your - # plugin's privacy impact, and then uncomment this line. For more information, - # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files - # s.resource_bundles = {'user_portrait_privacy' => ['user_portrait/Sources/user_portrait/PrivacyInfo.xcprivacy']} -end diff --git a/local_plugins/user_portrait/ios/user_portrait/Package.swift b/local_plugins/user_portrait/ios/user_portrait/Package.swift index d8d28ea06..4d8e0e3fc 100644 --- a/local_plugins/user_portrait/ios/user_portrait/Package.swift +++ b/local_plugins/user_portrait/ios/user_portrait/Package.swift @@ -13,20 +13,12 @@ let package = Package( ], dependencies: [], targets: [ - .target( - name: "user_portrait", - dependencies: [], - resources: [ - // If your plugin requires a privacy manifest, for example if it uses any required - // reason APIs, update the PrivacyInfo.xcprivacy file to describe your plugin's - // privacy impact, and then uncomment these lines. For more information, see - // https://developer.apple.com/documentation/bundleresources/privacy_manifest_files - // .process("PrivacyInfo.xcprivacy"), - - // If you have other resources that need to be bundled with your plugin, refer to - // the following instructions to add them: - // https://developer.apple.com/documentation/xcode/bundling-resources-with-a-swift-package - ] - ) - ] + .target( + name: "user_portrait", + dependencies: [ + + ], + path: "Sources/user_portrait" + ) + ] ) diff --git a/local_plugins/user_portrait/ios/user_portrait/Sources/user_portrait/PrivacyInfo.xcprivacy b/local_plugins/user_portrait/ios/user_portrait/Sources/user_portrait/PrivacyInfo.xcprivacy deleted file mode 100644 index a34b7e2e6..000000000 --- a/local_plugins/user_portrait/ios/user_portrait/Sources/user_portrait/PrivacyInfo.xcprivacy +++ /dev/null @@ -1,14 +0,0 @@ - - - - - NSPrivacyTrackingDomains - - NSPrivacyAccessedAPITypes - - NSPrivacyCollectedDataTypes - - NSPrivacyTracking - - - diff --git a/local_plugins/user_portrait/ios/user_portrait/Sources/user_portrait/UserPortraitPlugin.swift b/local_plugins/user_portrait/ios/user_portrait/Sources/user_portrait/UserPortraitPlugin.swift index b8cdc8b21..f43d0de1a 100644 --- a/local_plugins/user_portrait/ios/user_portrait/Sources/user_portrait/UserPortraitPlugin.swift +++ b/local_plugins/user_portrait/ios/user_portrait/Sources/user_portrait/UserPortraitPlugin.swift @@ -10,8 +10,8 @@ public class UserPortraitPlugin: NSObject, FlutterPlugin { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { - case "getPlatformVersion": - result("iOS " + UIDevice.current.systemVersion) + case "clean": + result(true) default: result(FlutterMethodNotImplemented) } diff --git a/local_plugins/user_portrait/pubspec.yaml b/local_plugins/user_portrait/pubspec.yaml index 1e3f104cc..862da4ec5 100644 --- a/local_plugins/user_portrait/pubspec.yaml +++ b/local_plugins/user_portrait/pubspec.yaml @@ -17,21 +17,8 @@ dev_dependencies: sdk: flutter flutter_lints: ^5.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec -# The following section is specific to Flutter packages. flutter: - # This section identifies this Flutter project as a plugin project. - # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) - # which should be registered in the plugin registry. This is required for - # using method channels. - # The Android 'package' specifies package in which the registered class is. - # This is required for using method channels on Android. - # The 'ffiPlugin' specifies that native code should be built and bundled. - # This is required for using `dart:ffi`. - # All these are used by the tooling to maintain consistency when - # adding or updating assets for this project. plugin: platforms: android: @@ -39,34 +26,5 @@ flutter: pluginClass: UserPortraitPlugin ios: pluginClass: UserPortraitPlugin - - # To add assets to your plugin package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/to/asset-from-package - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # To add custom fonts to your plugin package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/to/font-from-package + swiftPackage: + path: ios/user_portrait diff --git a/pubspec.yaml b/pubspec.yaml index f8599be53..0b0a70b83 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,6 @@ dependencies: haptic_feedback: ^0.5.1+1 tencentcloud_cos_sdk_plugin: ^1.2.3 photo_view: ^0.15.0 - window_size: git: url: https://github.com/google/flutter-desktop-embedding.git @@ -98,8 +97,6 @@ dependencies: path: local_plugins/amap_service location_service: path: local_plugins/location_service - bytedance_speech: - path: local_plugins/bytedance_speech realtime: path: local_plugins/realtime jl_opus: