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. 112
      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. 524
      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. 74
      lib/modules/meeting/views/bottomSheet/share_bottom_sheet.dart
  22. 195
      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. 480
      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. 127
      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. 586
      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. 716
      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. 220
      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. 471
      local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiService.swift
  53. 252
      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. 407
      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. 37
      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. 22
      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> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key> <key>MinimumOSVersion</key>
<string>18.0</string> <string>16.0</string>
</dict> </dict>
</plist> </plist>

4
ios/Podfile

@ -1,5 +1,5 @@
# Define a global platform for your project # 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. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@ -44,7 +44,7 @@ post_install do |installer|
# Ensure all pods use the same iOS deployment target # Ensure all pods use the same iOS deployment target
target.build_configurations.each do |config| 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'] ||= [ config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
'$(inherited)', '$(inherited)',

33
ios/Podfile.lock

@ -3,16 +3,12 @@ PODS:
- AMapNavi (~> 10.0.900) - AMapNavi (~> 10.0.900)
- Flutter - Flutter
- AMapFoundation (1.8.2) - AMapFoundation (1.8.2)
- AMapLocation (2.10.0):
- AMapFoundation (>= 1.8.0)
- AMapNavi (10.0.900): - AMapNavi (10.0.900):
- AMapFoundation (>= 1.8.2) - AMapFoundation (>= 1.8.2)
- audio_waveforms (0.0.1): - audio_waveforms (0.0.1):
- Flutter - Flutter
- classic_bluetooth (0.0.1): - classic_bluetooth (0.0.1):
- Flutter - Flutter
- device_info_plus (0.0.1):
- Flutter
- FBAEMKit (18.0.0): - FBAEMKit (18.0.0):
- FBSDKCoreKit_Basics (= 18.0.0) - FBSDKCoreKit_Basics (= 18.0.0)
- FBSDKCoreKit (18.0.0): - FBSDKCoreKit (18.0.0):
@ -44,6 +40,8 @@ PODS:
- WechatOpenSDK-XCFramework (~> 2.0.4) - WechatOpenSDK-XCFramework (~> 2.0.4)
- haptic_feedback (0.5.1): - haptic_feedback (0.5.1):
- Flutter - Flutter
- keyboard_height_plugin (0.0.1):
- Flutter
- libwebp (1.5.0): - libwebp (1.5.0):
- libwebp/demux (= 1.5.0) - libwebp/demux (= 1.5.0)
- libwebp/mux (= 1.5.0) - libwebp/mux (= 1.5.0)
@ -56,9 +54,6 @@ PODS:
- libwebp/sharpyuv (1.5.0) - libwebp/sharpyuv (1.5.0)
- libwebp/webp (1.5.0): - libwebp/webp (1.5.0):
- libwebp/sharpyuv - libwebp/sharpyuv
- location_service (0.0.1):
- AMapLocation (~> 2.10.0)
- Flutter
- Mantle (2.2.0): - Mantle (2.2.0):
- Mantle/extobjc (= 2.2.0) - Mantle/extobjc (= 2.2.0)
- Mantle/extobjc (2.2.0) - Mantle/extobjc (2.2.0)
@ -89,12 +84,13 @@ PODS:
- Flutter - Flutter
- QCloudCOSXML (= 6.4.7) - QCloudCOSXML (= 6.4.7)
- WechatOpenSDK-XCFramework (2.0.4) - WechatOpenSDK-XCFramework (2.0.4)
- wifi_iot (0.0.1):
- Flutter
DEPENDENCIES: DEPENDENCIES:
- amap_service (from `.symlinks/plugins/amap_service/ios`) - amap_service (from `.symlinks/plugins/amap_service/ios`)
- audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`)
- classic_bluetooth (from `.symlinks/plugins/classic_bluetooth/ios`) - classic_bluetooth (from `.symlinks/plugins/classic_bluetooth/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_facebook_auth (from `.symlinks/plugins/flutter_facebook_auth/ios`) - flutter_facebook_auth (from `.symlinks/plugins/flutter_facebook_auth/ios`)
- flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/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`) - flutter_tts (from `.symlinks/plugins/flutter_tts/ios`)
- fluwx (from `.symlinks/plugins/fluwx/ios`) - fluwx (from `.symlinks/plugins/fluwx/ios`)
- haptic_feedback (from `.symlinks/plugins/haptic_feedback/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`) - ota (from `.symlinks/plugins/ota/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- record_darwin (from `.symlinks/plugins/record_darwin/ios`) - record_darwin (from `.symlinks/plugins/record_darwin/ios`)
- spotify_sdk (from `.symlinks/plugins/spotify_sdk/ios`) - spotify_sdk (from `.symlinks/plugins/spotify_sdk/ios`)
- tencentcloud_cos_sdk_plugin (from `.symlinks/plugins/tencentcloud_cos_sdk_plugin/ios`) - tencentcloud_cos_sdk_plugin (from `.symlinks/plugins/tencentcloud_cos_sdk_plugin/ios`)
- wifi_iot (from `.symlinks/plugins/wifi_iot/ios`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- AMapFoundation - AMapFoundation
- AMapLocation
- AMapNavi - AMapNavi
- FBAEMKit - FBAEMKit
- FBSDKCoreKit - FBSDKCoreKit
@ -135,8 +131,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/audio_waveforms/ios" :path: ".symlinks/plugins/audio_waveforms/ios"
classic_bluetooth: classic_bluetooth:
:path: ".symlinks/plugins/classic_bluetooth/ios" :path: ".symlinks/plugins/classic_bluetooth/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_facebook_auth: flutter_facebook_auth:
@ -153,8 +147,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/fluwx/ios" :path: ".symlinks/plugins/fluwx/ios"
haptic_feedback: haptic_feedback:
:path: ".symlinks/plugins/haptic_feedback/ios" :path: ".symlinks/plugins/haptic_feedback/ios"
location_service: keyboard_height_plugin:
:path: ".symlinks/plugins/location_service/ios" :path: ".symlinks/plugins/keyboard_height_plugin/ios"
ota: ota:
:path: ".symlinks/plugins/ota/ios" :path: ".symlinks/plugins/ota/ios"
permission_handler_apple: permission_handler_apple:
@ -165,15 +159,15 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/spotify_sdk/ios" :path: ".symlinks/plugins/spotify_sdk/ios"
tencentcloud_cos_sdk_plugin: tencentcloud_cos_sdk_plugin:
:path: ".symlinks/plugins/tencentcloud_cos_sdk_plugin/ios" :path: ".symlinks/plugins/tencentcloud_cos_sdk_plugin/ios"
wifi_iot:
:path: ".symlinks/plugins/wifi_iot/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
amap_service: 1c3e7e07f51cbbdff03719a4a079c60d015c1c0b amap_service: 1c3e7e07f51cbbdff03719a4a079c60d015c1c0b
AMapFoundation: 9885c48fc3a78fdfb84a0299a2293e56ea3c9fec AMapFoundation: 9885c48fc3a78fdfb84a0299a2293e56ea3c9fec
AMapLocation: 5248aec2455ebb5d104b367813c946430a2ee033
AMapNavi: fceb4715f9a3b5a25f330facebee2aa2bdba42dd AMapNavi: fceb4715f9a3b5a25f330facebee2aa2bdba42dd
audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf
classic_bluetooth: c146e1618e867a47fa7ab79a3bf262e7d69d557d classic_bluetooth: 29f392f1ab7c52862a9ef036c94f2713b02e0c50
device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896
FBAEMKit: e34530df538b8eb8aeb53c35867715ba6c63ef0c FBAEMKit: e34530df538b8eb8aeb53c35867715ba6c63ef0c
FBSDKCoreKit: d3f479a69127acebb1c6aad91c1a33907bcf6c2f FBSDKCoreKit: d3f479a69127acebb1c6aad91c1a33907bcf6c2f
FBSDKCoreKit_Basics: 017b6dc2a1862024815a8229e75661e627ac1e29 FBSDKCoreKit_Basics: 017b6dc2a1862024815a8229e75661e627ac1e29
@ -186,8 +180,8 @@ SPEC CHECKSUMS:
flutter_tts: b88dbc8655d3dc961bc4a796e4e16a4cc1795833 flutter_tts: b88dbc8655d3dc961bc4a796e4e16a4cc1795833
fluwx: 6bf9c5a3a99ad31b0de137dd92370a0d10a60f4b fluwx: 6bf9c5a3a99ad31b0de137dd92370a0d10a60f4b
haptic_feedback: fd1d8509833f58f164fc12122c0357fa9b2750e0 haptic_feedback: fd1d8509833f58f164fc12122c0357fa9b2750e0
keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
location_service: fab4ef23d473296a215c49e12c129e466574d94e
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
ota: 20c3e522c57e7ba70ce118109e73650acc9fec76 ota: 20c3e522c57e7ba70ce118109e73650acc9fec76
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
@ -200,7 +194,8 @@ SPEC CHECKSUMS:
spotify_sdk: a48400bb29f70c4fe251ebfdc9135c37097ac5ca spotify_sdk: a48400bb29f70c4fe251ebfdc9135c37097ac5ca
tencentcloud_cos_sdk_plugin: 7bed564dbe72df23e7b5cacb1e51b1a20cb1639c tencentcloud_cos_sdk_plugin: 7bed564dbe72df23e7b5cacb1e51b1a20cb1639c
WechatOpenSDK-XCFramework: 36fb2bea0754266c17184adf4963d7e6ff98b69f WechatOpenSDK-XCFramework: 36fb2bea0754266c17184adf4963d7e6ff98b69f
wifi_iot: f645260a2be8608517b2a9bf4c39b98e97003acc
PODFILE CHECKSUM: 517ecd5ce5153c9609017e05f4e64ab72605e805 PODFILE CHECKSUM: e69e629768e642fde97676ea149350c61d57751e
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

26
ios/Runner.xcodeproj/project.pbxproj

@ -252,7 +252,7 @@
); );
mainGroup = 97C146E51CF9000F007C117D; mainGroup = 97C146E51CF9000F007C117D;
packageReferences = ( packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
D013B7972DFA83E400B2B69B /* XCRemoteSwiftPackageReference "flutterfire" */, D013B7972DFA83E400B2B69B /* XCRemoteSwiftPackageReference "flutterfire" */,
); );
productRefGroup = 97C146EF1CF9000F007C117D /* Products */; productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
@ -526,15 +526,16 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 42C82SZUVX; DEVELOPMENT_TEAM = 537SFY28XS;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0; INFOPLIST_KEY_CFBundleDisplayName = "Deep Voice";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; PRODUCT_BUNDLE_IDENTIFIER = com.chuangxiangyunqi.deapsound;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -549,6 +550,7 @@
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 537SFY28XS;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
@ -716,15 +718,16 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 42C82SZUVX; DEVELOPMENT_TEAM = 537SFY28XS;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0; INFOPLIST_KEY_CFBundleDisplayName = "Deep Voice";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; PRODUCT_BUNDLE_IDENTIFIER = com.chuangxiangyunqi.deapsound;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@ -741,15 +744,16 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 42C82SZUVX; DEVELOPMENT_TEAM = 537SFY28XS;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0; INFOPLIST_KEY_CFBundleDisplayName = "Deep Voice";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; PRODUCT_BUNDLE_IDENTIFIER = com.chuangxiangyunqi.deapsound;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -793,7 +797,7 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference; isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
}; };

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

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

42
ios/Runner/Info.plist

@ -39,7 +39,7 @@
</array> </array>
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <!-- 构建号 --> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FacebookAdvertiserIDCollectionEnabled</key> <key>FacebookAdvertiserIDCollectionEnabled</key>
<false/> <false/>
@ -72,50 +72,12 @@
</array> </array>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <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> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<true/> <true/>
<key>NSAllowsArbitraryLoadsInWebContent</key> <key>NSAllowsArbitraryLoadsInWebContent</key>
<true/> <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> </dict>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
<string>需要蓝牙权限用于连接和管理音频设备</string> <string>需要蓝牙权限用于连接和管理音频设备</string>
@ -160,5 +122,7 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict> </dict>
</plist> </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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <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> <key>com.apple.developer.networking.wifi-info</key>
<true/> <true/>
<key>com.apple.external-accessory.wireless-configuration</key> <key>com.apple.external-accessory.wireless-configuration</key>
<true/> <true/>
<key>com.apple.developer.homekit</key>
<true/>
</dict> </dict>
</plist> </plist>

112
lib/data/models/user_Info.dart

@ -1,3 +1,5 @@
import 'dart:ffi';
class User { class User {
// 单例实例 // 单例实例
static User? _instance; static User? _instance;
@ -21,15 +23,7 @@ class User {
String phonemac; String phonemac;
String password; String password;
int viplv = 0; //添加会员等级字段,0表示普通用户 int viplv = 0; //添加会员等级字段,0表示普通用户
String earphonename; List<UserDevice> devices;
String earphonemac;
String earphonelicense;
String earphoneauthcode;
// final Int64 lastbettime;
// final Int64 createtime;
// final String wxopenid;
// final String googleopenid;
// final String facebookopenid;
// 私有构造函数 // 私有构造函数
User._({ User._({
@ -42,21 +36,14 @@ class User {
required this.phone, required this.phone,
required this.phonemac, required this.phonemac,
required this.password, required this.password,
required this.viplv, // 添加默认值 required this.viplv,
required this.earphonename, required this.devices,
required this.earphonemac,
required this.earphonelicense,
required this.earphoneauthcode,
// required this.lastbettime,
// required this.createtime,
// required this.wxopenid,
// required this.googleopenid,
// required this.facebookopenid,
}); });
factory User.fromJson(Map<String, dynamic> json) { factory User.fromJson(Map<String, dynamic> json) {
// 修复1:安全获取嵌套的 user 字段 // 修复1:安全获取嵌套的 user 字段
final userData = json['user'] as Map<String, dynamic>?; // 允许空值 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 = json['data']['token'] as String? ?? '';
//final token = ''; //final token = '';
// 更新单例实例 // 更新单例实例
@ -74,21 +61,17 @@ class User {
phonemac: (userData?['phonemac'] as String?) ?? '', phonemac: (userData?['phonemac'] as String?) ?? '',
password: (userData?['password'] as String?) ?? '', password: (userData?['password'] as String?) ?? '',
viplv: (userData?['viplv'] as int?) ?? 0, // 提供默认值 viplv: (userData?['viplv'] as int?) ?? 0, // 提供默认值
earphonename: (userData?['earphonename'] as String?) ?? '', devices: (devicesData as List<dynamic>?)
earphonemac: (userData?['earphonemac'] as String?) ?? '', ?.map((e) => UserDevice.fromJson(e as Map<String, dynamic>))
earphonelicense: (userData?['earphonelicense'] as String?) ?? '', .toList() ??
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?) ?? '',
); );
} }
factory User.fronTokenJson(Map<String, dynamic> json) { factory User.fronTokenJson(Map<String, dynamic> json) {
// 修复1:安全获取嵌套的 user 字段 // 修复1:安全获取嵌套的 user 字段
final userData = json['user'] as Map<String, dynamic>?; // 允许空值 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._( return _instance = User._(
token: '', token: '',
@ -101,17 +84,12 @@ class User {
phone: (userData?['phone'] as String?) ?? '', phone: (userData?['phone'] as String?) ?? '',
phonemac: (userData?['phonemac'] as String?) ?? '', phonemac: (userData?['phonemac'] as String?) ?? '',
password: (userData?['password'] as String?) ?? '', password: (userData?['password'] as String?) ?? '',
viplv: (userData?['viplv'] as int?) ?? 0, // 提供默认值 viplv: (userData?['viplv'] as int?) ?? 0, // 提供默认值
earphonename: (userData?['earphonename'] as String?) ?? '', devices: devicesData != null
earphonemac: (userData?['earphonemac'] as String?) ?? '', ? (devicesData as List<dynamic>)
earphonelicense: (userData?['earphonelicense'] as String?) ?? '', .map((e) => UserDevice.fromJson(e as Map<String, dynamic>))
earphoneauthcode: (userData?['earphoneauthcode'] as String?) ?? '', .toList()
// lastbettime: _parseTimestamp(userData['lastbettime']), : [], // 处理设备列表
// createtime: _parseTimestamp(userData['createtime']),
// wxopenid: (userData?['wxopenid'] as String?) ?? '',
// googleopenid: (userData?['googleopenid'] as String?) ?? '',
// facebookopenid: (userData?['facebookopenid'] as String?) ?? '',
); );
} }
@ -121,7 +99,6 @@ class User {
'name': name, 'name': name,
'uid': uid, 'uid': uid,
'avatar': avatar, 'avatar': avatar,
'language': language, 'language': language,
'token': token, 'token': token,
'mail': mail, 'mail': mail,
@ -129,17 +106,52 @@ class User {
'phonemac': phonemac, 'phonemac': phonemac,
'password': password, 'password': password,
'viplv': viplv, 'viplv': viplv,
'devices': devices.map((device) => device.toJson()).toList(),
},
};
// 添加设备
addUserDevice(UserDevice device) {
devices.add(device);
}
// 查找设备
removeUserDevice(int id) {
devices.removeWhere((device) => device.id == id);
}
}
class UserDevice {
int id;
String uid;
int devicetype;
String devicename;
String devicemac;
String license;
UserDevice({
required this.id,
required this.uid,
required this.devicetype,
required this.devicename,
required this.devicemac,
required this.license,
});
'earphonename': earphonename, factory UserDevice.fromJson(Map<String, dynamic> json) => UserDevice(
'earphonemac': earphonemac, id: json['id'] as int,
"earphonelicense": earphonelicense, uid: json['uid'] as String,
'earphoneauthcode': earphoneauthcode, devicetype: (json['devicetype'] as num).toInt(),
devicename: json['devicename'] as String,
devicemac: json['devicemac'] as String,
license: json['license'] as String,
);
// 'lastbettime': lastbettime, toJson() => {
// 'createtime': createtime, 'id': id,
// 'wxopenid': wxopenid, 'uid': uid,
// 'googleopenid': googleopenid, 'devicetype': devicetype,
// 'facebookopenid': facebookopenid, '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 { try {
final result = _bleService.getDeviceInfo(address); final result = _bleService.getDeviceList(deviceName);
return result; return result;
} catch (e) { } catch (e) {
Logger.error('获取设备信息: ${e.toString()}'); Logger.error('获取设备信息: ${e.toString()}');
@ -865,4 +884,14 @@ class BleManager extends GetxService {
} }
return Future.value(Get.find<BleManager>()); 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( FlWX().register(
appSecret: '4cfde33bc22b6cc6b1f8b225dd97d255', appSecret: '4cfde33bc22b6cc6b1f8b225dd97d255',
appId: 'wx5bbb7be9324f3e2d', appId: 'wx5bbb7be9324f3e2d',
universalLink: 'https://yunqiinnovation.com/app/', universalLink: 'https://deepsoud.com/app/',
params: FlWXCallbackParams(onHttp: (url) async { params: FlWXCallbackParams(onHttp: (url) async {
return ''; return '';
})); }));

4
lib/data/services/network/api.dart

@ -137,10 +137,10 @@ class Api {
} }
//绑定设备 //绑定设备
static bindearphone([params]) { static binddevice([params]) {
return DioManager().request( return DioManager().request(
NWMethod.post, NWMethod.post,
'/api/home/user_bindearphone', '/api/home/user_binddevice',
params: params, params: params,
); );
} }

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

