From 89703e4610a8ddb2a1848d4b4c81b36dd268d9aa Mon Sep 17 00:00:00 2001 From: wolfplus Date: Fri, 9 May 2025 14:12:37 +0100 Subject: [PATCH] add --- .../yunqiinnovation/deepsound/MainActivity.kt | 1 - lib/data/services/ble_manager.dart | 9 +- .../controllers/pairing_controller.dart | 11 +- .../agent_service/android/build.gradle.kts | 13 +- .../android/src/main/AndroidManifest.xml | 37 + .../agent_service/AgentService.kt | 3 +- .../agent_service/AgentServicePlugin.kt | 47 +- .../agent_service/BleAgent.kt} | 9 +- .../agent_service}/BleMediaService.kt | 85 +- .../agent_service}/BleWakeReceiver.kt | 18 +- .../agent_service}/BootReceiver.kt | 2 +- .../yunqiinnovation/agent_service}/utils.kt | 2 +- .../ios/Classes/AgentService.swift | 994 ++++++++++++++++++ .../ios/Classes/AgentServiceListener.swift | 11 + .../ios/Classes/AgentServicePlugin.h | 4 + .../ios/Classes/AgentServicePlugin.m | 12 + .../ios/Classes/SwiftAgentServicePlugin.swift | 259 +++++ .../agent_service/lib/agent_service.dart | 36 + .../ios/Classes/AzureAsrHelper.swift | 873 +++++++++------ .../ios/Classes/AzureTtsHelper.swift | 454 +++++--- .../ble_service/android/build.gradle.kts | 6 - .../android/src/main/AndroidManifest.xml | 36 +- .../yunqiinnovation/ble_service/BleService.kt | 72 +- .../ble_service/BleServicePlugin.kt | 87 +- .../ios/Assets/ble_service-Info.plist | 15 + .../ble_service/ios/Classes/BleConst.swift | 90 ++ .../ble_service/ios/Classes/BleDelegate.swift | 25 + .../ble_service/ios/Classes/BleService.swift | 963 +++++++++++++++++ .../ios/Classes/BleServicePlugin.h | 4 + .../ios/Classes/BleServicePlugin.m | 12 + .../ios/Classes/SwiftBleServicePlugin.swift | 203 ++++ .../ble_service/ios/ble_service.podspec | 23 + .../ios/Classes/ChatStorageHelper.swift | 410 ++++++++ .../ios/Classes/ChatStoragePlugin.h | 4 + .../ios/Classes/ChatStoragePlugin.m | 12 + .../ios/Classes/ChatStoragePlugin.swift | 182 ++++ .../ios/Classes/SwiftChatStoragePlugin.swift | 11 + local_plugins/chat_storage/ios/README.md | 64 ++ .../chat_storage/ios/chat_storage.podspec | 11 +- .../ios/Classes/ClassicBluetoothHelper.swift | 197 ++++ .../ios/Classes/ClassicBluetoothPlugin.h | 4 + .../ios/Classes/ClassicBluetoothPlugin.m | 12 + .../ios/Classes/ClassicBluetoothPlugin.swift | 108 ++ .../ios/Classes/FileLogger.swift | 136 +++ .../Classes/SwiftClassicBluetoothPlugin.swift | 11 + local_plugins/classic_bluetooth/ios/README.md | 44 + .../ios/classic_bluetooth.podspec | 22 + local_plugins/classic_bluetooth/pubspec.yaml | 2 + .../ios/Classes/DeepvoiceUtilsPlugin.h | 4 + .../ios/Classes/DeepvoiceUtilsPlugin.m | 12 + .../ios/Classes/DeepvoiceUtilsPlugin.swift | 30 + .../ios/Classes/FileLogger.swift | 286 +++++ .../Classes/SwiftDeepvoiceUtilsPlugin.swift | 11 + local_plugins/deepvoice_utils/ios/README.md | 54 + .../ios/deepvoice_utils.podspec | 19 + .../ios/Classes/MCPClient.swift | 249 +++++ .../ios/Classes/OpenAIService.swift | 411 ++++++-- .../ios/Classes/OpenAIServicePlugin.swift | 146 ++- .../ios/Classes/SystemFunctionHandler.swift | 514 +++++++++ 59 files changed, 6578 insertions(+), 804 deletions(-) rename local_plugins/{ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/ChatAgent.kt => agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt} (95%) rename local_plugins/{ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service => agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service}/BleMediaService.kt (86%) rename local_plugins/{ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service => agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service}/BleWakeReceiver.kt (82%) rename local_plugins/{ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service => agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service}/BootReceiver.kt (93%) rename local_plugins/{ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service => agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service}/utils.kt (99%) create mode 100644 local_plugins/agent_service/ios/Classes/AgentService.swift create mode 100644 local_plugins/agent_service/ios/Classes/AgentServiceListener.swift create mode 100644 local_plugins/agent_service/ios/Classes/AgentServicePlugin.h create mode 100644 local_plugins/agent_service/ios/Classes/AgentServicePlugin.m create mode 100644 local_plugins/agent_service/ios/Classes/SwiftAgentServicePlugin.swift create mode 100644 local_plugins/ble_service/ios/Assets/ble_service-Info.plist create mode 100644 local_plugins/ble_service/ios/Classes/BleConst.swift create mode 100644 local_plugins/ble_service/ios/Classes/BleDelegate.swift create mode 100644 local_plugins/ble_service/ios/Classes/BleService.swift create mode 100644 local_plugins/ble_service/ios/Classes/BleServicePlugin.h create mode 100644 local_plugins/ble_service/ios/Classes/BleServicePlugin.m create mode 100644 local_plugins/ble_service/ios/Classes/SwiftBleServicePlugin.swift create mode 100644 local_plugins/ble_service/ios/ble_service.podspec create mode 100644 local_plugins/chat_storage/ios/Classes/ChatStorageHelper.swift create mode 100644 local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.h create mode 100644 local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.m create mode 100644 local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.swift create mode 100644 local_plugins/chat_storage/ios/Classes/SwiftChatStoragePlugin.swift create mode 100644 local_plugins/chat_storage/ios/README.md create mode 100644 local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothHelper.swift create mode 100644 local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h create mode 100644 local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m create mode 100644 local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.swift create mode 100644 local_plugins/classic_bluetooth/ios/Classes/FileLogger.swift create mode 100644 local_plugins/classic_bluetooth/ios/Classes/SwiftClassicBluetoothPlugin.swift create mode 100644 local_plugins/classic_bluetooth/ios/README.md create mode 100644 local_plugins/classic_bluetooth/ios/classic_bluetooth.podspec create mode 100644 local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.h create mode 100644 local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.m create mode 100644 local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.swift create mode 100644 local_plugins/deepvoice_utils/ios/Classes/FileLogger.swift create mode 100644 local_plugins/deepvoice_utils/ios/Classes/SwiftDeepvoiceUtilsPlugin.swift create mode 100644 local_plugins/deepvoice_utils/ios/README.md create mode 100644 local_plugins/deepvoice_utils/ios/deepvoice_utils.podspec create mode 100644 local_plugins/open_ai_service/ios/Classes/MCPClient.swift create mode 100644 local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift diff --git a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt b/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt index 27f8d8498..bf9d92455 100644 --- a/android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt +++ b/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" diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index 56f2841ac..ce8302c92 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -8,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 { @@ -381,7 +382,7 @@ class BleManager extends GetxService { } /// 启动BLE服务 - Future startBleService() async { + Future startAgentService() async { try { // 从.env文件直接读取环境变量 // final azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; @@ -431,7 +432,7 @@ class BleManager extends GetxService { } } - final result = await _bleService.startBleService(config); + final result = await AgentService.startAgentService(config); if (result) { Logger.info('BLE服务启动成功'); @@ -463,9 +464,9 @@ class BleManager extends GetxService { } /// 停止BLE服务 - Future stopBleService() async { + Future stopAgentService() async { try { - final result = await _bleService.stopBleService(); + final result = await AgentService.stopAgentService(); if (result) { Logger.info('BLE服务停止成功'); diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index 26c52885c..99f9f55fc 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/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 _startBleService() async { + Future _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 _stopBleService() async { + Future _stopAgentService() async { try { - final result = await BleManager.to.stopBleService(); + final result = await BleManager.to.stopAgentService(); if (result) { Logger.info('BLE后台服务停止成功'); diff --git a/local_plugins/agent_service/android/build.gradle.kts b/local_plugins/agent_service/android/build.gradle.kts index eb6ba06cd..5a0a26504 100644 --- a/local_plugins/agent_service/android/build.gradle.kts +++ b/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")) } \ No newline at end of file diff --git a/local_plugins/agent_service/android/src/main/AndroidManifest.xml b/local_plugins/agent_service/android/src/main/AndroidManifest.xml index 08d048432..ced0df1dc 100644 --- a/local_plugins/agent_service/android/src/main/AndroidManifest.xml +++ b/local_plugins/agent_service/android/src/main/AndroidManifest.xml @@ -2,4 +2,41 @@ package="com.yunqiinnovation.agent_service"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt index 2d3e4676d..60cdfdca7 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt @@ -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 /** * 代理服务事件监听接口 */ @@ -401,6 +401,7 @@ object AgentService : CoroutineScope { FileLogger.d(TAG, "停止语音识别") try { + BleService.closeCodec() azureAsrHelper?.stopContinuousRecognition() isRecognitionActive = false stopIdleCheck() diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt index 2505a6318..588e66ac5 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt @@ -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>("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) diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/ChatAgent.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt similarity index 95% rename from local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/ChatAgent.kt rename to local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt index 4fcca2ac6..368dbe967 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/ChatAgent.kt +++ b/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 diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleMediaService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt similarity index 86% rename from local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleMediaService.kt rename to local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt index b8674ed3b..b82ec1291 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleMediaService.kt +++ b/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, "语音交互代理初始化成功") @@ -364,7 +358,7 @@ class BleMediaService : Service(), CoroutineScope { val currentTime = System.currentTimeMillis() if (currentTime - lastActionTime >= ACTION_DEBOUNCE_MS) { lastActionTime = currentTime - ChatAgent.onWakeupSignalReceived() + 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) + } + } /** * 更新通知内容 */ diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeReceiver.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleWakeReceiver.kt similarity index 82% rename from local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeReceiver.kt rename to local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleWakeReceiver.kt index 0214d8e35..f979a1c03 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeReceiver.kt +++ b/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,15 +28,21 @@ class BleWakeReceiver : BroadcastReceiver() { } val list = intent.getParcelableArrayListExtra( 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 diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BootReceiver.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BootReceiver.kt similarity index 93% rename from local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BootReceiver.kt rename to local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BootReceiver.kt index 4b1bb5905..4f490ae88 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BootReceiver.kt +++ b/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 diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/utils.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/utils.kt similarity index 99% rename from local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/utils.kt rename to local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/utils.kt index 339aed64e..dacd6e2a7 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/utils.kt +++ b/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 diff --git a/local_plugins/agent_service/ios/Classes/AgentService.swift b/local_plugins/agent_service/ios/Classes/AgentService.swift new file mode 100644 index 000000000..7fdf89e62 --- /dev/null +++ b/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.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) + } +} \ No newline at end of file diff --git a/local_plugins/agent_service/ios/Classes/AgentServiceListener.swift b/local_plugins/agent_service/ios/Classes/AgentServiceListener.swift new file mode 100644 index 000000000..d7a63e653 --- /dev/null +++ b/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]) +} \ No newline at end of file diff --git a/local_plugins/agent_service/ios/Classes/AgentServicePlugin.h b/local_plugins/agent_service/ios/Classes/AgentServicePlugin.h new file mode 100644 index 000000000..a2cb23a03 --- /dev/null +++ b/local_plugins/agent_service/ios/Classes/AgentServicePlugin.h @@ -0,0 +1,4 @@ +#import + +@interface AgentServicePlugin : NSObject +@end \ No newline at end of file diff --git a/local_plugins/agent_service/ios/Classes/AgentServicePlugin.m b/local_plugins/agent_service/ios/Classes/AgentServicePlugin.m new file mode 100644 index 000000000..e79f257b9 --- /dev/null +++ b/local_plugins/agent_service/ios/Classes/AgentServicePlugin.m @@ -0,0 +1,12 @@ +#import "AgentServicePlugin.h" +#if __has_include() +#import +#else +#import "agent_service-Swift.h" +#endif + +@implementation AgentServicePlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftAgentServicePlugin registerWithRegistrar:registrar]; +} +@end \ No newline at end of file diff --git a/local_plugins/agent_service/ios/Classes/SwiftAgentServicePlugin.swift b/local_plugins/agent_service/ios/Classes/SwiftAgentServicePlugin.swift new file mode 100644 index 000000000..5aa8195a9 --- /dev/null +++ b/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)") + } + } +} \ No newline at end of file diff --git a/local_plugins/agent_service/lib/agent_service.dart b/local_plugins/agent_service/lib/agent_service.dart index f4818bdbe..ffcdd5fbb 100644 --- a/local_plugins/agent_service/lib/agent_service.dart +++ b/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 startAgentService(Map 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 stopAgentService() async { + try { + final bool result = await _channel.invokeMethod('stopAgentService'); + return result; + } on PlatformException catch (e) { + throw AgentServiceException(e.code, e.message ?? '停止语音助手服务失败', e.details); + } + } + /// 开始对话 /// /// 启动语音识别,开始监听用户语音输入 diff --git a/local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift b/local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift index d3dd8d0b8..7e01c0f8e 100644 --- a/local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift +++ b/local_plugins/azure_speech/ios/Classes/AzureAsrHelper.swift @@ -2,85 +2,187 @@ import Foundation import AVFoundation import MicrosoftCognitiveServicesSpeech -/// Azure 语音识别辅助类 -class AzureAsrHelper: NSObject { +/// Azure语音识别辅助类,支持麦克风和外部音频源的单次和连续语音识别 +class AzureAsrHelper { + private let tag = "AzureAsrHelper" + + // 核心组件 + private var speechConfig: SPXSpeechConfiguration? private var recognizer: SPXSpeechRecognizer? - private var speechConfig: SPXSpeechConfig? - private var audioConfig: SPXAudioConfig? - private var initialized = false + private var audioConfig: SPXAudioConfiguration? + + // 状态管理 private var isContinuousRecognitionActive = false + + // 配置参数 private var currentLanguage = "zh-CN" - private var subscriptionKey = "" - private var serviceRegion = "" + private var supportedLanguages = ["zh-CN"] private var isAutoDetectLanguage = false - private var supportedLanguages = ["zh-CN", "en-US"] + private var subscriptionKey = "" + private var region = "" + + // 音频源配置 + private var audioSourceType = AudioSourceType.microphone - // 音频会话管理 - private let audioSession = AVAudioSession.sharedInstance() + // 音频处理 + private var microphoneStream: MicrophoneStream? + private var externalAudioStream: ExternalAudioPullStream? - // 事件回调 - private var eventHandler: (([String: Any]) -> Void)? + // 连续识别回调 + private var continuousRecognizeCallback: ContinuousRecognizeCallback? - /// 设置事件处理器 - /// - /// - Parameter handler: 事件处理回调 - func setEventHandler(_ handler: @escaping ([String: Any]) -> Void) { - self.eventHandler = handler + /// 应用上下文 + private let context: Any + + /// 音频来源类型 + enum AudioSourceType { + /// 使用设备麦克风 + case microphone + + /// 使用外部提供的音频数据 + case external } - /// 初始化语音识别服务 - /// - /// - Parameters: - /// - speechSubscriptionKey: Azure 语音服务订阅密钥 - /// - serviceRegion: Azure 语音服务区域 - /// - supportedLanguages: 支持的语言列表,默认为 ["zh-CN", "en-US"] - /// - Returns: 是否初始化成功 - func initialize(speechSubscriptionKey: String, serviceRegion: String, supportedLanguages: [String] = ["zh-CN", "en-US"]) -> Bool { - print("[AzureAsrHelper] 初始化 Azure 语音服务") - - // 检查配置是否为空 - if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { - print("[AzureAsrHelper] 错误: Azure 配置信息不完整") - return false - } + /// 一次性识别回调 + protocol RecognizeCallback { + /// 识别结果回调 + /// - Parameters: + /// - text: 识别文本 + /// - detectedLanguage: 检测到的语言 + func onResult(text: String, detectedLanguage: String) - // 释放之前的资源 - dispose() + /// 识别错误回调 + /// - Parameter error: 错误信息 + func onError(error: String) + } + + /// 连续识别回调 + protocol ContinuousRecognizeCallback { + /// 识别中回调 + /// - Parameters: + /// - recognizing: 识别中的文本 + /// - detectedLanguage: 检测到的语言 + func onRecognizing(recognizing: String, detectedLanguage: String) - // 保存配置 - self.subscriptionKey = speechSubscriptionKey - self.serviceRegion = serviceRegion + /// 识别结果回调 + /// - Parameters: + /// - text: 识别文本 + /// - detectedLanguage: 检测到的语言 + func onResult(text: String, detectedLanguage: String) - // 设置语言 - if supportedLanguages.isEmpty { - print("[AzureAsrHelper] 警告: 传入的支持语言列表为空,将使用默认语言") - } else { - self.supportedLanguages = supportedLanguages - } + /// 会话开始回调 + func onSessionStarted() - // 根据支持的语言数量决定是否启用自动语言检测 - self.isAutoDetectLanguage = supportedLanguages.count >= 2 + /// 会话结束回调 + func onSessionStopped() - // 如果只有一种语言,设置为当前语言 - if !isAutoDetectLanguage && !supportedLanguages.isEmpty { - self.currentLanguage = supportedLanguages[0] - } + /// 识别取消回调 + /// - Parameters: + /// - reason: 取消原因 + /// - errorDetails: 错误详情 + func onCanceled(reason: String, errorDetails: String) - // 创建语音配置 + /// 识别错误回调 + /// - Parameter error: 错误信息 + func onError(error: String) + } + + /// 初始化Azure语音识别辅助类 + /// - Parameter context: 应用上下文 + init(_ context: Any) { + self.context = context + } + + /// 初始化Azure语音服务 + /// - Parameters: + /// - subscriptionKey: Azure 订阅密钥 + /// - region: Azure 区域 + /// - supportedLanguages: 支持的语言数组,默认为["zh-CN"] + /// - audioSourceType: 音频源类型,默认为麦克风 + /// - Returns: 初始化是否成功 + func initialize( + subscriptionKey: String, + region: String, + supportedLanguages: [String] = ["zh-CN"], + audioSourceType: AudioSourceType = .microphone + ) -> Bool { do { - speechConfig = try SPXSpeechConfig(subscription: speechSubscriptionKey, region: serviceRegion) + print("\(tag): 初始化 Azure 语音服务, 音频源类型: \(audioSourceType)") + + // 检查配置是否为空 + if subscriptionKey.isEmpty || region.isEmpty { + print("\(tag): Azure 配置信息不完整") + return false + } + + // 释放之前的资源 + dispose() + + // 保存配置 + self.subscriptionKey = subscriptionKey + self.region = region + self.audioSourceType = audioSourceType + + // 设置语言 + if !supportedLanguages.isEmpty { + self.supportedLanguages = supportedLanguages + } + + // 根据支持的语言数量决定是否启用自动语言检测 + self.isAutoDetectLanguage = supportedLanguages.count >= 2 + + // 如果只有一种语言,设置为当前语言 + if !isAutoDetectLanguage && !supportedLanguages.isEmpty { + self.currentLanguage = supportedLanguages[0] + } + + // 创建语音配置 + speechConfig = try SPXSpeechConfiguration(subscription: subscriptionKey, region: region) - // 设置语言配置 if isAutoDetectLanguage { - // 设置自动语言检测 - try speechConfig?.setPropertyTo("Continuous", byId: SPXPropertyId.SpeechServiceConnection_LanguageIdMode) + // 启用语言检测模式 + try speechConfig?.setPropertyTo("Continuous", byId: SPXPropertyId.speechServiceConnection_languageIdMode) } else { // 设置指定的识别语言 speechConfig?.speechRecognitionLanguage = currentLanguage } - // 创建音频配置 - 使用默认麦克风 - audioConfig = SPXAudioConfig.default() + // 设置音频配置 + return setupRecognizer() + + } catch { + print("\(tag): 初始化失败: \(error.localizedDescription)") + return false + } + } + + /// 设置识别器 + private func setupRecognizer() -> Bool { + do { + // 清理旧的识别器 + recognizer = nil + + // 设置音频配置 + switch audioSourceType { + case .microphone: + // 检查是否连接了耳机 + let isHeadsetConnected = isHeadphonesConnected() + + if !isHeadsetConnected { + print("\(tag): 未检测到耳机,使用麦克风输入") + // 使用拉流模式进行回音消除 + setupMicrophoneStream() + } else { + print("\(tag): 检测到耳机连接,使用默认麦克风输入") + // 使用默认麦克风输入 + audioConfig = SPXAudioConfiguration() + } + + case .external: + // 创建拉流 + setupExternalAudioStream() + print("\(tag): 使用外部音频源(拉流模式)") + } // 创建识别器 if isAutoDetectLanguage { @@ -90,372 +192,505 @@ class AzureAsrHelper: NSObject { recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!) } - // 配置音频会话 - try configureAudioSession() - - initialized = true - print("[AzureAsrHelper] Azure 语音服务初始化成功") + print("\(tag): Azure 语音服务初始化成功") return true } catch { - print("[AzureAsrHelper] 错误: 创建识别器失败: \(error.localizedDescription)") + print("\(tag): 创建识别器失败: \(error.localizedDescription)") + stopAudioProcessing() return false } } - /// 配置音频会话 - private func configureAudioSession() throws { - print("[AzureAsrHelper] 开始配置音频会话...") - + /// 设置麦克风流 - 使用拉流方式 + private func setupMicrophoneStream() { do { - // 设置音频会话类别和模式 - try audioSession.setCategory(.record, mode: .measurement, options: [.duckOthers, .allowBluetooth]) - try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + // 创建麦克风流 + microphoneStream = MicrophoneStream() + + // 创建音频配置 + audioConfig = try SPXAudioConfiguration(streamInput: microphoneStream!) + + print("\(tag): 已设置麦克风流(拉流模式)") + } catch { + print("\(tag): 设置麦克风流失败: \(error.localizedDescription)") + } + } + + /// 设置外部音频流 - 使用拉流方式 + private func setupExternalAudioStream() { + do { + // 创建外部音频拉流对象 + externalAudioStream = ExternalAudioPullStream() + + // 创建音频配置 + audioConfig = try SPXAudioConfiguration(streamInput: externalAudioStream!) + + print("\(tag): 已设置外部音频流(拉流模式)") } catch { - print("[AzureAsrHelper] 警告: 通过AudioSessionManager配置音频会话失败") - throw error + print("\(tag): 设置外部音频流失败: \(error.localizedDescription)") } } - /// 一次性语音识别 - /// - /// - Parameter completion: 完成回调,返回是否成功、识别文本、识别语言和可能的错误信息 - func recognizeOnce(completion: @escaping (Bool, String?, String?, String?) -> Void) { - if !initialized { - completion(false, nil, nil, "语音服务未初始化") + /// 向音频流写入音频数据 + /// 仅当音频源设置为external时有效 + /// - Parameter data: 音频数据字节数组 + func pushAudioData(_ data: Data) { + if audioSourceType != .external { + print("\(tag): 当前未使用外部音频源,忽略推送的音频数据") return } - // 重置 recognizer - if !resetRecognizer() { - completion(false, nil, nil, "重置识别器失败") + // 使用拉流模式,将数据推入队列 + externalAudioStream?.pushAudio(data) + } + + /// 执行一次性语音识别 + /// - Parameter callback: 识别结果回调 + func recognizeOnce(callback: RecognizeCallback) { + if speechConfig == nil { + callback.onError(error: "语音服务未初始化") return } + // 确保不在连续识别中 + if isContinuousRecognitionActive { + _ = stopContinuousRecognition() + } + + // 重置识别器 + if !setupRecognizer() { + callback.onError(error: "重置识别器失败") + return + } + + // 启动音频处理 + startAudioProcessing() + + // 执行同步识别 do { - // 激活音频会话 - try audioSession.setActive(true) + let result = try recognizer?.recognizeOnce() - // 添加识别事件处理 - recognizer?.addRecognizedEventHandler { [weak self] _, event in - guard let self = self else { return } - - if event.result.reason == .recognizedSpeech { - let detectedLanguage = self.getDetectedLanguage(from: event.result) - print("[AzureAsrHelper] 识别结果: \(event.result.text ?? ""), 语言: \(detectedLanguage)") - completion(true, event.result.text, detectedLanguage, nil) - } - } + // 停止音频处理 + stopAudioProcessing() - recognizer?.addRecognizingEventHandler { [weak self] _, event in - guard let self = self else { return } + if let result = result, result.reason == .recognizedSpeech { + let resultText = result.text ?? "" + var detectedLanguage = supportedLanguages[0] - if event.result.reason == .recognizingSpeech { - let detectedLanguage = self.getDetectedLanguage(from: event.result) - // print("[AzureAsrHelper] 识别中: \(event.result.text ?? ""), 语言: \(detectedLanguage)") - } - } - - // 添加会话事件处理 - recognizer?.addSessionStartedEventHandler { _, _ in - print("[AzureAsrHelper] 识别会话已开始") - } - - recognizer?.addSessionStoppedEventHandler { _, _ in - print("[AzureAsrHelper] 识别会话已结束") - } - - // 添加取消事件处理 - recognizer?.addCanceledEventHandler { _, event in - if let cancellationDetails = try? SPXCancellationDetails(fromCanceledRecognitionResult: event.result) { - let errorDetails = cancellationDetails.errorDetails ?? "未知错误" - print("[AzureAsrHelper] 识别取消: \(errorDetails)") - completion(false, nil, nil, "识别取消: \(errorDetails)") + // 尝试获取检测到的语言 + if isAutoDetectLanguage, let autoDetectResult = result.properties?.getPropertyByName("SourceLanguage") { + detectedLanguage = autoDetectResult } - } - - // 执行识别 - let result = try recognizer?.recognizeOnceAsync().get() - - if result?.reason != .recognizedSpeech { - completion(false, nil, nil, "未能识别语音") + + callback.onResult(text: resultText, detectedLanguage: detectedLanguage) + } else { + callback.onError(error: "未能识别语音") } } catch { - completion(false, nil, nil, "识别异常: \(error.localizedDescription)") + stopAudioProcessing() + callback.onError(error: "识别异常: \(error.localizedDescription)") } } - /// 重置识别器 - /// - /// - Returns: 是否重置成功 - private func resetRecognizer() -> Bool { - if !initialized { - print("[AzureAsrHelper] 错误: 语音服务未初始化") + /// 开始连续语音识别 + /// - Parameter callback: 连续识别结果回调 + /// - Returns: 是否成功开始识别 + func startContinuousRecognition(_ callback: ContinuousRecognizeCallback) -> Bool { + if speechConfig == nil { + callback.onError(error: "语音服务未初始化") return false } - // 检查配置是否为空 - if subscriptionKey.isEmpty || serviceRegion.isEmpty { - print("[AzureAsrHelper] 错误: Azure 配置信息不完整") + if isContinuousRecognitionActive { + return true + } + + // 重置识别器 + if !setupRecognizer() { + callback.onError(error: "重置识别器失败") return false } + // 保存回调 + continuousRecognizeCallback = callback + do { - // 释放之前的 recognizer - recognizer = nil + // 设置各种事件监听 + setupEventListeners(callback) - // 创建音频配置 - 使用默认麦克风 - audioConfig = SPXAudioConfig.default() + // 启动音频处理 + startAudioProcessing() - // 重新创建识别器 - if isAutoDetectLanguage { - let autoDetectConfig = try SPXAutoDetectSourceLanguageConfiguration(sourceLanguages: supportedLanguages) - recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, autoDetectSourceLanguageConfiguration: autoDetectConfig, audioConfiguration: audioConfig!) - } else { - recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!) - } + // 开始连续识别 + try recognizer?.startContinuousRecognition() + isContinuousRecognitionActive = true return true } catch { - print("[AzureAsrHelper] 错误: 重置识别器失败: \(error.localizedDescription)") + isContinuousRecognitionActive = false + stopAudioProcessing() + callback.onError(error: "启动连续识别失败: \(error.localizedDescription)") return false } } - /// 获取检测到的语言 - /// - /// - Parameter result: 识别结果 - /// - Returns: 检测到的语言代码 - private func getDetectedLanguage(from result: SPXSpeechRecognitionResult) -> String { - if isAutoDetectLanguage { - do { - if let autoDetectResult = try SPXAutoDetectSourceLanguageResult(fromRecognitionResult: result) { - return autoDetectResult.language - } - return "" - } catch { - print("[AzureAsrHelper] 错误: 获取检测到的语言失败: \(error.localizedDescription)") - return "" - } - } else { - return currentLanguage - } - } - - /// 开始连续语音识别 - /// - /// - Returns: 是否成功启动连续识别 - func startContinuousRecognition() -> Bool { - if !initialized { - print("[AzureAsrHelper] 错误: 语音服务未初始化") - return false - } - - // 检查配置是否为空 - if subscriptionKey.isEmpty || serviceRegion.isEmpty { - print("[AzureAsrHelper] 错误: Azure 配置信息不完整") - return false - } - - // 如果已经在进行连续识别,直接返回 - if isContinuousRecognitionActive { - print("[AzureAsrHelper] 已经在进行连续识别中,忽略请求") - return true - } + /// 设置事件监听器 + private func setupEventListeners(_ callback: ContinuousRecognizeCallback) { + guard let recognizer = recognizer else { return } - // 重置 recognizer - if !resetRecognizer() { - print("[AzureAsrHelper] 尝试重新创建识别器...") - return false + // 识别中事件 + recognizer.addRecognizing { [weak self] _, event in + guard let self = self else { return } + + var detectedLanguage = "" + if self.isAutoDetectLanguage, let lang = event.result.properties?.getPropertyByName("SourceLanguage") { + detectedLanguage = lang + } + + print("\(self.tag): 识别中: \(event.result.text ?? ""), 语言: \(detectedLanguage)") + callback.onRecognizing(recognizing: event.result.text ?? "", detectedLanguage: detectedLanguage) } - do { - // 激活音频会话 - try audioSession.setActive(true) + // 识别完成事件 + recognizer.addRecognized { [weak self] _, event in + guard let self = self else { return } - // 添加识别事件处理 - recognizer?.addRecognizedEventHandler { [weak self] _, event in - guard let self = self else { return } - - if event.result.reason == .recognizedSpeech { - let detectedLanguage = self.getDetectedLanguage(from: event.result) - let eventData: [String: Any] = [ - "eventType": "finalResult", - "text": event.result.text ?? "", - "language": detectedLanguage - ] - self.eventHandler?(eventData) + if event.result.reason == .recognizedSpeech { + var detectedLanguage = "" + if self.isAutoDetectLanguage, let lang = event.result.properties?.getPropertyByName("SourceLanguage") { + detectedLanguage = lang } - } - - // 识别中事件 - recognizer?.addRecognizingEventHandler { [weak self] _, event in - guard let self = self else { return } - if event.result.reason == .recognizingSpeech { - let detectedLanguage = self.getDetectedLanguage(from: event.result) - let eventData: [String: Any] = [ - "eventType": "recognizing", - "text": event.result.text ?? "", - "language": detectedLanguage - ] - self.eventHandler?(eventData) - } + print("\(self.tag): 识别完成: \(event.result.text ?? ""), 语言: \(detectedLanguage)") + callback.onResult(text: event.result.text ?? "", detectedLanguage: detectedLanguage) } + } + + // 会话开始事件 + recognizer.addSessionStarted { [weak self] _, _ in + guard let self = self else { return } - // 会话事件 - recognizer?.addSessionStartedEventHandler { [weak self] _, _ in - guard let self = self else { return } - - let eventData: [String: Any] = [ - "eventType": "sessionStarted" - ] - self.eventHandler?(eventData) - } + print("\(self.tag): 识别会话已开始") + callback.onSessionStarted() + } + + // 会话结束事件 + recognizer.addSessionStopped { [weak self] _, _ in + guard let self = self else { return } - recognizer?.addSessionStoppedEventHandler { [weak self] _, _ in - guard let self = self else { return } - - self.isContinuousRecognitionActive = false - let eventData: [String: Any] = [ - "eventType": "sessionStopped" - ] - self.eventHandler?(eventData) - } + print("\(self.tag): 识别会话已结束") + self.isContinuousRecognitionActive = false + self.stopAudioProcessing() + callback.onSessionStopped() + } + + // 取消事件 + recognizer.addCanceled { [weak self] _, event in + guard let self = self else { return } - // 取消事件 - recognizer?.addCanceledEventHandler { [weak self] _, event in - guard let self = self else { return } - - self.isContinuousRecognitionActive = false - var errorMessage = "未知错误" - - if let cancellationDetails = try? SPXCancellationDetails(fromCanceledRecognitionResult: event.result) { - errorMessage = cancellationDetails.errorDetails ?? "未知错误" - } - - let eventData: [String: Any] = [ - "eventType": "error", - "error": "识别取消: \(errorMessage)" - ] - self.eventHandler?(eventData) - } + let errorDetails = event.errorDetails ?? "未知错误" + let reason = String(describing: event.reason) - // 开始连续识别 - try recognizer?.startContinuousRecognition() - isContinuousRecognitionActive = true - print("[AzureAsrHelper] 连续识别已启动") + print("\(self.tag): 识别取消: \(errorDetails)") + self.isContinuousRecognitionActive = false + self.stopAudioProcessing() - return true - } catch { - print("[AzureAsrHelper] 错误: 开始连续识别失败: \(error.localizedDescription)") - return false + callback.onCanceled(reason: reason, errorDetails: errorDetails) + callback.onError(error: "识别取消: \(errorDetails)") // 兼容旧接口 } } /// 停止连续语音识别 - /// - /// - Returns: 是否成功停止连续识别 + /// - Returns: 是否成功停止 func stopContinuousRecognition() -> Bool { - if !initialized { - print("[AzureAsrHelper] 错误: 语音服务未初始化") + if speechConfig == nil { + print("\(tag): 语音服务未初始化") return false } if !isContinuousRecognitionActive { - print("[AzureAsrHelper] 未进行连续识别,忽略停止请求") + print("\(tag): 未进行连续识别,忽略停止请求") return true } do { - print("[AzureAsrHelper] 停止连续语音识别") + print("\(tag): 停止连续语音识别") if recognizer == nil { - if isContinuousRecognitionActive { - print("[AzureAsrHelper] 警告: 识别器为空,但状态显示活跃") - } + print("\(tag): 识别器为空,重置状态") isContinuousRecognitionActive = false - - // 通知停止成功 - let eventData: [String: Any] = [ - "eventType": "success", - "message": "连续识别已停止" - ] - eventHandler?(eventData) return true } // 停止连续识别 try recognizer?.stopContinuousRecognition() - // 延迟一点时间确保处理完成 + // 停止音频处理 + stopAudioProcessing() + + // 等待一段时间以确保资源正确释放 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - guard let self = self else { return } - - // 重置状态 - self.isContinuousRecognitionActive = false - - // 恢复音频会话 - do { - try self.audioSession.setActive(false, options: .notifyOthersOnDeactivation) - } catch { - // 忽略错误 - } - - print("[AzureAsrHelper] 连续识别已停止") - - // 通知停止成功 - let eventData: [String: Any] = [ - "eventType": "success", - "message": "连续识别已停止" - ] - self.eventHandler?(eventData) + self?.isContinuousRecognitionActive = false } + print("\(tag): 连续识别停止指令已发送") return true } catch { // 强制重置状态 isContinuousRecognitionActive = false - print("[AzureAsrHelper] 警告: 停止连续识别失败: \(error.localizedDescription)") + print("\(tag): 停止连续识别失败: \(error.localizedDescription)") - // 通知停止失败,但仍然视为处理完成 - let eventData: [String: Any] = [ - "eventType": "success", - "message": "连续识别已停止(但有错误)" - ] - eventHandler?(eventData) + // 停止音频处理 + stopAudioProcessing() + + // 尝试强制关闭识别器 + recognizer = nil return false } } - /// 释放资源 + /// 检查连续识别是否活跃 + /// - Returns: 是否活跃 + func isContinuousRecognitionActive() -> Bool { + return isContinuousRecognitionActive + } + + /// 释放所有资源 func dispose() { // 如果正在进行连续识别,先停止 if isContinuousRecognitionActive { _ = stopContinuousRecognition() } - // 恢复音频会话 - do { - try audioSession.setActive(false, options: .notifyOthersOnDeactivation) - } catch { - // 忽略错误 - } + // 停止音频处理 + stopAudioProcessing() - // 释放资源 + // 清理所有资源 recognizer = nil speechConfig = nil audioConfig = nil + microphoneStream = nil + externalAudioStream = nil - initialized = false + // 重置状态 isContinuousRecognitionActive = false - print("[AzureAsrHelper] 资源已释放") + continuousRecognizeCallback = nil } - /// 检查连续识别是否处于活跃状态 - /// - /// - Returns: 是否正在进行连续识别 - func isContinuousRecognitionActive() -> Bool { - return isContinuousRecognitionActive + /// 启动音频处理 + private func startAudioProcessing() { + switch audioSourceType { + case .microphone: + // 拉流模式不需要额外启动,SDK会自动拉取数据 + break + case .external: + // 外部音频数据模式下不需要启动处理,等待外部调用pushAudioData + break + } + } + + /// 停止音频处理 + private func stopAudioProcessing() { + microphoneStream?.close() + microphoneStream = nil + + externalAudioStream?.close() + externalAudioStream = nil + } + + /// 检查是否连接了耳机 + private func isHeadphonesConnected() -> Bool { + let audioSession = AVAudioSession.sharedInstance() + let outputs = audioSession.currentRoute.outputs + + // 检查是否存在耳机类型的输出 + let headphoneTypes: [AVAudioSession.Port] = [ + .headphones, .bluetoothA2DP, .bluetoothHFP, .bluetoothLE + ] + + for output in outputs { + if headphoneTypes.contains(output.portType) { + return true + } + } + + return false + } + + /// 麦克风流 - 拉流模式 + /// 实现SPXPullAudioInputStreamDelegate,为Azure SDK提供音频数据 + class MicrophoneStream: NSObject, SPXPullAudioInputStreamDelegate { + private let tag = "MicrophoneStream" + private var audioEngine: AVAudioEngine? + private var audioBuffer = Data() + private let bufferLock = NSLock() + + override init() { + super.init() + setupAudioEngine() + } + + private func setupAudioEngine() { + do { + print("\(tag): 初始化音频引擎") + + // 创建音频引擎 + audioEngine = AVAudioEngine() + + // 设置音频会话 + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.record, mode: .default) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + + // 获取输入节点 + guard let inputNode = audioEngine?.inputNode else { + print("\(tag): 无法获取输入节点") + return + } + + // 创建16kHz, 16bit, 单声道格式 + let format = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: true) + + // 设置音频处理回调 + let bufferSize = 4096 + inputNode.installTap(onBus: 0, bufferSize: UInt32(bufferSize), format: format) { [weak self] (buffer, time) in + guard let self = self else { return } + + // 将音频数据转换为Data + let audioBuffer = buffer.int16ChannelData?[0] + let audioBufferSize = Int(buffer.frameLength * buffer.format.streamDescription.pointee.mBytesPerFrame) + + if let audioBuffer = audioBuffer { + let data = Data(bytes: audioBuffer, count: audioBufferSize) + self.bufferLock.lock() + self.audioBuffer.append(data) + self.bufferLock.unlock() + } + } + + // 启动音频引擎 + audioEngine?.prepare() + try audioEngine?.start() + + print("\(tag): 音频引擎启动成功") + } catch { + print("\(tag): 设置音频引擎失败: \(error.localizedDescription)") + } + } + + // SPXPullAudioInputStreamDelegate协议方法 + func read(_ buffer: UnsafeMutablePointer, size: UInt32) -> Int32 { + bufferLock.lock() + defer { bufferLock.unlock() } + + let maxSize = Int(size) + + if audioBuffer.count == 0 { + // 没有数据可读 + return 0 + } + + let readSize = min(audioBuffer.count, maxSize) + audioBuffer.copyBytes(to: buffer, from: 0.., size: UInt32) -> Int32 { + bufferLock.lock() + defer { bufferLock.unlock() } + + if isClosed { + return 0 + } + + let maxSize = Int(size) + + if audioBuffer.count == 0 { + // 没有数据可读,返回0表示需要等待 + return 0 + } + + let readSize = min(audioBuffer.count, maxSize) + audioBuffer.copyBytes(to: buffer, from: 0.. 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 """ - - + + \(text) @@ -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 diff --git a/local_plugins/ble_service/android/build.gradle.kts b/local_plugins/ble_service/android/build.gradle.kts index e6a8a984f..b1b9b4095 100644 --- a/local_plugins/ble_service/android/build.gradle.kts +++ b/local_plugins/ble_service/android/build.gradle.kts @@ -28,13 +28,7 @@ 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") implementation(project(":deepvoice_utils")) - implementation(project(":agent_service")) implementation(project(":azure_speech")) implementation(project(":jl_opus")) } \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/AndroidManifest.xml b/local_plugins/ble_service/android/src/main/AndroidManifest.xml index 25fe5c93d..6dba89de3 100644 --- a/local_plugins/ble_service/android/src/main/AndroidManifest.xml +++ b/local_plugins/ble_service/android/src/main/AndroidManifest.xml @@ -18,39 +18,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index a6d81d279..5a1e020d5 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -175,7 +175,6 @@ object BleService { if (isScanning) return false FileLogger.i(TAG, "开始主动扫描BLE设备...") - unregisterBackgroundScan() // 避免双重扫描 scanResults.clear() val filter = ScanFilter.Builder() @@ -223,8 +222,6 @@ object BleService { scanHandler.removeCallbacksAndMessages(null) isScanning = false - // 重新注册后台扫描,保证唤醒能力 - registerBackgroundScan() return true } catch (e: Exception) { @@ -1017,74 +1014,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) - } - } + // ====================================================================================================== // 资源清理 diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt index b88f0620a..055d791fb 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt @@ -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 ?: 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) { - 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 - } - } } \ No newline at end of file diff --git a/local_plugins/ble_service/ios/Assets/ble_service-Info.plist b/local_plugins/ble_service/ios/Assets/ble_service-Info.plist new file mode 100644 index 000000000..9ea29bd4c --- /dev/null +++ b/local_plugins/ble_service/ios/Assets/ble_service-Info.plist @@ -0,0 +1,15 @@ + + + + + NSBluetoothAlwaysUsageDescription + 需要蓝牙权限与设备进行通信和传输音频数据 + NSBluetoothPeripheralUsageDescription + 需要蓝牙权限与设备进行通信和传输音频数据 + UIBackgroundModes + + bluetooth-central + audio + + + \ No newline at end of file diff --git a/local_plugins/ble_service/ios/Classes/BleConst.swift b/local_plugins/ble_service/ios/Classes/BleConst.swift new file mode 100644 index 000000000..3c551b120 --- /dev/null +++ b/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 +} \ No newline at end of file diff --git a/local_plugins/ble_service/ios/Classes/BleDelegate.swift b/local_plugins/ble_service/ios/Classes/BleDelegate.swift new file mode 100644 index 000000000..af104cb20 --- /dev/null +++ b/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]) +} \ No newline at end of file diff --git a/local_plugins/ble_service/ios/Classes/BleService.swift b/local_plugins/ble_service/ios/Classes/BleService.swift new file mode 100644 index 000000000..b4c2ae456 --- /dev/null +++ b/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? + + /// 是否已初始化 + 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]) +} \ No newline at end of file diff --git a/local_plugins/ble_service/ios/Classes/BleServicePlugin.h b/local_plugins/ble_service/ios/Classes/BleServicePlugin.h new file mode 100644 index 000000000..e28dfaa93 --- /dev/null +++ b/local_plugins/ble_service/ios/Classes/BleServicePlugin.h @@ -0,0 +1,4 @@ +#import + +@interface BleServicePlugin : NSObject +@end \ No newline at end of file diff --git a/local_plugins/ble_service/ios/Classes/BleServicePlugin.m b/local_plugins/ble_service/ios/Classes/BleServicePlugin.m new file mode 100644 index 000000000..f0a50cd8d --- /dev/null +++ b/local_plugins/ble_service/ios/Classes/BleServicePlugin.m @@ -0,0 +1,12 @@ +#import "BleServicePlugin.h" +#if __has_include() +#import +#else +#import "ble_service-Swift.h" +#endif + +@implementation BleServicePlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftBleServicePlugin registerWithRegistrar:registrar]; +} +@end \ No newline at end of file diff --git a/local_plugins/ble_service/ios/Classes/SwiftBleServicePlugin.swift b/local_plugins/ble_service/ios/Classes/SwiftBleServicePlugin.swift new file mode 100644 index 000000000..140c0ca7a --- /dev/null +++ b/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 + } +} \ No newline at end of file diff --git a/local_plugins/ble_service/ios/ble_service.podspec b/local_plugins/ble_service/ios/ble_service.podspec new file mode 100644 index 000000000..72f1802e5 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/local_plugins/chat_storage/ios/Classes/ChatStorageHelper.swift b/local_plugins/chat_storage/ios/Classes/ChatStorageHelper.swift new file mode 100644 index 000000000..d0702e65c --- /dev/null +++ b/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 + } +} \ No newline at end of file diff --git a/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.h b/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.h new file mode 100644 index 000000000..82613da1f --- /dev/null +++ b/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.h @@ -0,0 +1,4 @@ +#import + +@interface ChatStoragePlugin : NSObject +@end \ No newline at end of file diff --git a/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.m b/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.m new file mode 100644 index 000000000..0f111aba2 --- /dev/null +++ b/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.m @@ -0,0 +1,12 @@ +#import "ChatStoragePlugin.h" +#if __has_include() +#import +#else +#import "chat_storage-Swift.h" +#endif + +@implementation ChatStoragePlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftChatStoragePlugin registerWithRegistrar:registrar]; +} +@end \ No newline at end of file diff --git a/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.swift b/local_plugins/chat_storage/ios/Classes/ChatStoragePlugin.swift new file mode 100644 index 000000000..031cb370f --- /dev/null +++ b/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)) + } + } +} \ No newline at end of file diff --git a/local_plugins/chat_storage/ios/Classes/SwiftChatStoragePlugin.swift b/local_plugins/chat_storage/ios/Classes/SwiftChatStoragePlugin.swift new file mode 100644 index 000000000..bd72a61bc --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/local_plugins/chat_storage/ios/README.md b/local_plugins/chat_storage/ios/README.md new file mode 100644 index 000000000..4bf35eb37 --- /dev/null +++ b/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 端的实现,并确保在版本升级时提供适当的数据库迁移策略。 + +数据库迁移逻辑目前实现为重建表,在生产环境中应该改为保留用户数据的方式。 \ No newline at end of file diff --git a/local_plugins/chat_storage/ios/chat_storage.podspec b/local_plugins/chat_storage/ios/chat_storage.podspec index b7a2c39b8..c397034ec 100644 --- a/local_plugins/chat_storage/ios/chat_storage.podspec +++ b/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 \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothHelper.swift b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothHelper.swift new file mode 100644 index 000000000..a9aa9b3b5 --- /dev/null +++ b/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]) + } +} \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h new file mode 100644 index 000000000..69a8a8d4e --- /dev/null +++ b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.h @@ -0,0 +1,4 @@ +#import + +@interface ClassicBluetoothPlugin : NSObject +@end \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m new file mode 100644 index 000000000..93666d6e1 --- /dev/null +++ b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.m @@ -0,0 +1,12 @@ +#import "ClassicBluetoothPlugin.h" +#if __has_include() +#import +#else +#import "classic_bluetooth-Swift.h" +#endif + +@implementation ClassicBluetoothPlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftClassicBluetoothPlugin registerWithRegistrar:registrar]; +} +@end \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.swift b/local_plugins/classic_bluetooth/ios/Classes/ClassicBluetoothPlugin.swift new file mode 100644 index 000000000..b2cb2d30f --- /dev/null +++ b/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 + } +} \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/Classes/FileLogger.swift b/local_plugins/classic_bluetooth/ios/Classes/FileLogger.swift new file mode 100644 index 000000000..7bbc903c9 --- /dev/null +++ b/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 + } +} \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/Classes/SwiftClassicBluetoothPlugin.swift b/local_plugins/classic_bluetooth/ios/Classes/SwiftClassicBluetoothPlugin.swift new file mode 100644 index 000000000..9f70212e7 --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/README.md b/local_plugins/classic_bluetooth/ios/README.md new file mode 100644 index 000000000..857396358 --- /dev/null +++ b/local_plugins/classic_bluetooth/ios/README.md @@ -0,0 +1,44 @@ +# iOS蓝牙配置说明 + +## 所需权限 + +要在iOS中使用经典蓝牙功能,需要在应用的`Info.plist`中添加以下配置: + +### 1. 外部附件协议支持 + +在Info.plist中添加`UISupportedExternalAccessoryProtocols`数组,并添加你的设备支持的协议。如果不确定,可以先添加常见的协议: + +```xml +UISupportedExternalAccessoryProtocols + + com.example.protocol + + +``` + +### 2. 蓝牙使用说明 + +在iOS 13及以上版本中,需要添加蓝牙使用说明: + +```xml +NSBluetoothAlwaysUsageDescription +我们需要使用蓝牙来连接您的音频设备 +``` + +对于iOS 13以下版本: + +```xml +NSBluetoothPeripheralUsageDescription +我们需要使用蓝牙来连接您的音频设备 +``` + +## 常见问题 + +1. 如果看不到任何设备,请确保: + - 设备已配对并连接到iOS设备 + - 设备是MFi认证的(Made for iPhone/iPad),因为iOS对经典蓝牙有限制 + - 添加了正确的协议字符串 + +2. 蓝牙状态变化不及时: + - iOS系统对蓝牙状态变化的通知可能有延迟 + - 请确保用户已经授权蓝牙权限 \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/ios/classic_bluetooth.podspec b/local_plugins/classic_bluetooth/ios/classic_bluetooth.podspec new file mode 100644 index 000000000..8dbc9c504 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/local_plugins/classic_bluetooth/pubspec.yaml b/local_plugins/classic_bluetooth/pubspec.yaml index eca826603..c618200f1 100644 --- a/local_plugins/classic_bluetooth/pubspec.yaml +++ b/local_plugins/classic_bluetooth/pubspec.yaml @@ -17,4 +17,6 @@ flutter: platforms: android: package: com.yunqiinnovation.classic_bluetooth + pluginClass: ClassicBluetoothPlugin + ios: pluginClass: ClassicBluetoothPlugin \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.h b/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.h new file mode 100644 index 000000000..f64c3569a --- /dev/null +++ b/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.h @@ -0,0 +1,4 @@ +#import + +@interface DeepvoiceUtilsPlugin : NSObject +@end \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.m b/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.m new file mode 100644 index 000000000..af5d121b1 --- /dev/null +++ b/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.m @@ -0,0 +1,12 @@ +#import "DeepvoiceUtilsPlugin.h" +#if __has_include() +#import +#else +#import "deepvoice_utils-Swift.h" +#endif + +@implementation DeepvoiceUtilsPlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftDeepvoiceUtilsPlugin registerWithRegistrar:registrar]; +} +@end \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.swift b/local_plugins/deepvoice_utils/ios/Classes/DeepvoiceUtilsPlugin.swift new file mode 100644 index 000000000..922bb3c4b --- /dev/null +++ b/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() + } +} \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/ios/Classes/FileLogger.swift b/local_plugins/deepvoice_utils/ios/Classes/FileLogger.swift new file mode 100644 index 000000000..f54e040f8 --- /dev/null +++ b/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 + } +} \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/ios/Classes/SwiftDeepvoiceUtilsPlugin.swift b/local_plugins/deepvoice_utils/ios/Classes/SwiftDeepvoiceUtilsPlugin.swift new file mode 100644 index 000000000..408f07402 --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/ios/README.md b/local_plugins/deepvoice_utils/ios/README.md new file mode 100644 index 000000000..7f12f83cb --- /dev/null +++ b/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) +``` \ No newline at end of file diff --git a/local_plugins/deepvoice_utils/ios/deepvoice_utils.podspec b/local_plugins/deepvoice_utils/ios/deepvoice_utils.podspec new file mode 100644 index 000000000..758fe120c --- /dev/null +++ b/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 \ No newline at end of file diff --git a/local_plugins/open_ai_service/ios/Classes/MCPClient.swift b/local_plugins/open_ai_service/ios/Classes/MCPClient.swift new file mode 100644 index 000000000..99fd86008 --- /dev/null +++ b/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() + } +} \ No newline at end of file diff --git a/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift b/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift index 5df99e126..1eb798ef9 100644 --- a/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift +++ b/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 - } } \ No newline at end of file diff --git a/local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift b/local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift index d919e2584..0284055a8 100644 --- a/local_plugins/open_ai_service/ios/Classes/OpenAIServicePlugin.swift +++ b/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) } diff --git a/local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift b/local_plugins/open_ai_service/ios/Classes/SystemFunctionHandler.swift new file mode 100644 index 000000000..65c8a8add --- /dev/null +++ b/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 + } +} \ No newline at end of file