Browse Source

Merge branch 'new_dev' of https://github.com/deepcloud2048/deep_voice into new_dev

newdev_shunjiawei
lxm 1 year ago
parent
commit
96421cd9fd
  1. BIN
      assets/fonts/NotoSansSC-Regular.ttf
  2. 2
      ios/Flutter/AppFrameworkInfo.plist
  3. 4
      ios/Podfile
  4. 33
      ios/Podfile.lock
  5. 26
      ios/Runner.xcodeproj/project.pbxproj
  6. 2
      ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
  7. 42
      ios/Runner/Info.plist
  8. 8
      ios/Runner/Runner.entitlements
  9. 110
      lib/data/models/user_Info.dart
  10. 33
      lib/data/services/ble_manager.dart
  11. 2
      lib/data/services/login_service.dart
  12. 4
      lib/data/services/network/api.dart
  13. 10
      lib/data/services/speech_impl/azure_asr_service.dart
  14. 9
      lib/modules/ble/bindings/ble_binding.dart
  15. 281
      lib/modules/ble/controllers/ble_controller.dart
  16. 355
      lib/modules/ble/views/ble_view.dart
  17. 43
      lib/modules/devices/controllers/devices_controller.dart
  18. 428
      lib/modules/devices/views/devices_view.dart
  19. 2
      lib/modules/meeting/controllers/mind_map_controller.dart
  20. 164
      lib/modules/meeting/views/bottomSheet/second_level_bottom_sheet.dart
  21. 70
      lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart
  22. 183
      lib/modules/pairing/controllers/pairing_controller.dart
  23. 1
      lib/modules/splash/controllers/splash_controller.dart
  24. 12
      lib/modules/translation/controllers/translation_controller.dart
  25. 7
      lib/modules/translation/views/translation_view.dart
  26. 8
      lib/routes/app_pages.dart
  27. 3
      lib/routes/app_routes.dart
  28. 2
      local_plugins/agent_service/ios/agent_service/Package.swift
  29. 330
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift
  30. 4
      local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift
  31. 87
      local_plugins/amap_service/ios/Classes/AmapServicePlugin.swift
  32. 7
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt
  33. 618
      local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift
  34. 17
      local_plugins/azure_speech/ios/Classes/AzureSpeechPlugin.h
  35. 422
      local_plugins/azure_speech/ios/Classes/AzureSpeechPlugin.swift
  36. 604
      local_plugins/azure_speech/ios/Classes/AzureTtsHelper.swift
  37. 2
      local_plugins/azure_speech/ios/azure_speech/Package.swift
  38. 562
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift
  39. 186
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  40. 634
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift
  41. 233
      local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/RecordFile.swift
  42. 11
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  43. 6
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt
  44. 2
      local_plugins/ble_service/ios/ble_service/Package.swift
  45. 612
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleAccessorySetup.swift
  46. 586
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleScanManager.swift
  47. 393
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift
  48. 208
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift
  49. 56
      local_plugins/ble_service/lib/ble_service.dart
  50. 6
      local_plugins/chat_api/ios/chat_api/Package.swift
  51. 145
      local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiPlugin.swift
  52. 297
      local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiService.swift
  53. 240
      local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift
  54. 2
      local_plugins/chat_storage/ios/chat_storage/Package.swift
  55. 2
      local_plugins/classic_bluetooth/ios/classic_bluetooth.podspec
  56. 38
      local_plugins/location_service/ios/.gitignore
  57. 131
      local_plugins/location_service/ios/Classes/LocationService.swift
  58. 44
      local_plugins/location_service/ios/Classes/LocationServicePlugin.swift
  59. 27
      local_plugins/location_service/ios/location_service.podspec
  60. 21
      local_plugins/location_service/ios/location_service/Package.swift
  61. 7
      local_plugins/location_service/ios/location_service/Sources/location_service/LocationCallback.swift
  62. 16
      local_plugins/location_service/ios/location_service/Sources/location_service/LocationData.swift
  63. 357
      local_plugins/location_service/ios/location_service/Sources/location_service/LocationService.swift
  64. 72
      local_plugins/location_service/ios/location_service/Sources/location_service/LocationServicePlugin.swift
  65. 3
      local_plugins/location_service/pubspec.yaml
  66. 2
      local_plugins/music_service/ios/music_service/Package.swift
  67. 383
      local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift
  68. 37
      local_plugins/music_service/ios/music_service/Sources/music_service/MusicServicePlugin.swift
  69. 1
      local_plugins/ota/ios/Classes/BleManager/JLBleManager.m
  70. 2
      local_plugins/realtime/ios/realtime/Package.swift
  71. 21
      local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift
  72. 6
      local_plugins/speech/ios/speech/Sources/speech/ITtsService.swift
  73. 29
      local_plugins/user_portrait/ios/user_portrait.podspec
  74. 14
      local_plugins/user_portrait/ios/user_portrait/Package.swift
  75. 14
      local_plugins/user_portrait/ios/user_portrait/Sources/user_portrait/PrivacyInfo.xcprivacy
  76. 4
      local_plugins/user_portrait/ios/user_portrait/Sources/user_portrait/UserPortraitPlugin.swift
  77. 46
      local_plugins/user_portrait/pubspec.yaml
  78. 5
      pubspec.yaml

BIN
assets/fonts/NotoSansSC-Regular.ttf

Binary file not shown.

2
ios/Flutter/AppFrameworkInfo.plist

@ -21,6 +21,6 @@
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>18.0</string>
<string>16.0</string>
</dict>
</plist>

4
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)',

33
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

26
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;
};

2
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">

42
ios/Runner/Info.plist

@ -39,7 +39,7 @@
</array>
</dict>
</array>
<key>CFBundleVersion</key> <!-- 构建号 -->
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FacebookAdvertiserIDCollectionEnabled</key>
<false/>
@ -72,50 +72,12 @@
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAccessorySetupBluetoothCompanyIdentifiers</key>
<array>
<string>EFAB</string>
</array>
<key>NSAccessorySetupDescriptions</key>
<array>
<dict>
<key>AccessoryMediaType</key>
<string>Headphones</string>
<key>LocalizedAccessoryName</key>
<string>语音助理耳机</string>
</dict>
</array>
<key>NSAccessorySetupKitSupports</key>
<array>
<string>Bluetooth</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>mcp.amap.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.0</string>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>mcp.ideapsound.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSExceptionMinimumTLSVersion</key>
<string>TLSv1.0</string>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要蓝牙权限用于连接和管理音频设备</string>
@ -160,5 +122,7 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

8
ios/Runner/Runner.entitlements

@ -2,11 +2,15 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:deepsoud.com</string>
</array>
<key>com.apple.developer.homekit</key>
<true/>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.external-accessory.wireless-configuration</key>
<true/>
<key>com.apple.developer.homekit</key>
<true/>
</dict>
</plist>

110
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<UserDevice> 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<String, dynamic> json) {
// 修复1:安全获取嵌套的 user 字段
final userData = json['user'] as Map<String, dynamic>?; // 允许空值
final devicesData = json['devices'] as Map<String, dynamic>?; // 允许空值
//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<dynamic>?)
?.map((e) => UserDevice.fromJson(e as Map<String, dynamic>))
.toList() ??
[], // 处理设备列表
);
}
factory User.fronTokenJson(Map<String, dynamic> json) {
// 修复1:安全获取嵌套的 user 字段
final userData = json['user'] as Map<String, dynamic>?; // 允许空值
print("数据获取fronTokenJson${userData}");
final devicesData = json['devices'] as Map<String, dynamic>?; // 允许空值
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<dynamic>)
.map((e) => UserDevice.fromJson(e as Map<String, dynamic>))
.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(),
},
};
'earphonename': earphonename,
'earphonemac': earphonemac,
"earphonelicense": earphonelicense,
'earphoneauthcode': earphoneauthcode,
// 添加设备
addUserDevice(UserDevice device) {
devices.add(device);
}
// 'lastbettime': lastbettime,
// 'createtime': createtime,
// 'wxopenid': wxopenid,
// 'googleopenid': googleopenid,
// 'facebookopenid': facebookopenid,
// 查找设备
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,
});
factory UserDevice.fromJson(Map<String, dynamic> 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,
);
toJson() => {
'id': id,
'uid': uid,
'devicetype': devicetype,
'devicename': devicename,
'devicemac': devicemac,
'license': license,
};
}

33
lib/data/services/ble_manager.dart

@ -782,9 +782,28 @@ class BleManager extends GetxService {
}
}
Future<Map<String, String>?> getDeviceInfo(String address) async {
/// 验证设备是否可以配对
Future<void> stopScan() async {
try {
await _bleService.stopScan();
} catch (e) {
Logger.error('停止扫描设备: ${e.toString()}');
}
}
Future<Map<String, String>?> getDeviceInfo(String deviceName) async {
try {
final result = _bleService.getDeviceInfo(deviceName);
return result;
} catch (e) {
Logger.error('获取设备信息: ${e.toString()}');
return null;
}
}
Future<List<Map<String, String>>?> 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<BleManager>());
}
/// 请求蓝牙权限
Future<bool> requestBluetoothPermission() async {
try {
return await _bleService.requestBluetoothPermission();
} catch (e) {
Logger.error('请求蓝牙权限失败: ${e.toString()}');
return false;
}
}
}

2
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 '';
}));

4
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,
);
}

10
lib/data/services/speech_impl/azure_asr_service.dart

@ -81,11 +81,11 @@ class AzureAsrService extends GetxService implements AsrService {
try {
final List<String> languages =
supportedLanguages ?? _defaultSupportedLanguages;
// 检查是否需要重新初始化
if (_isInitialized) {
await dispose();
}
//底层会初始化前释放
// // 检查是否需要重新初始化
// if (_isInitialized) {
// await dispose();
// }
// 设置音频源类型
_audioSourceType = useExternalAudio

9
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>(BleController());
}
}