@ -81,11 +81,11 @@ class AzureAsrService extends GetxService implements AsrService {
try { try {
final List<String> languages = final List<String> languages =
supportedLanguages ?? _defaultSupportedLanguages; supportedLanguages ?? _defaultSupportedLanguages;
//底层会初始化前释放
// 检查是否需要重新初始化 // // 检查是否需要重新初始化
if (_isInitialized) { // if (_isInitialized) {
await dispose(); // await dispose();
} // }
// 设置音频源类型 // 设置音频源类型
_audioSourceType = useExternalAudio _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 '/core/utils/logger.dart';
import '/data/models/user_Info.dart'; import '/data/models/user_Info.dart';
import '/data/services/ble_manager.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 { class DevicesController extends GetxController {
late final BleManager _bleManager; late final BleManager _bleManager;
//耳机绑定 late final Map<int, List<UserDevice>> devices;
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;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
_bleManager = Get.find<BleManager>(); _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 @override
void onClose() { void onClose() {
super.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({ final response = await Api.unbindearphone({
"code": earphoneLicense.value, "id": device.id,
"earphonname": earphoneMac.value,
}); });
if (response != null) { if (response != null) {
User.instance.earphonename = ""; devices[device.devicetype]!.remove(device);
User.instance.earphonemac = ""; User.instance.removeUserDevice(device.id);
User.instance.earphonelicense = "";
earphonestate.value = 0;
_bleManager.clearPairedDevices(); _bleManager.clearPairedDevices();
_bleManager.disconnect(); _bleManager.disconnect();
Get.toNamed(Routes.pairing); Get.toNamed(Routes.pairing);

524
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/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.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> { class DevicesView extends GetView<DevicesController> {
const DevicesView({Key? key}) : super(key: key); const DevicesView({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 直接使用 Theme.of(context) 获取当前主题
final isDarkMode = Theme.of(context).brightness == Brightness.dark; final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold( return Scaffold(
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], backgroundColor: isDarkMode ? AppColors.darkBackground : Colors.white,
appBar: AppBar( body: Column(
automaticallyImplyLeading: false, children: [
centerTitle: true, // 顶部标题栏
elevation: 0, Container(
backgroundColor: isDarkMode ? Colors.black : Colors.white, padding: EdgeInsets.only(
leading: IconButton( top: MediaQuery.of(context).padding.top + 10.h,
icon: Icon( left: 20.w,
Icons.arrow_back_ios, right: 20.w,
size: 20.sp, bottom: 20.h,
color: isDarkMode ? Colors.white : Colors.black, ),
decoration: BoxDecoration(
color: isDarkMode ? Colors.black : Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
GestureDetector(
onTap: () => Get.back(),
child: Icon(
Icons.arrow_back_ios,
color: isDarkMode ? Colors.white : Colors.black,
size: 24.sp,
),
),
SizedBox(width: 16.w),
Text(
"deviceManagement".tr,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
),
],
),
), ),
onPressed: () { // 设备列表内容
Get.back(); // 正常返回 Expanded(
}, child: _buildDevicesList(isDarkMode),
),
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),
children: [
_buildEarphoneCard(isDarkMode),
SizedBox(height: 20.h),
_buildVoiceRecorderCard(isDarkMode),
SizedBox(height: 20.h),
], ],
), ),
); );
} }
// 耳机 Widget _buildDevicesList(bool isDarkMode) {
Widget _buildEarphoneCard(bool isDarkMode) { final devices = User.instance.devices;
return Column(
crossAxisAlignment: CrossAxisAlignment.start, 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: [ children: [
Padding( // 遍历每个设备类型
padding: EdgeInsets.only(left: 4.w, bottom: 8.h), ...groupedDevices.entries.map((entry) {
child: Text( final deviceType = entry.key;
'earphone'.tr, // 耳机 final deviceList = entry.value;
style: TextStyle(
fontSize: 12.sp, return Column(
fontWeight: FontWeight.w500, crossAxisAlignment: CrossAxisAlignment.start,
color: isDarkMode ? Colors.blue[400] : Colors.blue[600], children: [
letterSpacing: 1, // 设备类型标题
), Padding(
), padding: EdgeInsets.only(bottom: 12.h),
), child: Text(
Container( deviceType.displayName,
decoration: BoxDecoration( style: TextStyle(
color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, fontSize: 16.sp,
borderRadius: BorderRadius.circular(16.r), fontWeight: FontWeight.w600,
boxShadow: [ color: isDarkMode ? Colors.white : Colors.black87,
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( }).toList(),
borderRadius: BorderRadius.circular(16.r),
child: BackdropFilter(
filter: isDarkMode
? ImageFilter.blur(sigmaX: 5, sigmaY: 5)
: ImageFilter.blur(sigmaX: 0, sigmaY: 0),
child: Column(
children: [
Obx(
() =>
// 耳机列表
_buildSimpleNavigationSetting(
title: controller.earphonestate.value != 0
? controller.earphoneName.value
: "notBound".tr, // 未绑定
subtitle: controller.earphonestate.value != 0
? controller.earphoneMac.value
: "deviceNotBoundYet".tr, // 还未绑定设备
icon: Icons.headphones,
iconBgColor: isDarkMode
? const Color.fromARGB(255, 255, 255, 255)!
.withOpacity(0.3)
: const Color.fromARGB(255, 0, 0, 0)!,
iconColor: isDarkMode
? const Color.fromARGB(255, 0, 0, 0)!
: const Color.fromARGB(255, 255, 255, 255)!,
titleColor: isDarkMode ? Colors.white : null,
subtitleColor: isDarkMode ? Colors.white70 : null,
isDarkMode: isDarkMode,
showbutt: controller.earphonestate == 2,
onPressed: () {
controller.unbindearphone();
},
),
)
],
),
),
),
),
], ],
); );
} }
// 录音笔 Widget _buildEmptyState(bool isDarkMode) {
Widget _buildVoiceRecorderCard(bool isDarkMode) { return Center(
return Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Padding( Icon(
padding: EdgeInsets.only(left: 4.w, bottom: 8.h), Icons.devices_other,
child: Text( size: 80.sp,
'voicerecorder'.tr, //AI设置 color: isDarkMode ? Colors.white54 : Colors.grey[400],
),
SizedBox(height: 20.h),
Text(
"暂未绑定任何设备",
style: TextStyle( style: TextStyle(
fontSize: 12.sp, fontSize: 16.sp,
fontWeight: FontWeight.w500, color: isDarkMode ? Colors.white70 : Colors.grey[600],
color: isDarkMode ? Colors.blue[400] : Colors.blue[600],
letterSpacing: 1,
), ),
), ),
), SizedBox(height: 12.h),
Container( Text(
decoration: BoxDecoration( "请先绑定设备后再进行管理",
color: isDarkMode ? Colors.white.withOpacity(0.05) : Colors.white, style: TextStyle(
borderRadius: BorderRadius.circular(16.r), fontSize: 14.sp,
boxShadow: [ color: isDarkMode ? Colors.white54 : Colors.grey[500],
BoxShadow(
color: isDarkMode
? Colors.black.withOpacity(0.2)
: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16.r),
child: BackdropFilter(
filter: isDarkMode
? ImageFilter.blur(sigmaX: 5, sigmaY: 5)
: ImageFilter.blur(sigmaX: 0, sigmaY: 0),
child: Column(
children: [
// 实时对话入口
Obx(
() => _buildSimpleNavigationSetting(
title: 'notBound'.tr, // 未绑定
subtitle: 'deviceNotBoundYet'.tr, // 还未绑定设备
icon: Icons.edit,
iconBgColor: isDarkMode
? const Color.fromARGB(255, 255, 255, 255)!
.withOpacity(0.3)
: const Color.fromARGB(255, 0, 0, 0)!,
iconColor: isDarkMode
? const Color.fromARGB(255, 0, 0, 0)!
: const Color.fromARGB(255, 255, 255, 255)!,
titleColor: isDarkMode ? Colors.white : null,
subtitleColor: isDarkMode ? Colors.white70 : null,
isDarkMode: isDarkMode,
showbutt: controller.isvoicepen.value,
onPressed: () {},
),
)
],
),
), ),
), ),
), ],
], ),
); );
} }
// 简单导航设置项 Widget _buildDeviceCard({
Widget _buildSimpleNavigationSetting({ required UserDevice device,
required String title, required DeviceType deviceType,
String? subtitle,
required IconData icon,
required Color iconBgColor,
required Color iconColor,
Color? titleColor,
Color? subtitleColor,
required bool isDarkMode, required bool isDarkMode,
required bool showbutt,
required VoidCallback onPressed,
}) { }) {
return InkWell( return Container(
child: Padding( margin: EdgeInsets.only(bottom: 12.h),
padding: EdgeInsets.all(16.w), padding: EdgeInsets.all(16.w),
child: Row( decoration: BoxDecoration(
mainAxisAlignment: MainAxisAlignment.spaceBetween, color: isDarkMode ? AppColors.cardBackground : Colors.white,
children: [ borderRadius: BorderRadius.circular(12.r),
Expanded( boxShadow: [
child: Row( BoxShadow(
children: [ color: Colors.black.withOpacity(0.05),
Container( blurRadius: 8,
width: 32.w, offset: const Offset(0, 2),
height: 32.w, ),
decoration: BoxDecoration( ],
color: iconBgColor, ),
borderRadius: BorderRadius.circular(16.r), child: Row(
), children: [
child: Icon( // 设备图标
icon, Container(
size: 16.w, width: 48.w,
color: iconColor, height: 48.w,
), decoration: BoxDecoration(
color:
isDarkMode ? Colors.white.withOpacity(0.1) : Colors.grey[100],
borderRadius: BorderRadius.circular(12.r),
),
child: Icon(
deviceType.icon,
color: isDarkMode ? Colors.white : Colors.black87,
size: 24.sp,
),
),
SizedBox(width: 16.w),
// 设备信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
device.devicename.isNotEmpty ? device.devicename : "未知设备",
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
color: isDarkMode ? Colors.white : Colors.black87,
),
),
SizedBox(height: 4.h),
Text(
device.devicemac.isNotEmpty ? device.devicemac : "无MAC地址",
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.white70 : Colors.grey[600],
), ),
SizedBox(width: 12.w), ),
Flexible( if (device.license.isNotEmpty) ...[
child: Column( SizedBox(height: 4.h),
crossAxisAlignment: CrossAxisAlignment.start, Text(
children: [ "授权码: ${device.license}",
Text( style: TextStyle(
title, fontSize: 12.sp,
style: TextStyle( color: isDarkMode ? Colors.white54 : Colors.grey[500],
fontSize: 16.sp,
fontWeight: FontWeight.w500,
color: titleColor ??
(isDarkMode ? Colors.white : Colors.black87),
),
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) SizedBox(height: 2.h),
if (subtitle != null)
Text(
subtitle,
style: TextStyle(
fontSize: 12.sp,
color: subtitleColor ??
(isDarkMode
? Colors.white70
: Colors.black54),
),
overflow: TextOverflow.ellipsis,
),
],
), ),
), ),
], ],
],
),
),
// 解绑按钮
GestureDetector(
onTap: () => _showUnbindDialog(device, isDarkMode),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 6.h,
),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(6.r),
border: Border.all(
color: Colors.red.withOpacity(0.3),
width: 1,
),
),
child: Text(
"unbind".tr,
style: TextStyle(
fontSize: 12.sp,
color: Colors.red,
fontWeight: FontWeight.w500,
),
), ),
), ),
if (showbutt) ),
Container( ],
width: 74.w, ),
height: 40.w, );
child: ElevatedButton( }
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue, void _showUnbindDialog(UserDevice device, bool isDarkMode) {
shape: RoundedRectangleBorder( Get.dialog(
borderRadius: BorderRadius.circular(12), AlertDialog(
), backgroundColor: isDarkMode ? AppColors.cardBackground : Colors.white,
padding: EdgeInsets.symmetric(horizontal: 2, vertical: 2), title: Text(
), "确认解绑",
onPressed: onPressed, style: TextStyle(
child: Text( color: isDarkMode ? Colors.white : Colors.black87,
'unbind'.tr, // 解绑 ),
style: TextStyle( ),
color: Colors.white, content: Text(
), "确定要解绑设备 \"${device.devicename}\" 吗?",
), style: TextStyle(
)), color: isDarkMode ? Colors.white70 : Colors.black54,
], ),
), ),
actions: [
TextButton(
onPressed: () => Get.back(),
child: Text(
"取消",
style: TextStyle(
color: isDarkMode ? Colors.white70 : Colors.grey[600],
),
),
),
TextButton(
onPressed: () {
Get.back();
controller.unbindearphone(device);
},
child: const Text(
"确认解绑",
style: TextStyle(color: Colors.red),
),
),
],
), ),
); );
} }

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

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

