Browse Source

Merge branch 'ble' into fdp

weicu
fdp 1 year ago
parent
commit
fb244cff77
  1. 1
      android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt
  2. 9
      lib/core/bindings/initial_binding.dart
  3. 28
      lib/data/models/appconfig.dart
  4. 37
      lib/data/models/appconfig_model.dart
  5. 33
      lib/data/models/appconfig_model.g.dart
  6. 39
      lib/data/services/ble_manager.dart
  7. 27
      lib/data/services/music_manager.dart
  8. 9
      lib/data/services/open_ai_service_adapter.dart
  9. 36
      lib/data/services/speech_impl/azure_asr_service.dart
  10. 74
      lib/data/services/speech_impl/azure_tts_service.dart
  11. 9
      lib/data/services/spotify_service.dart
  12. 14
      lib/data/services/volcano_translation_service.dart
  13. 204
      lib/modules/agent/controllers/agent_controller.dart
  14. 104
      lib/modules/agent/views/agent_view.dart
  15. 132
      lib/modules/agent/views/message_bubble.dart
  16. 7
      lib/modules/login/controllers/login_controller.dart
  17. 11
      lib/modules/pairing/controllers/pairing_controller.dart
  18. 8
      lib/modules/splash/controllers/splash_controller.dart
  19. 13
      local_plugins/agent_service/android/build.gradle.kts
  20. 37
      local_plugins/agent_service/android/src/main/AndroidManifest.xml
  21. 8
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt
  22. 47
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt
  23. 12
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt
  24. 101
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt
  25. 20
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleWakeReceiver.kt
  26. 2
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BootReceiver.kt
  27. 2
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/utils.kt
  28. 994
      local_plugins/agent_service/ios/Classes/AgentService.swift
  29. 11
      local_plugins/agent_service/ios/Classes/AgentServiceListener.swift
  30. 4
      local_plugins/agent_service/ios/Classes/AgentServicePlugin.h
  31. 12
      local_plugins/agent_service/ios/Classes/AgentServicePlugin.m
  32. 259
      local_plugins/agent_service/ios/Classes/SwiftAgentServicePlugin.swift
  33. 36
      local_plugins/agent_service/lib/agent_service.dart
  34. 2
      local_plugins/agent_service/pubspec.yaml
  35. 16
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt
  36. 873
      local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift
  37. 454
      local_plugins/azure_speech/ios/Classes/AzureTtsHelper.swift
  38. 9
      local_plugins/ble_service/android/build.gradle.kts
  39. 36
      local_plugins/ble_service/android/src/main/AndroidManifest.xml
  40. 226
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  41. 87
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt
  42. 15
      local_plugins/ble_service/ios/Assets/ble_service-Info.plist
  43. 90
      local_plugins/ble_service/ios/Classes/BleConst.swift
  44. 25
      local_plugins/ble_service/ios/Classes/BleDelegate.swift
  45. 963
      local_plugins/ble_service/ios/Classes/BleService.swift
  46. 4
      local_plugins/ble_service/ios/Classes/BleServicePlugin.h
  47. 12
      local_plugins/ble_service/ios/Classes/BleServicePlugin.m
  48. 203
      local_plugins/ble_service/ios/Classes/SwiftBleServicePlugin.swift
  49. 23
      local_plugins/ble_service/ios/ble_service.podspec
  50. 410
      local_plugins/chat_storage/ios/Classes/ChatStorageHelper.swift
  51. 4
      local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.h
  52. 12
      local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.m
  53. 182
      local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.swift
  54. 11
      local_plugins/chat_storage/ios/Classes/SwiftChatStoragePlugin.swift
  55. 64
      local_plugins/chat_storage/ios/README.md
  56. 11
      local_plugins/chat_storage/ios/chat_storage.podspec
  57. 197
      local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothHelper.swift
  58. 4
      local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h
  59. 12
      local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m
  60. 108
      local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.swift
  61. 136
      local_plugins/classic_bluetooth/ios/Classes/FileLogger.swift
  62. 11
      local_plugins/classic_bluetooth/ios/Classes/SwiftClassicBluetoothPlugin.swift
  63. 44
      local_plugins/classic_bluetooth/ios/README.md
  64. 22
      local_plugins/classic_bluetooth/ios/classic_bluetooth.podspec
  65. 2
      local_plugins/classic_bluetooth/pubspec.yaml
  66. 4
      local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.h
  67. 12
      local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.m
  68. 30
      local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.swift
  69. 286
      local_plugins/deepvoice_utils/ios/Classes/FileLogger.swift
  70. 11
      local_plugins/deepvoice_utils/ios/Classes/SwiftDeepvoiceUtilsPlugin.swift
  71. 54
      local_plugins/deepvoice_utils/ios/README.md
  72. 19
      local_plugins/deepvoice_utils/ios/deepvoice_utils.podspec
  73. 33
      local_plugins/music_service/.gitignore
  74. 33
      local_plugins/music_service/.metadata
  75. 3
      local_plugins/music_service/CHANGELOG.md
  76. 1
      local_plugins/music_service/LICENSE
  77. 15
      local_plugins/music_service/README.md
  78. 4
      local_plugins/music_service/analysis_options.yaml
  79. 9
      local_plugins/music_service/android/.gitignore
  80. 70
      local_plugins/music_service/android/build.gradle
  81. 1
      local_plugins/music_service/android/settings.gradle
  82. 11
      local_plugins/music_service/android/src/main/AndroidManifest.xml
  83. 153
      local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt
  84. 46
      local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt
  85. 19
      local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt
  86. 38
      local_plugins/music_service/ios/.gitignore
  87. 0
      local_plugins/music_service/ios/Assets/.gitkeep
  88. 19
      local_plugins/music_service/ios/Classes/MusicServicePlugin.swift
  89. 14
      local_plugins/music_service/ios/Resources/PrivacyInfo.xcprivacy
  90. 29
      local_plugins/music_service/ios/music_service.podspec
  91. 55
      local_plugins/music_service/lib/music_service.dart
  92. 72
      local_plugins/music_service/pubspec.yaml
  93. 101
      local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt
  94. 4
      local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt
  95. 249
      local_plugins/open_ai_service/ios/Classes/MCPClient.swift
  96. 411
      local_plugins/open_ai_service/ios/Classes/OpenAIService.swift
  97. 146
      local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift
  98. 514
      local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift
  99. BIN
      local_plugins/opus/.gradle/8.10/fileHashes/fileHashes.lock
  100. BIN
      local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock

1
android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt

@ -13,7 +13,6 @@ import android.content.Context
import android.os.Handler
import android.os.Looper
import com.yunqiinnovation.deepvoice_utils.FileLogger
import com.yunqiinnovation.ble_service.BleMediaService
class MainActivity: FlutterActivity() {
private val TAG = "MainActivity"

9
lib/core/bindings/initial_binding.dart

@ -1,3 +1,4 @@
import 'package:deep_voice/data/services/music_manager.dart';
import 'package:get/get.dart';
import '../../data/services/speech_factory.dart';
import '../../data/services/chat_history_service.dart';
@ -8,6 +9,7 @@ import '../../core/utils/logger.dart';
import '../../data/services/open_ai_service_adapter.dart';
import '../../data/services/bluetooth_manager.dart';
import '../../data/services/ble_manager.dart';
/// 初始绑定,用于管理全局依赖
class InitialBinding extends Bindings {
@override
@ -34,23 +36,20 @@ class InitialBinding extends Bindings {
adapter.initialize();
return adapter;
}, fenix: true);
// 蓝牙服务
Get.put<BluetoothManager>(BluetoothManager(), permanent: true);
// BleManager
Get.put<BleManager>(BleManager(), permanent: true);
// MusicManager
Get.put<MusicManager>(MusicManager(), permanent: true);
// 火山AI服务
Get.lazyPut<VolcanoAIService>(() => VolcanoAIService(), fenix: true);
// 蓝牙媒体按钮服务
// Get.lazyPut<BluetoothMediaButtonService>(
// () => BluetoothMediaButtonService(),
// fenix: true);
// // 语音交互服务
// Get.lazyPut<VoiceInteractionService>(() => VoiceInteractionService(),

28
lib/data/models/appconfig.dart

@ -0,0 +1,28 @@
import 'dart:convert';
import 'package:deep_voice/data/models/appconfig_model.dart';
class AppConfig {
static late UserGetAppConfigResp config;
// 可选:提供初始化方法
static void initialize(UserGetAppConfigResp resp) {
config = resp;
}
//环境变量
static String? env(String key) {
return config.env[key];
}
//mcp配置
static dynamic mcpConfig() {
return {"mcpServers": config.mcps};
}
//智能体配置
static String agent(String agentId) {
final foundAgent = config.agents.firstWhere((agent) => agent.id == agentId);
return jsonEncode((foundAgent));
}
}

37
lib/data/models/appconfig_model.dart

@ -5,14 +5,49 @@ part 'appconfig_model.g.dart'; // 生成的文件名
@JsonSerializable()
class UserGetAppConfigResp {
final Map<String, String> env;
final List<DBAgent> agents;
final Map<String, DBMCPServer> mcps;
UserGetAppConfigResp({required this.env, required this.mcps});
UserGetAppConfigResp(
{required this.env, required this.agents, required this.mcps});
factory UserGetAppConfigResp.fromJson(Map<String, dynamic> json) =>
_$UserGetAppConfigRespFromJson(json);
Map<String, dynamic> toJson() => _$UserGetAppConfigRespToJson(this);
}
@JsonSerializable()
class DBAgent {
final String id;
final String name;
final String avatarUrl;
final String description;
final String tag;
final bool isOnline;
final String voice;
final String? welcomeMessage;
final String? voiceWelcomeMessage;
final String? systemPrompt;
final List<String>? tools;
DBAgent({
required this.id,
required this.name,
required this.avatarUrl,
required this.description,
required this.tag,
required this.isOnline,
required this.voice,
required this.welcomeMessage,
required this.voiceWelcomeMessage,
required this.systemPrompt,
required this.tools,
});
factory DBAgent.fromJson(Map<String, dynamic> json) =>
_$DBAgentFromJson(json);
Map<String, dynamic> toJson() => _$DBAgentToJson(this);
}
@JsonSerializable()
class DBMCPServer {
final String servername;

33
lib/data/models/appconfig_model.g.dart

@ -10,6 +10,9 @@ UserGetAppConfigResp _$UserGetAppConfigRespFromJson(
Map<String, dynamic> json) =>
UserGetAppConfigResp(
env: Map<String, String>.from(json['env'] as Map),
agents: (json['agents'] as List<dynamic>)
.map((e) => DBAgent.fromJson(e as Map<String, dynamic>))
.toList(),
mcps: (json['mcps'] as Map<String, dynamic>).map(
(k, e) => MapEntry(k, DBMCPServer.fromJson(e as Map<String, dynamic>)),
),
@ -19,9 +22,39 @@ Map<String, dynamic> _$UserGetAppConfigRespToJson(
UserGetAppConfigResp instance) =>
<String, dynamic>{
'env': instance.env,
'agents': instance.agents,
'mcps': instance.mcps,
};
DBAgent _$DBAgentFromJson(Map<String, dynamic> json) => DBAgent(
id: json['id'] as String,
name: json['name'] as String,
avatarUrl: json['avatarUrl'] as String,
description: json['description'] as String,
tag: json['tag'] as String,
isOnline: json['isOnline'] as bool,
voice: json['voice'] as String,
welcomeMessage: json['welcomeMessage'] as String?,
voiceWelcomeMessage: json['voiceWelcomeMessage'] as String?,
systemPrompt: json['systemPrompt'] as String?,
tools:
(json['tools'] as List<dynamic>?)?.map((e) => e as String).toList(),
);
Map<String, dynamic> _$DBAgentToJson(DBAgent instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'avatarUrl': instance.avatarUrl,
'description': instance.description,
'tag': instance.tag,
'isOnline': instance.isOnline,
'voice': instance.voice,
'welcomeMessage': instance.welcomeMessage,
'voiceWelcomeMessage': instance.voiceWelcomeMessage,
'systemPrompt': instance.systemPrompt,
'tools': instance.tools,
};
DBMCPServer _$DBMCPServerFromJson(Map<String, dynamic> json) => DBMCPServer(
servername: json['servername'] as String,
url: json['url'] as String,

39
lib/data/services/ble_manager.dart

@ -1,4 +1,5 @@
import 'dart:async';
import 'package:deep_voice/data/models/appconfig.dart';
import 'package:get/get.dart';
import 'package:ble_service/ble_service.dart';
import 'package:deep_voice/core/utils/logger.dart';
@ -7,6 +8,7 @@ import 'package:get_storage/get_storage.dart';
import 'package:deep_voice/data/models/appconfig_model.dart';
import 'dart:convert';
import 'package:flutter/services.dart' show rootBundle;
import 'package:agent_service/agent_service.dart';
/// 命令类型常量(与原生层保持一致)
class BleCommands {
@ -380,20 +382,31 @@ class BleManager extends GetxService {
}
/// 启动BLE服务
Future<bool> startBleService() async {
Future<bool> startAgentService() async {
try {
// 从.env文件直接读取环境变量
final azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? '';
final azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? 'eastasia';
final openaiApiKey = dotenv.env['OPENAI_API_KEY'] ?? '';
// final azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? '';
// final azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? 'eastasia';
// final openaiApiKey = dotenv.env['OPENAI_API_KEY'] ?? '';
// final openaiBaseUrl =
// dotenv.env['OPENAI_BASE_URL'] ?? 'https://api.openai.com/v1';
// final openaiModel = dotenv.env['OPENAI_MODEL'] ?? 'gpt-3.5-turbo';
// 从Flutter资源包中加载.mcp.json
// final mcpConfig = await rootBundle.loadString('.mcp.json');
// 验证JSON格式
// final jsonData = jsonDecode(mcpConfig);
final azureSpeechKey = AppConfig.env('AZURE_SPEECH_KEY') ?? '';
final azureSpeechRegion =
AppConfig.env('AZURE_SPEECH_REGION') ?? 'eastasia';
final openaiApiKey = AppConfig.env('OPENAI_API_KEY') ?? '';
final openaiBaseUrl =
dotenv.env['OPENAI_BASE_URL'] ?? 'https://api.openai.com/v1';
final openaiModel = dotenv.env['OPENAI_MODEL'] ?? 'gpt-3.5-turbo';
// 从Flutter资源包中加载.mcp.json
final mcpConfig = await rootBundle.loadString('.mcp.json');
AppConfig.env('OPENAI_BASE_URL') ?? 'https://api.openai.com/v1';
final openaiModel = AppConfig.env('OPENAI_MODEL') ?? 'gpt-3.5-turbo';
// 从Flutter资源包中加载.mcp.json
final jsonData = AppConfig.mcpConfig();
// 验证JSON格式
final jsonData = jsonDecode(mcpConfig);
final config = <String, dynamic>{
// 语音代理配置参数
'azureSpeechKey': azureSpeechKey,
@ -419,7 +432,7 @@ class BleManager extends GetxService {
}
}
final result = await _bleService.startBleService(config);
final result = await AgentService.startAgentService(config);
if (result) {
Logger.info('BLE服务启动成功');
@ -451,9 +464,9 @@ class BleManager extends GetxService {
}
/// 停止BLE服务
Future<bool> stopBleService() async {
Future<bool> stopAgentService() async {
try {
final result = await _bleService.stopBleService();
final result = await AgentService.stopAgentService();
if (result) {
Logger.info('BLE服务停止成功');

27
lib/data/services/music_manager.dart

@ -0,0 +1,27 @@
import 'package:deep_voice/core/utils/logger.dart';
import 'package:get/get.dart';
import 'package:music_service/music_service.dart';
//音乐播放管理
class MusicManager extends GetxService {
static MusicManager get to => Get.find<MusicManager>();
// 音乐服务实例
final MusicService _musicService = MusicService();
/// 初始化
@override
void onInit() {
super.onInit();
Logger.info('BLE管理器初始化');
}
/// 释放资源
@override
void onClose() {
super.onClose();
}
//播放 音乐
Future<void> play(String url) async {}
}

9
lib/data/services/open_ai_service_adapter.dart

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'package:deep_voice/data/models/appconfig.dart';
import 'package:deep_voice/data/models/appconfig_model.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get_storage/get_storage.dart';
@ -103,10 +104,10 @@ class OpenAIServiceAdapter implements AiService {
// final apiKey = dotenv.env['OPENAI_API_KEY'] ?? '';
// final baseUrl = dotenv.env['OPENAI_BASE_URL'] ?? '';
// final model = dotenv.env['OPENAI_MODEL'] ?? '';
final _env = _storage.read("ENV") as Map<String, String>;
final apiKey = _env['OPENAI_API_KEY'] ?? '';
final baseUrl = _env['OPENAI_BASE_URL'] ?? '';
final model = _env['OPENAI_MODEL'] ?? '';
// final _env = _storage.read("ENV") as Map<String, String>;
final apiKey = AppConfig.env('OPENAI_API_KEY') ?? '';
final baseUrl = AppConfig.env('OPENAI_BASE_URL') ?? '';
final model = AppConfig.env('OPENAI_MODEL') ?? '';
printInfo(info: '从.env读取OpenAI配置');
printInfo(info: '基础URL: $baseUrl');

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

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:deep_voice/data/models/appconfig.dart';
import 'package:flutter/services.dart';
import 'package:get_storage/get_storage.dart';
import '../../../core/utils/logger.dart';
@ -20,7 +21,7 @@ class AzureAsrService extends GetxService implements AsrService {
static const MethodChannel _channel = MethodChannel('azure_speech/asr');
static const EventChannel _eventChannel =
EventChannel('azure_speech/asr_events');
final GetStorage _storage = GetStorage();
// final GetStorage _storage = GetStorage();
bool _isInitialized = false;
late final String _subscriptionKey;
late final String _serviceRegion;
@ -51,9 +52,9 @@ class AzureAsrService extends GetxService implements AsrService {
/// 从环境变量加载配置
void _loadConfig() {
final _env = _storage.read("ENV") as Map<String, String>;
_subscriptionKey = _env['AZURE_SPEECH_KEY'] ?? '';
_serviceRegion = _env['AZURE_SPEECH_REGION'] ?? '';
// final _env = _storage.read("ENV") as Map<String, String>;
_subscriptionKey = AppConfig.env('AZURE_SPEECH_KEY') ?? '';
_serviceRegion = AppConfig.env('AZURE_SPEECH_REGION') ?? '';
if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) {
throw Exception(
@ -312,4 +313,31 @@ class AzureAsrService extends GetxService implements AsrService {
_isInitialized = false;
}
}
@override
Future<bool> pushAudioData(Uint8List data) async {
if (!_isInitialized) {
await initialize(useExternalAudio: true);
}
if (_audioSourceType != AudioSourceType.external) {
Logger.error('当前非外部音频模式,不能推送音频数据');
return false;
}
try {
final bool result = await _channel.invokeMethod('pushAudioData', {
'audioData': data // Flutter 会自动将 Uint8List 转换为 ByteBuffer
});
if (!result) {
throw Exception('推送音频数据失败');
}
return true;
} catch (e) {
Logger.error('推送音频数据失败: $e');
return false;
}
}
}

74
lib/data/services/speech_impl/azure_tts_service.dart

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:deep_voice/data/models/appconfig.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
@ -13,22 +14,24 @@ import '../tts_service.dart';
class AzureTtsService extends GetxService implements TtsService {
static final AzureTtsService to = Get.put(AzureTtsService());
static const MethodChannel _channel = MethodChannel('azure_speech/tts');
static const EventChannel _eventChannel = EventChannel('azure_speech/tts_events');
final GetStorage _storage = GetStorage();
static const EventChannel _eventChannel =
EventChannel('azure_speech/tts_events');
// final GetStorage _storage = GetStorage();
bool _isInitialized = false;
late final String _subscriptionKey;
late final String _serviceRegion;
// 当前设置
String _currentVoice = 'zh-CN-XiaoxiaoNeural';
// 语音合成队列
final List<String> _textQueue = [];
bool _isProcessingQueue = false;
// 事件流
final StreamController<TtsEvent> _eventStreamController = StreamController<TtsEvent>.broadcast();
final StreamController<TtsEvent> _eventStreamController =
StreamController<TtsEvent>.broadcast();
StreamSubscription? _eventSubscription;
// 可观察状态
@ -53,9 +56,9 @@ class AzureTtsService extends GetxService implements TtsService {
/// 从环境变量加载配置
void _loadConfig() {
final _env = _storage.read("ENV") as Map<String, String>;
_subscriptionKey = _env['AZURE_SPEECH_KEY'] ?? '';
_serviceRegion = _env['AZURE_SPEECH_REGION'] ?? '';
// final _env = _storage.read("ENV") as Map<String, String>;
_subscriptionKey = AppConfig.env('AZURE_SPEECH_KEY') ?? '';
_serviceRegion = AppConfig.env('AZURE_SPEECH_REGION') ?? '';
}
/// 设置事件通道
@ -74,50 +77,50 @@ class AzureTtsService extends GetxService implements TtsService {
/// 处理TTS事件
void _handleTtsEvent(Map<dynamic, dynamic> event) {
final String eventType = event['type'] as String? ?? '';
// Logger.debug('收到TTS事件: $eventType, 数据: $event');
switch (eventType) {
case 'synthesis_started':
_isSpeaking.value = true;
_eventStreamController.add(TtsEvent(type: TtsEventType.started));
break;
case 'synthesizing':
// _isSpeaking.value = true;
// // 合成中事件暂时映射到started类型,因为TtsEventType中没有synthesizing类型
// _eventStreamController.add(TtsEvent(type: TtsEventType.started));
break;
case 'synthesis_completed':
_isSpeaking.value = false;
_eventStreamController.add(TtsEvent(type: TtsEventType.completed));
// 如果有队列在处理中,继续处理下一项
if (_isProcessingQueue) {
_processNextInQueue();
}
break;
case 'synthesis_canceled':
_isSpeaking.value = false;
final String reason = event['reason'] as String? ?? '';
_eventStreamController.add(TtsEvent.error('合成取消: $reason'));
// 如果有队列在处理中,继续处理下一项(即使出错)
if (_isProcessingQueue) {
_processNextInQueue();
}
break;
case 'error':
_isSpeaking.value = false;
final String message = event['message'] as String? ?? '未知错误';
Logger.error('TTS错误: $message');
_eventStreamController.add(TtsEvent.error(message));
// 如果有队列在处理中,继续处理下一项(即使出错)
if (_isProcessingQueue) {
_processNextInQueue();
@ -189,7 +192,7 @@ class AzureTtsService extends GetxService implements TtsService {
final result = await _channel.invokeMethod('speakText', {
'text': text,
});
return result == true;
} catch (e) {
Logger.error('语音合成失败: $e');
@ -277,21 +280,24 @@ class AzureTtsService extends GetxService implements TtsService {
// 等待队列处理完成
final completer = Completer<bool>();
// 监听事件流检查队列是否完成
final subscription = onEvent.listen((event) {
// 当队列为空且不在处理中,且当前非说话状态,完成操作
if (_textQueue.isEmpty && !_isProcessingQueue && !_isSpeaking.value && !completer.isCompleted) {
if (_textQueue.isEmpty &&
!_isProcessingQueue &&
!_isSpeaking.value &&
!completer.isCompleted) {
completer.complete(true);
}
});
// 如果已经处于空闲状态,直接完成
if (_textQueue.isEmpty && !_isProcessingQueue && !_isSpeaking.value) {
subscription.cancel();
return true;
}
// 设置超时
final timeout = Timer(const Duration(seconds: 60), () {
if (!completer.isCompleted) {
@ -299,14 +305,14 @@ class AzureTtsService extends GetxService implements TtsService {
completer.complete(false);
}
});
// 等待完成
final result = await completer.future;
// 清理
subscription.cancel();
timeout.cancel();
Logger.info('TTS队列播放完成');
return result;
} catch (e) {
@ -320,11 +326,11 @@ class AzureTtsService extends GetxService implements TtsService {
if (_textQueue.isEmpty || _isProcessingQueue) return;
_isProcessingQueue = true;
// 处理队列中的第一项
await _processNextInQueue();
}
/// 处理队列中的下一项
Future<void> _processNextInQueue() async {
// 检查队列是否为空或已禁用
@ -333,14 +339,14 @@ class AzureTtsService extends GetxService implements TtsService {
_isProcessingQueue = false;
return;
}
try {
// 获取当前项但暂不从队列中移除
final text = _textQueue.first;
// 开始合成
final success = await speakOnce(text);
// 仅当合成操作失败时才从队列移除并继续处理下一项
// 成功的话会等待合成完成事件后自动处理下一项
if (!success) {
@ -353,12 +359,12 @@ class AzureTtsService extends GetxService implements TtsService {
}
} catch (e) {
Logger.error('处理语音队列出错: $e');
// 出错时移除当前项并尝试继续
if (_textQueue.isNotEmpty) {
_textQueue.removeAt(0);
}
// 如果队列中还有项目,继续处理
if (_textQueue.isNotEmpty) {
await _processNextInQueue();

9
lib/data/services/spotify_service.dart

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'package:deep_voice/data/models/appconfig.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get_storage/get_storage.dart';
import 'package:spotify_sdk/spotify_sdk.dart';
@ -12,7 +13,7 @@ import 'package:path_provider/path_provider.dart';
/// OpenAI服务适配器 - 连接AiService接口与OpenAIService插件
class SpotifService {
bool isInitialized = false;
final GetStorage _storage = GetStorage();
// final GetStorage _storage = GetStorage();
late String SPOTIFY_CLIENT_ID;
late String SPOTIFY_REDIRECT_URL;
@ -20,9 +21,9 @@ class SpotifService {
/// 初始化OpenAI服务
Future<bool> initialize() async {
if (!isInitialized) {
final _env = _storage.read("ENV") as Map<String, String>;
SPOTIFY_CLIENT_ID = _env['SPOTIFY_CLIENT_ID'] ?? '';
SPOTIFY_REDIRECT_URL = _env['SPOTIFY_REDIRECT_URL'] ?? '';
// final _env = _storage.read("ENV") as Map<String, String>;
SPOTIFY_CLIENT_ID = AppConfig.env('SPOTIFY_CLIENT_ID') ?? '';
SPOTIFY_REDIRECT_URL = AppConfig.env('SPOTIFY_REDIRECT_URL') ?? '';
printInfo(
info:
"SpotifService----------- initialize SPOTIFY_CLIENT_ID:${SPOTIFY_CLIENT_ID} SPOTIFY_REDIRECT_URL:${SPOTIFY_REDIRECT_URL}");

14
lib/data/services/volcano_translation_service.dart

@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:math';
import 'package:deep_voice/data/models/appconfig.dart';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart' as http;
import 'package:crypto/crypto.dart';
@ -22,7 +23,7 @@ class VolcanoTranslationService extends GetxService {
// 语言管理器
final LanguageManager _languageManager = Get.find<LanguageManager>();
final GetStorage _storage = GetStorage();
// final GetStorage _storage = GetStorage();
// 构造函数
VolcanoTranslationService();
@ -33,10 +34,13 @@ class VolcanoTranslationService extends GetxService {
String? region,
}) async {
try {
final _env = _storage.read("ENV") as Map<String, String>;
_accessKey = accessKey ?? _env['VOLCANO_TRANSLATION_ACCESS_KEY'] ?? '';
_secretKey = secretKey ?? _env['VOLCANO_TRANSLATION_SECRET_KEY'] ?? '';
_region = region ?? _env['VOLCANO_TRANSLATION_REGION'] ?? 'cn-north-1';
// final _env = _storage.read("ENV") as Map<String, String>;
_accessKey =
accessKey ?? AppConfig.env('VOLCANO_TRANSLATION_ACCESS_KEY') ?? '';
_secretKey =
secretKey ?? AppConfig.env('VOLCANO_TRANSLATION_SECRET_KEY') ?? '';
_region =
region ?? AppConfig.env('VOLCANO_TRANSLATION_REGION') ?? 'cn-north-1';
_service = 'translate';
_baseUrl = 'https://translate.volcengineapi.com';

204
lib/modules/agent/controllers/agent_controller.dart

@ -15,6 +15,7 @@ class Message {
final bool isRecognizing; // 是否为语音识别中的临时消息
final bool hasImage; // 是否包含图片
final String? imagePath; // 图片路径
final Map? meta; //卡片数据
Message({
required this.isUser,
@ -23,50 +24,51 @@ class Message {
this.isRecognizing = false,
this.hasImage = false,
this.imagePath,
this.meta,
}) : this.timestamp = timestamp ?? DateTime.now();
}
class AgentController extends GetxController {
static AgentController get to => Get.find();
final logger = Logger();
// 聊天消息列表
final messages = <Message>[].obs;
// 控制器
final TextEditingController textController = TextEditingController();
final ScrollController scrollController = ScrollController();
// 状态标志
final isInitialized = false.obs;
final isListening = false.obs;
final isSpeaking = false.obs;
final isProcessing = false.obs;
// 图片处理状态
final isImageProcessing = false.obs;
// 输入模式控制
final isTextInputMode = true.obs;
// 当前输入的文本内容
final currentText = ''.obs;
// 图片输入相关
final isImageInputActive = false.obs; // 是否处于图片输入模式
final selectedImagePath = Rx<String?>(null); // 当前选择的图片路径
// 流事件订阅
StreamSubscription<AgentServiceEvent>? _eventSubscription;
// 用于跟踪AI回复状态
bool _isNewAssistantResponse = true;
String _currentResponseId = '';
// 会话ID
final String sessionId = 'default_agent';
// 是否已加载聊天记录
final isHistoryLoaded = false.obs;
@ -86,7 +88,7 @@ class AgentController extends GetxController {
_eventSubscription?.cancel();
super.onClose();
}
// 加载聊天历史记录
Future<void> _loadChatHistory() async {
try {
@ -96,19 +98,21 @@ class AgentController extends GetxController {
page: 1,
pageSize: 20,
);
if (result.messages.isNotEmpty) {
// 转换并添加消息到列表
final historyMessages = result.messages.map((msg) => Message(
isUser: msg.sender == 'user',
text: msg.message,
timestamp: DateTime.parse(msg.timestamp),
)).toList();
final historyMessages = result.messages
.map((msg) => Message(
isUser: msg.sender == 'user',
text: msg.message,
timestamp: DateTime.parse(msg.timestamp),
))
.toList();
// 消息按时间排序,最新的消息在底部
historyMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp));
messages.assignAll(historyMessages);
logger.i('成功加载 ${historyMessages.length} 条聊天记录');
} else {
// 没有历史记录时添加欢迎消息
@ -120,16 +124,15 @@ class AgentController extends GetxController {
_addWelcomeMessage();
} finally {
isHistoryLoaded.value = true;
messages.refresh();
// 在加载完成后直接定位到底部,无需动画
WidgetsBinding.instance.addPostFrameCallback((_) {
scrollController.jumpTo(scrollController.position.maxScrollExtent);
});
}
}
// 添加欢迎消息
void _addWelcomeMessage() {
messages.add(Message(
@ -159,15 +162,16 @@ class AgentController extends GetxController {
isTextInputMode.value = true;
isTextInputMode.refresh();
break;
case AgentServiceEventType.recognizing:
final text = event.data['text'] ?? '';
currentText.value = text; // 保留当前文本,以便其他地方使用
if (text.isNotEmpty) {
// 查找是否有正在识别中的消息
final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser);
final index = messages
.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser);
if (index >= 0) {
// 更新已有的识别中消息
messages[index] = Message(
@ -188,13 +192,14 @@ class AgentController extends GetxController {
_scrollToBottom();
}
break;
case AgentServiceEventType.recognitionResult:
final text = event.data['text'] ?? '';
// 查找是否有正在识别中的消息
final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser);
final index =
messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser);
if (text.isNotEmpty) {
if (index >= 0) {
// 将识别中的消息转换为最终消息
@ -208,7 +213,7 @@ class AgentController extends GetxController {
// 如果没有找到识别中的消息,添加新消息
messages.add(Message(isUser: true, text: text));
}
isProcessing.value = true;
// 标记为新的AI回复
_isNewAssistantResponse = true;
@ -220,11 +225,11 @@ class AgentController extends GetxController {
messages.refresh();
}
break;
case AgentServiceEventType.ttsStarted:
isSpeaking.value = true;
break;
case AgentServiceEventType.ttsCompleted:
case AgentServiceEventType.ttsStopped:
case AgentServiceEventType.ttsCanceled:
@ -238,16 +243,17 @@ class AgentController extends GetxController {
case AgentServiceEventType.imageReady:
// 图片准备就绪,但AI还没有开始处理,保持processing状态
break;
case AgentServiceEventType.assistantToken:
if (!isProcessing.value) isProcessing.value = true;
final token = event.data['token'] ?? '';
final responseId = event.data['responseId'] ?? '';
if (token.isNotEmpty) {
// 如果是新的回复或者响应ID改变,创建新消息
if (_isNewAssistantResponse || (_currentResponseId != responseId && responseId.isNotEmpty)) {
if (_isNewAssistantResponse ||
(_currentResponseId != responseId && responseId.isNotEmpty)) {
messages.add(Message(isUser: false, text: token));
_isNewAssistantResponse = false;
_currentResponseId = responseId;
@ -267,35 +273,37 @@ class AgentController extends GetxController {
_scrollToBottom();
}
break;
case AgentServiceEventType.assistantResponse:
isProcessing.value = false;
isImageProcessing.value = false;
// 标记当前回复完成
_isNewAssistantResponse = true;
break;
case AgentServiceEventType.error:
isListening.value = false;
isSpeaking.value = false;
isProcessing.value = false;
isImageProcessing.value = false;
// 移除临时的识别消息
final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser);
final index =
messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser);
if (index >= 0) {
messages.removeAt(index);
messages.refresh();
}
logger.e('代理服务错误: ${event.data['message']}');
break;
case AgentServiceEventType.autoStop:
isListening.value = false;
// 移除临时的识别消息
final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser);
final index =
messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser);
if (index >= 0) {
// 只有当消息为空或文本太短时才移除
if (messages[index].text.trim().length < 2) {
@ -309,26 +317,51 @@ class AgentController extends GetxController {
timestamp: messages[index].timestamp,
);
messages.refresh();
// 发送此消息到AI处理
isProcessing.value = true;
_isNewAssistantResponse = true;
AgentService.processTextInput(messages[index].text, speakResponse: true);
AgentService.processTextInput(messages[index].text,
speakResponse: true);
}
}
break;
case AgentServiceEventType.functionCall:
// final functionName = event.data['name'] ?? '';
// final arguments = event.data['arguments'] ?? '';
// logger.i('mcp: $functionName, $arguments');
final functionName = event.data['name'] ?? '';
final arguments = event.data['arguments'] ?? '';
logger.i('Fultter 开始调用 mcp: $functionName, $arguments');
break;
case AgentServiceEventType.functionCallResult:
final responseId = event.data['responseId'] ?? '';
final result = event.data['result'] ?? '';
final functionCall = event.data['function_call'] ?? '';
if (!result.isBlank) {
final meta = result["meta"] ?? {};
//不为空
// 如果是新的回复或者响应ID改变,创建新消息
if (_isNewAssistantResponse ||
(_currentResponseId != responseId && responseId.isNotEmpty)) {
messages.add(Message(isUser: false, text: "", meta: meta));
_isNewAssistantResponse = false;
_currentResponseId = responseId;
} else {
// 否则更新最后一条消息
final lastIndex = messages.length - 1;
if (lastIndex >= 0 && !messages[lastIndex].isUser) {
messages[lastIndex] = Message(
isUser: false,
text: messages[lastIndex].text,
meta: meta,
timestamp: messages[lastIndex].timestamp,
);
}
}
}
logger.i('Fultter 结束调用 mcp: $result, $functionCall');
break;
default:
break;
}
@ -359,22 +392,22 @@ class AgentController extends GetxController {
final text = textController.text.trim();
final imagePath = selectedImagePath.value!; // 使用!强制断言非空
await sendImageMessage(imagePath, text: text);
// 清除图片输入状态
clearImageInput();
return;
}
final text = textController.text.trim();
if (text.isEmpty) return;
final message = Message(isUser: true, text: text);
messages.add(message);
textController.clear();
// 滚动到底部
_scrollToBottom();
try {
isProcessing.value = true;
// 标记为新的AI回复
@ -392,31 +425,31 @@ class AgentController extends GetxController {
logger.e('图片不存在: $imagePath');
return;
}
// 显示的文本,如果没有提供则使用默认值
final displayText = text?.isNotEmpty == true ? text! : '[图片]';
// 添加用户消息
final message = Message(
isUser: true,
isUser: true,
text: displayText,
hasImage: true,
imagePath: imagePath,
);
messages.add(message);
// 清空文本输入
textController.clear();
// 滚动到底部
_scrollToBottom();
try {
isProcessing.value = true;
isImageProcessing.value = true;
// 标记为新的AI回复
_isNewAssistantResponse = true;
// 调用Agent Service处理图片
await AgentService.processImageInput(
imagePath,
@ -433,7 +466,7 @@ class AgentController extends GetxController {
// 开始语音输入
Future<void> startVoiceInput() async {
if (isListening.value) return;
try {
await AgentService.startConversation();
} catch (e) {
@ -444,7 +477,7 @@ class AgentController extends GetxController {
// 停止语音输入
Future<void> stopVoiceInput() async {
if (!isListening.value) return;
try {
await AgentService.stopConversation();
} catch (e) {
@ -456,21 +489,20 @@ class AgentController extends GetxController {
Future<void> clearChat() async {
try {
await AgentService.clearChatHistory();
// 清空本地消息列表
messages.clear();
// 添加欢迎消息
_addWelcomeMessage();
// 重置回复状态
_isNewAssistantResponse = true;
} catch (e) {
logger.e('清除聊天历史失败: $e');
}
}
// 切换输入模式
void toggleInputMode() {
// 如果当前在图片输入模式,先清除
@ -478,9 +510,9 @@ class AgentController extends GetxController {
clearImageInput();
return;
}
isTextInputMode.toggle();
// 切换到语音模式时,直接开始语音输入
if (!isTextInputMode.value) {
startVoiceInput();
@ -498,10 +530,10 @@ class AgentController extends GetxController {
final XFile? image = await picker.pickImage(
source: ImageSource.gallery,
imageQuality: 80, // 设置图片质量
maxWidth: 1024, // 限制最大宽度
maxHeight: 1024, // 限制最大高度
maxWidth: 1024, // 限制最大宽度
maxHeight: 1024, // 限制最大高度
);
if (image != null) {
// 设置已选择的图片
selectedImagePath.value = image.path;
@ -520,10 +552,10 @@ class AgentController extends GetxController {
final XFile? photo = await picker.pickImage(
source: ImageSource.camera,
imageQuality: 80, // 设置图片质量
maxWidth: 1024, // 限制最大宽度
maxHeight: 1024, // 限制最大高度
maxWidth: 1024, // 限制最大宽度
maxHeight: 1024, // 限制最大高度
);
if (photo != null) {
// 设置已选择的图片
selectedImagePath.value = photo.path;
@ -534,18 +566,18 @@ class AgentController extends GetxController {
logger.e('拍照失败: $e');
}
}
// 清除图片输入状态
void clearImageInput() {
selectedImagePath.value = null;
isImageInputActive.value = false;
}
// 使用预设提示词处理图片
void useImagePrompt(String prompt) {
// 确保selectedImagePath.value不为null
if (selectedImagePath.value == null) return;
// 填充文本框同时直接发送消息
textController.text = prompt;
// 立即发送图片和提示词
@ -554,19 +586,19 @@ class AgentController extends GetxController {
// 发送后清除图片输入状态
clearImageInput();
}
// 预设提示词:总结图片内容
void summarizeImage() {
useImagePrompt('请总结这张图片的内容');
}
// 预设提示词:抽取图像文字
void extractTextFromImage() {
useImagePrompt('请提取这张图片中的所有文字');
}
// 预设提示词:翻译图像文字
void translateImageText() {
useImagePrompt('请翻译这张图片中的文字');
}
}
}

104
lib/modules/agent/views/agent_view.dart

@ -10,7 +10,7 @@ class AgentView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final controller = Get.find<AgentController>();
return Scaffold(
appBar: AppBar(
title: Row(
@ -93,12 +93,13 @@ class AgentView extends StatelessWidget {
isRecognizing: message.isRecognizing,
hasImage: message.hasImage,
imagePath: message.imagePath,
mate: message.meta,
);
},
),
),
),
// 输入区域
Container(
padding: const EdgeInsets.all(16),
@ -117,7 +118,8 @@ class AgentView extends StatelessWidget {
children: [
// 图片预览区域 - 仅在图片输入模式下显示
Obx(() {
if (controller.isImageInputActive.value && controller.selectedImagePath.value != null) {
if (controller.isImageInputActive.value &&
controller.selectedImagePath.value != null) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -154,7 +156,7 @@ class AgentView extends StatelessWidget {
),
],
),
// 预设提示选项
SizedBox(height: 8),
SingleChildScrollView(
@ -188,7 +190,7 @@ class AgentView extends StatelessWidget {
return SizedBox.shrink();
}
}),
// 输入控件行
Row(
children: [
@ -213,14 +215,14 @@ class AgentView extends StatelessWidget {
),
);
}),
const SizedBox(width: 8),
// 文本输入框或语音波形图
Expanded(
child: Obx(() {
final isTextMode = controller.isTextInputMode.value;
if (isTextMode) {
// 文本输入模式 - 注意观察图片模式时的高度变化
return Container(
@ -234,18 +236,23 @@ class AgentView extends StatelessWidget {
Expanded(
child: Obx(() {
// 图片模式下使用更高的输入框
final isImageActive = controller.isImageInputActive.value;
final isImageActive =
controller.isImageInputActive.value;
return TextField(
controller: controller.textController,
maxLines: isImageActive ? 2 : 1,
minLines: isImageActive ? 2 : 1,
decoration: InputDecoration(
hintText: isImageActive ? '添加对图片的问题或描述...' : '输入消息...',
hintText: isImageActive
? '添加对图片的问题或描述...'
: '输入消息...',
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(vertical: isImageActive ? 12 : 0),
contentPadding: EdgeInsets.symmetric(
vertical: isImageActive ? 12 : 0),
),
onSubmitted: (_) => controller.sendTextMessage(),
onSubmitted: (_) =>
controller.sendTextMessage(),
);
}),
),
@ -271,7 +278,8 @@ class AgentView extends StatelessWidget {
},
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
padding:
const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(24),
@ -281,7 +289,8 @@ class AgentView extends StatelessWidget {
// 语音波形图 - 始终显示
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisAlignment:
MainAxisAlignment.start,
children: [
...List.generate(10, (index) {
return _buildSoundBar(index);
@ -289,14 +298,16 @@ class AgentView extends StatelessWidget {
const Spacer(),
// "正在聆听"文字
Obx(() {
final isListening = controller.isListening.value;
final isListening =
controller.isListening.value;
return Row(
children: [
Icon(
Icons.mic,
size: 14,
color: isListening
? Theme.of(context).primaryColor
color: isListening
? Theme.of(context)
.primaryColor
: Colors.grey,
),
const SizedBox(width: 4),
@ -304,8 +315,9 @@ class AgentView extends StatelessWidget {
'正在聆听',
style: TextStyle(
fontSize: 12,
color: isListening
? Theme.of(context).primaryColor
color: isListening
? Theme.of(context)
.primaryColor
: Colors.grey,
),
),
@ -322,9 +334,9 @@ class AgentView extends StatelessWidget {
}
}),
),
const SizedBox(width: 8),
// 图片选择按钮
Container(
width: 48,
@ -351,7 +363,8 @@ class AgentView extends StatelessWidget {
value: 'camera',
child: Row(
children: [
Icon(Icons.camera_alt, color: Theme.of(context).primaryColor),
Icon(Icons.camera_alt,
color: Theme.of(context).primaryColor),
SizedBox(width: 8),
Text('拍照'),
],
@ -361,7 +374,8 @@ class AgentView extends StatelessWidget {
value: 'gallery',
child: Row(
children: [
Icon(Icons.photo_library, color: Theme.of(context).primaryColor),
Icon(Icons.photo_library,
color: Theme.of(context).primaryColor),
SizedBox(width: 8),
Text('从相册选择'),
],
@ -379,7 +393,7 @@ class AgentView extends StatelessWidget {
),
);
}
// 构建提示选项芯片
Widget _buildPromptChip({
required BuildContext context,
@ -408,15 +422,15 @@ class AgentView extends StatelessWidget {
),
);
}
// 构建声音柱
Widget _buildSoundBar(int index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: Obx(() => _SoundBar(
delay: index * 0.1,
isActive: Get.find<AgentController>().isListening.value,
)),
delay: index * 0.1,
isActive: Get.find<AgentController>().isListening.value,
)),
);
}
}
@ -425,41 +439,42 @@ class AgentView extends StatelessWidget {
class _SoundBar extends StatefulWidget {
final double delay;
final bool isActive;
const _SoundBar({
required this.delay,
required this.isActive,
});
@override
_SoundBarState createState() => _SoundBarState();
}
class _SoundBarState extends State<_SoundBar> with SingleTickerProviderStateMixin {
class _SoundBarState extends State<_SoundBar>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
_animation = Tween<double>(begin: 3, end: 15).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
),
)..addListener(() {
setState(() {});
});
setState(() {});
});
_startOrStopAnimation();
}
@override
void didUpdateWidget(_SoundBar oldWidget) {
super.didUpdateWidget(oldWidget);
@ -467,7 +482,7 @@ class _SoundBarState extends State<_SoundBar> with SingleTickerProviderStateMixi
_startOrStopAnimation();
}
}
void _startOrStopAnimation() {
if (widget.isActive) {
Future.delayed(Duration(milliseconds: (widget.delay * 1000).toInt()), () {
@ -481,22 +496,23 @@ class _SoundBarState extends State<_SoundBar> with SingleTickerProviderStateMixi
_controller.animateTo(0.1, duration: Duration(milliseconds: 300));
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: 2,
height: widget.isActive ? _animation.value : 3,
decoration: BoxDecoration(
color: widget.isActive ? Theme.of(context).primaryColor : Colors.grey[400],
color:
widget.isActive ? Theme.of(context).primaryColor : Colors.grey[400],
borderRadius: BorderRadius.circular(1),
),
);
}
}
}

132
lib/modules/agent/views/message_bubble.dart

@ -10,6 +10,7 @@ class MessageBubble extends StatelessWidget {
final bool isRecognizing;
final bool hasImage;
final String? imagePath;
final Map? mate;
const MessageBubble({
Key? key,
@ -19,31 +20,42 @@ class MessageBubble extends StatelessWidget {
this.isRecognizing = false,
this.hasImage = false,
this.imagePath,
this.mate,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final timeFormat = DateFormat('HH:mm');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
mainAxisAlignment:
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isUser) _buildAvatar(isUser, context),
const SizedBox(width: 8),
Flexible(
child: Column(
crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
crossAxisAlignment:
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Container(
padding: hasImage ? (message == '[图片]' ? EdgeInsets.zero : const EdgeInsets.all(12)) : const EdgeInsets.all(12),
padding: hasImage
? (message == '[图片]'
? EdgeInsets.zero
: const EdgeInsets.all(12))
: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _getBubbleColor(context),
borderRadius: BorderRadius.circular(18).copyWith(
bottomLeft: isUser ? const Radius.circular(18) : const Radius.circular(4),
bottomRight: !isUser ? const Radius.circular(18) : const Radius.circular(4),
bottomLeft: isUser
? const Radius.circular(18)
: const Radius.circular(4),
bottomRight: !isUser
? const Radius.circular(18)
: const Radius.circular(4),
),
boxShadow: [
BoxShadow(
@ -79,7 +91,7 @@ class MessageBubble extends StatelessWidget {
/// 获取气泡颜色
Color _getBubbleColor(BuildContext context) {
if (isUser) {
return isRecognizing
return isRecognizing
? Color(0xFF3B82F6).withOpacity(0.8) // 识别中的用户消息使用半透明的蓝色
: Color(0xFF3B82F6); // 使用更明亮的蓝色
} else {
@ -135,7 +147,7 @@ class MessageBubble extends StatelessWidget {
],
);
}
// 如果是图片消息,显示图片
if (hasImage && imagePath != null) {
// 如果消息是默认的[图片]标记,只显示图片
@ -150,7 +162,7 @@ class MessageBubble extends StatelessWidget {
),
);
}
// 如果消息是自定义文本,显示文本+图片
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -177,18 +189,34 @@ class MessageBubble extends StatelessWidget {
],
);
}
// 判断消息内容,显示特殊卡片
if (!isUser) {
if (message.contains('播放列表') || message.contains('音乐')) {
if (!isUser && mate != null) {
if (mate!.keys.contains('card_music')) {
final musicName = mate!['card_music']['name'] as String;
final musicUrl = mate!['card_music']['url'] as String;
final musicSinger = mate!['card_music']['sgener'] as String;
//音乐卡片
return _buildMusicItem(context, musicName, musicUrl, musicSinger);
} else if (mate!.keys.contains('card_musiclist')) {
//音乐播放列表
return _buildMusicContent(context);
} else if (message.contains('景点') || message.contains('旅游')) {
return _buildTravelContent(context);
} else if (message.contains('天气') || message.contains('气温') || message.contains('预报')) {
} else if (mate!.keys.contains('card_weather')) {
//天气
return _buildWeatherContent(context);
}
// if (message.contains('播放列表') || message.contains('音乐')) {
// return _buildMusicContent(context);
// } else if (message.contains('景点') || message.contains('旅游')) {
// return _buildTravelContent(context);
// } else if (message.contains('天气') ||
// message.contains('气温') ||
// message.contains('预报')) {
// return _buildWeatherContent(context);
// }
}
return Text(
message,
style: TextStyle(
@ -272,14 +300,19 @@ class MessageBubble extends StatelessWidget {
],
),
const SizedBox(height: 10),
// 歌曲示例
_buildMusicItem('雨声与钢琴曲', '纯音乐', context),
_buildMusicItem(
context,
'雨声与钢琴曲',
"",
'纯音乐',
),
Divider(height: 1, thickness: 1, color: Colors.grey[100]),
_buildMusicItem('Rainy Day', 'Coldplay', context),
_buildMusicItem(context, 'Rainy Day', "", 'Coldplay'),
Divider(height: 1, thickness: 1, color: Colors.grey[100]),
_buildMusicItem('雨天的爵士咖啡馆', '爵士合集', context),
_buildMusicItem(context, '雨天的爵士咖啡馆', "", '爵士合集'),
const SizedBox(height: 8),
Center(
child: TextButton(
@ -313,7 +346,8 @@ class MessageBubble extends StatelessWidget {
label: Text('播放全部'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).primaryColor,
side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)),
side: BorderSide(
color: Theme.of(context).primaryColor.withOpacity(0.5)),
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
@ -344,7 +378,7 @@ class MessageBubble extends StatelessWidget {
],
);
}
/// 构建旅游内容卡片
Widget _buildTravelContent(BuildContext context) {
return Column(
@ -396,14 +430,14 @@ class MessageBubble extends StatelessWidget {
],
),
const SizedBox(height: 10),
// 景点列表
_buildLocationItem('东京塔', '标志性建筑,可俯瞰东京全景', context),
Divider(height: 1, thickness: 1, color: Colors.grey[100]),
_buildLocationItem('涩谷十字路口', '世界上最繁忙的十字路口之一', context),
Divider(height: 1, thickness: 1, color: Colors.grey[100]),
_buildLocationItem('浅草寺', '东京最古老的寺庙', context),
const SizedBox(height: 8),
Center(
child: TextButton(
@ -437,7 +471,8 @@ class MessageBubble extends StatelessWidget {
label: Text('查看地图'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).primaryColor,
side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)),
side: BorderSide(
color: Theme.of(context).primaryColor.withOpacity(0.5)),
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
@ -470,7 +505,8 @@ class MessageBubble extends StatelessWidget {
}
/// 构建音乐项目
Widget _buildMusicItem(String title, String artist, BuildContext context) {
Widget _buildMusicItem(
BuildContext context, String name, String url, String singer) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
@ -494,7 +530,7 @@ class MessageBubble extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
name,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
@ -504,7 +540,7 @@ class MessageBubble extends StatelessWidget {
),
SizedBox(height: 2),
Text(
artist,
singer,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
@ -516,7 +552,8 @@ class MessageBubble extends StatelessWidget {
),
IconButton(
onPressed: () {},
icon: Icon(Icons.play_circle, color: Theme.of(context).primaryColor),
icon:
Icon(Icons.play_circle, color: Theme.of(context).primaryColor),
padding: EdgeInsets.zero,
constraints: BoxConstraints(),
iconSize: 24,
@ -525,9 +562,10 @@ class MessageBubble extends StatelessWidget {
),
);
}
/// 构建地点项目
Widget _buildLocationItem(String name, String description, BuildContext context) {
Widget _buildLocationItem(
String name, String description, BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
@ -749,7 +787,8 @@ class MessageBubble extends StatelessWidget {
label: Text('更多地区'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).primaryColor,
side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)),
side: BorderSide(
color: Theme.of(context).primaryColor.withOpacity(0.5)),
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
@ -780,7 +819,7 @@ class MessageBubble extends StatelessWidget {
],
);
}
/// 构建单日天气预报
Widget _buildForecastDay(String day, IconData icon, String temp) {
return Column(
@ -815,26 +854,27 @@ class MessageBubble extends StatelessWidget {
/// 动画点组件
class _AnimatedDot extends StatefulWidget {
final int delayMilliseconds;
const _AnimatedDot({required this.delayMilliseconds});
@override
_AnimatedDotState createState() => _AnimatedDotState();
}
class _AnimatedDotState extends State<_AnimatedDot> with SingleTickerProviderStateMixin {
class _AnimatedDotState extends State<_AnimatedDot>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
_animation = Tween<double>(begin: 0, end: 6).animate(
CurvedAnimation(
parent: _controller,
@ -842,22 +882,22 @@ class _AnimatedDotState extends State<_AnimatedDot> with SingleTickerProviderSta
reverseCurve: Interval(0.5, 1.0, curve: Curves.easeIn),
),
)..addListener(() {
setState(() {});
});
setState(() {});
});
Future.delayed(Duration(milliseconds: widget.delayMilliseconds), () {
if (mounted) {
_controller.repeat();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
@ -876,4 +916,4 @@ class _AnimatedDotState extends State<_AnimatedDot> with SingleTickerProviderSta
),
);
}
}
}

7
lib/modules/login/controllers/login_controller.dart

@ -1,3 +1,4 @@
import 'package:deep_voice/data/models/appconfig.dart';
import 'package:deep_voice/data/models/appconfig_model.dart';
import 'package:get/get.dart';
import 'package:flutter/material.dart';
@ -739,8 +740,10 @@ class LoginController extends GetxController {
// 根据API设计调整
final resp = UserGetAppConfigResp.fromJson(response);
// 存储用户信息(示例)
_storage.write('ENV', resp.env);
_storage.write('MCPS', resp.mcps);
// _storage.write('ENV', resp.env);
// _storage.write('AGENTS', resp.agents);
// _storage.write('MCPS', resp.mcps);
AppConfig.initialize(resp);
return true;
} catch (e) {
print('getAppConfig 取失败: $e');

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

@ -8,6 +8,7 @@ import 'dart:io';
import '../../../data/services/bluetooth_manager.dart';
import 'package:classic_bluetooth/classic_bluetooth.dart' show BluetoothDevice;
import '../../../data/services/ble_manager.dart';
import 'package:agent_service/agent_service.dart';
/// 配对控制器,负责检查蓝牙权限、状态,并处理配对流程
class PairingController extends GetxController {
static PairingController get to => Get.find();
@ -87,7 +88,7 @@ class PairingController extends GetxController {
statusDescription.value = 'searchingDevice'.tr;
// 设备连接时启动BLE后台服务
_startBleService();
_startAgentService();
await refreshConnectedDevices();
} catch (e) {
@ -241,7 +242,7 @@ class PairingController extends GetxController {
}
/// 启动BLE后台服务
Future<void> _startBleService() async {
Future<void> _startAgentService() async {
try {
// if (!isDeviceConnected) return;
@ -256,7 +257,7 @@ class PairingController extends GetxController {
}
// 启动BLE服务,无需传递配置参数,将从.env读取
final result = await BleManager.to.startBleService();
final result = await BleManager.to.startAgentService();
if (result) {
Logger.info('BLE后台服务启动成功');
@ -269,9 +270,9 @@ class PairingController extends GetxController {
}
/// 停止BLE后台服务
Future<void> _stopBleService() async {
Future<void> _stopAgentService() async {
try {
final result = await BleManager.to.stopBleService();
final result = await BleManager.to.stopAgentService();
if (result) {
Logger.info('BLE后台服务停止成功');

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

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:amap_flutter/amap_flutter.dart';
import 'package:deep_voice/data/models/appconfig.dart';
import 'package:deep_voice/data/models/appconfig_model.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
@ -132,9 +133,10 @@ class SplashController extends GetxController {
final resp = UserGetAppConfigResp.fromJson(response);
print('getAppConfig:进来了');
// 存储用户信息(示例)
_storage.write('ENV', resp.env);
_storage.write('MCPS', resp.mcps);
// _storage.write('ENV', resp.env);
// _storage.write('AGENTS', resp.agents);
// _storage.write('MCPS', resp.mcps);
AppConfig.initialize(resp);
return true;
} catch (e) {
print('getAppConfig 取失败: $e');

13
local_plugins/agent_service/android/build.gradle.kts

@ -34,13 +34,18 @@ android {
}
dependencies {
add("implementation", "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.10")
add("implementation", "androidx.core:core-ktx:1.8.0")
add("implementation", "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.10")
implementation("androidx.core:core-ktx:1.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0")
implementation("androidx.work:work-runtime-ktx:2.8.1")
implementation("androidx.media:media:1.6.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("androidx.security:security-crypto:1.1.0-alpha03")
// 依赖本地其他插件
add("compileOnly", project(":azure_speech"))
add("compileOnly", project(":open_ai_service"))
add("compileOnly", project(":deepvoice_utils"))
add("compileOnly", project(":chat_storage"))
add("compileOnly", project(":ble_service"))
}

37
local_plugins/agent_service/android/src/main/AndroidManifest.xml

@ -2,4 +2,41 @@
package="com.yunqiinnovation.agent_service">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application>
<!-- 媒体前台服务 -->
<service
android:name=".BleMediaService"
android:exported="true"
android:enabled="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</service>
<!-- 媒体按钮 Receiver -->
<receiver
android:name="androidx.media.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- 被系统 PendingIntent 触发的 Receiver -->
<receiver
android:name=".BleWakeReceiver"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="com.yunqiinnovation.ble_service.ACTION_BLE_SCAN" />
</intent-filter>
</receiver>
</application>
</manifest>

8
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt

@ -12,7 +12,7 @@ import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.*
import java.util.Collections
import kotlin.coroutines.CoroutineContext
import com.yunqiinnovation.ble_service.BleService
/**
* 代理服务事件监听接口
*/
@ -50,7 +50,7 @@ object AgentService : CoroutineScope {
private var openaiModel: String = "gpt-3.5-turbo"
// Azure服务
var azureAsrHelper: AzureAsrHelper? = null
private var azureAsrHelper: AzureAsrHelper? = null
private var azureTtsHelper: AzureTtsHelper? = null
// OpenAI服务
@ -392,6 +392,9 @@ object AgentService : CoroutineScope {
}
}
fun pushAudioData(data: ByteArray) {
azureAsrHelper?.pushAudioData(data)
}
/**
* 停止语音识别
*/
@ -402,6 +405,7 @@ object AgentService : CoroutineScope {
try {
azureAsrHelper?.stopContinuousRecognition()
BleService.closeCodec()
isRecognitionActive = false
stopIdleCheck()
FileLogger.d(TAG, "语音识别已停止")

47
local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt

@ -12,6 +12,8 @@ import com.yunqiinnovation.deepvoice_utils.FileLogger
import org.json.JSONObject
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import android.content.Intent
import android.os.Build
class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.StreamHandler, AgentServiceListener {
private val TAG = "AgentServicePlugin"
@ -36,7 +38,50 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
when (call.method) {
"startAgentService" -> {
try {
val config = call.argument<Map<String, Any>>("config")
if (config == null) {
result.error("INVALID_ARGUMENTS", "配置参数不能为空", null)
return
}
FileLogger.d(TAG, "启动BleMediaService,配置参数: $config")
// 创建Intent启动BleMediaService
val intent = Intent(context, BleMediaService::class.java)
// 将参数添加到Intent
config["azureSpeechKey"]?.toString()?.let { intent.putExtra("azureSpeechKey", it) }
config["azureSpeechRegion"]?.toString()?.let { intent.putExtra("azureSpeechRegion", it) }
config["openaiApiKey"]?.toString()?.let { intent.putExtra("openaiApiKey", it) }
config["openaiBaseUrl"]?.toString()?.let { intent.putExtra("openaiBaseUrl", it) }
config["openaiModel"]?.toString()?.let { intent.putExtra("openaiModel", it) }
config["mcpServer"]?.toString()?.let { intent.putExtra("mcpServer", it) }
// 启动前台服务
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
result.success(true)
} catch (e: Exception) {
FileLogger.e(TAG, "启动AgentService失败", e)
result.error("SERVICE_START_ERROR", "启动AgentService失败: ${e.message}", null)
}
}
"stopAgentService" -> {
try {
val intent = Intent(context, BleMediaService::class.java)
context.stopService(intent)
result.success(true)
} catch (e: Exception) {
FileLogger.e(TAG, "停止AgentService失败", e)
result.error("SERVICE_STOP_ERROR", "停止AgentService失败: ${e.message}", null)
}
}
"startConversation" -> {
try {
val isStarted = AgentService.startRecognition(false)

12
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/ChatAgent.kt → local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt

@ -1,17 +1,18 @@
package com.yunqiinnovation.ble_service
package com.yunqiinnovation.agent_service
import android.content.Context
import com.yunqiinnovation.agent_service.AgentService
import com.yunqiinnovation.agent_service.AgentServiceListener
import com.yunqiinnovation.deepvoice_utils.FileLogger
import com.yunqiinnovation.ble_service.BleService
import com.yunqiinnovation.ble_service.BleConst
/**
* 蓝牙语音交互代理
* 连接BleService和AgentService,响应蓝牙设备唤醒信号,启动语音识别和交互
*/
object ChatAgent : BleService.Callback, AgentServiceListener {
private const val TAG = "ChatAgent"
object BleAgent : BleService.Callback, AgentServiceListener {
private const val TAG = "BleAgent"
// 是否已初始化
private var isInitialized = false
@ -127,7 +128,8 @@ object ChatAgent : BleService.Callback, AgentServiceListener {
}
override fun onAudioDataReceived(data: ByteArray) {
AgentService.azureAsrHelper?.pushAudioData(data)
// FileLogger.d(TAG, "onAudioDataReceived, data: ${data.size}")
AgentService.pushAudioData(data)
// 可选:处理音频数据
}

101
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleMediaService.kt → local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt

@ -1,10 +1,13 @@
package com.yunqiinnovation.ble_service
package com.yunqiinnovation.agent_service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanSettings
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@ -28,6 +31,8 @@ import android.view.KeyEvent
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import com.yunqiinnovation.ble_service.BleConst
import com.yunqiinnovation.ble_service.BleService
/**
* 语音助手媒体服务
* 职责:
@ -149,18 +154,7 @@ class BleMediaService : Service(), CoroutineScope {
return START_STICKY
}
/**
* 注册后台蓝牙扫描
*/
private fun registerBackgroundScan() {
try {
BleService.registerBackgroundScan()
FileLogger.i(TAG, "后台蓝牙扫描已注册")
} catch (e: Exception) {
FileLogger.e(TAG, "注册后台蓝牙扫描失败", e)
}
}
/**
* 初始化语音交互代理
*/
@ -194,7 +188,7 @@ class BleMediaService : Service(), CoroutineScope {
if(config.isNotEmpty()) {
// 初始化ChatAgent
FileLogger.i(TAG, "开始初始化ChatAgent")
val success = ChatAgent.initialize(applicationContext, config)
val success = BleAgent.initialize(applicationContext, config)
if (success) {
isChatAgentInitialized = true
FileLogger.i(TAG, "语音交互代理初始化成功")
@ -359,15 +353,15 @@ class BleMediaService : Service(), CoroutineScope {
* 包含防抖逻辑,避免短时间内重复触发
*/
private fun handleMediaAction() {
FileLogger.i(TAG, "handleMediaAction")
// 防抖处理逻辑
val currentTime = System.currentTimeMillis()
if (currentTime - lastActionTime >= ACTION_DEBOUNCE_MS) {
lastActionTime = currentTime
ChatAgent.onWakeupSignalReceived()
} else {
FileLogger.i(TAG, "媒体操作防抖: 忽略${ACTION_DEBOUNCE_MS}ms内的重复请求")
}
// FileLogger.i(TAG, "handleMediaAction")
// // 防抖处理逻辑
// val currentTime = System.currentTimeMillis()
// if (currentTime - lastActionTime >= ACTION_DEBOUNCE_MS) {
// lastActionTime = currentTime
// BleAgent.onWakeupSignalReceived()
// } else {
// FileLogger.i(TAG, "媒体操作防抖: 忽略${ACTION_DEBOUNCE_MS}ms内的重复请求")
// }
}
@ -386,7 +380,66 @@ class BleMediaService : Service(), CoroutineScope {
FileLogger.e(TAG, "连接设备失败: ${e.message}", e)
}
}
// ======================================================================================================
// 后台扫描与唤醒
// ======================================================================================================
/**
* 注册后台持久扫描,APP被杀死后也能唤醒
*/
fun registerBackgroundScan() {
val scanner = (getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter?.bluetoothLeScanner ?: return
try {
val filter = ScanFilter.Builder()
.setManufacturerData(0xEFAB, byteArrayOf(0xA0.toByte(), 0x01))
.build()
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.build()
// 创建Intent并设置前台接收器标志
val scanIntent = Intent(BleConst.ACTION_SCAN_RESULT)
.setClass(this, BleWakeReceiver::class.java)
.addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
val pi = PendingIntent.getBroadcast(
this, 0, scanIntent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
)
scanner.startScan(listOf(filter), settings, pi)
FileLogger.i(TAG, "后台持久扫描已注册")
} catch (e: Exception) {
FileLogger.e(TAG, "注册后台扫描失败: ${e.message}", e)
}
}
/**
* 注销后台持久扫描
*/
fun unregisterBackgroundScan() {
// 获取一个局部引用,避免并发访问问题
val scanner = (getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter?.bluetoothLeScanner ?: return
try {
// 创建Intent并设置前台接收器标志
val scanIntent = Intent(BleConst.ACTION_SCAN_RESULT)
.setClass(this, BleWakeReceiver::class.java)
.addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
val pi = PendingIntent.getBroadcast(
this, 0, scanIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
scanner.stopScan(pi)
FileLogger.i(TAG, "后台持久扫描已注销")
} catch (e: Exception) {
FileLogger.e(TAG, "注销后台扫描失败: ${e.message}", e)
}
}
/**
* 更新通知内容
*/

20
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeReceiver.kt → local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleWakeReceiver.kt

@ -1,4 +1,4 @@
package com.yunqiinnovation.ble_service
package com.yunqiinnovation.agent_service
import android.content.*
import android.bluetooth.le.*
@ -7,6 +7,7 @@ import android.view.KeyEvent
import android.media.AudioManager
import com.yunqiinnovation.deepvoice_utils.FileLogger
import com.yunqiinnovation.ble_service.BleService
import com.yunqiinnovation.ble_service.BleConst
/**
* BLE唤醒接收器
* 当耳机发送含特定UUID广播时,系统会触发此接收器
@ -18,6 +19,7 @@ class BleWakeReceiver : BroadcastReceiver() {
}
override fun onReceive(ctx: Context, intent: Intent) {
FileLogger.d(TAG, "onReceive, intent: $intent, ${BleConst.ACTION_SCAN_RESULT}")
FileLogger.init(ctx)
try {
@ -26,20 +28,26 @@ class BleWakeReceiver : BroadcastReceiver() {
}
val list = intent.getParcelableArrayListExtra<ScanResult>(
BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT
) ?: return
)
// FileLogger.d(TAG, "list: $list")
if (list.isEmpty()) {
if (list?.isEmpty() == true || list == null) {
FileLogger.d(TAG, "扫描结果为空")
return
}
val mac = list.first().device.address
// FileLogger.d(TAG, "被系统唤醒,发现设备 $mac")
val mac = list.firstOrNull()?.device?.address
if (mac == null) {
FileLogger.d(TAG, "没有获取到设备MAC地址")
return
}
FileLogger.d(TAG, "被系统唤醒,发现设备 $mac")
if(!BleMediaService.isServiceRunning()) {
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val keyEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)
val keyEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK)
am.dispatchMediaKeyEvent(keyEvent)
// val mediaIntent = Intent(Intent.ACTION_MEDIA_BUTTON)
// mediaIntent.putExtra(Intent.EXTRA_KEY_EVENT, keyEvent)

2
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BootReceiver.kt → local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BootReceiver.kt

@ -1,4 +1,4 @@
package com.yunqiinnovation.ble_service
package com.yunqiinnovation.agent_service
import android.content.*
import android.os.Build

2
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/utils.kt → local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/utils.kt

@ -1,4 +1,4 @@
package com.yunqiinnovation.ble_service
package com.yunqiinnovation.agent_service
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences

994
local_plugins/agent_service/ios/Classes/AgentService.swift

@ -0,0 +1,994 @@
import Foundation
import MicrosoftCognitiveServicesSpeech
import open_ai_service
import chat_storage
/// 代理服务 - 处理语音识别、TTS和AI对话相关逻辑
///
/// 负责集成Azure语音服务、OpenAI服务和本地存储服务,
/// 提供语音识别、语音合成、AI对话等功能
class AgentService {
// 常量定义
private let TAG = "AgentService"
// 上下文和监听器
private var listeners = NSHashTable<AnyObject>.weakObjects()
// 配置参数
private var azureSpeechKey: String = ""
private var azureSpeechRegion: String = ""
private var openaiApiKey: String = ""
private var openaiBaseUrl: String = ""
private var openaiModel: String = "gpt-3.5-turbo"
private var mcpServer: String = ""
// Azure服务
private var azureAsrHelper: AzureAsrHelper?
private var azureTtsHelper: AzureTtsHelper?
// OpenAI服务
private var openAIService: OpenAIServiceBridge?
// 聊天存储服务
private var chatStorageHelper: ChatStorageHelperBridge?
// 会话ID,用于区分不同聊天上下文
private let sessionId = "default_agent"
// 历史聊天消息缓存
private var historyMessages = [[String: Any]]()
// 系统提示词
private var systemPrompt = ""
// 状态
private var isInitialized = false
private var isRecognitionActive = false
private var isTtsSpeaking = false
private var hasSpeechDetected = false
private var isAiStreaming = false
// AI流生成相关
private var currentAiTask: DispatchWorkItem?
// 空闲检测相关
private var idleCheckTimer: Timer?
private let maxIdleSeconds: TimeInterval = 10 // 最大空闲秒数
/// 初始化
init() {
// 创建OpenAI服务桥接器
openAIService = OpenAIServiceBridge()
// 创建聊天存储桥接器
chatStorageHelper = ChatStorageHelperBridge()
}
/**
* 初始化系统提示词
*/
private func initSystemPrompt() {
systemPrompt = """
你是一个友好、专业的语音助手,名叫"小语"。你的目标是通过对话为用户提供帮助、解答问题和完成任务。
遵循以下指导原则:
1. 保持简短精炼的回答,因为用户是通过语音与你交流
2. 优先使用中文回复,除非用户明确要求使用其他语言
3. 当用户问题不明确时,礼貌地请求更多信息
4. 避免过长的列表,尽量将信息分成小段
5. 不要使用需要视觉展示的元素(如表格、图表或代码块)
6. 记住用户之前的对话内容,保持对话连贯
7. 如果用户发送了图片,请根据图片内容和文字要求回答问题
你不仅可以回答知识性问题,还可以帮助用户设置提醒、提供建议,或进行轻松愉快的对话。
无论遇到什么问题,都要尽力以温暖、贴心的语气提供最佳帮助。
"""
}
/**
* 初始化
*
* - Parameter config: 配置参数,包含所需的所有API密钥和设置
* - Returns: 是否初始化成功
*/
func initialize(config: [String: Any]) -> Bool {
if isInitialized { return true }
do {
// 从配置中获取参数
if let azureKey = config["azureSpeechKey"] as? String {
azureSpeechKey = azureKey
}
if let azureRegion = config["azureSpeechRegion"] as? String {
azureSpeechRegion = azureRegion
}
if let openaiKey = config["openaiApiKey"] as? String {
openaiApiKey = openaiKey
}
if let baseUrl = config["openaiBaseUrl"] as? String {
openaiBaseUrl = baseUrl
}
if let model = config["openaiModel"] as? String {
openaiModel = model
}
if let server = config["mcpServer"] as? String {
mcpServer = server
}
// 自定义系统提示词
if let customSystemPrompt = config["systemPrompt"] as? String, !customSystemPrompt.isEmpty {
systemPrompt = customSystemPrompt
} else {
// 使用默认系统提示词
initSystemPrompt()
}
// 检查必要参数
if azureSpeechKey.isEmpty || azureSpeechRegion.isEmpty || openaiApiKey.isEmpty {
print("\(TAG): 初始化失败:关键配置参数缺失")
return false
}
// 初始化OpenAI服务
openAIService?.initialize(apiKey: openaiApiKey, baseUrl: openaiBaseUrl, model: openaiModel, mcpServer: mcpServer)
// 初始化Azure ASR
azureAsrHelper = AzureAsrHelper(self)
let asrInitResult = azureAsrHelper?.initialize(
subscriptionKey: azureSpeechKey,
region: azureSpeechRegion,
audioSourceType: .microphone
) ?? false
if !asrInitResult {
print("\(TAG): Azure ASR初始化失败")
return false
}
// 初始化Azure TTS
azureTtsHelper = AzureTtsHelper(self)
let ttsInitResult = azureTtsHelper?.initialize(
subscriptionKey: azureSpeechKey,
region: azureSpeechRegion,
callback: self
) ?? false
if !ttsInitResult {
print("\(TAG): Azure TTS初始化失败")
return false
}
// 加载最近的聊天记录
loadChatHistory()
isInitialized = true
print("\(TAG): 代理服务初始化成功")
return true
} catch {
print("\(TAG): 初始化失败:\(error.localizedDescription)")
return false
}
}
/**
* 添加事件监听器
*
* - Parameter listener: 要添加的监听器
*/
func addListener(_ listener: AgentServiceListener) {
listeners.add(listener as AnyObject)
}
/**
* 设置事件监听器(替换现有监听器)
*
* - Parameter listener: 要设置的监听器
*/
func setListener(_ listener: AgentServiceListener) {
listeners.removeAllObjects()
listeners.add(listener as AnyObject)
}
/**
* 移除事件监听器
*
* - Parameter listener: 要移除的监听器
*/
func removeListener(_ listener: AgentServiceListener) {
listeners.remove(listener as AnyObject)
}
/**
* 移除所有事件监听器
*/
func clearListeners() {
listeners.removeAllObjects()
}
/**
* 启动空闲检测
*/
private func startIdleCheck() {
stopIdleCheck() // 先停止现有的检查
if !isRecognitionActive { return }
// 创建定时器
idleCheckTimer = Timer.scheduledTimer(withTimeInterval: maxIdleSeconds, repeats: false) { [weak self] _ in
guard let self = self else { return }
// 如果状态仍然是空闲的,则停止识别
if self.isRecognitionActive && !self.hasSpeechDetected && !self.isTtsSpeaking && !self.isAiStreaming {
self.stopRecognition()
self.sendEvent("auto_stop", ["reason": "idle_timeout", "seconds": self.maxIdleSeconds])
}
}
}
/**
* 停止空闲检测
*/
private func stopIdleCheck() {
idleCheckTimer?.invalidate()
idleCheckTimer = nil
}
/**
* 重启空闲检测
* 当状态发生变化时调用
*/
private func restartIdleCheck() {
if isRecognitionActive {
startIdleCheck()
}
}
/**
* 开始语音识别
*
* - Returns: 是否成功开始识别
*/
func startRecognition() -> Bool {
if !isInitialized {
print("\(TAG): 服务未初始化")
return false
}
if isRecognitionActive { return true }
isRecognitionActive = true
hasSpeechDetected = false
do {
// 创建连续识别回调
class ContinuousRecognizeCallbackImpl: AzureAsrHelper.ContinuousRecognizeCallback {
private weak var service: AgentService?
init(_ service: AgentService) {
self.service = service
}
func onRecognizing(recognizing: String, detectedLanguage: String) {
guard let service = service else { return }
if !recognizing.isEmpty {
// 检测到语音,更新状态
let previousHasSpeech = service.hasSpeechDetected
service.hasSpeechDetected = true
// 状态发生变化时重启空闲检测
if !previousHasSpeech {
service.restartIdleCheck()
}
service.sendEvent("recognizing", [
"text": recognizing,
"language": detectedLanguage
])
// 如果TTS正在播放或AI正在生成,则触发打断
if service.isTtsSpeaking || service.isAiStreaming {
service.interruptCurrentResponse()
}
}
}
func onResult(text: String, detectedLanguage: String) {
guard let service = service else { return }
if !text.isEmpty {
service.sendEvent("recognition_result", [
"text": text,
"language": detectedLanguage
])
service.processWithOpenAI(text: text)
}
// 重置状态,继续识别
let previousHasSpeech = service.hasSpeechDetected
service.hasSpeechDetected = false
// 状态发生变化时重启空闲检测
if previousHasSpeech {
service.restartIdleCheck()
}
}
func onSessionStarted() {
guard let service = service else { return }
service.sendEvent("recognition_started", ["status": "started"])
// 启动空闲检测
service.startIdleCheck()
}
func onSessionStopped() {
guard let service = service else { return }
service.isRecognitionActive = false
service.stopIdleCheck()
service.sendEvent("recognition_stopped", ["status": "stopped"])
}
func onCanceled(reason: String, errorDetails: String) {
guard let service = service else { return }
service.isRecognitionActive = false
service.stopIdleCheck()
service.sendEvent("recognition_canceled", [
"reason": reason,
"details": errorDetails
])
}
func onError(error: String) {
guard let service = service else { return }
service.isRecognitionActive = false
service.stopIdleCheck()
print("\(service.TAG): 语音识别出错: \(error)")
service.sendEvent("error", [
"code": "RECOGNITION_ERROR",
"message": error
])
}
}
let callback = ContinuousRecognizeCallbackImpl(self)
let result = azureAsrHelper?.startContinuousRecognition(callback) ?? false
if !result {
isRecognitionActive = false
print("\(TAG): 启动语音识别失败")
sendEvent("error", [
"code": "RECOGNITION_START_ERROR",
"message": "启动语音识别失败"
])
}
return result
} catch {
isRecognitionActive = false
print("\(TAG): 启动语音识别失败: \(error.localizedDescription)")
sendEvent("error", [
"code": "RECOGNITION_START_ERROR",
"message": error.localizedDescription
])
return false
}
}
/**
* 停止语音识别
*/
func stopRecognition() {
if !isRecognitionActive { return }
print("\(TAG): 停止语音识别")
// 停止识别
let _ = azureAsrHelper?.stopContinuousRecognition()
isRecognitionActive = false
stopIdleCheck()
print("\(TAG): 语音识别已停止")
}
/**
* 打断当前响应
* 停止TTS播放和AI流输出
*/
func interruptCurrentResponse() {
if isAiStreaming || isTtsSpeaking {
// 停止TTS播放
stopTts()
// 停止AI流输出
stopAiStream()
// 发送打断事件
sendEvent("response_interrupted", ["status": "interrupted"])
}
}
/**
* 停止AI流输出
*/
private func stopAiStream() {
if isAiStreaming {
// 取消当前AI生成任务
currentAiTask?.cancel()
currentAiTask = nil
// 通知OpenAI服务终止当前流式请求
openAIService?.cancelCurrentStream()
// 更新状态
isAiStreaming = false
// 记录日志
print("\(TAG): AI流输出已停止")
}
}
/**
* 处理文本输入
* 作为语音输入的补充,直接处理文本并通过事件返回结果
*
* - Parameters:
* - text: 用户输入文本
* - speakResponse: 是否朗读回复,默认为false
* - Returns: 是否成功开始处理
*/
func processTextInput(text: String, speakResponse: Bool = false) -> Bool {
if !isInitialized {
print("\(TAG): 服务未初始化")
sendEvent("error", ["code": "NOT_INITIALIZED", "message": "服务未初始化"])
return false
}
if text.isEmpty {
print("\(TAG): 文本输入不能为空")
sendEvent("error", ["code": "EMPTY_TEXT", "message": "文本输入不能为空"])
return false
}
// 使用OpenAI处理文本
processWithOpenAI(text: text, speakResponse: speakResponse)
return true
}
/**
* 使用OpenAI处理语音识别结果
*
* - Parameter text: 用户输入文本
*/
private func processWithOpenAI(text: String) {
processWithOpenAI(text: text, speakResponse: true)
}
/**
* 使用OpenAI处理文本消息
*
* - Parameters:
* - text: 用户输入文本
* - speakResponse: 是否使用TTS朗读回复
*/
private func processWithOpenAI(text: String, speakResponse: Bool = true) {
print("\(TAG): 用户问题: \(text)")
// 创建用户文本消息并处理
guard let userMessage = openAIService?.createUserMessage(text: text) else {
print("\(TAG): 创建用户消息失败")
return
}
processWithOpenAIInternal(userMessage: userMessage, displayText: text, speakResponse: speakResponse)
}
/**
* 使用OpenAI处理图片
*
* - Parameters:
* - imagePath: 图片文件路径
* - text: 可选的文本描述或问题
* - speakResponse: 是否朗读回复
* - Returns: 是否成功开始处理
*/
func processImageInput(imagePath: String, text: String = "", speakResponse: Bool = false) -> Bool {
if !isInitialized {
print("\(TAG): 服务未初始化")
sendEvent("error", ["code": "NOT_INITIALIZED", "message": "服务未初始化"])
return false
}
if imagePath.isEmpty {
print("\(TAG): 图片路径不能为空")
sendEvent("error", ["code": "EMPTY_IMAGE_PATH", "message": "图片路径不能为空"])
return false
}
// 通知开始处理图片
sendEvent("image_processing", [
"status": "processing",
"imagePath": imagePath
])
// 异步处理图片
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else { return }
// 将图片转换为Base64格式
guard let imageBase64 = self.openAIService?.fileToBase64(filePath: imagePath) else {
DispatchQueue.main.async {
print("\(self.TAG): 图片转换失败: \(imagePath)")
self.sendEvent("error", [
"code": "IMAGE_CONVERSION_FAILED",
"message": "图片转换失败"
])
}
return
}
// 通知图片准备完成
DispatchQueue.main.async {
self.sendEvent("image_ready", [
"status": "ready",
"imagePath": imagePath
])
// 处理包含图片的消息
self.processImageWithOpenAI(imageBase64: imageBase64, text: text, speakResponse: speakResponse)
}
}
return true
}
/**
* 使用OpenAI处理图片
*
* - Parameters:
* - imageBase64: Base64编码的图片数据
* - text: 可选的文本描述或问题
* - speakResponse: 是否朗读回复
*/
private func processImageWithOpenAI(imageBase64: String, text: String = "", speakResponse: Bool = false) {
print("\(TAG): 处理图片输入: \(text.isEmpty ? "无附加文本" : "附带文本: \(text)")")
// 创建带图片的用户消息并处理
guard let userMessage = openAIService?.createUserMessageWithImage(text: text, imageBase64: imageBase64) else {
print("\(TAG): 创建带图片的用户消息失败")
return
}
// 图片描述用于存储
let displayText = text.isEmpty ? "[图片]" : text
processWithOpenAIInternal(userMessage: userMessage, displayText: displayText, speakResponse: speakResponse, hasImage: true)
}
/**
* 内部方法:通用的OpenAI处理逻辑
*
* - Parameters:
* - userMessage: 用户消息(可以是文本或图片格式)
* - displayText: 用于显示和存储的文本
* - speakResponse: 是否朗读回复
* - hasImage: 是否包含图片
*/
private func processWithOpenAIInternal(userMessage: [String: Any], displayText: String, speakResponse: Bool = true, hasImage: Bool = false) {
// 如果有正在进行的AI流式输出,先停止它
stopAiStream()
// 创建AI任务
let workItem = DispatchWorkItem { [weak self] in
guard let self = self else { return }
// 设置状态为正在流式输出
self.isAiStreaming = true
// 使用历史记录作为上下文发送到OpenAI
var responseBuilder = ""
// 添加系统提示词到历史消息的副本中
var messagesWithSystemPrompt: [[String: Any]] = []
// 先添加系统提示词
if !self.systemPrompt.isEmpty {
if let systemMessage = self.openAIService?.createSystemMessage(text: self.systemPrompt) {
messagesWithSystemPrompt.append(systemMessage)
}
}
// 再添加历史消息
messagesWithSystemPrompt.append(contentsOf: self.historyMessages)
messagesWithSystemPrompt.append(userMessage)
// 将用户消息添加到历史记录
self.addToHistoryMessages(userMessage)
// 流式回调
class StreamCallbackBridge: NSObject, OpenAIStreamCallback {
private weak var service: AgentService?
private var responseBuilder: String
private let speakResponse: Bool
private let displayText: String
private let hasImage: Bool
init(_ service: AgentService, responseBuilder: String = "", speakResponse: Bool, displayText: String, hasImage: Bool) {
self.service = service
self.responseBuilder = responseBuilder
self.speakResponse = speakResponse
self.displayText = displayText
self.hasImage = hasImage
super.init()
}
func onToken(token: String) {
guard let service = service else { return }
responseBuilder.append(token)
if speakResponse {
_ = service.azureTtsHelper?.speakStream(token)
}
// 发送流式回复token
service.sendEvent("assistant_token", ["token": token])
}
func onComplete() {
guard let service = service else { return }
// 视情况决定是否朗读回复
if speakResponse {
_ = service.azureTtsHelper?.flushStream()
}
if !responseBuilder.isEmpty {
// 发送完整回复,包含是否有图片的标记
var responseData: [String: Any] = [
"text": responseBuilder,
"userInput": displayText
]
if hasImage {
responseData["hasImage"] = true
}
service.sendEvent("assistant_response", responseData)
// 添加AI回复到历史记录
if let assistantMessage = service.openAIService?.createAssistantMessage(text: responseBuilder) {
service.addToHistoryMessages(assistantMessage)
}
// 保存聊天记录
service.saveChatMessage(userMessage: displayText, assistantMessage: responseBuilder)
}
// 标记AI流式输出已完成
service.isAiStreaming = false
service.currentAiTask = nil
}
func onError(error: Error) {
guard let service = service else { return }
print("\(service.TAG): AI处理出错: \(error.localizedDescription)")
service.sendEvent("error", [
"code": "AI_ERROR",
"message": error.localizedDescription
])
// 标记AI流式输出已完成
service.isAiStreaming = false
service.currentAiTask = nil
}
func onFunctionCall(call: [String: Any]) {
guard let service = service else { return }
if let name = call["name"] as? String {
service.sendEvent("function_call", [
"name": name,
"arguments": call
])
if name == "exit_interaction" {
service.stopRecognition()
}
}
}
func onFunctionCallResult(functionCall: [String: Any], functionCallResult: [String: Any]) {
guard let service = service else { return }
service.sendEvent("function_call_result", [
"function_call": functionCall,
"result": functionCallResult,
])
}
}
let callback = StreamCallbackBridge(self, responseBuilder: responseBuilder, speakResponse: speakResponse, displayText: displayText, hasImage: hasImage)
self.openAIService?.sendMessageStream(messages: messagesWithSystemPrompt, callback: callback)
}
// 保存任务引用并在全局队列中执行
currentAiTask = workItem
DispatchQueue.global(qos: .userInitiated).async(execute: workItem)
}
/**
* 加载最近的聊天历史记录
*/
private func loadChatHistory() {
// 清空当前历史记录
historyMessages.removeAll()
// 使用ChatStorageHelper直接获取最近消息
guard let recentMessages = chatStorageHelper?.getRecentMessages(sessionId: sessionId, count: 10) else {
print("\(TAG): 没有找到历史记录")
return
}
// 将消息添加到历史记录
for message in recentMessages {
if let sender = message["sender"] as? String,
let content = message["message"] as? String {
if sender == "user" {
if let userMessage = openAIService?.createUserMessage(text: content) {
addToHistoryMessages(userMessage)
}
} else if sender == "assistant" {
if let assistantMessage = openAIService?.createAssistantMessage(text: content) {
addToHistoryMessages(assistantMessage)
}
}
}
}
print("\(TAG): 已加载\(recentMessages.count)条历史记录")
}
/**
* 添加消息到历史记录,保持最近10条
*/
private func addToHistoryMessages(_ message: [String: Any]) {
// 添加新消息
historyMessages.append(message)
// 如果超过10条,删除最早的消息
while historyMessages.count > 10 {
historyMessages.removeFirst()
}
}
/**
* TTS播放函数
*
* - Parameter text: 要播放的文本
*/
func speakText(text: String) {
if text.isEmpty { return }
// 更新状态
isTtsSpeaking = true
restartIdleCheck() // 状态变化,重启检测
// 播放文本
_ = azureTtsHelper?.speakText(text)
}
/**
* 停止TTS播放
*/
func stopTts() {
if isTtsSpeaking {
_ = azureTtsHelper?.stopSpeaking()
isTtsSpeaking = false
restartIdleCheck() // 状态变化,重启检测
sendEvent("tts_stopped", ["status": "stopped"])
}
}
/**
* 保存聊天消息
*/
private func saveChatMessage(userMessage: String, assistantMessage: String) {
DispatchQueue.global(qos: .background).async { [weak self] in
guard let self = self else { return }
// 保存用户消息
let userMessageId = self.chatStorageHelper?.saveMessage(
sessionId: self.sessionId,
message: userMessage,
sender: "user"
) ?? -1
if userMessageId != -1 {
// 保存AI回复
let assistantMessageId = self.chatStorageHelper?.saveMessage(
sessionId: self.sessionId,
message: assistantMessage,
sender: "assistant"
) ?? -1
if assistantMessageId == -1 {
print("\(self.TAG): 保存助手消息失败")
}
} else {
print("\(self.TAG): 保存用户消息失败")
}
}
}
/**
* 清除聊天历史
*/
func clearChatHistory(completion: @escaping (Bool) -> Void) {
DispatchQueue.global(qos: .background).async { [weak self] in
guard let self = self else {
DispatchQueue.main.async {
completion(false)
}
return
}
// 清除指定会话的所有消息
let success = self.chatStorageHelper?.deleteMessages(sessionId: self.sessionId) ?? false
if success {
// 清空内存中的历史记录
self.historyMessages.removeAll()
print("\(self.TAG): 聊天历史已清除")
} else {
print("\(self.TAG): 清除聊天历史失败")
}
DispatchQueue.main.async {
completion(success)
}
}
}
/**
* 发送事件
*/
private func sendEvent(_ eventName: String, _ data: [String: Any]) {
// 向所有监听器发送事件
let allListeners = listeners.allObjects
for case let listener as AgentServiceListener in allListeners {
listener.onEvent(eventName: eventName, data: data)
}
}
/**
* 释放资源
*/
func dispose() {
stopRecognition()
stopTts()
stopAiStream()
stopIdleCheck()
azureAsrHelper?.dispose()
azureTtsHelper?.dispose()
// 清除所有监听器
clearListeners()
// 重置单例状态,以便下次使用时可以重新初始化
isInitialized = false
print("\(TAG): 代理服务资源已释放")
}
}
// MARK: - AzureTtsHelper.TtsCallback
extension AgentService: AzureTtsHelper.TtsCallback {
func onSynthesisStarted() {
isTtsSpeaking = true
// 状态变化,重置空闲检测
restartIdleCheck()
sendEvent("tts_started", ["status": "started"])
}
func onSynthesizing() {
// 可以在此添加TTS合成中的处理逻辑
}
func onSynthesisCompleted() {
isTtsSpeaking = false
// 状态变化,重启空闲检测
restartIdleCheck()
sendEvent("tts_completed", ["status": "completed"])
}
func onSynthesisCanceled() {
isTtsSpeaking = false
// 状态变化,重启空闲检测
restartIdleCheck()
sendEvent("tts_canceled", ["status": "canceled"])
}
}
// MARK: - 外部插件桥接器
/// 桥接OpenAIService插件
class OpenAIServiceBridge {
private let service = OpenAIService()
/// 初始化服务
func initialize(apiKey: String, baseUrl: String, model: String, mcpServer: String) {
service.initialize(apiKey: apiKey, baseUrl: baseUrl, model: model, mcpServer: mcpServer)
}
/// 创建系统消息
func createSystemMessage(text: String) -> [String: Any] {
return service.createSystemMessage(text)
}
/// 创建用户消息
func createUserMessage(text: String) -> [String: Any] {
return service.createUserMessage(text)
}
/// 创建助手消息
func createAssistantMessage(text: String) -> [String: Any] {
return service.createAssistantMessage(text)
}
/// 创建带图片的用户消息
func createUserMessageWithImage(text: String, imageBase64: String) -> [String: Any]? {
return service.createUserMessageWithImage(text: text, imageBase64: imageBase64)
}
/// 文件转Base64
func fileToBase64(filePath: String) -> String? {
return service.fileToBase64(filePath: filePath)
}
/// 发送流式消息
func sendMessageStream(messages: [[String: Any]], callback: OpenAIStreamCallback) {
service.sendMessageStream(messages: messages, callback: callback)
}
/// 取消当前流
func cancelCurrentStream() {
service.cancelCurrentStream()
}
}
/// OpenAI流式回调协议
@objc protocol OpenAIStreamCallback {
func onToken(token: String)
func onComplete()
func onError(error: Error)
func onFunctionCall(call: [String: Any])
func onFunctionCallResult(functionCall: [String: Any], functionCallResult: [String: Any])
}
/// 桥接ChatStorageHelper插件
class ChatStorageHelperBridge {
private let helper = ChatStorageHelper()
/// 保存消息
func saveMessage(sessionId: String, message: String, sender: String) -> Int64 {
return helper.saveMessage(sessionId: sessionId, message: message, sender: sender)
}
/// 获取最近消息
func getRecentMessages(sessionId: String, count: Int) -> [[String: Any]]? {
return helper.getRecentMessages(sessionId: sessionId, count: count)
}
/// 删除消息
func deleteMessages(sessionId: String) -> Bool {
return helper.deleteMessages(sessionId: sessionId)
}
}

11
local_plugins/agent_service/ios/Classes/AgentServiceListener.swift

@ -0,0 +1,11 @@
import Foundation
/// 代理服务事件监听器协议
/// 用于接收代理服务发送的各类事件
protocol AgentServiceListener: AnyObject {
/// 事件回调
/// - Parameters:
/// - eventName: 事件名称
/// - data: 事件数据
func onEvent(eventName: String, data: [String: Any])
}

4
local_plugins/agent_service/ios/Classes/AgentServicePlugin.h

@ -0,0 +1,4 @@
#import <Flutter/Flutter.h>
@interface AgentServicePlugin : NSObject<FlutterPlugin>
@end

12
local_plugins/agent_service/ios/Classes/AgentServicePlugin.m

@ -0,0 +1,12 @@
#import "AgentServicePlugin.h"
#if __has_include(<agent_service/agent_service-Swift.h>)
#import <agent_service/agent_service-Swift.h>
#else
#import "agent_service-Swift.h"
#endif
@implementation AgentServicePlugin
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
[SwiftAgentServicePlugin registerWithRegistrar:registrar];
}
@end

259
local_plugins/agent_service/ios/Classes/SwiftAgentServicePlugin.swift

@ -0,0 +1,259 @@
import Flutter
import UIKit
import open_ai_service
import chat_storage
/// Swift版Agent Service插件
/// 处理Flutter与原生层的通信
public class SwiftAgentServicePlugin: NSObject, FlutterPlugin {
// Flutter方法通道
private var methodChannel: FlutterMethodChannel?
// Flutter事件通道
private var eventChannel: FlutterEventChannel?
// 事件sink,用于向Flutter发送事件
private var eventSink: FlutterEventSink?
// 核心服务实例
private var agentService: AgentService?
/// 注册插件
public static func register(with registrar: FlutterPluginRegistrar) {
let instance = SwiftAgentServicePlugin()
// 初始化方法通道
let methodChannel = FlutterMethodChannel(name: "com.yunqiinnovation.agent_service", binaryMessenger: registrar.messenger())
registrar.addMethodCallDelegate(instance, channel: methodChannel)
instance.methodChannel = methodChannel
// 初始化事件通道
let eventChannel = FlutterEventChannel(name: "com.yunqiinnovation.agent_service/events", binaryMessenger: registrar.messenger())
eventChannel.setStreamHandler(instance)
instance.eventChannel = eventChannel
}
/// 处理方法调用
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "initialize":
handleInitialize(call, result)
case "startConversation":
handleStartConversation(result)
case "stopConversation":
handleStopConversation(result)
case "processTextInput":
handleProcessTextInput(call, result)
case "speakText":
handleSpeakText(call, result)
case "stopTts":
handleStopTts(result)
case "clearChatHistory":
handleClearChatHistory(result)
case "interruptCurrentResponse":
handleInterruptCurrentResponse(result)
case "processImageInput":
handleProcessImageInput(call, result)
case "dispose":
handleDispose(result)
default:
result(FlutterMethodNotImplemented)
}
}
// MARK: - 方法处理
/// 初始化
private func handleInitialize(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
guard let args = call.arguments as? [String: Any],
let config = args["config"] as? [String: Any] else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "缺少配置参数", details: nil))
return
}
// 初始化代理服务
if agentService == nil {
agentService = AgentService()
// 设置事件监听器
agentService?.setListener(self)
}
// 执行初始化
let success = agentService?.initialize(config: config) ?? false
result(success)
}
/// 开始对话
private func handleStartConversation(_ result: @escaping FlutterResult) {
guard let service = agentService else {
result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil))
return
}
let success = service.startRecognition()
result(success)
}
/// 停止对话
private func handleStopConversation(_ result: @escaping FlutterResult) {
guard let service = agentService else {
result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil))
return
}
service.stopRecognition()
result(true)
}
/// 处理文本输入
private func handleProcessTextInput(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
guard let service = agentService else {
result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil))
return
}
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 speakResponse = (args["speakResponse"] as? Bool) ?? false
let success = service.processTextInput(text: text, speakResponse: speakResponse)
result(success)
}
/// 朗读文本
private func handleSpeakText(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
guard let service = agentService else {
result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil))
return
}
guard let args = call.arguments as? [String: Any],
let text = args["text"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "无效的文本参数", details: nil))
return
}
service.speakText(text: text)
result(true)
}
/// 停止语音合成
private func handleStopTts(_ result: @escaping FlutterResult) {
guard let service = agentService else {
result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil))
return
}
service.stopTts()
result(true)
}
/// 清除聊天历史
private func handleClearChatHistory(_ result: @escaping FlutterResult) {
guard let service = agentService else {
result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil))
return
}
service.clearChatHistory { success in
result(success)
}
}
/// 中断当前响应
private func handleInterruptCurrentResponse(_ result: @escaping FlutterResult) {
guard let service = agentService else {
result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil))
return
}
service.interruptCurrentResponse()
result(true)
}
/// 处理图片输入
private func handleProcessImageInput(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) {
guard let service = agentService else {
result(FlutterError(code: "NOT_INITIALIZED", message: "AgentService尚未初始化", details: nil))
return
}
guard let args = call.arguments as? [String: Any],
let imagePath = args["imagePath"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "无效的图片路径参数", details: nil))
return
}
let text = (args["text"] as? String) ?? ""
let speakResponse = (args["speakResponse"] as? Bool) ?? false
let success = service.processImageInput(imagePath: imagePath, text: text, speakResponse: speakResponse)
result(success)
}
/// 释放资源
private func handleDispose(_ result: @escaping FlutterResult) {
guard let service = agentService else {
result(true) // 已经是nil,直接返回成功
return
}
service.dispose()
agentService = nil
result(true)
}
}
// MARK: - FlutterStreamHandler
extension SwiftAgentServicePlugin: FlutterStreamHandler {
public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
self.eventSink = events
return nil
}
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
self.eventSink = nil
return nil
}
}
// MARK: - AgentServiceListener
extension SwiftAgentServicePlugin: AgentServiceListener {
func onEvent(eventName: String, data: [String: Any]) {
guard let sink = eventSink else { return }
do {
// 构建事件JSON
let eventMap: [String: Any] = [
"event": eventName,
"data": data
]
// 将事件转换为JSON字符串
let jsonData = try JSONSerialization.data(withJSONObject: eventMap, options: [])
if let jsonString = String(data: jsonData, encoding: .utf8) {
// 发送到Flutter
DispatchQueue.main.async {
sink(jsonString)
}
}
} catch {
print("事件序列化失败: \(error.localizedDescription)")
}
}
}

36
local_plugins/agent_service/lib/agent_service.dart

@ -189,6 +189,42 @@ class AgentService {
}
}
/// 启动语音助手服务
///
/// 启动常驻的语音助手服务,支持后台蓝牙设备唤醒和媒体按钮唤醒
/// [config] 配置参数,应包含以下参数:
/// - azureSpeechKey: Azure语音服务密钥
/// - azureSpeechRegion: Azure语音服务区域
/// - openaiApiKey: OpenAI API密钥
/// - openaiBaseUrl: (可选) OpenAI API 基础URL
/// - openaiModel: (可选) OpenAI模型名称,默认为"gpt-3.5-turbo"
/// - mcpServer: (可选) MCP服务器地址
///
/// 返回是否成功启动服务
static Future<bool> startAgentService(Map<String, dynamic> config) async {
try {
final bool result = await _channel.invokeMethod('startAgentService', {
'config': config,
});
return result;
} on PlatformException catch (e) {
throw AgentServiceException(e.code, e.message ?? '启动语音助手服务失败', e.details);
}
}
/// 停止语音助手服务
///
/// 停止常驻的语音助手服务
/// 返回是否成功停止服务
static Future<bool> stopAgentService() async {
try {
final bool result = await _channel.invokeMethod('stopAgentService');
return result;
} on PlatformException catch (e) {
throw AgentServiceException(e.code, e.message ?? '停止语音助手服务失败', e.details);
}
}
/// 开始对话
///
/// 启动语音识别,开始监听用户语音输入

2
local_plugins/agent_service/pubspec.yaml

@ -19,6 +19,8 @@ dependencies:
path: ../deepvoice_utils
chat_storage:
path: ../chat_storage
music_service:
path: ../music_service
get: ^4.6.5
dev_dependencies:

16
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt

@ -23,7 +23,7 @@ import com.microsoft.cognitiveservices.speech.audio.PullAudioInputStreamCallback
import com.microsoft.cognitiveservices.speech.audio.AudioStreamFormat
import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
/**
* Azure语音识别辅助类,支持麦克风和外部音频源的单次和连续语音识别
*
@ -122,7 +122,7 @@ class AzureAsrHelper(private val context: Context) {
}
// SetProperty(PropertyId.Speech_SegmentationStrategy, "Semantic");
}
// 创建识别器
return setupRecognizer()
} catch (e: Exception) {
@ -308,7 +308,7 @@ class AzureAsrHelper(private val context: Context) {
if (isContinuousRecognitionActive) {
return true
}
this.audioSourceType = audioSourceType
this.audioSourceType = audioSourceType
// 重置识别器
if (!setupRecognizer()) {
callback.onError("重置识别器失败")
@ -377,11 +377,12 @@ class AzureAsrHelper(private val context: Context) {
recognizer?.sessionStopped?.addEventListener(
EventHandler<SessionEventArgs> { _, _ ->
FileLogger.d(tag, "识别会话已结束")
// 直接在当前线程调用回调
callback.onSessionStopped()
isContinuousRecognitionActive = false
stopAudioProcessing()
// 直接在当前线程调用回调
callback.onSessionStopped()
}
)
@ -430,9 +431,12 @@ class AzureAsrHelper(private val context: Context) {
isContinuousRecognitionActive = false
return true
}
if (audioSourceType == AudioSourceType.EXTERNAL) {
pushAudioData(ByteArray(0))
}
// 直接停止连续识别(SDK内部已是异步操作)
recognizer?.stopContinuousRecognitionAsync()
recognizer?.stopContinuousRecognitionAsync()?.get(1000, TimeUnit.MILLISECONDS)
// 停止音频处理
stopAudioProcessing()

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

File diff suppressed because it is too large

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

@ -4,166 +4,234 @@ import MicrosoftCognitiveServicesSpeech
/// Azure 语音合成辅助类
class AzureTtsHelper: NSObject {
// 常量定义
private let tag = "AzureTtsHelper"
private let DEFAULT_LANGUAGE = "zh-CN"
private let DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural"
// 核心组件
private var synthesizer: SPXSpeechSynthesizer?
private var speechConfig: SPXSpeechConfig?
private var audioConfig: SPXAudioConfig?
private var speechConfig: SPXSpeechConfiguration?
private var audioConfig: SPXAudioConfiguration?
private var initialized = false
private var speaking = false
// 音频输出类型
enum AudioOutputType {
case speaker // 扬声器
case earpiece // 听筒
case auto // 自动选择
}
// 当前设置
private var currentVoiceName = "zh-CN-XiaoxiaoNeural"
private var currentSpeechRate = 0
private var currentPitch = 0
private var currentVolume = 100
private var currentAudioOutputType: AudioOutputType = .auto
private var currentVoice = "zh-CN-XiaoxiaoNeural"
private var currentRate = "0%"
private var currentPitch = "0%"
private var currentVolume = "100%"
// 音频会话管理
private let audioSession = AVAudioSession.sharedInstance()
// 自定义音频输出流
private var customAudioOutputStream: SPXPushAudioOutputStream?
// 流式文本处理的缓冲区
private var streamBuffer = ""
private var lastSpeakTime: TimeInterval = 0
// 应用上下文
private let context: Any
// 全局回调
private var ttsCallback: TtsCallback?
/// TTS回调接口
protocol TtsCallback {
/// 合成开始
func onSynthesisStarted()
/// 合成中
func onSynthesizing()
/// 合成完成
func onSynthesisCompleted()
/// 合成取消
func onSynthesisCanceled()
}
/// 初始化
/// - Parameter context: 应用上下文
init(_ context: Any) {
self.context = context
super.init()
}
/// 初始化 TTS 引擎
///
/// - Parameters:
/// - speechSubscriptionKey: Azure 语音服务订阅密钥
/// - serviceRegion: Azure 语音服务区域
/// - language: 可选,默认语言,默认为 "zh-CN"
/// - subscriptionKey: Azure 语音服务订阅密钥
/// - region: Azure 语音服务区域
/// - callback: 可选,全局TTS回调接口
/// - Returns: 是否初始化成功
func initialize(speechSubscriptionKey: String, serviceRegion: String, language: String = "zh-CN") -> Bool {
print("[AzureTtsHelper] 初始化 Azure 语音服务")
func initialize(
subscriptionKey: String,
region: String,
callback: TtsCallback? = nil
) -> Bool {
print("\(tag): 初始化 Azure 语音服务")
// 检查配置是否为空
if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty {
print("[AzureTtsHelper] 错误: Azure 配置信息不完整")
if subscriptionKey.isEmpty || region.isEmpty {
print("\(tag): 错误: Azure 配置信息不完整")
return false
}
// 释放之前的资源
dispose()
// 保存全局回调
ttsCallback = callback
do {
// 创建语音配置
speechConfig = try SPXSpeechConfig(subscription: speechSubscriptionKey, region: serviceRegion)
speechConfig = try SPXSpeechConfiguration(subscription: subscriptionKey, region: region)
// 设置语音合成输出格式为高质量音频
speechConfig?.setSpeechSynthesisOutputFormat(.riff24Khz16BitMonoPcm)
// 设置默认语言
speechConfig?.setSpeechSynthesisLanguage(language)
// 设置默认语音
speechConfig?.setSpeechSynthesisVoiceName(currentVoiceName)
speechConfig?.setSpeechSynthesisVoiceName(currentVoice)
// 创建音频配置 - 使用默认扬声器
audioConfig = SPXAudioConfig.default()
// 创建音频配置
if let customOutput = customAudioOutputStream {
// 使用自定义音频输出流
audioConfig = try SPXAudioConfiguration(streamOutput: customOutput)
} else {
// 使用默认扬声器
audioConfig = SPXAudioConfiguration()
}
// 创建语音合成器
synthesizer = try SPXSpeechSynthesizer(speechConfig: speechConfig!, audioConfig: audioConfig!)
initialized = true
synthesizer = try SPXSpeechSynthesizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!)
// 设置默认音频输出类型为自动
setAudioOutputType(outputType: .auto)
// 设置事件监听
setupEventListeners()
print("[AzureTtsHelper] TTS 引擎初始化成功")
initialized = true
print("\(tag): TTS 引擎初始化成功")
return true
} catch {
print("[AzureTtsHelper] TTS 引擎初始化失败: \(error.localizedDescription)")
print("\(tag): TTS 引擎初始化失败: \(error.localizedDescription)")
return false
}
}
/// 设置音频输出设备类型
///
/// - Parameter outputType: 音频输出设备类型
/// - Returns: 是否设置成功
func setAudioOutputType(outputType: AudioOutputType) -> Bool {
if !initialized {
print("[AzureTtsHelper] TTS 引擎尚未初始化")
return false
/// 设置事件监听器
private func setupEventListeners() {
guard let synthesizer = synthesizer else { return }
// 合成开始事件
synthesizer.addSynthesisStarted { [weak self] _, _ in
guard let self = self else { return }
self.ttsCallback?.onSynthesisStarted()
}
// 合成中事件
synthesizer.addSynthesizing { [weak self] _, _ in
guard let self = self else { return }
self.ttsCallback?.onSynthesizing()
}
// 合成完成事件
synthesizer.addSynthesisCompleted { [weak self] _, _ in
guard let self = self else { return }
self.speaking = false
self.ttsCallback?.onSynthesisCompleted()
}
// 合成取消事件
synthesizer.addSynthesisCanceled { [weak self] _, _ in
guard let self = self else { return }
print("\(self.tag): 语音合成取消")
self.speaking = false
self.ttsCallback?.onSynthesisCanceled()
}
}
/// 设置TTS回调
/// - Parameter callback: TTS回调接口
func setTtsCallback(_ callback: TtsCallback?) {
ttsCallback = callback
}
/// 设置自定义音频输出流
/// - Parameter outputStream: 自定义音频输出流,如果为nil则使用默认音频输出
/// - Returns: 是否设置成功
func setCustomAudioOutputStream(_ outputStream: SPXPushAudioOutputStream?) -> Bool {
do {
currentAudioOutputType = outputType
switch outputType {
case .speaker:
// 使用扬声器
try audioSession.setCategory(.playback, mode: .default)
try audioSession.overrideOutputAudioPort(.speaker)
print("[AzureTtsHelper] 已设置音频输出设备为扬声器")
case .earpiece:
// 使用听筒
try audioSession.setCategory(.playback, mode: .voiceChat)
try audioSession.overrideOutputAudioPort(.none)
print("[AzureTtsHelper] 已设置音频输出设备为听筒")
case .auto:
// 检查是否有耳机连接
let outputs = audioSession.currentRoute.outputs
let hasHeadphones = outputs.contains { output in
return output.portType == .headphones || output.portType == .bluetoothA2DP || output.portType == .bluetoothHFP
}
if hasHeadphones {
// 有耳机,使用耳机
try audioSession.setCategory(.playback, mode: .default)
try audioSession.overrideOutputAudioPort(.none)
print("[AzureTtsHelper] 已设置音频输出设备为耳机")
} else {
// 无耳机,使用听筒
try audioSession.setCategory(.playback, mode: .voiceChat)
try audioSession.overrideOutputAudioPort(.none)
print("[AzureTtsHelper] 已设置音频输出设备为听筒")
}
// 保存引用
customAudioOutputStream = outputStream
// 如果已初始化,需要重新创建合成器以应用新的音频输出流
if initialized {
recreateSynthesizer()
}
try audioSession.setActive(true)
return true
} catch {
print("[AzureTtsHelper] 设置音频输出设备失败: \(error.localizedDescription)")
print("\(tag): 设置自定义音频输出流失败: \(error.localizedDescription)")
return false
}
}
/// 重新创建合成器
private func recreateSynthesizer() {
do {
// 关闭现有合成器
synthesizer = nil
// 创建音频配置
if let customOutput = customAudioOutputStream {
// 使用自定义音频输出流
audioConfig = try SPXAudioConfiguration(streamOutput: customOutput)
} else {
// 使用默认扬声器
audioConfig = SPXAudioConfiguration()
}
// 使用新的音频配置创建合成器
synthesizer = try SPXSpeechSynthesizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!)
// 重新设置事件监听
setupEventListeners()
} catch {
print("\(tag): 重新创建合成器失败: \(error.localizedDescription)")
}
}
/// 设置语音
///
/// - Parameter voiceName: 语音名称,例如 "zh-CN-XiaoxiaoNeural"
/// - Returns: 是否设置成功
func setVoice(voiceName: String) -> Bool {
if !initialized {
print("[AzureTtsHelper] TTS 引擎尚未初始化")
print("\(tag): TTS 引擎尚未初始化")
return false
}
if voiceName == currentVoiceName {
print("[AzureTtsHelper] 已设置语音: \(voiceName)")
if voiceName == currentVoice {
print("\(tag): 已设置语音: \(voiceName)")
return true
}
do {
currentVoiceName = voiceName
currentVoice = voiceName
speechConfig?.setSpeechSynthesisVoiceName(voiceName)
// 重新创建合成器
synthesizer = try SPXSpeechSynthesizer(speechConfig: speechConfig!, audioConfig: audioConfig!)
recreateSynthesizer()
print("[AzureTtsHelper] 已设置语音: \(voiceName)")
print("\(tag): 已设置语音: \(voiceName)")
return true
} catch {
print("[AzureTtsHelper] 设置语音失败: \(error.localizedDescription)")
print("\(tag): 设置语音失败: \(error.localizedDescription)")
return false
}
}
/// 设置语音合成参数
///
/// - Parameters:
/// - rate: 语速,范围 -100 到 100,默认为 0
/// - pitch: 音调,范围 -100 到 100,默认为 0
@ -171,58 +239,36 @@ class AzureTtsHelper: NSObject {
/// - Returns: 是否设置成功
func setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100) -> Bool {
if !initialized {
print("[AzureTtsHelper] TTS 引擎尚未初始化")
print("\(tag): TTS 引擎尚未初始化")
return false
}
currentSpeechRate = rate
currentPitch = pitch
currentVolume = volume
print("[AzureTtsHelper] 已设置语音参数: 语速=\(rate), 音调=\(pitch), 音量=\(volume)")
return true
}
/// 合成文本为语音并播放
///
/// - Parameters:
/// - text: 要合成的文本
/// - completion: 完成回调,返回是否成功和可能的错误信息
func speakText(text: String, completion: @escaping (Bool, String?) -> Void) {
if !initialized {
print("[AzureTtsHelper] TTS 引擎尚未初始化")
completion(false, "TTS 引擎尚未初始化")
return
}
do {
print("[AzureTtsHelper] 开始合成文本: \(text)")
currentRate = formatPercentage(rate)
currentPitch = formatPercentage(pitch)
currentVolume = "\(min(max(volume, 0), 100))%"
// 生成 SSML
let ssml = generateSsml(text: text)
// 使用 SSML 合成语音
speakSsml(ssml: ssml, completion: completion)
print("\(tag): 已设置语音参数: 语速=\(rate), 音调=\(pitch), 音量=\(volume)")
return true
} catch {
print("[AzureTtsHelper] 语音合成异常: \(error.localizedDescription)")
completion(false, "语音合成异常: \(error.localizedDescription)")
print("\(tag): 设置语音参数失败: \(error.localizedDescription)")
return false
}
}
/// 格式化百分比值
private func formatPercentage(_ value: Int) -> String {
return value >= 0 ? "+\(value)%" : "\(value)%"
}
/// 生成 SSML 文本
///
/// - Parameter text: 要转换的文本
/// - Returns: SSML 格式的文本
private func generateSsml(text: String) -> String {
// 计算 SSML 参数
let rateParam = currentSpeechRate == 0 ? "0%" : (currentSpeechRate < 0 ? "\(Int(Double(currentSpeechRate) * 0.9))%" : "\(currentSpeechRate)%")
let pitchParam = currentPitch == 0 ? "0%" : "\(Int(Double(currentPitch) * 0.5))%"
let volumeParam = "\(min(max(currentVolume, 0), 100))%"
return """
<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="\(currentVoiceName)">
<prosody rate="\(rateParam)" pitch="\(pitchParam)" volume="\(volumeParam)">
<voice name="\(currentVoice)">
<prosody rate="\(currentRate)" pitch="\(currentPitch)" volume="\(currentVolume)">
\(text)
</prosody>
</voice>
@ -230,73 +276,133 @@ class AzureTtsHelper: NSObject {
"""
}
/// 合成 SSML 为语音并播放
///
/// - Parameters:
/// - ssml: SSML 格式的文本
/// - completion: 完成回调,返回是否成功和可能的错误信息
private func speakSsml(ssml: String, completion: @escaping (Bool, String?) -> Void) {
/// 合成文本为语音并播放
/// - Parameter text: 要合成的文本
/// - Returns: 是否成功开始合成
func speakText(_ text: String) -> Bool {
if !initialized {
print("[AzureTtsHelper] TTS 引擎尚未初始化")
completion(false, "TTS 引擎尚未初始化")
return
print("\(tag): TTS 引擎尚未初始化")
return false
}
do {
print("[AzureTtsHelper] 开始合成 SSML")
print("\(tag): 开始合成文本: \(text)")
// 生成 SSML
let ssml = generateSsml(text: text)
// 标记为正在播放
speaking = true
// 激活音频会话
try audioSession.setActive(true)
// 异步合成语音
let result = try synthesizer!.speakSsml(ssml)
switch result.reason {
case .synthesizingAudioCompleted:
print("[AzureTtsHelper] 语音合成完成")
speaking = false
completion(true, "语音合成完成")
case .canceled:
if let cancelDetails = try? SPXSpeechSynthesisCancellationDetails(fromResult: result) {
print("[AzureTtsHelper] 语音合成取消: \(cancelDetails.errorDetails ?? "未知错误")")
speaking = false
completion(false, "语音合成取消: \(cancelDetails.errorDetails ?? "未知错误")")
} else {
print("[AzureTtsHelper] 语音合成取消")
speaking = false
completion(false, "语音合成取消")
try synthesizer?.speakSsmlAsync(ssml)
return true
} catch {
print("\(tag): 语音合成异常: \(error.localizedDescription)")
speaking = false
return false
}
}
/// 处理流式文本并在遇到标点符号时播放
/// - Parameter text: 收到的文本流片段
/// - Returns: 是否成功处理
func speakStream(_ text: String) -> Bool {
if !initialized || text.isEmpty {
return false
}
do {
// 添加新文本到缓冲区
streamBuffer.append(text)
// 增加500ms防抖逻辑
let currentTime = Date().timeIntervalSince1970
if currentTime - lastSpeakTime < 0.5 {
return true
}
lastSpeakTime = currentTime
let currentText = streamBuffer
// 定义标点符号列表
let punctuationMarks: [Character] = [".", "。", "!", "!", "?", "?", ";", ";", ",", ",", ":", ":"]
// 查找最后一个标点符号的位置
var lastPunctuationIndex = -1
for i in currentText.indices.reversed() {
if punctuationMarks.contains(currentText[i]) {
lastPunctuationIndex = currentText.distance(from: currentText.startIndex, to: i)
break
}
default:
print("[AzureTtsHelper] 语音合成失败: \(result.reason)")
speaking = false
completion(false, "语音合成失败: \(result.reason)")
}
// 如果找到标点符号,则播放到该标点符号
if lastPunctuationIndex >= 0 {
// 提取要播放的文本(包含标点符号)
let textToSpeak = String(currentText.prefix(lastPunctuationIndex + 1))
// 剩余的文本保存在缓冲区中
streamBuffer = String(currentText.dropFirst(lastPunctuationIndex + 1))
// 播放提取的文本
return speakText(textToSpeak)
}
// 如果没有找到标点符号,则等待更多文本
return true
} catch {
print("[AzureTtsHelper] 语音合成异常: \(error.localizedDescription)")
speaking = false
completion(false, "语音合成异常: \(error.localizedDescription)")
print("\(tag): 流式语音合成失败: \(error.localizedDescription)")
return false
}
}
/// 播放剩余的流式文本
/// - Returns: 是否成功播放剩余文本
func flushStream() -> Bool {
if !initialized {
return false
}
do {
// 获取缓冲区中剩余的文本
let remainingText = streamBuffer
// 清空缓冲区
streamBuffer = ""
// 如果缓冲区为空,直接返回成功
if remainingText.isEmpty {
return true
}
// 播放剩余文本
return speakText(remainingText)
} catch {
print("\(tag): 刷新流式文本失败: \(error.localizedDescription)")
return false
}
}
/// 停止当前语音合成
///
/// - Returns: 是否停止成功
func stopSpeaking() -> Bool {
if !initialized {
print("[AzureTtsHelper] TTS 引擎尚未初始化")
print("\(tag): TTS 引擎尚未初始化")
return false
}
do {
try synthesizer?.stopSpeaking()
// 清空流缓冲区
streamBuffer = ""
try synthesizer?.stopSpeakingAsync()
speaking = false
print("[AzureTtsHelper] 已停止语音合成")
print("\(tag): 已停止语音合成")
return true
} catch {
print("[AzureTtsHelper] 停止语音合成失败: \(error.localizedDescription)")
print("\(tag): 停止语音合成失败: \(error.localizedDescription)")
return false
}
}
@ -309,20 +415,30 @@ class AzureTtsHelper: NSObject {
// 恢复音频会话
try audioSession.setActive(false, options: .notifyOthersOnDeactivation)
// 关闭自定义音频输出流
customAudioOutputStream = nil
// 清空流缓冲区
streamBuffer = ""
// 清理资源
synthesizer = nil
speechConfig = nil
audioConfig = nil
ttsCallback = nil
initialized = false
speaking = false
print("[AzureTtsHelper] TTS 引擎已释放")
print("\(tag): TTS 引擎已释放")
} catch {
print("[AzureTtsHelper] 释放 TTS 引擎失败: \(error.localizedDescription)")
print("\(tag): 释放 TTS 引擎失败: \(error.localizedDescription)")
// 确保重置状态
initialized = false
speaking = false
}
}
/// 检查当前是否正在播放语音
///
/// - Returns: 是否正在播放语音
func isSpeaking() -> Bool {
return speaking

9
local_plugins/ble_service/android/build.gradle.kts

@ -28,13 +28,8 @@ android {
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0")
implementation("androidx.work:work-runtime-ktx:2.8.1")
implementation("androidx.media:media:1.6.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("androidx.security:security-crypto:1.1.0-alpha03")
// 使用从本地Maven仓库引用的AAR依赖
implementation("com.jieli.audio:jl_audio_decode:2.0.0")
implementation(project(":deepvoice_utils"))
implementation(project(":agent_service"))
implementation(project(":azure_speech"))
implementation(project(":jl_opus"))
}

36
local_plugins/ble_service/android/src/main/AndroidManifest.xml

@ -18,39 +18,5 @@
<uses-permission android:name="android.permission.MEDIA_CONTENT_CONTROL" />
<uses-permission android:name="android.permission.RECEIVE_MEDIA_BUTTON" />
<application>
<!-- 媒体前台服务 -->
<service
android:name=".BleMediaService"
android:exported="true"
android:enabled="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</service>
<!-- 媒体按钮 Receiver -->
<receiver
android:name="androidx.media.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- 被系统 PendingIntent 触发的 Receiver -->
<receiver
android:name=".BleWakeReceiver"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="com.yunqiinnovation.ble_service.ACTION_BLE_SCAN" />
</intent-filter>
</receiver>
</application>
</manifest>

226
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt

@ -23,7 +23,12 @@ import androidx.core.content.ContextCompat
import java.util.concurrent.ConcurrentHashMap
import android.content.pm.ServiceInfo
import java.util.concurrent.CopyOnWriteArrayList
import com.yunqiinnovation.jl_opus.JlOpusPlugin
// Import Jieli Opus SDK classes
import com.jieli.jl_audio_decode.opus.model.OpusOption
import com.jieli.jl_audio_decode.callback.OnDecodeStreamCallback
import com.jieli.jl_audio_decode.callback.OnStateCallback
import com.jieli.jl_audio_decode.opus.OpusManager
import com.jieli.jl_audio_decode.exceptions.OpusException
/**
* BLE服务类:提供蓝牙低功耗设备的扫描、连接和通信功能
@ -86,8 +91,10 @@ object BleService {
// 回调集合 - 使用线程安全的集合存储所有回调
private val callbacks = CopyOnWriteArrayList<Callback>()
// 获取插件实例
private val opusPlugin = JlOpusPlugin()
// Opus解码器实例
private var opusManager: OpusManager? = null
private val mainHandler = Handler(Looper.getMainLooper())
// 初始化状态
private var isInitialized = false
@ -112,6 +119,16 @@ object BleService {
// 初始化Handler
scanHandler = Handler(Looper.getMainLooper())
// 初始化OpusManager
try {
opusManager = OpusManager()
FileLogger.d(TAG, "OpusManager初始化成功")
} catch (e: OpusException) {
FileLogger.e(TAG, "OpusManager初始化失败: ${e.message}", e)
// 根据需要决定是否因为Opus初始化失败而返回false
}
isInitialized = true
FileLogger.d(TAG, "BLE服务初始化成功")
return true
@ -175,7 +192,6 @@ object BleService {
if (isScanning) return false
FileLogger.i(TAG, "开始主动扫描BLE设备...")
unregisterBackgroundScan() // 避免双重扫描
scanResults.clear()
val filter = ScanFilter.Builder()
@ -223,8 +239,6 @@ object BleService {
scanHandler.removeCallbacksAndMessages(null)
isScanning = false
// 重新注册后台扫描,保证唤醒能力
registerBackgroundScan()
return true
} catch (e: Exception) {
@ -427,13 +441,7 @@ object BleService {
// 设置主服务的通知
setupNotifications(g, notifyChar)
//开启解码
opusPlugin?.startOpusDecodeStream(false, 1, 16000, 40, object :
JlOpusPlugin.CallbackOpus {
override fun onAudioDataReceivedOpus(pcmData: ByteArray) {
// 回调给上层
notifyAudioDataReceived(pcmData)
}
})
}
override fun onCharacteristicChanged(g: BluetoothGatt, c: BluetoothGattCharacteristic) {
@ -515,18 +523,21 @@ object BleService {
/**
* 处理音频数据
* @param data 音频数据
* @param data 音频数据 (Opus)
*/
private fun processAudioData(data: ByteArray) {
try {
opusPlugin?.handleDecodeRequest(data)
if (opusManager?.isDecodeStream == true) {
opusManager?.writeAudioStream(data)
} else {
// FileLogger.d(TAG, "Opus解码流未启动,忽略音频数据")
}
} catch (e: Exception) {
FileLogger.e(TAG, "处理音频数据异常: ${e.message}", e)
}
}
/**
* 处理设备响应数据 - 帧头为0xBB
*/
@ -844,7 +855,7 @@ object BleService {
BleConst.CMD_WAKE_UP -> {
FileLogger.i(TAG, "收到唤醒指令")
// 接收到唤醒信号,打开编码器
// 接收到唤醒信号,打开编码器 (设备侧)
openEncoder()
notifyWakeupSignalReceived()
@ -865,6 +876,75 @@ object BleService {
}
}
// ======================================================================================================
// Opus 解码相关
// ======================================================================================================
private fun startOpusStreamDecoding(
hasHeader: Boolean = false, // 通常BLE传输的Opus没有文件头
channel: Int = 1,
sampleRate: Int = 16000, // 确认设备端Opus编码采样率
packetSize: Int = 40 // 确认设备端Opus编码帧长
): Boolean {
if (opusManager == null) {
FileLogger.e(TAG, "OpusManager未初始化,无法开始解码")
return false
}
// 如果已经在解码流,先停止
if (opusManager?.isDecodeStream == true) {
opusManager?.stopDecodeStream()
FileLogger.d(TAG, "已停止正在进行的Opus解码流")
}
val option = OpusOption()
.setHasHead(hasHeader)
.setChannel(channel)
.setSampleRate(sampleRate)
.setPacketSize(packetSize)
FileLogger.d(TAG, "准备开始Opus数据流解码, 参数: $option")
var streamStartedSuccessfully = false
opusManager?.startDecodeStream(option, object : OnDecodeStreamCallback {
override fun onDecodeStream(data: ByteArray?) {
if (data != null) {
// FileLogger.d(TAG, "Opus解码数据: ${data.size} bytes")
mainHandler.post { notifyAudioDataReceived(data) }
}
}
override fun onStart() {
streamStartedSuccessfully = true
FileLogger.i(TAG, "Opus数据流解码已开始")
// 可以通过回调通知上层解码已开始
}
override fun onComplete(outPath: String?) {
FileLogger.i(TAG, "Opus数据流解码完成: $outPath (通常流式解码不会调用此方法)")
}
override fun onError(code: Int, message: String?) {
FileLogger.e(TAG, "Opus数据流解码错误: [$code] $message")
// 可以通过回调通知上层解码错误
}
})
// 注意:startDecodeStream是异步启动的,这里的返回值可能不准确反映是否真的成功启动。
// onStart回调才是真正启动成功的标志。为了简单,我们暂时认为调用即尝试启动。
// 后续可以改造为返回Future或通过回调传递启动结果。
return true // 暂定为调用即成功
}
private fun stopOpusStreamDecoding(): Boolean {
if (opusManager?.isDecodeStream == true) {
opusManager?.stopDecodeStream()
FileLogger.i(TAG, "已停止Opus数据流解码")
return true
}
FileLogger.d(TAG, "Opus数据流未在解码或OpusManager未初始化")
return false
}
// ======================================================================================================
// 公开的命令接口
// ======================================================================================================
@ -905,11 +985,13 @@ object BleService {
*/
fun closeCodec(): Boolean {
FileLogger.i(TAG, "关闭编解码...")
stopOpusStreamDecoding() // 停止Opus解码流
return sendCommand(
BleConst.CMD_CONTROL_CODEC.toByte(),
byteArrayOf(BleConst.CODEC_CONTROL_CLOSE.toByte(),
BleConst.AUDIO_CHANNEL_RIGHT.toByte())
BleConst.AUDIO_CHANNEL_RIGHT.toByte()) // 或其他默认声道
)
}
@ -918,10 +1000,7 @@ object BleService {
*/
fun openDecoder(): Boolean {
FileLogger.i(TAG, "打开解码...")
return sendCommand(
BleConst.CMD_CONTROL_CODEC.toByte(),
byteArrayOf(BleConst.CODEC_CONTROL_DECODE_ON.toByte())
)
return sendCommand(BleConst.CMD_CONTROL_CODEC.toByte(), byteArrayOf(BleConst.CODEC_CONTROL_DECODE_ON.toByte()))
}
/**
@ -936,16 +1015,33 @@ object BleService {
* 控制编解码 - 打开编码
*/
fun openEncoder(): Boolean {
FileLogger.i(TAG, "打开编码...")
FileLogger.i(TAG, "打开编码 (设备侧)...")
val opusStarted = startOpusStreamDecoding(
hasHeader = false, // BLE传输的Opus数据通常无文件头
channel = 1, // 单声道
sampleRate = 16000,// Opus推荐16kHz或更高
packetSize = 40 // Opus常用帧长,如20ms, 40ms, 60ms。 (16000Hz * 1channel * 2bytes/sample * 0.04s = 1280 bytes/frame if PCM)
// packetSize for Opus is typically in ms, e.g. 20, 40, 60.
// The library might expect this in a different unit or derive it.
// Given JlOpusPlugin.kt, packetSize refers to Opus encoded frame duration in ms.
)
// 注意:此方法通常是控制设备端开始编码并发送数据。
// App侧通常是打开解码器(openDecoder)来接收和处理这些数据。
// 如果App也需要编码(例如发送语音到设备),则需要实现Opus编码逻辑。
// 目前仅发送命令通知设备开始编码。
return sendCommand(
BleConst.CMD_CONTROL_CODEC.toByte(),
byteArrayOf(
BleConst.CODEC_CONTROL_ENCODE_ON.toByte(),
BleConst.AUDIO_CHANNEL_RIGHT.toByte()
BleConst.AUDIO_CHANNEL_RIGHT.toByte() // 或设备期望的声道
)
)
}
/**
* 设置声道模式
* @param channelMode 声道模式:AUDIO_CHANNEL_LEFT/AUDIO_CHANNEL_RIGHT/AUDIO_CHANNEL_STEREO
@ -1017,74 +1113,7 @@ object BleService {
return bluetoothAdapter
}
// ======================================================================================================
// 后台扫描与唤醒
// ======================================================================================================
/**
* 注册后台持久扫描,APP被杀死后也能唤醒
*/
fun registerBackgroundScan() {
// 获取一个局部引用,避免并发访问问题
val scanner = bluetoothLeScanner ?: return
try {
val filter = ScanFilter.Builder()
// .setServiceUuid(ParcelUuid(BleConst.PRIMARY_SERVICE_UUID))
.setManufacturerData(0xEFAB, byteArrayOf(0xA0.toByte(), 0x01))
.build()
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
// .setCallbackType(ScanSettings.CALLBACK_TYPE_FIRST_MATCH)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.build()
// 创建Intent并设置前台接收器标志
val scanIntent = Intent(BleConst.ACTION_SCAN_RESULT)
.setClass(context, BleWakeReceiver::class.java)
.addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
val pi = PendingIntent.getBroadcast(
context, 0, scanIntent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
)
scanner.startScan(listOf(filter), settings, pi)
FileLogger.i(TAG, "后台持久扫描已注册")
} catch (e: Exception) {
FileLogger.e(TAG, "注册后台扫描失败: ${e.message}", e)
}
}
/**
* 注销后台持久扫描
*/
fun unregisterBackgroundScan() {
// 获取一个局部引用,避免并发访问问题
val scanner = bluetoothLeScanner ?: return
try {
// 创建Intent并设置前台接收器标志
val scanIntent = Intent(BleConst.ACTION_SCAN_RESULT)
.setClass(context, BleWakeReceiver::class.java)
.addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
val pi = PendingIntent.getBroadcast(
context, 0, scanIntent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
)
scanner.stopScan(pi)
FileLogger.i(TAG, "后台持久扫描已注销")
} catch (e: Exception) {
FileLogger.e(TAG, "注销后台扫描失败: ${e.message}", e)
}
}
// ======================================================================================================
// 资源清理
@ -1096,8 +1125,19 @@ object BleService {
stopScan()
scanHandler.removeCallbacksAndMessages(null)
disconnectGatt()
// 释放OpusManager
opusManager?.let {
if (it.isDecodeStream) {
it.stopDecodeStream()
}
it.release()
}
opusManager = null
FileLogger.d(TAG, "OpusManager已释放")
isInitialized = false // 标记为未初始化
}
// ======================================================================================================
// 协议命令实现
// ======================================================================================================

87
local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt

@ -160,48 +160,7 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, BleService.Callback {
val success = BleService.playA2DP()
result.success(success)
}
"registerBackgroundScan" -> {
try {
BleService.registerBackgroundScan()
FileLogger.d(TAG, "后台扫描已注册")
result.success(true)
} catch (e: Exception) {
FileLogger.e(TAG, "注册后台扫描失败", e)
result.error("BLE_SERVICE_ERROR", "注册后台扫描失败: ${e.message}", null)
}
}
"unregisterBackgroundScan" -> {
try {
BleService.unregisterBackgroundScan()
FileLogger.d(TAG, "后台扫描已注销")
result.success(true)
} catch (e: Exception) {
FileLogger.e(TAG, "注销后台扫描失败", e)
result.error("BLE_SERVICE_ERROR", "注销后台扫描失败: ${e.message}", null)
}
}
"startBleService" -> {
try {
// 从Flutter获取配置参数
val configParams = call.arguments as? Map<String, Any> ?: mapOf()
FileLogger.i(TAG, "启动BLE服务,配置参数: $configParams")
// 启动BLE服务并传递配置参数
startBleServiceWithConfig(configParams)
result.success(true)
} catch (e: Exception) {
FileLogger.e(TAG, "启动BLE服务失败", e)
result.error("BLE_SERVICE_ERROR", "启动BLE服务失败: ${e.message}", null)
}
}
"stopBleService" -> {
try {
stopBleService()
result.success(true)
} catch (e: Exception) {
FileLogger.e(TAG, "停止BLE服务失败", e)
result.error("BLE_SERVICE_ERROR", "停止BLE服务失败: ${e.message}", null)
}
}
else -> {
result.notImplemented()
}
@ -254,48 +213,4 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, BleService.Callback {
sendEvent(statusEventSink, resultMap, "发送设备信息异常")
}
/**
* 使用配置参数启动BLE服务
*/
private fun startBleServiceWithConfig(configParams: Map<String, Any>) {
try {
// 启动BLE前台服务
val serviceIntent = Intent(context, BleMediaService::class.java)
// 如果有额外配置需要传递给服务,可以通过Intent传递
configParams.forEach { (key, value) ->
when (value) {
is String -> serviceIntent.putExtra(key, value)
is Boolean -> serviceIntent.putExtra(key, value)
is Int -> serviceIntent.putExtra(key, value)
is Double -> serviceIntent.putExtra(key, value.toFloat())
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(serviceIntent)
} else {
context.startService(serviceIntent)
}
FileLogger.d(TAG, "成功启动BLE服务,配置参数: $configParams")
} catch (e: Exception) {
FileLogger.e(TAG, "BLE服务启动失败", e)
throw e
}
}
/**
* 停止BLE服务
*/
private fun stopBleService() {
try {
context.stopService(Intent(context, BleMediaService::class.java))
FileLogger.d(TAG, "成功停止BLE服务")
} catch (e: Exception) {
FileLogger.e(TAG, "停止BLE服务失败", e)
throw e
}
}
}

15
local_plugins/ble_service/ios/Assets/ble_service-Info.plist

@ -0,0 +1,15 @@
<?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>NSBluetoothAlwaysUsageDescription</key>
<string>需要蓝牙权限与设备进行通信和传输音频数据</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>需要蓝牙权限与设备进行通信和传输音频数据</string>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
<string>audio</string>
</array>
</dict>
</plist>

90
local_plugins/ble_service/ios/Classes/BleConst.swift

@ -0,0 +1,90 @@
import Foundation
import CoreBluetooth
/// BLE服务常量
struct BleConst {
// ================== 协议相关UUID ==================
/// 主服务UUID - 0000ABC0-0000-1111-2222-123456789ABC
static let PRIMARY_SERVICE_UUID = CBUUID(string: "0000abc0-0000-1111-2222-123456789abc")
/// 音频服务UUID - 00001801-0000-1000-8000-00805f9b34fb
static let AUDIO_SERVICE_UUID = CBUUID(string: "0000ae00-0000-1000-8000-00805f9b34fb")
/// 接收音频特征UUID - 0000ABC1-0000-1111-2222-123456789ABC
static let RECEIVE_AUDIO_CHAR_UUID = CBUUID(string: "0000ae02-0000-1000-8000-00805f9b34fb")
/// 写入特征UUID - 0000ABC1-0000-1111-2222-123456789ABC
static let WRITE_CHAR_UUID = CBUUID(string: "0000abc1-0000-1111-2222-123456789abc")
/// 通知特征UUID - 0000ABC2-0000-1111-2222-123456789ABC
static let NOTIFY_CHAR_UUID = CBUUID(string: "0000abc2-0000-1111-2222-123456789abc")
/// 客户端特征配置描述符UUID
static let CLIENT_CHAR_CONFIG_UUID = CBUUID(string: "0000fd7b-0000-1000-8000-00805f9b34fb")
/// BLE扫描Action
static let ACTION_SCAN_RESULT = "com.yunqiinnovation.ble_service.ACTION_BLE_SCAN"
// ================== 连接状态 ==================
static let STATE_DISCONNECTED = 0
static let STATE_CONNECTING = 1
static let STATE_CONNECTED = 2
static let STATE_DISCONNECTING = 3
static let STATE_ERROR = 4
// ================== 广播厂商信息 ==================
/// 厂商ID - 0xABEF
static let MANUFACTURER_ID: UInt16 = 0xABEF
/// 客户ID - 0xA001
static let CUSTOMER_ID: UInt16 = 0xA001
/// 产品ID - 0xA001
static let PRODUCT_ID: UInt16 = 0xA001
/// 蓝牙状态-未连接
static let BT_STATE_DISCONNECTED: UInt8 = 0x00
/// 蓝牙状态-已连接
static let BT_STATE_CONNECTED: UInt8 = 0x0C
// ================== 协议格式 ==================
/// APP请求帧头
static let APP_REQUEST_HEADER: UInt8 = 0xAA
/// 设备响应帧头
static let DEVICE_RESPONSE_HEADER: UInt8 = 0xBB
/// 设备主动上报帧头
static let DEVICE_NOTIFY_HEADER: UInt8 = 0xCC
// ================== 协议指令 (CMD) ==================
/// 获取耳机版本号
static let CMD_GET_VERSION: UInt8 = 0x01
/// 获取耳机产品ID
static let CMD_GET_PRODUCT_ID: UInt8 = 0x02
/// 获取耳机颜色ID
static let CMD_GET_COLOR_ID: UInt8 = 0x03
/// 获取耳机电量ID
static let CMD_GET_BATTERY_INFO: UInt8 = 0x04
/// APP控制编解码指令
static let CMD_CONTROL_CODEC: UInt8 = 0x05
/// 音量增大
static let CMD_VOLUME_UP: UInt8 = 0x06
/// 音量减小
static let CMD_VOLUME_DOWN: UInt8 = 0x07
/// A2DP播放
static let CMD_PLAY_A2DP: UInt8 = 0x08
/// 唤醒指令
static let CMD_WAKE_UP: UInt8 = 0x11
// ================== 编解码控制值 ==================
/// 关闭编解码指令
static let CODEC_CONTROL_CLOSE: UInt8 = 0x00
/// 打开解码指令
static let CODEC_CONTROL_DECODE_ON: UInt8 = 0xA1
/// 通过A2DP播放
static let CODEC_CONTROL_A2DP_PLAY: UInt8 = 0xA2
/// 打开编码指令
static let CODEC_CONTROL_ENCODE_ON: UInt8 = 0xB1
/// 左声道
static let AUDIO_CHANNEL_LEFT: UInt8 = 0x01
/// 右声道
static let AUDIO_CHANNEL_RIGHT: UInt8 = 0x02
/// 立体声
static let AUDIO_CHANNEL_STEREO: UInt8 = 0x03
}

25
local_plugins/ble_service/ios/Classes/BleDelegate.swift

@ -0,0 +1,25 @@
import Foundation
/// BLE服务回调代理协议
protocol BleDelegate: AnyObject {
/// 扫描结果回调
/// - Parameter devices: 设备列表,每个设备包含id、name、rssi和uuids等信息
func onScanResult(devices: [[String: Any]])
/// 连接状态改变回调
/// - Parameter state: 连接状态,参考BleConst中的STATE_*常量
func onConnectionStateChanged(state: Int)
/// 音频数据接收回调
/// - Parameter data: 接收到的音频数据
func onAudioDataReceived(data: Data)
/// 唤醒信号接收回调
func onWakeupSignalReceived()
/// 设备信息接收回调
/// - Parameters:
/// - infoType: 信息类型
/// - infoData: 信息数据
func onDeviceInfoReceived(infoType: Int, infoData: [String: Any])
}

963
local_plugins/ble_service/ios/Classes/BleService.swift

@ -0,0 +1,963 @@
import Foundation
import CoreBluetooth
import os.log
/// BLE服务类 - 单例实现
actor BleService {
// MARK: - 单例实现
static let shared = BleService()
// MARK: - 常量
/// 用于状态恢复的唯一标识符
private let restoreIdentifier = "com.yunqiinnovation.ble_service.restore"
private init() {
// 初始化连接状态
connectionState = BleConst.STATE_DISCONNECTED
// 将通知处理移到非actor方法中
setupNotifications()
}
deinit {
// 移除通知观察者
for observer in notificationObservers {
NotificationCenter.default.removeObserver(observer)
}
}
// MARK: - 属性
/// 中央管理器
private var centralManager: CBCentralManager?
/// 当前连接的外设
private var peripheral: CBPeripheral?
/// 主服务特征
private var writeCharacteristic: CBCharacteristic?
private var notifyCharacteristic: CBCharacteristic?
private var audioCharacteristic: CBCharacteristic?
/// 连接状态
private(set) var connectionState: Int {
didSet {
if oldValue != connectionState {
Task { await notifyConnectionStateChanged(state: connectionState) }
}
}
}
/// 是否正在扫描
private(set) var isScanning = false
/// 扫描结果缓存
private var scanResults = [CBPeripheral: [String: Any]]()
/// 扫描超时任务
private var scanTimeoutTask: Task<Void, Never>?
/// 是否已初始化
private var isInitialized = false
/// 回调代理列表 - 使用actor保证线程安全
private var delegates = [BleDelegate]()
/// 日志
private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService")
/// 通知观察者
private var notificationObservers = [NSObjectProtocol]()
// MARK: - 通知设置
/// 设置应用生命周期通知
private nonisolated func setupNotifications() {
// 使用nonisolated函数以避免actor隔离问题
let becomeActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self = self else { return }
Task {
await self.handleAppDidBecomeActive()
}
}
let enterBackgroundObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didEnterBackgroundNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self = self else { return }
Task {
await self.handleAppDidEnterBackground()
}
}
// 存储观察者以便后续移除
notificationObservers = [becomeActiveObserver, enterBackgroundObserver]
}
// MARK: - 公共方法
/// 初始化BLE服务
/// - Returns: 是否初始化成功
func initialize() -> Bool {
if isInitialized {
return true
}
do {
// 初始化中央管理器 - 配置状态恢复选项
let options: [String: Any] = [
CBCentralManagerOptionRestoreIdentifierKey: restoreIdentifier,
CBCentralManagerOptionShowPowerAlertKey: true
]
centralManager = CBCentralManager(
delegate: CBManagerHandler.shared,
queue: nil,
options: options
)
CBManagerHandler.shared.bleService = self
isInitialized = true
os_log("BLE服务初始化成功", log: logger, type: .info)
return true
} catch {
os_log("BLE服务初始化失败: %{public}@", log: logger, type: .error, error.localizedDescription)
return false
}
}
/// 添加回调代理
/// - Parameter delegate: 要添加的代理
/// - Returns: 是否添加成功
func addDelegate(_ delegate: BleDelegate) -> Bool {
if !delegates.contains(where: { $0 === delegate }) {
delegates.append(delegate)
os_log("已添加BLE回调, 当前回调数: %d", log: logger, type: .debug, delegates.count)
return true
}
return false
}
/// 移除回调代理
/// - Parameter delegate: 要移除的代理
/// - Returns: 是否移除成功
func removeDelegate(_ delegate: BleDelegate) -> Bool {
if let index = delegates.firstIndex(where: { $0 === delegate }) {
delegates.remove(at: index)
os_log("已移除BLE回调, 当前回调数: %d", log: logger, type: .debug, delegates.count)
return true
}
return false
}
/// 清除所有回调代理
func clearDelegates() {
delegates.removeAll()
os_log("已清除所有BLE回调", log: logger, type: .debug)
}
/// 检查是否已连接
/// - Returns: 是否已连接
func isConnected() -> Bool {
return connectionState == BleConst.STATE_CONNECTED
}
// MARK: - 扫描方法
/// 开始主动扫描BLE设备
/// - Parameter timeout: 扫描超时时间(秒)
/// - Returns: 是否成功开始扫描
func startScan(timeout: TimeInterval = 15.0) -> Bool {
if !isInitialized {
os_log("BLE服务未初始化", log: logger, type: .error)
return false
}
if isScanning {
return false
}
guard let manager = centralManager, manager.state == .poweredOn else {
os_log("蓝牙未开启", log: logger, type: .error)
return false
}
os_log("开始主动扫描BLE设备...", log: logger, type: .info)
unregisterBackgroundScan() // 避免双重扫描
scanResults.removeAll()
// 扫描选项 - 允许重复结果,以获取最新的RSSI值
let options: [String: Any] = [
CBCentralManagerScanOptionAllowDuplicatesKey: true
]
// 设置过滤服务UUID
let scanFilters = [
// 如果需要过滤,添加CBUUID
// CBUUID(string: BleConst.PRIMARY_SERVICE_UUID.uuidString)
]
// 使用过滤器开始扫描
manager.scanForPeripherals(
withServices: scanFilters.isEmpty ? nil : scanFilters,
options: options
)
isScanning = true
// 设置扫描超时
scanTimeoutTask?.cancel()
scanTimeoutTask = Task { [weak self] in
guard let self = self else { return }
do {
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
if !Task.isCancelled {
await self.stopScan()
}
} catch {
// 任务被取消,忽略错误
}
}
return true
}
/// 停止主动扫描
/// - Returns: 是否成功停止扫描
func stopScan() -> Bool {
if !isScanning {
return false
}
os_log("停止主动扫描", log: logger, type: .info)
centralManager?.stopScan()
scanTimeoutTask?.cancel()
scanTimeoutTask = nil
isScanning = false
// 重新注册后台扫描,保证唤醒能力
registerBackgroundScan()
return true
}
/// 注册后台扫描
/// - Returns: 是否成功注册
func registerBackgroundScan() -> Bool {
// iOS中无法直接实现与Android相同的后台扫描唤醒功能
os_log("iOS不支持直接的后台扫描唤醒,将使用状态保存和恢复功能", log: logger, type: .info)
return true
}
/// 注销后台扫描
/// - Returns: 是否成功注销
func unregisterBackgroundScan() -> Bool {
// iOS没有直接的后台扫描注销机制
os_log("iOS不支持直接的后台扫描注销", log: logger, type: .info)
return true
}
// MARK: - 连接方法
/// 连接到指定地址的BLE设备
/// - Parameter address: 设备UUID字符串
func connect(address: String) async {
if connectionState == BleConst.STATE_CONNECTED {
os_log("已经连接中,忽略连接请求", log: logger, type: .warning)
return
}
guard let central = centralManager, central.state == .poweredOn else {
updateConnectionState(BleConst.STATE_ERROR)
return
}
// 关闭现有连接
disconnectGatt()
// 查找对应的外设
if let uuid = UUID(uuidString: address),
let targetPeripheral = scanResults.keys.first(where: { $0.identifier == uuid }) {
// 如果在扫描结果中找到,直接连接
os_log("从扫描结果中找到目标设备,开始连接...", log: logger, type: .debug)
await connectToPeripheral(targetPeripheral)
} else {
// 如果没有找到,尝试从已知设备中恢复
os_log("尝试使用UUID恢复设备连接: %{public}@", log: logger, type: .debug, address)
if let uuid = UUID(uuidString: address) {
let peripherals = central.retrievePeripherals(withIdentifiers: [uuid])
if let targetPeripheral = peripherals.first {
await connectToPeripheral(targetPeripheral)
} else {
os_log("未找到指定UUID的设备: %{public}@", log: logger, type: .error, address)
updateConnectionState(BleConst.STATE_ERROR)
}
} else {
os_log("无效的UUID格式: %{public}@", log: logger, type: .error, address)
updateConnectionState(BleConst.STATE_ERROR)
}
}
}
/// 连接到指定外设
/// - Parameter peripheral: 要连接的外设
private func connectToPeripheral(_ peripheral: CBPeripheral) async {
self.peripheral = peripheral
peripheral.delegate = CBPeripheralHandler.shared
CBPeripheralHandler.shared.bleService = self
updateConnectionState(BleConst.STATE_CONNECTING)
centralManager?.connect(peripheral, options: nil)
}
/// 断开当前连接
func disconnect() async {
guard let peripheral = peripheral, let central = centralManager else {
return
}
updateConnectionState(BleConst.STATE_DISCONNECTING)
central.cancelPeripheralConnection(peripheral)
}
/// 安全关闭GATT连接
private func disconnectGatt() {
if let peripheral = peripheral, let central = centralManager {
central.cancelPeripheralConnection(peripheral)
}
peripheral = nil
writeCharacteristic = nil
notifyCharacteristic = nil
audioCharacteristic = nil
}
/// 更新连接状态并通知回调
private func updateConnectionState(_ state: Int) {
connectionState = state
}
// MARK: - 设备通信方法
/// 获取设备版本号
/// - Returns: 是否成功发送请求
func getDeviceVersion() -> Bool {
return sendCommand(BleConst.CMD_GET_VERSION)
}
/// 获取电池信息
/// - Returns: 是否成功发送请求
func getBatteryInfo() -> Bool {
return sendCommand(BleConst.CMD_GET_BATTERY_INFO)
}
/// 打开编码器
/// - Returns: 是否成功发送请求
func openEncoder() -> Bool {
return sendCodecControl(BleConst.CODEC_CONTROL_ENCODE_ON)
}
/// 打开解码器
/// - Returns: 是否成功发送请求
func openDecoder() -> Bool {
return sendCodecControl(BleConst.CODEC_CONTROL_DECODE_ON)
}
/// 关闭编解码器
/// - Returns: 是否成功发送请求
func closeCodec() -> Bool {
return sendCodecControl(BleConst.CODEC_CONTROL_CLOSE)
}
/// 播放A2DP
/// - Returns: 是否成功发送请求
func playA2DP() -> Bool {
return sendCodecControl(BleConst.CODEC_CONTROL_A2DP_PLAY)
}
/// 发送编解码控制指令
/// - Parameter control: 控制值
/// - Returns: 是否成功发送
private func sendCodecControl(_ control: UInt8) -> Bool {
let data = Data([BleConst.APP_REQUEST_HEADER, BleConst.CMD_CONTROL_CODEC, control])
return writeData(data)
}
/// 发送命令
/// - Parameter command: 命令字
/// - Returns: 是否成功发送
private func sendCommand(_ command: UInt8) -> Bool {
let data = Data([BleConst.APP_REQUEST_HEADER, command])
return writeData(data)
}
/// 写入数据
/// - Parameter data: 要写入的数据
/// - Returns: 是否成功写入
private func writeData(_ data: Data) -> Bool {
guard let peripheral = peripheral,
let characteristic = writeCharacteristic,
connectionState == BleConst.STATE_CONNECTED else {
os_log("无法写入数据,设备未连接或特征未找到", log: logger, type: .error)
return false
}
let hexString = data.map { String(format: "0x%02X", $0) }.joined(separator: " ")
os_log("写入数据: %{public}@", log: logger, type: .debug, hexString)
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
return true
}
// MARK: - 数据处理方法
/// 处理音频数据
/// - Parameter data: 音频数据
func processAudioData(_ data: Data) async {
os_log("收到音频数据: %d字节", log: logger, type: .debug, data.count)
await notifyAudioDataReceived(data: data)
}
/// 处理设备响应
/// - Parameter data: 设备响应数据
func processDeviceResponse(_ data: Data) async {
guard data.count >= 2 else { return }
let cmd = data[1]
os_log("收到设备响应: CMD=0x%02X", log: logger, type: .debug, cmd)
switch cmd {
case BleConst.CMD_GET_VERSION:
if data.count >= 3 {
// 解析版本号
let version = String(format: "%d.%d.%d", data[2], data.count > 3 ? data[3] : 0, data.count > 4 ? data[4] : 0)
let infoData: [String: Any] = ["version": version]
await notifyDeviceInfoReceived(infoType: Int(cmd), infoData: infoData)
}
case BleConst.CMD_GET_BATTERY_INFO:
if data.count >= 3 {
// 解析电池电量
let batteryLevel = Int(data[2])
let infoData: [String: Any] = ["level": batteryLevel]
await notifyDeviceInfoReceived(infoType: Int(cmd), infoData: infoData)
}
default:
os_log("未处理的响应命令: 0x%02X", log: logger, type: .debug, cmd)
}
}
/// 处理设备通知
/// - Parameter data: 设备通知数据
func processDeviceNotification(_ data: Data) async {
guard data.count >= 2 else { return }
let notifyType = data[1]
os_log("收到设备通知: Type=0x%02X", log: logger, type: .debug, notifyType)
switch notifyType {
case BleConst.CMD_WAKE_UP:
// 设备唤醒信号
await notifyWakeupSignalReceived()
default:
os_log("未处理的通知类型: 0x%02X", log: logger, type: .debug, notifyType)
}
}
// MARK: - 应用状态处理
/// 处理应用进入活跃状态
private func handleAppDidBecomeActive() async {
os_log("应用进入活跃状态", log: logger, type: .debug)
// 重新初始化蓝牙管理器,如果需要
if centralManager == nil {
_ = initialize()
}
}
/// 处理应用进入后台
private func handleAppDidEnterBackground() async {
os_log("应用进入后台", log: logger, type: .debug)
// 在iOS中,后台蓝牙操作有一定限制,这里可以做一些清理工作
}
// MARK: - 回调通知方法
/// 通知扫描结果
/// - Parameter devices: 设备列表
func notifyScanResult(devices: [[String: Any]]) async {
let currentDelegates = delegates
for delegate in currentDelegates {
await MainActor.run {
delegate.onScanResult(devices: devices)
}
}
}
/// 通知连接状态变化
/// - Parameter state: 连接状态
func notifyConnectionStateChanged(state: Int) async {
let currentDelegates = delegates
for delegate in currentDelegates {
await MainActor.run {
delegate.onConnectionStateChanged(state: state)
}
}
}
/// 通知音频数据接收
/// - Parameter data: 音频数据
func notifyAudioDataReceived(data: Data) async {
let currentDelegates = delegates
for delegate in currentDelegates {
await MainActor.run {
delegate.onAudioDataReceived(data: data)
}
}
}
/// 通知唤醒信号接收
func notifyWakeupSignalReceived() async {
let currentDelegates = delegates
for delegate in currentDelegates {
await MainActor.run {
delegate.onWakeupSignalReceived()
}
}
}
/// 通知设备信息接收
/// - Parameters:
/// - infoType: 信息类型
/// - infoData: 信息数据
func notifyDeviceInfoReceived(infoType: Int, infoData: [String: Any]) async {
let currentDelegates = delegates
for delegate in currentDelegates {
await MainActor.run {
delegate.onDeviceInfoReceived(infoType: infoType, infoData: infoData)
}
}
}
/// 处理扫描到的设备
func handleDiscoveredPeripheral(_ peripheral: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) async {
// 打印外设信息
os_log("扫描到设备: 名称=%{public}@, ID=%{public}@, RSSI=%d", log: logger, type: .debug,
peripheral.name ?? "未知", peripheral.identifier.uuidString, rssi.intValue)
// 提取服务UUID
var uuids: [String] = []
if let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] {
uuids = serviceUUIDs.map { $0.uuidString }
}
// 创建设备信息字典
let deviceInfo: [String: Any] = [
"id": peripheral.identifier.uuidString,
"name": peripheral.name ?? "未知",
"rssi": rssi.intValue,
"uuids": uuids
]
// 更新扫描缓存
scanResults[peripheral] = deviceInfo
// 通知扫描结果
await notifyScanResult(devices: [deviceInfo])
}
/// 处理连接结果
func handleConnectionResult(peripheral: CBPeripheral, success: Bool, error: Error?) async {
if success {
os_log("已连接到设备: %{public}@", log: logger, type: .info, peripheral.identifier.uuidString)
updateConnectionState(BleConst.STATE_CONNECTED)
// 开始服务发现
peripheral.discoverServices([BleConst.PRIMARY_SERVICE_UUID, BleConst.AUDIO_SERVICE_UUID])
} else {
os_log("连接设备失败: %{public}@", log: logger, type: .error, error?.localizedDescription ?? "未知错误")
updateConnectionState(BleConst.STATE_ERROR)
// 清理资源
self.peripheral = nil
}
}
/// 处理断开连接
func handleDisconnection(peripheral: CBPeripheral, error: Error?) async {
if let error = error {
os_log("设备连接断开(错误): %{public}@", log: logger, type: .error, error.localizedDescription)
updateConnectionState(BleConst.STATE_ERROR)
} else {
os_log("设备连接断开(正常)", log: logger, type: .info)
updateConnectionState(BleConst.STATE_DISCONNECTED)
}
// 清理资源
if self.peripheral?.identifier == peripheral.identifier {
self.peripheral = nil
self.writeCharacteristic = nil
self.notifyCharacteristic = nil
self.audioCharacteristic = nil
}
}
/// 处理发现服务
func handleDiscoveredServices(peripheral: CBPeripheral, error: Error?) async {
if let error = error {
os_log("发现服务失败: %{public}@", log: logger, type: .error, error.localizedDescription)
updateConnectionState(BleConst.STATE_ERROR)
return
}
guard let services = peripheral.services else {
os_log("没有发现任何服务", log: logger, type: .error)
updateConnectionState(BleConst.STATE_ERROR)
return
}
// 发现所有服务的特征
for service in services {
os_log("发现服务: %{public}@", log: logger, type: .debug, service.uuid.uuidString)
peripheral.discoverCharacteristics(nil, for: service)
}
}
/// 处理发现的特征
func handleDiscoveredCharacteristics(for service: CBService, peripheral: CBPeripheral, error: Error?) async {
if let error = error {
os_log("发现特征失败: %{public}@", log: logger, type: .error, error.localizedDescription)
return
}
guard let characteristics = service.characteristics else {
os_log("服务没有任何特征: %{public}@", log: logger, type: .warning, service.uuid.uuidString)
return
}
// 根据UUID识别和保存特征
for characteristic in characteristics {
os_log("发现特征: %{public}@", log: logger, type: .debug, characteristic.uuid.uuidString)
// 主服务特征
if service.uuid == BleConst.PRIMARY_SERVICE_UUID {
if characteristic.uuid == BleConst.WRITE_CHAR_UUID {
writeCharacteristic = characteristic
os_log("找到写入特征", log: logger, type: .debug)
} else if characteristic.uuid == BleConst.NOTIFY_CHAR_UUID {
notifyCharacteristic = characteristic
os_log("找到通知特征", log: logger, type: .debug)
// 设置通知
await setupNotification(for: characteristic, peripheral: peripheral)
}
}
// 音频服务特征
if service.uuid == BleConst.AUDIO_SERVICE_UUID {
if characteristic.uuid == BleConst.RECEIVE_AUDIO_CHAR_UUID {
audioCharacteristic = characteristic
os_log("找到音频特征", log: logger, type: .debug)
// 设置通知
await setupNotification(for: characteristic, peripheral: peripheral)
}
}
}
// 检查是否找到所有必要的特征
if writeCharacteristic == nil || notifyCharacteristic == nil {
os_log("未找到所有必要的特征", log: logger, type: .error)
updateConnectionState(BleConst.STATE_ERROR)
}
}
/// 设置特征的通知
/// - Parameters:
/// - characteristic: 特征
/// - peripheral: 外设
private func setupNotification(for characteristic: CBCharacteristic, peripheral: CBPeripheral) async {
if characteristic.properties.contains(.notify) {
peripheral.setNotifyValue(true, for: characteristic)
}
}
/// 处理特征值更新
func handleValueUpdate(for characteristic: CBCharacteristic, peripheral: CBPeripheral, error: Error?) async {
if let error = error {
os_log("特征值更新失败: %{public}@", log: logger, type: .error, error.localizedDescription)
return
}
guard let data = characteristic.value else {
os_log("特征没有值", log: logger, type: .warning)
return
}
let hexString = data.map { String(format: "%02X", $0) }.joined(separator: " ")
os_log("收到特征数据: %{public}@ - %{public}@", log: logger, type: .debug, characteristic.uuid.uuidString, hexString)
// 根据特征UUID处理不同类型的数据
if characteristic.uuid == BleConst.RECEIVE_AUDIO_CHAR_UUID {
// 音频数据处理
await processAudioData(data)
} else if characteristic.uuid == BleConst.NOTIFY_CHAR_UUID {
// 通知特征处理
if !data.isEmpty {
switch data[0] {
case BleConst.DEVICE_RESPONSE_HEADER:
// 设备响应
await processDeviceResponse(data)
case BleConst.DEVICE_NOTIFY_HEADER:
// 设备通知
await processDeviceNotification(data)
default:
os_log("未知数据类型: %d", log: logger, type: .debug, data[0])
}
}
}
}
// MARK: - 状态恢复处理
/// 处理蓝牙状态恢复
func handleStateRestoration(dict: [String: Any]) async {
os_log("正在恢复蓝牙状态...", log: logger, type: .info)
// 1. 恢复之前连接的外设
if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] {
os_log("恢复 %d 个已连接的外设", log: logger, type: .debug, peripherals.count)
for peripheral in peripherals {
self.peripheral = peripheral
peripheral.delegate = CBPeripheralHandler.shared
CBPeripheralHandler.shared.bleService = self
// 根据外设当前状态采取不同操作
if peripheral.state == .connected {
os_log("外设 %{public}@ 已连接", log: logger, type: .debug, peripheral.identifier.uuidString)
updateConnectionState(BleConst.STATE_CONNECTED)
// 重新发现服务
peripheral.discoverServices([BleConst.PRIMARY_SERVICE_UUID, BleConst.AUDIO_SERVICE_UUID])
} else if peripheral.state == .connecting {
os_log("外设 %{public}@ 正在连接中", log: logger, type: .debug, peripheral.identifier.uuidString)
updateConnectionState(BleConst.STATE_CONNECTING)
} else {
os_log("外设 %{public}@ 状态异常: %d", log: logger, type: .debug, peripheral.identifier.uuidString, peripheral.state.rawValue)
// 尝试重新连接
updateConnectionState(BleConst.STATE_CONNECTING)
centralManager?.connect(peripheral, options: nil)
}
}
}
// 2. 恢复扫描状态
if let scanServices = dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID] {
os_log("恢复扫描任务,服务UUID数: %d", log: logger, type: .debug, scanServices.count)
let scanOptions = dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any] ?? [
CBCentralManagerScanOptionAllowDuplicatesKey: true
]
// 重新启动扫描任务
centralManager?.scanForPeripherals(
withServices: scanServices.isEmpty ? nil : scanServices,
options: scanOptions
)
isScanning = true
// 设置新的扫描超时(默认15秒)
scanTimeoutTask?.cancel()
scanTimeoutTask = Task { [weak self] in
guard let self = self else { return }
do {
try await Task.sleep(nanoseconds: UInt64(15.0 * 1_000_000_000))
if !Task.isCancelled {
await self.stopScan()
}
} catch {
// 任务被取消,忽略错误
}
}
}
// 通知外部:BLE状态已恢复
await notifyStateRestored()
}
/// 通知状态恢复
private func notifyStateRestored() async {
let currentDelegates = delegates
for delegate in currentDelegates {
if let stateDelegate = delegate as? BleStateRestorationDelegate {
await MainActor.run {
stateDelegate.onBleStateRestored()
}
}
}
}
}
// MARK: - 中央管理器委托处理器
class CBManagerHandler: NSObject, CBCentralManagerDelegate {
static let shared = CBManagerHandler()
weak var bleService: BleService?
func centralManagerDidUpdateState(_ central: CBCentralManager) {
Task {
guard let service = bleService else { return }
switch central.state {
case .poweredOn:
await MainActor.run {
os_log("蓝牙已开启", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .info)
}
case .poweredOff:
await MainActor.run {
os_log("蓝牙已关闭", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .warning)
}
await service.updateConnectionState(BleConst.STATE_DISCONNECTED)
case .unauthorized, .unsupported:
await MainActor.run {
os_log("蓝牙不可用: %d", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .error, central.state.rawValue)
}
await service.updateConnectionState(BleConst.STATE_ERROR)
default:
await MainActor.run {
os_log("蓝牙状态变化: %d", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .debug, central.state.rawValue)
}
}
}
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
Task {
await bleService?.handleDiscoveredPeripheral(peripheral, advertisementData: advertisementData, rssi: RSSI)
}
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
Task {
await bleService?.handleConnectionResult(peripheral: peripheral, success: true, error: nil)
}
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
Task {
await bleService?.handleConnectionResult(peripheral: peripheral, success: false, error: error)
}
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
Task {
await bleService?.handleDisconnection(peripheral: peripheral, error: error)
}
}
/// 状态恢复处理
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) {
os_log("蓝牙状态恢复被触发", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .info)
// 打印恢复信息
if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] {
os_log("恢复外设数量: %d", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .debug, peripherals.count)
for (i, peripheral) in peripherals.enumerated() {
os_log("恢复外设[%d]: %{public}@", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .debug, i, peripheral.identifier.uuidString)
}
}
if let services = dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID] {
os_log("恢复扫描服务数量: %d", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .debug, services.count)
}
// 异步处理状态恢复
Task {
if let service = bleService {
await service.handleStateRestoration(dict: dict)
}
}
}
}
// MARK: - 外设委托处理器
class CBPeripheralHandler: NSObject, CBPeripheralDelegate {
static let shared = CBPeripheralHandler()
weak var bleService: BleService?
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
Task {
await bleService?.handleDiscoveredServices(peripheral: peripheral, error: error)
}
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
Task {
await bleService?.handleDiscoveredCharacteristics(for: service, peripheral: peripheral, error: error)
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
Task {
await bleService?.handleValueUpdate(for: characteristic, peripheral: peripheral, error: error)
}
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
if let error = error {
os_log("写入特征值失败: %{public}@", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .error, error.localizedDescription)
} else {
os_log("写入特征值成功", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .debug)
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
if let error = error {
os_log("设置通知状态失败: %{public}@", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .error, error.localizedDescription)
} else {
os_log("设置通知状态成功: %{public}@", log: OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleService"), type: .debug, characteristic.uuid.uuidString)
}
}
}
// MARK: - BLE状态恢复代理
/// 蓝牙状态恢复代理
protocol BleStateRestorationDelegate: AnyObject {
/// 当蓝牙状态被系统恢复时调用
func onBleStateRestored()
}
// MARK: - BLE代理
/// 蓝牙事件代理
protocol BleDelegate: AnyObject {
/// 扫描结果回调
func onScanResult(devices: [[String: Any]])
/// 连接状态变化回调
func onConnectionStateChanged(state: Int)
/// 音频数据接收回调
func onAudioDataReceived(data: Data)
/// 唤醒信号接收回调
func onWakeupSignalReceived()
/// 设备信息接收回调
func onDeviceInfoReceived(infoType: Int, infoData: [String: Any])
}

4
local_plugins/ble_service/ios/Classes/BleServicePlugin.h

@ -0,0 +1,4 @@
#import <Flutter/Flutter.h>
@interface BleServicePlugin : NSObject<FlutterPlugin>
@end

12
local_plugins/ble_service/ios/Classes/BleServicePlugin.m

@ -0,0 +1,12 @@
#import "BleServicePlugin.h"
#if __has_include(<ble_service/ble_service-Swift.h>)
#import <ble_service/ble_service-Swift.h>
#else
#import "ble_service-Swift.h"
#endif
@implementation BleServicePlugin
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
[SwiftBleServicePlugin registerWithRegistrar:registrar];
}
@end

203
local_plugins/ble_service/ios/Classes/SwiftBleServicePlugin.swift

@ -0,0 +1,203 @@
import Flutter
import UIKit
public class SwiftBleServicePlugin: NSObject, FlutterPlugin, BleDelegate {
private let TAG = "BleServicePlugin"
// 方法通道
private var methodChannel: FlutterMethodChannel?
// 事件通道
private var dataEventSink: FlutterEventSink?
private var statusEventSink: FlutterEventSink?
// BLE服务实例
private let bleService = BleService.shared
public static func register(with registrar: FlutterPluginRegistrar) {
let methodChannel = FlutterMethodChannel(name: "com.yunqiinnovation.ble_service", binaryMessenger: registrar.messenger())
// 创建插件实例
let instance = SwiftBleServicePlugin()
instance.methodChannel = methodChannel
// 设置方法通道处理器
registrar.addMethodCallDelegate(instance, channel: methodChannel)
// 设置事件通道
instance.setupEventChannels(with: registrar)
// 初始化BLE服务
_ = instance.bleService.initialize()
// 添加自身作为BLE回调
_ = instance.bleService.addDelegate(instance)
}
/// 设置事件通道
private func setupEventChannels(with registrar: FlutterPluginRegistrar) {
// 数据接收事件通道
let dataEventChannel = FlutterEventChannel(name: "com.yunqiinnovation.ble_service/data_events", binaryMessenger: registrar.messenger())
dataEventChannel.setStreamHandler(StreamHandler { [weak self] sink in
self?.dataEventSink = sink
} onCancel: { [weak self] in
self?.dataEventSink = nil
})
// 状态更新事件通道
let statusEventChannel = FlutterEventChannel(name: "com.yunqiinnovation.ble_service/status_events", binaryMessenger: registrar.messenger())
statusEventChannel.setStreamHandler(StreamHandler { [weak self] sink in
self?.statusEventSink = sink
} onCancel: { [weak self] in
self?.statusEventSink = nil
})
}
/// 发送事件到Flutter
private func sendEvent(_ eventSink: FlutterEventSink?, _ data: Any) {
DispatchQueue.main.async {
eventSink?(data)
}
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
NSLog("Method call: \(call.method)")
switch call.method {
case "isConnected":
result(bleService.isConnected())
case "getBatteryInfo":
result(bleService.getBatteryInfo())
case "getDeviceVersion":
result(bleService.getDeviceVersion())
case "openEncoder":
result(bleService.openEncoder())
case "openDecoder":
result(bleService.openDecoder())
case "closeCodec":
result(bleService.closeCodec())
case "playA2DP":
result(bleService.playA2DP())
case "registerBackgroundScan":
result(bleService.registerBackgroundScan())
case "unregisterBackgroundScan":
result(bleService.unregisterBackgroundScan())
case "startBleService":
guard let config = call.arguments as? [String: Any] else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "配置参数无效", details: nil))
return
}
// 处理配置参数
if let address = config["mac"] as? String {
// 如果提供了设备地址,尝试连接
bleService.connect(address: address)
}
// 开始扫描,寻找设备
let scanSuccess = bleService.startScan()
result(scanSuccess)
case "stopBleService":
// 停止扫描和断开连接
_ = bleService.stopScan()
if bleService.isConnected() {
bleService.disconnect()
}
result(true)
default:
result(FlutterMethodNotImplemented)
}
}
// MARK: - BleDelegate Methods
func onScanResult(devices: [[String: Any]]) {
// 目前Flutter层没有直接使用此事件,可以选择不转发
}
func onConnectionStateChanged(state: Int) {
// 将连接状态变化通过状态通道发送
let stateStr: String
switch state {
case BleConst.STATE_DISCONNECTED:
stateStr = "Disconnected"
case BleConst.STATE_CONNECTING:
stateStr = "Connecting"
case BleConst.STATE_CONNECTED:
stateStr = "Connected"
case BleConst.STATE_DISCONNECTING:
stateStr = "Disconnecting"
case BleConst.STATE_ERROR:
stateStr = "Connection Error"
default:
stateStr = "Unknown State(\(state))"
}
let stateMap: [String: Any] = [
"type": "connectionStateChanged",
"state": state,
"stateStr": stateStr
]
sendEvent(statusEventSink, stateMap)
}
func onAudioDataReceived(data: Data) {
// 将音频数据通过数据通道发送
let dataMap: [String: Any] = [
"type": "audioData",
"data": FlutterStandardTypedData(bytes: data)
]
sendEvent(dataEventSink, dataMap)
}
func onWakeupSignalReceived() {
// 将唤醒事件通过状态通道发送
sendEvent(statusEventSink, ["type": "wakeup"])
}
func onDeviceInfoReceived(infoType: Int, infoData: [String: Any]) {
// 将设备信息通过状态通道发送
var resultMap = infoData
resultMap["type"] = "deviceInfo"
resultMap["result"] = infoType
sendEvent(statusEventSink, resultMap)
}
}
// MARK: - 事件处理器类
/// 简化的事件处理器类
class StreamHandler: NSObject, FlutterStreamHandler {
private let onListen: (FlutterEventSink) -> Void
private let onCancel: () -> Void
init(onListen: @escaping (FlutterEventSink) -> Void, onCancel: @escaping () -> Void) {
self.onListen = onListen
self.onCancel = onCancel
super.init()
}
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
onListen(events)
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
onCancel()
return nil
}
}

23
local_plugins/ble_service/ios/ble_service.podspec

@ -0,0 +1,23 @@
#
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
# Run `pod lib lint ble_service.podspec` to validate before publishing.
#
Pod::Spec.new do |s|
s.name = 'ble_service'
s.version = '0.0.1'
s.summary = 'BLE服务Flutter插件'
s.description = <<-DESC
BLE服务Flutter插件,提供蓝牙设备扫描、连接和通信功能。
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.platform = :ios, '11.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'
end

410
local_plugins/chat_storage/ios/Classes/ChatStorageHelper.swift

@ -0,0 +1,410 @@
import Foundation
import SQLite3
/// ChatStorageHelper - iOS版本的聊天存储助手
/// 负责SQLite数据库操作和业务逻辑
class ChatStorageHelper {
// MARK: - Constants
private let TAG = "ChatStorageHelper"
// 数据库常量
private let DATABASE_NAME = "chat_messages.db"
private let DATABASE_VERSION = 1
// 表和列名
private let TABLE_MESSAGES = "messages"
private let COLUMN_ID = "id"
private let COLUMN_SESSION_ID = "session_id"
private let COLUMN_MESSAGE = "message"
private let COLUMN_SENDER = "sender"
private let COLUMN_TIMESTAMP = "timestamp"
private let COLUMN_METADATA = "metadata"
// MARK: - Properties
private var db: OpaquePointer?
private let dbPath: String
// MARK: - Initialization
init(_ context: Any) {
// 获取数据库文件路径
let fileManager = FileManager.default
if let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
let databaseDirectory = documentsDirectory.appendingPathComponent("Databases")
// 确保目录存在
try? fileManager.createDirectory(at: databaseDirectory, withIntermediateDirectories: true)
dbPath = databaseDirectory.appendingPathComponent(DATABASE_NAME).path
// 打开数据库连接
openDatabase()
} else {
dbPath = ""
print("\(TAG) 无法获取文档目录路径")
}
}
deinit {
closeDatabase()
}
// MARK: - Database Operations
/// 打开数据库连接
private func openDatabase() {
if sqlite3_open(dbPath, &db) == SQLITE_OK {
createTables()
print("\(TAG) 数据库已成功打开")
} else {
print("\(TAG) 无法打开数据库: \(String(describing: errorMessage))")
}
}
/// 关闭数据库连接
private func closeDatabase() {
if db != nil {
sqlite3_close(db)
db = nil
}
}
/// 创建必要的数据库表
private func createTables() {
// 创建消息表
let createTableSQL = """
CREATE TABLE IF NOT EXISTS \(TABLE_MESSAGES) (
\(COLUMN_ID) INTEGER PRIMARY KEY AUTOINCREMENT,
\(COLUMN_SESSION_ID) TEXT NOT NULL,
\(COLUMN_MESSAGE) TEXT NOT NULL,
\(COLUMN_SENDER) TEXT NOT NULL,
\(COLUMN_TIMESTAMP) TEXT NOT NULL,
\(COLUMN_METADATA) TEXT
);
"""
if executeUpdate(sql: createTableSQL) {
// 创建索引以加快查询速度
executeUpdate(sql: "CREATE INDEX IF NOT EXISTS idx_session_id ON \(TABLE_MESSAGES) (\(COLUMN_SESSION_ID))")
executeUpdate(sql: "CREATE INDEX IF NOT EXISTS idx_timestamp ON \(TABLE_MESSAGES) (\(COLUMN_TIMESTAMP))")
}
}
/// 获取SQLite错误消息
private var errorMessage: String {
if let errorPointer = sqlite3_errmsg(db) {
let errorMessage = String(cString: errorPointer)
return errorMessage
} else {
return "未知错误"
}
}
/// 执行SQL更新操作(INSERT, UPDATE, DELETE, CREATE等)
private func executeUpdate(sql: String, parameters: [Any]? = nil) -> Bool {
var statement: OpaquePointer?
if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK {
// 绑定参数
if let params = parameters {
for (index, param) in params.enumerated() {
bindParameter(statement: statement, index: index + 1, value: param)
}
}
// 执行语句
let result = sqlite3_step(statement)
sqlite3_finalize(statement)
return result == SQLITE_DONE
} else {
print("\(TAG) 准备SQL语句失败: \(errorMessage)")
return false
}
}
/// 绑定SQL参数
private func bindParameter(statement: OpaquePointer?, index: Int, value: Any) {
if let value = value as? Int {
sqlite3_bind_int(statement, Int32(index), Int32(value))
} else if let value = value as? Double {
sqlite3_bind_double(statement, Int32(index), value)
} else if let value = value as? String {
sqlite3_bind_text(statement, Int32(index), (value as NSString).utf8String, -1, nil)
} else if value is NSNull {
sqlite3_bind_null(statement, Int32(index))
}
}
/// 查询数据并处理结果
private func executeQuery(sql: String, parameters: [Any]? = nil, handler: (OpaquePointer) -> Void) {
var statement: OpaquePointer?
if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK {
// 绑定参数
if let params = parameters {
for (index, param) in params.enumerated() {
bindParameter(statement: statement, index: index + 1, value: param)
}
}
// 处理结果
while sqlite3_step(statement) == SQLITE_ROW {
handler(statement!)
}
} else {
print("\(TAG) 执行查询失败: \(errorMessage)")
}
sqlite3_finalize(statement)
}
// MARK: - Public Methods
/// 保存消息
/// - Parameters:
/// - sessionId: 会话ID
/// - message: 消息内容
/// - sender: 发送者
/// - metadata: 元数据
/// - Returns: 成功返回消息ID,失败返回-1
func saveMessage(sessionId: String, message: String, sender: String, metadata: String? = nil) -> Int64 {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
let timestamp = dateFormatter.string(from: Date())
let sql = """
INSERT INTO \(TABLE_MESSAGES) (\(COLUMN_SESSION_ID), \(COLUMN_MESSAGE), \(COLUMN_SENDER), \(COLUMN_TIMESTAMP), \(COLUMN_METADATA))
VALUES (?, ?, ?, ?, ?)
"""
var parameters: [Any] = [sessionId, message, sender, timestamp]
if let metadata = metadata {
parameters.append(metadata)
} else {
parameters.append(NSNull())
}
if executeUpdate(sql: sql, parameters: parameters) {
return sqlite3_last_insert_rowid(db)
} else {
print("\(TAG) 保存消息失败")
return -1
}
}
/// 获取指定会话的消息
/// - Parameters:
/// - sessionId: 会话ID
/// - page: 页码
/// - pageSize: 每页大小
/// - Returns: JSON字符串,包含消息列表和分页信息
func getMessages(sessionId: String, page: Int = 1, pageSize: Int = 20) -> [String: Any] {
var messages: [[String: Any]] = []
let offset = (page - 1) * pageSize
// 查询消息
let sql = """
SELECT * FROM \(TABLE_MESSAGES)
WHERE \(COLUMN_SESSION_ID) = ?
ORDER BY \(COLUMN_TIMESTAMP) DESC
LIMIT ? OFFSET ?
"""
executeQuery(sql: sql, parameters: [sessionId, pageSize, offset]) { statement in
var message: [String: Any] = [:]
// 获取ID
message["id"] = Int(sqlite3_column_int(statement, 0))
// 获取会话ID
if let sessionId = sqlite3_column_text(statement, 1) {
message["sessionId"] = String(cString: sessionId)
}
// 获取消息内容
if let messageText = sqlite3_column_text(statement, 2) {
message["message"] = String(cString: messageText)
}
// 获取发送者
if let sender = sqlite3_column_text(statement, 3) {
message["sender"] = String(cString: sender)
}
// 获取时间戳
if let timestamp = sqlite3_column_text(statement, 4) {
message["timestamp"] = String(cString: timestamp)
}
// 获取元数据(如果有)
if sqlite3_column_type(statement, 5) != SQLITE_NULL {
if let metadata = sqlite3_column_text(statement, 5) {
message["metadata"] = String(cString: metadata)
}
}
messages.append(message)
}
// 获取总记录数
var totalCount = 0
let countSql = "SELECT COUNT(*) FROM \(TABLE_MESSAGES) WHERE \(COLUMN_SESSION_ID) = ?"
executeQuery(sql: countSql, parameters: [sessionId]) { statement in
totalCount = Int(sqlite3_column_int(statement, 0))
}
// 计算总页数
let totalPages = (totalCount + pageSize - 1) / pageSize
return [
"messages": messages,
"page": page,
"pageSize": pageSize,
"totalCount": totalCount,
"totalPages": totalPages
]
}
/// 获取会话列表
/// - Returns: 会话列表数据
func getSessions() -> [[String: Any]] {
var sessions: [[String: Any]] = []
// 查询不同的会话ID及其最新消息
let sql = """
SELECT s1.\(COLUMN_SESSION_ID), s1.\(COLUMN_MESSAGE), s1.\(COLUMN_SENDER), s1.\(COLUMN_TIMESTAMP)
FROM \(TABLE_MESSAGES) s1
INNER JOIN (
SELECT \(COLUMN_SESSION_ID), MAX(\(COLUMN_TIMESTAMP)) as max_timestamp
FROM \(TABLE_MESSAGES)
GROUP BY \(COLUMN_SESSION_ID)
) s2 ON s1.\(COLUMN_SESSION_ID) = s2.\(COLUMN_SESSION_ID) AND s1.\(COLUMN_TIMESTAMP) = s2.max_timestamp
ORDER BY s1.\(COLUMN_TIMESTAMP) DESC
"""
executeQuery(sql: sql) { statement in
var session: [String: Any] = [:]
// 获取会话ID
if let sessionId = sqlite3_column_text(statement, 0) {
let sessionIdStr = String(cString: sessionId)
session["sessionId"] = sessionIdStr
// 获取该会话的消息数量
var messageCount = 0
let countSql = "SELECT COUNT(*) FROM \(TABLE_MESSAGES) WHERE \(COLUMN_SESSION_ID) = ?"
executeQuery(sql: countSql, parameters: [sessionIdStr]) { countStmt in
messageCount = Int(sqlite3_column_int(countStmt, 0))
}
session["messageCount"] = messageCount
}
// 获取最后一条消息
if let message = sqlite3_column_text(statement, 1) {
session["lastMessage"] = String(cString: message)
}
// 获取最后发送者
if let sender = sqlite3_column_text(statement, 2) {
session["lastSender"] = String(cString: sender)
}
// 获取时间戳
if let timestamp = sqlite3_column_text(statement, 3) {
session["timestamp"] = String(cString: timestamp)
}
sessions.append(session)
}
return sessions
}
/// 删除消息
/// - Parameters:
/// - sessionId: 会话ID,如果提供则删除整个会话
/// - messageIds: 消息ID列表,如果提供则删除指定消息
/// - Returns: 成功返回true,失败返回false
func deleteMessages(sessionId: String? = nil, messageIds: [Int]? = nil) -> Bool {
if let messageIds = messageIds, !messageIds.isEmpty {
// 删除指定ID的消息
let placeholders = Array(repeating: "?", count: messageIds.count).joined(separator: ",")
let sql = "DELETE FROM \(TABLE_MESSAGES) WHERE \(COLUMN_ID) IN (\(placeholders))"
return executeUpdate(sql: sql, parameters: messageIds.map { $0 })
} else if let sessionId = sessionId, !sessionId.isEmpty {
// 删除会话中的所有消息
let sql = "DELETE FROM \(TABLE_MESSAGES) WHERE \(COLUMN_SESSION_ID) = ?"
return executeUpdate(sql: sql, parameters: [sessionId])
}
return false
}
/// 清空数据库
/// - Returns: 成功返回true,失败返回false
func clearDatabase() -> Bool {
let sql = "DELETE FROM \(TABLE_MESSAGES)"
return executeUpdate(sql: sql)
}
/// 获取指定会话的最近消息
/// - Parameters:
/// - sessionId: 会话ID
/// - limit: 限制数量
/// - Returns: 消息列表,按时间正序排列
func getRecentMessages(sessionId: String, limit: Int) -> [[String: Any]] {
var messages: [[String: Any]] = []
// 查询语句 - 按时间倒序获取最近的N条,然后在结果中再按时间正序
let sql = """
SELECT * FROM (
SELECT * FROM \(TABLE_MESSAGES)
WHERE \(COLUMN_SESSION_ID) = ?
ORDER BY \(COLUMN_TIMESTAMP) DESC
LIMIT ?
) tmp ORDER BY \(COLUMN_TIMESTAMP) ASC
"""
executeQuery(sql: sql, parameters: [sessionId, limit]) { statement in
var message: [String: Any] = [:]
// 获取ID
message["id"] = Int(sqlite3_column_int(statement, 0))
// 获取会话ID
if let sessionId = sqlite3_column_text(statement, 1) {
message["sessionId"] = String(cString: sessionId)
}
// 获取消息内容
if let messageText = sqlite3_column_text(statement, 2) {
message["message"] = String(cString: messageText)
}
// 获取发送者
if let sender = sqlite3_column_text(statement, 3) {
message["sender"] = String(cString: sender)
}
// 获取时间戳
if let timestamp = sqlite3_column_text(statement, 4) {
message["timestamp"] = String(cString: timestamp)
}
// 获取元数据(如果有)
if sqlite3_column_type(statement, 5) != SQLITE_NULL {
if let metadata = sqlite3_column_text(statement, 5) {
message["metadata"] = String(cString: metadata)
}
}
messages.append(message)
}
return messages
}
}

4
local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.h

@ -0,0 +1,4 @@
#import <Flutter/Flutter.h>
@interface ChatStoragePlugin : NSObject<FlutterPlugin>
@end

12
local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.m

@ -0,0 +1,12 @@
#import "ChatStoragePlugin.h"
#if __has_include(<chat_storage/chat_storage-Swift.h>)
#import <chat_storage/chat_storage-Swift.h>
#else
#import "chat_storage-Swift.h"
#endif
@implementation ChatStoragePlugin
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
[SwiftChatStoragePlugin registerWithRegistrar:registrar];
}
@end

182
local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.swift

@ -0,0 +1,182 @@
import Flutter
import UIKit
public class ChatStoragePlugin: NSObject, FlutterPlugin {
private let TAG = "ChatStoragePlugin"
private var chatStorageHelper: ChatStorageHelper?
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "chat_storage", binaryMessenger: registrar.messenger())
let instance = ChatStoragePlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
override init() {
super.init()
chatStorageHelper = ChatStorageHelper(())
print("\(TAG) 聊天存储插件已加载")
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let helper = chatStorageHelper else {
result(FlutterError(code: "NOT_INITIALIZED", message: "存储助手未初始化", details: nil))
return
}
switch call.method {
case "saveMessage":
handleSaveMessage(call, result, helper)
case "getMessages":
handleGetMessages(call, result, helper)
case "getSessions":
handleGetSessions(call, result, helper)
case "deleteMessages":
handleDeleteMessages(call, result, helper)
case "clearDatabase":
handleClearDatabase(call, result, helper)
case "getRecentMessages":
handleGetRecentMessages(call, result, helper)
default:
result(FlutterMethodNotImplemented)
}
}
// MARK: - Method handlers
private func handleSaveMessage(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) {
guard let arguments = call.arguments as? [String: Any] else {
result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil))
return
}
guard let sessionId = arguments["sessionId"] as? String,
let message = arguments["message"] as? String,
let sender = arguments["sender"] as? String else {
result(FlutterError(code: "MISSING_ARGS", message: "缺少必要参数", details: nil))
return
}
let metadata = arguments["metadata"] as? String
do {
let id = helper.saveMessage(sessionId: sessionId, message: message, sender: sender, metadata: metadata)
if id != -1 {
result(["success": true, "id": id])
} else {
result(FlutterError(code: "DB_ERROR", message: "插入消息失败", details: nil))
}
} catch {
print("\(TAG) 保存消息时发生错误: \(error.localizedDescription)")
result(FlutterError(code: "EXCEPTION", message: "保存消息时发生错误: \(error.localizedDescription)", details: nil))
}
}
private func handleGetMessages(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) {
guard let arguments = call.arguments as? [String: Any] else {
result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil))
return
}
guard let sessionId = arguments["sessionId"] as? String else {
result(FlutterError(code: "MISSING_ARGS", message: "缺少会话ID", details: nil))
return
}
let page = arguments["page"] as? Int ?? 1
let pageSize = arguments["pageSize"] as? Int ?? 20
do {
let messagesData = helper.getMessages(sessionId: sessionId, page: page, pageSize: pageSize)
// 将结果转换为JSON字符串
if let jsonData = try? JSONSerialization.data(withJSONObject: messagesData),
let jsonString = String(data: jsonData, encoding: .utf8) {
result(jsonString)
} else {
result(FlutterError(code: "JSON_ERROR", message: "无法序列化结果", details: nil))
}
} catch {
print("\(TAG) 获取消息时发生错误: \(error.localizedDescription)")
result(FlutterError(code: "EXCEPTION", message: "获取消息时发生错误: \(error.localizedDescription)", details: nil))
}
}
private func handleGetSessions(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) {
do {
let sessions = helper.getSessions()
// 将结果转换为JSON字符串
if let jsonData = try? JSONSerialization.data(withJSONObject: sessions),
let jsonString = String(data: jsonData, encoding: .utf8) {
result(jsonString)
} else {
result(FlutterError(code: "JSON_ERROR", message: "无法序列化结果", details: nil))
}
} catch {
print("\(TAG) 获取会话列表时发生错误: \(error.localizedDescription)")
result(FlutterError(code: "EXCEPTION", message: "获取会话列表时发生错误: \(error.localizedDescription)", details: nil))
}
}
private func handleDeleteMessages(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) {
guard let arguments = call.arguments as? [String: Any] else {
result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil))
return
}
let sessionId = arguments["sessionId"] as? String
let messageIds = arguments["messageIds"] as? [Int]
do {
let success = helper.deleteMessages(sessionId: sessionId, messageIds: messageIds)
if success {
result(["success": true])
} else {
result(FlutterError(code: "INVALID_ARGS", message: "必须提供sessionId或messageIds", details: nil))
}
} catch {
print("\(TAG) 删除消息时发生错误: \(error.localizedDescription)")
result(FlutterError(code: "EXCEPTION", message: "删除消息时发生错误: \(error.localizedDescription)", details: nil))
}
}
private func handleClearDatabase(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) {
do {
let success = helper.clearDatabase()
result(["success": success])
} catch {
print("\(TAG) 清空数据库时发生错误: \(error.localizedDescription)")
result(FlutterError(code: "EXCEPTION", message: "清空数据库时发生错误: \(error.localizedDescription)", details: nil))
}
}
private func handleGetRecentMessages(_ call: FlutterMethodCall, _ result: @escaping FlutterResult, _ helper: ChatStorageHelper) {
guard let arguments = call.arguments as? [String: Any] else {
result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil))
return
}
guard let sessionId = arguments["sessionId"] as? String else {
result(FlutterError(code: "MISSING_ARGS", message: "缺少会话ID", details: nil))
return
}
let limit = arguments["limit"] as? Int ?? 10
do {
let messages = helper.getRecentMessages(sessionId: sessionId, limit: limit)
result(messages)
} catch {
print("\(TAG) 获取最近消息时发生错误: \(error.localizedDescription)")
result(FlutterError(code: "EXCEPTION", message: "获取最近消息时发生错误: \(error.localizedDescription)", details: nil))
}
}
}

11
local_plugins/chat_storage/ios/Classes/SwiftChatStoragePlugin.swift

@ -0,0 +1,11 @@
import Flutter
import UIKit
// 这个类是为了满足Flutter插件的命名约定
// 实际实现在ChatStoragePlugin.swift中
public class SwiftChatStoragePlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
// 调用实际实现类的注册方法
ChatStoragePlugin.register(with: registrar)
}
}

64
local_plugins/chat_storage/ios/README.md

@ -0,0 +1,64 @@
# Chat Storage 插件 - iOS 实现
## 概述
Chat Storage 插件的 iOS 实现,为 Flutter 应用提供本地 SQLite 数据库支持,用于存储和管理聊天记录。
## 功能特性
- 基于 SQLite 的聊天记录存储
- 支持按会话分组消息
- 提供分页查询功能
- 支持元数据存储
- 完整的增删改查 API
## 技术实现
本插件使用原生 Swift 实现,通过 SQLite C API 直接操作数据库,不依赖第三方 SQLite 封装库,以保持最小的依赖要求和最佳性能。
### 核心组件
- `ChatStorageHelper`: 负责 SQLite 数据库操作,提供完整的数据操作 API
- `ChatStoragePlugin`: 插件主类,处理与 Flutter 的通信
### 数据库结构
数据库包含一个 `messages` 表,结构如下:
```sql
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
message TEXT NOT NULL,
sender TEXT NOT NULL,
timestamp TEXT NOT NULL,
metadata TEXT
);
-- 索引
CREATE INDEX idx_session_id ON messages (session_id);
CREATE INDEX idx_timestamp ON messages (timestamp);
```
## 使用注意事项
### SQLite 多线程访问
本插件所有数据库操作均在主线程执行,以避免 SQLite 多线程访问的复杂性。在处理大量数据时,建议在 Flutter 层面使用 `compute` 函数或 `Isolate` 进行异步处理。
### 数据格式
- 时间戳使用 `yyyy-MM-dd HH:mm:ss` 格式的字符串
- JSON 转换遵循 Swift/Objective-C 的类型映射规则
## 调试与故障排除
- 日志前缀为 `ChatStoragePlugin` 和 `ChatStorageHelper`
- 可通过 Console.app 查看日志输出
- 数据库文件存储在应用的 Documents/Databases 目录下
## 开发者须知
如果需要扩展此插件的功能,比如添加新的表或字段,应同时更新 Android 和 iOS 端的实现,并确保在版本升级时提供适当的数据库迁移策略。
数据库迁移逻辑目前实现为重建表,在生产环境中应该改为保留用户数据的方式。

11
local_plugins/chat_storage/ios/chat_storage.podspec

@ -5,13 +5,13 @@
Pod::Spec.new do |s|
s.name = 'chat_storage'
s.version = '0.0.1'
s.summary = 'Chat Storage plugin'
s.summary = '聊天记录存储插件'
s.description = <<-DESC
A Flutter plugin for chat storage services.
用于存储聊天记录的Flutter插件,支持SQLite数据库操作。
DESC
s.homepage = 'http://example.com'
s.homepage = 'http://yunqiinnovation.com'
s.license = { :file => '../LICENSE' }
s.author = { 'Your Company' => 'email@example.com' }
s.author = { 'YunQi Innovation' => 'support@yunqiinnovation.com' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.dependency 'Flutter'
@ -20,4 +20,7 @@ A Flutter plugin for chat storage services.
# 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'
# 添加SQLite库依赖
s.library = 'sqlite3'
end

197
local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothHelper.swift

@ -0,0 +1,197 @@
import Foundation
import ExternalAccessory
import CoreBluetooth
/// 蓝牙事件类型
enum BluetoothEventType {
case stateChanged
case deviceConnected
case deviceDisconnected
}
/// 蓝牙状态枚举
enum BluetoothState: String {
case unknown = "unknown"
case off = "off"
case on = "on"
case turningOn = "turningOn"
case turningOff = "turningOff"
}
/// 经典蓝牙助手类,负责iOS上的蓝牙设备管理
class ClassicBluetoothHelper: NSObject {
private let TAG = "ClassicBluetoothHelper"
// 状态监控相关
private var isMonitoring = false
private var stateCallback: ((BluetoothEventType, [String: Any]) -> Void)?
// 用于监控蓝牙系统状态
private var centralManager: CBCentralManager?
// 通知观察者
private var accessoryConnectionObserver: NSObjectProtocol?
private var accessoryDisconnectionObserver: NSObjectProtocol?
override init() {
super.init()
centralManager = CBCentralManager(delegate: nil, queue: nil, options: [CBCentralManagerOptionShowPowerAlertKey: false])
}
deinit {
stopBluetoothStateMonitoring()
}
/// 检查蓝牙是否启用
func isBluetoothEnabled() -> Bool {
guard let centralManager = centralManager else { return false }
return centralManager.state == .poweredOn
}
/// 获取已连接的蓝牙设备
/// - Parameter completion: 完成回调
func getConnectedDevices(completion: @escaping ([[String: String]], Error?) -> Void) {
// 获取所有已连接的外部配件
let accessoryManager = EAAccessoryManager.shared()
let connectedAccessories = accessoryManager.connectedAccessories
// 将配件转换为设备信息数组
let devices = connectedAccessories.map { accessory -> [String: String] in
return [
"name": accessory.name,
"address": accessory.serialNumber // iOS使用序列号作为唯一标识
]
}
// 返回结果
completion(devices, nil)
}
/// 开始监听蓝牙状态变化
/// - Parameter callback: 状态变化回调
func startBluetoothStateMonitoring(callback: @escaping (BluetoothEventType, [String: Any]) -> Void) {
self.stateCallback = callback
if !isMonitoring {
isMonitoring = true
// 设置蓝牙中央管理器代理
centralManager?.delegate = self
// 注册蓝牙配件连接/断开通知
let notificationCenter = NotificationCenter.default
// 观察配件连接
accessoryConnectionObserver = notificationCenter.addObserver(
forName: NSNotification.Name.EAAccessoryDidConnect,
object: nil,
queue: nil
) { [weak self] notification in
guard let self = self,
let accessory = notification.userInfo?[EAAccessoryKey] as? EAAccessory else {
return
}
let device: [String: String] = [
"name": accessory.name,
"address": accessory.serialNumber
]
self.stateCallback?(.deviceConnected, ["device": device])
}
// 观察配件断开
accessoryDisconnectionObserver = notificationCenter.addObserver(
forName: NSNotification.Name.EAAccessoryDidDisconnect,
object: nil,
queue: nil
) { [weak self] notification in
guard let self = self,
let accessory = notification.userInfo?[EAAccessoryKey] as? EAAccessory else {
return
}
let device: [String: String] = [
"name": accessory.name,
"address": accessory.serialNumber
]
self.stateCallback?(.deviceDisconnected, ["device": device])
}
// 开始监视配件变化
EAAccessoryManager.shared().registerForLocalNotifications()
}
}
/// 停止监听蓝牙状态变化
func stopBluetoothStateMonitoring() {
if isMonitoring {
isMonitoring = false
// 移除蓝牙中央管理器代理
centralManager?.delegate = nil
// 注销配件通知观察者
if let observer = accessoryConnectionObserver {
NotificationCenter.default.removeObserver(observer)
accessoryConnectionObserver = nil
}
if let observer = accessoryDisconnectionObserver {
NotificationCenter.default.removeObserver(observer)
accessoryDisconnectionObserver = nil
}
// 停止监视配件变化
EAAccessoryManager.shared().unregisterForLocalNotifications()
}
// 清除回调
stateCallback = nil
}
/// 报告当前蓝牙状态
func reportCurrentBluetoothState() {
guard let centralManager = centralManager else { return }
let state: BluetoothState
switch centralManager.state {
case .poweredOn:
state = .on
case .poweredOff:
state = .off
case .resetting:
state = .turningOff // 最接近的近似值
case .unauthorized, .unsupported:
state = .off // 功能上等同于关闭
default:
state = .unknown
}
stateCallback?(.stateChanged, ["state": state.rawValue])
}
}
// MARK: - CBCentralManagerDelegate
extension ClassicBluetoothHelper: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
let state: BluetoothState
switch central.state {
case .poweredOn:
state = .on
case .poweredOff:
state = .off
case .resetting:
state = .turningOff
case .unauthorized, .unsupported:
state = .off
default:
state = .unknown
}
stateCallback?(.stateChanged, ["state": state.rawValue])
}
}

4
local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h

@ -0,0 +1,4 @@
#import <Flutter/Flutter.h>
@interface ClassicBluetoothPlugin : NSObject<FlutterPlugin>
@end

12
local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m

@ -0,0 +1,12 @@
#import "ClassicBluetoothPlugin.h"
#if __has_include(<classic_bluetooth/classic_bluetooth-Swift.h>)
#import <classic_bluetooth/classic_bluetooth-Swift.h>
#else
#import "classic_bluetooth-Swift.h"
#endif
@implementation ClassicBluetoothPlugin
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
[SwiftClassicBluetoothPlugin registerWithRegistrar:registrar];
}
@end

108
local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.swift

@ -0,0 +1,108 @@
import Flutter
import UIKit
import ExternalAccessory
public class ClassicBluetoothPlugin: NSObject, FlutterPlugin {
private let TAG = "ClassicBluetoothPlugin"
private var methodChannel: FlutterMethodChannel?
private var eventChannel: FlutterEventChannel?
private var eventSink: FlutterEventSink?
private let bluetoothHelper = ClassicBluetoothHelper()
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "com.yunqiinnovation.classic_bluetooth/methods", binaryMessenger: registrar.messenger())
let instance = ClassicBluetoothPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
// 设置事件通道(用于蓝牙状态监控)
let eventChannel = FlutterEventChannel(name: "com.yunqiinnovation.classic_bluetooth/events", binaryMessenger: registrar.messenger())
eventChannel.setStreamHandler(instance)
instance.methodChannel = channel
instance.eventChannel = eventChannel
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getConnectedDevices":
getConnectedDevices(result: result)
case "isBluetoothEnabled":
result(bluetoothHelper.isBluetoothEnabled())
default:
result(FlutterMethodNotImplemented)
}
}
private func getConnectedDevices(result: @escaping FlutterResult) {
if !bluetoothHelper.isBluetoothEnabled() {
result([])
return
}
bluetoothHelper.getConnectedDevices { devices, error in
if let error = error {
result(FlutterError(code: "BLUETOOTH_ERROR", message: error.localizedDescription, details: nil))
return
}
result(devices)
}
}
// 发送事件到Flutter
private func sendEvent(_ event: [String: Any]) {
if let eventSink = eventSink {
eventSink(event)
}
}
}
// MARK: - FlutterStreamHandler
extension ClassicBluetoothPlugin: FlutterStreamHandler {
public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
self.eventSink = events
// 开始监听蓝牙状态变化
bluetoothHelper.startBluetoothStateMonitoring { [weak self] eventType, data in
guard let self = self else { return }
switch eventType {
case .stateChanged:
if let state = data["state"] as? String {
self.sendEvent([
"type": "stateChanged",
"state": state,
"timestamp": Int(Date().timeIntervalSince1970 * 1000)
])
}
case .deviceConnected:
if let device = data["device"] as? [String: String] {
self.sendEvent([
"type": "deviceConnected",
"device": device,
"timestamp": Int(Date().timeIntervalSince1970 * 1000)
])
}
case .deviceDisconnected:
if let device = data["device"] as? [String: String] {
self.sendEvent([
"type": "deviceDisconnected",
"device": device,
"timestamp": Int(Date().timeIntervalSince1970 * 1000)
])
}
}
}
// 报告当前蓝牙状态
bluetoothHelper.reportCurrentBluetoothState()
return nil
}
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
eventSink = nil
bluetoothHelper.stopBluetoothStateMonitoring()
return nil
}
}

136
local_plugins/classic_bluetooth/ios/Classes/FileLogger.swift

@ -0,0 +1,136 @@
import Foundation
import os.log
/// iOS端的文件日志助手类,提供与Android端FileLogger类似的功能
class FileLogger {
private static let logQueue = DispatchQueue(label: "com.yunqiinnovation.classic_bluetooth.logger", qos: .background)
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
return formatter
}()
/// 日志类型
enum LogLevel: String {
case debug = "DEBUG"
case info = "INFO"
case warning = "WARNING"
case error = "ERROR"
}
/// 输出调试日志
/// - Parameters:
/// - tag: 标签
/// - message: 日志消息
static func d(_ tag: String, _ message: String) {
log(level: .debug, tag: tag, message: message)
}
/// 输出信息日志
/// - Parameters:
/// - tag: 标签
/// - message: 日志消息
static func i(_ tag: String, _ message: String) {
log(level: .info, tag: tag, message: message)
}
/// 输出警告日志
/// - Parameters:
/// - tag: 标签
/// - message: 日志消息
static func w(_ tag: String, _ message: String) {
log(level: .warning, tag: tag, message: message)
}
/// 输出错误日志
/// - Parameters:
/// - tag: 标签
/// - message: 日志消息
/// - error: 可选的错误对象
static func e(_ tag: String, _ message: String, _ error: Error? = nil) {
var fullMessage = message
if let error = error {
fullMessage += " - Error: \(error.localizedDescription)"
}
log(level: .error, tag: tag, message: fullMessage)
}
/// 输出日志
/// - Parameters:
/// - level: 日志级别
/// - tag: 标签
/// - message: 日志消息
private static func log(level: LogLevel, tag: String, message: String) {
let timestamp = dateFormatter.string(from: Date())
let logMessage = "[\(timestamp)] [\(level.rawValue)] [\(tag)]: \(message)"
// 输出到控制台
print(logMessage)
// 异步写入日志文件
logQueue.async {
writeToLogFile(logMessage)
}
// 使用系统日志
if #available(iOS 10.0, *) {
let osLog = OSLog(subsystem: "com.yunqiinnovation.classic_bluetooth", category: tag)
let osLogType: OSLogType
switch level {
case .debug:
osLogType = .debug
case .info:
osLogType = .info
case .warning:
osLogType = .default
case .error:
osLogType = .error
}
os_log("%{public}@", log: osLog, type: osLogType, message)
}
}
/// 将日志写入文件
/// - Parameter message: 日志消息
private static func writeToLogFile(_ message: String) {
guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
return
}
let logDirectoryURL = documentsDirectory.appendingPathComponent("Logs")
let dateString = DateFormatter().with { $0.dateFormat = "yyyy-MM-dd" }.string(from: Date())
let logFileURL = logDirectoryURL.appendingPathComponent("bluetooth_\(dateString).log")
do {
// 创建日志文件夹
try FileManager.default.createDirectory(at: logDirectoryURL, withIntermediateDirectories: true)
// 如果文件不存在,创建文件
if !FileManager.default.fileExists(atPath: logFileURL.path) {
FileManager.default.createFile(atPath: logFileURL.path, contents: nil)
}
// 追加日志内容
if let fileHandle = FileHandle(forWritingAtPath: logFileURL.path) {
fileHandle.seekToEndOfFile()
if let data = "\(message)\n".data(using: .utf8) {
fileHandle.write(data)
}
fileHandle.closeFile()
}
} catch {
print("写入日志文件失败: \(error.localizedDescription)")
}
}
}
// MARK: - DateFormatter 扩展
extension DateFormatter {
func with(_ block: (DateFormatter) -> Void) -> DateFormatter {
block(self)
return self
}
}

11
local_plugins/classic_bluetooth/ios/Classes/SwiftClassicBluetoothPlugin.swift

@ -0,0 +1,11 @@
import Flutter
import UIKit
// 这个文件是为了满足Flutter插件的命名约定
// 实际实现在ClassicBluetoothPlugin.swift中
public class SwiftClassicBluetoothPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
// 调用真正的实现
ClassicBluetoothPlugin.register(with: registrar)
}
}

44
local_plugins/classic_bluetooth/ios/README.md

@ -0,0 +1,44 @@
# iOS蓝牙配置说明
## 所需权限
要在iOS中使用经典蓝牙功能,需要在应用的`Info.plist`中添加以下配置:
### 1. 外部附件协议支持
在Info.plist中添加`UISupportedExternalAccessoryProtocols`数组,并添加你的设备支持的协议。如果不确定,可以先添加常见的协议:
```xml
<key>UISupportedExternalAccessoryProtocols</key>
<array>
<string>com.example.protocol</string>
<!-- 添加其他你设备支持的协议 -->
</array>
```
### 2. 蓝牙使用说明
在iOS 13及以上版本中,需要添加蓝牙使用说明:
```xml
<key>NSBluetoothAlwaysUsageDescription</key>
<string>我们需要使用蓝牙来连接您的音频设备</string>
```
对于iOS 13以下版本:
```xml
<key>NSBluetoothPeripheralUsageDescription</key>
<string>我们需要使用蓝牙来连接您的音频设备</string>
```
## 常见问题
1. 如果看不到任何设备,请确保:
- 设备已配对并连接到iOS设备
- 设备是MFi认证的(Made for iPhone/iPad),因为iOS对经典蓝牙有限制
- 添加了正确的协议字符串
2. 蓝牙状态变化不及时:
- iOS系统对蓝牙状态变化的通知可能有延迟
- 请确保用户已经授权蓝牙权限

22
local_plugins/classic_bluetooth/ios/classic_bluetooth.podspec

@ -0,0 +1,22 @@
#
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
#
Pod::Spec.new do |s|
s.name = 'classic_bluetooth'
s.version = '0.0.1'
s.summary = '经典蓝牙插件'
s.description = <<-DESC
一个用于管理经典蓝牙设备的Flutter插件。
DESC
s.homepage = 'http://yunqiinnovation.com'
s.license = { :file => '../LICENSE' }
s.author = { 'YunQi Innovation' => 'support@yunqiinnovation.com' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.ios.deployment_target = '11.0'
s.swift_version = '5.0'
# 添加CoreBluetooth和ExternalAccessory框架依赖
s.framework = 'CoreBluetooth', 'ExternalAccessory'
end

2
local_plugins/classic_bluetooth/pubspec.yaml

@ -17,4 +17,6 @@ flutter:
platforms:
android:
package: com.yunqiinnovation.classic_bluetooth
pluginClass: ClassicBluetoothPlugin
ios:
pluginClass: ClassicBluetoothPlugin

4
local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.h

@ -0,0 +1,4 @@
#import <Flutter/Flutter.h>
@interface DeepvoiceUtilsPlugin : NSObject<FlutterPlugin>
@end

12
local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.m

@ -0,0 +1,12 @@
#import "DeepvoiceUtilsPlugin.h"
#if __has_include(<deepvoice_utils/deepvoice_utils-Swift.h>)
#import <deepvoice_utils/deepvoice_utils-Swift.h>
#else
#import "deepvoice_utils-Swift.h"
#endif
@implementation DeepvoiceUtilsPlugin
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
[SwiftDeepvoiceUtilsPlugin registerWithRegistrar:registrar];
}
@end

30
local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.swift

@ -0,0 +1,30 @@
import Flutter
import UIKit
public class DeepvoiceUtilsPlugin: NSObject, FlutterPlugin {
private static let TAG = "DeepvoiceUtilsPlugin"
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "deepvoice_utils", binaryMessenger: registrar.messenger())
let instance = DeepvoiceUtilsPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
// 初始化文件日志
FileLogger.initialize()
FileLogger.d(TAG, "DeepvoiceUtilsPlugin 已初始化")
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getPlatformVersion":
result("iOS \(UIDevice.current.systemVersion)")
default:
result(FlutterMethodNotImplemented)
}
}
public func detachFromEngine(for registrar: FlutterPluginRegistrar) {
// 关闭日志系统
FileLogger.shutdown()
}
}

286
local_plugins/deepvoice_utils/ios/Classes/FileLogger.swift

@ -0,0 +1,286 @@
import Foundation
import os.log
/**
* 文件日志工具
* 将日志同时输出到控制台和文件系统
*/
@objc public class FileLogger: NSObject {
private static let TAG = "FileLogger"
private static var instance: FileLogger?
private let logDir: URL
private var currentLogFile: URL?
private let logQueue = DispatchQueue(label: "com.yunqiinnovation.deepvoice_utils.logger", qos: .utility)
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
private let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss.SSS"
return formatter
}()
/**
* 初始化日志系统
*/
private init(directory: URL) {
self.logDir = directory.appendingPathComponent("logs", isDirectory: true)
super.init()
// 创建日志目录
try? FileManager.default.createDirectory(at: logDir, withIntermediateDirectories: true)
// 创建当天日志文件
updateLogFile()
// 记录初始化完成
self.d(FileLogger.TAG, "文件日志系统已初始化")
}
/**
* 更新日志文件为当天日期
*/
private func updateLogFile() {
let today = dateFormatter.string(from: Date())
let logFile = logDir.appendingPathComponent("log_\(today).txt")
if currentLogFile == nil || currentLogFile?.path != logFile.path {
currentLogFile = logFile
// 如果是新文件,添加文件头
if !FileManager.default.fileExists(atPath: logFile.path) {
let headerDate = DateFormatter()
headerDate.dateFormat = "yyyy-MM-dd HH:mm:ss"
let headerLine = "===== 日志开始: \(headerDate.string(from: Date())) =====\n"
try? headerLine.write(to: logFile, atomically: true, encoding: .utf8)
}
}
}
/**
* 将日志写入文件
*/
private func logToFile(level: String, tag: String, message: String) {
logQueue.async { [weak self] in
guard let self = self else { return }
do {
// 检查是否需要更新日志文件
self.updateLogFile()
guard let logFile = self.currentLogFile,
FileManager.default.fileExists(atPath: logFile.path) else {
return
}
// 格式化日志内容
let timestamp = self.timeFormatter.string(from: Date())
let logLine = "\(timestamp) \(level)/\(tag): \(message)\n"
// 获取文件句柄并追加内容
if let fileHandle = try? FileHandle(forWritingTo: logFile) {
fileHandle.seekToEndOfFile()
if let data = logLine.data(using: .utf8) {
fileHandle.write(data)
}
fileHandle.closeFile()
} else {
// 如果无法获取文件句柄,尝试直接写入
let existingContent = try? String(contentsOf: logFile, encoding: .utf8)
let newContent = (existingContent ?? "") + logLine
try newContent.write(to: logFile, atomically: true, encoding: .utf8)
}
} catch {
print("写入日志文件失败: \(error.localizedDescription)")
}
}
}
/**
* 调试级别日志
*/
private func d(_ tag: String, _ message: String) {
// 输出到控制台
print("D/\(tag): \(message)")
// 写入文件
logToFile(level: "D", tag: tag, message: message)
}
/**
* 信息级别日志
*/
private func i(_ tag: String, _ message: String) {
// 输出到控制台
print("I/\(tag): \(message)")
// 写入文件
logToFile(level: "I", tag: tag, message: message)
}
/**
* 警告级别日志
*/
private func w(_ tag: String, _ message: String) {
// 输出到控制台
print("W/\(tag): \(message)")
// 写入文件
logToFile(level: "W", tag: tag, message: message)
}
/**
* 错误级别日志
*/
private func e(_ tag: String, _ message: String, _ error: Error? = nil) {
var fullMessage = message
// 如果有错误,添加错误信息
if let error = error {
fullMessage += "\n\(error.localizedDescription)"
}
// 输出到控制台
print("E/\(tag): \(fullMessage)")
// 写入文件
logToFile(level: "E", tag: tag, message: fullMessage)
}
/**
* 清理旧日志文件
*/
private func cleanOldLogs(daysToKeep: Int = 7) {
logQueue.async {
do {
let currentDate = Date()
let fileManager = FileManager.default
let logFiles = try fileManager.contentsOfDirectory(at: self.logDir, includingPropertiesForKeys: [.creationDateKey], options: [])
for fileURL in logFiles where fileURL.pathExtension == "txt" {
if let fileAttributes = try? fileManager.attributesOfItem(atPath: fileURL.path),
let creationDate = fileAttributes[.creationDate] as? Date {
let age = Calendar.current.dateComponents([.day], from: creationDate, to: currentDate)
if let days = age.day, days > daysToKeep {
try? fileManager.removeItem(at: fileURL)
print("D/\(FileLogger.TAG): 已删除旧日志文件: \(fileURL.lastPathComponent)")
}
}
}
} catch {
print("E/\(FileLogger.TAG): 清理旧日志失败: \(error.localizedDescription)")
}
}
}
// MARK: - 公共静态方法
/**
* 初始化日志系统
*/
@objc public static func initialize() {
if instance == nil {
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
instance = FileLogger(directory: documentsDirectory)
}
}
/**
* 初始化日志系统(使用自定义目录)
*/
@objc public static func initialize(withDirectory directory: URL) {
if instance == nil {
instance = FileLogger(directory: directory)
}
}
/**
* 调试日志
*/
@objc public static func d(_ tag: String, _ message: String) {
// 如果实例不存在,先使用默认目录初始化
if instance == nil {
initialize()
}
instance?.d(tag, message)
// 使用系统日志
if #available(iOS 10.0, *) {
os_log("%{public}@", log: OSLog(subsystem: "com.yunqiinnovation.deepvoice_utils", category: tag), type: .debug, message)
}
}
/**
* 信息日志
*/
@objc public static func i(_ tag: String, _ message: String) {
if instance == nil {
initialize()
}
instance?.i(tag, message)
// 使用系统日志
if #available(iOS 10.0, *) {
os_log("%{public}@", log: OSLog(subsystem: "com.yunqiinnovation.deepvoice_utils", category: tag), type: .info, message)
}
}
/**
* 警告日志
*/
@objc public static func w(_ tag: String, _ message: String) {
if instance == nil {
initialize()
}
instance?.w(tag, message)
// 使用系统日志
if #available(iOS 10.0, *) {
os_log("%{public}@", log: OSLog(subsystem: "com.yunqiinnovation.deepvoice_utils", category: tag), type: .default, message)
}
}
/**
* 错误日志
*/
@objc public static func e(_ tag: String, _ message: String, _ error: Error? = nil) {
if instance == nil {
initialize()
}
instance?.e(tag, message, error)
// 使用系统日志
if #available(iOS 10.0, *) {
let errorMessage = error != nil ? "\(message): \(error!.localizedDescription)" : message
os_log("%{public}@", log: OSLog(subsystem: "com.yunqiinnovation.deepvoice_utils", category: tag), type: .error, errorMessage)
}
}
/**
* 清理旧日志
*/
@objc public static func cleanOldLogs(daysToKeep: Int = 7) {
instance?.cleanOldLogs(daysToKeep: daysToKeep)
}
/**
* 关闭日志系统
*/
@objc public static func shutdown() {
instance = nil
}
}

11
local_plugins/deepvoice_utils/ios/Classes/SwiftDeepvoiceUtilsPlugin.swift

@ -0,0 +1,11 @@
import Flutter
import UIKit
// 这个类是为了满足Flutter插件的命名约定
// 实际实现在DeepvoiceUtilsPlugin.swift中
public class SwiftDeepvoiceUtilsPlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
// 调用真正的实现
DeepvoiceUtilsPlugin.register(with: registrar)
}
}

54
local_plugins/deepvoice_utils/ios/README.md

@ -0,0 +1,54 @@
# DeepVoice Utils iOS 实现
## 简介
本模块是 `deepvoice_utils` 插件的 iOS 实现部分,主要提供以下功能:
- 文件日志记录(FileLogger):将日志记录到文件,方便调试和问题排查
## 使用说明
### 日志系统
该插件自动初始化日志系统,开发者无需手动调用初始化方法。日志文件默认保存在应用的 Documents 目录下的 logs 文件夹中,按日期分文件存储。
## 配置要求
- iOS 11.0 或更高版本
- Swift 5.0
- 确保应用拥有文件写入权限
## 开发说明
### 文件结构
- `DeepvoiceUtilsPlugin.swift`:插件主类,负责与 Flutter 交互
- `FileLogger.swift`:日志系统实现,提供日志记录功能
- `SwiftDeepvoiceUtilsPlugin.swift`:命名约定桥接类
- `DeepvoiceUtilsPlugin.h/.m`:Objective-C 桥接文件
### 自定义日志目录
如需使用自定义日志目录,可以在应用启动时手动调用初始化方法:
```swift
import deepvoice_utils
// 在 AppDelegate 中
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 自定义日志目录
let customDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("custom_logs")
FileLogger.initialize(withDirectory: customDirectory)
return true
}
```
### 清理旧日志
默认情况下,日志系统不会自动清理旧日志。如果需要清理,可以手动调用:
```swift
// 清理7天前的日志
FileLogger.cleanOldLogs(daysToKeep: 7)
```

19
local_plugins/deepvoice_utils/ios/deepvoice_utils.podspec

@ -0,0 +1,19 @@
#
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
#
Pod::Spec.new do |s|
s.name = 'deepvoice_utils'
s.version = '0.0.1'
s.summary = '深语工具库'
s.description = <<-DESC
提供文件日志记录等通用功能的Flutter插件。
DESC
s.homepage = 'http://yunqiinnovation.com'
s.license = { :file => '../LICENSE' }
s.author = { 'YunQi Innovation' => 'support@yunqiinnovation.com' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.ios.deployment_target = '11.0'
s.swift_version = '5.0'
end

33
local_plugins/music_service/.gitignore

@ -0,0 +1,33 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
build/

33
local_plugins/music_service/.metadata

@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "c23637390482d4cf9598c3ce3f2be31aa7332daf"
channel: "stable"
project_type: plugin
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
- platform: android
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
- platform: ios
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

3
local_plugins/music_service/CHANGELOG.md

@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.

1
local_plugins/music_service/LICENSE

@ -0,0 +1 @@
TODO: Add your license here.

15
local_plugins/music_service/README.md

@ -0,0 +1,15 @@
# music_service
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter
[plug-in package](https://flutter.dev/to/develop-plugins),
a specialized package that includes platform-specific implementation code for
Android and/or iOS.
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

4
local_plugins/music_service/analysis_options.yaml

@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

9
local_plugins/music_service/android/.gitignore

@ -0,0 +1,9 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx

70
local_plugins/music_service/android/build.gradle

@ -0,0 +1,70 @@
group = "com.yunqiinnovation.music_service"
version = "1.0-SNAPSHOT"
buildscript {
ext.kotlin_version = "1.8.22"
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.7.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
apply plugin: "com.android.library"
apply plugin: "kotlin-android"
android {
namespace = "com.yunqiinnovation.music_service"
compileSdk = 35
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11
}
sourceSets {
main.java.srcDirs += "src/main/kotlin"
test.java.srcDirs += "src/test/kotlin"
}
defaultConfig {
minSdk = 21
}
dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.0.0")
}
testOptions {
unitTests.all {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed", "standardOut", "standardError"
outputs.upToDateWhen {false}
showStandardStreams = true
}
}
}
}
dependencies {
implementation project(':deepvoice_utils')
}

1
local_plugins/music_service/android/settings.gradle

@ -0,0 +1 @@
rootProject.name = 'music_service'

11
local_plugins/music_service/android/src/main/AndroidManifest.xml

@ -0,0 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.yunqiinnovation.music_service">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application>
<service
android:name=".MusicService"
android:exported="false"
android:enabled="true"
android:foregroundServiceType="mediaPlayback" />
</application>
</manifest>

153
local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt

@ -0,0 +1,153 @@
package com.yunqiinnovation.music_service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.media.AudioManager
import android.media.MediaPlayer
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.yunqiinnovation.music_service.MusicServiceStarter
import kotlinx.coroutines.CoroutineScope
import java.util.concurrent.atomic.AtomicBoolean
class MusicService : Service() {
companion object {
private const val TAG = "MusicService"
private const val CHANNEL_ID = "music_media_service_channel"
private const val NOTIFICATION_ID = 1002
private const val PREF_NAME = "deep_voice_config"
// 用于跟踪服务是否正在运行
private val isRunning = AtomicBoolean(false)
// 获取服务运行状态
fun isServiceRunning(): Boolean = isRunning.get()
}
private var mediaPlayer: MediaPlayer? = null
private val playlist = mutableListOf<String>() // 可扩展支持播放列表
private var currentIndex = 0
override fun onCreate() {
super.onCreate()
val notification = createNotification() // 创建一个非空通知
startForeground(1, notification)
}
private var isPlaying = false
/**
* 创建前台服务通知
*/
private fun createNotification(): Notification {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Android 8.0+ 需要创建通知渠道
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"音乐播放服务",
NotificationManager.IMPORTANCE_LOW
)
manager.createNotificationChannel(channel)
}
// 点击通知打开主界面
val intent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = if (intent != null) {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
null
}
// 构建通知
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("DeepSound")
.setContentText(if (isPlaying) "正在播放" else "已暂停")
.setSmallIcon(android.R.drawable.ic_media_play)
// 设置点击意图
pendingIntent?.let { builder.setContentIntent(it) }
return builder.build()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val command = intent?.getStringExtra("command") ?: return START_NOT_STICKY
Log.i(TAG, "onStartCommand: $command")
when (command) {
"play" -> {
val url = intent.getStringExtra("url") ?: return START_NOT_STICKY
play(url)
}
"pause" -> mediaPlayer?.pause()
"resume" -> mediaPlayer?.start()
"stop" -> stopSelf()
"next" -> {
currentIndex++
if (currentIndex < playlist.size) play(playlist[currentIndex])
}
"previous" -> {
currentIndex--
if (currentIndex >= 0) play(playlist[currentIndex])
}
"setVolume" -> {
val volume = intent.getFloatExtra("volume", 1.0f)
mediaPlayer?.setVolume(volume, volume)
}
}
return START_STICKY
}
private fun play(url: String) {
Log.i(TAG, "开始播放音乐: $url")
mediaPlayer?.release()
mediaPlayer = MediaPlayer().apply {
setDataSource(url)
setAudioStreamType(AudioManager.STREAM_MUSIC)
prepareAsync()
setOnPreparedListener { start() }
setOnCompletionListener { stopSelf() }
}
// startForeground(1, NotificationUtils.createNotification(this, "Playing..."))
}
override fun onDestroy() {
mediaPlayer?.release()
stopForeground(true)
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
/**
* 释放资源
*/
fun release() {
try {
mediaPlayer?.apply {
if (isPlaying) stop()
release()
}
mediaPlayer = null
} catch (e: Exception) {
// 忽略异常
}
}
/**
* 是否正在播放
*/
fun isPlaying() = mediaPlayer?.isPlaying == true
}

46
local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServicePlugin.kt

@ -0,0 +1,46 @@
package com.yunqiinnovation.music_service
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import com.yunqiinnovation.deepvoice_utils.FileLogger
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
/** MusicServicePlugin */
class MusicServicePlugin: FlutterPlugin, MethodCallHandler {
private val TAG = "MusicServicePlugin"
private lateinit var context: Context
private lateinit var channel : MethodChannel
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "music_service")
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
FileLogger.d(TAG, "Method call: ${call.method}")
val intent = Intent(context, MusicService::class.java)
when (call.method) {
"play" -> {
val url = call.argument<String>("url")
MusicServiceStarter.startServiceWithCommand(context, "play", url)
}
"pause" -> MusicServiceStarter.startServiceWithCommand(context, "pause")
"resume" -> MusicServiceStarter.startServiceWithCommand(context, "resume")
"stop" -> MusicServiceStarter.startServiceWithCommand(context, "stop")
"setVolume" -> {
val volume = call.argument<Double>("volume")?.toFloat()
MusicServiceStarter.startServiceWithCommand(context, "setVolume", volume = volume)
}
else -> result.notImplemented()
}
result.success(null)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {}
}

19
local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicServiceStarter.kt

@ -0,0 +1,19 @@
package com.yunqiinnovation.music_service
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.content.ContextCompat
object MusicServiceStarter {
private val TAG = "MusicServiceStarter"
fun startServiceWithCommand(context: Context, command: String, url: String? = null, volume: Float? = null) {
Log.i(TAG, "startServiceWithCommand: $command $url")
val intent = Intent(context, MusicService::class.java).apply {
putExtra("command", command)
url?.let { putExtra("url", it) }
volume?.let { putExtra("volume", it) }
}
ContextCompat.startForegroundService(context, intent)
}
}

38
local_plugins/music_service/ios/.gitignore

@ -0,0 +1,38 @@
.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

0
local_plugins/music_service/ios/Assets/.gitkeep

19
local_plugins/music_service/ios/Classes/MusicServicePlugin.swift

@ -0,0 +1,19 @@
import Flutter
import UIKit
public class MusicServicePlugin: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "music_service", binaryMessenger: registrar.messenger())
let instance = MusicServicePlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "getPlatformVersion":
result("iOS " + UIDevice.current.systemVersion)
default:
result(FlutterMethodNotImplemented)
}
}
}

14
local_plugins/music_service/ios/Resources/PrivacyInfo.xcprivacy

@ -0,0 +1,14 @@
<?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>

29
local_plugins/music_service/ios/music_service.podspec

@ -0,0 +1,29 @@
#
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
# Run `pod lib lint music_service.podspec` to validate before publishing.
#
Pod::Spec.new do |s|
s.name = 'music_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.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 = {'music_service_privacy' => ['Resources/PrivacyInfo.xcprivacy']}
end

55
local_plugins/music_service/lib/music_service.dart

@ -0,0 +1,55 @@
import 'package:flutter/services.dart';
class MusicService {
static final MusicService _instance = MusicService._internal();
/// 获取单例
factory MusicService() => _instance;
// 方法通道
final MethodChannel _methodChannel = const MethodChannel(
'com.yunqiinnovation.music_service',
);
// 事件通道 - 简化为只使用数据和状态通道
late final EventChannel _dataEventChannel;
late final EventChannel _statusEventChannel;
// 事件流
late final Stream<dynamic> _dataStream;
late final Stream<dynamic> _statusStream;
// 获取事件流
Stream<dynamic> get onDataReceived => _dataStream;
// 状态流直接传递原始数据
Stream<dynamic> get onStatusUpdated => _statusStream;
// 内部构造函数
MusicService._internal() {
_dataEventChannel = const EventChannel(
'com.yunqiinnovation.music_service/data_events',
);
_statusEventChannel = const EventChannel(
'com.yunqiinnovation.music_service/status_events',
);
// 初始化事件流
_dataStream = _dataEventChannel.receiveBroadcastStream();
_statusStream = _statusEventChannel.receiveBroadcastStream();
}
Future<void> playMusic(String url) async {
await _methodChannel.invokeMethod('play', {'url': url});
}
Future<void> pauseMusic() async => _methodChannel.invokeMethod('pause');
Future<void> resumeMusic() async => _methodChannel.invokeMethod('resume');
Future<void> nextTrack() async => _methodChannel.invokeMethod('next');
Future<void> previousTrack() async => _methodChannel.invokeMethod('previous');
Future<void> stopMusic() async => _methodChannel.invokeMethod('stop');
Future<void> setVolume(double volume) async => _methodChannel.invokeMethod(
'setVolume',
{'volume': volume},
); // volume: 0.0~1.0
}

72
local_plugins/music_service/pubspec.yaml

@ -0,0 +1,72 @@
name: music_service
description: "A new Flutter project."
version: 0.0.1
homepage:
environment:
sdk: ^3.7.2
flutter: '>=3.3.0'
dependencies:
flutter:
sdk: flutter
plugin_platform_interface: ^2.0.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# This section identifies this Flutter project as a plugin project.
# The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.)
# which should be registered in the plugin registry. This is required for
# using method channels.
# The Android 'package' specifies package in which the registered class is.
# This is required for using method channels on Android.
# The 'ffiPlugin' specifies that native code should be built and bundled.
# This is required for using `dart:ffi`.
# All these are used by the tooling to maintain consistency when
# adding or updating assets for this project.
plugin:
platforms:
android:
package: com.yunqiinnovation.music_service
pluginClass: MusicServicePlugin
ios:
pluginClass: MusicServicePlugin
# To add assets to your plugin package, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
#
# For details regarding assets in packages, see
# https://flutter.dev/to/asset-from-package
#
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# To add custom fonts to your plugin package, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts in packages, see
# https://flutter.dev/to/font-from-package

101
local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt

@ -199,88 +199,69 @@ class MCPSubClient(
*/
private fun updateToolMaps() {
toolMaps.clear()
try {
// 转换MCP工具为OpenAI函数格式
tools.forEach { toolUnion ->
val tool = toolUnion.tool().getOrNull() ?: return@forEach
try {
// 获取工具的输入模式
val inputSchema = tool.inputSchema() ?: return@forEach
// 使用toString()获取完整JSON字符串
val schemaStr = inputSchema.toString()
Log.d(TAG, "工具 ${tool.name()} 的Schema: $schemaStr")
// 解析properties和required
val propertiesMap = HashMap<String, Any>()
val requiredList = ArrayList<String>()
try {
// 匹配properties内部的内容
val propertiesPattern = "properties=\\{(.*?)\\}(?:,|\\s|$)".toRegex(RegexOption.DOT_MATCHES_ALL)
val propertiesMatch = propertiesPattern.find(schemaStr)
if (propertiesMatch != null) {
val propertiesContent = propertiesMatch.groupValues[1]
// 匹配每个属性字段,例如 query={description=搜索信息, type=string}
// ✅ 替换原来的正则方式,使用括号匹配方式提取 properties 内容
val propertiesContent = extractPropertiesContent(schemaStr)
if (propertiesContent != null) {
val fieldPattern = "([a-zA-Z0-9_]+)=\\{([^{}]+?)\\}".toRegex()
val fieldMatches = fieldPattern.findAll(propertiesContent)
fieldMatches.forEach { fieldMatch ->
val fieldName = fieldMatch.groupValues[1]
val fieldContent = fieldMatch.groupValues[2]
// 解析字段属性
val fieldMap = HashMap<String, Any>()
// 解析如 description=搜索信息, type=string 格式的键值对
// 同时处理带引号和不带引号的情况
val keyValuePattern = "([a-zA-Z0-9_]+)=(\"[^\"]*\"|'[^']*'|[^,}\\s]+)".toRegex()
val keyValueMatches = keyValuePattern.findAll(fieldContent)
keyValueMatches.forEach { kvMatch ->
val key = kvMatch.groupValues[1]
var value = kvMatch.groupValues[2].trim()
// 去除引号(如果有)
if ((value.startsWith("\"") && value.endsWith("\"")) ||
if ((value.startsWith("\"") && value.endsWith("\"")) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.substring(1, value.length - 1)
}
// 处理不同类型的值
val processedValue: Any = when {
value.equals("true", ignoreCase = true) -> true
value.equals("false", ignoreCase = true) -> false
value.matches("\\d+".toRegex()) -> value.toIntOrNull() ?: value
value.matches("\\d+\\.\\d+".toRegex()) -> value.toDoubleOrNull() ?: value
else -> value // 保持为字符串
else -> value
}
fieldMap[key] = processedValue
}
// 添加到属性映射
propertiesMap[fieldName] = fieldMap
}
Log.d(TAG, "成功解析属性: $propertiesMap")
}
// 解析required
// ✅ required 部分保持不变
val requiredPattern = "required=\\[(.*?)\\]".toRegex()
val requiredMatch = requiredPattern.find(schemaStr)
if (requiredMatch != null) {
val requiredStr = requiredMatch.groupValues[1]
if (requiredStr.isNotEmpty()) {
// 处理可能带引号的字段名
val fieldNames = requiredStr.split(",").map {
val fieldNames = requiredStr.split(",").map {
val trimmed = it.trim()
if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) ||
if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
trimmed.substring(1, trimmed.length - 1)
} else {
@ -295,8 +276,7 @@ class MCPSubClient(
Log.e(TAG, "解析Schema字符串失败: ${e.message}", e)
Log.e(TAG, "原始Schema: $schemaStr")
}
// 创建OpenAI函数格式
val functionMap = mapOf(
"name" to tool.name(),
"description" to (tool.description() ?: ""),
@ -306,14 +286,13 @@ class MCPSubClient(
"required" to requiredList
)
)
// 添加到工具列表
toolMaps.add(mapOf(
"type" to "function",
"function" to functionMap
))
Log.d(TAG, "成功转换工具 ${tool.name()} 到OpenAI函数格式")
Log.d(TAG, "成功转换工具 ${tool.name()} 到OpenAI函数格式 $functionMap")
} catch (e: Exception) {
Log.e(TAG, "转换工具 ${tool.name()} 失败: ${e.message}")
}
@ -321,10 +300,36 @@ class MCPSubClient(
} catch (e: Exception) {
Log.e(TAG, "更新工具映射失败: ${e.message}")
}
Log.d(TAG, "更新工具映射完成,共 ${toolMaps.size} 个工具")
}
private fun extractPropertiesContent(schemaStr: String): String? {
val startKey = "properties={"
val startIndex = schemaStr.indexOf(startKey)
if (startIndex == -1) return null
var braceCount = 0
var endIndex = -1
val contentStart = startIndex + startKey.length
for (i in contentStart until schemaStr.length) {
when (schemaStr[i]) {
'{' -> braceCount++
'}' -> {
if (braceCount == 0) {
endIndex = i
break
} else {
braceCount--
}
}
}
}
return if (endIndex != -1) schemaStr.substring(contentStart, endIndex) else null
}
/**
* 将JsonNode转换为Map
*/

4
local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt

@ -337,6 +337,8 @@ class OpenAIService(private val context: Context? = null) {
// 获取参数
val argumentsJson = functionCall.getString("arguments")
Log.e(TAG, "自动处理MCP工具调用: name:${name} argumentsJson:${argumentsJson}")
val arguments = mcpClient?.parseJsonArguments(argumentsJson) ?: mapOf()
// 调用工具(统一通过MCPClient处理)
@ -607,7 +609,7 @@ class OpenAIService(private val context: Context? = null) {
for (i in 0 until toolCalls.length()) {
val toolCall = toolCalls.getJSONObject(i)
val index = toolCall.getInt("index")
// Log.e(TAG, "解析道AI需要调用工具: $toolCall")
// 创建或获取现有的工具调用信息
val toolCallInfo = finalToolCalls.getOrPut(index) { ToolCallInfo() }

249
local_plugins/open_ai_service/ios/Classes/MCPClient.swift

@ -0,0 +1,249 @@
import Foundation
/// 工具类型枚举
enum ToolType {
case localFunction // 本地函数
case mcpTool // MCP工具
}
/// 函数处理器协议
protocol FunctionHandler {
/// 处理函数调用
/// - Parameters:
/// - arguments: 函数参数,Dictionary格式
/// - Returns: 函数执行结果,字符串
func handle(arguments: [String: Any]) async throws -> String
}
/// MCPClient - iOS版本,负责连接到Model Context Protocol服务器和管理本地函数
public class MCPClient {
private let TAG = "MCPClient"
// 本地函数Map,函数名 -> 处理器
private var localFunctions = [String: FunctionHandler]()
// 工具定义Map
private var toolDefinitions = [[String: Any]]()
// 是否已连接
private var isConnected = false
// 系统功能处理器
private var systemFunctionHandler: SystemFunctionHandler?
// SSE会话
private var sseSession: URLSession?
private var sseTask: URLSessionDataTask?
/// 初始化MCPClient
/// - Parameter context: 可选的应用上下文
init(_ context: Any? = nil) {
if let appContext = context {
systemFunctionHandler = SystemFunctionHandler(context: appContext)
// 初始化语音功能
initializeVoiceFunctions()
}
}
/// 初始化语音功能处理器
private func initializeVoiceFunctions() {
do {
// 使用SystemFunctionHandler注册所有功能
systemFunctionHandler?.registerAllFunctions(client: self)
print("\(TAG) 语音功能初始化完成")
} catch {
print("\(TAG) 语音功能初始化失败: \(error.localizedDescription)")
}
}
/// 注册本地函数
/// - Parameters:
/// - name: 函数名称
/// - description: 函数描述
/// - parameters: 函数参数定义
/// - handler: 函数处理器
/// - Returns: 是否注册成功
func registerLocalFunction(name: String, description: String, parameters: [String: Any], handler: FunctionHandler) -> Bool {
do {
// 存储函数处理器
localFunctions[name] = handler
// 创建函数定义
let functionDef: [String: Any] = [
"name": name,
"description": description,
"parameters": parameters
]
// 添加到工具定义列表
let toolDef: [String: Any] = [
"type": "function",
"function": functionDef
]
// 更新或添加工具定义
if let index = toolDefinitions.firstIndex(where: {
guard let function = $0["function"] as? [String: Any],
let functionName = function["name"] as? String else {
return false
}
return functionName == name
}) {
toolDefinitions[index] = toolDef
} else {
toolDefinitions.append(toolDef)
}
print("\(TAG) 成功注册本地函数: \(name)")
return true
} catch {
print("\(TAG) 注册函数失败: \(error.localizedDescription)")
return false
}
}
/// 连接到MCP服务器(通过SSE)
/// - Parameter mcpServer: 服务器URL
/// - Returns: 是否连接成功
func connectToSSE(_ mcpServer: String) -> Bool {
// 关闭现有连接
closeConnection()
// 如果URL为空,返回失败
if mcpServer.isEmpty {
print("\(TAG) MCP服务器URL为空")
return false
}
// 解析服务器URL
guard let serverURL = URL(string: mcpServer) else {
print("\(TAG) 无效的MCP服务器URL: \(mcpServer)")
return false
}
print("\(TAG) 正在连接到MCP服务器: \(mcpServer)")
// 创建会话配置
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 60
// 创建会话
let session = URLSession(configuration: config)
self.sseSession = session
// 创建请求
var request = URLRequest(url: serverURL)
request.httpMethod = "GET"
request.addValue("text/event-stream", forHTTPHeaderField: "Accept")
// 创建并启动SSE任务
let task = session.dataTask(with: request) { [weak self] data, response, error in
guard let self = self else { return }
if let error = error {
print("\(self.TAG) MCP服务器连接失败: \(error.localizedDescription)")
self.isConnected = false
return
}
if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode == 200 {
print("\(self.TAG) 成功连接到MCP服务器")
self.isConnected = true
// 处理服务器响应数据
if let data = data, let responseString = String(data: data, encoding: .utf8) {
print("\(self.TAG) 收到初始响应: \(responseString)")
// 在这里解析可用工具列表,暂时模拟
// 实际情况下,需要实现SSE流解析
}
} else {
print("\(self.TAG) MCP服务器连接失败,状态码: \(httpResponse.statusCode)")
self.isConnected = false
}
}
}
task.resume()
self.sseTask = task
// 在iOS中,连接是异步的,此处返回true表示连接已开始
// 实际连接状态通过isConnected属性检查
return true
}
/// 检查是否已连接
/// - Returns: 是否已连接
func isConnected() -> Bool {
return isConnected
}
/// 关闭连接
func closeConnection() {
sseTask?.cancel()
sseTask = nil
isConnected = false
}
/// 获取工具定义列表,用于OpenAI请求
/// - Returns: 工具定义列表
func getToolMaps() -> [[String: Any]] {
return toolDefinitions
}
/// 解析JSON参数
/// - Parameter argumentsJson: JSON字符串
/// - Returns: 解析后的参数字典
func parseJsonArguments(_ argumentsJson: String) -> [String: Any] {
guard !argumentsJson.isEmpty,
let data = argumentsJson.data(using: .utf8),
let parsedArgs = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return [:]
}
return parsedArgs
}
/// 调用工具
/// - Parameters:
/// - name: 工具名称
/// - arguments: 工具参数
/// - Returns: 工具调用结果
func callTool(name: String, arguments: [String: Any]) async -> [String: Any] {
do {
// 检查是否为本地函数
if let handler = localFunctions[name] {
let result = try await handler.handle(arguments: arguments)
return [
"name": name,
"context": result
]
}
// 如果不是本地函数,尝试作为MCP工具调用
if isConnected {
// 这里应该实现真正的MCP工具调用
// 暂时返回一个模拟结果
return [
"name": name,
"context": "模拟MCP工具调用结果: \(name)(\(arguments))"
]
}
return ["context": "无法处理工具调用:找不到工具 \(name) 或MCP未连接"]
} catch {
print("\(TAG) 调用工具失败: \(error.localizedDescription)")
return ["context": "工具调用失败: \(error.localizedDescription)"]
}
}
/// 关闭MCP客户端
func close() {
closeConnection()
localFunctions.removeAll()
toolDefinitions.removeAll()
}
}

411
local_plugins/open_ai_service/ios/Classes/OpenAIService.swift

@ -1,4 +1,5 @@
import Foundation
import UIKit
/// OpenAI服务异常
public class OpenAIError: Error {
@ -20,6 +21,15 @@ private class ToolCallInfo {
}
}
/// OpenAI服务流回调类型定义
public typealias StreamCallback = (
onToken: (String) -> Void,
onComplete: () -> Void,
onError: (Error) -> Void,
onFunctionCall: ([String: Any]) -> Void,
onFunctionCallResult: ([String: Any], [String: Any]) -> Void
)
/// OpenAI服务iOS原生实现
public class OpenAIService {
private let TAG = "OpenAIService"
@ -27,6 +37,7 @@ public class OpenAIService {
private var apiKey: String = ""
private var isInitialized = false
private var model: String = "doubao-1-5-lite-32k-250115" // 默认模型
private var visionModel: String = "doubao-1-5-vision-pro-32k-250115" // 默认视觉模型
// 用于存储注册的函数
private var registeredFunctions: [[String: Any]] = []
@ -34,6 +45,17 @@ public class OpenAIService {
// URL会话
private let session: URLSession
// 当前流式请求任务
private var currentStreamTask: URLSessionDataTask?
private var isCanceled = false
// MCP客户端
private var mcpClient: MCPClient?
private var isMcpInitialized = false
// 是否自动处理MCP工具调用
private var autoHandleMcpTools = true
public init() {
// 创建URL会话配置
let config = URLSessionConfiguration.default
@ -42,18 +64,181 @@ public class OpenAIService {
session = URLSession(configuration: config)
}
/// 初始化MCP客户端
/// - Parameter serverUrl: MCP服务器地址
/// - Returns: 是否成功开始初始化
public func initializeMcpClient(_ serverUrl: String) -> Bool {
if mcpClient != nil {
mcpClient?.close()
}
mcpClient = MCPClient()
// 开始异步连接
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self, let mcpClient = self.mcpClient else { return }
let result = mcpClient.connectToSSE(serverUrl)
self.isMcpInitialized = result
print("\(self.TAG) MCP客户端初始化\(result ? "成功" : "失败")")
}
return true // 立即返回,实际连接在后台进行
}
/// 检查MCP客户端是否已初始化
/// - Returns: 是否已初始化
public func isMcpInitialized() -> Bool {
return isMcpInitialized && mcpClient?.isConnected() == true
}
/// 关闭MCP客户端
/// - Returns: 是否成功关闭
public func closeMcpClient() -> Bool {
mcpClient?.close()
mcpClient = nil
isMcpInitialized = false
return true
}
/// 处理MCP工具调用
/// - Parameter functionCall: 函数调用信息
/// - Returns: 处理结果
public func handleMcpToolCall(_ functionCall: [String: Any]) async -> String {
if mcpClient == nil || !isMcpInitialized {
return "MCP客户端未初始化"
}
do {
// 获取函数名称
guard let name = functionCall["name"] as? String,
let argumentsJson = functionCall["arguments"] as? String else {
return "函数调用信息不完整"
}
// 解析参数
let arguments = mcpClient?.parseJsonArguments(argumentsJson) ?? [:]
// 调用工具
let result = await mcpClient?.callTool(name: name, arguments: arguments)
return result?["context"] as? String ?? "处理MCP工具调用失败"
} catch {
print("\(TAG) 处理MCP工具调用失败: \(error.localizedDescription)")
return "处理MCP工具调用失败: \(error.localizedDescription)"
}
}
/// 创建用户消息
public func createUserMessage(content: String) -> [String: Any] {
return ["role": "user", "content": content]
}
/// 创建系统消息
public func createSystemMessage(content: String) -> [String: Any] {
return ["role": "system", "content": content]
}
/// 创建助手消息
public func createAssistantMessage(content: String) -> [String: Any] {
return ["role": "assistant", "content": content]
}
/// 创建带图片的用户消息
public func createUserMessageWithImage(text: String, imageBase64: String) -> [String: Any] {
// 创建包含文本和图片的内容数组
var contentArray: [[String: Any]] = []
// 添加文本部分
if !text.isEmpty {
contentArray.append([
"type": "text",
"text": text
])
}
// 添加图片部分
contentArray.append([
"type": "image_url",
"image_url": [
"url": "data:image/jpeg;base64,\(imageBase64)"
]
])
return ["role": "user", "content": contentArray]
}
/// 将文件转换为Base64字符串
public func fileToBase64(_ filePath: String, maxSizeKB: Int = 20480) -> String? {
do {
let fileURL = URL(fileURLWithPath: filePath)
// 检查文件是否存在
guard FileManager.default.fileExists(atPath: filePath) else {
print("\(TAG) 文件不存在: \(filePath)")
return nil
}
// 读取图片
guard let originalImage = UIImage(contentsOfFile: filePath) else {
print("\(TAG) 无法读取图片: \(filePath)")
return nil
}
// 检查图片尺寸,限制最大为1024*1024
var processedImage = originalImage
let maxDimension: CGFloat = 1024
if originalImage.size.width > maxDimension || originalImage.size.height > maxDimension {
print("\(TAG) 图片尺寸超过限制,进行缩放: \(originalImage.size) -> \(maxDimension)")
// 计算缩放比例,保持纵横比
let widthRatio = maxDimension / originalImage.size.width
let heightRatio = maxDimension / originalImage.size.height
let ratio = min(widthRatio, heightRatio)
let newWidth = originalImage.size.width * ratio
let newHeight = originalImage.size.height * ratio
let newSize = CGSize(width: newWidth, height: newHeight)
UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0)
originalImage.draw(in: CGRect(origin: .zero, size: newSize))
if let resizedImage = UIGraphicsGetImageFromCurrentImageContext() {
processedImage = resizedImage
}
UIGraphicsEndImageContext()
print("\(TAG) 缩放后图片尺寸: \(newSize)")
}
// 压缩图片
var imageData = processedImage.jpegData(compressionQuality: 0.9)
// 检查文件大小,如果超出限制,继续压缩
var compressionQuality: CGFloat = 0.9
while let data = imageData, data.count > maxSizeKB * 1024 && compressionQuality > 0.1 {
compressionQuality -= 0.1
imageData = processedImage.jpegData(compressionQuality: compressionQuality)
}
guard let finalImageData = imageData else {
print("\(TAG) 无法压缩图片")
return nil
}
// 检查最终大小
if finalImageData.count > maxSizeKB * 1024 {
print("\(TAG) 压缩后图片仍然超出大小限制: \(finalImageData.count / 1024)KB > \(maxSizeKB)KB")
}
// 转为Base64
return finalImageData.base64EncodedString()
} catch {
print("\(TAG) 转换文件到Base64失败: \(error.localizedDescription)")
return nil
}
}
/// 初始化OpenAI服务
public func initialize(apiKey: String, baseUrl: String = "", model: String = "") -> Bool {
public func initialize(apiKey: String, baseUrl: String = "", model: String = "", mcpServer: String = "") -> Bool {
self.apiKey = apiKey
if !baseUrl.isEmpty {
self.baseUrl = baseUrl
@ -61,6 +246,12 @@ public class OpenAIService {
if !model.isEmpty {
self.model = model
}
// 初始化MCP客户端
if !mcpServer.isEmpty {
initializeMcpClient(mcpServer)
}
isInitialized = !apiKey.isEmpty
return isInitialized
}
@ -89,6 +280,76 @@ public class OpenAIService {
}
}
/// 取消当前流式请求
public func cancelCurrentStream() {
isCanceled = true
currentStreamTask?.cancel()
currentStreamTask = nil
}
/// 自动处理MCP工具调用
private func autoHandleMcpToolCall(
functionCall: [String: Any],
messages: [[String: Any]],
systemPrompt: String,
callback: @escaping StreamCallback
) {
Task {
do {
// 获取函数名称
guard let name = functionCall["name"] as? String,
let argumentsJson = functionCall["arguments"] as? String else {
callback.onError(OpenAIError("函数调用信息不完整"))
return
}
// 解析参数
let arguments = mcpClient?.parseJsonArguments(argumentsJson) ?? [:]
// 调用工具
if let result = await mcpClient?.callTool(name: name, arguments: arguments) {
let functionResult = result["context"] as? String ?? "工具调用失败"
// 创建结果对象,格式需要与Android匹配
let functionCallResult: [String: Any] = ["result": functionResult]
// 发送函数调用结果回调
callback.onFunctionCallResult(functionCall, functionCallResult)
// 发送函数调用结果
sendFunctionCallResult(
messages: messages,
systemPrompt: systemPrompt,
functionCall: functionCall,
functionResult: functionResult,
callback: callback
)
} else {
callback.onError(OpenAIError("工具调用失败"))
}
} catch {
print("\(TAG) 自动处理工具调用失败: \(error.localizedDescription)")
// 失败时返回错误给回调函数
let errorMessage = "工具调用失败: \(error.localizedDescription)"
// 创建错误结果对象
let errorResult: [String: Any] = ["error": errorMessage]
// 发送函数调用结果
callback.onFunctionCallResult(functionCall, errorResult)
sendFunctionCallResult(
messages: messages,
systemPrompt: systemPrompt,
functionCall: functionCall,
functionResult: errorMessage,
callback: callback
)
}
}
}
/// 发送消息(非流式输出)
public func sendMessage(messages: [[String: Any]], systemPrompt: String) throws -> String {
guard isInitialized, !apiKey.isEmpty else {
@ -239,7 +500,7 @@ public class OpenAIService {
/// 发送消息(流式输出)
public func sendMessageStream(
messages: [[String: Any]],
systemPrompt: String,
systemPrompt: String = "",
callback: @escaping StreamCallback
) {
guard isInitialized, !apiKey.isEmpty else {
@ -247,34 +508,36 @@ public class OpenAIService {
return
}
// 重置取消状态
isCanceled = false
// 检查最后一条消息内容中是否包含图片,决定使用哪个模型
var currentModel = model
if let lastMessage = messages.last,
let content = lastMessage["content"] as? [[String: Any]],
content.contains(where: { ($0["type"] as? String) == "image_url" }) {
currentModel = visionModel
}
// 构建请求体
var requestBody: [String: Any] = [
"model": model,
"model": currentModel,
"temperature": 0.7,
"max_tokens": 2000,
"stream": true
]
// 构建完整消息数组,添加系统提示
var fullMessages: [[String: Any]] = [
["role": "system", "content": systemPrompt]
]
var fullMessages: [[String: Any]] = []
if !systemPrompt.isEmpty {
fullMessages.append(["role": "system", "content": systemPrompt])
}
fullMessages.append(contentsOf: messages)
requestBody["messages"] = fullMessages
// 添加工具列表
if let toolMaps = mcpClient?.getToolMaps(), !toolMaps.isEmpty {
var tools: [[String: Any]] = []
for toolMap in toolMaps {
if let tool = toolMap as? [String: Any] {
tools.append(tool)
}
}
if !tools.isEmpty {
requestBody["tools"] = tools
}
requestBody["tools"] = toolMaps
} else if !registeredFunctions.isEmpty {
var tools: [[String: Any]] = []
@ -311,9 +574,25 @@ public class OpenAIService {
request.httpBody = jsonData
// 创建流式会话任务
let delegate = SSEStreamDelegate(callback: callback)
let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
let delegate = SSEStreamDelegate(
callback: callback,
autoHandleMcpTools: autoHandleMcpTools,
autoToolHandler: { [weak self] functionCall in
// 如果需要自动处理工具调用,调用处理方法
guard let self = self, self.autoHandleMcpTools else { return }
self.autoHandleMcpToolCall(
functionCall: functionCall,
messages: messages,
systemPrompt: systemPrompt,
callback: callback
)
}
)
let sessionConfig = URLSessionConfiguration.default
let session = URLSession(configuration: sessionConfig, delegate: delegate, delegateQueue: nil)
let task = session.dataTask(with: request)
currentStreamTask = task
task.resume()
}
@ -327,10 +606,12 @@ public class OpenAIService {
) {
do {
// 构建完整消息数组
var fullMessages: [[String: Any]] = [
// 添加系统提示
["role": "system", "content": systemPrompt]
]
var fullMessages: [[String: Any]] = []
// 添加系统提示
if !systemPrompt.isEmpty {
fullMessages.append(["role": "system", "content": systemPrompt])
}
// 添加用户消息
fullMessages.append(contentsOf: messages)
@ -347,7 +628,7 @@ public class OpenAIService {
// 添加函数调用消息
fullMessages.append([
"role": "assistant",
"content": NSNull(),
"content": nil as Any?,
"tool_calls": [
[
"id": id,
@ -378,11 +659,20 @@ public class OpenAIService {
/// SSE流委托实现
private class SSEStreamDelegate: NSObject, URLSessionDataDelegate {
let callback: StreamCallback
let autoHandleMcpTools: Bool
let autoToolHandler: ([String: Any]) -> Void
private var buffer = Data()
private var finalToolCalls: [Int: ToolCallInfo] = [:]
init(callback: @escaping StreamCallback) {
init(
callback: @escaping StreamCallback,
autoHandleMcpTools: Bool = true,
autoToolHandler: @escaping ([String: Any]) -> Void
) {
self.callback = callback
self.autoHandleMcpTools = autoHandleMcpTools
self.autoToolHandler = autoToolHandler
super.init()
}
@ -471,7 +761,7 @@ public class OpenAIService {
}
}
} catch {
NSLog("解析JSON出错: \(error.localizedDescription)")
print("解析JSON出错: \(error.localizedDescription)")
// 忽略解析错误,继续处理其他行
}
}
@ -482,50 +772,45 @@ public class OpenAIService {
if finalToolCalls.isEmpty { return false }
// 只处理第一个工具调用
guard let firstToolCall = finalToolCalls.values.first, firstToolCall.isValid else { return false }
// 创建函数调用字典
let functionCall: [String: Any] = [
"name": firstToolCall.name,
"arguments": firstToolCall.arguments,
"id": firstToolCall.id
]
for (_, toolCall) in finalToolCalls {
if toolCall.isValid {
// 创建函数调用字典
let functionCall: [String: Any] = [
"name": toolCall.name,
"arguments": toolCall.arguments,
"id": toolCall.id
]
// 调用回调
callback.onFunctionCall(functionCall)
// 如果需要自动处理工具调用
if autoHandleMcpTools {
autoToolHandler(functionCall)
}
return true
}
}
// 回调
callback.onFunctionCall(functionCall)
return true
return false
}
// 处理完成
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
callback.onError(OpenAIError("请求失败: \(error.localizedDescription)"))
if (error as NSError).code == NSURLErrorCancelled {
// 请求被取消,无需处理
return
}
callback.onError(error)
} else {
// 如果没有处理任何工具调用且没有错误,则完成
if finalToolCalls.isEmpty {
callback.onComplete()
}
}
}
}
/// 流式输出回调协议
public typealias StreamCallback = (onToken: (String) -> Void,
onComplete: () -> Void,
onError: (Error) -> Void,
onFunctionCall: ([String: Any]) -> Void)
/// 处理工具调用(函数调用)并回调
private func processToolCalls(_ toolCalls: [Int: ToolCallInfo], callback: StreamCallback) -> Bool {
if toolCalls.isEmpty { return false }
// 只处理第一个工具调用
guard let firstToolCall = toolCalls.values.first, firstToolCall.isValid else { return false }
// 创建函数调用字典
let functionCall: [String: Any] = [
"name": firstToolCall.name,
"arguments": firstToolCall.arguments,
"id": firstToolCall.id
]
// 回调
callback.onFunctionCall(functionCall)
return true
}
}

146
local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift

@ -26,7 +26,8 @@ public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler
let apiKey = args["apiKey"] as? String {
let baseUrl = args["baseUrl"] as? String ?? ""
let model = args["model"] as? String ?? ""
let initialized = openAIService.initialize(apiKey: apiKey, baseUrl: baseUrl, model: model)
let mcpServer = args["mcpServer"] as? String ?? ""
let initialized = openAIService.initialize(apiKey: apiKey, baseUrl: baseUrl, model: model, mcpServer: mcpServer)
result(initialized)
} else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "初始化参数无效", details: nil))
@ -46,12 +47,13 @@ public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler
case "sendMessage":
guard let args = call.arguments as? [String: Any],
let messagesRaw = args["messages"] as? [[String: Any]],
let systemPrompt = args["systemPrompt"] as? String else {
let messagesRaw = args["messages"] as? [[String: Any]] else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "发送消息参数无效", details: nil))
return
}
let systemPrompt = args["systemPrompt"] as? String ?? ""
// 在后台线程执行
DispatchQueue.global(qos: .userInitiated).async {
do {
@ -70,12 +72,13 @@ public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler
case "sendMessageStream":
guard let args = call.arguments as? [String: Any],
let messagesRaw = args["messages"] as? [[String: Any]],
let systemPrompt = args["systemPrompt"] as? String else {
let messagesRaw = args["messages"] as? [[String: Any]] else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "发送消息参数无效", details: nil))
return
}
let systemPrompt = args["systemPrompt"] as? String ?? ""
// 检查事件接收器
guard let eventSink = self.eventSink else {
result(FlutterError(code: "NO_EVENT_SINK", message: "没有可用的事件流接收器", details: nil))
@ -106,7 +109,35 @@ public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler
onFunctionCall: { functionCall in
// 发送函数调用事件
DispatchQueue.main.async {
eventSink(["type": "functionCall", "content": functionCall])
// 转换为字符串
do {
let data = try JSONSerialization.data(withJSONObject: functionCall)
if let jsonString = String(data: data, encoding: .utf8) {
eventSink(["type": "function_call", "content": jsonString])
} else {
eventSink(["type": "error", "content": "函数调用数据序列化失败"])
}
} catch {
eventSink(["type": "error", "content": "函数调用数据序列化失败: \(error.localizedDescription)"])
}
}
},
onFunctionCallResult: { functionCall, functionCallResult in
// 发送函数调用结果事件
DispatchQueue.main.async {
do {
let functionCallData = try JSONSerialization.data(withJSONObject: functionCall)
let resultData = try JSONSerialization.data(withJSONObject: functionCallResult)
if let functionCallString = String(data: functionCallData, encoding: .utf8),
let resultString = String(data: resultData, encoding: .utf8) {
eventSink(["type": "function_call_result", "function_call": functionCallString, "result": resultString])
} else {
eventSink(["type": "error", "content": "函数调用结果数据序列化失败"])
}
} catch {
eventSink(["type": "error", "content": "函数调用结果数据序列化失败: \(error.localizedDescription)"])
}
}
}
)
@ -122,13 +153,14 @@ public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler
case "sendFunctionCallResult":
guard let args = call.arguments as? [String: Any],
let messagesRaw = args["messages"] as? [[String: Any]],
let systemPrompt = args["systemPrompt"] as? String,
let functionCallRaw = args["functionCall"] as? [String: Any],
let functionResult = args["functionResult"] as? String else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "发送函数调用结果参数无效", details: nil))
return
}
let systemPrompt = args["systemPrompt"] as? String ?? ""
// 检查事件接收器
guard let eventSink = self.eventSink else {
result(FlutterError(code: "NO_EVENT_SINK", message: "没有可用的事件流接收器", details: nil))
@ -159,7 +191,35 @@ public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler
onFunctionCall: { functionCall in
// 发送函数调用事件
DispatchQueue.main.async {
eventSink(["type": "functionCall", "content": functionCall])
// 转换为字符串
do {
let data = try JSONSerialization.data(withJSONObject: functionCall)
if let jsonString = String(data: data, encoding: .utf8) {
eventSink(["type": "function_call", "content": jsonString])
} else {
eventSink(["type": "error", "content": "函数调用数据序列化失败"])
}
} catch {
eventSink(["type": "error", "content": "函数调用数据序列化失败: \(error.localizedDescription)"])
}
}
},
onFunctionCallResult: { functionCall, functionCallResult in
// 发送函数调用结果事件
DispatchQueue.main.async {
do {
let functionCallData = try JSONSerialization.data(withJSONObject: functionCall)
let resultData = try JSONSerialization.data(withJSONObject: functionCallResult)
if let functionCallString = String(data: functionCallData, encoding: .utf8),
let resultString = String(data: resultData, encoding: .utf8) {
eventSink(["type": "function_call_result", "function_call": functionCallString, "result": resultString])
} else {
eventSink(["type": "error", "content": "函数调用结果数据序列化失败"])
}
} catch {
eventSink(["type": "error", "content": "函数调用结果数据序列化失败: \(error.localizedDescription)"])
}
}
}
)
@ -196,6 +256,76 @@ public class OpenAIServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler
result(FlutterError(code: "INVALID_ARGUMENT", message: "创建助手消息参数无效", details: nil))
}
case "createUserMessageWithImage":
if let args = call.arguments as? [String: Any],
let text = args["text"] as? String,
let imageBase64 = args["imageBase64"] as? String {
let message = openAIService.createUserMessageWithImage(text: text, imageBase64: imageBase64)
result(message)
} else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "创建图片消息参数无效", details: nil))
}
case "fileToBase64":
if let args = call.arguments as? [String: Any],
let filePath = args["filePath"] as? String {
let maxSizeKB = args["maxSizeKB"] as? Int ?? 20480
let base64 = openAIService.fileToBase64(filePath, maxSizeKB: maxSizeKB)
if let base64 = base64 {
result(base64)
} else {
result(FlutterError(code: "FILE_ERROR", message: "文件转换失败", details: nil))
}
} else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "文件转换参数无效", details: nil))
}
case "initializeMcpClient":
if let args = call.arguments as? [String: Any],
let serverUrl = args["serverUrl"] as? String {
let success = openAIService.initializeMcpClient(serverUrl)
result(success)
} else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "初始化MCP客户端参数无效", details: nil))
}
case "isMcpInitialized":
result(openAIService.isMcpInitialized())
case "closeMcpClient":
result(openAIService.closeMcpClient())
case "handleMcpToolCall":
if let args = call.arguments as? [String: Any],
let functionCallJson = args["functionCall"] as? String {
// 解析函数调用JSON
guard let data = functionCallJson.data(using: .utf8),
let functionCall = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "无效的函数调用JSON", details: nil))
return
}
// 在后台线程执行
Task {
do {
let toolResult = await openAIService.handleMcpToolCall(functionCall)
// 在主线程返回结果
DispatchQueue.main.async {
result(toolResult)
}
} catch {
// 在主线程返回错误
DispatchQueue.main.async {
result(FlutterError(code: "MCP_TOOL_ERROR", message: "处理MCP工具调用失败: \(error.localizedDescription)", details: nil))
}
}
}
} else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "处理MCP工具调用参数无效", details: nil))
}
default:
result(FlutterMethodNotImplemented)
}

514
local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift

@ -0,0 +1,514 @@
import Foundation
import UIKit
import CoreLocation
import Contacts
import EventKit
/// 系统功能处理器
class SystemFunctionHandler {
private let TAG = "SystemFunctionHandler"
private let context: Any
static let ACTION_EXIT_INTERACTION = "com.yunqiinnovation.deepsound.action.EXIT_INTERACTION"
/// 初始化系统功能处理器
/// - Parameter context: 应用上下文
init(context: Any) {
self.context = context
}
/// 注册所有系统功能
/// - Parameter client: MCP客户端
func registerAllFunctions(client: MCPClient) {
// 注册退出交互函数
registerExitInteractionFunction(client)
// 注册翻译模式函数
registerTranslationModeFunction(client)
// 注册发送短信函数
registerSendTextMessageFunction(client)
// 注册拨打电话函数
registerMakePhoneCallFunction(client)
// 注册打开应用函数
registerOpenAppFunction(client)
// 注册设置提醒函数
registerSetReminderFunction(client)
// 注册获取当前时间函数
registerGetCurrentTimeFunction(client)
// 注册获取当前位置函数
registerGetCurrentLocationFunction(client)
print("\(TAG) 所有系统功能注册完成")
}
/// 注册退出交互函数
private func registerExitInteractionFunction(_ client: MCPClient) {
// 定义函数参数
let parameters: [String: Any] = [
"type": "object",
"properties": [:],
"required": []
]
// 创建函数处理器
let handler = ExitInteractionHandler()
// 注册函数
client.registerLocalFunction(
name: "exit_interaction",
description: "结束当前交互",
parameters: parameters,
handler: handler
)
print("\(TAG) 已注册退出交互功能")
}
/// 注册翻译模式函数
private func registerTranslationModeFunction(_ client: MCPClient) {
// 定义函数参数
let parameters: [String: Any] = [
"type": "object",
"properties": [:],
"required": []
]
// 创建函数处理器
let handler = TranslationModeHandler()
// 注册函数
client.registerLocalFunction(
name: "enter_translation_mode",
description: "用户请求进入实时翻译模式时,启动实时翻译功能",
parameters: parameters,
handler: handler
)
print("\(TAG) 已注册翻译模式功能")
}
/// 注册发送短信函数
private func registerSendTextMessageFunction(_ client: MCPClient) {
// 定义函数参数
let parameters: [String: Any] = [
"type": "object",
"properties": [
"contact": [
"type": "string",
"description": "联系人姓名或电话号码"
],
"message": [
"type": "string",
"description": "短信内容"
]
],
"required": ["contact", "message"]
]
// 创建函数处理器
let handler = SendTextMessageHandler()
// 注册函数
client.registerLocalFunction(
name: "send_text_message",
description: "发送短信",
parameters: parameters,
handler: handler
)
print("\(TAG) 已注册发送短信功能")
}
/// 注册拨打电话函数
private func registerMakePhoneCallFunction(_ client: MCPClient) {
// 定义函数参数
let parameters: [String: Any] = [
"type": "object",
"properties": [
"contact": [
"type": "string",
"description": "联系人姓名或电话号码"
]
],
"required": ["contact"]
]
// 创建函数处理器
let handler = MakePhoneCallHandler()
// 注册函数
client.registerLocalFunction(
name: "make_phone_call",
description: "拨打电话",
parameters: parameters,
handler: handler
)
print("\(TAG) 已注册拨打电话功能")
}
/// 注册打开应用函数
private func registerOpenAppFunction(_ client: MCPClient) {
// 定义函数参数
let parameters: [String: Any] = [
"type": "object",
"properties": [
"app_name": [
"type": "string",
"description": "应用名称"
]
],
"required": ["app_name"]
]
// 创建函数处理器
let handler = OpenAppHandler()
// 注册函数
client.registerLocalFunction(
name: "open_app",
description: "当用户请求打开特定应用时,启动相应应用",
parameters: parameters,
handler: handler
)
print("\(TAG) 已注册打开应用功能")
}
/// 注册设置提醒函数
private func registerSetReminderFunction(_ client: MCPClient) {
// 定义函数参数
let parameters: [String: Any] = [
"type": "object",
"properties": [
"title": [
"type": "string",
"description": "提醒标题"
],
"content": [
"type": "string",
"description": "提醒内容"
],
"time": [
"type": "string",
"description": "提醒时间,格式为'yyyy-MM-dd HH:mm',如'2023-12-31 14:30'"
]
],
"required": ["title", "time"]
]
// 创建函数处理器
let handler = SetReminderHandler()
// 注册函数
client.registerLocalFunction(
name: "set_reminder",
description: "设置提醒事项",
parameters: parameters,
handler: handler
)
print("\(TAG) 已注册设置提醒功能")
}
/// 注册获取当前时间函数
private func registerGetCurrentTimeFunction(_ client: MCPClient) {
// 定义函数参数
let parameters: [String: Any] = [
"type": "object",
"properties": [
"format": [
"type": "string",
"description": "时间格式,可选,默认为标准格式"
]
],
"required": []
]
// 创建函数处理器
let handler = GetCurrentTimeHandler()
// 注册函数
client.registerLocalFunction(
name: "get_current_time",
description: "获取当前日期和时间",
parameters: parameters,
handler: handler
)
print("\(TAG) 已注册获取当前时间功能")
}
/// 注册获取当前位置函数
private func registerGetCurrentLocationFunction(_ client: MCPClient) {
// 定义函数参数
let parameters: [String: Any] = [
"type": "object",
"properties": [:],
"required": []
]
// 创建函数处理器
let handler = GetCurrentLocationHandler()
// 注册函数
client.registerLocalFunction(
name: "get_current_location",
description: "获取当前地理位置",
parameters: parameters,
handler: handler
)
print("\(TAG) 已注册获取当前位置功能")
}
}
/// 退出交互处理器
class ExitInteractionHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
// 发送通知和广播
NotificationCenter.default.post(
name: Notification.Name(SystemFunctionHandler.ACTION_EXIT_INTERACTION),
object: nil,
userInfo: ["timestamp": Date().timeIntervalSince1970]
)
return "{\"result\": \"已结束当前交互\"}"
}
}
/// 翻译模式处理器
class TranslationModeHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
// 发送通知
NotificationCenter.default.post(
name: Notification.Name("com.yunqiinnovation.deepsound.ACTION_ENTER_TRANSLATION_MODE"),
object: nil,
userInfo: ["timestamp": Date().timeIntervalSince1970]
)
return "{\"result\": \"已进入实时翻译模式\"}"
}
}
/// 发送短信处理器
class SendTextMessageHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
guard let contact = arguments["contact"] as? String,
let message = arguments["message"] as? String else {
return "{\"result\": \"发送短信失败:缺少联系人或消息内容\"}"
}
// iOS不允许应用直接发送短信,但可以打开短信应用
if let url = URL(string: "sms:\(contact)&body=\(message.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")") {
DispatchQueue.main.async {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
return "{\"result\": \"已打开短信应用,收件人:\(contact)\"}"
}
return "{\"result\": \"发送短信失败:无法打开短信应用\"}"
}
}
/// 拨打电话处理器
class MakePhoneCallHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
guard let contact = arguments["contact"] as? String else {
return "{\"result\": \"拨打电话失败:缺少联系人\"}"
}
let phoneNumber = contact.replacingOccurrences(of: "[^+0-9]", with: "", options: .regularExpression)
if let url = URL(string: "tel://\(phoneNumber)") {
DispatchQueue.main.async {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
return "{\"result\": \"正在拨打\(contact)的电话\"}"
}
return "{\"result\": \"拨打电话失败:无法拨打电话\"}"
}
}
/// 打开应用处理器
class OpenAppHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
guard let appName = arguments["app_name"] as? String else {
return "{\"result\": \"打开应用失败:缺少应用名称\"}"
}
// iOS没有直接打开其他应用的API,只能通过URL Scheme
// 这里只是模拟返回成功,实际上无法在iOS上实现这个功能
return "{\"result\": \"打开应用功能在iOS上受限,无法直接打开\(appName)\"}"
}
}
/// 设置提醒处理器
class SetReminderHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
guard let title = arguments["title"] as? String,
let timeStr = arguments["time"] as? String else {
return "{\"result\": \"设置提醒失败:缺少标题或时间\"}"
}
let content = arguments["content"] as? String ?? ""
// 解析时间
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm"
guard let date = dateFormatter.date(from: timeStr) else {
return "{\"result\": \"设置提醒失败:时间格式无效,请使用'yyyy-MM-dd HH:mm'格式\"}"
}
// 使用EventKit添加提醒
let eventStore = EKEventStore()
let event = EKEvent(eventStore: eventStore)
event.title = title
event.notes = content
event.startDate = date
event.endDate = date.addingTimeInterval(3600) // 1小时后
event.calendar = eventStore.defaultCalendarForNewEvents
do {
try eventStore.save(event, span: .thisEvent)
return "{\"result\": \"已创建提醒: \(title), 时间: \(timeStr)\"}"
} catch {
return "{\"result\": \"设置提醒失败:\(error.localizedDescription)\"}"
}
}
}
/// 获取当前时间处理器
class GetCurrentTimeHandler: FunctionHandler {
func handle(arguments: [String: Any]) async throws -> String {
let format = arguments["format"] as? String
let calendar = Calendar.current
let date = Date()
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "zh_CN")
let timeStr: String
switch format?.lowercased() {
case "short":
dateFormatter.dateFormat = "HH:mm"
timeStr = dateFormatter.string(from: date)
case "date":
dateFormatter.dateFormat = "yyyy年MM月dd日"
timeStr = dateFormatter.string(from: date)
case "time":
dateFormatter.dateFormat = "HH:mm:ss"
timeStr = dateFormatter.string(from: date)
case "full":
dateFormatter.dateFormat = "yyyy年MM月dd日 EEEE HH:mm:ss"
timeStr = dateFormatter.string(from: date)
case "iso":
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
timeStr = dateFormatter.string(from: date)
default:
dateFormatter.dateFormat = "yyyy年MM月dd日 HH:mm"
timeStr = dateFormatter.string(from: date)
}
// 获取星期几
let weekDay: String
if format == "full" {
weekDay = "" // 已经包含在dateFormatter里
} else {
let weekDayNum = calendar.component(.weekday, from: date)
switch weekDayNum {
case 1: weekDay = "星期日"
case 2: weekDay = "星期一"
case 3: weekDay = "星期二"
case 4: weekDay = "星期三"
case 5: weekDay = "星期四"
case 6: weekDay = "星期五"
case 7: weekDay = "星期六"
default: weekDay = ""
}
}
// 格式化结果
let resultTime: String
if format == "short" || format == "time" {
resultTime = timeStr
} else if format == "full" {
resultTime = timeStr // already contains weekday
} else {
resultTime = "\(timeStr) \(weekDay)"
}
return "{\"result\": \"\(resultTime)\", \"time\": \"\(resultTime)\"}"
}
}
/// 获取当前位置处理器
class GetCurrentLocationHandler: FunctionHandler {
private let locationManager = CLLocationManager()
private var locationCompletionHandler: ((String, Bool) -> Void)?
func handle(arguments: [String: Any]) async throws -> String {
return await withCheckedContinuation { continuation in
self.getLocation { message, success in
continuation.resume(returning: "{\"result\": \"\(message)\", \"success\": \(success)}")
}
}
}
private func getLocation(completion: @escaping (String, Bool) -> Void) {
locationCompletionHandler = completion
// 检查权限
let authStatus = CLLocationManager.authorizationStatus()
if authStatus == .denied || authStatus == .restricted {
completion("需要位置权限才能获取当前位置", false)
return
}
// 检查位置服务是否开启
if !CLLocationManager.locationServicesEnabled() {
completion("位置服务未启用,请打开GPS或网络定位", false)
return
}
// 如果有最后的位置,使用它
if let lastLocation = locationManager.location {
let message = "当前位置: 经度 \(lastLocation.coordinate.longitude), 纬度 \(lastLocation.coordinate.latitude)"
completion(message, true)
return
}
// 否则请求更新
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyBest
locationManager.requestWhenInUseAuthorization()
locationManager.startUpdatingLocation()
// 设置超时
DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in
if let handler = self?.locationCompletionHandler {
self?.locationManager.stopUpdatingLocation()
handler("获取位置超时,请稍后重试", false)
self?.locationCompletionHandler = nil
}
}
}
}
// 扩展以接收位置更新
extension GetCurrentLocationHandler: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last, let handler = locationCompletionHandler else { return }
manager.stopUpdatingLocation()
let message = "当前位置: 经度 \(location.coordinate.longitude), 纬度 \(location.coordinate.latitude)"
handler(message, true)
locationCompletionHandler = nil
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
manager.stopUpdatingLocation()
locationCompletionHandler?("获取位置失败: \(error.localizedDescription)", false)
locationCompletionHandler = nil
}
}

BIN
local_plugins/opus/.gradle/8.10/fileHashes/fileHashes.lock

Binary file not shown.

BIN
local_plugins/opus/.gradle/buildOutputCleanup/buildOutputCleanup.lock

Binary file not shown.

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save