281
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<fbp.ScanResult> scanResults = <fbp.ScanResult>[].obs;
// 连接的设备
final Rx<fbp.BluetoothDevice?> connectedDevice =
Rx<fbp.BluetoothDevice?>(null);
// 错误信息
final RxString errorMessage = ''.obs;
@override
void onInit() {
super.onInit();
_initBluetooth();
}
@override
void onClose() {
stopScan();
super.onClose();
}
// 初始化蓝牙
Future<void> _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<void> _checkPermissions() async {
try {
Map<Permission, PermissionStatus> 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<bool> _requestPermissions() async {
try {
Map<Permission, PermissionStatus> 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<void> 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<void> stopScan() async {
try {
await fbp.FlutterBluePlus.stopScan();
} catch (e) {
print('停止扫描失败: $e');
}
}
// 连接设备
Future<void> 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<void> 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;
}
}

355
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<BleController> {
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<Color>(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),
),
);
}
}

43
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<int, List<UserDevice>> devices;
@override
void onInit() {
super.onInit();
_bleManager = Get.find<BleManager>();
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);

428
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<DevicesController> {
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,
),
onPressed: () {
Get.back(); // 正常返回
},
),
title: Text(
'deviceManagement'.tr,
style: TextStyle(
fontSize: 20.sp,
color: isDarkMode ? Colors.white : Colors.black,
),
),
),
body: ListView(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h),
backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.white,
body: Column(
children: [
_buildEarphoneCard(isDarkMode),
SizedBox(height: 20.h),
_buildVoiceRecorderCard(isDarkMode),
SizedBox(height: 20.h),
],
),
);
}
// 耳机
Widget _buildEarphoneCard(bool isDarkMode) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
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(
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.white.withOpacity(0.05) : Colors.white,
borderRadius: BorderRadius.circular(16.r),
color: isDarkMode ? Colors.black : Colors.white,
boxShadow: [
BoxShadow(
color: isDarkMode
? Colors.black.withOpacity(0.2)
: Colors.black.withOpacity(0.05),
color: 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(
child: Row(
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();
},
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,
),
),
],
),
),
// 设备列表内容
Expanded(
child: _buildDevicesList(isDarkMode),
),
],
),
);
}
// 录音笔
Widget _buildVoiceRecorderCard(bool isDarkMode) {
Widget _buildDevicesList(bool isDarkMode) {
final devices = User.instance.devices;
if (devices.isEmpty) {
return _buildEmptyState(isDarkMode);
}
// 按设备类型分组
final Map<DeviceType, List<UserDevice>> 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: [
// 遍历每个设备类型
...groupedDevices.entries.map((entry) {
final deviceType = entry.key;
final deviceList = entry.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 设备类型标题
Padding(
padding: EdgeInsets.only(left: 4.w, bottom: 8.h),
padding: EdgeInsets.only(bottom: 12.h),
child: Text(
'voicerecorder'.tr, //AI设置
deviceType.displayName,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w500,
color: isDarkMode ? Colors.blue[400] : Colors.blue[600],
letterSpacing: 1,
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black87,
),
),
),
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),
),
// 该类型下的设备列表
...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),
);
}).toList(),
],
);
}
Widget _buildEmptyState(bool isDarkMode) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
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: () {},
Icon(
Icons.devices_other,
size: 80.sp,
color: isDarkMode ? Colors.white54 : Colors.grey[400],
),
)
],
SizedBox(height: 20.h),
Text(
"暂未绑定任何设备",
style: TextStyle(
fontSize: 16.sp,
color: isDarkMode ? Colors.white70 : Colors.grey[600],
),
),
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(
return Container(
margin: EdgeInsets.only(bottom: 12.h),
padding: EdgeInsets.all(16.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
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: 32.w,
height: 32.w,
width: 48.w,
height: 48.w,
decoration: BoxDecoration(
color: iconBgColor,
borderRadius: BorderRadius.circular(16.r),
color:
isDarkMode ? Colors.white.withOpacity(0.1) : Colors.grey[100],
borderRadius: BorderRadius.circular(12.r),
),
child: Icon(
icon,
size: 16.w,
color: iconColor,
deviceType.icon,
color: isDarkMode ? Colors.white : Colors.black87,
size: 24.sp,
),
),
SizedBox(width: 12.w),
Flexible(
SizedBox(width: 16.w),
// 设备信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
device.devicename.isNotEmpty ? device.devicename : "未知设备",
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
color: titleColor ??
(isDarkMode ? Colors.white : Colors.black87),
color: isDarkMode ? Colors.white : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) SizedBox(height: 2.h),
if (subtitle != null)
SizedBox(height: 4.h),
Text(
subtitle,
device.devicemac.isNotEmpty ? device.devicemac : "无MAC地址",
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.white70 : Colors.grey[600],
),
),
if (device.license.isNotEmpty) ...[
SizedBox(height: 4.h),
Text(
"授权码: ${device.license}",
style: TextStyle(
fontSize: 12.sp,
color: subtitleColor ??
(isDarkMode
? Colors.white70
: Colors.black54),
color: isDarkMode ? Colors.white54 : Colors.grey[500],
),
overflow: TextOverflow.ellipsis,
),
],
],
),
),
// 解绑按钮
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,
),
),
),
),
],
),
);
}
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,
),
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),
content: Text(
"确定要解绑设备 \"${device.devicename}\" 吗?",
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.black54,
),
onPressed: onPressed,
),
actions: [
TextButton(
onPressed: () => Get.back(),
child: Text(
'unbind'.tr, // 解绑
"取消",
style: TextStyle(
color: Colors.white,
color: isDarkMode ? Colors.white70 : Colors.grey[600],
),
),
)),
],
),
TextButton(
onPressed: () {
Get.back();
controller.unbindearphone(device);
},
child: const Text(
"确认解绑",
style: TextStyle(color: Colors.red),
),
),
],
),
);
}

2
lib/modules/meeting/controllers/mind_map_controller.dart

@ -170,7 +170,7 @@ class MindMapController extends GetxController {
final String base64Str = dataUrl.split(',').last;
final Uint8List bytes = base64.decode(base64Str);
final String fileName =
'${_meetingDetailsController.meetingData.value.title} mindmap.png';
'${_meetingDetailsController.meetingData.value.title}_mindmap.png';
final tempDir = await getTemporaryDirectory();
final File file = File('${tempDir.path}/$fileName');
await file.writeAsBytes(bytes);

164
lib/modules/meeting/views/bottomSheet/second_level_bottom_sheet.dart

@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
class SecondLevelBottomSheet extends StatefulWidget {
final String title;
final String subtitle;
final Function(int index) onTap;
final List children;
const SecondLevelBottomSheet(
this.title, this.subtitle, this.onTap, this.children,
{super.key});
@override
State<SecondLevelBottomSheet> createState() => _SecondLevelBottomSheetState();
}
class _SecondLevelBottomSheetState extends State<SecondLevelBottomSheet> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.symmetric(vertical: 10.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title,
style: TextStyle(
fontSize: 16.sp,
color: Colors.black87,
fontWeight: FontWeight.bold,
),
),
GestureDetector(
onTap: () => Get.back(),
child: Container(
width: 26.w,
height: 26.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(26.r),
),
child: Icon(
Icons.close,
size: 18.w,
color: Colors.grey[400],
),
),
)
],
),
),
Divider(
height: 1,
color: Colors.grey[300],
),
Padding(
padding: EdgeInsetsGeometry.only(
top: 20.w,
bottom: 10.w,
left: 12.w,
),
child: Text(
widget.subtitle,
style: TextStyle(
fontSize: 14.sp,
color: Colors.grey[600],
),
),
),
Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
),
child: Column(
children: List.generate(widget.children.length, (int index) {
Map item = widget.children[index];
return InkWell(
onTap: () {
setState(() {
_selectedIndex = index;
});
},
child: Container(
padding:
EdgeInsets.symmetric(horizontal: 12.w, vertical: 14.w),
decoration: BoxDecoration(
border: item['isBorder']
? Border(
bottom: BorderSide(
width: 0.5,
color: Colors.grey[300]!,
),
)
: null,
),
child: Row(
children: [
Icon(
item['icon'],
color: Colors.black87,
),
10.horizontalSpace,
Expanded(
child: Text(
item['title'],
style: TextStyle(
fontSize: 14.sp,
color: Colors.black87,
),
),
),
if (_selectedIndex == index)
const Icon(
Icons.check,
color: Colors.black87,
),
],
),
),
);
}),
),
),
Padding(
padding: EdgeInsets.symmetric(vertical: 20.w),
child: GestureDetector(
onTap: () {
Get.back();
widget.onTap(_selectedIndex);
},
child: Container(
height: 48.w,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(12.r),
),
child: Text(
'导出',
style: TextStyle(
fontSize: 16.sp,
color: Colors.white,
),
),
),
),
),
],
),
);
}
}

70
lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart

@ -7,6 +7,7 @@ import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:htmltopdfwidgets/htmltopdfwidgets.dart' as pdf;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:webview_flutter/webview_flutter.dart';
@ -14,6 +15,7 @@ import 'package:webview_flutter/webview_flutter.dart';
import '../../../../core/utils/synchrodata.dart';
import '../../controllers/meeting_details_controller.dart';
import '../../controllers/mind_map_controller.dart';
import 'second_level_bottom_sheet.dart';
class ShareBottomSheet extends StatefulWidget {
const ShareBottomSheet({super.key});
@ -141,7 +143,7 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
Get.back();
final tempDir = await getTemporaryDirectory();
final file = File(
'${tempDir.path}/${_controller.meetingData.value.title} speaker.txt');
'${tempDir.path}/${_controller.meetingData.value.title}_speaker.txt');
String allText = _getAllText();
await file.writeAsString(allText);
_share(file.path, '分享会议转写');
@ -151,12 +153,46 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
onTap: _controller.meetingDetails.value.tasktype >= 3
? () async {
Get.back();
final tempDir = await getTemporaryDirectory();
Get.bottomSheet(
SecondLevelBottomSheet(
'导出总结',
'导出为',
(int index) async {
if (index == 0) {
// 导出TXT
final tempDir =
await getTemporaryDirectory();
final file = File(
'${tempDir.path}/${_controller.meetingData.value.title} summary.txt');
await file.writeAsString(
_controller.meetingDetails.value.summary);
'${tempDir.path}/${_controller.meetingData.value.title}_summary.txt');
await file.writeAsString(_controller
.meetingDetails.value.summary);
_share(file.path, '分享会议总结');
} else {
// 导出PDF
_exportMarkdownPdf();
}
},
const [
{
'icon': Icons.text_snippet,
'title': 'TXT',
'isBorder': true,
},
{
'icon': Icons.picture_as_pdf,
'title': 'PDF',
'isBorder': false,
},
],
),
isScrollControlled: true,
backgroundColor: Colors.grey[100],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
);
}
: null),
_shareItem(
@ -255,7 +291,7 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
);
}
void _share(String filepath, String title) async {
Future<void> _share(String filepath, String title) async {
final result = await SharePlus.instance.share(ShareParams(
text: title,
files: [XFile(filepath)],
@ -301,4 +337,26 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
await file.writeAsBytes(byteData.buffer.asUint8List());
return XFile(file.path);
}
Future<void> _exportMarkdownPdf() async {
EasyLoading.show(status: '正在导出PDF...');
final tempDir = await getTemporaryDirectory();
final file = File(
'${tempDir.path}/${_controller.meetingData.value.title}_summary.pdf');
final List<pdf.Widget> markdownWidgets =
await pdf.HTMLToPdf().convertMarkdown(
_controller.meetingDetails.value.summary,
fontFallback: [
pdf.Font.ttf(
await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf')),
],
);
final markdownPdf = pdf.Document();
markdownPdf.addPage(pdf.MultiPage(
build: (context) => markdownWidgets,
));
await file.writeAsBytes(await markdownPdf.save());
await _share(file.path, '分享会议总结');
EasyLoading.dismiss();
}
}

183
lib/modules/pairing/controllers/pairing_controller.dart

@ -501,13 +501,14 @@ class PairingController extends GetxController {
//连接ble
Future<void> 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();
@ -602,69 +603,162 @@ class PairingController extends GetxController {
//自动绑定设备 0 未合作商 1 合作商 2,已绑定
Future<int> 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<String, String>? deviceinfo;
List<Map<String, String>>? deviceinfos;
for (int i = 0; i < 3; i++) {
deviceinfo = await _bleManager.getDeviceInfo(device.address);
print('获取到 deviceinfo: $deviceinfo');
if (deviceinfo != null) {
break;
}
await Future.delayed(Duration(seconds: 1));
// 使用设备名称获取设备列表
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;
// }
}
print('获取到 deviceinfo: $deviceinfo');
if (deviceinfo != null) {
// final code = processHex(deviceinfo["hex"]!, 15, 25);
String code = extractAndReverseBytes(deviceinfo["hex"]!, 15, 24);
print('获取到 code: $code');
// 安全检查长度
} 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 {
return 0;
break;
}
}
}
}
}
await Future.delayed(Duration(seconds: 1));
}
return 0;
// 调用BLE管理器的自动绑定方法
} else {
return 0;
}
}
// 新增方法:从hex数据中提取MAC地址并与当前设备MAC对比
String extractMacFromHex(String hexData, String deviceMac) {
try {
// 将hex字符串转换为字节数组
List<int> 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<int> 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;
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, '绑定设备失败!');
@ -696,13 +790,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 {
@ -782,7 +876,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!);
}

1
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;

12
lib/modules/translation/controllers/translation_controller.dart