74
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:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import 'package:htmltopdfwidgets/htmltopdfwidgets.dart' as pdf;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:webview_flutter/webview_flutter.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 '../../../../core/utils/synchrodata.dart';
import '../../controllers/meeting_details_controller.dart'; import '../../controllers/meeting_details_controller.dart';
import '../../controllers/mind_map_controller.dart'; import '../../controllers/mind_map_controller.dart';
import 'second_level_bottom_sheet.dart';
class ShareBottomSheet extends StatefulWidget { class ShareBottomSheet extends StatefulWidget {
const ShareBottomSheet({super.key}); const ShareBottomSheet({super.key});
@ -141,7 +143,7 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
Get.back(); Get.back();
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final file = File( final file = File(
'${tempDir.path}/${_controller.meetingData.value.title} speaker.txt'); '${tempDir.path}/${_controller.meetingData.value.title}_speaker.txt');
String allText = _getAllText(); String allText = _getAllText();
await file.writeAsString(allText); await file.writeAsString(allText);
_share(file.path, '分享会议转写'); _share(file.path, '分享会议转写');
@ -151,12 +153,46 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
onTap: _controller.meetingDetails.value.tasktype >= 3 onTap: _controller.meetingDetails.value.tasktype >= 3
? () async { ? () async {
Get.back(); Get.back();
final tempDir = await getTemporaryDirectory(); Get.bottomSheet(
final file = File( SecondLevelBottomSheet(
'${tempDir.path}/${_controller.meetingData.value.title} summary.txt'); '导出总结',
await file.writeAsString( '导出为',
_controller.meetingDetails.value.summary); (int index) async {
_share(file.path, '分享会议总结'); 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);
_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), : null),
_shareItem( _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( final result = await SharePlus.instance.share(ShareParams(
text: title, text: title,
files: [XFile(filepath)], files: [XFile(filepath)],
@ -301,4 +337,26 @@ class _ShareBottomSheetState extends State<ShareBottomSheet> {
await file.writeAsBytes(byteData.buffer.asUint8List()); await file.writeAsBytes(byteData.buffer.asUint8List());
return XFile(file.path); 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();
}
} }

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

@ -501,13 +501,14 @@ class PairingController extends GetxController {
//连接ble //连接ble
Future<void> connectble(BluetoothDevice device) async { Future<void> connectble(BluetoothDevice device) async {
Logger.i(_tag, '设备验证通过'); Logger.i(_tag, '设备验证通过');
_verifiedDeviceAddress = device.address; // _verifiedDeviceAddress = device.address;
// 检查是否已有BLE关联 // 检查是否已有BLE关联
final pairedMacAddress = await _bleManager.getPairedMacAddress(); final pairedMacAddress = await _bleManager.getPairedMacAddress();
Logger.i(_tag, '已关联的MAC地址: $pairedMacAddress'); Logger.i(_tag, '已关联的MAC地址: $pairedMacAddress');
if (pairedMacAddress != null && if (pairedMacAddress != null &&
pairedMacAddress.toUpperCase() == device.address.toUpperCase()) { pairedMacAddress.toUpperCase() ==
_verifiedDeviceAddress?.toUpperCase()) {
// 已有关联,检查BLE连接状态 // 已有关联,检查BLE连接状态
Logger.i(_tag, '设备已关联,检查BLE连接状态'); Logger.i(_tag, '设备已关联,检查BLE连接状态');
final currentBleState = await _bleManager.getConnectionState(); final currentBleState = await _bleManager.getConnectionState();
@ -602,69 +603,162 @@ class PairingController extends GetxController {
//自动绑定设备 0 未合作商 1 合作商 2,已绑定 //自动绑定设备 0 未合作商 1 合作商 2,已绑定
Future<int> authbindingDevice(BluetoothDevice device) async { Future<int> authbindingDevice(BluetoothDevice device) async {
if (User.instance.earphonelicense != "" && // if (User.instance.earphoneauthcode != "" &&
User.instance.earphonemac == device.address) { // User.instance.earphonemac == device.address) {
// 已经绑定 // // 已经绑定
return 10; // return 9;
} // }
if (User.instance.earphoneauthcode != "" &&
User.instance.earphonemac == device.address) {
// 已经绑定
return 9;
}
// 检查设备名称是否包含 DeepSound 或 DeapSound // 检查设备名称是否包含 DeepSound 或 DeapSound
final deviceName = device.name; final deviceName = device.name;
final names = AppConfig.allproductNames(); final names = AppConfig.allproductNames();
print('获取到 deviceName: $deviceName names:$names'); print(
'获取到 deviceName: $deviceName address: ${device.address} names:$names');
if (names.contains(deviceName)) { if (names.contains(deviceName)) {
await _bleManager.startScan(deviceName); await _bleManager.startScan(deviceName);
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
// 尝试最多读取 3 次设备信息,每次间隔 1 秒 // 尝试最多读取 3 次设备信息,每次间隔 1 秒
Map<String, String>? deviceinfo; List<Map<String, String>>? deviceinfos;
for (int i = 0; i < 3; i++) { for (int i = 0; i < 3; i++) {
deviceinfo = await _bleManager.getDeviceInfo(device.address); // 使用设备名称获取设备列表
print('获取到 deviceinfo: $deviceinfo'); deviceinfos = await _bleManager.getDeviceList(deviceName);
if (deviceinfo != null) { print('获取到 deviceinfos: $deviceinfos');
break;
if (deviceinfos != null && deviceinfos.isNotEmpty) {
// 循环读取每个设备的hex数据
for (var deviceInfo in deviceinfos) {
String? hexData = deviceInfo["hex"];
if (hexData != null && hexData.length >= 28) {
// 确保hex数据长度足够(14字节 = 28个字符)
// 读取9-14字节的MAC地址
String macFromHex = extractMacFromHex(hexData, device.address);
if (macFromHex.isNotEmpty) {
String code = "";
// 找到匹配的设备,提取授权码
if (Platform.isIOS) {
// iOS设备,提取13-22字节的授权码
code = extractAndReverseBytes(hexData, 13, 22);
print('找到匹配设备,获取到 code: $code');
if (code != "") {
_bleManager.stopScan();
deviceCode.value = code;
String? uuids = deviceInfo["id"];
_verifiedDeviceAddress = uuids;
print('找到匹配设备,获取到 uuids: $uuids');
// 检查是否已经绑定
// if (User.instance.earphonelicense != "" &&
// User.instance.earphonemac == device.address) {
// // 已经绑定
// return 10;
// } else {
return 1;
// }
}
} else if (Platform.isAndroid) {
// Android设备,提取15-24字节的授权码
code = extractAndReverseBytes(hexData, 15, 24);
print('找到匹配设备,获取到 code: $code');
if (code != "") {
_bleManager.stopScan();
deviceCode.value = code;
_verifiedDeviceAddress = device.address;
// 检查是否已经绑定
// if (User.instance.earphonelicense != "" &&
// User.instance.earphonemac == device.address) {
// // 已经绑定
// return 10;
// } else {
return 1;
// }
}
} else {
break;
}
}
}
}
} }
await Future.delayed(Duration(seconds: 1)); await Future.delayed(Duration(seconds: 1));
} }
print('获取到 deviceinfo: $deviceinfo');
if (deviceinfo != null) {
// final code = processHex(deviceinfo["hex"]!, 15, 25);
String code = extractAndReverseBytes(deviceinfo["hex"]!, 15, 24);
print('获取到 code: $code');
// 安全检查长度
if (code != "") {
deviceCode.value = code;
return 1;
} else {
return 0;
}
}
return 0; return 0;
// 调用BLE管理器的自动绑定方法
} else { } else {
return 0; 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 { void confirmBindDevice() async {
final response = await Api.bindearphone({ final response = await Api.binddevice({
"code": deviceCode.value, "code": deviceCode.value,
"earphonname": currentDevice?.name, "devicename": currentDevice?.name,
"earphonemac": currentDevice?.address, "devicemac": currentDevice?.address, // 使用 address 替代 deviceId
}); });
Logger.i(_tag, '手动授权结果 ${response}'); Logger.i(_tag, '手动授权结果 ${response}');
if (response != null) { if (response != null) {
Logger.i(_tag, '绑定设备成功'); Logger.i(_tag, '绑定设备成功 $response');
User.instance.earphonemac = currentDevice?.address ?? ''; if (response['device'] != '') {
User.instance.earphonename = currentDevice?.name ?? ''; // 根据API设计调整
User.instance.earphonelicense = deviceCode.value; final device = UserDevice.fromJson(response['device']);
_transition(PairingState.verifying); User.instance.addUserDevice(device);
_verifyDevice(_currentClassicDevice!); _transition(PairingState.verifying);
_verifyDevice(_currentClassicDevice!);
}
return; return;
} else { } else {
Logger.e(_tag, '绑定设备失败!'); Logger.e(_tag, '绑定设备失败!');
@ -696,13 +790,13 @@ class PairingController extends GetxController {
final response = await Api.bindauthcode({ final response = await Api.bindauthcode({
"code": codeInputController.text.trim(), "code": codeInputController.text.trim(),
"earphonname": currentDevice?.name, "earphonname": currentDevice?.name,
"earphonemac": currentDevice?.address, "earphonemac": currentDevice?.address, // 使用 address 替代 deviceId
}); });
Logger.i(_tag, '手动授权结果 ${response}'); Logger.i(_tag, '手动授权结果 ${response}');
if (response != null) { if (response != null) {
User.instance.earphonemac = currentDevice?.address ?? ''; // User.instance.earphonemac = currentDevice?.address ?? ''; // 使用 address
User.instance.earphonename = currentDevice?.name ?? ''; // User.instance.earphonename = currentDevice?.name ?? '';
User.instance.earphoneauthcode = codeInputController.text.trim(); // User.instance.earphoneauthcode = codeInputController.text.trim();
_transition(PairingState.verifying); _transition(PairingState.verifying);
_verifyDevice(_currentClassicDevice!); _verifyDevice(_currentClassicDevice!);
} else { } else {
@ -782,7 +876,16 @@ class PairingController extends GetxController {
void onSkipBindingTap() { void onSkipBindingTap() {
Logger.i(_tag, '用户选择临时跳过手动绑定'); 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); _transition(PairingState.verifying);
_verifyDevice(_currentClassicDevice!); _verifyDevice(_currentClassicDevice!);
} }

1
lib/modules/splash/controllers/splash_controller.dart

@ -122,6 +122,7 @@ class SplashController extends GetxController {
print('getLoginToken:进来了'); print('getLoginToken:进来了');
// 根据API设计调整 // 根据API设计调整
final user = User.fromJson(response); final user = User.fromJson(response);
// 存储用户信息(示例) // 存储用户信息(示例)
await _storage.write('user_info', user.toJson()); await _storage.write('user_info', user.toJson());
return true; return true;

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

@ -143,6 +143,7 @@ class TranslationController extends GetxController {
if (fTFTranslationResult != '' && !isPlayback) { if (fTFTranslationResult != '' && !isPlayback) {
isPlayback = true; isPlayback = true;
print("松开走了着");
await _ttsService.setAudioOutputDevice(lastActiveSpeaker.value); await _ttsService.setAudioOutputDevice(lastActiveSpeaker.value);
await Future.delayed(const Duration(milliseconds: 1300)); await Future.delayed(const Duration(milliseconds: 1300));
await playTranslatedText(fTFTranslationResult!); 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 { Rx<TranslationItem?> get currentItem {
@ -369,6 +372,7 @@ class TranslationController extends GetxController {
_translationDebounceTimer?.cancel(); _translationDebounceTimer?.cancel();
scrollController.dispose(); scrollController.dispose();
restoreOtherServices(); restoreOtherServices();
_asrService.dispose();
super.onClose(); super.onClose();
} }
@ -521,7 +525,6 @@ class TranslationController extends GetxController {
if (!isRecognizing.value) return; if (!isRecognizing.value) return;
try { try {
restoreOriginalAudioState();
await _asrService.stopContinuousRecognition(); await _asrService.stopContinuousRecognition();
// 停止ASR活跃时长计时 // 停止ASR活跃时长计时
@ -1340,7 +1343,8 @@ class TranslationController extends GetxController {
_stopAsrActiveTracking(); _stopAsrActiveTracking();
final now = DateTime.now(); final now = DateTime.now();
final totalDurationSeconds = now.difference(_asrSessionStartTime!).inSeconds; final totalDurationSeconds =
now.difference(_asrSessionStartTime!).inSeconds;
final effectiveDurationSeconds = _asrActiveDuration.inSeconds; final effectiveDurationSeconds = _asrActiveDuration.inSeconds;
_usageService.recordAsrApiCall( _usageService.recordAsrApiCall(

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

@ -461,8 +461,6 @@ class TranslationView extends GetView<TranslationController> {
child: Obx(() { child: Obx(() {
// 同时模式显示双按钮 // 同时模式显示双按钮
if (controller.currentMode.value == 'faceToFace') { if (controller.currentMode.value == 'faceToFace') {
controller.startRecognition();
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@ -593,11 +591,6 @@ class TranslationView extends GetView<TranslationController> {
// 设置300ms延迟防止误触 // 设置300ms延迟防止误触
_holdTimer = Timer(const Duration(milliseconds: 200), () { _holdTimer = Timer(const Duration(milliseconds: 200), () {
if (activeSpeaker == 0) { if (activeSpeaker == 0) {
if (speakerId == 1) {
controller.restoreOriginalAudioState();
} else {
controller.disableBluetoothAudio();
}
controller.setActiveSpeaker(speakerId); 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/bindings/devices_binding.dart';
import '../../modules/devices/views/devices_view.dart'; import '../../modules/devices/views/devices_view.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@ -266,5 +269,10 @@ abstract class AppPages {
page: () => const DevicesView(), page: () => const DevicesView(),
binding: DevicesBinding(), 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'; 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( let package = Package(
name: "agent_service", name: "agent_service",
platforms: [.iOS("18.0")], platforms: [.iOS("16.0")],
products: [ products: [
.library(name: "agent-service", targets: ["agent_service"]) .library(name: "agent-service", targets: ["agent_service"])
], ],

480
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 apiKey: String = ""
private var baseUrl: String = "https://api.openai.com/v1/chat/completions" private var baseUrl: String = "https://api.openai.com/v1/chat/completions"
private var model: String = "gpt-3.5-turbo" private var model: String = "gpt-3.5-turbo"
private var visionModel = "gpt-4-vision-preview"
private var systemPrompt: String = "" private var systemPrompt: String = ""
private var chatHistory: [[String: Any]] = [] private var chatHistory: [[String: Any]] = []
@ -55,6 +56,8 @@ class AgentServiceImpl: NSObject {
private var hasSpeechDetected: Bool = false private var hasSpeechDetected: Bool = false
internal var isAiStreaming: Bool = false internal var isAiStreaming: Bool = false
var currsessionId: String = ""
private var idleTimer: DispatchSourceTimer? private var idleTimer: DispatchSourceTimer?
private let maxIdleSeconds: TimeInterval = 10 private let maxIdleSeconds: TimeInterval = 10
@ -209,6 +212,12 @@ class AgentServiceImpl: NSObject {
sendError("初始化语音识别服务失败", code: "ASR_INIT_ERROR") sendError("初始化语音识别服务失败", code: "ASR_INIT_ERROR")
return false return false
} }
guard let asrsetupSuccess = azureAsrHelper?.setupEventListeners(
callback: self
), asrsetupSuccess else {
sendError("初始化语音识别服务失败", code: "ASR_INIT_ERROR")
return false
}
guard let ttsSuccess = azureTtsHelper?.initialize( guard let ttsSuccess = azureTtsHelper?.initialize(
ttsAppId: "", ttsAppId: "",
@ -242,6 +251,7 @@ class AgentServiceImpl: NSObject {
apiKey: apiKey, apiKey: apiKey,
baseUrl: baseUrl, baseUrl: baseUrl,
model: model, model: model,
visionModel:visionModel,
mcpServer: mcpServer mcpServer: mcpServer
) )
@ -293,8 +303,7 @@ class AgentServiceImpl: NSObject {
startIdleCheck() startIdleCheck()
} }
} }
func startRecognition(useBle: Bool = false) -> Bool {
func startRecognition(useBle: Bool = false) -> Bool {
if !isInitialized { if !isInitialized {
sendError("服务未初始化", code: "NOT_INITIALIZED") sendError("服务未初始化", code: "NOT_INITIALIZED")
return false return false
@ -318,9 +327,9 @@ class AgentServiceImpl: NSObject {
} else { } else {
AudioSessionHub.shared.begin(.voice) AudioSessionHub.shared.begin(.voice)
} }
print("ai启动语音=\(audioSourceType)=\(useBle)")
guard let success = azureAsrHelper?.startContinuousRecognition( guard let success = azureAsrHelper?.startContinuousRecognition(
callback: self, // callback: self,
audioSourceType: audioSourceType audioSourceType: audioSourceType
), success else { ), success else {
sendError("启动语音识别失败", code: "RECOGNITION_START_ERROR") sendError("启动语音识别失败", code: "RECOGNITION_START_ERROR")
@ -329,6 +338,10 @@ class AgentServiceImpl: NSObject {
return true return true
} }
func recognizeCallback(useBle: Bool = false) -> Bool {
return true
}
func stopRecognition() -> Bool { func stopRecognition() -> Bool {
if !isRecognizing { if !isRecognizing {
@ -346,8 +359,12 @@ class AgentServiceImpl: NSObject {
if !isRecognizing || !isInitialized { if !isRecognizing || !isInitialized {
return false 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 return true
} }
@ -472,10 +489,10 @@ class AgentServiceImpl: NSObject {
displayText: displayText, displayText: displayText,
hasImage: hasImage hasImage: hasImage
) )
currsessionId = UUID().uuidString
os_log("设置回调并调用sendMessageStream", log: logger, type: .info) os_log("设置回调并调用sendMessageStream", log: logger, type: .info)
chatApiService.setStreamCallback(callback) chatApiService.setStreamCallback(currsessionId,callback)
chatApiService.sendMessageStream(messages: messages) chatApiService.sendMessageStream(currsessionId,messages: messages)
os_log("sendMessageStream调用完成", log: logger, type: .info) os_log("sendMessageStream调用完成", log: logger, type: .info)
} }
@ -582,27 +599,28 @@ class AgentServiceImpl: NSObject {
/** /**
* 保存聊天消息 * 保存聊天消息
*/ */
internal func saveChatMessage(userMessage: String, assistantMessage: String, metadata: String = "") { internal func saveChatMessage(userMessage: String, assistantMessage: String, metadata: String = "",userMetadata: String) {
DispatchQueue.global(qos: .utility).async { DispatchQueue.global(qos: .utility).async {
// 保存用户消息 // 保存用户消息
let userMessageId = self.chatStorageHelper.saveMessage( let userMessageId = self.chatStorageHelper.saveMessage(
sessionId: self.sessionId, sessionId: self.sessionId,
message: userMessage, message: userMessage,
sender: "user", sender: "user",
metadata: nil as String? metadata: userMetadata
) )
if userMessageId != -1 { if userMessageId != -1 {
// 保存AI回复 if !assistantMessage.isEmpty || !metadata.isEmpty {
let assistantMessageId = self.chatStorageHelper.saveMessage( // 保存AI回复
sessionId: self.sessionId, let assistantMessageId = self.chatStorageHelper.saveMessage(
message: assistantMessage, sessionId: self.sessionId,
sender: "assistant", message: assistantMessage,
metadata: metadata.isEmpty ? nil : metadata sender: "assistant",
) metadata: metadata.isEmpty ? nil : metadata
)
if assistantMessageId == -1 { if assistantMessageId == -1 {
os_log("保存助手消息失败", log: self.logger, type: .error) os_log("保存助手消息失败", log: self.logger, type: .error)
}
} }
} else { } else {
os_log("保存用户消息失败", log: self.logger, type: .error) os_log("保存用户消息失败", log: self.logger, type: .error)
@ -610,95 +628,182 @@ class AgentServiceImpl: NSObject {
} }
} }
//解析卡片数据 // 解析卡片数据
@discardableResult @discardableResult
internal func autoHandleFunctionCallResult(toolName: String, functionCallResult: [String: Any]) -> (String, Bool) { internal func autoHandleFunctionCallResult(toolName: String, functionCallResult: [String: Any]) -> [String:Any] {
if let contentArray = functionCallResult["content"] as? [[String: Any]], // 1. 从functionCallResult获取context
let firstContent = contentArray.first, guard let contextValue = functionCallResult["context"] else {
let textStr = firstContent["text"] as? String, return [
let jsonData = textStr.data(using: .utf8) { "meta": "",
do { "broadcast": true,
if let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] { "notreply": false,
print("解析成功:\(jsonObject)") ]
var metadata: [String: Any] = [:] }
metadata[toolName] = jsonObject
// 将 metadata 转为 JSON 字符串 var contextStr = ""
let metaStr: String
if let jsonData = try? JSONSerialization.data(withJSONObject: metadata, options: [.prettyPrinted]), // 处理不同类型的context值
let jsonString = String(data: jsonData, encoding: .utf8) { if let strValue = contextValue as? String {
metaStr = jsonString contextStr = strValue
} else { } else if let data = try? JSONSerialization.data(withJSONObject: contextValue, options: []),
metaStr = "" let str = String(data: data, encoding: .utf8) {
} contextStr = str
// 处理 card_music }
if let cardMusic = jsonObject["card_music"] as? [String: Any] {
let id = cardMusic["id"] as? String ?? "" // 尝试解析JSON
let url = cardMusic["url"] as? String ?? "" if let jsonData = contextStr.data(using: .utf8),
let title = cardMusic["name"] as? String ?? "" let jsonObject = try? JSONSerialization.jsonObject(with: jsonData, options: .allowFragments) {
let artist = cardMusic["sgener"] as? String ?? ""
let coverUrl = cardMusic["image"] as? String ?? "" // 处理字典类型的JSON
if let jsonDict = jsonObject as? [String: Any] {
processMusicPlay([ return handleJSONDictionary(jsonDict, toolName: toolName)
"id": id, }
"url": url, // 处理数组类型的JSON
"title": title, else if let jsonArray = jsonObject as? [[String: Any]] {
"artist": artist, return handleJSONDictionary(["array": jsonArray], toolName: toolName)
"coverUrl": coverUrl }
]) // 处理基础类型
return (metaStr, false) else {
// 处理 card_musiclist return handlePrimitiveType(jsonObject, toolName: toolName)
} else if let cardMusiclist = jsonObject["card_musiclist"] as? [String: Any],
let musics = cardMusiclist["musics"] as? [[String: Any]] {
var playlist: [[String: String]] = []
for item in musics {
let id = item["id"] as? String ?? ""
let url = item["url"] as? String ?? ""
let title = item["name"] as? String ?? ""
let artist = item["sgener"] as? String ?? ""
let coverUrl = item["image"] as? String ?? ""
let song: [String: String] = [
"id": id,
"url": url,
"title": title,
"artist": artist,
"coverUrl": coverUrl
]
playlist.append(song)
}
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)
} }
// 纯文本内容
return handlePlainText(contextStr, toolName: toolName)
}
// 处理字典类型的JSON
private func handleJSONDictionary(_ jsonDict: [String: Any], toolName: String) -> [String: Any] {
var broadcast = true
var notreply = false
var metadata: [String: Any] = [:]
metadata[toolName] = jsonDict
if let _broadcast = jsonDict["broadcast"] as? Bool {
broadcast = _broadcast
}
if let _notreply = jsonDict["notreply"] as? Bool {
notreply = _notreply
}
metadata["notreply"] = notreply
// 处理卡片数据
if let cardMusic = jsonDict["card_music"] as? [String: Any] {
processMusicCard(cardMusic)
} else if let cardMusiclist = jsonDict["card_musiclist"] as? [String: Any],
let musics = cardMusiclist["musics"] as? [[String: Any]] {
processMusicListCard(musics)
} else if let cardNavigation = jsonDict["card_navigation"] as? [String: Any] {
processNavigationCard(cardNavigation)
}
let metaStr: String
if let jsonData = try? JSONSerialization.data(withJSONObject: metadata, options: []),
let jsonString = String(data: jsonData, encoding: .utf8) {
metaStr = jsonString
} else {
metaStr = ""
}
return [
"meta": metaStr,
"broadcast": broadcast,
"notreply": notreply
]
}
// 处理基本类型(字符串、数字等)
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,
"ts": ts
])
}
// 处理音乐列表卡片
private func processMusicListCard(_ musics: [[String: Any]]) {
var playlist: [[String: Any]] = []
for item in musics {
let id = item["id"] as? Int ?? 0
let url = item["url"] as? String ?? ""
let title = item["name"] as? String ?? ""
let artist = item["sgener"] as? String ?? ""
let coverUrl = item["image"] as? String ?? ""
let ts = item["ts"] as? Int ?? 0
playlist.append([
"id": id,
"url": url,
"title": title,
"artist": artist,
"coverUrl": coverUrl,
"ts": ts
])
}
if !playlist.isEmpty {
processMusicPlayList(playlist)
}
}
// 处理导航卡片
private func processNavigationCard(_ cardNavigation: [String: Any]) {
let start = cardNavigation["start"] as? String ?? ""
let end = cardNavigation["end"] as? String ?? ""
// 实际处理导航逻辑
print("启动导航: 从 \(start) 到 \(end)")
}
// 后台播放单曲 // 后台播放单曲
private func processMusicPlay(_ song: [String: String]) { private func processMusicPlay(_ song: [String: Any]) {
sendEvent(name: "music_play", data: song) sendEvent(name: "music_play", data: song)
MusicService.shared.play(song: song) MusicService.shared.play(song: song)
} }
// 后台播放音乐列表 // 后台播放音乐列表
private func processMusicPlayList(_ songs: [[String: String]]) { private func processMusicPlayList(_ songs: [[String: Any]]) {
// sendEvent(name: "music_playlist", data: songs) // sendEvent(name: "music_playlist", data: songs)
MusicService.shared.playPlaylist(songs: songs) MusicService.shared.playPlaylist(songs: songs)
} }
@ -791,6 +896,7 @@ class AgentServiceImpl: NSObject {
} }
func dispose() -> Bool { func dispose() -> Bool {
print("ai释放资源")
if isRecognizing { if isRecognizing {
stopRecognition() stopRecognition()
} }
@ -832,7 +938,9 @@ class ChatApiStreamCallback: StreamCallback {
private let hasImage: Bool private let hasImage: Bool
private var responseBuilder = "" private var responseBuilder = ""
private var metadata = "" 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) { init(agentService: AgentServiceImpl, speakResponse: Bool, displayText: String, hasImage: Bool) {
self.agentService = agentService 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 }
responseBuilder += token if (sessionId != agentService.currsessionId) {
return
}
agentService.sendEvent(name: "assistant_token", data: ["token": token]) responseBuilder += token
if speakResponse && nobroadcast{ if speakResponse && reply && broadcast{
agentService.azureTtsHelper?.speakStream(token) 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() { func onComplete(_ sessionId:String) {
guard let agentService = agentService else { return } do {
guard let agentService = try agentService else { return }
if speakResponse && nobroadcast{ if speakResponse && reply && broadcast && sessionId == agentService.currsessionId{
agentService.azureTtsHelper?.flushStream() agentService.azureTtsHelper?.flushStream()
} }
let response = responseBuilder let response = responseBuilder
if !response.isEmpty {
var responseData: [String: Any] = [ var responseData: [String: Any] = [
"text": response, "text": response,
"userInput": displayText "userInput": displayText
@ -874,77 +990,109 @@ class ChatApiStreamCallback: StreamCallback {
if hasImage { if hasImage {
responseData["hasImage"] = true responseData["hasImage"] = true
} }
agentService.sendEvent(name: "assistant_response", data: responseData) if (reply) {
try agentService.sendEvent(name: "assistant_response", data: responseData)
}
let assistantMessage: [String: Any] = [ let assistantMessage: [String: Any] = [
"role": "assistant", "role": "assistant",
"content": response "content": response
] ]
agentService.addToHistoryMessages(assistantMessage) if !response.isEmpty {
try agentService.addToHistoryMessages(assistantMessage)
}
// 保存聊天记录 // 保存聊天记录
agentService.saveChatMessage(userMessage: displayText, assistantMessage: response, metadata: metadata) try agentService.saveChatMessage(userMessage: displayText, assistantMessage: response, metadata: metadata,userMetadata:toJSONString(userMetadata))
agentService.isAiStreaming = false
os_log("保存聊天记录完成", log: agentService.logger, type: .info) os_log("设置AI流式状态为false", log: agentService.logger, type: .info)
} else { }catch{
os_log("警告: 响应内容为空", log: agentService.logger, type: .error) print("liwei--------------- AI Call onComplete 异常: \(error)")
} }
agentService.isAiStreaming = false
os_log("设置AI流式状态为false", log: agentService.logger, type: .info)
} }
func onError(_ error: Error) { func onError(_ sessionId:String,_ error: Error) {
guard let agentService = agentService else { return } do {
guard let agentService = try agentService else { return }
os_log("ChatAPI错误: %{public}@", log: agentService.logger, type: .error, error.localizedDescription) 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.sendEvent(name: "error", data: [ agentService.isAiStreaming = false
"code": "AI_ERROR", os_log("因错误设置AI流式状态为false", log: agentService.logger, type: .info)
"message": error.localizedDescription }catch{
]) print("liwei--------------- AI Call onError 异常: \(error)")
}
agentService.isAiStreaming = false
os_log("因错误设置AI流式状态为false", log: agentService.logger, type: .info)
} }
func onFunctionCall(_ functionCall: [String: Any]) { func onFunctionCall(_ sessionId:String,_ functionCall: [String: Any]) {
guard let agentService = agentService else { return } do {
guard let agentService = try agentService else { return }
let name = functionCall["name"] as? String ?? "" if (sessionId != agentService.currsessionId) {
os_log("收到函数调用: %{public}@", log: agentService.logger, type: .info, name) return
}
agentService.audioPlayer?.playCallingSound() let name = functionCall["name"] as? String ?? ""
os_log("收到函数调用: %{public}@", log: agentService.logger, type: .info, name)
agentService.sendEvent(name: "function_call", data: [ print("onFunctionCall=functionCall\(functionCall)")
"name": name, // try agentService.audioPlayer?.playCallingSound()
"arguments": functionCall
]) try agentService.sendEvent(name: "function_call", data: [
"name": name,
"arguments": functionCall
])
if name == "exit_interaction" {
os_log("执行退出交互", log: agentService.logger, type: .info)
agentService.stopRecognition()
}
if name == "exit_interaction" { }catch{
os_log("执行退出交互", log: agentService.logger, type: .info) print("liwei--------------- AI Call onFunctionCall 异常: \(error)")
agentService.stopRecognition()
} }
} }
func onFunctionCallResult(_ functionCall: [String: Any], _ functionCallResult: [String: Any]) { func onFunctionCallResult(_ sessionId:String,_ functionCall: [String: Any], _ functionCallResult: [String: Any]) {
guard let agentService = agentService else { return } do {
guard let agentService = agentService else { return }
let name = functionCall["name"] as? String ?? ""
os_log("收到函数调用结果: %{public}@", log: agentService.logger, type: .info, functionCallResult)
// agentService.audioPlayer?.stopCallingSound()
let resultmap = agentService.autoHandleFunctionCallResult(toolName:name,functionCallResult:functionCallResult)
metadata = resultmap["meta"] as? String ?? ""
broadcast = resultmap["broadcast"] as? Bool ?? true
reply = !(resultmap["notreply"] as? Bool ?? true)
os_log("卡片数据: %{public}@", log: agentService.logger, type: .info, metadata)
agentService.sendEvent(name: "function_call_result", data: [
"function_call": functionCall,
"result": functionCallResult,
"meta":metadata,
])
os_log("处理函数调用结果完成", log: agentService.logger, type: .info)
}catch{
print("liwei--------------- AI Call onFunctionCallResult 异常: \(error)")
}
let name = functionCall["name"] as? String ?? "" }
os_log("收到函数调用结果: %{public}@", log: agentService.logger, type: .info, functionCallResult)
agentService.audioPlayer?.stopCallingSound() func toJSONString(_ dict: [String: Any]) -> String {
let (metestr, broadcast) = agentService.autoHandleFunctionCallResult(toolName:name,functionCallResult:functionCallResult) guard JSONSerialization.isValidJSONObject(dict),
metadata = metestr; let data = try? JSONSerialization.data(withJSONObject: dict),
nobroadcast = broadcast let json = String(data: data, encoding: .utf8) else {
os_log("卡片数据: %{public}@", log: agentService.logger, type: .info, metadata) return ""
agentService.sendEvent(name: "function_call_result", data: [ }
"function_call": functionCall, return json
"result": functionCallResult,
"meta":metadata,
])
os_log("处理函数调用结果完成", log: agentService.logger, type: .info)
} }
} }
extension AgentServiceImpl: TtsEventListener { 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() impl.startRecognition()
result(true) result(true)
case "recognizeCallback":
impl.recognizeCallback()
result(true)
case "stopConversation": case "stopConversation":
impl.stopRecognition() impl.stopRecognition()
result(true) result(true)

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

@ -2,46 +2,103 @@ import Flutter
import UIKit import UIKit
import AMapNaviKit import AMapNaviKit
public class AmapServicePlugin: NSObject, FlutterPlugin { public class AmapServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
private static var channel: FlutterMethodChannel? private static var channel: FlutterMethodChannel?
public static func register(with registrar: FlutterPluginRegistrar) { private var eventSink: FlutterEventSink?
let channel = FlutterMethodChannel(name: "com.yunqiinnovation.amap_service", binaryMessenger: registrar.messenger())
let instance = AmapServicePlugin() public static func register(with registrar: FlutterPluginRegistrar) {
// 必须先设置隐私合规 let methodChannel = FlutterMethodChannel(
DispatchQueue.main.async { name: "com.yunqiinnovation.amap_service",
// 2. API Key binaryMessenger: registrar.messenger()
AMapServices.shared()?.apiKey = "aa60a03be9f6531bcc64029e60f95d03" )
AMapServices.shared().enableHTTPS = true
// 导航SDK隐私合规(如果使用导航功能) let eventChannel = FlutterEventChannel(
AMapNaviManagerConfig.shared().updatePrivacyShow(.didShow, privacyInfo: .didContain) name: "com.yunqiinnovation.amap_service/events",
AMapNaviManagerConfig.shared().updatePrivacyAgree(.didAgree) binaryMessenger: registrar.messenger()
// 3. 打印验证(调试用) )
print("高德SDK初始化完成")
let instance = AmapServicePlugin()
eventChannel.setStreamHandler(instance)
// 高德SDK初始化
DispatchQueue.main.async {
AMapServices.shared()?.apiKey = "aa60a03be9f6531bcc64029e60f95d03"
AMapServices.shared()?.enableHTTPS = true
if #available(iOS 13.0, *) {
AMapNaviManagerConfig.shared().updatePrivacyShow(.didShow, privacyInfo: .didContain)
AMapNaviManagerConfig.shared().updatePrivacyAgree(.didAgree)
}
print("高德SDK初始化完成")
}
registrar.addMethodCallDelegate(instance, channel: methodChannel)
self.channel = methodChannel
}
// 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
} }
registrar.addMethodCallDelegate(instance, channel: channel)
self.channel = channel // MARK: - 事件发送方法
} private func sendEvent(eventName: String, data: [String: Any]) {
guard let sink = eventSink else { return }
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { let eventData: [String: Any] = [
switch call.method { "event": eventName,
case "open": "data": data
print("liwei------------------ 打开导航页面") ]
if let args = call.arguments as? [String: Any], sink(eventData)
let startpos = args["startpos"] as? String, }
let endpos = args["endpos"] as? String,
let ntype = args["ntype"] as? String{ // MARK: - 方法处理
print("liwei------------------ 打开导航页面1 \(startpos) \(endpos)") public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
DispatchQueue.main.async { switch call.method {
let navVC = AmapViewController(startpos: startpos, endpos: endpos) case "open":
guard let args = call.arguments as? [String: Any],
let startpos = args["startpos"] as? String,
let endpos = args["endpos"] as? String,
let ntype = args["ntype"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "Missing arguments", details: nil))
return
}
print("打开导航页面: \(startpos) → \(endpos)")
DispatchQueue.main.async {
let navVC = AmapViewController(startpos: startpos, endpos: endpos)
navVC.modalPresentationStyle = .fullScreen navVC.modalPresentationStyle = .fullScreen
UIApplication.shared.keyWindow?.rootViewController?.present(navVC, animated: true) UIApplication.shared.keyWindow?.rootViewController?.present(navVC, animated: true)
} }
result(nil) result(nil)
} else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "Missing 'open' map", details: nil)) case "start":
guard let args = call.arguments as? [String: Any],
let startpos = args["startpos"] as? String,
let endpos = args["endpos"] as? String,
let ntype = args["ntype"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "Missing arguments", details: nil))
return
}
// 这里添加实际导航启动逻辑
print("开始导航: \(startpos) → \(endpos), 类型: \(ntype)")
sendEvent(eventName: "navigation_started", data: ["status": "success"])
result(nil)
case "stop":
// 这里添加停止导航逻辑
print("停止导航")
sendEvent(eventName: "navigation_stopped", data: ["status": "success"])
result(nil)
default:
result(FlutterMethodNotImplemented)
} }
default:
result(FlutterMethodNotImplemented)
} }
}
} }

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() { private fun startWriteThread() {
writeThread = Thread { writeThread = Thread {
try { try {
@ -745,6 +747,9 @@ class AzureAsrHelper(private val context: Context) {
} }
/**
* 开始音频输入
*/
fun startAudioRecord() { fun startAudioRecord() {
isWriting.set(true) 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( let package = Package(
name: "azure_speech", name: "azure_speech",
platforms: [.iOS("18.0")], platforms: [.iOS("16.0")],
products: [ products: [
.library(name: "azure-speech", targets: ["azure_speech"]) .library(name: "azure-speech", targets: ["azure_speech"])
], ],

586
local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift

@ -5,7 +5,7 @@ import MicrosoftCognitiveServicesSpeech
import speech import speech
import os.log import os.log
// MARK: - 基于微软Azure语音服务的ASR实现
/** /**
* Azure ASR Helper * Azure ASR Helper
* *
@ -44,8 +44,12 @@ public class AzureAsrHelper: NSObject {
private var audioSourceType = AudioSourceType.microphone private var audioSourceType = AudioSourceType.microphone
// 音频处理 // 音频处理
private var externalAudioStream: ExternalAudioPullStream? // private var externalAudioStream: ExternalAudioPullStream?
// 音频处理
public var audioStream: AudioStream?
//录音文件
public var recordfile: RecordFile?
/** /**
* 初始化Azure语音服务 * 初始化Azure语音服务
* *
@ -70,7 +74,6 @@ public class AzureAsrHelper: NSObject {
// 释放之前的资源 // 释放之前的资源
dispose() dispose()
// 保存配置 // 保存配置
self.subscriptionKey = subscriptionKey self.subscriptionKey = subscriptionKey
self.region = region self.region = region
@ -104,29 +107,16 @@ public class AzureAsrHelper: NSObject {
// 设置指定的识别语言 // 设置指定的识别语言
speechConfig?.speechRecognitionLanguage = currentLanguage speechConfig?.speechRecognitionLanguage = currentLanguage
} }
// 录音文件类
recordfile = RecordFile()
// 创建识别器 // 创建识别器
return setupRecognizer() return true
} catch { } catch {
os_log("初始化失败: %{public}@", log: log, type: .error, error.localizedDescription) os_log("初始化失败: %{public}@", log: log, type: .error, error.localizedDescription)
return false 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 是否成功开始识别 * @return 是否成功开始识别
*/ */
public func startContinuousRecognition( public func startContinuousRecognition(
callback: ContinuousRecognizeCallback,
audioSourceType: AudioSourceType = .microphone audioSourceType: AudioSourceType = .microphone
) -> Bool { ) -> Bool {
guard speechConfig != nil else { guard speechConfig != nil else {
callback.onError("语音服务未初始化") //callback.onError("语音服务未初始化")
return false return false
} }
@ -150,18 +139,17 @@ public class AzureAsrHelper: NSObject {
self.audioSourceType = audioSourceType self.audioSourceType = audioSourceType
// 重置识别器
if !setupRecognizer() {
callback.onError("重置识别器失败")
return false
}
do { do {
// 设置各种事件监听 guard recognizer != nil else {
setupEventListeners(callback: callback) if !setupRecognizer() {
// callback.onError("重置识别器失败")
return false
}
return false
}
// 启动音频处理 // 启动音频处理
startAudioProcessing() audioStream?.startAudioInput(audioSourceType: audioSourceType)
// 开始连续识别 // 开始连续识别
try recognizer?.startContinuousRecognition() try recognizer?.startContinuousRecognition()
@ -171,9 +159,9 @@ public class AzureAsrHelper: NSObject {
return true return true
} catch { } catch {
stopAudioProcessing() audioStream?.stopAudioCapture()
_isContinuousRecognitionActive = false _isContinuousRecognitionActive = false
callback.onError("启动连续识别失败: \(error.localizedDescription)") //callback.onError("启动连续识别失败: \(error.localizedDescription)")
return false return false
} }
} }
@ -188,40 +176,35 @@ public class AzureAsrHelper: NSObject {
os_log("语音服务未初始化", log: log, type: .error) os_log("语音服务未初始化", log: log, type: .error)
return false return false
} }
guard let recognizer = recognizer else {
os_log("识别器为空,重置状态", log: log, type: .info)
return true
}
if !_isContinuousRecognitionActive { if !_isContinuousRecognitionActive {
return true return true
} }
do { do {
guard let recognizer = recognizer else { _isContinuousRecognitionActive = false
os_log("识别器为空,重置状态", log: log, type: .info)
_isContinuousRecognitionActive = false
return true
}
if audioSourceType == .external { if audioSourceType == .external {
pushAudioData(data: Data()) //pushAudioData(data: Data())
} }
audioStream?.stopAudioCapture()
// 停止连续识别 // 停止连续识别
try recognizer.stopContinuousRecognition() try recognizer.stopContinuousRecognition()
// 停止音频处理
stopAudioProcessing()
// 会话结束事件会设置_isContinuousRecognitionActive = false // 会话结束事件会设置_isContinuousRecognitionActive = false
return true return true
} catch { } catch {
// 强制重置状态 // 强制重置状态
_isContinuousRecognitionActive = false _isContinuousRecognitionActive = false
os_log("停止连续识别失败: %{public}@", log: log, type: .error, error.localizedDescription) os_log("强制停止识别失败", log: log, type: .error)
// 停止音频处理 // 停止音频处理
stopAudioProcessing() audioStream?.stopAudioCapture()
// 尝试强制关闭识别器
recognizer = nil
return false return false
} }
@ -239,6 +222,7 @@ public class AzureAsrHelper: NSObject {
*/ */
public func dispose() { public func dispose() {
do { do {
print("释放所有资源:")
// 如果正在进行连续识别,先停止 // 如果正在进行连续识别,先停止
if _isContinuousRecognitionActive { if _isContinuousRecognitionActive {
// 直接停止,不等待结果 // 直接停止,不等待结果
@ -247,46 +231,45 @@ public class AzureAsrHelper: NSObject {
} }
// 停止音频处理 // 停止音频处理
stopAudioProcessing() audioStream?.releaseAudioResources()
// 停止录音
recordfile?.closeFile(isSave: true)
// 释放资源 // 释放资源
recognizer = nil recognizer = nil
speechConfig = nil speechConfig = nil
audioConfig = nil audioConfig = nil
audioStream = nil
// 确保状态被重置 // 确保状态被重置
_isContinuousRecognitionActive = false _isContinuousRecognitionActive = false
externalAudioStream = nil //externalAudioStream = nil
} catch { } catch {
// 确保状态被重置 // 确保状态被重置
_isContinuousRecognitionActive = false _isContinuousRecognitionActive = false
externalAudioStream = nil //externalAudioStream = nil
audioConfig = nil audioConfig = nil
recognizer = nil recognizer = nil
speechConfig = nil speechConfig = nil
audioStream = nil
} }
} }
// MARK: - 私有方法
/** /**
* 设置识别器 * 设置识别器
*/ */
private func setupRecognizer() -> Bool { private func setupRecognizer() -> Bool {
do { do {
print("设置识别器${_isContinuousRecognitionActive}")
// 如果正在进行连续识别,先停止
if _isContinuousRecognitionActive {
// 直接停止,不等待结果
try? recognizer?.stopContinuousRecognition()
_isContinuousRecognitionActive = false
}
setupMicrophoneStream()
// 清理旧的识别器 // 清理旧的识别器
recognizer = nil recognizer = nil
// 设置音频配置
switch audioSourceType {
case .microphone:
// 使用默认麦克风输入配置
audioConfig = try SPXAudioConfiguration()
case .external:
// 使用拉流方式处理外部音频
setupExternalAudioStream()
}
// 创建识别器 // 创建识别器
if isAutoDetectLanguage { if isAutoDetectLanguage {
let autoDetectConfig = try SPXAutoDetectSourceLanguageConfiguration(supportedLanguages) let autoDetectConfig = try SPXAutoDetectSourceLanguageConfiguration(supportedLanguages)
@ -305,76 +288,95 @@ public class AzureAsrHelper: NSObject {
return true return true
} catch { } catch {
os_log("创建识别器失败: %{public}@", log: log, type: .error, error.localizedDescription) os_log("创建识别器失败: %{public}@", log: log, type: .error, error.localizedDescription)
stopAudioProcessing() try? recognizer?.stopContinuousRecognition()
return false return false
} }
} }
/** /**
* 设置外部音频流 - 使用拉流方式 * 设置麦克风流 - 使用推流方式
*/ */
private func setupExternalAudioStream() { private func setupMicrophoneStream() {
do { do {
// 创建外部音频拉流对象
externalAudioStream = ExternalAudioPullStream() if (audioStream == nil) {
// 创建外部音频拉流对象
// 明确指定使用外部音频源(推流模式)
audioStream = AudioStream()
audioStream?.initAudioRecord()
audioStream?.onAudioData = { [weak self] data in
self?.recordfile?.saveAudioDataToWav(data)
}
}
// 创建音频配置 // 创建音频配置
audioConfig = SPXAudioConfiguration(streamInput: externalAudioStream!.pullStream) if let pushStream = audioStream?.pushAudioStream { // Safely unwrap optional
audioConfig = SPXAudioConfiguration(streamInput: pushStream)
os_log("设置麦克风流:")
}
} catch { } catch {
os_log("设置外部音频流失败: %{public}@", log: log, type: .error, error.localizedDescription) os_log("设置麦克风流失败: %{public}@", log: log, type: .error, error.localizedDescription)
externalAudioStream = nil audioStream = nil
} }
} }
/** /**
* 设置事件监听器 * 设置事件监听器
*/ */
private func setupEventListeners(callback: ContinuousRecognizeCallback) { public func setupEventListeners(callback: ContinuousRecognizeCallback) -> Bool{
guard let recognizer = recognizer else { return } print("设置ssssss监听器:${speechConfig}")
// 识别中事件
recognizer.addRecognizingEventHandler { [weak self] (sender, event) in
guard let self = self else { return }
let result = event.result // 重设识别器
let detectedLanguage = self.getDetectedLanguage(from: result) if (!setupRecognizer()) {
// os_log("识别中: %{public}@", log: self.log, type: .info, result.text ?? "") return false
// 直接在当前线程调用回调 }
guard let recognizer = recognizer else { return false}
// 正在识别事件
recognizer.addRecognizingEventHandler { [weak self] (_, event) in
guard let self = self else { return }
let result = event.result
let detectedLanguage = self.detectLanguage(from: result)
print("识别中事件: \(detectedLanguage)")
DispatchQueue.main.async {
callback.onRecognizing(result.text ?? "", detectedLanguage) callback.onRecognizing(result.text ?? "", detectedLanguage)
} }
}
// 识别完成事件 // 识别完成事件
recognizer.addRecognizedEventHandler { [weak self] (sender, event) in recognizer.addRecognizedEventHandler { [weak self] (_, event) in
guard let self = self else { return } guard let self = self else { return }
let result = event.result
let result = event.result if result.reason == .recognizedSpeech {
if result.reason == SPXResultReason.recognizedSpeech { let detectedLanguage = self.detectLanguage(from: result)
let detectedLanguage = self.getDetectedLanguage(from: result) print("识别完成事件: \(detectedLanguage)")
// 直接在当前线程调用回调 DispatchQueue.main.async {
callback.onResult(result.text ?? "", detectedLanguage) callback.onResult(result.text ?? "", detectedLanguage)
} }
} }
}
// 会话开始事件 // 会话开始事件
recognizer.addSessionStartedEventHandler { (sender, event) in recognizer.addSessionStartedEventHandler { (sender, event) in
// 直接在当前线程调用回调 // 直接在当前线程调用回调
callback.onSessionStarted() callback.onSessionStarted()
print("会话开始事件:")
} }
// 会话结束事件 // 会话结束事件
recognizer.addSessionStoppedEventHandler { [weak self] (sender, event) in recognizer.addSessionStoppedEventHandler { [weak self] (sender, event) in
guard let self = self else { return } guard let self = self else { return }
print("会话结束事件:")
// 直接在当前线程调用回调 // 直接在当前线程调用回调
callback.onSessionStopped() // callback.onSessionStopped()
self._isContinuousRecognitionActive = false // self._isContinuousRecognitionActive = false
self.stopAudioProcessing() //self.stopAudioProcessing()
} }
// 取消事件 // 取消事件
recognizer.addCanceledEventHandler { [weak self] (sender, event) in recognizer.addCanceledEventHandler { [weak self] (sender, event) in
guard let self = self else { return } guard let self = self else { return }
print("取消事件:")
let errorDetails = event.errorDetails ?? "未知错误" let errorDetails = event.errorDetails ?? "未知错误"
let reason = String(describing: event.reason.rawValue) let reason = String(describing: event.reason.rawValue)
@ -382,8 +384,18 @@ public class AzureAsrHelper: NSObject {
callback.onCanceled(reason, errorDetails) 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 { do {
let langResult = try SPXAutoDetectSourceLanguageResult(result) let langResult = try SPXAutoDetectSourceLanguageResult(result)
print("获取检测到的语言")
return langResult.language ?? "" return langResult.language ?? ""
} catch { } catch {
return "" return ""
@ -408,94 +421,361 @@ public class AzureAsrHelper: NSObject {
} }
/** /**
* 启动音频处理 * 禁用蓝牙音频功能,切换回正常音频模式
*/ */
private func startAudioProcessing() { public func disableBluetoothAudio() {
switch audioSourceType {
case .microphone: do {
// 拉流模式不需要额外启动,SDK会自动拉取数据 audioStream?.setAudioOutputRoute(.speaker)
break } catch {
case .external: print("disableBluetoothAudio")
// 外部音频数据模式下不需要启动处理,等待外部调用pushAudioData
break
} }
} }
/**
* 恢复原始音频设备状态(通常是重新启用蓝牙)
*/
public func restoreOriginalAudioState() {
do {
audioStream?.setAudioOutputRoute(.receiver)
} catch {
print("restoreOriginalAudioState")
}
}
// MARK: - 录音音频文件方法
/** /**
* 停止音频处理 * 开启录音
*/ */
private func stopAudioProcessing() { public func enableRecord(filePath: String) {
if let stream = externalAudioStream {
stream.close()
externalAudioStream = nil recordfile?.closeFile(isSave: true)
// os_log("外部音频流已关闭", log: log, type: .info) recordfile?.creatingFiles(atPath: filePath) // Fixed method call
}
}
/**
* 移动文件到新路径
*/
public func moveFile(sourcePath: String, destPath: String)-> Bool {
recordfile?.moveFile(from: sourcePath, to: destPath) // Fixed method call
return true
} }
/** /**
* 外部音频拉流 * 重命名指定路径的音频文件
* 实现PullAudioInputStreamCallback,将外部推送的音频数据转换为SDK可拉取的形式
*/ */
private class ExternalAudioPullStream: NSObject { public func renameFile(filePath: String, newName: String)-> Bool {
private(set) var pullStream: SPXPullAudioInputStream!
private let queue = LinkedBlockingQueue<Data>()
private var closed = false
recordfile?.renameFile(at: filePath, to: newName) // Fixed method call
override init() { return true
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)) public func pauseRecord() {
},
closeHandler: { [weak self] in
self?.close() 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
) )
} }
/**
* 开始音频输入
*/
public func startAudioInput(audioSourceType: AudioSourceType = .microphone) {
isWriting=true
self.audioSourceType = audioSourceType;
print("startAudioInput=audioSourceType\(audioSourceType)")
switch audioSourceType {
case .microphone:
runMicrophoneCapture()
case .external:
runExternalCapture()
}
}
/** /**
* 外部调用:推送音频数据到队列 * 开启音频写入线程
* @param data 音频数据
*/ */
func pushAudio(_ data: Data) { public func startWriteThread() {
if !closed { writeThread?.async { [weak self] in
queue.put(data) guard let self = self else { return }
while self.isRunning {
print("是否写入: \(self.isWriting)")
if !self.isWriting {
usleep(10_000)
continue
}
guard let dataToWrite = self.writeQueue.take() else { continue }
print("写入数据长度: \(dataToWrite.count)")
self.pushAudioStream?.write(dataToWrite)
self.onAudioData?(dataToWrite)
}
} }
} }
/** /**
* SDK调用:从队列中拉取数据 * 向音频流写入音频数据
* @param buffer SDK提供的缓冲区 * 仅当音频源设置为external时有效
* @param size 缓冲区大小 *
* @return 读取的字节数,0表示流结束 * @param data 音频数据字节数组
*/ */
private func read(buffer: NSMutableData, size: Int) -> Int { public func saveAudioDataTo(data: Data) {
// 阻塞等待下一块数据 if audioSourceType != .external {
guard let chunk = queue.take() else { return
return 0 // 队列已关闭 }
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
if chunk.isEmpty { }
return 0 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
}
let toCopy = min(chunk.count, size) // 音频路由管理
buffer.append(chunk.prefix(toCopy)) public enum AudioOutputRoute {
return toCopy 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 {
/** // 重新激活
* SDK调用:关闭流 try audioSession.setActive(true, options: [.notifyOthersOnDeactivation])
*/
func close() { try audioEngine?.start()
closed = true writeThread?.async {
queue.close() self.writeQueue.close()
}
}
} catch {
print("路由切换失败: \(error)")
}
} }
} }
// MARK: - iOS版LinkedBlockingQueue实现
/** /**
* iOS版LinkedBlockingQueue实现 * iOS版LinkedBlockingQueue实现
@ -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) registrar.addMethodCallDelegate(instance, channel: asrChannel)
instance.asrChannel = asrChannel instance.asrChannel = asrChannel
// 设置ASR通道处理器 - 使用实例方法
asrChannel.setMethodCallHandler { [weak instance] (call, result) in
instance?.handleAsrMethodCall(call, result: result)
}
// 初始化TTS通道 // 初始化TTS通道
let ttsChannel = FlutterMethodChannel(name: "azure_speech/tts", binaryMessenger: registrar.messenger()) let ttsChannel = FlutterMethodChannel(name: "azure_speech/tts", binaryMessenger: registrar.messenger())
registrar.addMethodCallDelegate(instance, channel: ttsChannel) registrar.addMethodCallDelegate(instance, channel: ttsChannel)
instance.ttsChannel = ttsChannel instance.ttsChannel = ttsChannel
// 设置TTS通道处理器 - 使用实例方法
ttsChannel.setMethodCallHandler { [weak instance] (call, result) in
instance?.handleTtsMethodCall(call, result: result)
}
// 初始化ASR事件通道 // 初始化ASR事件通道
let asrEventChannel = FlutterEventChannel(name: "azure_speech/asr_events", binaryMessenger: registrar.messenger()) let asrEventChannel = FlutterEventChannel(name: "azure_speech/asr_events", binaryMessenger: registrar.messenger())
asrEventChannel.setStreamHandler(instance) asrEventChannel.setStreamHandler(instance)
@ -60,7 +70,6 @@ import os.log
ttsEventChannel.setStreamHandler(instance) ttsEventChannel.setStreamHandler(instance)
instance.ttsEventChannel = ttsEventChannel instance.ttsEventChannel = ttsEventChannel
} }
// 发送ASR事件方法 // 发送ASR事件方法
internal func sendAsrEvent(_ event: [String: Any]) { internal func sendAsrEvent(_ event: [String: Any]) {
if asrEventSink == nil { if asrEventSink == nil {
@ -96,19 +105,22 @@ import os.log
} }
// // 处理Flutter方法调用
// 处理Flutter方法调用 // public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { // switch call.method {
if call.method.hasPrefix("tts_") { // case "azure_speech/asr":
handleTtsMethodCall(call, result) // handleAsrMethodCall(call, result)
} else { // case "azure_speech/tts":
handleAsrMethodCall(call, result) // handleTtsMethodCall(call, result)
} // default:
} // // 处理未识别的通道
// result(FlutterMethodNotImplemented)
// }
// }
// MARK: - ASR 方法处理 // MARK: - ASR 方法处理
private func handleAsrMethodCall(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { private func handleAsrMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method { switch call.method {
case "initialize": case "initialize":
guard let args = call.arguments as? [String: Any], guard let args = call.arguments as? [String: Any],
@ -155,16 +167,25 @@ import os.log
audioSourceType = .microphone audioSourceType = .microphone
} }
// 创建回调包装器
currentAsrCallback = AsrCallbackWrapper(plugin: self)
let success = azureAsrHelper.startContinuousRecognition( let success = azureAsrHelper.startContinuousRecognition(
callback: currentAsrCallback!,
audioSourceType: audioSourceType audioSourceType: audioSourceType
) )
result(success) 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": case "stopContinuousRecognition":
let success = azureAsrHelper.stopContinuousRecognition() let success = azureAsrHelper.stopContinuousRecognition()
currentAsrCallback = nil currentAsrCallback = nil
result(success) result(success)
@ -173,6 +194,7 @@ import os.log
result(azureAsrHelper.isContinuousRecognitionActive()) result(azureAsrHelper.isContinuousRecognitionActive())
case "dispose": case "dispose":
print("dispose")
azureAsrHelper.dispose() azureAsrHelper.dispose()
currentAsrCallback = nil currentAsrCallback = nil
result(true) result(true)
@ -184,7 +206,88 @@ import os.log
return 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) result(true)
default: default:
@ -194,9 +297,9 @@ import os.log
// MARK: - TTS 方法处理 // MARK: - TTS 方法处理
private func handleTtsMethodCall(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { private func handleTtsMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method { switch call.method {
case "tts_initialize": case "initialize":
guard let args = call.arguments as? [String: Any], guard let args = call.arguments as? [String: Any],
let subscriptionKey = args["subscriptionKey"] as? String, let subscriptionKey = args["subscriptionKey"] as? String,
let region = args["region"] as? String else { let region = args["region"] as? String else {
@ -213,7 +316,7 @@ import os.log
result(success) result(success)
case "tts_set_voice": case "setVoice":
guard let args = call.arguments as? [String: Any], guard let args = call.arguments as? [String: Any],
let voiceName = args["voiceName"] as? String else { let voiceName = args["voiceName"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "语音名称不能为空", details: nil)) result(FlutterError(code: "INVALID_ARGUMENTS", message: "语音名称不能为空", details: nil))
@ -223,7 +326,7 @@ import os.log
let success = azureTtsHelper.setVoice(voiceName) let success = azureTtsHelper.setVoice(voiceName)
result(success) result(success)
case "tts_speak_once": case "speakText":
guard let args = call.arguments as? [String: Any], guard let args = call.arguments as? [String: Any],
let text = args["text"] as? String else { let text = args["text"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "文本不能为空", details: nil)) result(FlutterError(code: "INVALID_ARGUMENTS", message: "文本不能为空", details: nil))
@ -233,7 +336,7 @@ import os.log
let success = azureTtsHelper.speakOnce(text) let success = azureTtsHelper.speakOnce(text)
result(success) result(success)
case "tts_speak_stream": case "speakStream":
guard let args = call.arguments as? [String: Any], guard let args = call.arguments as? [String: Any],
let text = args["text"] as? String else { let text = args["text"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "文本不能为空", details: nil)) result(FlutterError(code: "INVALID_ARGUMENTS", message: "文本不能为空", details: nil))
@ -243,19 +346,46 @@ import os.log
let success = azureTtsHelper.speakStream(text) let success = azureTtsHelper.speakStream(text)
result(success) result(success)
case "tts_flush_stream": case "flushStream":
let success = azureTtsHelper.flushStream() let success = azureTtsHelper.flushStream()
result(success) result(success)
case "tts_stop": case "stop":
let success = azureTtsHelper.stop() let success = azureTtsHelper.stop()
result(success) result(success)
case "tts_isSpeaking": case "isSpeaking":
result(azureTtsHelper.isSpeaking()) result(azureTtsHelper.isSpeaking())
case "tts_release": case "dispose":
azureTtsHelper.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) result(true)
default: default:
@ -276,7 +406,7 @@ private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback {
plugin?.sendAsrEvent([ plugin?.sendAsrEvent([
"type": "result", "type": "result",
"text": text, "text": text,
"language": detectedLanguage "detectedLanguage": detectedLanguage
]) ])
} }
@ -284,7 +414,7 @@ private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback {
plugin?.sendAsrEvent([ plugin?.sendAsrEvent([
"type": "recognizing", "type": "recognizing",
"text": recognizing, "text": recognizing,
"language": detectedLanguage "detectedLanguage": detectedLanguage
]) ])
} }

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

@ -1,28 +1,11 @@
import Foundation import Foundation
import AVFoundation import AVFoundation
import MicrosoftCognitiveServicesSpeech import MicrosoftCognitiveServicesSpeech
import os.log
// 自定义语音处理组件,提供音频流处理等功能 // 自定义语音处理组件,提供音频流处理等功能
import speech import speech
import os.log public class AzureTtsHelper: NSObject, ITtsService {
/**
* 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 tag = "AzureTtsHelper"
// 日志对象
private let log = OSLog(subsystem: "com.azure.speech", category: "AzureTtsHelper") private let log = OSLog(subsystem: "com.azure.speech", category: "AzureTtsHelper")
private static let DEFAULT_LANGUAGE = "zh-CN" private static let DEFAULT_LANGUAGE = "zh-CN"
@ -43,59 +26,51 @@ import os.log
// 事件监听器列表 // 事件监听器列表
private var eventListeners = NSHashTable<AnyObject>.weakObjects() private var eventListeners = NSHashTable<AnyObject>.weakObjects()
private var audioDataListeners = NSHashTable<AnyObject>.weakObjects()
// 流式文本处理的缓冲区 // 流式文本处理的缓冲区
private var streamBuffer = "" private var streamBuffer = ""
private var lastSpeakTime: TimeInterval = 0 private var lastSpeakTime: TimeInterval = 0
// 内部异步处理队列,保证顺序执行 // 内部异步处理队列
private let synthesisQueue = DispatchQueue(label: "com.azure.tts.synthesis", qos: .userInitiated) private let synthesisQueue = DispatchQueue(label: "com.azure.tts.synthesis", qos: .userInitiated)
private var isProcessing = false // 添加处理状态标志
private let synthesisGroup = DispatchGroup() private let synthesisGroup = DispatchGroup()
private var pendingTasks: [() -> Void] = [] private var pendingTasks: [() -> Void] = []
private let taskLock = NSLock() 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 { public func initialize(ttsAppId: String, ttsAppToken: String, ttsResource: String, language: String) -> Bool {
do { do {
// 创建语音配置 // 创建语音配置
if ttsResource.isEmpty { speechConfig = try SPXSpeechConfiguration(subscription: ttsAppToken, region: ttsResource)
speechConfig = try SPXSpeechConfiguration(subscription: ttsAppToken, region: "eastasia")
} else {
speechConfig = try SPXSpeechConfiguration(subscription: ttsAppToken, region: ttsResource)
}
// 设置语言
speechConfig?.speechSynthesisLanguage = language speechConfig?.speechSynthesisLanguage = language
currentLanguage = language currentLanguage = language
// 设置音频输出格式 - 使用16k、16位的PCM格式 // 设置音频输出格式 - 与Android一致
speechConfig?.setPropertyTo("Audio16Khz16BitMonoPcm", speechConfig?.setPropertyTo("riff-16khz-16bit-mono-pcm",
byName: "SpeechServiceConnection_SynthOutputFormat") byName: "SpeechServiceConnection_SynthOutputFormat")
// 创建合成器,使用默认音频输出(扬声器) // 设置低延迟属性
synthesizer = try SPXSpeechSynthesizer(speechConfig!) speechConfig?.setPropertyTo("300", byName: "SpeechServiceConnection_InitialSilenceTimeoutMs")
speechConfig?.setPropertyTo("300", byName: "SpeechServiceConnection_EndSilenceTimeoutMs")
// 设置事件监听 // 创建合成器
setupEventListeners() recreateSynthesizer()
// 设置默认音色 - 中文默认使用晓晓,英文默认使用Jenny
if language.lowercased().starts(with: "zh") {
_ = setVoice("zh-CN-XiaoxiaoNeural")
} else {
_ = setVoice("en-US-JennyNeural")
}
// 设置初始化完成 // 设置初始化完成
isInitialized = true isInitialized = true
// 预热TTS引擎
warmupSynthesizer()
return true return true
} catch { } catch {
os_log("语音合成服务初始化失败: %{public}@", log: log, type: .error, error.localizedDescription) 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 { public func setVoice(_ voiceName: String) -> Bool {
if !isInitialized { return false } if !isInitialized { return false }
do { do {
currentVoice = voiceName currentVoice = voiceName
speechConfig?.speechSynthesisVoiceName = voiceName
// 更新语音名称
if let config = speechConfig {
config.speechSynthesisVoiceName = voiceName
}
// 重新创建合成器
recreateSynthesizer() recreateSynthesizer()
return true return true
} catch { } catch {
@ -135,173 +111,88 @@ import os.log
/** /**
* 单次合成并播放 * 单次合成并播放
*
* @param text 要合成的文本
* @return 是否成功开始合成
*/ */
public func speakOnce(_ text: String) -> Bool { public func speakOnce(_ text: String) -> Bool {
if !isInitialized { if !isInitialized {
os_log("语音合成未初始化", log: log, type: .error) os_log("语音合成未初始化", log: log, type: .error)
notifyEvent(eventType: .error, params: [
"errorCode": "NOT_INITIALIZED",
"errorMessage": "TTS引擎未初始化"
])
return false return false
} }
// 立即返回成功,内部异步处理 // 清理文本
enqueueSynthesisTask { let cleanedText = cleanTextForTTS(text)
self.performSynthesis(text: text) if cleanedText.isEmpty {
return false
} }
return true // 异步处理
} enqueueSynthesisTask {
self.performSynthesis(text: cleanedText)
/**
* 将合成任务加入队列,保证顺序执行
*/
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() return true
// 处理下一个任务
self.taskLock.lock()
if !self.pendingTasks.isEmpty {
self.taskLock.unlock()
self.processNextTask()
} else {
self.taskLock.unlock()
}
}
} }
/** /**
* 实际执行合成的方法 * 流式合成文本
*/ */
private func performSynthesis(text: String) { public func speakStream(_ text: String) -> Bool {
// 重置状态 if !isInitialized {
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: [ notifyEvent(eventType: .error, params: [
"errorCode": "SYNTHESIS_FAILED", "errorCode": "NOT_INITIALIZED",
"errorMessage": error.localizedDescription "errorMessage": "TTS引擎未初始化"
]) ])
speaking = false return false
} }
}
/** if text.isEmpty {
* 流式合成文本 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 { // 清理文本并添加到缓冲区
// 添加新文本到缓冲区 let cleanedText = cleanTextForTTS(text)
streamBuffer.append(text) streamBuffer.append(cleanedText)
// 增加500ms防抖逻辑 // 防抖逻辑 (150ms)
let currentTime = Date().timeIntervalSince1970 let currentTime = Date().timeIntervalSince1970
if currentTime - lastSpeakTime < 0.6 { if currentTime - lastSpeakTime < 0.15 {
return true return true
} }
lastSpeakTime = currentTime lastSpeakTime = currentTime
let currentText = streamBuffer let currentText = streamBuffer
// 定义标点符号列表 // 标点符号列表 (与Android一致)
let punctuationMarks: [Character] = [".", "。", "!", "!", "?", "?", ";", ";", ",", ",", ":", ":", "\n"] let punctuationMarks: [Character] = [".", "。", "!", "!", "?", "?", ";", ";", ",", ",", ":", ":", "\n"]
// 查找最后一个标点符号的位置 // 从后往前查找最后一个标点符号
var lastPunctuationIndex = -1 var lastPunctuationIndex = -1
for (i, char) in currentText.enumerated().reversed() { for (i, char) in currentText.enumerated().reversed() {
if punctuationMarks.contains(char) { if punctuationMarks.contains(char) {
lastPunctuationIndex = i lastPunctuationIndex = i
break break
}
} }
}
// 如果找到标点符号,则播放到该标点符号 // 播放到找到的标点符号
if lastPunctuationIndex >= 0 { if lastPunctuationIndex >= 0 {
// 提取要播放的文本(包含标点符号) let textToSpeak = String(currentText.prefix(lastPunctuationIndex + 1))
let textToSpeak = String(currentText.prefix(lastPunctuationIndex + 1)).trimmingCharacters(in: .whitespacesAndNewlines) let startIndex = currentText.index(currentText.startIndex, offsetBy: lastPunctuationIndex + 1)
streamBuffer = String(currentText[startIndex...])
// 剩余的文本保存在缓冲区中
let startIndex = currentText.index(currentText.startIndex, offsetBy: lastPunctuationIndex + 1)
streamBuffer = String(currentText[startIndex...])
// 只有非空文本才播放 if !textToSpeak.isEmpty {
if !textToSpeak.isEmpty {
return speakOnce(textToSpeak) return speakOnce(textToSpeak)
}
} }
// 如果没有找到标点符号,则等待更多文本
return true
} catch {
os_log("流式语音合成失败: %{public}@", log: log, type: .error, error.localizedDescription)
notifyEvent(eventType: .error, params: [
"errorCode": "STREAM_FAILED",
"errorMessage": "流式语音合成失败: \(error.localizedDescription)"
])
return false
} }
return true
} }
/** /**
* 刷新并播放流式文本缓冲区中的剩余内容 * 刷新并播放流式文本
*
* @return 是否成功处理
*/ */
public func flushStream() -> Bool { public func flushStream() -> Bool {
if !isInitialized { if !isInitialized {
@ -312,115 +203,71 @@ import os.log
return false return false
} }
do { let remainingText = streamBuffer
// 获取缓冲区中剩余的文本 streamBuffer = ""
let remainingText = streamBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
// 清空缓冲区
streamBuffer = ""
// 如果缓冲区为空,直接返回成功
if remainingText.isEmpty {
return true
}
// 播放剩余文本 if remainingText.isEmpty {
return speakOnce(remainingText) return true
} catch {
os_log("刷新流式文本失败: %{public}@", log: log, type: .error, error.localizedDescription)
notifyEvent(eventType: .error, params: [
"errorCode": "FLUSH_FAILED",
"errorMessage": "刷新流式文本失败: \(error.localizedDescription)"
])
return false
} }
return speakOnce(remainingText)
} }
/** /**
* 停止语音合成和播放 * 停止语音合成
*
* @return 是否成功停止
*/ */
public func stop() -> Bool { public func stop() -> Bool {
// 立即更新状态
speaking = false speaking = false
// 清除流式缓冲区中的待播放内容
streamBuffer = "" streamBuffer = ""
// 清空待处理的任务队列
taskLock.lock() taskLock.lock()
pendingTasks.removeAll() pendingTasks.removeAll()
taskLock.unlock() taskLock.unlock()
// 在后台队列停止合成器,避免阻塞主线程
synthesisQueue.async { synthesisQueue.async {
if let synthesizer = self.synthesizer { do {
do { try self.synthesizer?.stopSpeaking()
try synthesizer.stopSpeaking() self.notifyEvent(eventType: .synthesisCanceled)
os_log("语音合成已停止", log: self.log, type: .info) } catch {
os_log("停止语音合成失败: %{public}@", log: self.log, type: .error, error.localizedDescription)
// 直接通知停止完成 self.notifyEvent(eventType: .error, params: [
self.notifyEvent(eventType: .synthesisCanceled) "errorCode": "STOP_FAILED",
} catch { "errorMessage": error.localizedDescription
os_log("停止语音合成失败: %{public}@", log: self.log, type: .error, error.localizedDescription) ])
self.notifyEvent(eventType: .error, params: [
"errorCode": "STOP_FAILED",
"errorMessage": error.localizedDescription
])
}
} }
} }
return true return true
} }
/** /**
* 释放资源 * 释放资源
* 在不再需要服务时调用,释放底层资源
*/ */
public func dispose() { public func dispose() {
// 停止播放
_ = stop() _ = stop()
// 等待所有任务完成
synthesisGroup.wait() synthesisGroup.wait()
// 清理资源 synthesisQueue.sync {
synthesisQueue.async { synthesizer = nil
// 释放合成器 speechConfig = nil
self.synthesizer = nil customAudioOutputStream = nil
isInitialized = false
// 释放配置 speaking = false
self.speechConfig = nil
// 清空流缓冲区
self.streamBuffer = ""
// 重置状态
self.isInitialized = false
self.speaking = false
// 清空任务队列 taskLock.lock()
self.taskLock.lock() pendingTasks.removeAll()
self.pendingTasks.removeAll() taskLock.unlock()
self.taskLock.unlock()
} }
} }
/** /**
* 添加TTS事件监听器 * 添加事件监听器
*
* @param listener 事件监听器
*/ */
public func addListener(_ listener: TtsEventListener) { public func addListener(_ listener: TtsEventListener) {
eventListeners.add(listener as AnyObject) eventListeners.add(listener as AnyObject)
} }
/** /**
* 移除TTS事件监听器 * 移除事件监听器
*
* @param listener 要移除的事件监听器
*/ */
public func removeListener(_ listener: TtsEventListener) { public func removeListener(_ listener: TtsEventListener) {
eventListeners.remove(listener as AnyObject) eventListeners.remove(listener as AnyObject)
@ -428,100 +275,180 @@ import os.log
/** /**
* 添加音频数据监听器 * 添加音频数据监听器
* 由于不再支持自定义音频流,此方法实际上不再有效
*
* @param listener 音频数据监听器
*/ */
public func addAudioDataListener(_ listener: AudioDataListener) { public func addAudioDataListener(_ listener: AudioDataListener) {
os_log("警告:不支持音频数据监听器功能", log: log, type: .info) audioDataListeners.add(listener as AnyObject)
} }
/** /**
* 移除音频数据监听器 * 移除音频数据监听器
* 由于不再支持自定义音频流,此方法实际上不再有效
*
* @param listener 要移除的音频数据监听器
*/ */
public func removeAudioDataListener(_ listener: AudioDataListener) { public func removeAudioDataListener(_ listener: AudioDataListener) {
// 不做任何操作 audioDataListeners.remove(listener as AnyObject)
} }
/** /**
* 当前是否正在播放/合成 * 设置是否使用内部播放器
*/ */
public func isSpeaking() -> Bool { public func setUseInternalPlayer(_ useInternalPlayer: Bool) {
return speaking self.useInternalPlayer = useInternalPlayer
if isInitialized {
recreateSynthesizer()
}
} }
// MARK: - 辅助方法
/** /**
* 设置事件监听器 * 设置音频输出设备
*/ */
private func setupEventListeners() { public func setAudioOutputDevice() -> Bool {
guard let synthesizer = synthesizer else { return } // 先暂停当前播放
let wasSpeaking = speaking
if wasSpeaking {
_ = stop()
}
// 添加合成开始事件处理器 // 重新创建合成器以应用新设备
synthesizer.addSynthesisStartedEventHandler { [weak self] _, _ in if isInitialized {
guard let self = self else { return } print(" 重新创建合成器以应用新设备")
self.notifyEvent(eventType: .synthesisStarted) recreateSynthesizer()
} }
// 添加合成中事件处理器 // 如果之前正在播放,恢复播放
synthesizer.addSynthesizingEventHandler { [weak self] _, _ in if wasSpeaking, let lastText = lastSpokenText {
// 可以在这里处理合成中的事件,目前没有特别操作 speakOnce(lastText)
} }
// 添加合成完成事件处理器 return true
synthesizer.addSynthesisCompletedEventHandler { [weak self] _, e in }
guard let self = self else { return } // /**
self.speaking = false // * 设置音频输出设备
self.notifyEvent(eventType: .synthesisCompleted) // */
// public func setAudioOutputDevice(_ device: AudioOutputDevice) -> Bool {
// do {
// let session = AVAudioSession.sharedInstance()
// try session.setCategory(.playAndRecord, options: [.defaultToSpeaker, .allowBluetooth])
//
// switch device {
// case .default:
// try session.overrideOutputAudioPort(.none)
// case .speaker:
// try session.overrideOutputAudioPort(.speaker)
// case .headphones:
// try session.overrideOutputAudioPort(.none)
// }
//
// try session.setActive(true)
// return true
// } catch {
// os_log("设置音频输出设备失败: %{public}@", log: log, type: .error, error.localizedDescription)
// return false
// }
// }
//
// MARK: - 私有方法
/**
* 加入合成任务队列
*/
private func enqueueSynthesisTask(_ task: @escaping () -> Void) {
taskLock.lock()
pendingTasks.append(task)
taskLock.unlock()
// 确保任务被处理
processTasksIfNeeded()
}
/**
* 处理任务队列
*/
private func processTasksIfNeeded() {
taskLock.lock()
// 如果已经在处理中或没有任务,则直接返回
guard !isProcessing && !pendingTasks.isEmpty else {
taskLock.unlock()
return
} }
// 添加合成取消事件处理器 isProcessing = true
synthesizer.addSynthesisCanceledEventHandler { [weak self] _, e in let task = pendingTasks.removeFirst()
guard let self = self else { return } taskLock.unlock()
self.speaking = false synthesisQueue.async {
task()
var params: [String: Any] = [:] // 任务完成后,检查是否有更多任务
do { self.taskLock.lock()
let cancellationDetails = try SPXSpeechSynthesisCancellationDetails(fromCanceledSynthesisResult: e.result) self.isProcessing = false
if cancellationDetails.reason == SPXCancellationReason.error {
params["reason"] = String(describing: cancellationDetails.reason.rawValue) // 如果还有任务,递归处理
params["errorDetails"] = cancellationDetails.errorDetails ?? "未知错误" if !self.pendingTasks.isEmpty {
} self.taskLock.unlock()
} catch { self.processTasksIfNeeded()
params["errorDetails"] = "获取取消详情失败: \(error.localizedDescription)" } else {
self.taskLock.unlock()
} }
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] = [:]) { private func performSynthesis(text: String) {
let event = TtsEvent(type: eventType, params: params) speaking = true
lastSpokenText = text
// 生成SSML
let ssml = generateOptimizedSsml(text)
// 直接通知事件,由调用方处理线程切换 do {
for case let listener as TtsEventListener in self.eventListeners.allObjects { os_log("开始合成: %{public}@", log: log, type: .debug, ssml)
listener.onEvent(event) 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() { private func recreateSynthesizer() {
do { 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() setupEventListeners()
} catch { } catch {
os_log("重新创建合成器失败: %{public}@", log: log, type: .error, error.localizedDescription) 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 { private func setupEventListeners() {
if !isInitialized { return false } synthesizer?.addSynthesisStartedEventHandler { [weak self] _, _ in
self?.notifyEvent(eventType: .synthesisStarted)
self?.notifyEvent(eventType: .playbackStarted)
}
do { synthesizer?.addSynthesizingEventHandler { [weak self] _, event in
currentRate = formatPercentage(rate) if let audioData = event.result.audioData {
currentPitch = formatPercentage(pitch) self?.notifyAudioData(audioData)
currentVolume = "\(min(max(volume, 0), 100))%" }
return true }
} catch {
os_log("设置语音参数失败: %{public}@", log: log, type: .error, error.localizedDescription) synthesizer?.addSynthesisCompletedEventHandler { [weak self] _, _ in
notifyEvent(eventType: .error, params: [ guard let self = self else { return }
"errorCode": "PARAMS_SET_FAILED", self.speaking = false
"errorMessage": "设置语音参数失败: \(error.localizedDescription)" self.notifyEvent(eventType: .synthesisCompleted)
]) self.notifyEvent(eventType: .playbackCompleted)
return false }
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 notifyAudioData(_ data: Data) {
for case let listener as AudioDataListener in audioDataListeners.allObjects {
listener.onAudioData(data)
}
}
/**
* 预热TTS引擎
*/
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 formatPercentage(_ value: Int) -> String { private func cleanTextForTTS(_ text: String) -> String {
return value >= 0 ? "+\(value)%" : "\(value)%" var cleaned = text
// 移除URL
if let regex = try? NSRegularExpression(pattern: "https?://\\S+", options: .caseInsensitive) {
cleaned = regex.stringByReplacingMatches(in: cleaned, range: NSRange(location: 0, length: cleaned.count), withTemplate: "")
}
// 移除emoji
if let regex = try? NSRegularExpression(pattern: "[\\uD83C-\\uDBFF\\uDC00-\\uDFFF]+", options: .caseInsensitive) {
cleaned = regex.stringByReplacingMatches(in: cleaned, range: NSRange(location: 0, length: cleaned.count), withTemplate: "")
}
// 合并空格
if let regex = try? NSRegularExpression(pattern: "\\s+", options: .caseInsensitive) {
cleaned = regex.stringByReplacingMatches(in: cleaned, range: NSRange(location: 0, length: cleaned.count), withTemplate: " ")
}
return cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
} }
/** /**
* 生成SSML * 生成优化的SSML
*/ */
private func generateSsml(_ rawText: String) -> String { private func generateOptimizedSsml(_ rawText: String) -> String {
// 1. 定义要静音的符号和表情符号列表 // 转义XML保留字符
let symbolsToMute = [ let escapedText = rawText
"#", "*",
"😀", "😂", "😊", "😍", "😢", "😎", "😉", "👍", "🙌", "🎉"
]
// 2. 转义 XML 保留字符
var escapedText = rawText
.replacingOccurrences(of: "&", with: "&amp;") .replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;") .replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;") .replacingOccurrences(of: ">", with: "&gt;")
// 3. 静音处理特殊符号和表情符号 // 简化SSML结构
// 使用空白替换法,直接将符号替换为空字符串 return """
var processedText = escapedText <speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="\(currentLanguage)">
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)"> <voice name="\(currentVoice)">
<mstts:express-as style="cheerful"> <prosody rate="\(currentRate)" pitch="\(currentPitch)" volume="\(currentVolume)">
<prosody rate="\(currentRate)" pitch="\(currentPitch)" volume="\(currentVolume)"> \(escapedText)
<say-as interpret-as="text">\(processedText)</say-as> </prosody>
</prosody>
</mstts:express-as>
</voice> </voice>
</speak> </speak>
""" """
}
/**
* 设置语音参数
*/
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
}
return ssml /**
* 是否正在播放
*/
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; 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 * 打印设备信息和服务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") Log.d(TAG, "getDeviceInfo $info")
result.success(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" -> { "disconnect" -> {
BleService.disconnect(); BleService.disconnect();
result.success(null) result.success(null)

2
local_plugins/ble_service/ios/ble_service/Package.swift

@ -3,7 +3,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "ble_service", name: "ble_service",
platforms: [.iOS("18.0")], platforms: [.iOS("16.0")],
products: [ products: [
.library(name: "ble-service", targets: ["ble_service"]) .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 Foundation
import CoreBluetooth // import CoreBluetooth
import os.log // import os.log
import AVFoundation // import AVFoundation
import AccessorySetupKit // import AccessorySetupKit
import UIKit // import UIKit
@available(iOS 18.0, *) // @available(iOS 18.0, *)
class BleAccessorySetup: NSObject { // class BleAccessorySetup: NSObject {
// MARK: - 属性 // // MARK: - 属性
static let shared = BleAccessorySetup() // static let shared = BleAccessorySetup()
private var bleService: BleService? // private var bleService: BleService?
private var session: ASAccessorySession? // private var session: ASAccessorySession?
private var isSessionActivated = false // private var isSessionActivated = false
private var isInitializing = false // private var isInitializing = false
private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleAccessorySetup") // private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleAccessorySetup")
// 杰理蓝牙设备的公司标识符 // // 杰理蓝牙设备的公司标识符
private let deviceCompanyIdentifier: UInt16 = 0xEFAB // private let deviceCompanyIdentifier: UInt16 = 0xEFAB
private let deviceDisplayName = "语音助理耳机" // private let deviceDisplayName = "语音助理耳机"
// 用于存储配对设备UUID的UserDefaults键 // // 用于存储配对设备UUID的UserDefaults键
private let pairedDeviceUUIDKey = "com.yunqiinnovation.ble_service.paired_device_uuid" // private let pairedDeviceUUIDKey = "com.yunqiinnovation.ble_service.paired_device_uuid"
typealias DeviceDiscoveredCallback = (UUID) -> Void // typealias DeviceDiscoveredCallback = (UUID) -> Void
private var onDeviceDiscovered: DeviceDiscoveredCallback? // private var onDeviceDiscovered: DeviceDiscoveredCallback?
// MARK: - 初始化 // // MARK: - 初始化
private override init() { super.init() } // private override init() { super.init() }
// MARK: - 公共方法 // // MARK: - 公共方法
func setBleService(_ service: Any) { // func setBleService(_ service: Any) {
self.bleService = service as? BleService // self.bleService = service as? BleService
} // }
func setDeviceDiscoveredCallback(_ callback: @escaping (UUID) -> Void) { // func setDeviceDiscoveredCallback(_ callback: @escaping (UUID) -> Void) {
self.onDeviceDiscovered = callback // self.onDeviceDiscovered = callback
} // }
// 处理发现的设备并连接 // // 处理发现的设备并连接
func connectToAccessory(uuid: UUID) { // func connectToAccessory(uuid: UUID) {
guard let service = bleService else { // guard let service = bleService else {
return // return
} // }
service.connectToAccessory(uuid: uuid) // service.connectToAccessory(uuid: uuid)
} // }
func addToReconnectQueue(uuid: UUID) { // func addToReconnectQueue(uuid: UUID) {
guard let service = bleService else { // guard let service = bleService else {
return // return
} // }
service.addToReconnectQueue(uuid: uuid) // service.addToReconnectQueue(uuid: uuid)
} // }
// 从视图控制器显示设备选择器 - 主要入口点 // // 从视图控制器显示设备选择器 - 主要入口点
func showDevicePickerFromViewController(_ viewController: UIViewController) { // func showDevicePickerFromViewController(_ viewController: UIViewController) {
initializeSession() // initializeSession()
} // }
// 清除蓝牙状态(仅用于测试或重置,不会清除系统授权) // // 清除蓝牙状态(仅用于测试或重置,不会清除系统授权)
func resetBluetoothState() { // func resetBluetoothState() {
if let service = bleService { // if let service = bleService {
service.disconnect() // service.disconnect()
} // }
} // }
// MARK: - 会话管理 // // MARK: - 会话管理
func initializeSession() { // func initializeSession() {
if isInitializing { // if isInitializing {
os_log("会话正在初始化中,跳过", log: logger, type: .info) // os_log("会话正在初始化中,跳过", log: logger, type: .info)
return // return
} // }
// 如果会话已激活,检查是否有配对设备 // // 如果会话已激活,检查是否有配对设备
if session != nil && isSessionActivated { // if session != nil && isSessionActivated {
os_log("会话已激活,检查配对设备", log: logger, type: .info) // os_log("会话已激活,检查配对设备", log: logger, type: .info)
// 会话已激活,检查是否有配对设备 // // 会话已激活,检查是否有配对设备
if let accessories = session?.accessories, !accessories.isEmpty { // if let accessories = session?.accessories, !accessories.isEmpty {
os_log("发现 %d 个已配对设备,尝试连接", log: logger, type: .info, accessories.count) // os_log("发现 %d 个已配对设备,尝试连接", log: logger, type: .info, accessories.count)
// 有配对设备,尝试连接 // // 有配对设备,尝试连接
if let service = bleService { // if let service = bleService {
let _ = service.getCentralManager() // let _ = service.getCentralManager()
for accessory in accessories { // for accessory in accessories {
if let uuid = accessory.bluetoothIdentifier { // if let uuid = accessory.bluetoothIdentifier {
onDeviceDiscovered?(uuid) // onDeviceDiscovered?(uuid)
connectToAccessory(uuid: uuid) // connectToAccessory(uuid: uuid)
} // }
} // }
} // }
} else { // } else {
os_log("没有已配对设备,显示配对框", log: logger, type: .info) // os_log("没有已配对设备,显示配对框", log: logger, type: .info)
// 没有配对设备,直接显示配对框 // // 没有配对设备,直接显示配对框
showDevicePicker() // showDevicePicker()
} // }
return // return
} // }
os_log("开始初始化新会话", log: logger, type: .info) // os_log("开始初始化新会话", log: logger, type: .info)
isInitializing = true // isInitializing = true
do { // do {
session = try ASAccessorySession() // session = try ASAccessorySession()
session?.activate(on: .main) { [weak self] event in // session?.activate(on: .main) { [weak self] event in
guard let self = self else { return } // guard let self = self else { return }
self.handleSessionEvent(event) // self.handleSessionEvent(event)
} // }
} catch { // } catch {
os_log("会话初始化失败: %{public}@", log: logger, type: .error, error.localizedDescription) // os_log("会话初始化失败: %{public}@", log: logger, type: .error, error.localizedDescription)
handleSessionInitError(error) // handleSessionInitError(error)
} // }
} // }
func resetSession() { // func resetSession() {
session?.invalidate() // session?.invalidate()
session = nil // session = nil
isSessionActivated = false // isSessionActivated = false
isInitializing = false // isInitializing = false
} // }
// MARK: - 设备选择器显示 // // MARK: - 设备选择器显示
private func showDevicePicker() { // private func showDevicePicker() {
guard let session = session, isSessionActivated else { // guard let session = session, isSessionActivated else {
os_log("无法显示配对框:会话未激活", log: logger, type: .error) // os_log("无法显示配对框:会话未激活", log: logger, type: .error)
return // return
} // }
os_log("准备显示设备配对框", log: logger, type: .info) // os_log("准备显示设备配对框", log: logger, type: .info)
let descriptor = ASDiscoveryDescriptor() // let descriptor = ASDiscoveryDescriptor()
descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(deviceCompanyIdentifier) // descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(deviceCompanyIdentifier)
let deviceImage = UIImage(systemName: "headphones") ?? UIImage() // let deviceImage = UIImage(systemName: "headphones") ?? UIImage()
let displayItem = ASPickerDisplayItem( // let displayItem = ASPickerDisplayItem(
name: deviceDisplayName, // name: deviceDisplayName,
productImage: deviceImage, // productImage: deviceImage,
descriptor: descriptor // descriptor: descriptor
) // )
DispatchQueue.main.async { // DispatchQueue.main.async {
os_log("正在显示设备配对框", log: self.logger, type: .info) // os_log("正在显示设备配对框", log: self.logger, type: .info)
session.showPicker(for: [displayItem]) { [weak self] error in // session.showPicker(for: [displayItem]) { [weak self] error in
guard let self = self else { return } // guard let self = self else { return }
if let error = error { // if let error = error {
let nsError = error as NSError // let nsError = error as NSError
os_log("设备选择器错误: %{public}@", log: self.logger, type: .error, error.localizedDescription) // os_log("设备选择器错误: %{public}@", log: self.logger, type: .error, error.localizedDescription)
} else { // } else {
os_log("设备选择器完成", log: self.logger, type: .info) // os_log("设备选择器完成", log: self.logger, type: .info)
} // }
} // }
} // }
} // }
// MARK: - 私有辅助方法 // // MARK: - 私有辅助方法
private func handleSessionEvent(_ event: ASAccessoryEvent) { // private func handleSessionEvent(_ event: ASAccessoryEvent) {
switch event.eventType { // switch event.eventType {
case .activated: // case .activated:
os_log("会话已激活", log: logger, type: .info) // os_log("会话已激活", log: logger, type: .info)
handleSessionActivated() // handleSessionActivated()
case .accessoryAdded: // case .accessoryAdded:
os_log("发现新设备: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") // os_log("发现新设备: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown")
handleAccessoryAdded(event.accessory) // handleAccessoryAdded(event.accessory)
case .accessoryRemoved: // case .accessoryRemoved:
os_log("设备已移除: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") // os_log("设备已移除: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown")
handleAccessoryRemoved(event.accessory) // handleAccessoryRemoved(event.accessory)
case .accessoryChanged: // case .accessoryChanged:
os_log("设备状态已改变", log: logger, type: .info) // os_log("设备状态已改变", log: logger, type: .info)
break // break
case .pickerDidPresent: // case .pickerDidPresent:
os_log("配对框已显示", log: logger, type: .info) // os_log("配对框已显示", log: logger, type: .info)
break // break
case .pickerDidDismiss: // case .pickerDidDismiss:
os_log("配对框已关闭", log: logger, type: .info) // os_log("配对框已关闭", log: logger, type: .info)
break // break
case .pickerSetupFailed: // case .pickerSetupFailed:
os_log("选择器设置失败: %{public}@", log: logger, type: .error, // os_log("选择器设置失败: %{public}@", log: logger, type: .error,
event.error?.localizedDescription ?? "未知错误") // event.error?.localizedDescription ?? "未知错误")
default: // default:
os_log("未处理的会话事件: %d", log: logger, type: .info, event.eventType.rawValue) // os_log("未处理的会话事件: %d", log: logger, type: .info, event.eventType.rawValue)
break // break
} // }
} // }
private func handleSessionActivated() { // private func handleSessionActivated() {
isSessionActivated = true // isSessionActivated = true
isInitializing = false // isInitializing = false
if let accessories = session?.accessories, !accessories.isEmpty { // if let accessories = session?.accessories, !accessories.isEmpty {
if let service = bleService { // if let service = bleService {
let _ = service.getCentralManager() // let _ = service.getCentralManager()
for accessory in accessories { // for accessory in accessories {
if let uuid = accessory.bluetoothIdentifier { // if let uuid = accessory.bluetoothIdentifier {
onDeviceDiscovered?(uuid) // onDeviceDiscovered?(uuid)
connectToAccessory(uuid: uuid) // connectToAccessory(uuid: uuid)
} // }
} // }
} // }
} else { // } else {
showDevicePicker() // showDevicePicker()
} // }
} // }
private func handleAccessoryAdded(_ accessory: ASAccessory?) { // private func handleAccessoryAdded(_ accessory: ASAccessory?) {
guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { // guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else {
return // return
} // }
// 保存配对设备的UUID // // 保存配对设备的UUID
savePairedMacAddress(uuid) // savePairedMacAddress(uuid)
onDeviceDiscovered?(uuid) // onDeviceDiscovered?(uuid)
connectToAccessory(uuid: uuid) // connectToAccessory(uuid: uuid)
} // }
private func handleAccessoryRemoved(_ accessory: ASAccessory?) { // private func handleAccessoryRemoved(_ accessory: ASAccessory?) {
guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { // guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else {
return // return
} // }
if let service = bleService { // if let service = bleService {
service.disconnect() // service.disconnect()
} // }
} // }
private func handleSessionInitError(_ error: Error) { // private func handleSessionInitError(_ error: Error) {
isInitializing = false // isInitializing = false
session = nil // session = nil
} // }
// MARK: - 蓝牙状态检查 // // MARK: - 蓝牙状态检查
func isBluetoothAvailable() -> Bool { // func isBluetoothAvailable() -> Bool {
do { // do {
let audioSession = AVAudioSession.sharedInstance() // let audioSession = AVAudioSession.sharedInstance()
let outputs = audioSession.currentRoute.outputs // let outputs = audioSession.currentRoute.outputs
for output in outputs { // for output in outputs {
if output.portType == .bluetoothA2DP || // if output.portType == .bluetoothA2DP ||
output.portType == .bluetoothHFP || // output.portType == .bluetoothHFP ||
output.portType == .bluetoothLE { // output.portType == .bluetoothLE {
return true // return true
} // }
} // }
} catch { // } catch {
os_log("检查蓝牙状态出错: %{public}@", log: logger, type: .error, error.localizedDescription) // os_log("检查蓝牙状态出错: %{public}@", log: logger, type: .error, error.localizedDescription)
} // }
return true // return true
} // }
// MARK: - 清除配对关系 // // MARK: - 清除配对关系
func clearAssociations() { // func clearAssociations() {
resetBluetoothState() // resetBluetoothState()
// 清除保存的配对设备UUID // // 清除保存的配对设备UUID
clearPairedMacAddress() // clearPairedMacAddress()
#if canImport(AccessorySetupKit) && swift(>=5.9) // #if canImport(AccessorySetupKit) && swift(>=5.9)
if #available(iOS 18.0, *) { // if #available(iOS 18.0, *) {
resetSession() // resetSession()
initializeSession() // initializeSession()
} // }
#endif // #endif
} // }
// MARK: - 配对设备UUID管理 // // MARK: - 配对设备UUID管理
/// 获取已配对设备的UUID字符串 // /// 获取已配对设备的UUID字符串
/// 返回存储的配对设备UUID,如果没有则返回nil // /// 返回存储的配对设备UUID,如果没有则返回nil
func getPairedMacAddress() -> String? { // func getPairedMacAddress() -> String? {
let uuidString = UserDefaults.standard.string(forKey: pairedDeviceUUIDKey) // let uuidString = UserDefaults.standard.string(forKey: pairedDeviceUUIDKey)
if let uuidString = uuidString, !uuidString.isEmpty { // if let uuidString = uuidString, !uuidString.isEmpty {
os_log("获取已配对设备UUID: %{public}@", log: logger, type: .info, uuidString) // os_log("获取已配对设备UUID: %{public}@", log: logger, type: .info, uuidString)
return uuidString // return uuidString
} // }
os_log("未找到已配对设备UUID", log: logger, type: .info) // os_log("未找到已配对设备UUID", log: logger, type: .info)
return nil // return nil
} // }
/// 保存配对设备的UUID // /// 保存配对设备的UUID
/// - Parameter uuid: 要保存的设备UUID // /// - Parameter uuid: 要保存的设备UUID
func savePairedMacAddress(_ uuid: UUID) { // func savePairedMacAddress(_ uuid: UUID) {
let uuidString = uuid.uuidString // let uuidString = uuid.uuidString
UserDefaults.standard.set(uuidString, forKey: pairedDeviceUUIDKey) // UserDefaults.standard.set(uuidString, forKey: pairedDeviceUUIDKey)
UserDefaults.standard.synchronize() // UserDefaults.standard.synchronize()
os_log("已保存配对设备UUID: %{public}@", log: logger, type: .info, uuidString) // os_log("已保存配对设备UUID: %{public}@", log: logger, type: .info, uuidString)
} // }
/// 清除已保存的配对设备UUID // /// 清除已保存的配对设备UUID
private func clearPairedMacAddress() { // private func clearPairedMacAddress() {
UserDefaults.standard.removeObject(forKey: pairedDeviceUUIDKey) // UserDefaults.standard.removeObject(forKey: pairedDeviceUUIDKey)
UserDefaults.standard.synchronize() // UserDefaults.standard.synchronize()
os_log("已清除配对设备UUID", log: logger, type: .info) // 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 os.log
import AVFoundation import AVFoundation
/// BLE服务类 - 专为AccessorySetupKit设备设计,支持自动重连和状态恢复 /// BLE服务类 - 传统BLE扫描和连接实现
/// ///
/// 核心特性: /// 核心特性:
/// 1. 外设掉电→上电后自动重连(通过pendingReconnectUUIDs队列) /// 1. 传统BLE扫描和连接逻辑
/// 2. App被系统终止后,外设广播或特征通知时自动恢复连接 /// 2. 外设掉电→上电后自动重连(通过pendingReconnectUUIDs队列)
/// 3. 使用CBCentralManagerOptionRestoreIdentifierKey实现系统级状态保留 /// 3. App被系统终止后,外设广播或特征通知时自动恢复连接
/// 4. 支持bluetooth-central后台模式,允许后台BLE操作 /// 4. 使用CBCentralManagerOptionRestoreIdentifierKey实现系统级状态保留
/// 5. willRestoreState恢复系统保留的外设、扫描条件 /// 5. 支持bluetooth-central后台模式,允许后台BLE操作
/// 6. 统一的重连队列管理,适配AccessorySetupKit工作流程 /// 6. willRestoreState恢复系统保留的外设、扫描条件
/// 7. 统一的重连队列管理
@available(iOS 13.0, *) @available(iOS 13.0, *)
public class BleService: NSObject { public class BleService: NSObject {
// MARK: - 单例实现 // MARK: - 单例实现
@ -43,6 +44,15 @@ public class BleService: NSObject {
private var audioCharacteristic: CBCharacteristic? private var audioCharacteristic: CBCharacteristic?
private var isScanning = false 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模式 // 是否是ota模式
var isEnterOta = false var isEnterOta = false
@ -50,8 +60,6 @@ public class BleService: NSObject {
private var callbacks = [Callback]() private var callbacks = [Callback]()
private var connectionState = BleConst.STATE_DISCONNECTED private var connectionState = BleConst.STATE_DISCONNECTED
// 状态恢复相关 // 状态恢复相关
private var restoredPeripherals: [CBPeripheral] = [] private var restoredPeripherals: [CBPeripheral] = []
@ -62,26 +70,28 @@ public class BleService: NSObject {
// MARK: - Opus音频处理器 // MARK: - Opus音频处理器
private var opusProcessor: SwiftOpusAudioProcessor? 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: - 初始化 // MARK: - 初始化
private override init() { private override init() {
super.init() super.init()
#if canImport(AccessorySetupKit) && swift(>=5.9)
if #available(iOS 18.0, *) {
// iOS 18+下不立即初始化,等待AccessorySetupKit
} else {
initializeCentralManager()
}
#else
initializeCentralManager() initializeCentralManager()
#endif
// 初始化Opus音频处理器
initializeOpusProcessor() initializeOpusProcessor()
} }
// 延迟初始化中央管理器 // 初始化中央管理器
private func initializeCentralManager() { private func initializeCentralManager() {
if centralManager != nil { if centralManager != nil {
os_log("🔧 CBCentralManager已存在,跳过初始化", log: logger, type: .debug) os_log("🔧 CBCentralManager已存在,跳过初始化", log: logger, type: .debug)
@ -169,35 +179,70 @@ public class BleService: NSObject {
return centralManager! return centralManager!
} }
func getPairedMacAddress() -> String? {
let uuidString = UserDefaults.standard.string(forKey: pairedDeviceUUIDKey)
if let uuidString = uuidString, !uuidString.isEmpty {
os_log("获取已配对设备UUID: %{public}@", log: logger, type: .info, uuidString)
return uuidString
}
os_log("未找到已配对设备UUID", log: logger, type: .info)
return nil
}
/// 保存配对设备的UUID
/// - Parameter uuid: 要保存的设备UUID
func savePairedMacAddress(_ uuid: UUID) {
let uuidString = uuid.uuidString
UserDefaults.standard.set(uuidString, forKey: pairedDeviceUUIDKey)
UserDefaults.standard.synchronize()
os_log("已保存配对设备UUID: %{public}@", log: logger, type: .info, uuidString)
}
/// 清除已保存的配对设备UUID
private func clearPairedMacAddress() {
UserDefaults.standard.removeObject(forKey: pairedDeviceUUIDKey)
UserDefaults.standard.synchronize()
os_log("已清除配对设备UUID", log: logger, type: .info)
}
// MARK: - 清除配对关系
func clearAssociations() {
resetBluetoothState()
// 清除保存的配对设备UUID
clearPairedMacAddress()
}
/// 开始扫描BLE设备 /// 开始扫描BLE设备
public func startScan() { public func startScan(timeout: TimeInterval = 30.0) -> Bool {
#if canImport(AccessorySetupKit) && swift(>=5.9)
if #available(iOS 18.0, *) {
// iOS 18+不使用传统扫描
return
}
#endif
if centralManager == nil { if centralManager == nil {
initializeCentralManager() initializeCentralManager()
} }
guard let manager = centralManager, manager.state == .poweredOn else { guard let manager = centralManager, manager.state == .poweredOn else {
return os_log("❌ 蓝牙未就绪,无法开始扫描", log: logger, type: .error)
return false
} }
if isScanning { if isScanning {
stopScan() return true
} }
// 清空之前的扫描结果
discoveredDevices.removeAll()
deviceAdvertisementData.removeAll()
isScanning = true isScanning = true
let serviceUUIDs = [BleConst.PRIMARY_SERVICE_UUID, BleConst.AUDIO_SERVICE_UUID] // 移除服务UUID过滤,扫描所有设备
manager.scanForPeripherals(withServices: serviceUUIDs, options: [ manager.scanForPeripherals(withServices: nil, options: [
CBCentralManagerScanOptionAllowDuplicatesKey: false 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,10 +250,37 @@ public class BleService: NSObject {
if isScanning && centralManager != nil { if isScanning && centralManager != nil {
centralManager?.stopScan() centralManager?.stopScan()
isScanning = false 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
}
/// 连接到指定设备 /// 连接到指定设备
public func connect(to peripheral: CBPeripheral) { public func connect(to peripheral: CBPeripheral) {
connectPeripheral(peripheral) connectPeripheral(peripheral)
@ -231,18 +303,18 @@ public class BleService: NSObject {
} }
if peripheral.state == .connected { 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) resumeSession(for: peripheral)
return return
} }
if peripheral.state == .connecting { if peripheral.state == .connecting {
os_log("设备正在连接中: %@", log: logger, type: .info, peripheral.identifier.uuidString) os_log("📱 设备正在连接中: %@", log: logger, type: .info, peripheral.identifier.uuidString)
return return
} }
if connectionState == BleConst.STATE_CONNECTED && peripheralDevice?.identifier != peripheral.identifier { if connectionState == BleConst.STATE_CONNECTED && peripheralDevice?.identifier != peripheral.identifier {
os_log("断开当前设备,连接新设备", log: logger, type: .info) os_log("📱 断开当前设备,连接新设备", log: logger, type: .info)
disconnect() disconnect()
} }
@ -257,7 +329,7 @@ public class BleService: NSObject {
CBConnectPeripheralOptionNotifyOnNotificationKey: true 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) os_log("🔍 恢复会话时重新发现服务和特征", log: logger, type: .info)
peripheral.discoverServices([BleConst.PRIMARY_SERVICE_UUID, BleConst.AUDIO_SERVICE_UUID]) 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 { if centralManager != nil {
updateConnectionState(BleConst.STATE_DISCONNECTING) updateConnectionState(BleConst.STATE_DISCONNECTING)
centralManager?.cancelPeripheralConnection(peripheral) 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 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: - 回调通知方法 // MARK: - 回调通知方法
//清除蓝牙状态(仅用于测试或重置,不会清除系统授权)
func resetBluetoothState() {
}
/// 通知所有回调扫描结果 /// 通知所有回调扫描结果
private func notifyScanResult(devices: [[String: Any]]) { private func notifyScanResult(devices: [[String: Any]]) {
for callback in callbacks { for callback in callbacks {
@ -513,8 +663,20 @@ public class BleService: NSObject {
os_log("发送命令: CMD=0x%02X, 长度=%d, 数据=[%@], CRC=0x%02X", os_log("发送命令: CMD=0x%02X, 长度=%d, 数据=[%@], CRC=0x%02X",
log: logger, type: .debug, cmd, cmdLength, dataHexString, crc) log: logger, type: .debug, cmd, cmdLength, dataHexString, crc)
// 加入队列并触发发送
if isReply{
peripheralDevice?.writeValue(cmdPacket, for: characteristic, type: .withoutResponse)
isReply = false;
return true
} else {
commandQueue.append(cmdPacket)
}
// 发送数据(使用无响应写入,与Android端保持一致) // 发送数据(使用无响应写入,与Android端保持一致)
peripheralDevice?.writeValue(cmdPacket, for: characteristic, type: .withoutResponse)
return true return true
} catch { } catch {
os_log("发送命令异常: %@", log: logger, type: .error, error.localizedDescription) os_log("发送命令异常: %@", log: logger, type: .error, error.localizedDescription)
@ -554,6 +716,11 @@ public class BleService: NSObject {
/// 处理接收到的响应数据 /// 处理接收到的响应数据
private func handleResponseData(_ data: Data) { 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 { guard data.count >= 4 else {
os_log("响应数据格式错误:数据长度过短", log: logger, type: .error) os_log("响应数据格式错误:数据长度过短", log: logger, type: .error)
return return
@ -595,6 +762,12 @@ public class BleService: NSObject {
// 提取响应数据 // 提取响应数据
let responseData = length > 0 ? data.subdata(in: 3..<(3 + Int(length))) : Data() 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 { 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 // MARK: - SwiftOpusAudioProcessor.AudioDataCallback
@ -943,7 +1098,7 @@ extension BleService: CBCentralManagerDelegate {
case .poweredOn: case .poweredOn:
os_log("🔧 蓝牙已开启", log: logger, type: .info) os_log("🔧 蓝牙已开启", log: logger, type: .info)
// 注:AccessorySetupKit应用不需要恢复传统BLE扫描 // 蓝牙就绪后可以开始扫描
// 恢复外设连接 // 恢复外设连接
if !restoredPeripherals.isEmpty { if !restoredPeripherals.isEmpty {
@ -1040,26 +1195,118 @@ extension BleService: CBCentralManagerDelegate {
} }
} }
// 原有方法
public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { 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] = [ var deviceInfo: [String: Any] = [
"name": peripheral.name ?? "Unknown", "id": deviceId,
"id": peripheral.identifier.uuidString, "name": deviceName,
"rssi": RSSI.intValue "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 { if let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data {
deviceInfo["manufacturerData"] = manufacturerData allData.append(manufacturerData)
} }
if let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] { if let serviceData = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] {
deviceInfo["serviceUUIDs"] = serviceUUIDs.map { $0.uuidString } for (_, data) in serviceData {
allData.append(data)
}
} }
// 报告给回调 return allData.isEmpty ? nil : allData.map { String(format: "%02X", $0) }.joined()
notifyScanResult(devices: [deviceInfo]) }
// 数据清理方法
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) { public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {

220
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? internal var statusEventSink: FlutterEventSink?
private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleServicePlugin") private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleServicePlugin")
// 标记是否已初始化所有服务
private var servicesInitialized = false
// MARK: - 初始化方法 // MARK: - 初始化方法
init(_ channel: FlutterMethodChannel) { init(_ channel: FlutterMethodChannel) {
self.methodChannel = channel self.methodChannel = channel
super.init() super.init()
#if canImport(AccessorySetupKit) && swift(>=5.9) // 直接初始化BLE服务
if #available(iOS 18.0, *) { initializeBleService()
// 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
} }
// 初始化BLE服务
// 确保所有服务都已初始化 private func initializeBleService() {
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
}
}
// 初始化所有服务(iOS 17及以下版本的标准行为)
private func initializeAllServices() {
// 设置BLE服务回调 // 设置BLE服务回调
BleService.shared.addCallback(self) BleService.shared.addCallback(self)
os_log("✅ BLE服务已初始化", log: logger, type: .info)
// 初始化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
} }
// MARK: - FlutterPlugin Protocol // MARK: - FlutterPlugin Protocol
@ -106,9 +47,6 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin {
} }
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
// 检查是否需要初始化服务
checkAndInitializeServices()
switch call.method { switch call.method {
case "isConnected": case "isConnected":
result(BleService.shared.isConnected()) result(BleService.shared.isConnected())
@ -123,31 +61,6 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin {
details: nil)) 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": case "isBleConnected":
result(BleService.shared.isConnected()) result(BleService.shared.isConnected())
@ -193,43 +106,67 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin {
case "playA2DP": case "playA2DP":
result(BleService.shared.playA2DP()) result(BleService.shared.playA2DP())
// iOS 18+ AccessorySetupKit相关方法 case "startScan":
case "clearAssociations": let arguments = call.arguments as? [String: Any]
// 清除设备配对关系 let timeout = (arguments?["timeout"] as? Double) ?? 30.0
#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
result(true)
#endif
case "startDeviceAssociation": os_log("🔍 开始传统BLE扫描,超时: %.1f秒", log: logger, type: .info, timeout)
// 用户主动触发设备关联流程 BleService.shared.startScan(timeout: timeout)
#if canImport(AccessorySetupKit) && swift(>=5.9) result(true)
if #available(iOS 18.0, *) {
os_log("用户主动触发设备关联流程", log: logger, type: .info)
// 确保服务已初始化 case "stopScan":
ensureServicesInitialized() os_log("⏹️ 停止BLE扫描", log: logger, type: .info)
BleService.shared.stopScan()
result(true)
case "getDeviceList":
let arguments = call.arguments as? [String: Any]
let deviceName = (arguments?["deviceName"] as? String) ?? ""
os_log("🔍 查询设备信息,deviceName: %s", log: logger, type: .info, deviceName)
let info = BleService.shared.getDeviceAdvertisementData(deviceName: deviceName)
result(info)
case "isCompanionSupported":
#if canImport(AccessorySetupKit) && swift(>=5.9)
if #available(iOS 18.0, *) {
result(true)
} else {
result(false)
}
#else
result(false)
#endif
// 重新初始化会话 case "getPairedMacAddress":
BleAccessorySetup.shared.initializeSession() 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":
result(true) let arguments = call.arguments as? [String: Any]
} else { guard let deviceId = arguments?["deviceId"] as? String else {
os_log("当前iOS版本不支持AccessorySetupKit", log: logger, type: .info) result(FlutterError(code: "INVALID_ARGUMENT",
result(false) message: "缺少设备ID参数",
details: nil))
return
} }
#else os_log("🔗 连接到设备: %@", log: logger, type: .info, deviceId)
os_log("当前环境不支持AccessorySetupKit", log: logger, type: .info) let success = BleService.shared.connectToDevice(uuid: deviceId)
result(false) result(success)
#endif case "clearExpiredDevices":
BleService.shared.clearExpiredDevices()
result(true)
case "requestBluetoothPermission":
// 移除这个方法调用,让iOS系统自然处理权限
result(FlutterMethodNotImplemented)
default: default:
result(FlutterMethodNotImplemented) result(FlutterMethodNotImplemented)
@ -237,22 +174,6 @@ public class SwiftBleServicePlugin: NSObject, FlutterPlugin {
} }
// MARK: - 私有方法 // 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) { private func sendEvent<T>(_ eventSink: FlutterEventSink?, data: T, errorPrefix: String) {
if let eventSink = eventSink { if let eventSink = eventSink {
DispatchQueue.main.async { 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 // 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 { try {
final result = await _methodChannel final result = await _methodChannel
.invokeMethod<Map>('getDeviceInfo', {'address': address}); .invokeMethod<Map>('getDeviceInfo', {'deviceName': deviceName});
print('获取设备信息!: $result'); print('获取设备信息!: $result');
if (result != null) { if (result != null) {
return { return {
'name': result['name'] ?? "未知", 'name': result['name'] ?? "未知",
'address': result['address'] ?? "", 'address': result['address'] ?? "",
'id': result['id'] ??
result['address'] ??
"", // iOS 返回 UUID,Android 返回 address
'hex': result['hex'] ?? "", 'hex': result['hex'] ?? "",
}; };
} else { } 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后台扫描 /// 注册BLE后台扫描
/// ///
/// 注册后,即使应用被杀死,系统也会在检测到设备广播时唤醒应用 /// 注册后,即使应用被杀死,系统也会在检测到设备广播时唤醒应用
@ -435,4 +475,16 @@ class BleService {
return false; 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: [ dependencies: [
.package(url: "https://github.com/MacPaw/OpenAI.git", from: "0.4.3"), .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: [ targets: [
.target( .target(
name: "chat_api", name: "chat_api",
dependencies: [ dependencies: [
.product(name: "MCP", package: "swift-sdk"), .product(name: "MCP", package: "swift-sdk"),
.product(name: "OpenAI", package: "OpenAI") .product(name: "OpenAI", package: "OpenAI"),
.product(name: "location-service", package: "location_service")
], ],
exclude: [] 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() let instance = ChatApiPlugin()
registrar.addMethodCallDelegate(instance, channel: methodChannel) registrar.addMethodCallDelegate(instance, channel: methodChannel)
eventChannel.setStreamHandler(instance) // eventChannel.setStreamHandler(instance)
} }
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method { switch call.method {
case "initialize": case "initialize":
handleInitialize(call: call, result: result) handleInitialize(call: call, result: result)
case "createUserMessage": // case "createUserMessage":
handleCreateUserMessage(call: call, result: result) // handleCreateUserMessage(call: call, result: result)
case "createAssistantMessage": // case "createAssistantMessage":
handleCreateAssistantMessage(call: call, result: result) // handleCreateAssistantMessage(call: call, result: result)
case "sendMessage": // case "sendMessage":
handleSendMessage(call: call, result: result) // handleSendMessage(call: call, result: result)
case "sendMessageStream": // case "sendMessageStream":
handleSendMessageStream(call: call, result: result) // handleSendMessageStream(call: call, result: result)
case "sendFunctionCallResult": // case "sendFunctionCallResult":
handleSendFunctionCallResult(call: call, result: result) // handleSendFunctionCallResult(call: call, result: result)
case "cancelCurrentStream": // case "cancelCurrentStream":
handleCancelCurrentStream(result: result) // handleCancelCurrentStream(result: result)
case "registerFunction": case "registerFunction":
handleRegisterFunction(call: call, result: result) handleRegisterFunction(call: call, result: result)
case "initializeMcpClient": case "initializeMcpClient":
@ -63,14 +63,16 @@ public class ChatApiPlugin: NSObject, FlutterPlugin {
let baseUrl = args["baseUrl"] as? String ?? "" let baseUrl = args["baseUrl"] as? String ?? ""
let model = args["model"] as? String ?? "" let model = args["model"] as? String ?? ""
let visionModel = args["modelmedia"] as? String ?? ""
let mcpServer = args["mcpServer"] as? String ?? "" let mcpServer = args["mcpServer"] as? String ?? ""
service.setStreamCallback(StreamCallbackImpl(eventSink: eventSink)) // service.setStreamCallback(StreamCallbackImpl(eventSink: eventSink))
let success = service.initialize( let success = service.initialize(
apiKey: apiKey, apiKey: apiKey,
baseUrl: baseUrl, baseUrl: baseUrl,
model: model, model: model,
visionModel:visionModel,
mcpServer: mcpServer mcpServer: mcpServer
) )
@ -120,16 +122,16 @@ public class ChatApiPlugin: NSObject, FlutterPlugin {
} }
} }
private func handleSendMessageStream(call: FlutterMethodCall, result: @escaping FlutterResult) { // private func handleSendMessageStream(call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any], // guard let args = call.arguments as? [String: Any],
let messages = args["messages"] as? [[String: Any]] else { // let messages = args["messages"] as? [[String: Any]] else {
result(FlutterError(code: "INVALID_ARGS", message: "缺少必要参数", details: nil)) // result(FlutterError(code: "INVALID_ARGS", message: "缺少必要参数", details: nil))
return // return
} // }
//
service.sendMessageStream(messages: messages) // service.sendMessageStream(messages: messages)
result(true) // result(true)
} // }
private func handleSendFunctionCallResult(call: FlutterMethodCall, result: @escaping FlutterResult) { private func handleSendFunctionCallResult(call: FlutterMethodCall, result: @escaping FlutterResult) {
result(FlutterError(code: "DEPRECATED", message: "sendFunctionCallResult已废弃,工具调用结果现在自动处理", details: nil)) 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"
])
}
}
}
}

471
local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiService.swift

@ -15,13 +15,14 @@ public struct ChatApiException: Error {
} }
} }
/// 流式回调协议 ///// 流式回调协议
public protocol StreamCallback { public protocol StreamCallback {
func onToken(_ token: String) func onUsage(_ sessionid: String,_ prompt_tokens: Int?,_ completion_tokens: Int?,_ total_tokens: Int?)
func onComplete() func onToken(_ sessionId:String,_ token: String)
func onError(_ error: Error) func onComplete(_ sessionId:String)
func onFunctionCall(_ functionCall: [String: Any]) func onError(_ sessionId:String,_ error: Error)
func onFunctionCallResult(_ functionCall: [String: Any], _ functionCallResult: [String: Any]) 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>? private var currentStreamTask: Task<Void, Never>?
var streamCallback: StreamCallback? private var sessionCallbacks: [String: StreamCallback] = [:]
private var currentMessages: [[String: Any]] = [] private var currentMessages: [[String: Any]] = []
private var toolCalls: [Int: ToolCallInfo] = [:] private var toolCalls: [Int: ToolCallInfo] = [:]
private var isCanceled = false private var currSessionId = ""
// MARK: - 初始化 // MARK: - 初始化
public override init() { public override init() {
super.init() super.init()
@ -67,12 +67,16 @@ public class ChatApiService: NSObject {
// MARK: - 公共方法 // MARK: - 公共方法
/// 设置流式回调 /// 设置流式回调
public func setStreamCallback(_ callback: StreamCallback) { public func setStreamCallback(_ sessionId:String,_ callback: StreamCallback) {
self.streamCallback = callback self.sessionCallbacks[sessionId] = callback
}
public func getSessionCallback(_ sessionId:String)->StreamCallback?{
return self.sessionCallbacks[sessionId]
} }
/// 初始化ChatAPI服务 /// 初始化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 self.apiKey = apiKey
if !baseUrl.isEmpty { if !baseUrl.isEmpty {
self.baseUrl = baseUrl self.baseUrl = baseUrl
@ -80,7 +84,9 @@ public class ChatApiService: NSObject {
if !model.isEmpty { if !model.isEmpty {
self.model = model self.model = model
} }
if !visionModel.isEmpty {
self.visionModel = visionModel
}
// 分段解析baseUrl为OpenAI库需要的格式 // 分段解析baseUrl为OpenAI库需要的格式
guard let url = URL(string: self.baseUrl) else { guard let url = URL(string: self.baseUrl) else {
return false return false
@ -246,191 +252,321 @@ public class ChatApiService: NSObject {
} }
/// 发送消息(流式输出) /// 发送消息(流式输出)
public func sendMessageStream(messages: [[String: Any]]) { // MARK: - 流式请求实现
guard isInitialized && !apiKey.isEmpty, let openAI = openAI else { public func sendMessageStream(_ sessionId: String, messages: [[String: Any]]) {
let error = ChatApiException("ChatAPI服务未初始化") // 1. 立即中止旧会话
streamCallback?.onError(error) abortCurrentSession()
return
} // 2. 创建新会话
currSessionId = sessionId
guard isInitialized && !apiKey.isEmpty, let openAI = openAI else {
let error = ChatApiException("ChatAPI服务未初始化")
getSessionCallback(sessionId)?.onError(sessionId, error)
return
}
// 重置状态 // 保存当前消息和回调
isCanceled = false currentMessages = messages
currentMessages = messages toolCalls = [:]
toolCalls = [:] sessionCallbacks[sessionId] = getSessionCallback(sessionId)
// 检查是否包含图片,决定模型
let currentModel = containsImage(in: messages) ? visionModel : model
currentStreamTask = Task { [weak self] in
guard let self = self else { return }
var replyContext = "" // 收集AI回复内容
do {
// 转换消息格式
let chatMessages = try self.convertToChatMessages(messages)
// 获取工具列表
let tools = self.mcpClient?.getToolMaps() ?? []
// 构建请求
let query: ChatQuery
if !tools.isEmpty {
let chatTools = self.convertToOpenAITools(tools)
query = ChatQuery(
messages: chatMessages,
model: currentModel,
maxTokens: 2000,
temperature: 0.7,
tools: chatTools
)
} else {
query = ChatQuery(
messages: chatMessages,
model: currentModel,
maxTokens: 2000,
temperature: 0.7
)
}
// 检查是否包含图片,决定使用哪个模型 let chatsStream: AsyncThrowingStream<ChatStreamResult, Error> = openAI.chatsStream(query: query)
var currentModel = model
if containsImage(in: messages) {
currentModel = visionModel
}
currentStreamTask = Task { [weak self] in for try await result in chatsStream {
guard let self = self else { return } // 检查会话是否已变更
if sessionId != self.currSessionId {
return
}
do { guard let choice = result.choices.first else { continue }
// 转换消息格式 let delta = choice.delta
let chatMessages = try self.convertToChatMessages(messages)
// 处理文本内容
// 获取MCP工具列表 if let content = delta.content {
let tools = self.mcpClient?.getToolMaps() ?? [] replyContext += content
self.getSessionCallback(sessionId)?.onToken(sessionId, content)
// 构建请求
let query: ChatQuery
if !tools.isEmpty {
// 转换工具格式
let chatTools = self.convertToOpenAITools(tools)
query = ChatQuery(
messages: chatMessages,
model: currentModel,
maxTokens: 2000,
temperature: 0.7,
tools: chatTools
)
} else {
query = ChatQuery(
messages: chatMessages,
model: currentModel,
maxTokens: 2000,
temperature: 0.7
)
} }
let chatsStream: AsyncThrowingStream<ChatStreamResult, Error> = openAI.chatsStream(query: query) // 收集工具调用信息
if let toolCalls = delta.toolCalls {
for toolCall in toolCalls {
let index = toolCall.index
for try await result in chatsStream { // 初始化工具调用信息
if self.isCanceled { if self.toolCalls[index] == nil {
break self.toolCalls[index] = ToolCallInfo()
} }
if let choice = result.choices.first { guard let toolCallInfo = self.toolCalls[index] else { continue }
let delta = choice.delta
// 处理普通文本内容 // 更新ID
if let content = delta.content { if let id = toolCall.id {
self.streamCallback?.onToken(content) toolCallInfo.id = id
} }
// 收集工具调用信息 // 更新函数信息
if let toolCalls = delta.toolCalls { if let function = toolCall.function {
for toolCall in toolCalls { if let name = function.name {
let index = toolCall.index toolCallInfo.name = name
}
// 创建或获取现有的工具调用信息
if self.toolCalls[index] == nil { if let arguments = function.arguments {
self.toolCalls[index] = ToolCallInfo() toolCallInfo.arguments += arguments
}
let toolCallInfo = self.toolCalls[index]!
// 更新ID
if let id = toolCall.id {
toolCallInfo.id = id
}
// 更新函数信息
if let function = toolCall.function {
if let name = function.name {
toolCallInfo.name = name
}
if let arguments = function.arguments {
toolCallInfo.arguments += arguments
}
}
} }
} }
} }
} }
}
if !self.isCanceled { // 流式响应结束
// 处理工具调用或完成 if sessionId == self.currSessionId {
let hasToolCalls = await self.processToolCalls() print("发送消息(流式输出)\(replyContext)")
if !hasToolCalls { // 计算token使用量
self.streamCallback?.onComplete() 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.getSessionCallback(sessionId)?.onComplete(sessionId)
self.clearCurrentSession()
} }
}
} catch { } catch {
if !self.isCanceled { if sessionId == self.currSessionId {
let chatApiError = ChatApiException("流式请求失败: \(error.localizedDescription)") let errorMessage = "流式请求失败: \(error.localizedDescription)"
self.streamCallback?.onError(chatApiError) 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 {
return false // 验证工具调用集合不为空
} if toolCalls.isEmpty {
print("[Session: \(sessionId)] 没有工具调用需要处理")
return false
}
// 创建函数调用字典 // 输出所有收集到的工具调用信息(用于调试)
let functionCall: [String: Any] = [ print("[Session: \(sessionId)] 收集到 \(toolCalls.count) 个工具调用")
"name": firstToolCall.name, for (index, toolCall) in toolCalls {
"arguments": firstToolCall.arguments, print("[Session: \(sessionId)] 工具调用[\(index)]: name=\(toolCall.name), args.length=\(toolCall.arguments.count), args=\(toolCall.arguments.prefix(100))...")
"id": firstToolCall.id }
]
// 通知上层工具调用事件 // 只处理有效的工具调用
streamCallback?.onFunctionCall(functionCall) let validToolCalls = toolCalls.values.filter { $0.isValid() }
if validToolCalls.isEmpty {
print("[Session: \(sessionId)] 没有有效的工具调用(参数可能不完整)")
return false
}
// 在后台队列处理工具调用 // 处理第一个有效的工具调用
Task { let firstToolCall = validToolCalls.first!
do { print("[Session: \(sessionId)] 处理工具调用: \(firstToolCall.name),参数: \(firstToolCall.arguments)")
if !self.isCanceled {
// 调用工具 // 创建函数调用字典
let result: [String: Any] let functionCall: [String: Any] = [
let args = try self.parseJsonArguments(firstToolCall.arguments) "name": firstToolCall.name,
if let client = self.mcpClient { "arguments": firstToolCall.arguments,
result = await client.callTool(name: firstToolCall.name, arguments: args) ?? ["context": "工具调用失败"] "id": firstToolCall.id
]
// 转换为JSON对象格式
let jsonFunctionCall = convertMapToJsonObject(functionCall)
getSessionCallback(sessionId)?.onFunctionCall(sessionId, jsonFunctionCall)
// 通知上层工具调用事件
// getSessionCallback(sessionId)?.onFunctionCall(sessionId, functionCall)
// 在后台处理工具调用
Task { [weak self] in
guard let self = self else { return }
do {
if sessionId == self.currSessionId {
// 解析参数
let args = try self.parseJsonArguments(firstToolCall.arguments)
// 通过MCP客户端处理工具调用
var result: [String: Any] = [:]
if let client = self.mcpClient {
// 调用MCP工具
if let toolResult = await client.callTool(name: firstToolCall.name, arguments: args) {
print("[Session: \(sessionId)] AI调用工具结果: \(firstToolCall.name), 参数: \(args), 结果: \(toolResult)")
// 统一结果格式
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 { } 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) self.getSessionCallback(sessionId)?.onFunctionCallResult(
sessionId,
// 将result转换为JSON字符串 functionCall,
let context: String result
do { )
let jsonData = try JSONSerialization.data(withJSONObject: result, options: .prettyPrinted)
context = String(data: jsonData, encoding: .utf8) ?? "工具调用失败"
} catch {
context = "工具调用失败"
}
// 将结果发送回OpenAI继续对话 // 将结果发送回OpenAI继续对话
await self.sendFunctionCallResultInternal(
messages: self.currentMessages,
functionCall: functionCall,
functionResult: context
)
}
}
} catch {
if !self.isCanceled {
let errorMessage = "工具调用处理失败: \(error.localizedDescription)"
await self.sendFunctionCallResultInternal( await self.sendFunctionCallResultInternal(
sessionId: sessionId,
messages: self.currentMessages, messages: self.currentMessages,
functionCall: functionCall, functionCall: functionCall,
functionResult: errorMessage functionResult: self.jsonToString(result) ?? "{}"
) )
} }
} }
} catch {
print("[Session: \(sessionId)] 工具调用处理过程中出错: \(error.localizedDescription)")
if sessionId == self.currSessionId {
let errorMessage = "工具调用处理失败: \(error.localizedDescription)"
await self.sendFunctionCallResultInternal(
sessionId: sessionId,
messages: self.currentMessages,
functionCall: functionCall,
functionResult: errorMessage
)
}
} }
}
return true return true
}
/// 辅助方法:将字典转换为JSON字符串
private func jsonToString(_ json: [String: Any]) -> String? {
do {
let jsonData = try JSONSerialization.data(withJSONObject: json, options: [])
return String(data: jsonData, encoding: .utf8)
} catch {
print("JSON序列化失败: \(error)")
return nil
}
}
// MARK: - JSON转换工具
private func convertMapToJsonObject(_ map: [String: Any]) -> [String: Any] {
var result = [String: Any]()
for (key, value) in map {
if let nestedMap = value as? [String: Any] {
result[key] = convertMapToJsonObject(nestedMap)
} else if let array = value as? [Any] {
var jsonArray = [Any]()
for item in array {
if let itemMap = item as? [String: Any] {
jsonArray.append(convertMapToJsonObject(itemMap))
} else {
jsonArray.append(item)
}
}
result[key] = jsonArray
} else {
result[key] = value
}
} }
return result
}
/// 发送函数调用结果(内部方法) /// 发送函数调用结果(内部方法)
private func sendFunctionCallResultInternal( private func sendFunctionCallResultInternal(
sessionId:String,
messages: [[String: Any]], messages: [[String: Any]],
functionCall: [String: Any], functionCall: [String: Any],
functionResult: String functionResult: String
) async { ) async {
if isCanceled { if sessionId != self.currSessionId {
return return
} }
@ -459,12 +595,12 @@ public class ChatApiService: NSObject {
]) ])
// 重新发起对话,让AI基于工具调用结果生成回复 // 重新发起对话,让AI基于工具调用结果生成回复
sendMessageStream(messages: fullMessages) sendMessageStream(sessionId,messages: fullMessages)
} }
/// 取消当前流式请求 /// 取消当前流式请求
public func cancelCurrentStream() -> Bool { public func cancelCurrentStream() -> Bool {
isCanceled = true currSessionId = ""
currentStreamTask?.cancel() currentStreamTask?.cancel()
currentStreamTask = nil currentStreamTask = nil
return true return true
@ -543,6 +679,35 @@ public class ChatApiService: NSObject {
return result?["context"] as? String ?? "无法处理MCP工具调用" 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: - 工具方法 // MARK: - 工具方法
/// 解析JSON参数 /// 解析JSON参数

252
local_plugins/chat_api/ios/chat_api/Sources/chat_api/SystemFunctionHandler.swift

@ -1,12 +1,11 @@
import Foundation import Foundation
import UIKit import UIKit
import Contacts
import EventKit import EventKit
import CoreLocation import CoreLocation
import MediaPlayer import MessageUI
import AVFoundation import location_service
/// 操作结果 /// 结果结构体
public struct Result { public struct Result {
let success: Bool let success: Bool
let message: String let message: String
@ -20,26 +19,14 @@ public struct Result {
/// 系统功能处理器 /// 系统功能处理器
public class SystemFunctionHandler: NSObject { public class SystemFunctionHandler: NSObject {
// 位置管理器
private var locationManager: CLLocationManager?
private var locationCompletion: ((Result) -> Void)?
// 事件存储 // 事件存储
private var eventStore: EKEventStore? private var eventStore: EKEventStore?
public override init() { public override init() {
super.init() super.init()
setupLocationManager()
setupEventStore() setupEventStore()
} }
/// 设置位置管理器
private func setupLocationManager() {
locationManager = CLLocationManager()
locationManager?.delegate = self
locationManager?.desiredAccuracy = kCLLocationAccuracyBest
}
/// 设置事件存储 /// 设置事件存储
private func setupEventStore() { private func setupEventStore() {
eventStore = EKEventStore() eventStore = EKEventStore()
@ -67,7 +54,7 @@ public class SystemFunctionHandler: NSObject {
registerGetCurrentTimeFunction(client: client) registerGetCurrentTimeFunction(client: client)
// 注册获取当前位置函数 // 注册获取当前位置函数
// registerGetCurrentLocationFunction(client: client) registerGetCurrentLocationFunction(client: client)
// 注册媒体控制函数 // 注册媒体控制函数
registerMediaPlayFunction(client: client) registerMediaPlayFunction(client: client)
@ -112,8 +99,8 @@ public class SystemFunctionHandler: NSObject {
let handler = TranslationModeHandler() let handler = TranslationModeHandler()
_ = client.registerLocalFunction( _ = client.registerLocalFunction(
name: "enter_translation_mode", name: "translation_mode",
description: "用户请求进入实时翻译模式时,启动实时翻译功能", description: "进入翻译模式",
parameters: parameters, parameters: parameters,
handler: handler handler: handler
) )
@ -130,7 +117,7 @@ public class SystemFunctionHandler: NSObject {
], ],
"message": [ "message": [
"type": "string", "type": "string",
"description": "短信内容" "description": "要发送的短信内容"
] ]
], ],
"required": ["contact", "message"] "required": ["contact", "message"]
@ -182,7 +169,7 @@ public class SystemFunctionHandler: NSObject {
], ],
"time": [ "time": [
"type": "string", "type": "string",
"description": "提醒时间,格式为'yyyy-MM-dd HH:mm',如'2023-12-31 14:30'" "description": "提醒时间,格式:yyyy-MM-dd HH:mm"
] ]
], ],
"required": ["title", "time"] "required": ["title", "time"]
@ -191,7 +178,7 @@ public class SystemFunctionHandler: NSObject {
let handler = SetReminderHandler(eventStore: eventStore) let handler = SetReminderHandler(eventStore: eventStore)
_ = client.registerLocalFunction( _ = client.registerLocalFunction(
name: "set_reminder", name: "set_reminder",
description: "设置提醒事项", description: "设置提醒",
parameters: parameters, parameters: parameters,
handler: handler handler: handler
) )
@ -204,7 +191,7 @@ public class SystemFunctionHandler: NSObject {
"properties": [ "properties": [
"format": [ "format": [
"type": "string", "type": "string",
"description": "时间格式,可选,默认为标准格式" "description": "时间格式,可选"
] ]
], ],
"required": [] "required": []
@ -213,7 +200,7 @@ public class SystemFunctionHandler: NSObject {
let handler = GetCurrentTimeHandler() let handler = GetCurrentTimeHandler()
_ = client.registerLocalFunction( _ = client.registerLocalFunction(
name: "get_current_time", name: "get_current_time",
description: "获取当前日期和时间", description: "获取当前时间",
parameters: parameters, parameters: parameters,
handler: handler handler: handler
) )
@ -227,16 +214,16 @@ public class SystemFunctionHandler: NSObject {
"required": [] "required": []
] ]
let handler = GetCurrentLocationHandler(systemHandler: self) let handler = GetCurrentLocationHandler()
_ = client.registerLocalFunction( _ = client.registerLocalFunction(
name: "get_current_location", name: "get_current_location",
description: "获取当前地理位置", description: "获取当前位置",
parameters: parameters, parameters: parameters,
handler: handler handler: handler
) )
} }
/// 注册媒体播放功能 /// 注册媒体播放函数
private func registerMediaPlayFunction(client: MCPClient) { private func registerMediaPlayFunction(client: MCPClient) {
let parameters: [String: Any] = [ let parameters: [String: Any] = [
"type": "object", "type": "object",
@ -253,7 +240,7 @@ public class SystemFunctionHandler: NSObject {
) )
} }
/// 注册媒体暂停功能 /// 注册媒体暂停函数
private func registerMediaPauseFunction(client: MCPClient) { private func registerMediaPauseFunction(client: MCPClient) {
let parameters: [String: Any] = [ let parameters: [String: Any] = [
"type": "object", "type": "object",
@ -270,7 +257,7 @@ public class SystemFunctionHandler: NSObject {
) )
} }
/// 注册媒体上一首功能 /// 注册媒体上一首函数
private func registerMediaPreviousFunction(client: MCPClient) { private func registerMediaPreviousFunction(client: MCPClient) {
let parameters: [String: Any] = [ let parameters: [String: Any] = [
"type": "object", "type": "object",
@ -287,7 +274,7 @@ public class SystemFunctionHandler: NSObject {
) )
} }
/// 注册媒体下一首功能 /// 注册媒体下一首函数
private func registerMediaNextFunction(client: MCPClient) { private func registerMediaNextFunction(client: MCPClient) {
let parameters: [String: Any] = [ let parameters: [String: Any] = [
"type": "object", "type": "object",
@ -304,7 +291,7 @@ public class SystemFunctionHandler: NSObject {
) )
} }
/// 注册打开录音机功能 /// 注册打开录音机函数
private func registerOpenRecorderFunction(client: MCPClient) { private func registerOpenRecorderFunction(client: MCPClient) {
let parameters: [String: Any] = [ let parameters: [String: Any] = [
"type": "object", "type": "object",
@ -315,122 +302,72 @@ public class SystemFunctionHandler: NSObject {
let handler = OpenRecorderHandler() let handler = OpenRecorderHandler()
_ = client.registerLocalFunction( _ = client.registerLocalFunction(
name: "open_recorder", name: "open_recorder",
description: "打开系统录音机并开始录音", description: "打开录音机",
parameters: parameters, parameters: parameters,
handler: handler handler: handler
) )
} }
}
// MARK: - 内部方法 // 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
}
// 确保locationManager已初始化
guard let locationManager = locationManager else {
locationCompletion?(Result(success: false, message: "位置管理器未初始化"))
locationCompletion = nil
return
}
// 检查位置权限
let authStatus: CLAuthorizationStatus
if #available(iOS 14.0, *) {
authStatus = locationManager.authorizationStatus
} else {
authStatus = CLLocationManager.authorizationStatus()
}
print("当前位置权限状态: \(authStatus.rawValue)") /// 获取当前位置处理器
private class GetCurrentLocationHandler: FunctionHandler {
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 func handle(arguments: [String: Any]) async throws -> String {
extension SystemFunctionHandler: CLLocationManagerDelegate { // 检查位置权限
public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard LocationService.shared.hasLocationPermission() else {
guard let location = locations.last else { let errorResult: [String: Any] = [
locationCompletion?(Result(success: false, message: "无法获取位置信息")) "success": false,
return "message": "位置权限被拒绝。请前往 设置 > 隐私与安全 > 定位服务 > Deep Voice 启用定位权限"
]
let jsonData = try JSONSerialization.data(withJSONObject: errorResult)
return String(data: jsonData, encoding: .utf8) ?? "{\"error\": \"JSON序列化失败\"}"
} }
let latitude = location.coordinate.latitude // 先尝试获取缓存的位置数据
let longitude = location.coordinate.longitude if let cachedLocation = LocationService.shared.getCachedLocationData() {
let message = "当前位置:纬度 \(latitude),经度 \(longitude)" let message = "当前位置:\(cachedLocation.address ?? "未知地址")(纬度 \(cachedLocation.latitude),经度 \(cachedLocation.longitude))"
locationCompletion?(Result(success: true, message: message)) // 异步触发最新定位(不等待结果)
locationCompletion = nil Task {
} do {
_ = try await LocationService.shared.getCurrentLocation()
public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { } catch {
locationCompletion?(Result(success: false, message: "获取位置失败:\(error.localizedDescription)")) print("后台更新位置失败: \(error)")
locationCompletion = nil }
} }
public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { let result: [String: Any] = [
// 只有在有位置请求等待时才处理权限变化 "success": true,
guard locationCompletion != nil else { "message": message
return ]
let jsonData = try JSONSerialization.data(withJSONObject: result)
return String(data: jsonData, encoding: .utf8) ?? "{\"error\": \"JSON序列化失败\"}"
} }
switch status { // 如果没有缓存数据,同步获取位置
case .authorizedWhenInUse, .authorizedAlways: do {
manager.requestLocation() let locationData = try await LocationService.shared.getCurrentLocation()
case .denied, .restricted: let message = "当前位置:\(locationData.address ?? "未知地址")(纬度 \(locationData.latitude),经度 \(locationData.longitude))"
locationCompletion?(Result(success: false, message: "位置权限被拒绝。请前往 设置 > 隐私与安全 > 定位服务 > Deep Voice 启用定位权限"))
locationCompletion = nil let result: [String: Any] = [
case .notDetermined: "success": true,
// 权限仍未确定,等待用户操作 "message": message
break ]
@unknown default: let jsonData = try JSONSerialization.data(withJSONObject: result)
locationCompletion?(Result(success: false, message: "位置权限状态异常,请重试")) return String(data: jsonData, encoding: .utf8) ?? "{\"error\": \"JSON序列化失败\"}"
locationCompletion = nil } 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 { private class ExitInteractionHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String { 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 { 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 return await withCheckedContinuation { continuation in
DispatchQueue.main.async { DispatchQueue.main.async {
if UIApplication.shared.canOpenURL(url) { if MFMessageComposeViewController.canSendText() {
UIApplication.shared.open(url) { success in let messageVC = MFMessageComposeViewController()
if success { messageVC.recipients = [contact]
continuation.resume(returning: Result(success: true, message: "已打开短信应用")) messageVC.body = message
} else {
continuation.resume(returning: Result(success: false, message: "打开短信应用失败")) if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
} let window = windowScene.windows.first,
let rootViewController = window.rootViewController {
rootViewController.present(messageVC, animated: true)
continuation.resume(returning: Result(success: true, message: "短信界面已打开"))
} else {
continuation.resume(returning: Result(success: false, message: "无法打开短信界面"))
} }
} else { } 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 { private func getCurrentTime(format: String?) -> Result {
let formatter = DateFormatter() let formatter = DateFormatter()
if let format = format, !format.isEmpty { if let customFormat = format, !customFormat.isEmpty {
formatter.dateFormat = format formatter.dateFormat = customFormat
} else { } else {
formatter.dateStyle = .full formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.timeStyle = .full
formatter.locale = Locale(identifier: "zh_CN")
} }
let currentTime = formatter.string(from: Date()) formatter.locale = Locale(identifier: "zh_CN")
return Result(success: true, message: currentTime) let timeString = formatter.string(from: Date())
}
}
/// 获取当前位置处理器
private class GetCurrentLocationHandler: FunctionHandler {
private let systemHandler: SystemFunctionHandler
init(systemHandler: SystemFunctionHandler) {
self.systemHandler = systemHandler
}
func handle(arguments: [String: Any]) async throws -> String {
// guard let systemHandler = systemHandler else {
// throw ChatApiException("系统处理器未初始化")
// }
let result: Result = await systemHandler.getCurrentLocation() return Result(success: true, message: timeString)
return "{\"result\": \"\(result.message)\", \"success\": \(result.success)}"
} }
} }

2
local_plugins/chat_storage/ios/chat_storage/Package.swift

@ -3,7 +3,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "chat_storage", name: "chat_storage",
platforms: [.iOS("18.0")], platforms: [.iOS("16.0")],
products: [ products: [
.library(name: "chat-storage", targets: ["chat_storage"]) .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.author = { 'YunQi Innovation' => 'support@yunqiinnovation.com' }
s.source = { :path => '.' } s.source = { :path => '.' }
s.source_files = 'Classes/**/*' s.source_files = 'Classes/**/*'
s.ios.deployment_target = '18.0' s.ios.deployment_target = '16.0'
s.dependency 'Flutter' s.dependency 'Flutter'
s.swift_version = '5.0' 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 pluginClass: LocationServicePlugin
ios: ios:
pluginClass: LocationServicePlugin 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( let package = Package(
name: "music_service", name: "music_service",
platforms: [.iOS("18.0")], platforms: [.iOS("16.0")],
products: [ products: [
.library(name: "music-service", targets: ["music_service"]) .library(name: "music-service", targets: ["music_service"])
], ],

407
local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift

@ -4,6 +4,7 @@
// //
// Created by 李伟 on 2025/6/4. // Created by 李伟 on 2025/6/4.
// //
import Flutter
import Foundation import Foundation
import AVFoundation import AVFoundation
import MediaPlayer import MediaPlayer
@ -13,17 +14,37 @@ public class MusicService: NSObject {
public static let shared: MusicService = MusicService() public static let shared: MusicService = MusicService()
private var player: AVPlayer? private var player: AVPlayer?
private var playlist: [[String: String]] = [] private var playlist: [[String: Any]] = []
private var currentIndex = 0 private var currentIndex = 0
public var isPlaying = false public var isPlaying = false
private var flutterChannel: FlutterMethodChannel? private var flutterChannel: FlutterMethodChannel?
private var progressTimer: Timer? 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() { private override init() {
super.init() super.init()
setupRemoteCommandCenter() 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? { func getCurrentUrlString() -> String? {
guard let urlAsset = player?.currentItem?.asset as? AVURLAsset else { guard let urlAsset = player?.currentItem?.asset as? AVURLAsset else {
return nil return nil
@ -31,53 +52,149 @@ public class MusicService: NSObject {
return urlAsset.url.absoluteString return urlAsset.url.absoluteString
} }
public func play(song: [String: String]) { func getCurrentId() -> Int64? {
guard let urlString = song["url"], let url = URL(string: urlString) else { return } return currentIdInternal
player = AVPlayer(url: url)
player?.play()
isPlaying = true
updateNowPlayingInfo(song: song)
} }
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 playlist = songs
currentIndex = 0 currentIndex = 0
play(song: playlist[currentIndex]) if !playlist.isEmpty {
play(song: playlist[currentIndex])
}
} }
func pause() { func pause() {
player?.pause() player?.pause()
isPlaying = false isPlaying = false
updatePlaybackState() updatePlaybackState()
updateMediaSessionPlaybackState(isPlaying: false)
stopProgressTimer()
print("已暂停")
} }
func resume() { func resume() {
player?.play() player?.play()
isPlaying = true isPlaying = true
updatePlaybackState() updatePlaybackState()
updateMediaSessionPlaybackState(isPlaying: true)
startProgressTimer()
print("继续播放")
} }
func next() { func next() {
guard currentIndex + 1 < playlist.count else { return } playNext()
currentIndex += 1
play(song: playlist[currentIndex])
} }
func previous() { func previous() {
guard currentIndex - 1 >= 0 else { return } playPrevious()
currentIndex -= 1 }
private func playNext() {
guard !playlist.isEmpty else { return }
currentIndex = (currentIndex + 1) % playlist.count
play(song: playlist[currentIndex]) 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() { func stop() {
player?.pause() player?.pause()
player?.replaceCurrentItem(with: nil) player?.replaceCurrentItem(with: nil)
isPlaying = false isPlaying = false
currentIdInternal = nil
updatePlaybackState() updatePlaybackState()
updateMediaSessionPlaybackState(isPlaying: false)
stopProgressTimer()
print("已停止")
} }
// MARK: - 音量控制
func setVolume(volume: Float) { 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] { func getProgress() -> [String: Any] {
guard let player = player, let currentItem = player.currentItem else { guard let player = player, let currentItem = player.currentItem else {
return [ return [
@ -90,23 +207,56 @@ public class MusicService: NSObject {
let currentTime = CMTimeGetSeconds(player.currentTime()) let currentTime = CMTimeGetSeconds(player.currentTime())
let duration = CMTimeGetSeconds(currentItem.duration) let duration = CMTimeGetSeconds(currentItem.duration)
// 处理无效值 // 更严格的有效性检查
let currentPosition = currentTime.isNaN || currentTime.isInfinite ? 0 : Int(currentTime * 1000) let currentPosition = (currentTime.isNaN || currentTime.isInfinite || currentTime < 0) ? 0 : Int(currentTime * 1000)
let totalDuration = duration.isNaN || duration.isInfinite ? 0 : Int(duration * 1000) let totalDuration = (duration.isNaN || duration.isInfinite || duration <= 0) ? 0 : Int(duration * 1000)
let progress = totalDuration > 0 ? currentTime / duration : 0.0 let progress = (totalDuration > 0 && currentTime >= 0) ? currentTime / duration : 0.0
return [ let result: [String: Any] = [ // Add explicit type annotation here
"currentPosition": currentPosition, "currentPosition": currentPosition,
"duration": totalDuration, "duration": totalDuration,
"progress": progress.isNaN || progress.isInfinite ? 0.0 : progress "progress": progress.isNaN || progress.isInfinite ? 0.0 : progress
] ]
// 添加调试日志
if totalDuration == 0 {
print("警告: 音频时长为0,可能还在加载中。原始duration: \(duration)")
}
return result
} }
func seekTo(positionMs: Int) { func seekTo(positionMs: Int) {
guard let player = player else { return } guard let player = player else { return }
// 记录当前播放状态
let wasPlaying = isPlaying
let time = CMTime(seconds: Double(positionMs) / 1000.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) 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() { private func setupRemoteCommandCenter() {
let commandCenter = MPRemoteCommandCenter.shared() let commandCenter = MPRemoteCommandCenter.shared()
@ -129,21 +279,41 @@ public class MusicService: NSObject {
self.previous() self.previous()
return .success 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] = [ var nowPlayingInfo: [String: Any] = [
MPMediaItemPropertyTitle: song["title"] ?? "Unknown Title", MPMediaItemPropertyTitle: title,
MPMediaItemPropertyArtist: song["artist"] ?? "Unknown Artist" 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) {
let image = UIImage(data: data) { DispatchQueue.global(qos: .background).async {
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image } if let data = try? Data(contentsOf: url),
let image = UIImage(data: data) {
DispatchQueue.main.async {
nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
} else {
DispatchQueue.main.async {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
}
}
} else {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
} }
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
} }
private func updatePlaybackState() { private func updatePlaybackState() {
@ -169,14 +339,22 @@ public class MusicService: NSObject {
flutterChannel = channel flutterChannel = channel
setupEnhancedRemoteCommandCenter() setupEnhancedRemoteCommandCenter()
startProgressTimer() startProgressTimer()
print("媒体会话回调已启用")
} }
/// 更新媒体会话元数据 /// 更新媒体会话元数据
public func updateMediaSessionMetadata(song: [String: Any]) { public func updateMediaSessionMetadata(song: [String: Any]) {
let title = song["title"] as? String ?? song["name"] as? String ?? "Unknown Title" let title = song["title"] as? String ?? song["name"] as? String ?? "未知标题"
let artist = song["artist"] as? String ?? song["sgener"] as? String ?? "Unknown Artist" let artist = song["artist"] as? String ?? song["sgener"] as? String ?? "未知歌手"
let coverUrl = song["coverUrl"] as? String ?? song["image"] 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] = [ var nowPlayingInfo: [String: Any] = [
MPMediaItemPropertyTitle: title, MPMediaItemPropertyTitle: title,
MPMediaItemPropertyArtist: artist MPMediaItemPropertyArtist: artist
@ -200,6 +378,8 @@ public class MusicService: NSObject {
} else { } else {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
} }
print("媒体会话元数据已更新: \(title) - \(artist)")
} }
/// 更新媒体会话播放状态 /// 更新媒体会话播放状态
@ -214,6 +394,8 @@ public class MusicService: NSObject {
} else { } else {
stopProgressTimer() stopProgressTimer()
} }
print("媒体会话播放状态已更新: \(isPlaying)")
} }
/// 设置增强的远程控制中心 /// 设置增强的远程控制中心
@ -225,6 +407,7 @@ public class MusicService: NSObject {
commandCenter.pauseCommand.removeTarget(nil) commandCenter.pauseCommand.removeTarget(nil)
commandCenter.nextTrackCommand.removeTarget(nil) commandCenter.nextTrackCommand.removeTarget(nil)
commandCenter.previousTrackCommand.removeTarget(nil) commandCenter.previousTrackCommand.removeTarget(nil)
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
// 播放命令 // 播放命令
commandCenter.playCommand.addTarget { [weak self] _ in commandCenter.playCommand.addTarget { [weak self] _ in
@ -254,13 +437,12 @@ public class MusicService: NSObject {
return .success return .success
} }
// 跳转命令 // 跳转命令 - 关键升级点
commandCenter.changePlaybackPositionCommand.isEnabled = true commandCenter.changePlaybackPositionCommand.isEnabled = true
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
if let event = event as? MPChangePlaybackPositionCommandEvent { if let event = event as? MPChangePlaybackPositionCommandEvent {
let positionMs = Int(event.positionTime * 1000) let positionMs = Int(event.positionTime * 1000)
self?.seekTo(positionMs: positionMs) self?.seekTo(positionMs: positionMs)
self?.flutterChannel?.invokeMethod("onMediaSessionSeekTo", arguments: ["position": positionMs])
return .success return .success
} }
return .commandFailed return .commandFailed
@ -272,6 +454,8 @@ public class MusicService: NSObject {
self?.flutterChannel?.invokeMethod("onMediaSessionStop", arguments: nil) self?.flutterChannel?.invokeMethod("onMediaSessionStop", arguments: nil)
return .success return .success
} }
print("增强的远程控制中心已设置,支持进度条滑动")
} }
/// 启动进度定时器 /// 启动进度定时器
@ -296,11 +480,148 @@ public class MusicService: NSObject {
let duration = CMTimeGetSeconds(currentItem.duration) let duration = CMTimeGetSeconds(currentItem.duration)
if !currentTime.isNaN && !currentTime.isInfinite && !duration.isNaN && !duration.isInfinite { if !currentTime.isNaN && !currentTime.isInfinite && !duration.isNaN && !duration.isInfinite {
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:] // 更新系统媒体信息 - 关键升级点
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime updateSystemMediaInfo(currentTime: currentTime, duration: duration)
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 // 通知Flutter层
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo let currentPosition = Int(currentTime * 1000)
let totalDuration = Int(duration * 1000)
let progress = totalDuration > 0 ? Float(currentTime / duration) : 0.0
flutterChannel?.invokeMethod("onMediaSessionProgress", arguments: [
"currentIdInternal": currentIdInternal ?? 0,
"currentPosition": currentPosition,
"duration": totalDuration,
"progress": progress
])
}
}
/// 立即更新进度信息(用于seekTo后)
private func updateProgressInfoImmediately() {
guard let player = player, let currentItem = player.currentItem else { return }
let currentTime = CMTimeGetSeconds(player.currentTime())
let duration = CMTimeGetSeconds(currentItem.duration)
if !currentTime.isNaN && !currentTime.isInfinite && !duration.isNaN && !duration.isInfinite {
updateSystemMediaInfo(currentTime: currentTime, duration: duration)
} }
} }
/// 更新系统媒体信息
private func updateSystemMediaInfo(currentTime: Double, duration: Double) {
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
// MARK: - 播放完成处理
private func setupPlayerObservers() {
guard let player = player else { return }
NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: player.currentItem,
queue: .main
) { [weak self] _ in
self?.stopProgressTimer()
self?.playNextOrStop()
}
}
// MARK: - 播放项观察者设置
private func setupPlayerItemObservers(playerItem: AVPlayerItem, song: [String: Any]) {
// 移除之前的观察者
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: nil)
NotificationCenter.default.removeObserver(self, name: .AVPlayerItemFailedToPlayToEndTime, object: nil)
// 监听播放项状态
playerItem.addObserver(self, forKeyPath: "status", options: [.new, .initial], context: nil)
playerItem.addObserver(self, forKeyPath: "duration", options: [.new], context: nil)
playerItem.addObserver(self, forKeyPath: "loadedTimeRanges", options: [.new], context: nil)
// 播放完成通知
NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: playerItem,
queue: .main
) { [weak self] _ in
print("歌曲播放完成")
self?.stopProgressTimer()
self?.playNextOrStop()
}
// 播放失败通知
NotificationCenter.default.addObserver(
forName: .AVPlayerItemFailedToPlayToEndTime,
object: playerItem,
queue: .main
) { [weak self] notification in
print("播放失败: \(notification)")
self?.isPlaying = false
self?.updateMediaSessionPlaybackState(isPlaying: false)
}
// 存储歌曲信息,用于状态变化时更新
objc_setAssociatedObject(playerItem, "songInfo", song, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
// MARK: - KVO 观察者
override public func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
guard let playerItem = object as? AVPlayerItem else { return }
switch keyPath {
case "status":
switch playerItem.status {
case .readyToPlay:
print("播放项准备就绪,开始播放")
// 获取关联的歌曲信息
if let song = objc_getAssociatedObject(playerItem, "songInfo") as? [String: Any] {
updateNowPlayingInfo(song: song)
}
// 开始播放
player?.play()
isPlaying = true
updateMediaSessionPlaybackState(isPlaying: true)
startProgressTimer()
case .failed:
print("播放项加载失败: \(playerItem.error?.localizedDescription ?? "未知错误")")
isPlaying = false
updateMediaSessionPlaybackState(isPlaying: false)
case .unknown:
print("播放项状态未知")
@unknown default:
break
}
case "duration":
let duration = CMTimeGetSeconds(playerItem.duration)
if !duration.isNaN && !duration.isInfinite && duration > 0 {
print("获取到音频时长: \(duration) 秒")
// 更新系统媒体信息
updateProgressInfoImmediately()
}
case "loadedTimeRanges":
if let timeRange = playerItem.loadedTimeRanges.first?.timeRangeValue {
let loadedDuration = CMTimeGetSeconds(CMTimeAdd(timeRange.start, timeRange.duration))
let totalDuration = CMTimeGetSeconds(playerItem.duration)
if !totalDuration.isNaN && !totalDuration.isInfinite && totalDuration > 0 {
let loadedPercentage = loadedDuration / totalDuration * 100
print("缓冲进度: \(String(format: "%.1f", loadedPercentage))%")
}
}
default:
break
}
}
} }

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 { switch call.method {
case "play": case "play":
if let args = call.arguments as? [String: Any], 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) MusicService.shared.play(song: song)
result(nil) result(nil)
} else { } else {
@ -27,8 +27,7 @@ public class MusicServicePlugin: NSObject, FlutterPlugin {
case "playlist": case "playlist":
if let args = call.arguments as? [String: Any], if let args = call.arguments as? [String: Any],
let songs = args["songs"] as? [[String: String]], !songs.isEmpty { let songs = args["songs"] as? [[String: Any]], !songs.isEmpty {
// MusicServiceStarter.startServiceWithPlaylist(command: "playlist", songs: songs)
MusicService.shared.playPlaylist(songs: songs) MusicService.shared.playPlaylist(songs: songs)
result(nil) result(nil)
} else { } else {
@ -44,7 +43,6 @@ public class MusicServicePlugin: NSObject, FlutterPlugin {
result(nil) result(nil)
case "stop": case "stop":
// MusicServiceStarter.startService(command: "stop")
MusicService.shared.stop() MusicService.shared.stop()
result(nil) result(nil)
@ -62,7 +60,7 @@ public class MusicServicePlugin: NSObject, FlutterPlugin {
let currentUrl = MusicService.shared.getCurrentUrlString() let currentUrl = MusicService.shared.getCurrentUrlString()
let status: [String: Any] = [ let status: [String: Any] = [
"isPlaying": isPlaying, "isPlaying": isPlaying,
"url": currentUrl != nil ? currentUrl! : NSNull() "id": currentUrl ?? NSNull()
] ]
result(status) result(status)
@ -76,9 +74,36 @@ public class MusicServicePlugin: NSObject, FlutterPlugin {
MusicService.shared.seekTo(positionMs: position) MusicService.shared.seekTo(positionMs: position)
result(nil) result(nil)
} else { } 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: default:
result(FlutterMethodNotImplemented) 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 { - (void)addPeripheral:(CBPeripheral*)peripheral Info:(NSString*)macStr {
kJLLog(JLLOG_DEBUG, @"BLE addPeripheral ---> Device %@", peripheral.name);
if (self.lastAddress && [macStr isEqualToString:self.lastAddress]) { if (self.lastAddress && [macStr isEqualToString:self.lastAddress]) {
[self stopScanBLE]; [self stopScanBLE];
[self connectBLE:peripheral]; [self connectBLE:peripheral];

2
local_plugins/realtime/ios/realtime/Package.swift

@ -3,7 +3,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "realtime", name: "realtime",
platforms: [.iOS("18.0")], platforms: [.iOS("16.0")],
products: [ products: [
.library(name: "realtime", targets: ["realtime"]) .library(name: "realtime", targets: ["realtime"])
], ],

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

@ -324,21 +324,32 @@ class RealtimeAudioManager {
} }
/// 计算音频缓冲区的RMS值 /// 计算音频缓冲区的RMS值
private func calculateRMS(buffer: AVAudioPCMBuffer) -> Float { private func calculateRMS(buffer: AVAudioPCMBuffer) -> Float {
guard let channelData = buffer.int16ChannelData?[0], guard let channelData = buffer.int16ChannelData?[0],
buffer.frameLength > 0 else { buffer.frameLength > 0 else {
return 0.0 return 0.0
} }
let frameCount = Int(buffer.frameLength) 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)
// 计算平方根并归一化 // 1. 将16位整型转换为浮点型
rms = sqrt(rms / Float(frameCount)) / 32768.0 vDSP_vflt16(channelDataPointer, 1, &floatData, 1, vDSP_Length(frameCount))
return rms // 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 synthesisCanceled
/** 播放开始 */
case playbackStarted
/** 播放完成 */
case playbackCompleted
/** 发生错误 */ /** 发生错误 */
case error 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

22
local_plugins/user_portrait/ios/user_portrait/Package.swift

@ -13,20 +13,12 @@ let package = Package(
], ],
dependencies: [], dependencies: [],
targets: [ targets: [
.target( .target(
name: "user_portrait", name: "user_portrait",
dependencies: [], dependencies: [
resources: [
// If your plugin requires a privacy manifest, for example if it uses any required
// reason APIs, update the PrivacyInfo.xcprivacy file to describe your plugin's
// privacy impact, and then uncomment these lines. For more information, see
// https://developer.apple.com/documentation/bundleresources/privacy_manifest_files
// .process("PrivacyInfo.xcprivacy"),
// If you have other resources that need to be bundled with your plugin, refer to ],
// the following instructions to add them: path: "Sources/user_portrait"
// https://developer.apple.com/documentation/xcode/bundling-resources-with-a-swift-package )
] ]
)
]
) )

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) { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method { switch call.method {
case "getPlatformVersion": case "clean":
result("iOS " + UIDevice.current.systemVersion) result(true)
default: default:
result(FlutterMethodNotImplemented) result(FlutterMethodNotImplemented)
} }

46
local_plugins/user_portrait/pubspec.yaml

@ -17,21 +17,8 @@ dev_dependencies:
sdk: flutter sdk: flutter
flutter_lints: ^5.0.0 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: 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: plugin:
platforms: platforms:
android: android:
@ -39,34 +26,5 @@ flutter:
pluginClass: UserPortraitPlugin pluginClass: UserPortraitPlugin
ios: ios:
pluginClass: UserPortraitPlugin pluginClass: UserPortraitPlugin
swiftPackage:
# To add assets to your plugin package, add an assets section, like this: path: ios/user_portrait
# 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

5
pubspec.yaml

@ -25,6 +25,7 @@ dependencies:
markdown: ^7.1.1 markdown: ^7.1.1
fleather: ^1.24.0+1 fleather: ^1.24.0+1
parchment: ^1.24.0 parchment: ^1.24.0
htmltopdfwidgets: ^1.1.0
wifi_iot: ^0.3.19+2 wifi_iot: ^0.3.19+2
permission_handler: ^10.4.5 permission_handler: ^10.4.5
web_socket_channel: ^2.4.0 web_socket_channel: ^2.4.0
@ -43,7 +44,6 @@ dependencies:
haptic_feedback: ^0.5.1+1 haptic_feedback: ^0.5.1+1
tencentcloud_cos_sdk_plugin: ^1.2.3 tencentcloud_cos_sdk_plugin: ^1.2.3
photo_view: ^0.15.0 photo_view: ^0.15.0
window_size: window_size:
git: git:
url: https://github.com/google/flutter-desktop-embedding.git url: https://github.com/google/flutter-desktop-embedding.git
@ -97,8 +97,6 @@ dependencies:
path: local_plugins/amap_service path: local_plugins/amap_service
location_service: location_service:
path: local_plugins/location_service path: local_plugins/location_service
bytedance_speech:
path: local_plugins/bytedance_speech
realtime: realtime:
path: local_plugins/realtime path: local_plugins/realtime
jl_opus: jl_opus:
@ -134,6 +132,7 @@ flutter:
- assets/images/explore1.png - assets/images/explore1.png
- assets/images/explore2.jpg - assets/images/explore2.jpg
- assets/images/mine-background.png - assets/images/mine-background.png
- assets/fonts/NotoSansSC-Regular.ttf
- assets/lottie/ - assets/lottie/
- assets/js/ - assets/js/
- assets/images/headphone_new4.png - assets/images/headphone_new4.png

Loading…
Cancel
Save