@ -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<void> disableBluetoothAudio() async =>
await _asrService.disableBluetoothAudio();
//恢复原始音频状态
void restoreOriginalAudioState() => _asrService.restoreOriginalAudioState();
Future<void> restoreOriginalAudioState() async =>
await _asrService.restoreOriginalAudioState();
// 兼容旧版视图的计算属性
Rx<TranslationItem?> 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(

7
lib/modules/translation/views/translation_view.dart

@ -461,8 +461,6 @@ class TranslationView extends GetView<TranslationController> {
child: Obx(() {
// 同时模式显示双按钮
if (controller.currentMode.value == 'faceToFace') {
controller.startRecognition();
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@ -593,11 +591,6 @@ class TranslationView extends GetView<TranslationController> {
// 设置300ms延迟防止误触
_holdTimer = Timer(const Duration(milliseconds: 200), () {
if (activeSpeaker == 0) {
if (speakerId == 1) {
controller.restoreOriginalAudioState();
} else {
controller.disableBluetoothAudio();
}
controller.setActiveSpeaker(speakerId);
}
});

8
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(),
),
];
}

3
lib/routes/app_routes.dart

@ -58,4 +58,7 @@ abstract class Routes {
// 设备管理
static const devices = '/devices';
// ble设备管理
static const ble = '/ble';
}

2
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"])
],

330
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,7 +303,6 @@ class AgentServiceImpl: NSObject {
startIdleCheck()
}
}
func startRecognition(useBle: Bool = false) -> Bool {
if !isInitialized {
sendError("服务未初始化", code: "NOT_INITIALIZED")
@ -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
}
guard let audioStream = azureAsrHelper?.audioStream else {
os_log("音频流未初始化", log: logger,type: .error)
return false
}
audioStream.saveAudioDataTo(data: audioData)
azureAsrHelper?.pushAudioData(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,17 +599,18 @@ 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 {
if !assistantMessage.isEmpty || !metadata.isEmpty {
// 保存AI回复
let assistantMessageId = self.chatStorageHelper.saveMessage(
sessionId: self.sessionId,
@ -600,10 +618,10 @@ class AgentServiceImpl: NSObject {
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)
}
@ -612,93 +630,180 @@ 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)")
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] = jsonObject
// 将 metadata 转为 JSON 字符串
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: [.prettyPrinted]),
if let jsonData = try? JSONSerialization.data(withJSONObject: metadata, options: []),
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 ?? ""
return [
"meta": metaStr,
"broadcast": broadcast,
"notreply": notreply
]
}
// 处理基本类型(字符串、数字等)
private func handlePrimitiveType(_ value: Any, toolName: String) -> [String: Any] {
let metaStr = "{\"\(toolName)\": \"\(value)\"}"
return [
"meta": metaStr,
"broadcast": true,
"notreply": false
]
}
// 处理纯文本内容
private func handlePlainText(_ text: String, toolName: String) -> [String: Any] {
let metaStr = "{\"\(toolName)\": \"\(text)\"}"
return [
"meta": metaStr,
"broadcast": true,
"notreply": false
]
}
/// 辅助方法:将字典转换为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
}
}
// 处理音乐卡片
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
"coverUrl": coverUrl,
"ts": ts
])
return (metaStr, false)
// 处理 card_musiclist
} else if let cardMusiclist = jsonObject["card_musiclist"] as? [String: Any],
let musics = cardMusiclist["musics"] as? [[String: Any]] {
}
var playlist: [[String: String]] = []
// 处理音乐列表卡片
private func processMusicListCard(_ musics: [[String: Any]]) {
var playlist: [[String: Any]] = []
for item in musics {
let id = item["id"] as? String ?? ""
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
let song: [String: String] = [
playlist.append([
"id": id,
"url": url,
"title": title,
"artist": artist,
"coverUrl": coverUrl
]
playlist.append(song)
"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 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)
}
@ -791,6 +896,7 @@ class AgentServiceImpl: NSObject {
}
func dispose() -> Bool {
print("ai释放资源")
if isRecognizing {
stopRecognition()
}
@ -832,7 +938,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
@ -842,31 +950,39 @@ class ChatApiStreamCallback: StreamCallback {
}
func onUsage(_ sessionid: String,_ prompt_tokens: Int?,_ completion_tokens: Int?,_ total_tokens: Int?){
func onToken(_ token: String) {
guard let agentService = agentService else { return }
}
func onToken(_ sessionId:String,_ token: String) {
do {
guard let agentService = try agentService else { return }
if (sessionId != agentService.currsessionId) {
return
}
responseBuilder += token
agentService.sendEvent(name: "assistant_token", data: ["token": token])
if speakResponse && nobroadcast{
agentService.azureTtsHelper?.speakStream(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 }
func onComplete(_ sessionId:String) {
do {
guard let agentService = try agentService else { return }
if speakResponse && nobroadcast{
if speakResponse && reply && broadcast && sessionId == agentService.currsessionId{
agentService.azureTtsHelper?.flushStream()
}
let response = responseBuilder
if !response.isEmpty {
var responseData: [String: Any] = [
"text": response,
"userInput": displayText
@ -874,49 +990,60 @@ 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)
// 保存聊天记录
agentService.saveChatMessage(userMessage: displayText, assistantMessage: response, metadata: metadata)
os_log("保存聊天记录完成", log: agentService.logger, type: .info)
} else {
os_log("警告: 响应内容为空", log: agentService.logger, type: .error)
if !response.isEmpty {
try agentService.addToHistoryMessages(assistantMessage)
}
// 保存聊天记录
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(_ error: Error) {
guard let agentService = agentService else { return }
}
os_log("ChatAPI错误: %{public}@", log: agentService.logger, type: .error, error.localizedDescription)
func onError(_ sessionId:String,_ error: Error) {
do {
guard let agentService = try agentService else { return }
agentService.sendEvent(name: "error", data: [
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)")
}
func onFunctionCall(_ functionCall: [String: Any]) {
guard let agentService = agentService else { return }
}
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()
agentService.audioPlayer?.playCallingSound()
agentService.sendEvent(name: "function_call", data: [
try agentService.sendEvent(name: "function_call", data: [
"name": name,
"arguments": functionCall
])
@ -925,18 +1052,25 @@ class ChatApiStreamCallback: StreamCallback {
os_log("执行退出交互", log: agentService.logger, type: .info)
agentService.stopRecognition()
}
}catch{
print("liwei--------------- AI Call onFunctionCall 异常: \(error)")
}
func onFunctionCallResult(_ functionCall: [String: Any], _ functionCallResult: [String: Any]) {
}
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 (metestr, broadcast) = agentService.autoHandleFunctionCallResult(toolName:name,functionCallResult:functionCallResult)
metadata = metestr;
nobroadcast = broadcast
// 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,
@ -944,7 +1078,21 @@ class ChatApiStreamCallback: StreamCallback {
"meta":metadata,
])
os_log("处理函数调用结果完成", log: agentService.logger, type: .info)
}catch{
print("liwei--------------- AI Call onFunctionCallResult 异常: \(error)")
}
}
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 {

4
local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift

@ -44,6 +44,10 @@ public class AgentServicePlugin: NSObject, FlutterPlugin {
impl.startRecognition()
result(true)
case "recognizeCallback":
impl.recognizeCallback()
result(true)
case "stopConversation":
impl.stopRecognition()
result(true)

87
local_plugins/amap_service/ios/Classes/AmapServicePlugin.swift

@ -2,44 +2,101 @@ import Flutter
import UIKit
import AMapNaviKit
public class AmapServicePlugin: NSObject, FlutterPlugin {
public class AmapServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
private static var channel: FlutterMethodChannel?
private var eventSink: FlutterEventSink?
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "com.yunqiinnovation.amap_service", binaryMessenger: registrar.messenger())
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 {
// 2. API Key
AMapServices.shared()?.apiKey = "aa60a03be9f6531bcc64029e60f95d03"
AMapServices.shared().enableHTTPS = true
// 导航SDK隐私合规(如果使用导航功能)
AMapServices.shared()?.enableHTTPS = true
if #available(iOS 13.0, *) {
AMapNaviManagerConfig.shared().updatePrivacyShow(.didShow, privacyInfo: .didContain)
AMapNaviManagerConfig.shared().updatePrivacyAgree(.didAgree)
// 3. 打印验证(调试用)
}
print("高德SDK初始化完成")
}
registrar.addMethodCallDelegate(instance, channel: channel)
self.channel = channel
registrar.addMethodCallDelegate(instance, channel: methodChannel)
self.channel = methodChannel
}
// 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":
print("liwei------------------ 打开导航页面")
if let args = call.arguments as? [String: Any],
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{
print("liwei------------------ 打开导航页面1 \(startpos) \(endpos)")
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)
}

7
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)

618
local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift

@ -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<Data>()
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<T> {
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)
}
}

17
local_plugins/azure_speech/ios/Classes/AzureSpeechPlugin.h

@ -1,17 +0,0 @@
//
// AzureSpeechPlugin.h
// azure_speech
//
// Created for azure_speech plugin compatibility.
//
#import <Flutter/Flutter.h>
/**
* Azure Speech Plugin 头文件
*
* 注:实际实现使用 Swift,此头文件仅用于 Flutter 框架兼容
*/
@interface AzureSpeechPlugin : NSObject <FlutterPlugin>
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar;
@end

422
local_plugins/azure_speech/ios/Classes/AzureSpeechPlugin.swift

@ -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)
}
}

604
local_plugins/azure_speech/ios/Classes/AzureTtsHelper.swift

@ -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<AnyObject>.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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
// 3. 静音处理特殊符号和表情符号
// 使用空白替换法,直接将符号替换为空字符串
var processedText = escapedText
for symbol in symbolsToMute {
processedText = processedText.replacingOccurrences(of: symbol, with: "")
}
// 4. 构造简化的SSML文档,减少嵌套层级
let ssml = """
<speak version="1.0"
xmlns="http://www.w3.org/2001/10/synthesis"
xmlns:mstts="https://www.w3.org/2001/mstts"
xml:lang="zh-CN">
<voice name="\(currentVoice)">
<mstts:express-as style="cheerful">
<prosody rate="\(currentRate)" pitch="\(currentPitch)" volume="\(currentVolume)">
<say-as interpret-as="text">\(processedText)</say-as>
</prosody>
</mstts:express-as>
</voice>
</speak>
"""
return ssml
}
}

2
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"])
],

562
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
@ -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
// 重置识别器
do {
guard recognizer != nil else {
if !setupRecognizer() {
callback.onError("重置识别器失败")
// callback.onError("重置识别器失败")
return false
}
return false
}
do {
// 设置各种事件监听
setupEventListeners(callback: callback)
// 启动音频处理
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
}
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 {
if (audioStream == nil) {
// 创建外部音频拉流对象
externalAudioStream = ExternalAudioPullStream()
// 明确指定使用外部音频源(推流模式)
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
// 重设识别器
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.getDetectedLanguage(from: result)
// os_log("识别中: %{public}@", log: self.log, type: .info, result.text ?? "")
// 直接在当前线程调用回调
let detectedLanguage = self.detectLanguage(from: result)
print("识别中事件: \(detectedLanguage)")
DispatchQueue.main.async {
callback.onRecognizing(result.text ?? "", detectedLanguage)
}
}
// 识别完成事件
recognizer.addRecognizedEventHandler { [weak self] (sender, event) in
recognizer.addRecognizedEventHandler { [weak self] (_, event) in
guard let self = self else { return }
let result = event.result
if result.reason == SPXResultReason.recognizedSpeech {
let detectedLanguage = self.getDetectedLanguage(from: 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,6 +413,7 @@ public class AzureAsrHelper: NSObject {
// 尝试另一种方式获取语言
do {
let langResult = try SPXAutoDetectSourceLanguageResult(result)
print("获取检测到的语言")
return langResult.language ?? ""
} catch {
return ""
@ -408,95 +421,362 @@ public class AzureAsrHelper: NSObject {
}
/**
* 启动音频处理
* 禁用蓝牙音频功能,切换回正常音频模式
*/
private func startAudioProcessing() {
switch audioSourceType {
case .microphone:
// 拉流模式不需要额外启动,SDK会自动拉取数据
break
case .external:
// 外部音频数据模式下不需要启动处理,等待外部调用pushAudioData
break
public func disableBluetoothAudio() {
do {
audioStream?.setAudioOutputRoute(.speaker)
} catch {
print("disableBluetoothAudio")
}
}
/**
* 停止音频处理
* 恢复原始音频设备状态(通常是重新启用蓝牙)
*/
private func stopAudioProcessing() {
if let stream = externalAudioStream {
stream.close()
externalAudioStream = nil
// os_log("外部音频流已关闭", log: log, type: .info)
public func restoreOriginalAudioState() {
do {
audioStream?.setAudioOutputRoute(.receiver)
} catch {
print("restoreOriginalAudioState")
}
}
// MARK: - 录音音频文件方法
/**
* 外部音频拉流
* 实现PullAudioInputStreamCallback,将外部推送的音频数据转换为SDK可拉取的形式
* 开启录音
*/
private class ExternalAudioPullStream: NSObject {
private(set) var pullStream: SPXPullAudioInputStream!
private let queue = LinkedBlockingQueue<Data>()
private var closed = false
public func enableRecord(filePath: String) {
override init() {
super.init()
recordfile?.closeFile(isSave: true)
recordfile?.creatingFiles(atPath: filePath) // Fixed method call
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 moveFile(sourcePath: String, destPath: String)-> Bool {
recordfile?.moveFile(from: sourcePath, to: destPath) // Fixed method call
return true
}
/**
* 重命名指定路径的音频文件
*/
public func renameFile(filePath: String, newName: String)-> Bool {
recordfile?.renameFile(at: filePath, to: newName) // Fixed method call
return true
}
/**
* 外部调用:推送音频数据到队列
* @param data 音频数据
* 停止录音
*/
func pushAudio(_ data: Data) {
if !closed {
queue.put(data)
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<Data>()
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
)
}
/**
* SDK调用:从队列中拉取数据
* @param buffer SDK提供的缓冲区
* @param size 缓冲区大小
* @return 读取的字节数,0表示流结束
* 开始音频输入
*/
private func read(buffer: NSMutableData, size: Int) -> Int {
// 阻塞等待下一块数据
guard let chunk = queue.take() else {
return 0 // 队列已关闭
public func startAudioInput(audioSourceType: AudioSourceType = .microphone) {
isWriting=true
self.audioSourceType = audioSourceType;
print("startAudioInput=audioSourceType\(audioSourceType)")
switch audioSourceType {
case .microphone:
runMicrophoneCapture()
case .external:
runExternalCapture()
}
}
// 如果是空数据,表示流结束
if chunk.isEmpty {
return 0
/**
* 开启音频写入线程
*/
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
}
let toCopy = min(chunk.count, size)
buffer.append(chunk.prefix(toCopy))
return toCopy
guard let dataToWrite = self.writeQueue.take() else { continue }
print("写入数据长度: \(dataToWrite.count)")
self.pushAudioStream?.write(dataToWrite)
self.onAudioData?(dataToWrite)
}
}
}
/**
* SDK调用:关闭流
* 向音频流写入音频数据
* 仅当音频源设置为external时有效
*
* @param data 音频数据字节数组
*/
func close() {
closed = true
queue.close()
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])
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<Int16>.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..<frameLength {
let sample = floatBuffer[i]
let clamped = max(-1.0, min(sample, 1.0))
let scaled = clamped * Float(Int16.max)
int16Array[i] = Int16(scaled)
}
return Data(
bytes: int16Array,
count: dataLength
)
}
return Data() // Fallback for unsupported formats
}
private func runExternalCapture() {
if audioSourceType == .external {
//pushAudioData(data: Data())
audioEngine?.pause()
//audioEngine?.inputNode.removeTap(onBus: 0)
}
}
public func stopAudioCapture() {
guard isWriting==true else { return }
print("stopContinuousRecognition")
isWriting=false
audioEngine?.pause()
audioEngine?.inputNode.removeTap(onBus: 0)
writeThread?.async {
self.writeQueue.close()
}
}
public func releaseAudioResources() {
print("释放了")
stopAudioCapture()
audioEngine?.stop()
writeThread = nil
audioEngine?.inputNode.removeTap(onBus: 0)
isRunning=false
audioEngine = nil
}
// 音频路由管理
public enum AudioOutputRoute {
case speaker
case receiver
case bluetooth
}
public func setAudioOutputRoute(_ route: AudioOutputRoute) {
guard currentRoute != route else { return }
print("setAudioOutputRoute")
do {
//
if self.isWriting {
print("当前正在识别")
try audioEngine?.pause()
// 先停用以避免冲突
try audioSession.setActive(false)
}
switch route {
case .speaker:
// 使用扬声器时必须用videoChat模式
try audioSession.setCategory(
.playAndRecord,
mode: .videoChat,
options: [.allowBluetooth, .mixWithOthers, .defaultToSpeaker]
)
try audioSession.overrideOutputAudioPort(.speaker)
case .receiver:
// 听筒模式使用voiceChat节省资源
try audioSession.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.allowBluetooth, .mixWithOthers, .defaultToSpeaker]
)
try audioSession.overrideOutputAudioPort(.none)
case .bluetooth:
// 完整蓝牙设备支持
try audioSession.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.allowBluetooth, .allowBluetoothA2DP]
)
try audioSession.overrideOutputAudioPort(.none)
// 不需要override,系统自动路由
}
self.currentRoute = route
if self.isWriting {
// 重新激活
try audioSession.setActive(true, options: [.notifyOthersOnDeactivation])
try audioEngine?.start()
writeThread?.async {
self.writeQueue.close()
}
}
} catch {
print("路由切换失败: \(error)")
}
}
}
// MARK: - iOS版LinkedBlockingQueue实现
/**
* iOS版LinkedBlockingQueue实现
* 与Android LinkedBlockingQueue保持一致的API和行为
@ -573,7 +853,7 @@ public class AzureAsrHelper: NSObject {
}
}
}
// MARK: - 回调函数
/**
* 连续识别回调接口
*/

186
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift

@ -45,11 +45,21 @@ import os.log
registrar.addMethodCallDelegate(instance, channel: asrChannel)
instance.asrChannel = asrChannel
// 设置ASR通道处理器 - 使用实例方法
asrChannel.setMethodCallHandler { [weak instance] (call, result) in
instance?.handleAsrMethodCall(call, result: result)
}
// 初始化TTS通道
let ttsChannel = FlutterMethodChannel(name: "azure_speech/tts", binaryMessenger: registrar.messenger())
registrar.addMethodCallDelegate(instance, channel: ttsChannel)
instance.ttsChannel = ttsChannel
// 设置TTS通道处理器 - 使用实例方法
ttsChannel.setMethodCallHandler { [weak instance] (call, result) in
instance?.handleTtsMethodCall(call, result: result)
}
// 初始化ASR事件通道
let asrEventChannel = FlutterEventChannel(name: "azure_speech/asr_events", binaryMessenger: registrar.messenger())
asrEventChannel.setStreamHandler(instance)
@ -60,7 +70,6 @@ import os.log
ttsEventChannel.setStreamHandler(instance)
instance.ttsEventChannel = ttsEventChannel
}
// 发送ASR事件方法
internal func sendAsrEvent(_ event: [String: Any]) {
if asrEventSink == nil {
@ -96,19 +105,22 @@ import os.log
}
// 处理Flutter方法调用
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
if call.method.hasPrefix("tts_") {
handleTtsMethodCall(call, result)
} else {
handleAsrMethodCall(call, result)
}
}
// // 处理Flutter方法调用
// public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
// switch call.method {
// case "azure_speech/asr":
// handleAsrMethodCall(call, result)
// case "azure_speech/tts":
// handleTtsMethodCall(call, result)
// default:
// // 处理未识别的通道
// result(FlutterMethodNotImplemented)
// }
// }
// MARK: - ASR 方法处理
private func handleAsrMethodCall(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
private func handleAsrMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "initialize":
guard let args = call.arguments as? [String: Any],
@ -155,16 +167,25 @@ import os.log
audioSourceType = .microphone
}
// 创建回调包装器
currentAsrCallback = AsrCallbackWrapper(plugin: self)
let success = azureAsrHelper.startContinuousRecognition(
callback: currentAsrCallback!,
audioSourceType: audioSourceType
)
result(success)
case "recognizeCallback":
// 确保事件通道已准备好
guard asrEventSink != nil else {
result(FlutterError(code: "EVENT_CHANNEL_NOT_READY", message: "事件通道未准备好,无法开始连续识别", details: nil))
return
}
// 创建回调包装器
currentAsrCallback = AsrCallbackWrapper(plugin: self)
print("recognizeCallbacks")
let success = azureAsrHelper.setupEventListeners(
callback: currentAsrCallback!
)
result(success)
case "stopContinuousRecognition":
let success = azureAsrHelper.stopContinuousRecognition()
currentAsrCallback = nil
result(success)
@ -173,6 +194,7 @@ import os.log
result(azureAsrHelper.isContinuousRecognitionActive())
case "dispose":
print("dispose")
azureAsrHelper.dispose()
currentAsrCallback = nil
result(true)
@ -184,7 +206,88 @@ import os.log
return
}
azureAsrHelper.pushAudioData(data: audioBytes.data)
// 安全解包版本
guard let audioStream = azureAsrHelper.audioStream else {
os_log("音频流未初始化", type: .error)
return
}
audioStream.saveAudioDataTo(data: audioBytes.data)
result(true)
case "renameFile":
guard let args = call.arguments as? [String: Any],
let filePath = args["filePath"] as? String,
let newName = args["newName"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "必要的参数不能为空", details: nil))
return
}
do {
print("音频文件名称为: \(filePath)")
try azureAsrHelper.renameFile(filePath: filePath, newName: newName)
result(true)
} catch {
result(FlutterError(code: "RENAMEFILE_ERROR", message: error.localizedDescription, details: nil))
}
case "moveFile":
guard let args = call.arguments as? [String: Any],
let sourcePath = args["sourcePath"] as? String,
let destPath = args["destPath"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "必要的参数不能为空", details: nil))
return
}
do {
print("音频文件名称为: \(sourcePath)")
try azureAsrHelper.moveFile(sourcePath: sourcePath, destPath: destPath)
result(true)
} catch {
result(FlutterError(code: "MOVEFILE_ERROR", message: error.localizedDescription, details: nil))
}
case "enableRecord":
guard let args = call.arguments as? [String: Any],
let filePath = args["filePath"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "filePath 参数不能为空", details: nil))
return
}
do {
print("音频文件名称为: \(filePath)")
try azureAsrHelper.enableRecord(filePath: filePath)
result(true)
} catch {
result(FlutterError(code: "ENABLERECORD_ERROR", message: error.localizedDescription, details: nil))
}
case "pauseRecord":
azureAsrHelper.pauseRecord()
result(true)
case "stopRecord":
guard let args = call.arguments as? [String: Any],
let isSave = args["isSave"] as? Bool else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "isSave 参数不能为空", details: nil))
return
}
do {
try azureAsrHelper.stopRecord(isSave: isSave)
result(true)
} catch {
result(FlutterError(code: "STOPRECORD_ERROR", message: error.localizedDescription, details: nil))
}
case "disableBluetoothAudio":
print("disableBluetoothAudio")
azureAsrHelper.disableBluetoothAudio();
azureTtsHelper.stop()
result(true)
case "restoreOriginalAudioState":
print("restoreOriginalAudioState")
azureAsrHelper.restoreOriginalAudioState();
azureTtsHelper.stop()
result(true)
default:
@ -194,9 +297,9 @@ import os.log
// MARK: - TTS 方法处理
private func handleTtsMethodCall(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
private func handleTtsMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "tts_initialize":
case "initialize":
guard let args = call.arguments as? [String: Any],
let subscriptionKey = args["subscriptionKey"] as? String,
let region = args["region"] as? String else {
@ -213,7 +316,7 @@ import os.log
result(success)
case "tts_set_voice":
case "setVoice":
guard let args = call.arguments as? [String: Any],
let voiceName = args["voiceName"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "语音名称不能为空", details: nil))
@ -223,7 +326,7 @@ import os.log
let success = azureTtsHelper.setVoice(voiceName)
result(success)
case "tts_speak_once":
case "speakText":
guard let args = call.arguments as? [String: Any],
let text = args["text"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "文本不能为空", details: nil))
@ -233,7 +336,7 @@ import os.log
let success = azureTtsHelper.speakOnce(text)
result(success)
case "tts_speak_stream":
case "speakStream":
guard let args = call.arguments as? [String: Any],
let text = args["text"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "文本不能为空", details: nil))
@ -243,19 +346,46 @@ import os.log
let success = azureTtsHelper.speakStream(text)
result(success)
case "tts_flush_stream":
case "flushStream":
let success = azureTtsHelper.flushStream()
result(success)
case "tts_stop":
case "stop":
let success = azureTtsHelper.stop()
result(success)
case "tts_isSpeaking":
case "isSpeaking":
result(azureTtsHelper.isSpeaking())
case "tts_release":
case "dispose":
azureTtsHelper.dispose()
result(true)
case "setAudioOutputDevice":
guard let args = call.arguments as? [String: Any],
let type = args["type"] as? Int else {
print("setAudioOutputDevice:type=nil")
return
}
print("setAudioOutputDevice:type=\(type)")
if (type == 0) {
// 默认(如果有耳机选耳机,否则使用系统扬声器)
azureAsrHelper.restoreOriginalAudioState();
// azureTtsHelper.setAudioOutputDevice()
} else if (type == 1) {
// 强制使用声器
azureAsrHelper.disableBluetoothAudio();
// azureTtsHelper.setAudioOutputDevice()
} else if (type == 2) {
// 强制使用耳机
azureAsrHelper.restoreOriginalAudioState();
//azureTtsHelper.setAudioOutputDevice()
}
result(true)
default:
@ -276,7 +406,7 @@ private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback {
plugin?.sendAsrEvent([
"type": "result",
"text": text,
"language": detectedLanguage
"detectedLanguage": detectedLanguage
])
}
@ -284,7 +414,7 @@ private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback {
plugin?.sendAsrEvent([
"type": "recognizing",
"text": recognizing,
"language": detectedLanguage
"detectedLanguage": detectedLanguage
])
}

634
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift

@ -1,28 +1,11 @@
import Foundation
import AVFoundation
import MicrosoftCognitiveServicesSpeech
import os.log
// 自定义语音处理组件,提供音频流处理等功能
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"
@ -43,59 +26,51 @@ import os.log
// 事件监听器列表
private var eventListeners = NSHashTable<AnyObject>.weakObjects()
private var audioDataListeners = NSHashTable<AnyObject>.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?.speechSynthesisLanguage = language
currentLanguage = language
// 设置音频输出格式 - 使用16k、16位的PCM格式
speechConfig?.setPropertyTo("Audio16Khz16BitMonoPcm",
// 设置音频输出格式 - 与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,134 +111,64 @@ 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
}
// 立即返回成功,内部异步处理
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 cleanedText = cleanTextForTTS(text)
if cleanedText.isEmpty {
return false
}
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()
}
}
// 异步处理
enqueueSynthesisTask {
self.performSynthesis(text: cleanedText)
}
/**
* 实际执行合成的方法
*/
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
}
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引擎未初始化"
])
}
return false
}
do {
// 添加新文本到缓冲区
streamBuffer.append(text)
if text.isEmpty {
return true
}
// 增加500ms防抖逻辑
// 清理文本并添加到缓冲区
let cleanedText = cleanTextForTTS(text)
streamBuffer.append(cleanedText)
// 防抖逻辑 (150ms)
let currentTime = Date().timeIntervalSince1970
if currentTime - lastSpeakTime < 0.6 {
if currentTime - lastSpeakTime < 0.15 {
return true
}
lastSpeakTime = currentTime
let currentText = streamBuffer
// 定义标点符号列表
// 标点符号列表 (与Android一致)
let punctuationMarks: [Character] = [".", "。", "!", "!", "?", "?", ";", ";", ",", ",", ":", ":", "\n"]
// 查找最后一个标点符号的位置
// 从后往前查找最后一个标点符号
var lastPunctuationIndex = -1
for (i, char) in currentText.enumerated().reversed() {
if punctuationMarks.contains(char) {
@ -271,37 +177,22 @@ import os.log
}
}
// 如果找到标点符号,则播放到该标点符号
// 播放到找到的标点符号
if lastPunctuationIndex >= 0 {
// 提取要播放的文本(包含标点符号)
let textToSpeak = String(currentText.prefix(lastPunctuationIndex + 1)).trimmingCharacters(in: .whitespacesAndNewlines)
// 剩余的文本保存在缓冲区中
let textToSpeak = String(currentText.prefix(lastPunctuationIndex + 1))
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 {
@ -312,55 +203,30 @@ import os.log
return false
}
do {
// 获取缓冲区中剩余的文本
let remainingText = streamBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
// 清空缓冲区
let remainingText = streamBuffer
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)
// 直接通知停止完成
try self.synthesizer?.stopSpeaking()
self.notifyEvent(eventType: .synthesisCanceled)
} catch {
os_log("停止语音合成失败: %{public}@", log: self.log, type: .error, error.localizedDescription)
@ -370,57 +236,38 @@ import os.log
])
}
}
}
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,100 +275,180 @@ 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()
}
}
// MARK: - 辅助方法
/**
* 设置事件监听器
* 设置音频输出设备
*/
private func setupEventListeners() {
guard let synthesizer = synthesizer else { return }
public func setAudioOutputDevice() -> Bool {
// 先暂停当前播放
let wasSpeaking = speaking
if wasSpeaking {
_ = stop()
}
// 添加合成开始事件处理器
synthesizer.addSynthesisStartedEventHandler { [weak self] _, _ in
guard let self = self else { return }
self.notifyEvent(eventType: .synthesisStarted)
// 重新创建合成器以应用新设备
if isInitialized {
print(" 重新创建合成器以应用新设备")
recreateSynthesizer()
}
// 添加合成中事件处理器
synthesizer.addSynthesizingEventHandler { [weak self] _, _ in
// 可以在这里处理合成中的事件,目前没有特别操作
// 如果之前正在播放,恢复播放
if wasSpeaking, let lastText = lastSpokenText {
speakOnce(lastText)
}
// 添加合成完成事件处理器
synthesizer.addSynthesisCompletedEventHandler { [weak self] _, e in
guard let self = self else { return }
self.speaking = false
self.notifyEvent(eventType: .synthesisCompleted)
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: - 私有方法
// 添加合成取消事件处理器
synthesizer.addSynthesisCanceledEventHandler { [weak self] _, e in
guard let self = self else { return }
/**
* 加入合成任务队列
*/
private func enqueueSynthesisTask(_ task: @escaping () -> Void) {
taskLock.lock()
pendingTasks.append(task)
taskLock.unlock()
self.speaking = false
// 确保任务被处理
processTasksIfNeeded()
}
/**
* 处理任务队列
*/
private func processTasksIfNeeded() {
taskLock.lock()
// 如果已经在处理中或没有任务,则直接返回
guard !isProcessing && !pendingTasks.isEmpty else {
taskLock.unlock()
return
}
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 ?? "未知错误"
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()
}
}
} 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 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 notifyEvent(eventType: TtsEventType, params: [String: Any] = [:]) {
let event = TtsEvent(type: eventType, params: params)
private func performSynthesis(text: String) {
speaking = true
lastSpokenText = text
// 生成SSML
let ssml = generateOptimizedSsml(text)
// 直接通知事件,由调用方处理线程切换
for case let listener as TtsEventListener in self.eventListeners.allObjects {
listener.onEvent(event)
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 {
// 使用默认音频输出配置创建合成器
synthesizer = try SPXSpeechSynthesizer(speechConfig!)
// 创建音频配置
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)
@ -533,72 +460,139 @@ import os.log
}
/**
* 设置语音参数
* 设置事件监听器
*/
private func setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100) -> Bool {
if !isInitialized { return false }
private func setupEventListeners() {
synthesizer?.addSynthesisStartedEventHandler { [weak self] _, _ in
self?.notifyEvent(eventType: .synthesisStarted)
self?.notifyEvent(eventType: .playbackStarted)
}
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
synthesizer?.addSynthesizingEventHandler { [weak self] _, event in
if let audioData = event.result.audioData {
self?.notifyAudioData(audioData)
}
}
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] _, event in
guard let self = self else { return }
self.speaking = false
var params: [String: Any] = [:]
if let details = try? SPXSpeechSynthesisCancellationDetails(fromCanceledSynthesisResult: event.result) {
params["errorCode"] = details.errorCode
params["errorDetails"] = details.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 eventListeners.allObjects {
listener.onEvent(event)
}
}
/**
* 格式化百分比值
* 通知音频数据
*/
private func formatPercentage(_ value: Int) -> String {
return value >= 0 ? "+\(value)%" : "\(value)%"
private func notifyAudioData(_ data: Data) {
for case let listener as AudioDataListener in audioDataListeners.allObjects {
listener.onAudioData(data)
}
}
/**
* 生成SSML
* 预热TTS引擎
*/
private func generateSsml(_ rawText: String) -> String {
// 1. 定义要静音的符号和表情符号列表
let symbolsToMute = [
"#", "*",
"😀", "😂", "😊", "😍", "😢", "😎", "😉", "👍", "🙌", "🎉"
]
// 2. 转义 XML 保留字符
var escapedText = rawText
private func warmupSynthesizer() {
let warmupSsml = """
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="\(currentLanguage)">
<voice name="\(currentVoice)">
<prosody rate="\(currentRate)" pitch="\(currentPitch)" volume="0%">
.
</prosody>
</voice>
</speak>
"""
synthesisQueue.async {
_ = try? self.synthesizer?.startSpeakingSsml(warmupSsml)
}
}
/**
* 清理TTS文本
*/
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
*/
private func generateOptimizedSsml(_ rawText: String) -> String {
// 转义XML保留字符
let escapedText = rawText
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
// 3. 静音处理特殊符号和表情符号
// 使用空白替换法,直接将符号替换为空字符串
var processedText = escapedText
for symbol in symbolsToMute {
processedText = processedText.replacingOccurrences(of: symbol, with: "")
}
// 4. 构造简化的SSML文档,减少嵌套层级
let ssml = """
<speak version="1.0"
xmlns="http://www.w3.org/2001/10/synthesis"
xmlns:mstts="https://www.w3.org/2001/mstts"
xml:lang="zh-CN">
// 简化SSML结构
return """
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="\(currentLanguage)">
<voice name="\(currentVoice)">
<mstts:express-as style="cheerful">
<prosody rate="\(currentRate)" pitch="\(currentPitch)" volume="\(currentVolume)">
<say-as interpret-as="text">\(processedText)</say-as>
\(escapedText)
</prosody>
</mstts:express-as>
</voice>
</speak>
"""
}
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
}
}

233
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
}
}
}

11
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<Map<String, Any>>? {
// 更新缓存
val matchedResults = scanResults.filter {
it.device.name == deviceName
}
Log.d(TAG, "找到 ${matchedResults.size} 个名称为 $deviceName 的设备")
if (matchedResults.isEmpty()) {
return emptyList()
}
return null;
}
/**
* 打印设备信息和服务UUID
*/

6
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<String>("deviceName")?:""
val infos = BleService.getDeviceInfos(deviceName);
Log.d(TAG, "getDeviceList $infos")
result.success(infos)
}
"disconnect" -> {
BleService.disconnect();
result.success(null)

2
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"])
],

612
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
@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 let deviceCompanyIdentifier: UInt16 = 0xEFAB
private let deviceDisplayName = "语音助理耳机"
// 用于存储配对设备UUID的UserDefaults键
private let pairedDeviceUUIDKey = "com.yunqiinnovation.ble_service.paired_device_uuid"
typealias DeviceDiscoveredCallback = (UUID) -> Void
private var onDeviceDiscovered: DeviceDiscoveredCallback?
// MARK: - 初始化
private override init() { super.init() }
// MARK: - 公共方法
func setBleService(_ service: Any) {
self.bleService = service as? BleService
}
func setDeviceDiscoveredCallback(_ callback: @escaping (UUID) -> Void) {
self.onDeviceDiscovered = callback
}
// 处理发现的设备并连接
func connectToAccessory(uuid: UUID) {
guard let service = bleService else {
return
}
service.connectToAccessory(uuid: uuid)
}
func addToReconnectQueue(uuid: UUID) {
guard let service = bleService else {
return
}
service.addToReconnectQueue(uuid: uuid)
}
// 从视图控制器显示设备选择器 - 主要入口点
func showDevicePickerFromViewController(_ viewController: UIViewController) {
initializeSession()
}
// 清除蓝牙状态(仅用于测试或重置,不会清除系统授权)
func resetBluetoothState() {
if let service = bleService {
service.disconnect()
}
}
// 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()
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
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
}
// MARK: - 设备选择器显示
private func showDevicePicker() {
guard let session = session, isSessionActivated else {
os_log("无法显示配对框:会话未激活", log: logger, type: .error)
return
}
os_log("准备显示设备配对框", log: logger, type: .info)
let descriptor = ASDiscoveryDescriptor()
descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(deviceCompanyIdentifier)
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 }
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()
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 .accessoryChanged:
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 .pickerSetupFailed:
os_log("选择器设置失败: %{public}@", log: logger, type: .error,
event.error?.localizedDescription ?? "未知错误")
default:
os_log("未处理的会话事件: %d", log: logger, type: .info, event.eventType.rawValue)
break
}
}
private func handleSessionActivated() {
isSessionActivated = true
isInitializing = false
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()
}
}
private func handleAccessoryAdded(_ accessory: ASAccessory?) {
guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else {
return
}
// 保存配对设备的UUID
savePairedMacAddress(uuid)
onDeviceDiscovered?(uuid)
connectToAccessory(uuid: uuid)
}
private func handleAccessoryRemoved(_ accessory: ASAccessory?) {
guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else {
return
}
if let service = bleService {
service.disconnect()
}
}
private func handleSessionInitError(_ error: Error) {
isInitializing = false
session = nil
}
// 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)
}
return true
}
// MARK: - 清除配对关系
func clearAssociations() {
resetBluetoothState()
// 清除保存的配对设备UUID
clearPairedMacAddress()
#if canImport(AccessorySetupKit) && swift(>=5.9)
if #available(iOS 18.0, *) {
resetSession()
initializeSession()
}
#endif
}
// 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
/// - 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)
}
}
// 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()
// 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 = "语音助理耳机"
// // 用于存储配对设备UUID的UserDefaults键
// private let pairedDeviceUUIDKey = "com.yunqiinnovation.ble_service.paired_device_uuid"
// typealias DeviceDiscoveredCallback = (UUID) -> Void
// private var onDeviceDiscovered: DeviceDiscoveredCallback?
// // MARK: - 初始化
// private override init() { super.init() }
// // MARK: - 公共方法
// func setBleService(_ service: Any) {
// self.bleService = service as? BleService
// }
// func setDeviceDiscoveredCallback(_ callback: @escaping (UUID) -> Void) {
// self.onDeviceDiscovered = callback
// }
// // 处理发现的设备并连接
// func connectToAccessory(uuid: UUID) {
// guard let service = bleService else {
// return
// }
// service.connectToAccessory(uuid: uuid)
// }
// func addToReconnectQueue(uuid: UUID) {
// guard let service = bleService else {
// return
// }
// service.addToReconnectQueue(uuid: uuid)
// }
// // 从视图控制器显示设备选择器 - 主要入口点
// func showDevicePickerFromViewController(_ viewController: UIViewController) {
// initializeSession()
// }
// // 清除蓝牙状态(仅用于测试或重置,不会清除系统授权)
// func resetBluetoothState() {
// if let service = bleService {
// service.disconnect()
// }
// }
// // 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()
// 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
// 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
// }
// // MARK: - 设备选择器显示
// private func showDevicePicker() {
// guard let session = session, isSessionActivated else {
// os_log("无法显示配对框:会话未激活", log: logger, type: .error)
// return
// }
// os_log("准备显示设备配对框", log: logger, type: .info)
// let descriptor = ASDiscoveryDescriptor()
// descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(deviceCompanyIdentifier)
// 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 }
// 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()
// 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 .accessoryChanged:
// 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 .pickerSetupFailed:
// os_log("选择器设置失败: %{public}@", log: logger, type: .error,
// event.error?.localizedDescription ?? "未知错误")
// default:
// os_log("未处理的会话事件: %d", log: logger, type: .info, event.eventType.rawValue)
// break
// }
// }
// private func handleSessionActivated() {
// isSessionActivated = true
// isInitializing = false
// 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()
// }
// }
// private func handleAccessoryAdded(_ accessory: ASAccessory?) {
// guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else {
// return
// }
// // 保存配对设备的UUID
// savePairedMacAddress(uuid)
// onDeviceDiscovered?(uuid)
// connectToAccessory(uuid: uuid)
// }
// private func handleAccessoryRemoved(_ accessory: ASAccessory?) {
// guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else {
// return
// }
// if let service = bleService {
// service.disconnect()
// }
// }
// private func handleSessionInitError(_ error: Error) {
// isInitializing = false
// session = nil
// }
// // 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)
// }
// return true
// }
// // MARK: - 清除配对关系
// func clearAssociations() {
// resetBluetoothState()
// // 清除保存的配对设备UUID
// clearPairedMacAddress()
// #if canImport(AccessorySetupKit) && swift(>=5.9)
// if #available(iOS 18.0, *) {
// resetSession()
// initializeSession()
// }
// #endif
// }
// // 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
// /// - 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)
// }
// }

586
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 "未知状态"
// }
// }
// }

393
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!
}
/// 开始扫描BLE设备
public func startScan() {
#if canImport(AccessorySetupKit) && swift(>=5.9)
if #available(iOS 18.0, *) {
// iOS 18+不使用传统扫描
return
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
}
#endif
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(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)
// 发送数据(使用无响应写入,与Android端保持一致)
// 加入队列并触发发送
if isReply{
peripheralDevice?.writeValue(cmdPacket, for: characteristic, type: .withoutResponse)
isReply = false;
return true
} else {
commandQueue.append(cmdPacket)
}
// 发送数据(使用无响应写入,与Android端保持一致)
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,6 +762,12 @@ 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 {
@ -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) {

208
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,66 +106,74 @@ 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 "startDeviceAssociation":
// 用户主动触发设备关联流程
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, *) {
os_log("用户主动触发设备关联流程", log: logger, type: .info)
// 确保服务已初始化
ensureServicesInitialized()
// 重新初始化会话
BleAccessorySetup.shared.initializeSession()
// 返回成功,实际配对结果会通过回调通知
result(true)
} else {
os_log("当前iOS版本不支持AccessorySetupKit", log: logger, type: .info)
result(false)
}
#else
os_log("当前环境不支持AccessorySetupKit", log: logger, type: .info)
result(false)
#endif
case "getPairedMacAddress":
let pairedUUID = BleService.shared.getPairedMacAddress()
result(nil)
case "clearAssociations":
BleService.shared.clearAssociations()
result(true)
case "startDeviceAssociation":
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
}
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)
}
}
// 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<T>(_ 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

56
local_plugins/ble_service/lib/ble_service.dart

@ -294,15 +294,29 @@ class BleService {
}
/// 断开连接
Future<Map<String, String>?> getDeviceInfo(String address) async {
Future<bool> stopScan() async {
try {
final result = await _methodChannel.invokeMethod<bool>('stopScan');
return result ?? false;
} catch (e) {
print('开启扫描ble设备失败: $e');
return false;
}
}
/// 获取设备信息
Future<Map<String, String>?> getDeviceInfo(String deviceName) async {
try {
final result = await _methodChannel
.invokeMethod<Map>('getDeviceInfo', {'address': address});
.invokeMethod<Map>('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<List<Map<String, String>>?> getDeviceList(String deviceName) async {
try {
final result = await _methodChannel
.invokeMethod<List>('getDeviceList', {'deviceName': deviceName});
print('获取设备列表!: $result');
if (result != null) {
return result.map<Map<String, String>>((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<bool> requestBluetoothPermission() async {
try {
final result =
await _methodChannel.invokeMethod<bool>('requestBluetoothPermission');
return result ?? false;
} catch (e) {
print('请求蓝牙权限失败: $e');
return false;
}
}
}

6
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: []
),

145
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"
])
}
}
}
}

297
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<Void, Never>?
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,38 +252,42 @@ public class ChatApiService: NSObject {
}
/// 发送消息(流式输出)
public func sendMessageStream(messages: [[String: Any]]) {
// 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服务未初始化")
streamCallback?.onError(error)
getSessionCallback(sessionId)?.onError(sessionId, error)
return
}
// 重置状态
isCanceled = false
// 保存当前消息和回调
currentMessages = messages
toolCalls = [:]
sessionCallbacks[sessionId] = getSessionCallback(sessionId)
// 检查是否包含图片,决定使用哪个模型
var currentModel = model
if containsImage(in: messages) {
currentModel = visionModel
}
// 检查是否包含图片,决定模型
let currentModel = containsImage(in: messages) ? visionModel : model
currentStreamTask = Task { [weak self] in
guard let self = self else { return }
var replyContext = "" // 收集AI回复内容
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,
@ -298,16 +308,18 @@ public class ChatApiService: NSObject {
let chatsStream: AsyncThrowingStream<ChatStreamResult, Error> = openAI.chatsStream(query: query)
for try await result in chatsStream {
if self.isCanceled {
break
// 检查会话是否已变更
if sessionId != self.currSessionId {
return
}
if let choice = result.choices.first {
guard let choice = result.choices.first else { continue }
let delta = choice.delta
// 处理普通文本内容
// 处理文本内容
if let content = delta.content {
self.streamCallback?.onToken(content)
replyContext += content
self.getSessionCallback(sessionId)?.onToken(sessionId, content)
}
// 收集工具调用信息
@ -315,12 +327,12 @@ public class ChatApiService: NSObject {
for toolCall in toolCalls {
let index = toolCall.index
// 创建或获取现有的工具调用信息
// 初始化工具调用信息
if self.toolCalls[index] == nil {
self.toolCalls[index] = ToolCallInfo()
}
let toolCallInfo = self.toolCalls[index]!
guard let toolCallInfo = self.toolCalls[index] else { continue }
// 更新ID
if let id = toolCall.id {
@ -340,79 +352,168 @@ public class ChatApiService: NSObject {
}
}
}
}
if !self.isCanceled {
// 处理工具调用或完成
let hasToolCalls = await self.processToolCalls()
// 流式响应结束
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)
// 处理工具调用
let hasToolCalls = await self.processToolCalls(sessionId)
if !hasToolCalls {
self.streamCallback?.onComplete()
self.getSessionCallback(sessionId)?.onComplete(sessionId)
self.clearCurrentSession()
}
}
} catch {
if !self.isCanceled {
let chatApiError = ChatApiException("流式请求失败: \(error.localizedDescription)")
self.streamCallback?.onError(chatApiError)
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 {
/// 处理工具调用
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)
// 通知上层工具调用事件
streamCallback?.onFunctionCall(functionCall)
// getSessionCallback(sessionId)?.onFunctionCall(sessionId, functionCall)
// 在后台处理工具调用
Task { [weak self] in
guard let self = self else { return }
// 在后台队列处理工具调用
Task {
do {
if !self.isCanceled {
// 调用工具
let result: [String: Any]
if sessionId == self.currSessionId {
// 解析参数
let args = try self.parseJsonArguments(firstToolCall.arguments)
// 通过MCP客户端处理工具调用
var result: [String: Any] = [:]
if let client = self.mcpClient {
result = await client.callTool(name: firstToolCall.name, arguments: args) ?? ["context": "工具调用失败"]
// 调用MCP工具
if let toolResult = await client.callTool(name: firstToolCall.name, arguments: args) {
print("[Session: \(sessionId)] AI调用工具结果: \(firstToolCall.name), 参数: \(args), 结果: \(toolResult)")
// 统一结果格式
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"]
}
} else {
result = ["context": "MCP客户端未初始化"]
print("[Session: \(sessionId)] MCP工具调用返回nil")
result = ["context": "Tool call failed"]
}
} else {
// 工具不存在
result = ["context": "Tool not found: \(firstToolCall.name)"]
}
if !self.isCanceled {
if sessionId == self.currSessionId {
// 处理结果
self.streamCallback?.onFunctionCallResult(functionCall, result)
// 将result转换为JSON字符串
let context: String
do {
let jsonData = try JSONSerialization.data(withJSONObject: result, options: .prettyPrinted)
context = String(data: jsonData, encoding: .utf8) ?? "工具调用失败"
} catch {
context = "工具调用失败"
}
self.getSessionCallback(sessionId)?.onFunctionCallResult(
sessionId,
functionCall,
result
)
// 将结果发送回OpenAI继续对话
await self.sendFunctionCallResultInternal(
sessionId: sessionId,
messages: self.currentMessages,
functionCall: functionCall,
functionResult: context
functionResult: self.jsonToString(result) ?? "{}"
)
}
}
} catch {
if !self.isCanceled {
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
@ -424,13 +525,48 @@ public class ChatApiService: NSObject {
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参数

240
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: - 内部方法
/// 获取当前位置
internal func getCurrentLocation() async -> Result {
return await withCheckedContinuation { continuation in
locationCompletion = { result in
continuation.resume(returning: result)
}
// 设置15秒超时
DispatchQueue.global().asyncAfter(deadline: .now() + 15.0) { [weak self] in
if self?.locationCompletion != nil {
self?.locationCompletion?(Result(success: false, message: "位置请求超时"))
self?.locationCompletion = nil
}
}
// 检查位置服务是否可用
guard CLLocationManager.locationServicesEnabled() else {
locationCompletion?(Result(success: false, message: "位置服务未启用。请前往 设置 > 隐私与安全 > 定位服务 启用定位服务"))
locationCompletion = nil
return
}
// MARK: - 功能处理器实现
// 确保locationManager已初始化
guard let locationManager = locationManager else {
locationCompletion?(Result(success: false, message: "位置管理器未初始化"))
locationCompletion = nil
return
}
/// 获取当前位置处理器
private class GetCurrentLocationHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
// 检查位置权限
let authStatus: CLAuthorizationStatus
if #available(iOS 14.0, *) {
authStatus = locationManager.authorizationStatus
} else {
authStatus = CLLocationManager.authorizationStatus()
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序列化失败\"}"
}
print("当前位置权限状态: \(authStatus.rawValue)")
// 先尝试获取缓存的位置数据
if let cachedLocation = LocationService.shared.getCachedLocationData() {
let message = "当前位置:\(cachedLocation.address ?? "未知地址")(纬度 \(cachedLocation.latitude),经度 \(cachedLocation.longitude))"
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
// 异步触发最新定位(不等待结果)
Task {
do {
_ = try await LocationService.shared.getCurrentLocation()
} catch {
print("后台更新位置失败: \(error)")
}
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
let result: [String: Any] = [
"success": true,
"message": message
]
let jsonData = try JSONSerialization.data(withJSONObject: result)
return String(data: jsonData, encoding: .utf8) ?? "{\"error\": \"JSON序列化失败\"}"
}
public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
// 只有在有位置请求等待时才处理权限变化
guard locationCompletion != nil else {
return
}
// 如果没有缓存数据,同步获取位置
do {
let locationData = try await LocationService.shared.getCurrentLocation()
let message = "当前位置:\(locationData.address ?? "未知地址")(纬度 \(locationData.latitude),经度 \(locationData.longitude))"
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
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: "已打开短信应用"))
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: "打开短信应用失败"))
}
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")
}
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
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
}
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)
}
}

2
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"])
],

2
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'

38
local_plugins/location_service/ios/.gitignore

@ -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

131
local_plugins/location_service/ios/Classes/LocationService.swift

@ -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()
}
}

44
local_plugins/location_service/ios/Classes/LocationServicePlugin.swift

@ -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()
}
}
}

27
local_plugins/location_service/ios/location_service.podspec

@ -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

21
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"
)
]
)

7
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)
}

16
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
}
}

357
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
}
}
}

72
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

3
local_plugins/location_service/pubspec.yaml

@ -26,4 +26,5 @@ flutter:
pluginClass: LocationServicePlugin
ios:
pluginClass: LocationServicePlugin
swiftPackage:
path: ios/location_service

2
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"])
],

383
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,17 +14,37 @@ 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
@ -31,53 +52,149 @@ public class MusicService: NSObject {
return urlAsset.url.absoluteString
}
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)
func getCurrentId() -> Int64? {
return currentIdInternal
}
public func playPlaylist(songs: [[String: String]]) {
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: Any]]) {
playlist = songs
currentIndex = 0
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 [
@ -90,23 +207,56 @@ public class MusicService: NSObject {
let currentTime = CMTimeGetSeconds(player.currentTime())
let duration = CMTimeGetSeconds(currentItem.duration)
// 处理无效值
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
// 更严格的有效性检查
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
return [
let result: [String: Any] = [ // Add explicit type annotation here
"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,22 +279,42 @@ 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),
// 异步加载封面图片
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
}
}
private func updatePlaybackState() {
// iOS manages playback state automatically, but you can update NowPlayingInfo if needed
@ -169,14 +339,22 @@ 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,
MPMediaItemPropertyArtist: artist
@ -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 {
// 更新系统媒体信息 - 关键升级点
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] = 1.0
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
}
}
}

37
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,9 +74,36 @@ 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)
}

1
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];

2
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"])
],

21
local_plugins/realtime/ios/realtime/Sources/realtime/RealtimeAudioManager.swift

@ -331,14 +331,25 @@ class RealtimeAudioManager {
}
let frameCount = Int(buffer.frameLength)
var rms: Float = 0.0
let channelDataPointer = UnsafeMutablePointer(mutating: channelData)
// 使用Accelerate框架高效计算RMS
vDSP_measqv(channelData, 1, &rms, vDSP_Length(frameCount))
// 创建浮点缓冲区用于转换
var floatData = [Float](repeating: 0.0, count: frameCount)
// 计算平方根并归一化
rms = sqrt(rms / Float(frameCount)) / 32768.0
// 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
}
}

6
local_plugins/speech/ios/speech/Sources/speech/ITtsService.swift

@ -13,6 +13,12 @@ import Foundation
/** 合成取消 */
case synthesisCanceled
/** 播放开始 */
case playbackStarted
/** 播放完成 */
case playbackCompleted
/** 发生错误 */
case error
}

29
local_plugins/user_portrait/ios/user_portrait.podspec

@ -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

14
local_plugins/user_portrait/ios/user_portrait/Package.swift

@ -15,18 +15,10 @@ let package = Package(
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"),
dependencies: [
// 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
]
],
path: "Sources/user_portrait"
)
]
)

14
local_plugins/user_portrait/ios/user_portrait/Sources/user_portrait/PrivacyInfo.xcprivacy

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>

4
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)
}

46
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

5
pubspec.yaml

@ -25,6 +25,7 @@ dependencies:
markdown: ^7.1.1
fleather: ^1.24.0+1
parchment: ^1.24.0
htmltopdfwidgets: ^1.1.0
wifi_iot: ^0.3.19+2
permission_handler: ^10.4.5
web_socket_channel: ^2.4.0
@ -43,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
@ -97,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:
@ -134,6 +132,7 @@ flutter:
- assets/images/explore1.png
- assets/images/explore2.jpg
- assets/images/mine-background.png
- assets/fonts/NotoSansSC-Regular.ttf
- assets/lottie/
- assets/js/
- assets/images/headphone_new4.png

Loading…
Cancel
Save