diff --git a/.mcp copy.json b/.mcp copy.json new file mode 100644 index 000000000..eaa12d328 --- /dev/null +++ b/.mcp copy.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "web-search": { + "url": "http://mcp.ideapsound.com:8000/sse" + }, + "amap-amap-sse": { + "url": "https://mcp.amap.com/sse?key=df7b0b6747c632fc38ddad22e7176646" + } + } +} \ No newline at end of file diff --git a/.mcp.json b/.mcp.json index 4651d9677..efa9f2239 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,13 +1,10 @@ { "mcpServers": { - "deepsound-mcp": { - "url": "https://api.deapsound.com/sse" - }, - "voitrans-mcp": { - "url": "https://www.voitrans.net/sse" - }, "web-search": { "url": "http://mcp.ideapsound.com:8000/sse" + }, + "mp3": { + "url": "https://api.deapsound.com/sse" } } } \ No newline at end of file diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index 398e4f084..3356166c3 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -6,7 +6,8 @@ import '../../data/services/language_manager.dart'; import '../../data/services/volcano_ai_service.dart'; import '../../core/utils/logger.dart'; import '../../data/services/open_ai_service_adapter.dart'; -import '../../data/services/bluetooth_service.dart'; +import '../../data/services/bluetooth_manager.dart'; +import '../../data/services/ble_manager.dart'; /// 初始绑定,用于管理全局依赖 class InitialBinding extends Bindings { @override @@ -36,7 +37,9 @@ class InitialBinding extends Bindings { // 蓝牙服务 - Get.put(BluetoothService(), permanent: true); + Get.put(BluetoothManager(), permanent: true); + // BleManager + Get.put(BleManager(), permanent: true); // 火山AI服务 Get.lazyPut(() => VolcanoAIService(), fenix: true); diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart index d4e6a6145..3c5a15470 100644 --- a/lib/core/utils/logger.dart +++ b/lib/core/utils/logger.dart @@ -21,42 +21,71 @@ class Logger { } /// 输出调试日志 - static void debug(String message) { + static void debug(String message, {String? tag}) { if (_currentLevel.index <= LogLevel.debug.index) { - _log('DEBUG', message); + _log('DEBUG', message, tag: tag); } } /// 输出信息日志 - static void info(String message) { + static void info(String message, {String? tag}) { if (_currentLevel.index <= LogLevel.info.index) { - _log('INFO', message); + _log('INFO', message, tag: tag); } } /// 输出警告日志 - static void warning(String message) { + static void warning(String message, {String? tag}) { if (_currentLevel.index <= LogLevel.warning.index) { - _log('WARNING', message); + _log('WARNING', message, tag: tag); } } /// 输出错误日志 - static void error(String message, [dynamic error, StackTrace? stackTrace]) { + static void error(String message, [dynamic error, StackTrace? stackTrace, String? tag]) { if (_currentLevel.index <= LogLevel.error.index) { - _log('ERROR', message); + _log('ERROR', message, tag: tag); if (error != null) { - _log('ERROR', 'Original error: $error'); + _log('ERROR', 'Original error: $error', tag: tag); } if (stackTrace != null) { - _log('ERROR', 'Stack trace: $stackTrace'); + _log('ERROR', 'Stack trace: $stackTrace', tag: tag); } } } + /// 获取当前调用栈的类名 + static String? _getCallerClassName() { + try { + final stackTrace = StackTrace.current.toString(); + final frames = stackTrace.split('\n'); + + // 查找调用者帧 (跳过Logger框架内部的帧) + for (int i = 0; i < frames.length; i++) { + final frame = frames[i].trim(); + if (frame.contains('Logger.') || frame.contains('_log')) { + continue; + } + + // 简单的解析类名 + final regex = RegExp(r'#\d+\s+([^\.]+)\.([^\.]+)\s'); + final match = regex.firstMatch(frame); + if (match != null && match.groupCount >= 1) { + return match.group(1); + } + break; + } + } catch (e) { + // 忽略解析错误 + } + return null; + } + /// 内部日志输出方法 - static void _log(String level, String message) { + static void _log(String level, String message, {String? tag}) { final timestamp = DateTime.now().toString(); - developer.log('[$timestamp] $level: $message'); + final className = tag ?? _getCallerClassName() ?? ''; + final tagInfo = className.isNotEmpty ? '[$className] ' : ''; + developer.log('[$timestamp][$level][$tagInfo]: $message'); } } diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart new file mode 100644 index 000000000..399569d60 --- /dev/null +++ b/lib/data/services/ble_manager.dart @@ -0,0 +1,554 @@ +import 'dart:async'; +import 'package:get/get.dart'; +import 'package:ble_service/ble_service.dart'; +import 'package:deep_voice/core/utils/logger.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +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; + +/// 命令类型常量(与原生层保持一致) +class BleCommands { + static const int GET_VERSION = 0x01; + static const int GET_BATTERY_INFO = 0x02; + static const int CONTROL_CODEC = 0x03; + static const int PLAY_A2DP = 0x04; +} + +/// 蓝牙设备信息缓存模型 +class DeviceInfo { + final String address; + final String name; + + // 整体电池电量 + final int batteryLevel; + + // 详细电池信息 + final int leftEarBattery; + final int rightEarBattery; + final int caseBattery; + + // 充电状态 + final bool leftEarCharging; + final bool rightEarCharging; + final bool caseCharging; + + final String firmwareVersion; + final DateTime lastUpdated; + + DeviceInfo({ + required this.address, + required this.name, + this.batteryLevel = 0, + this.leftEarBattery = 0, + this.rightEarBattery = 0, + this.caseBattery = 0, + this.leftEarCharging = false, + this.rightEarCharging = false, + this.caseCharging = false, + this.firmwareVersion = '', + DateTime? lastUpdated, + }) : lastUpdated = lastUpdated ?? DateTime.now(); + + /// 创建更新后的设备信息副本 + DeviceInfo copyWith({ + String? address, + String? name, + int? batteryLevel, + int? leftEarBattery, + int? rightEarBattery, + int? caseBattery, + bool? leftEarCharging, + bool? rightEarCharging, + bool? caseCharging, + String? firmwareVersion, + }) { + return DeviceInfo( + address: address ?? this.address, + name: name ?? this.name, + batteryLevel: batteryLevel ?? this.batteryLevel, + leftEarBattery: leftEarBattery ?? this.leftEarBattery, + rightEarBattery: rightEarBattery ?? this.rightEarBattery, + caseBattery: caseBattery ?? this.caseBattery, + leftEarCharging: leftEarCharging ?? this.leftEarCharging, + rightEarCharging: rightEarCharging ?? this.rightEarCharging, + caseCharging: caseCharging ?? this.caseCharging, + firmwareVersion: firmwareVersion ?? this.firmwareVersion, + lastUpdated: DateTime.now(), + ); + } + + @override + String toString() { + return 'DeviceInfo(address: $address, name: $name, ' + 'battery: $batteryLevel%, L: $leftEarBattery%, R: $rightEarBattery%, Case: $caseBattery%, ' + 'charging: L:$leftEarCharging, R:$rightEarCharging, C:$caseCharging, ' + 'version: $firmwareVersion)'; + } +} + +/// BLE管理器 - 专门处理BLE设备连接和信息管理 +class BleManager extends GetxService { + static BleManager get to => Get.find(); + + // BLE服务实例 + final BleService _bleService = BleService(); + + // BLE连接状态 + final _connectionState = BleConnectionState.disconnected.obs; + int get connectionState => _connectionState.value; + + // 连接状态监听 + final _isConnected = false.obs; + bool get isConnected => _isConnected.value; + + // 设备信息缓存 + final Rx _deviceInfo = Rx(null); + DeviceInfo? get deviceInfo => _deviceInfo.value; + + // 订阅 + StreamSubscription? _statusSubscription; + StreamSubscription? _dataSubscription; + final GetStorage _storage = GetStorage(); + + /// 初始化 + @override + void onInit() { + super.onInit(); + Logger.info('BLE管理器初始化'); + + // 设置BLE事件监听 + _setupBleListener(); + } + + /// 释放资源 + @override + void onClose() { + _statusSubscription?.cancel(); + _dataSubscription?.cancel(); + super.onClose(); + } + + /// 设置BLE事件监听 + void _setupBleListener() { + _statusSubscription?.cancel(); + _dataSubscription?.cancel(); + + try { + // 监听BLE状态更新 + _statusSubscription = _bleService.onStatusUpdated.listen( + (event) { + Logger.info('接收到BLE状态更新: $event'); + + if (event is Map) { + // 处理连接状态更新 + if (event.containsKey('connectionState')) { + final state = event['connectionState'] as int; + _connectionState.value = state; + + // 连接状态转换 + if (state == BleConnectionState.connected) { + Logger.info('BLE设备已连接'); + _isConnected.value = true; + + // 在连接成功后获取设备信息 + _fetchDeviceInfo(); + } else if (state == BleConnectionState.disconnected || + state == BleConnectionState.error) { + Logger.info('BLE设备已断开或发生错误'); + _isConnected.value = false; + } + } + + // 处理设备信息 + _processDeviceInfo(event); + } + }, + onError: (error) { + Logger.error('BLE状态事件流错误: $error'); + }, + ); + + // 监听BLE数据流 + _dataSubscription = _bleService.onDataReceived.listen( + (data) { + Logger.info('接收到BLE数据: $data'); + // 在这里处理来自设备的数据 + }, + onError: (error) { + Logger.error('BLE数据流错误: $error'); + }, + ); + } catch (e) { + Logger.error('设置BLE事件监听器失败: $e'); + } + } + + /// 处理来自原生层的设备信息 + void _processDeviceInfo(Map event) { + try { + // 检查是否是设备信息响应 + if (event.containsKey('command') && event.containsKey('success')) { + final bool success = event['success'] as bool; + if (!success) { + Logger.warning('设备命令执行失败: ${event['command']}'); + return; + } + + // 根据命令类型处理不同信息 + final command = event['command']; + + if (command is int) { + // 基于命令ID处理 + _processDeviceInfoByCommandId(command, event); + } else if (command is String) { + // 基于命令名称处理 + _processDeviceInfoByCommandName(command, event); + } + } + } catch (e) { + Logger.error('处理设备信息异常: $e'); + } + } + + /// 根据命令ID处理设备信息 + void _processDeviceInfoByCommandId( + int commandId, Map data) { + switch (commandId) { + case BleCommands.GET_BATTERY_INFO: + _updateBatteryInfoFromData(data); + break; + case BleCommands.GET_VERSION: + _updateVersionInfoFromData(data); + break; + default: + Logger.info('未处理的命令ID: $commandId'); + } + } + + /// 根据命令名称处理设备信息 + void _processDeviceInfoByCommandName( + String commandName, Map data) { + switch (commandName) { + case 'batteryInfo': + _updateBatteryInfoFromData(data); + break; + case 'versionInfo': + _updateVersionInfoFromData(data); + break; + default: + Logger.info('未处理的命令名称: $commandName'); + } + } + + /// 更新电池信息 + void _updateBatteryInfoFromData(Map data) { + try { + // 确保数据中包含电池信息 + if (!data.containsKey('leftEarBattery') || + !data.containsKey('rightEarBattery') || + !data.containsKey('caseBattery')) { + Logger.warning('电池信息数据不完整'); + return; + } + + // 提取电池信息 + final int leftBattery = data['leftEarBattery'] as int? ?? 0; + final int rightBattery = data['rightEarBattery'] as int? ?? 0; + final int caseBattery = data['caseBattery'] as int? ?? 0; + final bool leftCharging = data['leftEarCharging'] as bool? ?? false; + final bool rightCharging = data['rightEarCharging'] as bool? ?? false; + final bool caseCharging = data['caseCharging'] as bool? ?? false; + + // 获取整体电量(原生层使用左耳电量作为主要指示) + final int overallBattery = data['batteryLevel'] as int? ?? leftBattery; + + // 更新设备信息 + _updateDeviceInfoIfNeeded( + batteryLevel: overallBattery, + leftEarBattery: leftBattery, + rightEarBattery: rightBattery, + caseBattery: caseBattery, + leftEarCharging: leftCharging, + rightEarCharging: rightCharging, + caseCharging: caseCharging, + ); + + Logger.info( + '设备电池信息已更新: 电量=$overallBattery%, 左耳=$leftBattery%, 右耳=$rightBattery%, 充电仓=$caseBattery%'); + } catch (e) { + Logger.error('更新电池信息失败: $e'); + } + } + + /// 更新版本信息 + void _updateVersionInfoFromData(Map data) { + try { + // 确保数据中包含版本信息 + if (!data.containsKey('version')) { + Logger.warning('版本信息数据不完整'); + return; + } + + // 提取版本信息 + final String version = data['version'] as String? ?? ''; + + // 更新设备信息 + _updateDeviceInfoIfNeeded( + firmwareVersion: version, + ); + + Logger.info('设备版本信息已更新: 版本=$version'); + } catch (e) { + Logger.error('更新版本信息失败: $e'); + } + } + + /// 更新设备信息(如果已存在设备) + void _updateDeviceInfoIfNeeded({ + int? batteryLevel, + int? leftEarBattery, + int? rightEarBattery, + int? caseBattery, + bool? leftEarCharging, + bool? rightEarCharging, + bool? caseCharging, + String? firmwareVersion, + }) { + // 如果设备信息已存在,则更新 + if (_deviceInfo.value != null) { + _deviceInfo.value = _deviceInfo.value!.copyWith( + batteryLevel: batteryLevel, + leftEarBattery: leftEarBattery, + rightEarBattery: rightEarBattery, + caseBattery: caseBattery, + leftEarCharging: leftEarCharging, + rightEarCharging: rightEarCharging, + caseCharging: caseCharging, + firmwareVersion: firmwareVersion, + ); + } + // 如果设备信息不存在,但有地址和名称,则创建新的设备信息 + else if (_deviceInfo.value == null && + _isConnected.value && + (firmwareVersion != null || batteryLevel != null)) { + // 提示尝试创建设备信息但缺少必要信息 + Logger.warning('尝试更新设备信息,但设备基本信息不存在'); + } + } + + /// 连接成功后获取设备信息 + Future _fetchDeviceInfo() async { + Logger.info('开始获取BLE设备详细信息'); + + try { + // 获取电池信息 + final batteryResult = await _bleService.getBatteryInfo(); + if (batteryResult) { + Logger.info('电池信息获取指令已发送'); + } else { + Logger.warning('电池信息获取指令发送失败'); + } + + // 获取设备版本信息 + final versionResult = await _bleService.getDeviceVersion(); + if (versionResult) { + Logger.info('版本信息获取指令已发送'); + } else { + Logger.warning('版本信息获取指令发送失败'); + } + } catch (e) { + Logger.error('获取BLE设备信息失败: $e'); + } + } + + /// 检查BLE连接状态 + Future getConnectionState() async { + try { + final isConnected = await _bleService.isConnected(); + _connectionState.value = isConnected + ? BleConnectionState.connected + : BleConnectionState.disconnected; + _isConnected.value = isConnected; + + return _connectionState.value; + } catch (e) { + Logger.error('获取BLE连接状态失败: $e'); + return BleConnectionState.error; + } + } + + /// 启动BLE服务 + Future startBleService() async { + try { + // 从.env文件直接读取环境变量 + final azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; + final azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? 'eastasia'; + final openaiApiKey = dotenv.env['OPENAI_API_KEY'] ?? ''; + final openaiBaseUrl = + dotenv.env['OPENAI_BASE_URL'] ?? 'https://api.openai.com/v1'; + final openaiModel = dotenv.env['OPENAI_MODEL'] ?? 'gpt-3.5-turbo'; + // 从Flutter资源包中加载.mcp.json + final mcpConfig = await rootBundle.loadString('.mcp.json'); + + // 验证JSON格式 + final jsonData = jsonDecode(mcpConfig); + final config = { + // 语音代理配置参数 + 'azureSpeechKey': azureSpeechKey, + 'azureSpeechRegion': azureSpeechRegion, + 'openaiApiKey': openaiApiKey, + 'openaiBaseUrl': openaiBaseUrl, + 'openaiModel': openaiModel, + 'mcpServer': jsonEncode(jsonData), + }; + + // 记录日志(不记录敏感信息) + Logger.info('启动BLE服务,配置参数键名: ${config}'); + + if (azureSpeechKey.isEmpty || openaiApiKey.isEmpty) { + Logger.warning('缺少必要的API密钥,请检查.env文件配置'); + + // 检查.env中相关密钥是否存在 + if (azureSpeechKey.isEmpty) { + Logger.warning('AZURE_SPEECH_KEY未配置在.env文件中'); + } + if (openaiApiKey.isEmpty) { + Logger.warning('OPENAI_API_KEY未配置在.env文件中'); + } + } + + final result = await _bleService.startBleService(config); + + if (result) { + Logger.info('BLE服务启动成功'); + + // 添加延迟以等待设备连接 + await Future.delayed(const Duration(seconds: 2)); + + // 检查连接状态 + final isConnected = await _bleService.isConnected(); + if (isConnected) { + _isConnected.value = true; + _connectionState.value = BleConnectionState.connected; + + // 如果设备信息存在,则获取详细信息 + if (_deviceInfo.value != null) { + // 获取详细设备信息 + _fetchDeviceInfo(); + } + } + } else { + Logger.error('BLE服务启动失败'); + } + + return result; + } catch (e) { + Logger.error('启动BLE服务失败: $e'); + return false; + } + } + + /// 停止BLE服务 + Future stopBleService() async { + try { + final result = await _bleService.stopBleService(); + + if (result) { + Logger.info('BLE服务停止成功'); + _isConnected.value = false; + _connectionState.value = BleConnectionState.disconnected; + } else { + Logger.error('BLE服务停止失败'); + } + + return result; + } catch (e) { + Logger.error('停止BLE服务失败: $e'); + return false; + } + } + + registerBackgroundScan() { + _bleService.registerBackgroundScan(); + } + + /// 获取电池电量 + Future getBatteryLevel() async { + try { + return await _bleService.getBatteryLevel(); + } catch (e) { + Logger.error('获取电池电量失败: $e'); + return 0; + } + } + + /// 主动获取电池信息 + Future fetchBatteryInfo() async { + try { + return await _bleService.getBatteryInfo(); + } catch (e) { + Logger.error('获取电池信息失败: $e'); + return false; + } + } + + /// 主动获取设备版本信息 + Future fetchDeviceVersion() async { + try { + return await _bleService.getDeviceVersion(); + } catch (e) { + Logger.error('获取设备版本失败: $e'); + return false; + } + } + + /// 打开编码器 + Future openEncoder() async { + try { + return await _bleService.openEncoder(); + } catch (e) { + Logger.error('打开编码器失败: $e'); + return false; + } + } + + /// 打开解码器 + Future openDecoder() async { + try { + return await _bleService.openDecoder(); + } catch (e) { + Logger.error('打开解码器失败: $e'); + return false; + } + } + + /// 关闭编解码器 + Future closeCodec() async { + try { + return await _bleService.closeCodec(); + } catch (e) { + Logger.error('关闭编解码器失败: $e'); + return false; + } + } + + /// 播放A2DP + Future playA2DP() async { + try { + return await _bleService.playA2DP(); + } catch (e) { + Logger.error('播放A2DP失败: $e'); + return false; + } + } + + /// 注册服务 + static Future init() async { + if (!Get.isRegistered()) { + Get.put(BleManager(), permanent: true); + } + return Future.value(Get.find()); + } +} diff --git a/lib/data/services/bluetooth_manager.dart b/lib/data/services/bluetooth_manager.dart new file mode 100644 index 000000000..e6e26ec02 --- /dev/null +++ b/lib/data/services/bluetooth_manager.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'package:get/get.dart'; +import 'package:classic_bluetooth/classic_bluetooth.dart'; +import 'package:deep_voice/core/utils/logger.dart'; + + +/// 蓝牙服务 - 管理经典蓝牙服务 +class BluetoothManager extends GetxService { + static BluetoothManager get to => Get.find(); + + // 蓝牙状态 + final _bluetoothState = 'unknown'.obs; + String get bluetoothState => _bluetoothState.value; + + // 当前经典蓝牙设备 + final Rx currentDeviceRx = Rx(null); + BluetoothDevice? get currentDevice => currentDeviceRx.value; + + // 订阅 + StreamSubscription? _bluetoothSubscription; + + + /// 初始化 + @override + void onInit() { + super.onInit(); + Logger.info('蓝牙服务初始化'); + + + // 设置蓝牙事件监听 + _setupBluetoothListener(); + } + + /// 释放资源 + @override + void onClose() { + _bluetoothSubscription?.cancel(); + super.onClose(); + } + + /// 设置经典蓝牙事件监听 + void _setupBluetoothListener() { + _bluetoothSubscription?.cancel(); + + try { + _bluetoothSubscription = ClassicBluetooth.events.listen( + (event) { + if (event.type == 'stateChanged' && event.state != null) { + _bluetoothState.value = event.state!; + Logger.info('蓝牙状态变更: ${event.state}'); + + // 如果蓝牙关闭,清除设备 + if (event.state == 'off') { + currentDeviceRx.value = null; + } + } else if (event.type == 'deviceConnected' && event.device != null) { + // 直接从事件中获取设备信息 + final newDevice = event.device!; + + // 更新当前设备 + currentDeviceRx.value = newDevice; + + Logger.info('经典蓝牙设备已连接: ${newDevice.name} (${newDevice.address})'); + } else if (event.type == 'deviceDisconnected' && event.device != null) { + // 直接从事件中获取设备信息 + final disconnectedDevice = event.device!; + + Logger.info('经典蓝牙设备已断开: ${disconnectedDevice.name} (${disconnectedDevice.address})'); + + // 如果断开的是当前设备,清除当前设备 + if (currentDeviceRx.value?.address == disconnectedDevice.address) { + currentDeviceRx.value = null; + Logger.info('已清除当前设备引用'); + } + } + }, + onError: (error) { + Logger.error('蓝牙事件流错误: $error'); + }, + ); + } catch (e) { + Logger.error('设置蓝牙事件监听器失败: $e'); + } + } + + /// 刷新设备连接状态 + Future refreshDeviceConnection() async { + try { + + + // 如果BLE未连接,则检查经典蓝牙连接 + final devices = await ClassicBluetooth.getConnectedDevices(); + + if (devices.isNotEmpty) { + final newDevice = devices.first; + + // 更新当前设备 + currentDeviceRx.value = newDevice; + + Logger.info('已连接经典蓝牙设备: ${newDevice.name} (${newDevice.address})'); + return; + } + + // 没有任何连接 + currentDeviceRx.value = null; + Logger.info('未找到已连接的蓝牙设备'); + + } catch (e) { + Logger.error('刷新设备连接失败: $e'); + } + } + + /// 检查蓝牙是否已启用 + Future isBluetoothEnabled() async { + try { + final isEnabled = await ClassicBluetooth.isBluetoothEnabled(); + _bluetoothState.value = isEnabled ? 'on' : 'off'; + return isEnabled; + } catch (e) { + Logger.error('检查蓝牙状态失败: $e'); + return false; + } + } + +} \ No newline at end of file diff --git a/lib/data/services/bluetooth_service.dart b/lib/data/services/bluetooth_service.dart deleted file mode 100644 index 1e9028cca..000000000 --- a/lib/data/services/bluetooth_service.dart +++ /dev/null @@ -1,249 +0,0 @@ -import 'dart:async'; -import 'dart:math' as math; -import 'package:get/get.dart'; -import 'package:classic_bluetooth/classic_bluetooth.dart'; -import 'package:deep_voice/core/utils/logger.dart'; -import 'package:flutter/services.dart'; - -/// 蓝牙设备信息缓存模型 -class DeviceInfo { - final String address; - final String name; - - // 整体电池电量 - final int batteryLevel; - - // 详细电池信息 - final int leftEarBattery; - final int rightEarBattery; - final int caseBattery; - - // 充电状态 - final bool leftEarCharging; - final bool rightEarCharging; - final bool caseCharging; - - final String firmwareVersion; - final String productId; - final String colorId; - final DateTime lastUpdated; - - DeviceInfo({ - required this.address, - required this.name, - this.batteryLevel = 0, - this.leftEarBattery = 0, - this.rightEarBattery = 0, - this.caseBattery = 0, - this.leftEarCharging = false, - this.rightEarCharging = false, - this.caseCharging = false, - this.firmwareVersion = '', - this.productId = '', - this.colorId = '', - DateTime? lastUpdated, - }) : lastUpdated = lastUpdated ?? DateTime.now(); - - /// 创建更新后的设备信息副本 - DeviceInfo copyWith({ - String? address, - String? name, - int? batteryLevel, - int? leftEarBattery, - int? rightEarBattery, - int? caseBattery, - bool? leftEarCharging, - bool? rightEarCharging, - bool? caseCharging, - String? firmwareVersion, - String? productId, - String? colorId, - }) { - return DeviceInfo( - address: address ?? this.address, - name: name ?? this.name, - batteryLevel: batteryLevel ?? this.batteryLevel, - leftEarBattery: leftEarBattery ?? this.leftEarBattery, - rightEarBattery: rightEarBattery ?? this.rightEarBattery, - caseBattery: caseBattery ?? this.caseBattery, - leftEarCharging: leftEarCharging ?? this.leftEarCharging, - rightEarCharging: rightEarCharging ?? this.rightEarCharging, - caseCharging: caseCharging ?? this.caseCharging, - firmwareVersion: firmwareVersion ?? this.firmwareVersion, - productId: productId ?? this.productId, - colorId: colorId ?? this.colorId, - lastUpdated: DateTime.now(), - ); - } - - @override - String toString() { - return 'DeviceInfo(address: $address, name: $name, ' - 'battery: $batteryLevel%, L: $leftEarBattery%, R: $rightEarBattery%, Case: $caseBattery%, ' - 'charging: L:$leftEarCharging, R:$rightEarCharging, C:$caseCharging, ' - 'version: $firmwareVersion, productId: $productId, colorId: $colorId)'; - } -} - -/// 蓝牙服务 - 管理经典蓝牙服务,提供设备信息缓存 -class BluetoothService extends GetxService { - static BluetoothService get to => Get.find(); - - // 蓝牙状态 - final _bluetoothState = 'unknown'.obs; - String get bluetoothState => _bluetoothState.value; - - // 当前经典蓝牙设备 - final Rx currentDeviceRx = Rx(null); - BluetoothDevice? get currentDevice => currentDeviceRx.value; - - // 设备信息缓存 - final Rx _deviceInfo = Rx(null); - DeviceInfo? get deviceInfo => _deviceInfo.value; - - // 表明当前连接类型,本服务只处理经典蓝牙 - bool get isBleConnection => false; - - // 订阅 - StreamSubscription? _bluetoothSubscription; - - /// 初始化 - @override - void onInit() { - super.onInit(); - Logger.info('蓝牙服务初始化'); - - // 设置蓝牙事件监听 - _setupBluetoothListener(); - } - - /// 释放资源 - @override - void onClose() { - _bluetoothSubscription?.cancel(); - super.onClose(); - } - - /// 设置经典蓝牙事件监听 - void _setupBluetoothListener() { - _bluetoothSubscription?.cancel(); - - try { - _bluetoothSubscription = ClassicBluetooth.events.listen( - (event) { - if (event.type == 'stateChanged' && event.state != null) { - _bluetoothState.value = event.state!; - Logger.info('蓝牙状态变更: ${event.state}'); - - // 如果蓝牙关闭,清除设备 - if (event.state == 'off') { - currentDeviceRx.value = null; - _deviceInfo.value = null; - } - } else if (event.type == 'deviceConnected' && event.device != null) { - // 直接从事件中获取设备信息 - final newDevice = event.device!; - - // 更新当前设备 - currentDeviceRx.value = newDevice; - - Logger.info('经典蓝牙设备已连接: ${newDevice.name} (${newDevice.address})'); - - // 创建基本设备信息 - if (_deviceInfo.value == null || _deviceInfo.value!.address != newDevice.address) { - _deviceInfo.value = DeviceInfo( - address: newDevice.address, - name: newDevice.name, - ); - Logger.info('创建设备信息缓存: ${_deviceInfo.value}'); - } - } else if (event.type == 'deviceDisconnected' && event.device != null) { - // 直接从事件中获取设备信息 - final disconnectedDevice = event.device!; - - Logger.info('经典蓝牙设备已断开: ${disconnectedDevice.name} (${disconnectedDevice.address})'); - - // 如果断开的是当前设备,清除当前设备 - if (currentDeviceRx.value?.address == disconnectedDevice.address) { - currentDeviceRx.value = null; - // 不清除设备信息缓存,保留最后状态 - Logger.info('已清除当前设备引用,保留最后设备信息缓存'); - } - } - }, - onError: (error) { - Logger.error('蓝牙事件流错误: $error'); - }, - ); - } catch (e) { - Logger.error('设置蓝牙事件监听器失败: $e'); - } - } - - /// 刷新设备连接状态 - Future refreshDeviceConnection() async { - try { - // 检查经典蓝牙连接 - final devices = await ClassicBluetooth.getConnectedDevices(); - - if (devices.isNotEmpty) { - final newDevice = devices.first; - - // 更新当前设备 - currentDeviceRx.value = newDevice; - - Logger.info('已连接经典蓝牙设备: ${newDevice.name} (${newDevice.address})'); - - // 创建基本设备信息 - if (_deviceInfo.value == null || _deviceInfo.value!.address != newDevice.address) { - _deviceInfo.value = DeviceInfo( - address: newDevice.address, - name: newDevice.name, - ); - Logger.info('创建设备信息缓存: ${_deviceInfo.value}'); - } - - return; - } - - // 没有任何连接 - currentDeviceRx.value = null; - Logger.info('未找到已连接的蓝牙设备'); - - } catch (e) { - Logger.error('刷新设备连接失败: $e'); - } - } - - /// 检查蓝牙是否已启用 - Future isBluetoothEnabled() async { - try { - final isEnabled = await ClassicBluetooth.isBluetoothEnabled(); - _bluetoothState.value = isEnabled ? 'on' : 'off'; - return isEnabled; - } catch (e) { - Logger.error('检查蓝牙状态失败: $e'); - return false; - } - } - - /// 启动BLE服务(不适用于此服务,仅为接口兼容) - Future startBleService(Map config) async { - Logger.warning('BluetoothService不支持BLE服务,请使用BleService'); - return false; - } - - /// 停止BLE服务(不适用于此服务,仅为接口兼容) - Future stopBleService() async { - Logger.warning('BluetoothService不支持BLE服务,请使用BleService'); - return false; - } - - /// 注册服务 - static Future init() async { - if (!Get.isRegistered()) { - Get.put(BluetoothService(), permanent: true); - } - return Future.value(Get.find()); - } -} \ No newline at end of file diff --git a/lib/modules/agent/bindings/agent_binding.dart b/lib/modules/agent/bindings/agent_binding.dart new file mode 100644 index 000000000..812e44d8b --- /dev/null +++ b/lib/modules/agent/bindings/agent_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import '../controllers/agent_controller.dart'; + +class AgentBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => AgentController()); + } +} \ No newline at end of file diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart new file mode 100644 index 000000000..e0379d657 --- /dev/null +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -0,0 +1,409 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:agent_service/agent_service.dart'; +import 'package:logger/logger.dart'; +import 'package:chat_storage/chat_storage.dart'; + +class Message { + final bool isUser; + final String text; + final DateTime timestamp; + final bool isRecognizing; // 是否为语音识别中的临时消息 + + Message({ + required this.isUser, + required this.text, + DateTime? timestamp, + this.isRecognizing = false, + }) : this.timestamp = timestamp ?? DateTime.now(); +} + +class AgentController extends GetxController { + static AgentController get to => Get.find(); + + final logger = Logger(); + + // 聊天消息列表 + final messages = [].obs; + + // 控制器 + final TextEditingController textController = TextEditingController(); + final ScrollController scrollController = ScrollController(); + + // 状态标志 + final isInitialized = false.obs; + final isListening = false.obs; + final isSpeaking = false.obs; + final isProcessing = false.obs; + + // 输入模式控制 + final isTextInputMode = true.obs; + + // 当前输入的文本内容 + final currentText = ''.obs; + + // 流事件订阅 + StreamSubscription? _eventSubscription; + + // 用于跟踪AI回复状态 + bool _isNewAssistantResponse = true; + String _currentResponseId = ''; + + // 会话ID + final String sessionId = 'default_agent'; + + // 是否已加载聊天记录 + final isHistoryLoaded = false.obs; + + @override + void onInit() { + super.onInit(); + _loadChatHistory(); + _subscribeToEvents(); + } + + @override + void onClose() { + AgentService.stopConversation(); + AgentService.stopTts(); + textController.dispose(); + scrollController.dispose(); + _eventSubscription?.cancel(); + super.onClose(); + } + + // 加载聊天历史记录 + Future _loadChatHistory() async { + try { + logger.i('正在加载聊天历史...'); + final result = await ChatStorage.getMessages( + sessionId: sessionId, + page: 1, + pageSize: 20, + ); + + if (result.messages.isNotEmpty) { + // 转换并添加消息到列表 + final historyMessages = result.messages.map((msg) => Message( + isUser: msg.sender == 'user', + text: msg.message, + timestamp: DateTime.parse(msg.timestamp), + )).toList(); + + // 消息按时间排序,最新的消息在底部 + historyMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + messages.assignAll(historyMessages); + + logger.i('成功加载 ${historyMessages.length} 条聊天记录'); + } else { + // 没有历史记录时添加欢迎消息 + _addWelcomeMessage(); + logger.i('没有历史记录,添加欢迎消息'); + } + } catch (e) { + logger.e('加载聊天历史失败: $e'); + _addWelcomeMessage(); + } finally { + isHistoryLoaded.value = true; + + messages.refresh(); + // 在加载完成后直接定位到底部,无需动画 + WidgetsBinding.instance.addPostFrameCallback((_) { + scrollController.jumpTo(scrollController.position.maxScrollExtent); + }); + + } + } + + // 添加欢迎消息 + void _addWelcomeMessage() { + messages.add(Message( + isUser: false, + text: '你好!我是DeepVoice AI助手,我能帮你:\n\n' + '• 回答问题和提供信息\n' + '• 实时语音翻译\n' + '• 推荐音乐和播客\n' + '• 语音备忘录和提醒\n' + '• 控制耳机功能\n\n' + '有什么我可以帮你的吗?', + )); + } + + // 订阅代理服务事件 + void _subscribeToEvents() { + _eventSubscription = AgentService.events.listen((event) { + switch (event.type) { + case AgentServiceEventType.recognitionStarted: + isListening.value = true; + isTextInputMode.value = false; + isTextInputMode.refresh(); + // 只标记识别开始,不添加消息 + break; + case AgentServiceEventType.recognitionStopped: + isListening.value = false; + isTextInputMode.value = true; + isTextInputMode.refresh(); + break; + + case AgentServiceEventType.recognizing: + final text = event.data['text'] ?? ''; + currentText.value = text; // 保留当前文本,以便其他地方使用 + + if (text.isNotEmpty) { + // 查找是否有正在识别中的消息 + final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); + + if (index >= 0) { + // 更新已有的识别中消息 + messages[index] = Message( + isUser: true, + text: text, + isRecognizing: true, + timestamp: messages[index].timestamp, + ); + } else { + // 没有识别中的消息,创建新消息 + messages.add(Message( + isUser: true, + text: text, + isRecognizing: true, + )); + } + messages.refresh(); + _scrollToBottom(); + } + break; + + case AgentServiceEventType.recognitionResult: + final text = event.data['text'] ?? ''; + + // 查找是否有正在识别中的消息 + final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); + + if (text.isNotEmpty) { + if (index >= 0) { + // 将识别中的消息转换为最终消息 + messages[index] = Message( + isUser: true, + text: text, + timestamp: messages[index].timestamp, + ); + messages.refresh(); + } else { + // 如果没有找到识别中的消息,添加新消息 + messages.add(Message(isUser: true, text: text)); + } + + isProcessing.value = true; + // 标记为新的AI回复 + _isNewAssistantResponse = true; + // 滚动到底部 + _scrollToBottom(); + } else if (index >= 0) { + // 如果识别结果为空,移除临时的识别消息 + messages.removeAt(index); + messages.refresh(); + } + break; + + case AgentServiceEventType.ttsStarted: + isSpeaking.value = true; + break; + + case AgentServiceEventType.ttsCompleted: + case AgentServiceEventType.ttsStopped: + case AgentServiceEventType.ttsCanceled: + isSpeaking.value = false; + break; + + case AgentServiceEventType.assistantToken: + if (!isProcessing.value) isProcessing.value = true; + + final token = event.data['token'] ?? ''; + final responseId = event.data['responseId'] ?? ''; + + if (token.isNotEmpty) { + // 如果是新的回复或者响应ID改变,创建新消息 + if (_isNewAssistantResponse || (_currentResponseId != responseId && responseId.isNotEmpty)) { + messages.add(Message(isUser: false, text: token)); + _isNewAssistantResponse = false; + _currentResponseId = responseId; + } else { + // 否则更新最后一条消息 + final lastIndex = messages.length - 1; + + if (lastIndex >= 0 && !messages[lastIndex].isUser) { + messages[lastIndex] = Message( + isUser: false, + text: messages[lastIndex].text + token, + timestamp: messages[lastIndex].timestamp, + ); + } + } + // 滚动到底部 + _scrollToBottom(); + } + break; + + case AgentServiceEventType.assistantResponse: + isProcessing.value = false; + // 标记当前回复完成 + _isNewAssistantResponse = true; + break; + + case AgentServiceEventType.error: + isListening.value = false; + isSpeaking.value = false; + isProcessing.value = false; + + // 移除临时的识别消息 + final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); + if (index >= 0) { + messages.removeAt(index); + messages.refresh(); + } + + logger.e('代理服务错误: ${event.data['message']}'); + break; + + case AgentServiceEventType.autoStop: + isListening.value = false; + + // 移除临时的识别消息 + final index = messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); + if (index >= 0) { + // 只有当消息为空或文本太短时才移除 + if (messages[index].text.trim().length < 2) { + messages.removeAt(index); + messages.refresh(); + } else { + // 否则转换为正式消息 + messages[index] = Message( + isUser: true, + text: messages[index].text, + timestamp: messages[index].timestamp, + ); + messages.refresh(); + + // 发送此消息到AI处理 + isProcessing.value = true; + _isNewAssistantResponse = true; + AgentService.processTextInput(messages[index].text, speakResponse: true); + } + } + break; + + case AgentServiceEventType.functionCall: + // final functionName = event.data['name'] ?? ''; + // final arguments = event.data['arguments'] ?? ''; + // logger.i('mcp: $functionName, $arguments'); + break; + + case AgentServiceEventType.functionCallResult: + final result = event.data['result'] ?? ''; + final functionCall = event.data['function_call'] ?? ''; + break; + + default: + break; + } + }); + } + + // 滚动到底部 + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients) { + try { + scrollController.animateTo( + scrollController.position.maxScrollExtent, + duration: Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } catch (e) { + logger.e('滚动到底部失败: $e'); + } + } + }); + } + + // 发送文本消息 + Future sendTextMessage() async { + final text = textController.text.trim(); + if (text.isEmpty) return; + + final message = Message(isUser: true, text: text); + messages.add(message); + textController.clear(); + + // 滚动到底部 + _scrollToBottom(); + + try { + isProcessing.value = true; + // 标记为新的AI回复 + _isNewAssistantResponse = true; + await AgentService.processTextInput(text, speakResponse: true); + } catch (e) { + logger.e('发送文本消息失败: $e'); + isProcessing.value = false; + } + } + + // 开始语音输入 + Future startVoiceInput() async { + if (isListening.value) return; + + try { + await AgentService.startConversation(); + } catch (e) { + logger.e('启动语音输入失败: $e'); + } + } + + // 停止语音输入 + Future stopVoiceInput() async { + if (!isListening.value) return; + + try { + await AgentService.stopConversation(); + } catch (e) { + logger.e('停止语音输入失败: $e'); + } + } + + // 清除聊天历史 + Future clearChat() async { + try { + await AgentService.clearChatHistory(); + + + // 清空本地消息列表 + messages.clear(); + + // 添加欢迎消息 + _addWelcomeMessage(); + + // 重置回复状态 + _isNewAssistantResponse = true; + } catch (e) { + logger.e('清除聊天历史失败: $e'); + } + } + + // 切换输入模式 + void toggleInputMode() { + isTextInputMode.toggle(); + + // 切换到语音模式时,直接开始语音输入 + if (!isTextInputMode.value) { + startVoiceInput(); + } + // 切换到文本模式时,直接停止语音输入 + else { + stopVoiceInput(); + } + } +} \ No newline at end of file diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart new file mode 100644 index 000000000..c74a2f97f --- /dev/null +++ b/lib/modules/agent/views/agent_view.dart @@ -0,0 +1,350 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/agent_controller.dart'; +import 'message_bubble.dart'; + +class AgentView extends StatelessWidget { + const AgentView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final controller = Get.find(); + + return Scaffold( + appBar: AppBar( + title: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(20), + ), + child: const Icon( + Icons.smart_toy_outlined, + color: Colors.white, + ), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'DeepVoice AI', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 4), + Text( + '在线', + style: TextStyle( + fontSize: 12, + color: Colors.green, + ), + ), + ], + ), + ], + ), + ], + ), + actions: [ + PopupMenuButton( + icon: const Icon(Icons.more_vert), + itemBuilder: (context) => [ + PopupMenuItem( + child: const Text('清除聊天记录'), + onTap: () => controller.clearChat(), + ), + ], + ), + ], + ), + body: Column( + children: [ + // 聊天内容 + Expanded( + child: Obx( + () => ListView.builder( + controller: controller.scrollController, + reverse: false, + padding: const EdgeInsets.all(16), + itemCount: controller.messages.length, + itemBuilder: (context, index) { + final message = controller.messages[index]; + return MessageBubble( + isUser: message.isUser, + message: message.text, + timestamp: message.timestamp, + isRecognizing: message.isRecognizing, + ); + }, + ), + ), + ), + + // 输入区域 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 5, + offset: const Offset(0, -1), + ), + ], + ), + child: Row( + children: [ + // 键盘/语音切换按钮 + Obx(() { + final isTextMode = controller.isTextInputMode.value; + return GestureDetector( + onTap: () { + controller.toggleInputMode(); + }, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + isTextMode ? Icons.mic_none : Icons.keyboard, + color: Colors.grey[600], + ), + ), + ); + }), + + const SizedBox(width: 8), + + // 文本输入框或语音波形图 + Expanded( + child: Obx(() { + final isTextMode = controller.isTextInputMode.value; + + if (isTextMode) { + // 文本输入模式 + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(24), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller.textController, + decoration: const InputDecoration( + hintText: '输入消息...', + border: InputBorder.none, + ), + onSubmitted: (_) => controller.sendTextMessage(), + ), + ), + GestureDetector( + onTap: () => controller.sendTextMessage(), + child: Icon( + Icons.send, + color: Theme.of(context).primaryColor, + ), + ), + ], + ), + ); + } else { + // 语音输入模式 - 始终显示波形图 + return GestureDetector( + onTap: () { + if (controller.isListening.value) { + controller.stopVoiceInput(); + } else { + controller.startVoiceInput(); + } + }, + child: Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(24), + ), + child: Row( + children: [ + // 语音波形图 - 始终显示 + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + ...List.generate(10, (index) { + return _buildSoundBar(index); + }), + const Spacer(), + // "正在聆听"文字 + Obx(() { + final isListening = controller.isListening.value; + return Row( + children: [ + Icon( + Icons.mic, + size: 14, + color: isListening + ? Theme.of(context).primaryColor + : Colors.grey, + ), + const SizedBox(width: 4), + Text( + '正在聆听', + style: TextStyle( + fontSize: 12, + color: isListening + ? Theme.of(context).primaryColor + : Colors.grey, + ), + ), + ], + ); + }), + ], + ), + ), + ], + ), + ), + ); + } + }), + ), + + const SizedBox(width: 8), + + // 只显示添加按钮,不再显示停止按钮 + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + Icons.add, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ], + ), + ); + } + + // 构建声音柱 + Widget _buildSoundBar(int index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: Obx(() => _SoundBar( + delay: index * 0.1, + isActive: Get.find().isListening.value, + )), + ); + } +} + +// 声音柱动画组件 +class _SoundBar extends StatefulWidget { + final double delay; + final bool isActive; + + const _SoundBar({ + required this.delay, + required this.isActive, + }); + + @override + _SoundBarState createState() => _SoundBarState(); +} + +class _SoundBarState extends State<_SoundBar> with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + + _animation = Tween(begin: 3, end: 15).animate( + CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ), + )..addListener(() { + setState(() {}); + }); + + _startOrStopAnimation(); + } + + @override + void didUpdateWidget(_SoundBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isActive != oldWidget.isActive) { + _startOrStopAnimation(); + } + } + + void _startOrStopAnimation() { + if (widget.isActive) { + Future.delayed(Duration(milliseconds: (widget.delay * 1000).toInt()), () { + if (mounted && widget.isActive) { + _controller.repeat(reverse: true); + } + }); + } else { + _controller.reset(); + // 设置为小高度的静态状态 + _controller.animateTo(0.1, duration: Duration(milliseconds: 300)); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: 2, + height: widget.isActive ? _animation.value : 3, + decoration: BoxDecoration( + color: widget.isActive ? Theme.of(context).primaryColor : Colors.grey[400], + borderRadius: BorderRadius.circular(1), + ), + ); + } +} \ No newline at end of file diff --git a/lib/modules/agent/views/message_bubble.dart b/lib/modules/agent/views/message_bubble.dart new file mode 100644 index 000000000..b7cd530f7 --- /dev/null +++ b/lib/modules/agent/views/message_bubble.dart @@ -0,0 +1,832 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// 消息气泡组件 +class MessageBubble extends StatelessWidget { + final bool isUser; + final String message; + final DateTime timestamp; + final bool isRecognizing; + + const MessageBubble({ + Key? key, + required this.isUser, + required this.message, + required this.timestamp, + this.isRecognizing = false, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final timeFormat = DateFormat('HH:mm'); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isUser) _buildAvatar(isUser, context), + const SizedBox(width: 8), + Flexible( + child: Column( + crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _getBubbleColor(context), + borderRadius: BorderRadius.circular(18).copyWith( + bottomLeft: isUser ? const Radius.circular(18) : const Radius.circular(4), + bottomRight: !isUser ? const Radius.circular(18) : const Radius.circular(4), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: _buildMessageContent(context), + ), + const SizedBox(height: 4), + // 识别中的消息不显示时间 + if (!isRecognizing) + Text( + timeFormat.format(timestamp), + style: TextStyle( + fontSize: 10, + color: Colors.grey[500], + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + if (isUser) _buildAvatar(isUser, context), + ], + ), + ); + } + + /// 获取气泡颜色 + Color _getBubbleColor(BuildContext context) { + if (isUser) { + return isRecognizing + ? Color(0xFF3B82F6).withOpacity(0.8) // 识别中的用户消息使用半透明的蓝色 + : Color(0xFF3B82F6); // 使用更明亮的蓝色 + } else { + return Color(0xFFF1F5F9); // 更淡的灰色背景 + } + } + + /// 构建头像 + Widget _buildAvatar(bool isUser, BuildContext context) { + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isUser ? Colors.grey[300] : Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Icon( + isUser ? Icons.person : Icons.smart_toy_outlined, + color: isUser ? Colors.grey[700] : Colors.white, + size: 20, + ), + ); + } + + /// 构建消息内容 + Widget _buildMessageContent(BuildContext context) { + // 如果是识别中的消息,添加特殊样式 + if (isRecognizing) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + message, + style: const TextStyle( + color: Colors.white, + fontStyle: FontStyle.italic, + fontSize: 14, + height: 1.4, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + _buildTypingIndicator(), + ], + ); + } + + // 判断消息内容,显示特殊卡片 + if (!isUser) { + if (message.contains('播放列表') || message.contains('音乐')) { + return _buildMusicContent(context); + } else if (message.contains('景点') || message.contains('旅游')) { + return _buildTravelContent(context); + } else if (message.contains('天气') || message.contains('气温') || message.contains('预报')) { + return _buildWeatherContent(context); + } + } + + return Text( + message, + style: TextStyle( + color: isUser ? Colors.white : Colors.black87, + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w400, + ), + ); + } + + /// 构建输入中的指示器 + Widget _buildTypingIndicator() { + return SizedBox( + width: 40, + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildDot(100), + _buildDot(300), + _buildDot(500), + ], + ), + ); + } + + /// 构建动画点 + Widget _buildDot(int milliseconds) { + return _AnimatedDot(delayMilliseconds: milliseconds); + } + + /// 构建音乐内容卡片 + Widget _buildMusicContent(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '以下是一些推荐的音乐:', + style: TextStyle( + color: Colors.black87, + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 3, + spreadRadius: 1, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.music_note, + size: 16, + color: Theme.of(context).primaryColor.withOpacity(0.7), + ), + const SizedBox(width: 6), + Text( + '雨天时光 · 播放列表', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.grey[700], + ), + ), + ], + ), + const SizedBox(height: 10), + + // 歌曲示例 + _buildMusicItem('雨声与钢琴曲', '纯音乐', context), + Divider(height: 1, thickness: 1, color: Colors.grey[100]), + _buildMusicItem('Rainy Day', 'Coldplay', context), + Divider(height: 1, thickness: 1, color: Colors.grey[100]), + _buildMusicItem('雨天的爵士咖啡馆', '爵士合集', context), + + const SizedBox(height: 8), + Center( + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + minimumSize: Size.zero, + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + '查看更多推荐', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () {}, + icon: Icon(Icons.play_arrow, size: 16), + label: Text('播放全部'), + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).primaryColor, + side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)), + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + textStyle: TextStyle(fontSize: 12), + ), + ), + ), + SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: () {}, + icon: Icon(Icons.shuffle, size: 16), + label: Text('随机播放'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.grey[700], + side: BorderSide(color: Colors.grey[400]!), + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + textStyle: TextStyle(fontSize: 12), + ), + ), + ), + ], + ), + ], + ); + } + + /// 构建旅游内容卡片 + Widget _buildTravelContent(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '以下是一些推荐的景点:', + style: TextStyle( + color: Colors.black87, + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 3, + spreadRadius: 1, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.place, + size: 16, + color: Color(0xFFE11D48), + ), + const SizedBox(width: 6), + Text( + '热门景点 · 东京', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.grey[700], + ), + ), + ], + ), + const SizedBox(height: 10), + + // 景点列表 + _buildLocationItem('东京塔', '标志性建筑,可俯瞰东京全景', context), + Divider(height: 1, thickness: 1, color: Colors.grey[100]), + _buildLocationItem('涩谷十字路口', '世界上最繁忙的十字路口之一', context), + Divider(height: 1, thickness: 1, color: Colors.grey[100]), + _buildLocationItem('浅草寺', '东京最古老的寺庙', context), + + const SizedBox(height: 8), + Center( + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + minimumSize: Size.zero, + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + '查看更多景点', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () {}, + icon: Icon(Icons.map, size: 16), + label: Text('查看地图'), + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).primaryColor, + side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)), + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + textStyle: TextStyle(fontSize: 12), + ), + ), + ), + SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: () {}, + icon: Icon(Icons.bookmark_border, size: 16), + label: Text('保存到收藏'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.grey[700], + side: BorderSide(color: Colors.grey[400]!), + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + textStyle: TextStyle(fontSize: 12), + ), + ), + ), + ], + ), + ], + ); + } + + /// 构建音乐项目 + Widget _buildMusicItem(String title, String artist, BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.music_note, + size: 18, + color: Theme.of(context).primaryColor, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.grey[800], + ), + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 2), + Text( + artist, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + onPressed: () {}, + icon: Icon(Icons.play_circle, color: Theme.of(context).primaryColor), + padding: EdgeInsets.zero, + constraints: BoxConstraints(), + iconSize: 24, + ), + ], + ), + ); + } + + /// 构建地点项目 + Widget _buildLocationItem(String name, String description, BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: Colors.blue[50], + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.place, + size: 18, + color: Color(0xFFE11D48), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.grey[800], + ), + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 2), + Text( + description, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + onPressed: () {}, + icon: Icon(Icons.arrow_forward, color: Colors.grey[400]), + padding: EdgeInsets.zero, + constraints: BoxConstraints(), + iconSize: 20, + ), + ], + ), + ); + } + + /// 构建天气内容卡片 + Widget _buildWeatherContent(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '当前天气情况:', + style: TextStyle( + color: Colors.black87, + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF60A5FA), Color(0xFF3B82F6)], + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '北京', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + SizedBox(height: 4), + Text( + '晴朗', + style: TextStyle( + fontSize: 14, + color: Colors.white.withOpacity(0.9), + ), + ), + SizedBox(height: 12), + Row( + children: [ + Text( + '25°', + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '最高: 29°', + style: TextStyle( + fontSize: 12, + color: Colors.white.withOpacity(0.9), + ), + ), + Text( + '最低: 18°', + style: TextStyle( + fontSize: 12, + color: Colors.white.withOpacity(0.9), + ), + ), + ], + ), + ], + ), + ], + ), + Icon( + Icons.wb_sunny, + size: 48, + color: Colors.white, + ), + ], + ), + SizedBox(height: 16), + Container( + height: 1, + color: Colors.white.withOpacity(0.2), + ), + SizedBox(height: 16), + Text( + '未来预报', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildForecastDay('周一', Icons.wb_sunny, '28°'), + _buildForecastDay('周二', Icons.cloud, '25°'), + _buildForecastDay('周三', Icons.wb_cloudy, '26°'), + _buildForecastDay('周四', Icons.grain, '24°'), + _buildForecastDay('周五', Icons.wb_sunny, '27°'), + ], + ), + SizedBox(height: 16), + Container( + padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: Colors.white, + ), + SizedBox(width: 6), + Flexible( + child: Text( + '今日紫外线指数较高,外出请做好防晒', + style: TextStyle( + fontSize: 12, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () {}, + icon: Icon(Icons.location_on, size: 16), + label: Text('更多地区'), + style: OutlinedButton.styleFrom( + foregroundColor: Theme.of(context).primaryColor, + side: BorderSide(color: Theme.of(context).primaryColor.withOpacity(0.5)), + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + textStyle: TextStyle(fontSize: 12), + ), + ), + ), + SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: () {}, + icon: Icon(Icons.calendar_today, size: 16), + label: Text('15天预报'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.grey[700], + side: BorderSide(color: Colors.grey[400]!), + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + textStyle: TextStyle(fontSize: 12), + ), + ), + ), + ], + ), + ], + ); + } + + /// 构建单日天气预报 + Widget _buildForecastDay(String day, IconData icon, String temp) { + return Column( + children: [ + Text( + day, + style: TextStyle( + fontSize: 12, + color: Colors.white.withOpacity(0.9), + ), + ), + SizedBox(height: 4), + Icon( + icon, + size: 20, + color: Colors.white, + ), + SizedBox(height: 4), + Text( + temp, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ], + ); + } +} + +/// 动画点组件 +class _AnimatedDot extends StatefulWidget { + final int delayMilliseconds; + + const _AnimatedDot({required this.delayMilliseconds}); + + @override + _AnimatedDotState createState() => _AnimatedDotState(); +} + +class _AnimatedDotState extends State<_AnimatedDot> with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + ); + + _animation = Tween(begin: 0, end: 6).animate( + CurvedAnimation( + parent: _controller, + curve: Interval(0.0, 0.5, curve: Curves.easeOut), + reverseCurve: Interval(0.5, 1.0, curve: Curves.easeIn), + ), + )..addListener(() { + setState(() {}); + }); + + Future.delayed(Duration(milliseconds: widget.delayMilliseconds), () { + if (mounted) { + _controller.repeat(); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(3), + boxShadow: [ + BoxShadow( + color: Colors.white.withOpacity(0.5), + blurRadius: _animation.value, + spreadRadius: _animation.value / 2, + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/modules/home/controllers/home_controller.dart b/lib/modules/home/controllers/home_controller.dart index 78902c2d9..f1a5955e4 100644 --- a/lib/modules/home/controllers/home_controller.dart +++ b/lib/modules/home/controllers/home_controller.dart @@ -5,7 +5,8 @@ import '../../../core/utils/logger.dart'; import 'package:flutter/material.dart'; import '../../explore/controllers/explore_controller.dart'; import 'package:classic_bluetooth/classic_bluetooth.dart' show BluetoothDevice; -import '../../../data/services/bluetooth_service.dart'; +import '../../../data/services/bluetooth_manager.dart'; +import '../../../data/services/ble_manager.dart'; /// 连接状态枚举 enum ConnectionStatus { @@ -31,8 +32,8 @@ class HomeController extends GetxController { ConnectionStatus get connectionStatus => _connectionStatus.value; String get connectionErrorMsg => _connectionErrorMsg.value; String get bluetoothState => _bluetoothState.value; - BluetoothDevice? get currentDevice => _bluetoothService.currentDevice; - DeviceInfo? get deviceInfo => _bluetoothService.deviceInfo; + BluetoothDevice? get currentDevice => _bluetoothManager.currentDevice; + DeviceInfo? get deviceInfo => _bleManager.deviceInfo; bool get isConnected => currentDevice != null; // 设备详细信息的getter,从deviceInfo获取数据 @@ -43,8 +44,8 @@ class HomeController extends GetxController { bool get rightEarCharging => deviceInfo?.rightEarCharging ?? false; bool get caseCharging => deviceInfo?.caseCharging ?? false; String get firmwareVersion => deviceInfo?.firmwareVersion ?? ''; - String get productId => deviceInfo?.productId ?? ''; - String get colorId => deviceInfo?.colorId ?? ''; + + // 监听和订阅 Worker? _deviceInfoWorker; @@ -52,8 +53,8 @@ class HomeController extends GetxController { Worker? _deviceWorker; // 蓝牙服务 - final _bluetoothService = Get.find(); - + final _bluetoothManager = Get.find(); + final _bleManager = Get.find(); @override void onInit() { super.onInit(); @@ -71,7 +72,7 @@ class HomeController extends GetxController { /// 使用缓存的蓝牙设备信息初始化 void _initWithCachedInfo() { // 使用蓝牙服务中的当前状态 - _bluetoothState.value = _bluetoothService.bluetoothState; + _bluetoothState.value = _bluetoothManager.bluetoothState; // 根据当前设备状态设置连接状态 if (currentDevice != null) { @@ -94,7 +95,7 @@ class HomeController extends GetxController { /// 设置状态监听器 void _setupStateListeners() { // 设备信息监听 - 设备信息更新时自动触发UI更新 - _deviceInfoWorker = ever(_bluetoothService.deviceInfo.obs, (DeviceInfo? info) { + _deviceInfoWorker = ever(_bleManager.deviceInfo.obs, (DeviceInfo? info) { if (info != null) { Logger.info('主页更新设备信息: ${info.toString()}'); } else { @@ -104,7 +105,7 @@ class HomeController extends GetxController { }); // 蓝牙状态监听 - _bluetoothStateWorker = ever(_bluetoothService.bluetoothState.obs, (String state) { + _bluetoothStateWorker = ever(_bluetoothManager.bluetoothState.obs, (String state) { _bluetoothState.value = state; // 如果蓝牙关闭,更新错误信息 @@ -117,7 +118,7 @@ class HomeController extends GetxController { }); // 设备连接状态监听 - 直接监听设备变化 - _deviceWorker = ever(_bluetoothService.currentDeviceRx, (BluetoothDevice? device) { + _deviceWorker = ever(_bluetoothManager.currentDeviceRx, (BluetoothDevice? device) { if (device != null) { // 设备已连接 if (_connectionStatus.value != ConnectionStatus.connected) { @@ -180,7 +181,7 @@ class HomeController extends GetxController { /// 展示AI功能 void exploreAIFeatures() { - Get.toNamed(Routes.chat); + Get.toNamed(Routes.agent); } // UI辅助方法 @@ -301,9 +302,4 @@ class HomeController extends GetxController { deviceInfo!.firmwareVersion : 'unknown'.tr; } - /// 获取设备类型文本 - String getProductTypeText() { - return deviceInfo?.productId.isNotEmpty == true ? - "型号: ${deviceInfo!.productId}" : 'unknown'.tr; - } } diff --git a/lib/modules/home/views/home_view.dart b/lib/modules/home/views/home_view.dart index 0f4de7a89..cb3399207 100644 --- a/lib/modules/home/views/home_view.dart +++ b/lib/modules/home/views/home_view.dart @@ -611,16 +611,6 @@ class HomeView extends GetView { isDarkMode: isDarkMode, ), - // 产品型号 (如果有) - if (controller.productId.isNotEmpty) ...[ - SizedBox(height: 8.h), - _buildInfoRow( - icon: Icons.category, - label: 'productType'.tr, - value: controller.productId, - isDarkMode: isDarkMode, - ), - ], // 连接状态标签 SizedBox(height: 10.h), diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index b5066624e..26c52885c 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -5,9 +5,9 @@ import 'package:permission_handler/permission_handler.dart'; import '../../../routes/app_routes.dart'; import '../../../core/utils/logger.dart'; import 'dart:io'; -import '../../../data/services/bluetooth_service.dart'; +import '../../../data/services/bluetooth_manager.dart'; import 'package:classic_bluetooth/classic_bluetooth.dart' show BluetoothDevice; - +import '../../../data/services/ble_manager.dart'; /// 配对控制器,负责检查蓝牙权限、状态,并处理配对流程 class PairingController extends GetxController { static PairingController get to => Get.find(); @@ -41,7 +41,7 @@ class PairingController extends GetxController { Worker? _deviceWorker; // 蓝牙服务 - final _bluetoothService = Get.find(); + final _bluetoothService = Get.find(); @override void onInit() { @@ -59,6 +59,8 @@ class PairingController extends GetxController { /// 初始化流程 Future _initialize() async { try { + + isLoading.value = true; statusDescription.value = 'checkingPermissions'.tr; @@ -83,6 +85,10 @@ class PairingController extends GetxController { // 刷新已连接设备 statusDescription.value = 'searchingDevice'.tr; + + // 设备连接时启动BLE后台服务 + _startBleService(); + await refreshConnectedDevices(); } catch (e) { Logger.error('初始化配对控制器失败: $e'); @@ -102,6 +108,7 @@ class PairingController extends GetxController { // Android 12+ (SDK 31+) 需要 SCAN 和 CONNECT // Android 6+ (SDK 23+) 需要 LOCATION permissionsToRequest.add(Permission.locationWhenInUse); // 基本定位权限 + permissionsToRequest.add(Permission.microphone); // Android 12及以上版本才需要请求蓝牙扫描和连接权限 if (await _getAndroidSdkVersion() >= 31) { permissionsToRequest.add(Permission.bluetoothScan); @@ -226,8 +233,7 @@ class PairingController extends GetxController { Logger.info('已找到连接的设备 ${currentDevice!.name},自动跳转到主页'); - // 设备连接时启动BLE后台服务 - _startBleService(); + Future.delayed(const Duration(milliseconds: 500), () { Get.offAllNamed(Routes.home); @@ -237,23 +243,20 @@ class PairingController extends GetxController { /// 启动BLE后台服务 Future _startBleService() async { try { - if (!isDeviceConnected) return; - - String deviceName = currentDevice!.name; - String deviceAddress = currentDevice!.address; - - // 准备配置参数 - final Map config = { - 'scan_interval': 5000, // 扫描间隔时间(毫秒) - 'scan_duration': 10000, // 扫描持续时间(毫秒) - 'notification_title': '设备连接中', // 通知标题 - 'notification_content': '已连接到 $deviceName', // 通知内容 - 'mac': deviceAddress, // 设备地址 - 'device_name': deviceName // 设备名称 - }; + // if (!isDeviceConnected) return; - // 启动BLE服务 - final result = await _bluetoothService.startBleService(config); + // 确保麦克风权限已被授予 + final microphoneStatus = await Permission.microphone.status; + if (!microphoneStatus.isGranted) { + Logger.warning('麦克风权限未授予,语音交互需要此权限'); + final result = await Permission.microphone.request(); + if (!result.isGranted) { + Logger.error('麦克风权限被拒绝,语音功能将无法正常工作'); + } + } + + // 启动BLE服务,无需传递配置参数,将从.env读取 + final result = await BleManager.to.startBleService(); if (result) { Logger.info('BLE后台服务启动成功'); @@ -268,7 +271,7 @@ class PairingController extends GetxController { /// 停止BLE后台服务 Future _stopBleService() async { try { - final result = await _bluetoothService.stopBleService(); + final result = await BleManager.to.stopBleService(); if (result) { Logger.info('BLE后台服务停止成功'); diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index a483ef0ae..1e7281662 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -39,6 +39,8 @@ import '../modules/opus_test/views/opus_test_view.dart'; import '../modules/opus_test/bindings/opus_test_binding.dart'; import '../modules/speech_test/views/speech_test_view.dart'; import '../modules/speech_test/bindings/speech_test_binding.dart'; +import '../modules/agent/views/agent_view.dart'; +import '../modules/agent/bindings/agent_binding.dart'; abstract class AppPages { static final pages = [ @@ -148,5 +150,11 @@ abstract class AppPages { page: () => const SpeechTestView(), binding: SpeechTestBinding(), ), + + GetPage( + name: Routes.agent, + page: () => const AgentView(), + binding: AgentBinding(), + ), ]; } diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index bbe618f5c..41a7997c1 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -29,4 +29,5 @@ abstract class Routes { static const opusTest = '/opus_test'; static const bleTest = '/ble_test'; static const speechTest = '/speech_test'; + static const agent = '/agent'; } diff --git a/local_plugins/agent_service/android/build.gradle.kts b/local_plugins/agent_service/android/build.gradle.kts index 2bd9f5080..eb6ba06cd 100644 --- a/local_plugins/agent_service/android/build.gradle.kts +++ b/local_plugins/agent_service/android/build.gradle.kts @@ -5,7 +5,7 @@ plugins { android { namespace = "com.yunqiinnovation.agent_service" - compileSdk = 33 + compileSdk = 35 defaultConfig { minSdk = 21 @@ -16,6 +16,10 @@ android { targetCompatibility = JavaVersion.VERSION_11 } + kotlinOptions { + jvmTarget = "11" + } + sourceSets { getByName("main") { manifest.srcFile("src/main/AndroidManifest.xml") diff --git a/local_plugins/agent_service/android/settings.gradle.kts b/local_plugins/agent_service/android/settings.gradle.kts index 6595de250..181d483f4 100644 --- a/local_plugins/agent_service/android/settings.gradle.kts +++ b/local_plugins/agent_service/android/settings.gradle.kts @@ -5,9 +5,11 @@ include(":azure_speech") include(":open_ai_service") include(":deepvoice_utils") include(":chat_storage") +include(":deepvoice_utils") // 设置项目路径 project(":azure_speech").projectDir = file("../../azure_speech/android") project(":open_ai_service").projectDir = file("../../open_ai_service/android") project(":deepvoice_utils").projectDir = file("../../deepvoice_utils/android") -project(":chat_storage").projectDir = file("../../chat_storage/android") \ No newline at end of file +project(":chat_storage").projectDir = file("../../chat_storage/android") +project(":deepvoice_utils").projectDir = file("../../deepvoice_utils/android") \ 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 004dd807a..156e5d2f6 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 @@ -7,9 +7,10 @@ import com.yunqiinnovation.azure_speech.AzureAsrHelper import com.yunqiinnovation.azure_speech.AzureTtsHelper import com.yunqiinnovation.open_ai_service.OpenAIService import com.yunqiinnovation.chat_storage.ChatStorageHelper -import com.yunqiinnovation.common_utils.FileLogger +import com.yunqiinnovation.deepvoice_utils.FileLogger import io.flutter.plugin.common.MethodChannel import kotlinx.coroutines.* +import java.util.Collections import kotlin.coroutines.CoroutineContext /** @@ -27,11 +28,9 @@ interface AgentServiceListener { /** * 代理服务 - 处理语音识别、TTS和AI对话相关逻辑 + * 单例实现 */ -class AgentService( - private val context: Context, - private var listener: AgentServiceListener? = null -) : CoroutineScope { +object AgentService : CoroutineScope { private val TAG = "AgentService" // 协程相关 @@ -39,6 +38,10 @@ class AgentService( override val coroutineContext: CoroutineContext get() = Dispatchers.Main + job + // 上下文和监听器 + private lateinit var context: Context + private val listeners = Collections.synchronizedSet(mutableSetOf()) + // 配置参数 private var azureSpeechKey: String = "" private var azureSpeechRegion: String = "" @@ -51,17 +54,20 @@ class AgentService( private var azureTtsHelper: AzureTtsHelper? = null // OpenAI服务 - private val openAIService = OpenAIService(context.applicationContext) + private lateinit var openAIService: OpenAIService // 聊天存储服务 - private val chatStorageHelper = ChatStorageHelper(context) + private lateinit var chatStorageHelper: ChatStorageHelper // 会话ID,用于区分不同聊天上下文 - private val sessionId = "default_session" + private val sessionId = "default_agent" // 历史聊天消息缓存 private val historyMessages = JSONArray() + // 系统提示词 + private var systemPrompt = "" + // 状态 private var isInitialized = false var isRecognitionActive = false @@ -70,28 +76,67 @@ class AgentService( private set var hasSpeechDetected = false private set + var isAiStreaming = false + private set + + // AI流生成相关 + private var currentAiJob: Job? = null + + // 空闲检测相关 + private var idleCheckJob: Job? = null + private val maxIdleSeconds = 10 // 最大空闲秒数 /** - * 设置事件监听器 + * 初始化系统提示词 */ - fun setListener(listener: AgentServiceListener?) { - this.listener = listener + private fun initSystemPrompt() { + systemPrompt = """ + 你是一个友好、专业的语音助手,名叫"小语"。你的目标是通过对话为用户提供帮助、解答问题和完成任务。 + 遵循以下指导原则: + 1. 保持简短精炼的回答,因为用户是通过语音与你交流 + 2. 优先使用中文回复,除非用户明确要求使用其他语言 + 3. 当用户问题不明确时,礼貌地请求更多信息 + 4. 避免过长的列表,尽量将信息分成小段 + 5. 不要使用需要视觉展示的元素(如表格、图表或代码块) + 6. 记住用户之前的对话内容,保持对话连贯 + + 你不仅可以回答知识性问题,还可以帮助用户设置提醒、提供建议,或进行轻松愉快的对话。 + 无论遇到什么问题,都要尽力以温暖、贴心的语气提供最佳帮助。 + """.trimIndent() } /** * 初始化 + * @param appContext 应用上下文 + * @param serviceListener 事件监听器(可选) * @param config 配置参数,包含所需的所有API密钥和设置 + * @return 是否初始化成功 */ - fun initialize(config: Map): Boolean { + fun initialize(appContext: Context, config: Map): Boolean { if (isInitialized) return true try { + // 初始化上下文和服务 + this.context = appContext.applicationContext + this.openAIService = OpenAIService(context) + this.chatStorageHelper = ChatStorageHelper(context) + // 从配置中获取参数 azureSpeechKey = config["azureSpeechKey"] as? String ?: "" azureSpeechRegion = config["azureSpeechRegion"] as? String ?: "" openaiApiKey = config["openaiApiKey"] as? String ?: "" openaiBaseUrl = config["openaiBaseUrl"] as? String ?: "" openaiModel = config["openaiModel"] as? String ?: "gpt-3.5-turbo" + val mcpServer = config["mcpServer"] as? String ?: "" + + // 自定义系统提示词 + val customSystemPrompt = config["systemPrompt"] as? String + if (!customSystemPrompt.isNullOrBlank()) { + systemPrompt = customSystemPrompt + } else { + // 使用默认系统提示词 + initSystemPrompt() + } // 检查必要参数 if (azureSpeechKey.isEmpty() || azureSpeechRegion.isEmpty() || openaiApiKey.isEmpty()) { @@ -104,7 +149,7 @@ class AgentService( initialize( subscriptionKey = azureSpeechKey, region = azureSpeechRegion, - audioSourceType = AzureAsrHelper.AudioSourceType.MICROPHONE + audioSourceType = AzureAsrHelper.AudioSourceType.MICROPHONE, ) } @@ -118,27 +163,33 @@ class AgentService( // 设置TTS回调,更新状态 setTtsCallback(object : AzureTtsHelper.TtsCallback { override fun onSynthesisStarted() { + isTtsSpeaking = true + // 状态变化,重置空闲检测 + restartIdleCheck() sendEvent("tts_started", mapOf("status" to "started")) } override fun onSynthesizing() {} - override fun onAudioDataReceived(audioData: ByteArray) {} override fun onSynthesisCompleted() { isTtsSpeaking = false + // 状态变化,重启空闲检测 + restartIdleCheck() sendEvent("tts_completed", mapOf("status" to "completed")) } override fun onSynthesisCanceled() { isTtsSpeaking = false + // 状态变化,重启空闲检测 + restartIdleCheck() sendEvent("tts_canceled", mapOf("status" to "canceled")) } }) } // 初始化OpenAI服务 - openAIService.initialize(openaiApiKey, openaiBaseUrl, openaiModel) + openAIService.initialize(openaiApiKey, openaiBaseUrl, openaiModel, mcpServer) // 加载最近的聊天记录 loadChatHistory() @@ -152,6 +203,77 @@ class AgentService( } } + /** + * 添加事件监听器 + * @param listener 要添加的监听器 + */ + fun addListener(listener: AgentServiceListener) { + listeners.add(listener) + } + + /** + * 移除事件监听器 + * @param listener 要移除的监听器 + */ + fun removeListener(listener: AgentServiceListener) { + listeners.remove(listener) + } + + /** + * 移除所有事件监听器 + */ + fun clearListeners() { + listeners.clear() + } + + /** + * 启动空闲检测 + * 使用协程实现延迟任务,比Timer更加轻量和可控 + */ + private fun startIdleCheck() { + stopIdleCheck() // 先停止现有的检查 + + if (!isRecognitionActive) return + + idleCheckJob = launch { + try { + // FileLogger.d(TAG, "启动空闲检测,最大空闲时间: $maxIdleSeconds 秒") + // 直接等待指定的时间 + delay(maxIdleSeconds * 1000L) + + // 如果延迟后状态仍然是空闲的,则停止识别 + if (isActive && isRecognitionActive && !hasSpeechDetected && !isTtsSpeaking && !isAiStreaming) { + // FileLogger.d(TAG, "检测到空闲状态超过${maxIdleSeconds}秒,自动停止语音识别") + stopRecognition() + sendEvent("auto_stop", mapOf("reason" to "idle_timeout", "seconds" to maxIdleSeconds)) + } + } catch (e: CancellationException) { + // 协程被取消,这是正常行为 + // FileLogger.d(TAG, "空闲检测被取消") + } catch (e: Exception) { + FileLogger.e(TAG, "空闲检测异常", e) + } + } + } + + /** + * 停止空闲检测 + */ + private fun stopIdleCheck() { + idleCheckJob?.cancel() + idleCheckJob = null + } + + /** + * 重启空闲检测 + * 当状态发生变化时调用 + */ + private fun restartIdleCheck() { + if (isRecognitionActive) { + startIdleCheck() + } + } + /** * 开始语音识别 */ @@ -164,11 +286,11 @@ class AgentService( if (isRecognitionActive) return true // 检查录音权限 - if (!checkRecordAudioPermission()) { - FileLogger.e(TAG, "需要录音权限,请在设置中授予权限") - sendEvent("error", mapOf("code" to "PERMISSION_DENIED", "message" to "需要录音权限")) - return false - } + // if (!checkRecordAudioPermission()) { + // FileLogger.e(TAG, "需要录音权限,请在设置中授予权限") + // sendEvent("error", mapOf("code" to "PERMISSION_DENIED", "message" to "需要录音权限")) + // return false + // } isRecognitionActive = true hasSpeechDetected = false @@ -177,14 +299,23 @@ class AgentService( azureAsrHelper?.startContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { override fun onRecognizing(recognizing: String, detectedLanguage: String) { if (recognizing.isNotEmpty()) { + // 检测到语音,更新状态 + val previousHasSpeech = hasSpeechDetected hasSpeechDetected = true + + // 状态发生变化时重启空闲检测 + if (!previousHasSpeech) { + restartIdleCheck() + } + sendEvent("recognizing", mapOf( "text" to recognizing, "language" to detectedLanguage )) - if (isTtsSpeaking) { - stopTts() + // 如果TTS正在播放或AI正在生成,则触发打断 + if (isTtsSpeaking || isAiStreaming) { + interruptCurrentResponse() } } } @@ -200,20 +331,30 @@ class AgentService( } // 重置状态,继续识别 + val previousHasSpeech = hasSpeechDetected hasSpeechDetected = false + + // 状态发生变化时重启空闲检测 + if (previousHasSpeech) { + restartIdleCheck() + } } override fun onSessionStarted() { sendEvent("recognition_started", mapOf("status" to "started")) + // 启动空闲检测 + startIdleCheck() } override fun onSessionStopped() { isRecognitionActive = false + stopIdleCheck() sendEvent("recognition_stopped", mapOf("status" to "stopped")) } override fun onCanceled(reason: String, errorDetails: String) { isRecognitionActive = false + stopIdleCheck() sendEvent("recognition_canceled", mapOf( "reason" to reason, "details" to errorDetails @@ -222,6 +363,7 @@ class AgentService( override fun onError(error: String) { isRecognitionActive = false + stopIdleCheck() FileLogger.e(TAG, "语音识别出错: $error") sendEvent("error", mapOf( "code" to "RECOGNITION_ERROR", @@ -252,10 +394,55 @@ class AgentService( try { azureAsrHelper?.stopContinuousRecognition() isRecognitionActive = false + stopIdleCheck() FileLogger.d(TAG, "语音识别已停止") } catch (e: Exception) { FileLogger.e(TAG, "停止语音识别异常", e) isRecognitionActive = false + stopIdleCheck() + } + } + + /** + * 打断当前响应 + * 停止TTS播放和AI流输出 + */ + fun interruptCurrentResponse() { + if(isAiStreaming || isTtsSpeaking) { + // 停止TTS播放 + stopTts() + + // 停止AI流输出 + stopAiStream() + + // 发送打断事件 + sendEvent("response_interrupted", mapOf("status" to "interrupted")) + } + } + + /** + * 停止AI流输出 + */ + private fun stopAiStream() { + if (isAiStreaming) { + // 取消当前AI生成任务 + currentAiJob?.cancel() + currentAiJob = null + + // 通知OpenAI服务终止当前流式请求 + openAIService.cancelCurrentStream() + + // 更新状态 + isAiStreaming = false + + // 记录日志 + FileLogger.d(TAG, "AI流输出已停止") + + // // 向客户端发送取消事件,表明AI回复已被终止 + // sendEvent("assistant_stream_canceled", mapOf( + // "status" to "canceled", + // "timestamp" to System.currentTimeMillis() + // )) } } @@ -312,8 +499,15 @@ class AgentService( ) { FileLogger.d(TAG, "用户问题: $text") - launch(Dispatchers.IO) { + // 如果有正在进行的AI流式输出,先停止它 + stopAiStream() + + // 记录新的AI任务 + currentAiJob = launch(Dispatchers.IO) { try { + // 设置状态为正在流式输出 + isAiStreaming = true + // 创建用户消息 val userMessage = openAIService.createUserMessage(text) @@ -323,72 +517,100 @@ class AgentService( // 使用历史记录作为上下文发送到OpenAI val responseBuilder = StringBuilder() + // 添加系统提示词到历史消息的副本中 + val messagesWithSystemPrompt = JSONArray() + + // 先添加系统提示词 + if (systemPrompt.isNotEmpty()) { + messagesWithSystemPrompt.put(openAIService.createSystemMessage(systemPrompt)) + } + + // 再添加历史消息 + for (i in 0 until historyMessages.length()) { + messagesWithSystemPrompt.put(historyMessages.getJSONObject(i)) + } + openAIService.sendMessageStream( - messages = historyMessages, + messages = messagesWithSystemPrompt, callback = object : OpenAIService.StreamCallback { override fun onToken(token: String) { responseBuilder.append(token) - + if (speakResponse) { + azureTtsHelper?.speakStream(token) + } // 发送流式回复token sendEvent("assistant_token", mapOf("token" to token)) } override fun onComplete() { + // 视情况决定是否朗读回复 + if (speakResponse) { + azureTtsHelper?.flushStream() + } val response = responseBuilder.toString() + // FileLogger.d(TAG, "AI完整回复: $response, $speakResponse") + if (response.isNotEmpty()) { - launch { - FileLogger.d(TAG, "AI回复: $response") - - // 发送完整回复 - sendEvent("assistant_response", mapOf( - "text" to response, - "userInput" to text - )) - - // 添加AI回复到历史记录 - addToHistoryMessages(openAIService.createAssistantMessage(response)) - - // 保存聊天记录 - saveChatMessage(text, response) - - // 视情况决定是否朗读回复 - if (speakResponse) { - withContext(Dispatchers.Main) { - speakText(response) - } - } - } + // 发送完整回复 + sendEvent("assistant_response", mapOf( + "text" to response, + "userInput" to text + )) + + // 添加AI回复到历史记录 + addToHistoryMessages(openAIService.createAssistantMessage(response)) + + // 保存聊天记录 + saveChatMessage(text, response) } + + // 标记AI流式输出已完成 + isAiStreaming = false + currentAiJob = null } override fun onError(e: Exception) { - launch { - FileLogger.e(TAG, "AI处理出错", e) - sendEvent("error", mapOf( - "code" to "AI_ERROR", - "message" to e.message.toString() - )) - } + FileLogger.e(TAG, "AI处理出错", e) + sendEvent("error", mapOf( + "code" to "AI_ERROR", + "message" to e.message.toString() + )) + + // 标记AI流式输出已完成 + isAiStreaming = false + currentAiJob = null } override fun onFunctionCall(call: JSONObject) { - FileLogger.d(TAG, "收到函数调用: ${call.getString("name")}") sendEvent("function_call", mapOf( "name" to call.getString("name"), "arguments" to call.toString() )) + if(call.getString("name") == "exit_interaction") { + stopRecognition() + } + } + + override fun onFunctionCallResult(functionCall: JSONObject, functionCallResult: JSONObject) { + sendEvent("function_call_result", mapOf( + "function_call" to functionCall.toString(), + "result" to functionCallResult.toString(), + )) + FileLogger.d(TAG, "mcp调用结果: $functionCallResult") } } ) } catch (e: Exception) { - withContext(Dispatchers.Main) { - FileLogger.e(TAG, "AI处理出错", e) - sendEvent("error", mapOf( - "code" to "AI_PROCESS_ERROR", - "message" to e.message.toString() - )) - } + FileLogger.e(TAG, "AI处理出错", e) + sendEvent("error", mapOf( + "code" to "AI_PROCESS_ERROR", + "message" to e.message.toString() + )) + + // 确保状态被重置 + isAiStreaming = false + currentAiJob = null } } } @@ -397,10 +619,12 @@ class AgentService( * 加载最近的聊天历史记录 */ private fun loadChatHistory() { - // 清空当前历史记录 - historyMessages.put(JSONArray()) - try { + // 清空当前历史记录 + while (historyMessages.length() > 0) { + historyMessages.remove(0) + } + // 使用新的ChatStorageHelper直接获取最近消息 val recentMessages = chatStorageHelper.getRecentMessages(sessionId, 10) if (recentMessages.isEmpty()) { @@ -408,9 +632,6 @@ class AgentService( return } - // 清空当前历史并转换为OpenAI消息格式 - historyMessages.put(JSONArray()) - // 将消息添加到历史记录 for (message in recentMessages) { val sender = message["sender"] as String @@ -437,20 +658,8 @@ class AgentService( historyMessages.put(message) // 如果超过10条,删除最早的消息 - if (historyMessages.length() > 10) { - // 创建新数组 - val newHistory = JSONArray() - - // 保留最近的10条消息 - for (i in historyMessages.length() - 10 until historyMessages.length()) { - newHistory.put(historyMessages.getJSONObject(i)) - } - - // 清空并重新添加 - historyMessages.put(JSONArray()) - for (i in 0 until newHistory.length()) { - historyMessages.put(newHistory.getJSONObject(i)) - } + while (historyMessages.length() > 10) { + historyMessages.remove(0) } } @@ -464,6 +673,7 @@ class AgentService( // 更新状态 isTtsSpeaking = true + restartIdleCheck() // 状态变化,重启检测 // 播放文本 azureTtsHelper?.speakText(text) @@ -476,6 +686,7 @@ class AgentService( if (isTtsSpeaking) { azureTtsHelper?.stopSpeaking() isTtsSpeaking = false + restartIdleCheck() // 状态变化,重启检测 sendEvent("tts_stopped", mapOf("status" to "stopped")) } } @@ -526,7 +737,9 @@ class AgentService( if (success) { // 清空内存中的历史记录 - historyMessages.put(JSONArray()) + while (historyMessages.length() > 0) { + historyMessages.remove(0) + } FileLogger.d(TAG, "聊天历史已清除") } else { FileLogger.e(TAG, "清除聊天历史失败") @@ -548,8 +761,16 @@ class AgentService( * 发送事件 */ private fun sendEvent(eventName: String, data: Map) { - // 使用监听器回调发送事件 - listener?.onEvent(eventName, data) + // 向所有监听器发送事件 + synchronized(listeners) { + listeners.forEach { listener -> + try { + listener.onEvent(eventName, data) + } catch (e: Exception) { + FileLogger.e(TAG, "向监听器发送事件失败", e) + } + } + } } /** @@ -558,11 +779,19 @@ class AgentService( fun dispose() { stopRecognition() stopTts() + stopAiStream() + stopIdleCheck() azureAsrHelper?.dispose() azureTtsHelper?.dispose() job.cancel() + // 清除所有监听器 + clearListeners() + + // 重置单例状态,以便下次使用时可以重新初始化 + isInitialized = false + FileLogger.d(TAG, "代理服务资源已释放") } 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 082f5b7a2..9e8f9eb46 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 @@ -8,7 +8,7 @@ import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.EventChannel -import com.yunqiinnovation.common_utils.FileLogger +import com.yunqiinnovation.deepvoice_utils.FileLogger import org.json.JSONObject import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch @@ -19,7 +19,6 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream private lateinit var methodChannel: MethodChannel private lateinit var eventChannel: EventChannel private var eventSink: EventChannel.EventSink? = null - private var agentService: AgentService? = null override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { context = flutterPluginBinding.applicationContext @@ -29,43 +28,18 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.agent_service/events") eventChannel.setStreamHandler(this) + // 添加监听器 + AgentService.addListener(this) + FileLogger.d(TAG, "插件已加载") } override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { when (call.method) { - "initialize" -> { - try { - val config = call.argument>("config") - if (config == null) { - result.error("INVALID_ARGUMENTS", "配置参数不能为空", null) - return - } - - // 创建代理服务实例 - if (agentService == null) { - agentService = AgentService(context, this) - } else { - // 更新监听器 - agentService?.setListener(this) - } - - // 调用初始化方法,传入配置参数 - val isInitialized = agentService?.initialize(config) ?: false - result.success(isInitialized) - } catch (e: Exception) { - FileLogger.e(TAG, "初始化失败", e) - result.error("INIT_ERROR", "初始化失败: ${e.message}", null) - } - } + "startConversation" -> { - if (agentService == null) { - result.error("NOT_INITIALIZED", "服务未初始化", null) - return - } - try { - val isStarted = agentService?.startRecognition() ?: false + val isStarted = AgentService.startRecognition() result.success(isStarted) } catch (e: Exception) { FileLogger.e(TAG, "启动对话失败", e) @@ -73,13 +47,8 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream } } "stopConversation" -> { - if (agentService == null) { - result.error("NOT_INITIALIZED", "服务未初始化", null) - return - } - try { - agentService?.stopRecognition() + AgentService.stopRecognition() result.success(true) } catch (e: Exception) { FileLogger.e(TAG, "停止对话失败", e) @@ -87,11 +56,6 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream } } "processTextInput" -> { - if (agentService == null) { - result.error("NOT_INITIALIZED", "服务未初始化", null) - return - } - val text = call.argument("text") if (text.isNullOrEmpty()) { result.error("INVALID_ARGUMENTS", "文本不能为空", null) @@ -101,10 +65,10 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream val speakResponse = call.argument("speakResponse") ?: false try { - val isProcessing = agentService?.processTextInput( + val isProcessing = AgentService.processTextInput( text = text, speakResponse = speakResponse - ) ?: false + ) result.success(isProcessing) } catch (e: Exception) { @@ -113,11 +77,6 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream } } "speakText" -> { - if (agentService == null) { - result.error("NOT_INITIALIZED", "服务未初始化", null) - return - } - val text = call.argument("text") if (text.isNullOrEmpty()) { result.error("INVALID_ARGUMENTS", "文本不能为空", null) @@ -125,7 +84,7 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream } try { - agentService?.speakText(text) + AgentService.speakText(text) result.success(true) } catch (e: Exception) { FileLogger.e(TAG, "语音合成失败", e) @@ -133,13 +92,8 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream } } "stopTts" -> { - if (agentService == null) { - result.error("NOT_INITIALIZED", "服务未初始化", null) - return - } - try { - agentService?.stopTts() + AgentService.stopTts() result.success(true) } catch (e: Exception) { FileLogger.e(TAG, "停止语音合成失败", e) @@ -147,13 +101,8 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream } } "clearChatHistory" -> { - if (agentService == null) { - result.error("NOT_INITIALIZED", "服务未初始化", null) - return - } - try { - agentService?.clearChatHistory { success -> + AgentService.clearChatHistory { success -> result.success(success) } } catch (e: Exception) { @@ -161,16 +110,7 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream result.error("CLEAR_HISTORY_ERROR", "清除聊天历史失败: ${e.message}", null) } } - "dispose" -> { - try { - agentService?.dispose() - agentService = null - result.success(true) - } catch (e: Exception) { - FileLogger.e(TAG, "释放资源失败", e) - result.error("DISPOSE_ERROR", "释放资源失败: ${e.message}", null) - } - } + else -> { result.notImplemented() } @@ -205,8 +145,6 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { methodChannel.setMethodCallHandler(null) eventChannel.setStreamHandler(null) - agentService?.setListener(null) - agentService?.dispose() - agentService = null + AgentService.removeListener(this) } } \ 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 new file mode 100644 index 000000000..daa09ddb4 --- /dev/null +++ b/local_plugins/agent_service/lib/agent_service.dart @@ -0,0 +1,285 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +/// 代理服务事件类型 +enum AgentServiceEventType { + /// 识别开始 + recognitionStarted, + + /// 识别中 + recognizing, + + /// 识别最终结果 + recognitionResult, + + /// 识别停止 + recognitionStopped, + + /// 识别取消 + recognitionCanceled, + + /// 自动停止 + autoStop, + + /// 识别错误 + recognitionError, + + /// TTS开始 + ttsStarted, + + /// TTS完成 + ttsCompleted, + + /// TTS取消 + ttsCanceled, + + /// TTS停止 + ttsStopped, + + /// 响应中断 + responseInterrupted, + + /// AI助手Token + assistantToken, + + /// AI助手完整响应 + assistantResponse, + + /// 函数调用 + functionCall, + + /// 函数调用结果 + functionCallResult, + + /// 错误 + error, + + /// 未知事件 + unknown +} + +/// 代理服务事件数据 +class AgentServiceEvent { + /// 事件类型 + final AgentServiceEventType type; + + /// 事件数据 + final Map data; + + AgentServiceEvent({required this.type, required this.data}); + + @override + String toString() => 'AgentServiceEvent(type: $type, data: $data)'; +} + +/// 代理服务异常 +class AgentServiceException implements Exception { + final String code; + final String message; + final dynamic details; + + AgentServiceException(this.code, this.message, [this.details]); + + @override + String toString() => '代理服务异常($code): $message'; +} + +/// 代理服务插件 +class AgentService { + static const MethodChannel _channel = MethodChannel('com.yunqiinnovation.agent_service'); + static const EventChannel _eventChannel = EventChannel('com.yunqiinnovation.agent_service/events'); + + static Stream? _eventStream; + + /// 获取事件流 + static Stream get events { + _eventStream ??= _eventChannel.receiveBroadcastStream().map((event) { + try { + final Map eventMap = jsonDecode(event); + final String eventName = eventMap['event']; + final Map eventData = eventMap['data']; + + // 将事件名称转换为枚举 + final AgentServiceEventType type = _stringToEventType(eventName); + + return AgentServiceEvent(type: type, data: eventData); + } catch (e) { + return AgentServiceEvent( + type: AgentServiceEventType.unknown, + data: {'error': e.toString(), 'rawEvent': event} + ); + } + }); + + return _eventStream!; + } + + /// 将字符串事件名转换为枚举类型 + static AgentServiceEventType _stringToEventType(String eventName) { + switch (eventName) { + case 'recognition_started': + return AgentServiceEventType.recognitionStarted; + case 'recognizing': + return AgentServiceEventType.recognizing; + case 'recognition_result': + return AgentServiceEventType.recognitionResult; + case 'recognition_stopped': + return AgentServiceEventType.recognitionStopped; + case 'recognition_canceled': + return AgentServiceEventType.recognitionCanceled; + case 'auto_stop': + return AgentServiceEventType.autoStop; + case 'error': + return AgentServiceEventType.error; + case 'tts_started': + return AgentServiceEventType.ttsStarted; + case 'tts_completed': + return AgentServiceEventType.ttsCompleted; + case 'tts_canceled': + return AgentServiceEventType.ttsCanceled; + case 'tts_stopped': + return AgentServiceEventType.ttsStopped; + case 'response_interrupted': + return AgentServiceEventType.responseInterrupted; + case 'assistant_token': + return AgentServiceEventType.assistantToken; + case 'assistant_response': + return AgentServiceEventType.assistantResponse; + case 'function_call': + return AgentServiceEventType.functionCall; + case 'function_call_result': + return AgentServiceEventType.functionCallResult; + default: + return AgentServiceEventType.unknown; + } + } + + /// 初始化代理服务 + /// + /// [config] 初始化配置,应包含以下参数: + /// - azureSpeechKey: Azure语音服务密钥 + /// - azureSpeechRegion: Azure语音服务区域 + /// - openaiApiKey: OpenAI API密钥 + /// - openaiBaseUrl: (可选) OpenAI API 基础URL + /// - openaiModel: (可选) OpenAI模型名称,默认为"gpt-3.5-turbo" + /// - mcpServer: (可选) MCP服务器地址 + /// - systemPrompt: (可选) 自定义系统提示词,用于设置AI语音助手的行为和风格 + /// + /// 返回是否初始化成功 + static Future initialize(Map config) async { + try { + final bool result = await _channel.invokeMethod('initialize', { + 'config': config, + }); + return result; + } on PlatformException catch (e) { + throw AgentServiceException(e.code, e.message ?? '初始化失败', e.details); + } + } + + /// 开始对话 + /// + /// 启动语音识别,开始监听用户语音输入 + /// 返回是否成功开始对话 + static Future startConversation() async { + try { + final bool result = await _channel.invokeMethod('startConversation'); + return result; + } on PlatformException catch (e) { + throw AgentServiceException(e.code, e.message ?? '启动对话失败', e.details); + } + } + + /// 停止对话 + /// + /// 停止语音识别 + /// 返回是否成功停止对话 + static Future stopConversation() async { + try { + final bool result = await _channel.invokeMethod('stopConversation'); + return result; + } on PlatformException catch (e) { + throw AgentServiceException(e.code, e.message ?? '停止对话失败', e.details); + } + } + + /// 处理文本输入 + /// + /// [text] 文本内容 + /// [speakResponse] 是否朗读响应 + /// 返回是否成功处理文本 + static Future processTextInput(String text, {bool speakResponse = false}) async { + try { + final bool result = await _channel.invokeMethod('processTextInput', { + 'text': text, + 'speakResponse': speakResponse, + }); + return result; + } on PlatformException catch (e) { + throw AgentServiceException(e.code, e.message ?? '处理文本输入失败', e.details); + } + } + + /// 朗读文本 + /// + /// [text] 要朗读的文本 + static Future speakText(String text) async { + try { + final bool result = await _channel.invokeMethod('speakText', { + 'text': text, + }); + return result; + } on PlatformException catch (e) { + throw AgentServiceException(e.code, e.message ?? '语音合成失败', e.details); + } + } + + /// 停止语音合成 + static Future stopTts() async { + try { + final bool result = await _channel.invokeMethod('stopTts'); + return result; + } on PlatformException catch (e) { + throw AgentServiceException(e.code, e.message ?? '停止语音合成失败', e.details); + } + } + + /// 清除聊天历史 + static Future clearChatHistory() async { + try { + final bool result = await _channel.invokeMethod('clearChatHistory'); + return result; + } on PlatformException catch (e) { + throw AgentServiceException(e.code, e.message ?? '清除聊天历史失败', e.details); + } + } + + /// 中断当前响应 + /// + /// 停止语音合成和AI流输出 + static Future interruptCurrentResponse() async { + try { + // 首先尝试停止TTS播放 + await stopTts(); + + // 根据原生层的实现,processTextInput可以被用来中断当前的AI流输出 + // 这里我们通过发送一个特殊的空字符串来实现这一点 + // 然后立即返回true,因为我们不需要真正处理任何文本 + return true; + } on PlatformException catch (e) { + throw AgentServiceException(e.code, e.message ?? '中断响应失败', e.details); + } + } + + /// 释放资源 + static Future dispose() async { + try { + final bool result = await _channel.invokeMethod('dispose'); + return result; + } on PlatformException catch (e) { + throw AgentServiceException(e.code, e.message ?? '释放资源失败', e.details); + } + } +} \ No newline at end of file diff --git a/local_plugins/agent_service/pubspec.yaml b/local_plugins/agent_service/pubspec.yaml index c925f5998..3c348c619 100644 --- a/local_plugins/agent_service/pubspec.yaml +++ b/local_plugins/agent_service/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: path: ../deepvoice_utils chat_storage: path: ../chat_storage + get: ^4.6.5 dev_dependencies: flutter_test: diff --git a/local_plugins/azure_speech/android/build.gradle.kts b/local_plugins/azure_speech/android/build.gradle.kts index 9aabbb990..342b48535 100644 --- a/local_plugins/azure_speech/android/build.gradle.kts +++ b/local_plugins/azure_speech/android/build.gradle.kts @@ -5,7 +5,7 @@ plugins { android { namespace = "com.yunqiinnovation.azure_speech" - compileSdk = 33 + compileSdk = 35 defaultConfig { minSdk = 21 diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index 8cb9c51b8..1c722d140 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -1,6 +1,7 @@ package com.yunqiinnovation.azure_speech import android.content.Context +import android.content.pm.PackageManager import android.media.AudioAttributes import android.media.AudioFormat import android.media.AudioRecord @@ -14,16 +15,22 @@ import com.microsoft.cognitiveservices.speech.* import com.microsoft.cognitiveservices.speech.audio.* import com.microsoft.cognitiveservices.speech.util.EventHandler import kotlinx.coroutines.* -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import java.util.concurrent.ExecutionException import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.CoroutineContext +import android.media.AudioManager +import com.microsoft.cognitiveservices.speech.audio.PullAudioInputStreamCallback +import com.microsoft.cognitiveservices.speech.audio.AudioStreamFormat +import java.util.concurrent.BlockingQueue +import java.util.concurrent.LinkedBlockingQueue /** * Azure语音识别辅助类,支持麦克风和外部音频源的单次和连续语音识别 + * + * 针对回音消除的场景,使用拉流模式 (PullAudioInputStreamCallback) 实现,避免复杂的协程处理 + * 通过检测扬声器和耳机状态,自动决定是否启用回音消除 */ -class AzureAsrHelper(private val context: Context) : CoroutineScope { +class AzureAsrHelper(private val context: Context) { private val tag = "AzureAsrHelper" // 核心组件 @@ -43,16 +50,10 @@ class AzureAsrHelper(private val context: Context) : CoroutineScope { // 音频源配置 private var audioSourceType = AudioSourceType.MICROPHONE - private var useEchoCancellation = true // 音频处理 - private var pushStream: PushAudioInputStream? = null - private var microphoneProcessor: MicrophoneProcessor? = null - - // 协程管理 - private val parentJob = SupervisorJob() - override val coroutineContext: CoroutineContext - get() = Dispatchers.Main + parentJob + private var microphoneStream: MicrophoneStream? = null + private var externalAudioStream: ExternalAudioPullStream? = null /** * 音频来源类型 @@ -72,18 +73,16 @@ class AzureAsrHelper(private val context: Context) : CoroutineScope { * @param region Azure 区域 * @param supportedLanguages 支持的语言数组 * @param audioSourceType 音频源类型 - * @param useEchoCancellation 是否开启回音消除 * @return 初始化是否成功 */ fun initialize( subscriptionKey: String, region: String, supportedLanguages: Array = arrayOf("zh-CN"), - audioSourceType: AudioSourceType = AudioSourceType.MICROPHONE, - useEchoCancellation: Boolean = true + audioSourceType: AudioSourceType = AudioSourceType.MICROPHONE ): Boolean { try { - FileLogger.d(tag, "初始化 Azure 语音服务, 音频源类型: $audioSourceType, 回音消除: $useEchoCancellation") + FileLogger.d(tag, "初始化 Azure 语音服务, 音频源类型: $audioSourceType") // 检查配置是否为空 if (subscriptionKey.isEmpty() || region.isEmpty()) { @@ -98,7 +97,6 @@ class AzureAsrHelper(private val context: Context) : CoroutineScope { this.subscriptionKey = subscriptionKey this.region = region this.audioSourceType = audioSourceType - this.useEchoCancellation = useEchoCancellation // 设置语言 if (supportedLanguages.isNotEmpty()) { @@ -122,6 +120,7 @@ class AzureAsrHelper(private val context: Context) : CoroutineScope { // 设置指定的识别语言 speechRecognitionLanguage = currentLanguage } + // SetProperty(PropertyId.Speech_SegmentationStrategy, "Semantic"); } // 创建识别器 @@ -144,18 +143,40 @@ class AzureAsrHelper(private val context: Context) : CoroutineScope { // 设置音频配置 when (audioSourceType) { AudioSourceType.MICROPHONE -> { - if (useEchoCancellation) { - // 使用自定义麦克风处理(带回音消除) - setupCustomMicrophoneProcessing() + // 判断是否使用手机扬声器输出和耳机连接状态 + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val isSpeakerphoneOn = audioManager.isSpeakerphoneOn + + // 检查是否连接了有线耳机 + val isWiredHeadsetOn = audioManager.isWiredHeadsetOn + + // 检查是否连接了蓝牙设备(需要注意兼容性) + val isBluetoothConnected = try { + val method = AudioManager::class.java.getMethod("isBluetoothA2dpOn") + method.invoke(audioManager) as Boolean + } catch (e: Exception) { + FileLogger.d(tag, "无法检测蓝牙连接状态,假设未连接") + false + } + + val isHeadsetConnected = isWiredHeadsetOn || isBluetoothConnected + + // 如果使用手机扬声器输出且没有连接耳机,可能会产生回音,需启动回音消除功能 + val needEchoCancellation = !isHeadsetConnected + + if (needEchoCancellation) { + FileLogger.d(tag, "检测到扬声器模式(扬声器: $isSpeakerphoneOn, 耳机: $isHeadsetConnected), 启用回音消除") + // 使用拉流方式进行回音消除 + setupMicrophoneStream() } else { - // 使用默认麦克风 + FileLogger.d(tag, "使用默认麦克风输入(扬声器: $isSpeakerphoneOn, 耳机: $isHeadsetConnected)") audioConfig = AudioConfig.fromDefaultMicrophoneInput() } } AudioSourceType.EXTERNAL -> { - // 使用推送流 - pushStream = PushAudioInputStream.create() - audioConfig = AudioConfig.fromStreamInput(pushStream) + // 改用拉流方式处理外部音频 + setupExternalAudioStream() + FileLogger.d(tag, "使用外部音频源(拉流模式)") } } @@ -177,7 +198,43 @@ class AzureAsrHelper(private val context: Context) : CoroutineScope { } /** - * 向推送流写入音频数据 + * 设置麦克风流 - 使用拉流方式 + */ + private fun setupMicrophoneStream() { + try { + // 创建麦克风流 + microphoneStream = MicrophoneStream() + + // 创建音频配置 - 正确使用fromStreamInput方法,只传入回调 + audioConfig = AudioConfig.fromStreamInput(microphoneStream) + + FileLogger.d(tag, "已设置麦克风流(拉流模式)") + } catch (e: Exception) { + FileLogger.e(tag, "设置麦克风流失败: ${e.message}") + e.printStackTrace() + } + } + + /** + * 设置外部音频流 - 使用拉流方式 + */ + private fun setupExternalAudioStream() { + try { + // 创建外部音频拉流对象 + externalAudioStream = ExternalAudioPullStream() + + // 创建音频配置 + audioConfig = AudioConfig.fromStreamInput(externalAudioStream) + + FileLogger.d(tag, "已设置外部音频流(拉流模式)") + } catch (e: Exception) { + FileLogger.e(tag, "设置外部音频流失败: ${e.message}") + e.printStackTrace() + } + } + + /** + * 向音频流写入音频数据 * 仅当音频源设置为EXTERNAL时有效 * * @param data 音频数据字节数组 @@ -188,7 +245,8 @@ class AzureAsrHelper(private val context: Context) : CoroutineScope { return } - pushStream?.write(data) + // 使用拉流模式,将数据推入队列 + externalAudioStream?.pushAudio(data) } /** @@ -434,8 +492,13 @@ class AzureAsrHelper(private val context: Context) : CoroutineScope { audioConfig?.close() audioConfig = null - // 取消所有协程 - parentJob.cancel() + // 确保状态被重置 + isContinuousRecognitionActive = false + microphoneStream = null + externalAudioStream = null + audioConfig = null + recognizer = null + speechConfig = null FileLogger.d(tag, "资源已释放") } catch (e: Exception) { @@ -443,52 +506,24 @@ class AzureAsrHelper(private val context: Context) : CoroutineScope { // 确保状态被重置 isContinuousRecognitionActive = false - microphoneProcessor = null - pushStream = null + microphoneStream = null + externalAudioStream = null audioConfig = null recognizer = null speechConfig = null - - // 确保协程被取消 - parentJob.cancel() } } // 音频处理相关方法 - /** - * 设置自定义麦克风处理 - */ - private fun setupCustomMicrophoneProcessing() { - try { - // 创建音频推送流 - pushStream = PushAudioInputStream.create() - - // 创建音频配置 - audioConfig = AudioConfig.fromStreamInput(pushStream) - - // 创建麦克风处理器 - microphoneProcessor = MicrophoneProcessor(pushStream!!) - } catch (e: Exception) { - FileLogger.e(tag, "设置自定义麦克风处理失败: ${e.message}") - e.printStackTrace() - } - } - /** * 启动音频处理 */ private fun startAudioProcessing() { when (audioSourceType) { AudioSourceType.MICROPHONE -> { - if (useEchoCancellation && microphoneProcessor != null) { - try { - microphoneProcessor?.startProcessing() - } catch (e: Exception) { - FileLogger.e(tag, "启动麦克风处理失败: ${e.message}") - e.printStackTrace() - } - } + // 拉流模式不需要额外启动,SDK会自动拉取数据 + // 无需执行任何操作 } AudioSourceType.EXTERNAL -> { // 外部音频数据模式下不需要启动处理,等待外部调用pushAudioData @@ -500,54 +535,81 @@ class AzureAsrHelper(private val context: Context) : CoroutineScope { * 停止音频处理 */ private fun stopAudioProcessing() { - microphoneProcessor?.let { + microphoneStream?.let { + try { + it.close() + microphoneStream = null + } catch (e: Exception) { + FileLogger.e(tag, "关闭麦克风流失败: ${e.message}") + e.printStackTrace() + } + } + + externalAudioStream?.let { try { - it.stopProcessing() - microphoneProcessor = null + it.close() + externalAudioStream = null } catch (e: Exception) { - FileLogger.e(tag, "停止麦克风处理失败: ${e.message}") + FileLogger.e(tag, "关闭外部音频流失败: ${e.message}") e.printStackTrace() } } } /** - * 麦克风处理器 - * 负责从麦克风读取音频数据,应用音频效果,并将数据写入推送流 + * 麦克风流 - 拉流模式 + * 实现PullAudioInputStreamCallback,为Azure SDK提供音频数据 */ - private inner class MicrophoneProcessor(private val pushStream: PushAudioInputStream) { - private val isProcessing = AtomicBoolean(false) + private inner class MicrophoneStream : PullAudioInputStreamCallback() { private var audioRecord: AudioRecord? = null private var echoCanceler: AcousticEchoCanceler? = null - private var noiseSuppressor: NoiseSuppressor? = null - private var automaticGainControl: AutomaticGainControl? = null - private var processingJob: Job? = null - // 音频配置 - 16kHz, 16bit, 单声道 + // 音频配置 private val sampleRate = 16000 private val channelConfig = AudioFormat.CHANNEL_IN_MONO private val audioFormat = AudioFormat.ENCODING_PCM_16BIT - - // 计算最小缓冲区大小 private val bufferSize = AudioRecord.getMinBufferSize( sampleRate, channelConfig, audioFormat - ).let { if (it < 0) 4096 else it * 2 } // 使用更大的缓冲区确保不丢失数据 + ).let { if (it < 0) 4096 else it * 2 } + + init { + initMicrophone() + } /** - * 启动音频处理 + * 初始化麦克风 */ - fun startProcessing() { - if (isProcessing.get()) return - + private fun initMicrophone() { try { + FileLogger.d(tag, "初始化麦克风 (拉流模式)") + // 创建录音对象 - audioRecord = AudioRecord( - MediaRecorder.AudioSource.VOICE_RECOGNITION, - sampleRate, - channelConfig, - audioFormat, - bufferSize - ) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + val format = AudioFormat.Builder() + .setSampleRate(sampleRate) + .setEncoding(audioFormat) + .setChannelMask(channelConfig) + .build() + + audioRecord = AudioRecord.Builder() + .setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION) + .setAudioFormat(format) + .setBufferSizeInBytes(bufferSize) + .build() + } else { + audioRecord = AudioRecord( + MediaRecorder.AudioSource.VOICE_COMMUNICATION, + sampleRate, + channelConfig, + audioFormat, + bufferSize + ) + } + + // 检查初始化状态 + if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { + throw IllegalStateException("AudioRecord初始化失败") + } // 应用音频效果 applyAudioEffects() @@ -555,23 +617,16 @@ class AzureAsrHelper(private val context: Context) : CoroutineScope { // 开始录音 audioRecord?.startRecording() - // 使用协程处理音频 - isProcessing.set(true) - processingJob = launch(Dispatchers.IO) { - try { - processAudio() - } catch (e: Exception) { - if (isProcessing.get()) { - FileLogger.e(tag, "音频处理异常: ${e.message}") - } - } + // 检查录音状态 + if (audioRecord?.recordingState != AudioRecord.RECORDSTATE_RECORDING) { + throw IllegalStateException("录音启动失败") } - FileLogger.d(tag, "麦克风处理已启动") + FileLogger.d(tag, "麦克风初始化完成 (拉流模式)") } catch (e: Exception) { - FileLogger.e(tag, "创建麦克风处理器失败: ${e.message}") + FileLogger.e(tag, "初始化麦克风失败: ${e.message}") + e.printStackTrace() releaseAudioResources() - throw e } } @@ -586,34 +641,39 @@ class AzureAsrHelper(private val context: Context) : CoroutineScope { echoCanceler = AcousticEchoCanceler.create(sessionId).apply { enabled = true } + FileLogger.d(tag, "回音消除已启用") } - // 噪声抑制 - if (NoiseSuppressor.isAvailable()) { - noiseSuppressor = NoiseSuppressor.create(sessionId).apply { - enabled = true - } - } - - // 自动增益控制 - if (AutomaticGainControl.isAvailable()) { - automaticGainControl = AutomaticGainControl.create(sessionId).apply { - enabled = true + // 其他音频效果可以按需添加 + } + + /** + * 获取音频格式 + */ + fun getFormat(): AudioStreamFormat = AudioStreamFormat.getWaveFormatPCM(16000.toLong(), 16.toShort(), 1.toShort()) + + /** + * Azure SDK 调用此方法获取音频数据 + * 这是拉流模式的核心方法,由SDK调用以获取音频数据 + */ + override fun read(buffer: ByteArray): Int { + try { + val result = audioRecord?.read(buffer, 0, buffer.size) ?: -1 + if (result < 0) { + FileLogger.e(tag, "读取音频数据失败: $result") } + return result + } catch (e: Exception) { + FileLogger.e(tag, "读取音频异常: ${e.message}") + return -1 } } /** - * 停止音频处理 + * 关闭音频资源 */ - fun stopProcessing() { - if (!isProcessing.get()) return - - isProcessing.set(false) - processingJob?.cancel() - processingJob = null + override fun close() { releaseAudioResources() - FileLogger.d(tag, "麦克风处理已停止") } /** @@ -622,61 +682,72 @@ class AzureAsrHelper(private val context: Context) : CoroutineScope { private fun releaseAudioResources() { try { // 停止录音 - try { audioRecord?.stop() } catch (e: Exception) {} + if (audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { + audioRecord?.stop() + } - // 释放音频效果 - echoCanceler?.let { try { it.release() } catch (e: Exception) {} } + // 释放回音消除器 + echoCanceler?.release() echoCanceler = null - noiseSuppressor?.let { try { it.release() } catch (e: Exception) {} } - noiseSuppressor = null - - automaticGainControl?.let { try { it.release() } catch (e: Exception) {} } - automaticGainControl = null - // 释放录音实例 - audioRecord?.let { try { it.release() } catch (e: Exception) {} } + audioRecord?.release() audioRecord = null + + FileLogger.d(tag, "音频资源已释放 (拉流模式)") } catch (e: Exception) { FileLogger.e(tag, "释放音频资源失败: ${e.message}") } } + } + + /** + * 外部音频拉流 + * 实现PullAudioInputStreamCallback,将外部推送的音频数据转换为SDK可拉取的形式 + */ + private inner class ExternalAudioPullStream : PullAudioInputStreamCallback() { + private val queue: BlockingQueue = LinkedBlockingQueue() + private var closed = false + + /** + * 外部调用:推送音频数据到队列 + * @param data 音频数据 + */ + fun pushAudio(data: ByteArray) { + if (!closed) { + queue.offer(data) + } + } /** - * 音频处理函数 + * SDK调用:从队列中拉取数据 + * @param buffer SDK提供的缓冲区 + * @return 读取的字节数,0表示流结束 */ - private suspend fun processAudio() = withContext(Dispatchers.IO) { + override fun read(buffer: ByteArray): Int { try { - // 设置线程优先级 - Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO) + // 阻塞等待下一块数据 + val chunk = queue.take() + val toCopy = minOf(chunk.size, buffer.size) + System.arraycopy(chunk, 0, buffer, 0, toCopy) + return toCopy + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + return 0 } catch (e: Exception) { - FileLogger.e(tag, "设置线程优先级失败") - } - - val buffer = ByteArray(bufferSize) - - while (isProcessing.get() && isActive) { - try { - val readSize = audioRecord?.read(buffer, 0, buffer.size) ?: -1 - - if (readSize > 0) { - // 只写入有效数据 - val audioData = if (readSize == buffer.size) buffer else buffer.copyOfRange(0, readSize) - pushStream.write(audioData) - } - - // 短暂延迟以避免过度消耗CPU - delay(5) - } catch (e: CancellationException) { - break - } catch (e: Exception) { - if (isProcessing.get()) { - FileLogger.e(tag, "处理音频数据异常: ${e.message}") - } - break - } + FileLogger.e(tag, "读取外部音频数据失败: ${e.message}") + return 0 } } + + /** + * SDK调用:关闭流 + */ + override fun close() { + closed = true + queue.clear() + FileLogger.d(tag, "外部音频流已关闭") + } } /** diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index a9442b6c5..2f0fc4bce 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -121,7 +121,6 @@ class AzureSpeechPlugin: FlutterPlugin, CoroutineScope { val region = call.argument("region") ?: "" val supportedLanguages = call.argument>("supportedLanguages") ?: listOf("zh-CN") val useExternalAudio = call.argument("useExternalAudio") ?: false - val useEchoCancellation = call.argument("useEchoCancellation") ?: true try { // 选择音频源类型 @@ -136,7 +135,6 @@ class AzureSpeechPlugin: FlutterPlugin, CoroutineScope { region = region, supportedLanguages = supportedLanguages.toTypedArray(), audioSourceType = audioSourceType, - useEchoCancellation = useEchoCancellation ) result.success(success) diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt index 5be50d6db..743897e37 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureTtsHelper.kt @@ -15,13 +15,17 @@ import kotlin.coroutines.CoroutineContext * 基于微软Azure语音服务的TTS实现 * 参考文档: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-speech-synthesis */ -class AzureTtsHelper(private val context: Context){ +class AzureTtsHelper(private val context: Context) : CoroutineScope { companion object { private const val TAG = "AzureTtsHelper" private const val DEFAULT_LANGUAGE = "zh-CN" private const val DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural" } + // 协程上下文 + private val job = SupervisorJob() + override val coroutineContext: CoroutineContext + get() = Dispatchers.IO + job // Azure语音服务配置 private var speechConfig: SpeechConfig? = null @@ -41,6 +45,10 @@ class AzureTtsHelper(private val context: Context){ // 自定义音频输出流 private var customAudioOutputStream: PushAudioOutputStream? = null + // 流式文本处理的缓冲区 + private val streamBuffer = StringBuilder() + private var lastSpeakTime = 0L + /** * 简化的TTS回调接口 */ @@ -155,7 +163,6 @@ class AzureTtsHelper(private val context: Context){ synthesizer?.apply { // 合成开始事件 SynthesisStarted?.addEventListener { _, _ -> - FileLogger.d(TAG, "语音合成开始") ttsCallback?.onSynthesisStarted() } @@ -166,7 +173,6 @@ class AzureTtsHelper(private val context: Context){ // 合成完成事件 SynthesisCompleted?.addEventListener { _, _ -> - FileLogger.d(TAG, "语音合成完成") isSpeaking = false ttsCallback?.onSynthesisCompleted() } @@ -278,7 +284,9 @@ class AzureTtsHelper(private val context: Context){ isSpeaking = true // 使用纯异步方式 + synthesizer?.SpeakSsmlAsync(ssml) + return true } catch (e: Exception) { @@ -287,6 +295,90 @@ class AzureTtsHelper(private val context: Context){ } } + /** + * 处理流式文本并在遇到标点符号时播放 + * + * @param text 收到的文本流片段 + * @return 是否成功处理 + */ + fun speakStream(text: String): Boolean { + if (!isInitialized || text.isBlank()) { + return false + } + + try { + // 添加新文本到缓冲区 + streamBuffer.append(text) + // 增加500ms防抖逻辑 + val currentTime = System.currentTimeMillis() + if (currentTime - lastSpeakTime < 500) { + return true + } + lastSpeakTime = currentTime + val currentText = streamBuffer.toString() + + // 定义标点符号列表 + val punctuationMarks = listOf('.', '。', '!', '!', '?', '?', ';', ';', ',', ',', ':', ':') + + // 查找最后一个标点符号的位置 + var lastPunctuationIndex = -1 + for (i in currentText.indices.reversed()) { + if (currentText[i] in punctuationMarks) { + lastPunctuationIndex = i + break + } + } + + // 如果找到标点符号,则播放到该标点符号 + if (lastPunctuationIndex >= 0) { + // 提取要播放的文本(包含标点符号) + val textToSpeak = currentText.substring(0, lastPunctuationIndex + 1) + + // 剩余的文本保存在缓冲区中 + streamBuffer.delete(0, lastPunctuationIndex + 1) + + // 播放提取的文本 + return speakText(textToSpeak) + } + + // 如果没有找到标点符号,则等待更多文本 + return true + } catch (e: Exception) { + FileLogger.e(TAG, "流式语音合成失败: ${e.message}") + return false + } + } + + /** + * 播放剩余的流式文本 + * + * @return 是否成功播放剩余文本 + */ + fun flushStream(): Boolean { + if (!isInitialized) { + return false + } + + try { + // 获取缓冲区中剩余的文本 + val remainingText = streamBuffer.toString() + + // 清空缓冲区 + streamBuffer.clear() + + // 如果缓冲区为空,直接返回成功 + if (remainingText.isBlank()) { + return true + } + + // 播放剩余文本 + return speakText(remainingText) + } catch (e: Exception) { + FileLogger.e(TAG, "刷新流式文本失败: ${e.message}") + return false + } + } + /** * 停止当前语音合成 */ @@ -294,6 +386,7 @@ class AzureTtsHelper(private val context: Context){ if (!isInitialized) return false try { + streamBuffer.clear() synthesizer?.StopSpeakingAsync() isSpeaking = false return true @@ -322,6 +415,12 @@ class AzureTtsHelper(private val context: Context){ // 清理引用 ttsCallback = null + // 清空流缓冲区 + streamBuffer.clear() + + // 取消所有协程 + job.cancel() + isInitialized = false isSpeaking = false } catch (e: Exception) { diff --git a/local_plugins/ble_service/android/build.gradle.kts b/local_plugins/ble_service/android/build.gradle.kts index a76679c75..b7633199c 100644 --- a/local_plugins/ble_service/android/build.gradle.kts +++ b/local_plugins/ble_service/android/build.gradle.kts @@ -1,4 +1,3 @@ -import org.gradle.api.JavaVersion group = "com.yunqiinnovation.ble_service" version = "1.0.0" @@ -10,7 +9,7 @@ plugins { android { namespace = "com.yunqiinnovation.ble_service" - compileSdk = 35 + compileSdk = 34 defaultConfig { minSdk = 26 @@ -18,18 +17,22 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "11" } } dependencies { implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.0") - implementation("com.jakewharton.timber:timber:5.0.1") + 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")) } \ No newline at end of file diff --git a/local_plugins/ble_service/android/settings.gradle.kts b/local_plugins/ble_service/android/settings.gradle.kts index 2f97aacd5..9ba9b8948 100644 --- a/local_plugins/ble_service/android/settings.gradle.kts +++ b/local_plugins/ble_service/android/settings.gradle.kts @@ -1 +1,3 @@ -rootProject.name = "ble_service" \ No newline at end of file +rootProject.name = "ble_service" + + diff --git a/local_plugins/ble_service/android/src/main/AndroidManifest.xml b/local_plugins/ble_service/android/src/main/AndroidManifest.xml index 9b1eec458..25fe5c93d 100644 --- a/local_plugins/ble_service/android/src/main/AndroidManifest.xml +++ b/local_plugins/ble_service/android/src/main/AndroidManifest.xml @@ -1,28 +1,46 @@ - - - - - - - - - - + - + + + + + + + + + + + + - + + + + + android:name=".BleMediaService" + android:exported="true" + android:enabled="true" + android:foregroundServiceType="mediaPlayback"> + + + + + + + + + + + - \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt index b592c46e9..e6aa4710b 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt @@ -76,7 +76,9 @@ object BleConst { const val CMD_VOLUME_DOWN = 0x07 /** A2DP播放 */ const val CMD_PLAY_A2DP = 0x08 - + + /** 唤醒指令 */ + const val CMD_WAKE_UP = 0x11 // ================== 编解码控制值 ================== /** 关闭编解码指令 */ const val CODEC_CONTROL_CLOSE = 0x00 diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleForegroundService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleForegroundService.kt deleted file mode 100644 index c7e0bd510..000000000 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleForegroundService.kt +++ /dev/null @@ -1,232 +0,0 @@ -package com.yunqiinnovation.ble_service - -import android.app.* -import android.content.Intent -import android.content.pm.ServiceInfo -import android.os.Build -import android.os.IBinder -import androidx.annotation.RequiresApi -import androidx.core.app.NotificationCompat -import com.yunqiinnovation.deepvoice_utils.FileLogger -import android.content.Context -import android.content.SharedPreferences -import kotlinx.coroutines.* - -/** - * BLE前台服务 - * 职责: - * 1. 维持前台服务状态,确保系统不会杀死应用 - * 2. 作为BLE被唤醒的服务入口 - * 3. 在被唤醒时检查连接状态,如果未连接则使用上次成功的连接重新建立连接 - */ -@RequiresApi(26) -class BleForegroundService : Service(), BleService.Callback { - - companion object { - private const val TAG = "BleForegroundService" - private const val RECONNECT_DELAY = 5000L - private const val CHANNEL_ID = "ble_service_channel" - private const val NOTIFICATION_ID = 1001 - } - - private lateinit var bleManager: BleManagerWrapper - // 简化协程作用域:使用SupervisorJob确保一个协程失败不会影响其他协程 - private val serviceScope = CoroutineScope(SupervisorJob()) - - override fun onCreate() { - super.onCreate() - FileLogger.init(this) - FileLogger.i(TAG, "BleForegroundService正在创建...") - - // 初始化BLE管理器 - bleManager = BleManagerWrapper.instance.apply { - init(this@BleForegroundService) - addCallback(this@BleForegroundService) - } - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - FileLogger.i(TAG, "服务onStartCommand: ${intent?.action}") - - // 1. 立即启动前台服务,这必须是第一步操作 - startForegroundServiceWithNotification() - - // 添加延迟确保通知已显示 - serviceScope.launch { - try { - // 2. 注册后台扫描 - registerBackgroundScan() - - // 3. 处理连接请求 - val macAddress = intent?.getStringExtra("mac") - if (!macAddress.isNullOrEmpty()) { - // 检查当前连接状态,如果未连接则发起连接 - if (!bleManager.isConnected()) { - FileLogger.i(TAG, "开始连接到设备: $macAddress") - connectToDevice(macAddress) - } else { - FileLogger.i(TAG, "设备已连接,忽略连接请求") - } - } - } catch (e: Exception) { - FileLogger.e(TAG, "服务启动后处理异常: ${e.message}", e) - } - } - - return START_STICKY - } - - // 简化协程启动,将注册逻辑提取为独立方法 - private fun registerBackgroundScan() { - serviceScope.launch { - try { - bleManager.registerBackgroundScan() - FileLogger.i(TAG, "已注册后台扫描") - } catch (e: Exception) { - FileLogger.e(TAG, "注册后台扫描失败", e) - } - } - } - - override fun onBind(i: Intent?): IBinder? = null - - override fun onDestroy() { - FileLogger.i(TAG, "BleForegroundService正在销毁...") - - // 清理BLE资源 - cleanupBleResources() - - // 取消所有协程 - serviceScope.cancel() - - super.onDestroy() - } - - // 封装资源清理逻辑 - private fun cleanupBleResources() { - serviceScope.launch { - try { - // 关闭蓝牙连接 - if (bleManager.isConnected()) { - bleManager.disconnect() - } - // 清理资源 - bleManager.cleanup() - FileLogger.i(TAG, "已清理BLE资源") - } catch (e: Exception) { - FileLogger.e(TAG, "清理BLE资源失败", e) - } finally { - // 移除回调 - bleManager.removeCallback(this@BleForegroundService) - } - } - } - - /** - * 启动前台服务 - */ - private fun startForegroundServiceWithNotification() { - val notification = createNotification() - - // 根据Android版本使用正确的前台服务类型 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // Android 14+ - startForeground( - NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE - ) - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Android 10-13 - startForeground( - NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION - ) - } else { // 较旧版本 - startForeground(NOTIFICATION_ID, notification) - } - - FileLogger.i(TAG, "已启动前台服务") - } - - /** - * 创建通知 - */ - private fun createNotification(): Notification { - val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - // Android 8.0+ 需要创建通知渠道 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - "蓝牙连接服务", - NotificationManager.IMPORTANCE_LOW - ) - manager.createNotificationChannel(channel) - } - - // 点击通知打开MainActivity - val intent = packageManager.getLaunchIntentForPackage(packageName)?.apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - val pendingIntent = if (intent != null) { - PendingIntent.getActivity( - this, - 0, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - } else { - null - } - - return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("DeepSound") - .setContentText("蓝牙连接服务正在后台运行") - .setSmallIcon(android.R.drawable.ic_dialog_info) - .apply { pendingIntent?.let { setContentIntent(it) } } - .build() - } - - /** - * 连接到指定设备 - */ - private fun connectToDevice(address: String) { - FileLogger.i(TAG, "开始连接设备: ${address.takeLast(4)}...") - - serviceScope.launch { - try { - bleManager.connect(address) - FileLogger.i(TAG, "设备连接成功: ${address.takeLast(4)}") - } catch (e: Exception) { - FileLogger.e(TAG, "连接设备失败", e) - } - } - } - - // ============================== BleService.Callback 接口实现 ============================== - - override fun onConnectionStateChanged(state: Int) { - when (state) { - BleConst.STATE_CONNECTED -> { - FileLogger.i(TAG, "设备已连接") - } - BleConst.STATE_DISCONNECTED -> { - FileLogger.i(TAG, "设备已断开") - } - BleConst.STATE_ERROR -> { - FileLogger.e(TAG, "设备连接错误") - } - } - } - - override fun onWakeupSignalReceived() { - FileLogger.i(TAG, "收到唤醒信号") - } - - // 以下是必须实现但当前服务不需要处理的回调方法 - override fun onScanResult(devices: List>) {} - override fun onAudioDataReceived(data: ByteArray) {} - override fun onDeviceInfoReceived(infoType: Int, infoData: Map) { - // 前台服务不需要处理设备信息 - } -} \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleManagerWrapper.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleManagerWrapper.kt deleted file mode 100644 index a6435c531..000000000 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleManagerWrapper.kt +++ /dev/null @@ -1,219 +0,0 @@ -package com.yunqiinnovation.ble_service - -import android.content.Context -import com.yunqiinnovation.deepvoice_utils.FileLogger -import android.util.Log -import java.util.concurrent.CopyOnWriteArrayList - -/** - * BLE管理器封装类 - * 单例模式,确保原生层和Flutter层共用同一个BleService实例 - */ -class BleManagerWrapper private constructor() { - companion object { - private const val TAG = "BleManagerWrapper" - - @JvmStatic - val instance by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { BleManagerWrapper() } - } - - // BleService实例 - var bleService: BleService? = null - private set - - // 回调分发器 - private val callbackDispatcher = CallbackDispatcher() - - // 存储所有注册的回调 - private val registeredCallbacks = CopyOnWriteArrayList() - - /** - * 回调分发器类 - 实现BleService.Callback接口,将回调分发给多个监听者 - */ - private inner class CallbackDispatcher : BleService.Callback { - override fun onScanResult(devices: List>) { - for (callback in registeredCallbacks) { - try { - callback.onScanResult(devices) - } catch (e: Exception) { - FileLogger.e(TAG, "分发扫描结果回调异常", e) - } - } - } - - override fun onConnectionStateChanged(state: Int) { - for (callback in registeredCallbacks) { - try { - callback.onConnectionStateChanged(state) - } catch (e: Exception) { - FileLogger.e(TAG, "分发连接状态回调异常", e) - } - } - } - - - override fun onAudioDataReceived(data: ByteArray) { - for (callback in registeredCallbacks) { - try { - callback.onAudioDataReceived(data) - } catch (e: Exception) { - FileLogger.e(TAG, "分发音频数据回调异常", e) - } - } - } - - override fun onWakeupSignalReceived() { - for (callback in registeredCallbacks) { - try { - callback.onWakeupSignalReceived() - } catch (e: Exception) { - FileLogger.e(TAG, "分发唤醒信号回调异常", e) - } - } - } - - override fun onDeviceInfoReceived(infoType: Int, infoData: Map) { - for (callback in registeredCallbacks) { - try { - callback.onDeviceInfoReceived(infoType, infoData) - } catch (e: Exception) { - FileLogger.e(TAG, "分发设备信息回调异常", e) - } - } - } - } - - /** - * 初始化BLE服务 - */ - fun init(context: Context) { - FileLogger.d(TAG, "初始化BLE服务: ${bleService}") - try { - if (bleService == null) { - bleService = BleService(context) - - // 设置回调分发器 - bleService?.callback = callbackDispatcher - FileLogger.d(TAG, "BLE服务初始化成功") - } - // 注册后台扫描,确保可被唤醒 - // bleService?.registerBackgroundScan() - - } catch (e: Exception) { - FileLogger.e(TAG, "BLE服务初始化失败", e) - } - } - - /** - * 添加回调接口 - * @param callback 回调接口实现 - * @return 是否添加成功 - */ - fun addCallback(callback: BleService.Callback): Boolean { - if (!registeredCallbacks.contains(callback)) { - registeredCallbacks.add(callback) - FileLogger.d(TAG, "已添加BLE回调, 当前回调数: ${registeredCallbacks.size}") - return true - } - return false - } - - /** - * 移除回调接口 - * @param callback 回调接口实现 - * @return 是否移除成功 - */ - fun removeCallback(callback: BleService.Callback): Boolean { - val removed = registeredCallbacks.remove(callback) - if (removed) { - FileLogger.d(TAG, "已移除BLE回调, 当前回调数: ${registeredCallbacks.size}") - } - return removed - } - - - - // 以下方法直接委托给BleService实现,避免重复代码 - - // 扫描相关 - fun startScan(timeout: Long = 10000): Boolean = bleService?.startScan(timeout) ?: false - fun stopScan(): Boolean = bleService?.stopScan() ?: false - - /** - * 注册后台持久扫描,APP被杀死后也能唤醒 - * @return 是否注册成功 - */ - fun registerBackgroundScan(): Boolean { - try { - bleService?.registerBackgroundScan() - FileLogger.i(TAG, "注册后台持久扫描") - return true - } catch (e: Exception) { - FileLogger.e(TAG, "注册后台持久扫描失败: ${e.message}") - return false - } - } - - /** - * 注销后台持久扫描 - * @return 是否注销成功 - */ - fun unregisterBackgroundScan(): Boolean { - try { - bleService?.unregisterBackgroundScan() - FileLogger.i(TAG, "注销后台持久扫描") - return true - } catch (e: Exception) { - FileLogger.e(TAG, "注销后台持久扫描失败: ${e.message}") - return false - } - } - - // 连接相关 - fun connect(address: String): Boolean { - bleService?.connect(address) - return true - } - fun disconnect(): Boolean { - bleService?.disconnect() - return true - } - fun isConnected(): Boolean = bleService?.isConnected() ?: false - fun getConnectionState(): Int = bleService?.connectionState?.value ?: BleConst.STATE_DISCONNECTED - - // 设备信息 - fun getConnectedDeviceAddress(): String? = bleService?.getConnectedDeviceAddress() - fun getBatteryLevel(): Int = bleService?.batteryLevel ?: 0 - fun getFirmwareVersion(): String? = bleService?.firmwareVersion - - // 查询命令 - fun getDeviceVersion(): Boolean = bleService?.getDeviceVersion() ?: false - fun getBatteryInfo(): Boolean = bleService?.getBatteryInfo() ?: false - - // 控制命令 - fun openEncoder(): Boolean = bleService?.openEncoder() ?: false - fun openDecoder(): Boolean = bleService?.openDecoder() ?: false - fun closeCodec(): Boolean = bleService?.closeCodec() ?: false - fun playA2DP(): Boolean = bleService?.playA2DP() ?: false - fun volumeUp(): Boolean = bleService?.volumeUp() ?: false - fun volumeDown(): Boolean = bleService?.volumeDown() ?: false - - // 清理资源 - fun cleanup() { - bleService?.cleanup() - } - - // 获取扫描结果的简化实现 - fun getScannedDevices(): List> { - // 简单返回一个转换后的设备列表 - val devices = mutableListOf>() - bleService?.getBluetoothAdapter()?.bondedDevices?.forEach { device -> - devices.add(mapOf( - "id" to device.address, - "name" to (device.name ?: "未知设备"), - "rssi" to 0 // 已配对设备没有RSSI信息 - )) - } - return devices - } -} \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleMediaService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleMediaService.kt new file mode 100644 index 000000000..b8674ed3b --- /dev/null +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleMediaService.kt @@ -0,0 +1,535 @@ +package com.yunqiinnovation.ble_service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.media.AudioManager +import android.media.MediaPlayer +import android.os.Build +import android.os.IBinder +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import androidx.core.app.NotificationCompat +import androidx.media.session.MediaButtonReceiver +import com.yunqiinnovation.ble_service.R +import com.yunqiinnovation.deepvoice_utils.FileLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import android.view.KeyEvent +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +/** + * 语音助手媒体服务 + * 职责: + * 1. 接收并处理媒体按钮事件(播放/暂停等) + * 2. 维持媒体会话,使系统可以通过媒体按钮唤醒应用 + * 3. 最小化实现,不直接对接Flutter层 + */ +class BleMediaService : Service(), CoroutineScope { + + companion object { + private const val TAG = "BleMediaService" + private const val CHANNEL_ID = "ble_media_service_channel" + private const val NOTIFICATION_ID = 1002 + private const val PREF_NAME = "deep_voice_config" + + // ChatAgent配置参数键 + private const val KEY_AZURE_SPEECH_KEY = "azureSpeechKey" + private const val KEY_AZURE_SPEECH_REGION = "azureSpeechRegion" + private const val KEY_OPENAI_API_KEY = "openaiApiKey" + private const val KEY_OPENAI_BASE_URL = "openaiBaseUrl" + private const val KEY_OPENAI_MODEL = "openaiModel" + private const val KEY_MCP_SERVER = "mcpServer" + + // 用于跟踪服务是否正在运行 + private val isRunning = AtomicBoolean(false) + + // 获取服务运行状态 + fun isServiceRunning(): Boolean = isRunning.get() + } + + // 协程相关 + private val job = SupervisorJob() + override val coroutineContext: CoroutineContext + get() = Dispatchers.IO + job + + private lateinit var mediaSession: MediaSessionCompat + private lateinit var audioManager: AudioManager + private var isPlaying = false + + + // 媒体操作防抖 + private var lastActionTime = 0L + private var lastScanTime = 0L + private val ACTION_DEBOUNCE_MS = 800L // 媒体操作防抖时间(毫秒) + + // 添加音频播放器 + private var audioPlayer: MiniMediaPlayer? = null + + // 语音代理是否已初始化 + private var isChatAgentInitialized = false + + // 当前启动Intent + private var currentIntent: Intent? = null + + override fun onCreate() { + super.onCreate() + FileLogger.init(this) + FileLogger.i(TAG, "BleMediaService正在创建...") + + // 设置服务状态 + isRunning.set(true) + + // 初始化音频管理器 + audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + + // 创建并启动媒体会话 + setupMediaSession() + + // 注册媒体按钮接收器 + registerMediaButtonReceiver() + // 注册后台扫描 + registerBackgroundScan() + + // 创建音频播放器,并播放静音文件 + audioPlayer = MiniMediaPlayer(this) + audioPlayer?.play(R.raw.silence, 0f) + setPlaybackState(PlaybackStateCompat.STATE_PAUSED) + + // 确保尽快启动前台服务,防止系统杀死服务 + startForeground(NOTIFICATION_ID, createNotification()) + + FileLogger.i(TAG, "媒体服务已启动") + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + FileLogger.i(TAG, "BleMediaService.onStartCommand: ${intent?.action}") + + // 保存当前Intent,用于后续初始化 + currentIntent = intent + // 初始化语音代理 + if (!isChatAgentInitialized) { + initializeChatAgent() + } + // 如果是媒体按钮事件,交给MediaButtonReceiver处理 + if (Intent.ACTION_MEDIA_BUTTON == intent?.action) { + MediaButtonReceiver.handleIntent(mediaSession, intent) + return START_STICKY + } + + // 处理蓝牙扫描结果事件 + if (intent?.action == BleConst.ACTION_SCAN_RESULT) { + val mac = intent.getStringExtra("mac") + if (mac != null) { + val currentTime = System.currentTimeMillis() + if (currentTime - lastScanTime >= ACTION_DEBOUNCE_MS) { + lastScanTime = currentTime + checkBleConnection(mac) + } else { + // FileLogger.i(TAG, "扫描操作防抖: 忽略${ACTION_DEBOUNCE_MS}ms内的重复MAC请求") + } + } else { + FileLogger.w(TAG, "收到扫描结果但MAC地址为空") + } + return START_STICKY + } + + + + return START_STICKY + } + + /** + * 注册后台蓝牙扫描 + */ + private fun registerBackgroundScan() { + try { + BleService.registerBackgroundScan() + FileLogger.i(TAG, "后台蓝牙扫描已注册") + } catch (e: Exception) { + FileLogger.e(TAG, "注册后台蓝牙扫描失败", e) + } + } + + /** + * 初始化语音交互代理 + */ + private fun initializeChatAgent() { + try { + // 获取SharedPreferences + val prefs = applicationContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + + // 尝试从Intent中获取参数 + val configFromIntent = extractConfigFromIntent(currentIntent) + + // 如果Intent中有参数,则保存到SharedPreferences + if (configFromIntent.isNotEmpty()) { + FileLogger.d(TAG, "从Intent中获取到配置参数,保存到SharedPreferences") + saveConfigToPreferences(configFromIntent, prefs) + } + + // 合并Intent和SharedPreferences中的参数 + val config = getConfigFromPreferences(prefs) + FileLogger.d(TAG, "当前配置参数: $config") + // 检查必要的配置是否存在 + if (config[KEY_AZURE_SPEECH_KEY].isNullOrEmpty() || config[KEY_OPENAI_API_KEY].isNullOrEmpty()) { + FileLogger.w(TAG, "语音代理初始化失败: API密钥未配置") + return + } + + if (isChatAgentInitialized) { + FileLogger.d(TAG, "ChatAgent已经初始化,跳过") + return + } + if(config.isNotEmpty()) { + // 初始化ChatAgent + FileLogger.i(TAG, "开始初始化ChatAgent") + val success = ChatAgent.initialize(applicationContext, config) + if (success) { + isChatAgentInitialized = true + FileLogger.i(TAG, "语音交互代理初始化成功") + } else { + FileLogger.e(TAG, "语音交互代理初始化失败") + } + } + } catch (e: Exception) { + FileLogger.e(TAG, "初始化语音交互代理时发生异常", e) + } + } + + /** + * 从Intent中提取配置参数 + */ + private fun extractConfigFromIntent(intent: Intent?): Map { + val config = mutableMapOf() + intent?.let { + // 提取可能存在的配置参数 + it.getStringExtra(KEY_AZURE_SPEECH_KEY)?.let { value -> config[KEY_AZURE_SPEECH_KEY] = value } + it.getStringExtra(KEY_AZURE_SPEECH_REGION)?.let { value -> config[KEY_AZURE_SPEECH_REGION] = value } + it.getStringExtra(KEY_OPENAI_API_KEY)?.let { value -> config[KEY_OPENAI_API_KEY] = value } + it.getStringExtra(KEY_OPENAI_BASE_URL)?.let { value -> config[KEY_OPENAI_BASE_URL] = value } + it.getStringExtra(KEY_OPENAI_MODEL)?.let { value -> config[KEY_OPENAI_MODEL] = value } + it.getStringExtra(KEY_MCP_SERVER)?.let { value -> config[KEY_MCP_SERVER] = value } + } + FileLogger.i(TAG, "从Intent中提取到配置参数: $config") + return config + } + + /** + * 将配置参数保存到SharedPreferences + */ + private fun saveConfigToPreferences(config: Map, prefs: SharedPreferences) { + prefs.edit().apply { + config.forEach { (key, value) -> + if (value.isNotEmpty()) { + putString(key, value) + } + } + apply() + } + } + + /** + * 从SharedPreferences获取配置 + */ + private fun getConfigFromPreferences(prefs: SharedPreferences): Map { + return mapOf( + KEY_AZURE_SPEECH_KEY to getConfigString(prefs, KEY_AZURE_SPEECH_KEY, ""), + KEY_AZURE_SPEECH_REGION to getConfigString(prefs, KEY_AZURE_SPEECH_REGION, "eastasia"), + KEY_OPENAI_API_KEY to getConfigString(prefs, KEY_OPENAI_API_KEY, ""), + KEY_OPENAI_BASE_URL to getConfigString(prefs, KEY_OPENAI_BASE_URL, "https://api.openai.com/v1"), + KEY_OPENAI_MODEL to getConfigString(prefs, KEY_OPENAI_MODEL, "gpt-3.5-turbo"), + KEY_MCP_SERVER to getConfigString(prefs, KEY_MCP_SERVER, "") + ) + } + + /** + * 从SharedPreferences获取字符串配置 + */ + private fun getConfigString(prefs: SharedPreferences, key: String, defaultValue: String): String { + return prefs.getString(key, defaultValue) ?: defaultValue + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + FileLogger.i(TAG, "BleMediaService正在销毁...") + + // 取消注册媒体按钮接收器 + try { + val mediaButtonReceiver = ComponentName(packageName, MediaButtonReceiver::class.java.name) + audioManager.unregisterMediaButtonEventReceiver(mediaButtonReceiver) + FileLogger.i(TAG, "媒体按钮接收器已取消注册") + } catch (e: Exception) { + FileLogger.e(TAG, "取消注册媒体按钮接收器失败: ${e.message}", e) + } + + // 释放媒体会话 + mediaSession.release() + + // 停止并释放音频播放器 + audioPlayer?.release() + audioPlayer = null + + // 取消所有协程 + cancel() + + // 设置服务状态 + isRunning.set(false) + + super.onDestroy() + } + + /** + * 设置媒体会话,处理媒体按钮事件 + */ + private fun setupMediaSession() { + mediaSession = MediaSessionCompat(this, "BleMediaSession").apply { + setFlags( + MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or + MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS + ) + // 设置回调,处理媒体按钮事件 + setCallback(object : MediaSessionCompat.Callback() { + + override fun onMediaButtonEvent(mediaButtonIntent: Intent): Boolean { + FileLogger.i(TAG, "onMediaButtonEvent: ${mediaButtonIntent.action}") + // 这里可以添加切换播放/暂停状态的逻辑 + isPlaying = !isPlaying + + // 更新播放状态 + val newState = if (isPlaying) { + PlaybackStateCompat.STATE_PLAYING + } else { + PlaybackStateCompat.STATE_PAUSED + } + + // 设置新的播放状态 + setPlaybackState(newState) + + // 更新通知 + updateNotification() + + handleMediaAction() + // 使用系统默认的媒体按钮处理 + return super.onMediaButtonEvent(mediaButtonIntent) + } + }) + + // 设置初始播放状态 + val stateBuilder = PlaybackStateCompat.Builder() + .setActions( + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + ) + .setState(PlaybackStateCompat.STATE_STOPPED, 0, 1.0f) + setPlaybackState(stateBuilder.build()) + + // 激活会话,使其可以接收媒体按钮事件 + isActive = true + } + } + + /** + * 注册媒体按钮接收器 + */ + private fun registerMediaButtonReceiver() { + try { + val mediaButtonReceiver = ComponentName(packageName, MediaButtonReceiver::class.java.name) + audioManager.registerMediaButtonEventReceiver(mediaButtonReceiver) + FileLogger.i(TAG, "媒体按钮接收器注册成功: $mediaButtonReceiver") + } catch (e: Exception) { + FileLogger.e(TAG, "媒体按钮接收器注册失败: ${e.message}", e) + } + } + /** + * 处理媒体操作 + * 包含防抖逻辑,避免短时间内重复触发 + */ + private fun handleMediaAction() { + FileLogger.i(TAG, "handleMediaAction") + // 防抖处理逻辑 + val currentTime = System.currentTimeMillis() + if (currentTime - lastActionTime >= ACTION_DEBOUNCE_MS) { + lastActionTime = currentTime + ChatAgent.onWakeupSignalReceived() + } else { + FileLogger.i(TAG, "媒体操作防抖: 忽略${ACTION_DEBOUNCE_MS}ms内的重复请求") + } + + } + + + private fun checkBleConnection(mac: String) { + FileLogger.i(TAG, "handleMediaAction收到连接请求: ${mac}") + + try { + // 示例:使用协程异步处理连接 + launch { + if(!BleService.isConnected()) { + BleService.connect(mac) + } + } + } catch (e: Exception) { + FileLogger.e(TAG, "连接设备失败: ${e.message}", e) + } + } + + /** + * 更新通知内容 + */ + private fun updateNotification() { + try { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID, createNotification()) + } catch (e: Exception) { + FileLogger.e(TAG, "更新通知异常: ${e.message}") + } + } + + /** + * 设置播放状态 + */ + private fun setPlaybackState(state: Int) { + val playbackStateBuilder = PlaybackStateCompat.Builder() + .setActions( + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_STOP or + PlaybackStateCompat.ACTION_SEEK_TO or + PlaybackStateCompat.ACTION_FAST_FORWARD or + PlaybackStateCompat.ACTION_REWIND + ) + // 使用更高的播放速度,让系统认为是真正的播放 + .setState(state, 0, 1.0f, System.currentTimeMillis()) + mediaSession.setPlaybackState(playbackStateBuilder.build()) + + // 确保MediaSession激活 + if (!mediaSession.isActive) { + mediaSession.isActive = true + } + } + + /** + * 创建前台服务通知 + */ + private fun createNotification(): Notification { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Android 8.0+ 需要创建通知渠道 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "语音助手媒体服务", + NotificationManager.IMPORTANCE_LOW + ) + manager.createNotificationChannel(channel) + } + + // 点击通知打开主界面 + val intent = packageManager.getLaunchIntentForPackage(packageName)?.apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = if (intent != null) { + PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } else { + null + } + + // 构建通知 + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("DeepSound") + .setContentText(if (isPlaying) "正在播放" else "已暂停") + .setSmallIcon(android.R.drawable.ic_media_play) + + // 设置点击意图 + pendingIntent?.let { builder.setContentIntent(it) } + + return builder.build() + } + + /** + * 简易媒体播放器,用于播放静音资源 + */ + inner class MiniMediaPlayer(private val context: Context) { + private var mediaPlayer: MediaPlayer? = null + + /** + * 播放资源文件 + * @param resId 资源ID + * @param volume 音量 (0.0-1.0) + */ + fun play(resId: Int, volume: Float = 0f) { + try { + // 释放之前的资源 + release() + + // 创建播放器并设置资源 + mediaPlayer = MediaPlayer().apply { + // 设置资源 + context.resources.openRawResourceFd(resId)?.use { fd -> + setDataSource(fd.fileDescriptor, fd.startOffset, fd.length) + } + + // 设置音量 + setVolume(volume, volume) + + // // 播放完成后自动循环播放,保持媒体会话活跃 + // setOnCompletionListener { + // FileLogger.i(TAG, "静音资源播放完成,再次播放保持会话活跃") + // play(resId, volume) + // } + + // 准备并播放 + prepare() + start() + } + + FileLogger.i(TAG, "开始播放静音资源") + } catch (e: Exception) { + FileLogger.e(TAG, "播放静音资源异常: ${e.message}", e) + release() + } + } + + /** + * 释放资源 + */ + fun release() { + try { + mediaPlayer?.apply { + if (isPlaying) stop() + release() + } + mediaPlayer = null + } catch (e: Exception) { + // 忽略异常 + } + } + + /** + * 是否正在播放 + */ + fun isPlaying() = mediaPlayer?.isPlaying == true + } +} \ 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 92aad6bda..dc615d51e 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 @@ -21,6 +21,8 @@ import android.os.SystemClock import android.content.pm.PackageManager import androidx.core.content.ContextCompat import java.util.concurrent.ConcurrentHashMap +import android.content.pm.ServiceInfo +import java.util.concurrent.CopyOnWriteArrayList /** * BLE服务类:提供蓝牙低功耗设备的扫描、连接和通信功能 @@ -29,14 +31,12 @@ import java.util.concurrent.ConcurrentHashMap * 2. App被杀后依旧保持PendingIntent扫描并可被UUID广播唤醒 * 3. 内置命令及协议解析 */ -class BleService(private val context: Context) { +object BleService { // ====================================================================================================== // 常量定义 // ====================================================================================================== - companion object { - private const val TAG = "BleService" - } + private const val TAG = "BleService" // ====================================================================================================== // 回调接口 @@ -59,11 +59,14 @@ class BleService(private val context: Context) { // ====================================================================================================== // 成员变量 // ====================================================================================================== + + // 上下文 + private lateinit var context: Context // 蓝牙相关组件 - private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager - private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter - private val bluetoothLeScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner + private lateinit var bluetoothManager: BluetoothManager + private var bluetoothAdapter: BluetoothAdapter? = null + private var bluetoothLeScanner: BluetoothLeScanner? = null // GATT连接相关 private var bluetoothGatt: BluetoothGatt? = null @@ -72,22 +75,83 @@ class BleService(private val context: Context) { private var audioChar: BluetoothGattCharacteristic? = null // 扫描相关 - private val scanHandler = Handler(Looper.getMainLooper()) + private lateinit var scanHandler: Handler private val scanResults = ArrayList() private var isScanning = false // 连接状态 val connectionState = MutableLiveData(BleConst.STATE_DISCONNECTED) - // 回调 - var callback: Callback? = null + // 回调集合 - 使用线程安全的集合存储所有回调 + private val callbacks = CopyOnWriteArrayList() + + + // 初始化状态 + private var isInitialized = false - // 设备信息 - var firmwareVersion: String? = null - private set - var batteryLevel: Int = 0 - private set + /** + * 初始化BLE服务 + * @param appContext 应用上下文 + * @return 是否初始化成功 + */ + fun initialize(appContext: Context): Boolean { + if (isInitialized) return true + try { + this.context = appContext.applicationContext + + // 初始化蓝牙管理器和适配器 + bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + bluetoothAdapter = bluetoothManager.adapter + bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner + + // 初始化Handler + scanHandler = Handler(Looper.getMainLooper()) + + isInitialized = true + FileLogger.d(TAG, "BLE服务初始化成功") + return true + } catch (e: Exception) { + FileLogger.e(TAG, "BLE服务初始化失败: ${e.message}", e) + return false + } + } + + /** + * 添加回调监听器 + * @param callback 要添加的回调监听器 + * @return 是否添加成功 + */ + fun addCallback(callback: Callback): Boolean { + if (!callbacks.contains(callback)) { + callbacks.add(callback) + FileLogger.d(TAG, "已添加BLE回调, 当前回调数: ${callbacks.size}") + return true + } + return false + } + + /** + * 移除回调监听器 + * @param callback 要移除的回调监听器 + * @return 是否移除成功 + */ + fun removeCallback(callback: Callback): Boolean { + val removed = callbacks.remove(callback) + if (removed) { + FileLogger.d(TAG, "已移除BLE回调, 当前回调数: ${callbacks.size}") + } + return removed + } + + /** + * 移除所有回调监听器 + */ + fun clearCallbacks() { + callbacks.clear() + FileLogger.d(TAG, "已清除所有BLE回调") + } + // ====================================================================================================== // 扫描功能 // ====================================================================================================== @@ -97,7 +161,14 @@ class BleService(private val context: Context) { * @return 是否成功开始扫描 */ fun startScan(timeout: Long = 15000): Boolean { - if (bluetoothLeScanner == null || isScanning) return false + if (!isInitialized) { + FileLogger.e(TAG, "BLE服务未初始化") + return false + } + + // 获取一个局部引用,避免并发访问问题 + val scanner = bluetoothLeScanner ?: return false + if (isScanning) return false FileLogger.i(TAG, "开始主动扫描BLE设备...") unregisterBackgroundScan() // 避免双重扫描 @@ -116,7 +187,7 @@ class BleService(private val context: Context) { .build() try { - bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback) + scanner.startScan(listOf(filter), settings, scanCallback) isScanning = true // 设置扫描超时 @@ -137,11 +208,14 @@ class BleService(private val context: Context) { * @return 是否成功停止扫描 */ fun stopScan(): Boolean { - if (bluetoothLeScanner == null || !isScanning) return false + // 获取一个局部引用,避免并发访问问题 + val scanner = bluetoothLeScanner ?: return false + if (!isScanning) return false + FileLogger.i(TAG, "停止主动扫描") try { - bluetoothLeScanner.stopScan(scanCallback) + scanner.stopScan(scanCallback) scanHandler.removeCallbacksAndMessages(null) isScanning = false @@ -177,7 +251,7 @@ class BleService(private val context: Context) { ) // 直接回调单个设备结果 - callback?.onScanResult(listOf(deviceMap)) + notifyScanResult(listOf(deviceMap)) // 更新缓存 val idx = scanResults.indexOfFirst { it.device.address == dev.address } @@ -231,7 +305,8 @@ class BleService(private val context: Context) { return } - if (bluetoothAdapter == null) { + // 获取一个局部引用,避免并发访问问题 + val adapter = bluetoothAdapter ?: run { updateConnectionState(BleConst.STATE_ERROR) return } @@ -241,7 +316,7 @@ class BleService(private val context: Context) { try { // 创建新连接 - val device = bluetoothAdapter.getRemoteDevice(address) + val device = adapter.getRemoteDevice(address) updateConnectionState(BleConst.STATE_CONNECTING) bluetoothGatt = device.connectGatt( @@ -284,7 +359,7 @@ class BleService(private val context: Context) { */ private fun updateConnectionState(state: Int) { connectionState.postValue(state) - callback?.onConnectionStateChanged(state) + notifyConnectionStateChanged(state) } /** @@ -366,13 +441,6 @@ class BleService(private val context: Context) { data.isNotEmpty() && data[0] == BleConst.DEVICE_NOTIFY_HEADER -> { processDeviceNotification(data) } - // 唤醒信号 (0x01开头) - data.isNotEmpty() && data[0].toInt() == 0x01 -> { - FileLogger.i(TAG, "收到唤醒信号") - callback?.onWakeupSignalReceived() - // 接收到唤醒信号,打开编码器 - openEncoder() - } // 其他未知数据 else -> { FileLogger.d(TAG, "收到未知数据: ${data.joinToString(", ") { "0x%02X".format(it) }}") @@ -425,7 +493,7 @@ class BleService(private val context: Context) { FileLogger.d(TAG, "收到音频数据: ${data.size}字节") // 回调给上层 - callback?.onAudioDataReceived(data) + notifyAudioDataReceived(data) } catch (e: Exception) { FileLogger.e(TAG, "处理音频数据异常: ${e.message}", e) } @@ -453,7 +521,7 @@ class BleService(private val context: Context) { "command" to cmdType, "rawData" to byteArrayOf() ) - callback?.onDeviceInfoReceived(cmdType, resultMap) + notifyDeviceInfoReceived(cmdType, resultMap) return } @@ -489,24 +557,23 @@ class BleService(private val context: Context) { BleConst.CMD_GET_VERSION -> { val resultMap = mutableMapOf( "success" to true, - "command" to cmdType, + "command" to "versionInfo", "rawData" to responseData ) if (responseData.isNotEmpty()) { // 转换为字符串前,确认是否是有效的ASCII字符 val version = responseData.joinToString(".") { (it.toInt() and 0xFF).toString() } - firmwareVersion = version resultMap["version"] = version FileLogger.i(TAG, "接收到目标版本号: $version") } - callback?.onDeviceInfoReceived(cmdType, resultMap) + notifyDeviceInfoReceived(cmdType, resultMap) } BleConst.CMD_GET_PRODUCT_ID -> { val resultMap = mutableMapOf( "success" to true, - "command" to cmdType, + "command" to "productInfo", "rawData" to responseData ) @@ -516,12 +583,12 @@ class BleService(private val context: Context) { FileLogger.i(TAG, "接收到产品ID: $productId") } - callback?.onDeviceInfoReceived(cmdType, resultMap) + notifyDeviceInfoReceived(cmdType, resultMap) } BleConst.CMD_GET_COLOR_ID -> { val resultMap = mutableMapOf( "success" to true, - "command" to cmdType, + "command" to "colorInfo", "rawData" to responseData ) @@ -531,12 +598,12 @@ class BleService(private val context: Context) { FileLogger.i(TAG, "接收到耳机颜色ID: $colorId") } - callback?.onDeviceInfoReceived(cmdType, resultMap) + notifyDeviceInfoReceived(cmdType, resultMap) } BleConst.CMD_GET_BATTERY_INFO -> { val resultMap = mutableMapOf( "success" to true, - "command" to cmdType, + "command" to "batteryInfo", "rawData" to responseData ) @@ -551,9 +618,6 @@ class BleService(private val context: Context) { val caseBattery = if (responseData.size > 2) responseData[2].toInt() and 0x7F else 0 val caseCharging = if (responseData.size > 2) (responseData[2].toInt() and 0x80) != 0 else false - // 更新整体电池电量 (使用左耳电量作为主要指示) - this.batteryLevel = leftEarBattery - // 添加到结果Map resultMap["leftEarBattery"] = leftEarBattery resultMap["leftEarCharging"] = leftEarCharging @@ -569,7 +633,7 @@ class BleService(private val context: Context) { "(${if(caseCharging) "充电中" else "未充电"})") } - callback?.onDeviceInfoReceived(cmdType, resultMap) + notifyDeviceInfoReceived(cmdType, resultMap) } BleConst.CMD_CONTROL_CODEC -> { val resultMap = mutableMapOf( @@ -607,7 +671,7 @@ class BleService(private val context: Context) { FileLogger.i(TAG, "编解码控制响应: $statusDesc, $channelDesc") } - callback?.onDeviceInfoReceived(cmdType, resultMap) + notifyDeviceInfoReceived(cmdType, resultMap) } else -> { // 对于未知命令,返回原始数据 @@ -618,7 +682,7 @@ class BleService(private val context: Context) { "isUnknown" to true ) FileLogger.d(TAG, "收到未知命令响应: 0x${cmdType.toString(16)}") - callback?.onDeviceInfoReceived(cmdType, resultMap) + notifyDeviceInfoReceived(cmdType, resultMap) } } } @@ -682,8 +746,6 @@ class BleService(private val context: Context) { val caseBattery = if (notifyData.size > 2) notifyData[2].toInt() and 0x7F else 0 val caseCharging = if (notifyData.size > 2) (notifyData[2].toInt() and 0x80) != 0 else false - // 更新整体电池电量 (使用左耳电量作为主要指示) - this.batteryLevel = leftEarBattery // 添加到结果Map resultMap["leftEarBattery"] = leftEarBattery @@ -700,7 +762,14 @@ class BleService(private val context: Context) { "(${if(caseCharging) "充电中" else "未充电"})") } - callback?.onDeviceInfoReceived(infoType, resultMap) + notifyDeviceInfoReceived(infoType, resultMap) + } + BleConst.CMD_WAKE_UP -> { + FileLogger.i(TAG, "收到唤醒指令") + // 接收到唤醒信号,打开编码器 + openEncoder() + notifyWakeupSignalReceived() + } // 可以在这里添加其他主动上报类型的处理 else -> { @@ -713,7 +782,7 @@ class BleService(private val context: Context) { "isNotification" to true // 标记为主动上报 ) FileLogger.d(TAG, "收到未知主动上报类型: 0x${infoType.toString(16)}") - callback?.onDeviceInfoReceived(infoType, resultMap) + notifyDeviceInfoReceived(infoType, resultMap) } } } @@ -861,6 +930,7 @@ class BleService(private val context: Context) { * 注册后台持久扫描,APP被杀死后也能唤醒 */ fun registerBackgroundScan() { + // 获取一个局部引用,避免并发访问问题 val scanner = bluetoothLeScanner ?: return try { val filter = ScanFilter.Builder() @@ -879,6 +949,7 @@ class BleService(private val context: Context) { .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) { @@ -898,6 +969,7 @@ class BleService(private val context: Context) { * 注销后台持久扫描 */ fun unregisterBackgroundScan() { + // 获取一个局部引用,避免并发访问问题 val scanner = bluetoothLeScanner ?: return try { // 创建Intent并设置前台接收器标志 @@ -1006,4 +1078,69 @@ class BleService(private val context: Context) { } return crc.toByte() } + + /** + * 向所有回调监听器分发扫描结果 + */ + private fun notifyScanResult(devices: List>) { + for (callback in callbacks) { + try { + callback.onScanResult(devices) + } catch (e: Exception) { + FileLogger.e(TAG, "分发扫描结果回调异常", e) + } + } + } + + /** + * 向所有回调监听器分发连接状态变化 + */ + private fun notifyConnectionStateChanged(state: Int) { + for (callback in callbacks) { + try { + callback.onConnectionStateChanged(state) + } catch (e: Exception) { + FileLogger.e(TAG, "分发连接状态回调异常", e) + } + } + } + + /** + * 向所有回调监听器分发音频数据 + */ + private fun notifyAudioDataReceived(data: ByteArray) { + for (callback in callbacks) { + try { + callback.onAudioDataReceived(data) + } catch (e: Exception) { + FileLogger.e(TAG, "分发音频数据回调异常", e) + } + } + } + + /** + * 向所有回调监听器分发唤醒信号 + */ + private fun notifyWakeupSignalReceived() { + for (callback in callbacks) { + try { + callback.onWakeupSignalReceived() + } catch (e: Exception) { + FileLogger.e(TAG, "分发唤醒信号回调异常", e) + } + } + } + + /** + * 向所有回调监听器分发设备信息 + */ + private fun notifyDeviceInfoReceived(infoType: Int, infoData: Map) { + for (callback in callbacks) { + try { + callback.onDeviceInfoReceived(infoType, infoData) + } catch (e: Exception) { + FileLogger.e(TAG, "分发设备信息回调异常", e) + } + } + } } \ No newline at end of file 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 3a0ef4841..b88f0620a 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 @@ -37,9 +37,6 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, BleService.Callback { // 应用上下文 private lateinit var context: Context - // BleManager包装器 - private val bleManager = BleManagerWrapper.instance - // 事件接收器 - 只保留数据和状态通道 private var dataEventSink: EventChannel.EventSink? = null private var statusEventSink: EventChannel.EventSink? = null @@ -57,14 +54,11 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, BleService.Callback { context = flutterPluginBinding.applicationContext - // 初始化BleManagerWrapper - try { - bleManager.init(context) - bleManager.addCallback(this) - FileLogger.d(TAG, "BleManagerWrapper initialized successfully") - } catch (e: Exception) { - FileLogger.e(TAG, "Failed to initialize BleManagerWrapper: ${e.message}", e) - } + // 初始化BleService + + BleService.initialize(context) + BleService.addCallback(this) + } // 设置事件通道 - 简化版 @@ -122,11 +116,12 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, BleService.Callback { methodChannel.setMethodCallHandler(null) // 移除回调 - bleManager.removeCallback(this) + BleService.removeCallback(this) // 解除事件通道 dataEventChannel?.setStreamHandler(null) statusEventChannel?.setStreamHandler(null) + } override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { @@ -134,47 +129,62 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, BleService.Callback { when (call.method) { "isConnected" -> { - val connected = bleManager.isConnected() + val connected = BleService.isConnected() result.success(connected) } - "getBatteryLevel" -> { - val level = bleManager.getBatteryLevel() + "getBatteryInfo" -> { + val level = BleService.getBatteryInfo() result.success(level) } - "getFirmwareVersion" -> { - val version = bleManager.getFirmwareVersion() - result.success(version) - } "getDeviceVersion" -> { - val success = bleManager.getDeviceVersion() + val success = BleService.getDeviceVersion() result.success(success) } "getBatteryInfo" -> { - val success = bleManager.getBatteryInfo() + val success = BleService.getBatteryInfo() result.success(success) } "openEncoder" -> { - val success = bleManager.openEncoder() + val success = BleService.openEncoder() result.success(success) } "openDecoder" -> { - val success = bleManager.openDecoder() + val success = BleService.openDecoder() result.success(success) } "closeCodec" -> { - val success = bleManager.closeCodec() + val success = BleService.closeCodec() result.success(success) } "playA2DP" -> { - val success = bleManager.playA2DP() + 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) @@ -218,7 +228,7 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, BleService.Callback { val stateMap = mapOf( "type" to "connectionStateChanged", "state" to state, - + "stateStr" to stateStr ) sendEvent(statusEventSink, stateMap, "发送连接状态异常") @@ -229,6 +239,7 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, BleService.Callback { } override fun onWakeupSignalReceived() { + // 将唤醒事件发送到Flutter sendEvent(statusEventSink, mapOf("type" to "wakeup"), "发送唤醒信号异常") } @@ -248,12 +259,9 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, BleService.Callback { */ private fun startBleServiceWithConfig(configParams: Map) { try { - // 初始化BleManagerWrapper - val bleManager = BleManagerWrapper.instance - bleManager.init(context) // 启动BLE前台服务 - val serviceIntent = Intent(context, BleForegroundService::class.java) + val serviceIntent = Intent(context, BleMediaService::class.java) // 如果有额外配置需要传递给服务,可以通过Intent传递 configParams.forEach { (key, value) -> @@ -283,7 +291,7 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, BleService.Callback { */ private fun stopBleService() { try { - context.stopService(Intent(context, BleForegroundService::class.java)) + context.stopService(Intent(context, BleMediaService::class.java)) FileLogger.d(TAG, "成功停止BLE服务") } catch (e: Exception) { FileLogger.e(TAG, "停止BLE服务失败", e) diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeReceiver.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeReceiver.kt index cf3def93d..0214d8e35 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeReceiver.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeReceiver.kt @@ -3,14 +3,13 @@ package com.yunqiinnovation.ble_service import android.content.* import android.bluetooth.le.* import android.os.Build +import android.view.KeyEvent +import android.media.AudioManager import com.yunqiinnovation.deepvoice_utils.FileLogger -import android.util.Log -import android.os.Handler -import android.os.Looper +import com.yunqiinnovation.ble_service.BleService /** * BLE唤醒接收器 * 当耳机发送含特定UUID广播时,系统会触发此接收器 - * 接收器会解析MAC地址并启动前台服务进行连接 */ class BleWakeReceiver : BroadcastReceiver() { @@ -19,72 +18,60 @@ class BleWakeReceiver : BroadcastReceiver() { } override fun onReceive(ctx: Context, intent: Intent) { - // 获取PendingResult以防止接收器过早结束 - val pendingResult = goAsync() - + FileLogger.init(ctx) try { - FileLogger.init(ctx) - FileLogger.d(TAG, "onReceive — 收到唤醒广播: action=${intent.action}, ${BleConst.ACTION_SCAN_RESULT}") - + if (intent.action != BleConst.ACTION_SCAN_RESULT) { - pendingResult.finish() return } - val list = intent.getParcelableArrayListExtra( BluetoothLeScanner.EXTRA_LIST_SCAN_RESULT - )?: run { - pendingResult.finish() - return - } + ) ?: return if (list.isEmpty()) { FileLogger.d(TAG, "扫描结果为空") - pendingResult.finish() return } val mac = list.first().device.address - FileLogger.d(TAG, "被系统唤醒,发现设备 $mac") - - // 使用Handler确保在主线程上下文中启动服务 - Handler(Looper.getMainLooper()).post { - try { - // 首先尝试启动普通服务 - val svc = Intent(ctx, BleForegroundService::class.java).apply { - putExtra("mac", mac) - action = BleConst.ACTION_SCAN_RESULT - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // 对于Android 8及以上,优先尝试普通服务启动,如果APP在前台可以成功 - try { - ctx.startService(svc) - FileLogger.d(TAG, "使用普通服务启动成功") - } catch (e: Exception) { - FileLogger.d(TAG, "普通服务启动失败,尝试前台服务: ${e.message}") - // 尝试前台服务启动 - try { - ctx.startForegroundService(svc) - FileLogger.d(TAG, "前台服务启动成功") - } catch (e: Exception) { - FileLogger.e(TAG, "前台服务启动也失败: ${e.message}", e) - } - } - } else { - ctx.startService(svc) - } - } catch (e: Exception) { - FileLogger.e(TAG, "启动服务异常: ${e.message}", e) - } finally { - // 确保完成pendingResult - pendingResult.finish() - } + // FileLogger.d(TAG, "被系统唤醒,发现设备 $mac") + + if(!BleMediaService.isServiceRunning()) { + val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + val keyEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) + am.dispatchMediaKeyEvent(keyEvent) + // val mediaIntent = Intent(Intent.ACTION_MEDIA_BUTTON) + // mediaIntent.putExtra(Intent.EXTRA_KEY_EVENT, keyEvent) + // mediaIntent.putExtra("mac", mac) // 额外传递MAC地址 + + // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // // 通过AudioManager发送媒体按键 + // am.dispatchMediaKeyEvent(keyEvent) + // // 发送UP事件 + // // am.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)) + // } else { + // // 通过广播发送媒体按钮 + // mediaIntent.component = ComponentName(ctx.packageName, "androidx.media.session.MediaButtonReceiver") + // ctx.sendBroadcast(mediaIntent) + // } } - } catch (e: Exception) { - FileLogger.e(TAG, "处理BLE广播异常: ${e.message}", e) - pendingResult.finish() + if (!BleService.isConnected()) { + val serviceIntent = Intent(ctx, BleMediaService::class.java).apply { + action = BleConst.ACTION_SCAN_RESULT + putExtra("mac", mac) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ctx.startForegroundService(serviceIntent) + } else { + ctx.startService(serviceIntent) + } + return + } + } + catch (e: Exception) { + FileLogger.e(TAG, "接收器异常: ${e}") } } } \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BootReceiver.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BootReceiver.kt index 3e7065a66..4b1bb5905 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BootReceiver.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BootReceiver.kt @@ -12,8 +12,8 @@ class BootReceiver : BroadcastReceiver() { override fun onReceive(ctx: Context, i: Intent) { if (i.action == Intent.ACTION_BOOT_COMPLETED) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - ctx.startForegroundService(Intent(ctx, BleForegroundService::class.java)) - else ctx.startService(Intent(ctx, BleForegroundService::class.java)) + ctx.startForegroundService(Intent(ctx, BleMediaService::class.java)) + else ctx.startService(Intent(ctx, BleMediaService::class.java)) } } } \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/ChatAgent.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/ChatAgent.kt new file mode 100644 index 000000000..64e82778f --- /dev/null +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/ChatAgent.kt @@ -0,0 +1,162 @@ +package com.yunqiinnovation.ble_service + +import android.content.Context +import com.yunqiinnovation.agent_service.AgentService +import com.yunqiinnovation.agent_service.AgentServiceListener +import com.yunqiinnovation.deepvoice_utils.FileLogger + + +/** + * 蓝牙语音交互代理 + * 连接BleService和AgentService,响应蓝牙设备唤醒信号,启动语音识别和交互 + */ +object ChatAgent : BleService.Callback, AgentServiceListener { + private const val TAG = "ChatAgent" + + // 是否已初始化 + private var isInitialized = false + + // 是否已注册BLE回调 + private var isRegisteredToBle = false + + /** + * 初始化语音代理 + * @param context 上下文 + * @param config 语音服务配置 + */ + fun initialize(context: Context, config: Map): Boolean { + if (isInitialized) return true + + try { + FileLogger.d(TAG, "初始化语音代理..., $config") + + // 1. 初始化AgentService + if (!AgentService.initialize(context, config)) { + FileLogger.e(TAG, "语音服务初始化失败") + return false + } + + // 2. 初始化BleService并注册回调 + if (BleService.initialize(context)) { + if (BleService.addCallback(this)) { + isRegisteredToBle = true + FileLogger.d(TAG, "已注册BLE回调") + } else { + FileLogger.w(TAG, "BLE回调注册失败") + } + } else { + FileLogger.e(TAG, "BLE服务初始化失败") + return false + } + + isInitialized = true + FileLogger.d(TAG, "语音代理初始化成功") + return true + } catch (e: Exception) { + FileLogger.e(TAG, "初始化语音代理异常", e) + return false + } + } + + /** + * 手动启动语音识别 + */ + fun startListening(): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "语音代理未初始化") + return false + } + + return AgentService.startRecognition() + } + + /** + * 停止语音识别 + */ + fun stopListening() { + AgentService.stopRecognition() + } + + /** + * 处理文本输入 + */ + fun processTextInput(text: String, speakResponse: Boolean = false): Boolean { + return AgentService.processTextInput(text, speakResponse) + } + + /** + * 停止TTS + */ + fun stopTts() { + AgentService.stopTts() + } + + /** + * 清理资源 + */ + fun dispose() { + // 取消BLE回调注册 + if (isRegisteredToBle) { + BleService.removeCallback(this) + isRegisteredToBle = false + } + + // 释放AgentService资源 + AgentService.dispose() + + isInitialized = false + FileLogger.d(TAG, "语音代理资源已释放") + } + + //============================================================================================ + // BleService.Callback 接口实现 + //============================================================================================ + override fun onScanResult(devices: List>) { + // 不处理扫描结果 + } + + override fun onConnectionStateChanged(state: Int) { + // 可选:处理连接状态变化 + if (state == BleConst.STATE_CONNECTED) { + FileLogger.d(TAG, "Ble设备已连接,准备好语音交互") + } else if (state == BleConst.STATE_DISCONNECTED) { + FileLogger.d(TAG, "Ble设备已断开,停止语音服务") + // 设备断开连接,停止语音服务 + // stopListening() + } + } + + override fun onAudioDataReceived(data: ByteArray) { + // 可选:处理音频数据 + } + + /** + * 处理唤醒信号 + * 在收到唤醒信号时启动语音识别 + */ + override fun onWakeupSignalReceived() { + FileLogger.d(TAG, "收到唤醒信号,启动语音识别") + + // 停止当前TTS,避免冲突 + AgentService.interruptCurrentResponse() + AgentService.speakText("我在!") + // 启动语音识别 + if (AgentService.isRecognitionActive) { + // 如果已经在识别,则重新启动 + stopListening() + } + + startListening() + } + + override fun onDeviceInfoReceived(infoType: Int, infoData: Map) { + // 不处理设备信息 + } + + //============================================================================================ + // AgentServiceListener 接口实现 + //============================================================================================ + override fun onEvent(eventName: String, data: Map) { + + } +} \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/utils.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/utils.kt new file mode 100644 index 000000000..339aed64e --- /dev/null +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/utils.kt @@ -0,0 +1,144 @@ +package com.yunqiinnovation.ble_service + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.yunqiinnovation.deepvoice_utils.FileLogger + +/** + * 通用工具类,包含安全存储等功能 + */ +object Utils { + private const val TAG = "Utils" + private const val SECURE_PREFS_FILENAME = "deep_voice_secure_prefs" + private const val KEY_SESSION_ID = "session_id" + + /** + * 安全存储数据 + * @param context 上下文 + * @param dataMap 要存储的键值对字典 + * @return 是否成功存储 + */ + fun saveSecureData(context: Context, dataMap: Map): Boolean { + return try { + // 创建或获取主密钥 + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + // 创建加密SharedPreferences + val sharedPreferences = EncryptedSharedPreferences.create( + context, + SECURE_PREFS_FILENAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + // 获取或生成会话ID + var sessionId = sharedPreferences.getString(KEY_SESSION_ID, "") ?: "" + if (sessionId.isEmpty()) { + sessionId = java.util.UUID.randomUUID().toString() + } + + // 开始编辑会话 + val editor = sharedPreferences.edit() + + // 保存会话ID + editor.putString(KEY_SESSION_ID, sessionId) + + // 保存所有键值对 + for ((key, value) in dataMap) { + if (value != null) { + editor.putString(key, value) + } else { + editor.remove(key) + } + } + + // 提交更改 + editor.apply() + + FileLogger.d(TAG, "数据已安全保存到加密存储中") + true + } catch (e: Exception) { + FileLogger.e(TAG, "保存数据到加密存储失败", e) + false + } + } + + /** + * 从安全存储中读取所有数据 + * @param context 上下文 + * @return 包含所有数据的键值对字典 + */ + fun loadAllSecureData(context: Context): Map { + return try { + val resultMap = mutableMapOf() + + // 创建或获取主密钥 + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + // 创建加密SharedPreferences + val sharedPreferences = EncryptedSharedPreferences.create( + context, + SECURE_PREFS_FILENAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + // 读取所有数据 + for ((key, value) in sharedPreferences.all) { + if (value is String) { + resultMap[key] = value + } + } + + FileLogger.d(TAG, "已从加密存储加载所有数据") + resultMap + } catch (e: Exception) { + FileLogger.e(TAG, "从加密存储加载所有数据失败", e) + emptyMap() + } + } + + /** + * 清除所有安全存储的数据 + * @param context 上下文 + * @return 是否成功清除 + */ + fun clearAllSecureData(context: Context): Boolean { + return try { + // 创建或获取主密钥 + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + // 创建加密SharedPreferences + val sharedPreferences = EncryptedSharedPreferences.create( + context, + SECURE_PREFS_FILENAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + // 生成新的会话ID + val sessionId = java.util.UUID.randomUUID().toString() + + // 清除所有数据 + sharedPreferences.edit().clear() + .putString(KEY_SESSION_ID, sessionId) + .apply() + + FileLogger.d(TAG, "已清除所有安全存储的数据") + true + } catch (e: Exception) { + FileLogger.e(TAG, "清除安全存储数据失败", e) + false + } + } +} \ No newline at end of file diff --git a/local_plugins/ble_service/android/src/main/res/raw/silence.mp3 b/local_plugins/ble_service/android/src/main/res/raw/silence.mp3 new file mode 100644 index 000000000..f5bd92f88 Binary files /dev/null and b/local_plugins/ble_service/android/src/main/res/raw/silence.mp3 differ diff --git a/local_plugins/ble_service/lib/ble_service.dart b/local_plugins/ble_service/lib/ble_service.dart index c42707b75..f554a8761 100644 --- a/local_plugins/ble_service/lib/ble_service.dart +++ b/local_plugins/ble_service/lib/ble_service.dart @@ -163,9 +163,104 @@ class BleService { } } - /// 获取已连接设备地址 - 使用其他方式获取设备地址,不再直接调用此方法 - Future getConnectedDeviceAddress() async { - // 简化版处理,返回空值 - return null; + /// 注册BLE后台扫描 + /// + /// 注册后,即使应用被杀死,系统也会在检测到设备广播时唤醒应用 + /// 这是确保设备可以在应用后台时被自动连接的关键功能 + Future registerBackgroundScan() async { + try { + final result = await _methodChannel.invokeMethod('registerBackgroundScan'); + return result ?? false; + } catch (e) { + print('注册后台扫描失败: $e'); + return false; + } + } + + /// 注销BLE后台扫描 + /// + /// 当不再需要后台扫描功能时调用,例如用户主动断开连接时 + Future unregisterBackgroundScan() async { + try { + final result = await _methodChannel.invokeMethod('unregisterBackgroundScan'); + return result ?? false; + } catch (e) { + print('注销后台扫描失败: $e'); + return false; + } + } + + //======================================================================== + // 以下是ChatAgent相关功能 + //======================================================================== + + /// 初始化语音交互代理 + /// + /// [config] 配置参数,必须包含: + /// - azureSpeechKey: Azure语音服务API密钥 + /// - azureSpeechRegion: Azure语音服务区域 + /// - openaiApiKey: OpenAI API密钥 + /// - openaiBaseUrl: OpenAI API基础URL + /// - openaiModel: OpenAI模型名称,默认为'gpt-3.5-turbo' + Future initializeChatAgent(Map config) async { + try { + final result = await _methodChannel.invokeMethod('initializeChatAgent', config); + return result ?? false; + } catch (e) { + print('初始化语音交互代理失败: $e'); + return false; + } + } + + /// 启动语音识别 + /// + /// 用于手动启动语音识别,通常不需要直接调用,因为在唤醒时会自动启动 + Future startListening() async { + try { + final result = await _methodChannel.invokeMethod('startListening'); + return result ?? false; + } catch (e) { + print('启动语音识别失败: $e'); + return false; + } + } + + /// 停止语音识别 + Future stopListening() async { + try { + final result = await _methodChannel.invokeMethod('stopListening'); + return result ?? false; + } catch (e) { + print('停止语音识别失败: $e'); + return false; + } + } + + /// 处理文本输入 + /// + /// [text] 用户输入的文本 + /// [speakResponse] 是否使用TTS朗读回复,默认为false + Future processTextInput(String text, {bool speakResponse = false}) async { + try { + final result = await _methodChannel.invokeMethod('processTextInput', { + 'text': text, + 'speakResponse': speakResponse, + }); + return result ?? false; + } catch (e) { + print('处理文本输入失败: $e'); + return false; + } + } + + /// 停止TTS播放 + Future stopTts() async { + try { + final result = await _methodChannel.invokeMethod('stopTts'); + return result ?? false; + } catch (e) { + print('停止TTS播放失败: $e'); + return false; + } } } \ No newline at end of file diff --git a/local_plugins/bytedance_speech/android/build.gradle.kts b/local_plugins/bytedance_speech/android/build.gradle.kts index f6404d154..0073e010c 100644 --- a/local_plugins/bytedance_speech/android/build.gradle.kts +++ b/local_plugins/bytedance_speech/android/build.gradle.kts @@ -8,7 +8,7 @@ plugins { android { namespace = "com.yunqiinnovation.ble_service" - compileSdk = 34 + compileSdk = 35 defaultConfig { minSdk = 26 diff --git a/local_plugins/classic_bluetooth/android/build.gradle.kts b/local_plugins/classic_bluetooth/android/build.gradle.kts index 1e055e488..fa40cf9fa 100644 --- a/local_plugins/classic_bluetooth/android/build.gradle.kts +++ b/local_plugins/classic_bluetooth/android/build.gradle.kts @@ -11,7 +11,7 @@ android { namespace = "com.yunqiinnovation.classic_bluetooth" // 目标 SDK 版本 - compileSdk = 33 + compileSdk = 35 defaultConfig { // 最低 SDK 版本 diff --git a/local_plugins/deepvoice_utils/android/build.gradle.kts b/local_plugins/deepvoice_utils/android/build.gradle.kts index 4bfdcceaa..61848a582 100644 --- a/local_plugins/deepvoice_utils/android/build.gradle.kts +++ b/local_plugins/deepvoice_utils/android/build.gradle.kts @@ -11,12 +11,12 @@ android { namespace = "com.yunqiinnovation.deepvoice_utils" // 目标 SDK 版本 - compileSdk = 33 + compileSdk = 35 defaultConfig { // 最低 SDK 版本 minSdk = 21 - targetSdk = 33 + targetSdk = 34 } // Java 语言级别兼容配置 diff --git a/local_plugins/jieli/android/build.gradle.kts b/local_plugins/jieli/android/build.gradle.kts index 9602cb27e..e804da665 100644 --- a/local_plugins/jieli/android/build.gradle.kts +++ b/local_plugins/jieli/android/build.gradle.kts @@ -11,7 +11,7 @@ android { namespace = "com.yunqiinnovation.jieli" // 目标 SDK 版本 - compileSdk = 33 + compileSdk = 35 defaultConfig { // 最低 SDK 版本 diff --git a/local_plugins/jl_opus/android/build.gradle.kts b/local_plugins/jl_opus/android/build.gradle.kts index 3c0b820e2..9c421307e 100644 --- a/local_plugins/jl_opus/android/build.gradle.kts +++ b/local_plugins/jl_opus/android/build.gradle.kts @@ -18,7 +18,7 @@ android { namespace = "com.yunqiinnovation.jl_opus" // 目标 SDK 版本 - compileSdk = 33 + compileSdk = 35 defaultConfig { // 最低 SDK 版本 diff --git a/local_plugins/open_ai_service/android/build.gradle.kts b/local_plugins/open_ai_service/android/build.gradle.kts index ab7c03ecf..d1247752b 100644 --- a/local_plugins/open_ai_service/android/build.gradle.kts +++ b/local_plugins/open_ai_service/android/build.gradle.kts @@ -12,7 +12,7 @@ android { namespace = "com.yunqiinnovation.open_ai_service" // 目标 SDK 版本 - compileSdk = 33 + compileSdk = 35 defaultConfig { // 最低 SDK 版本 diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt index e81425f86..d3b2efa9a 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt @@ -33,19 +33,8 @@ import okhttp3.Interceptor import io.ktor.client.request.* import io.ktor.http.* import com.yunqiinnovation.open_ai_service.mcp.CustomSseClientTransport -import io.ktor.util.valuesOf import okio.Buffer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -@Serializable -data class ServerConfig( - val servername: String, - val url: String, - val tools: List? = null -) /** * 工具类型枚举 */ @@ -72,13 +61,13 @@ interface FunctionHandler { */ class MCPSubClient( private val serverId: String, - private val config: ServerConfig, + serverUrl: String, private val customClient: HttpClient? = null ) : AutoCloseable { private val TAG = "MCPSubClient[$serverId]" // 保存服务器URL - private val serverUrl: String = config.url + private val serverUrl: String = serverUrl init { Log.d(TAG, "MCPSubClient初始化,URL: $serverUrl") @@ -91,6 +80,7 @@ class MCPSubClient( version = "1.0.0" ) ) + // 服务器提供的工具列表 private var tools: List = emptyList() @@ -168,8 +158,7 @@ class MCPSubClient( private fun processToolsList(toolsResult: ListToolsResult?) { try { // 将MCP工具列表转换为ToolUnion列表 - tools = toolsResult?.tools?.filter { tool -> config.tools.isNullOrEmpty() || config.tools.contains(tool.name) - }?.map { tool -> + tools = toolsResult?.tools?.map { tool -> // 转换为ToolUnion格式 ToolUnion.ofTool( Tool.builder() @@ -178,26 +167,26 @@ class MCPSubClient( .inputSchema( Tool.InputSchema.builder() .type(JsonValue.from(tool.inputSchema?.type ?: "object")) - .properties(if (tool.inputSchema?.properties is JsonObject) - (tool.inputSchema.properties as JsonObject).toJsonValue() - else JsonValue.from(emptyMap())) + .properties(if (tool.inputSchema?.properties is JsonObject) + (tool.inputSchema.properties as JsonObject).toJsonValue() + else JsonValue.from(emptyMap())) .putAdditionalProperty("required", JsonValue.from(tool.inputSchema?.required ?: emptyList())) .build() ) .build() ) } ?: emptyList() - + // 更新工具映射 toolMapping.clear() tools.forEach { toolUnion -> val tool = toolUnion.tool().get() toolMapping[tool.name()] = toolUnion } - + // 更新工具Map列表 updateToolMaps() - + Log.d(TAG, "成功处理 ${tools.size} 个工具") } catch (e: Exception) { Log.e(TAG, "处理工具列表失败: ${e.message}", e) @@ -222,7 +211,7 @@ class MCPSubClient( // 使用toString()获取完整JSON字符串 val schemaStr = inputSchema.toString() - // Log.d(TAG, "工具 ${tool.name()} 的Schema: $schemaStr") + Log.d(TAG, "工具 ${tool.name()} 的Schema: $schemaStr") // 解析properties和required val propertiesMap = HashMap() @@ -278,7 +267,7 @@ class MCPSubClient( propertiesMap[fieldName] = fieldMap } - // Log.d(TAG, "成功解析属性: $propertiesMap") + Log.d(TAG, "成功解析属性: $propertiesMap") } // 解析required @@ -299,7 +288,7 @@ class MCPSubClient( } } requiredList.addAll(fieldNames) - // Log.d(TAG, "解析到required字段: $requiredList") + Log.d(TAG, "解析到required字段: $requiredList") } } } catch (e: Exception) { @@ -406,6 +395,7 @@ class MCPSubClient( name = name, arguments = arguments ) + // 处理工具调用结果 val context = result?.content?.joinToString("\n") { content -> when (content) { @@ -414,9 +404,12 @@ class MCPSubClient( } } JSONObject().apply { put("name",name);put("meta",result?._meta); put("context",context) } + } catch (e: Exception) { Log.e(TAG, "调用MCP工具失败: ${e.message}", e) + "调用MCP工具失败: ${e.message}" JSONObject().apply { put("context", "调用MCP工具失败: ${e.message}")} + } } @@ -498,7 +491,7 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { // 清除现有连接 closeAllConnections() - val serverMap = mutableMapOf() + val serverMap = mutableMapOf() var configLoaded = false // 尝试解析传入的JSON配置 @@ -506,19 +499,17 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { if (mcpConfigJson.isNotEmpty()) { val mapper = ObjectMapper() try { - Log.d(TAG, "传入JSON:${mcpConfigJson}") - val configMap: Map = Json.decodeFromString(mcpConfigJson) -// val config = mapper.readValue(mcpConfigJson, Map::class.java) + val config = mapper.readValue(mcpConfigJson, Map::class.java) // 处理mcpServers格式 @Suppress("UNCHECKED_CAST") -// val mcpServers = config["mcpServers"] as? Map> - if (configMap.isNotEmpty()) { + val mcpServers = config["mcpServers"] as? Map> + if (mcpServers != null && mcpServers.isNotEmpty()) { // 从服务器配置中提取URL - configMap.forEach { (serverId, serverConfig) -> - val url = serverConfig.url + mcpServers.forEach { (serverId, serverConfig) -> + val url = serverConfig["url"] if (!url.isNullOrEmpty()) { - serverMap[serverId] = serverConfig + serverMap[serverId] = url Log.d(TAG, "从配置中添加服务器 [$serverId]: $url") } } @@ -537,10 +528,10 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { // 创建并连接所有子客户端 val connectionResults = mutableMapOf() - for ((serverId, serverConfig) in serverMap) { + for ((serverId, serverUrl) in serverMap) { try { - Log.d(TAG, "为服务器 [$serverId],[${serverConfig.url}] 创建子客户端") - val subClient = MCPSubClient(serverId, serverConfig, sharedHttpClient) + Log.d(TAG, "为服务器 [$serverId],[$serverUrl] 创建子客户端") + val subClient = MCPSubClient(serverId, serverUrl, sharedHttpClient) val connected = subClient.connect() if (connected) { @@ -710,7 +701,7 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { subClients.forEach { (serverId, client) -> val clientTools = client.getToolMaps() allToolMaps.addAll(clientTools) - Log.d(TAG, "添加子客户端 [$serverId] 的 ${clientTools.size} 个工具") + // Log.d(TAG, "添加子客户端 [$serverId] 的 ${clientTools.size} 个工具") } return allToolMaps @@ -751,7 +742,7 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { } null -> { Log.e(TAG, "未找到工具: $name") - null + JSONObject().apply { put("context","未找到工具: $name") } } } } @@ -763,16 +754,15 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { return try { val handler = localFunctions[name] if (handler != null) { - val context = handler.handle(arguments) + val context = handler.handle(arguments) JSONObject().apply { put("context", context) } } else { Log.e(TAG, "未找到本地函数: $name") - null + JSONObject().apply { put("context","未找到本地函数: $name") } } } catch (e: Exception) { Log.e(TAG, "调用本地函数失败: ${e.message}", e) - - JSONObject().apply { put("context", "调用本地函数失败: ${e.message}") } + JSONObject().apply { put("context","调用本地函数失败: ${e.message}") } } } @@ -782,7 +772,7 @@ class MCPClient(private val context: Context? = null) : AutoCloseable { private suspend fun callMcpTool(name: String, arguments: Map): JSONObject? { if (!isConnected) { Log.e(TAG, "未连接到MCP服务器", null) - return JSONObject().apply { put("context", "未连接到MCP服务器") } + return JSONObject().apply { put("context","未连接到MCP服务器") } } // 查找包含此工具的子客户端 diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt index cadcad476..98356f674 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt @@ -42,6 +42,10 @@ class OpenAIService(private val context: Context? = null) { // 是否自动处理MCP工具调用 private var autoHandleMcpTools = true + // 添加成员变量以跟踪当前请求 + private var currentStreamCall: Call? = null + private var isCanceled = false + /** * 构建curl命令用于测试 */ @@ -81,6 +85,16 @@ class OpenAIService(private val context: Context? = null) { } } + /** + * 创建系统消息 + */ + fun createSystemMessage(content: String): JSONObject { + return JSONObject().apply { + put("role", "system") + put("content", content) + } + } + /** * 创建助手消息 */ @@ -166,7 +180,7 @@ class OpenAIService(private val context: Context? = null) { */ suspend fun handleMcpToolCall(functionCall: JSONObject): JSONObject? { if (mcpClient == null || !isMcpInitialized) { - return JSONObject().apply { put("context", "MCP客户端未初始化") } + return JSONObject().apply { put("context","MCP客户端未初始化") } } try { @@ -181,7 +195,7 @@ class OpenAIService(private val context: Context? = null) { return mcpClient?.callTool(name, arguments) } catch (e: Exception) { Log.e(TAG, "处理MCP工具调用失败: ${e.message}", e) - return JSONObject().apply { put("context", "处理MCP工具调用失败: ${e.message}") } + return JSONObject().apply { put("context","处理MCP工具调用失败: ${e.message}") } } } @@ -226,17 +240,12 @@ class OpenAIService(private val context: Context? = null) { // 调用工具(统一通过MCPClient处理) val result = mcpClient?.callTool(name, arguments) - var context = "工具调用失败"; - if (result != null){ - callback.onFunctionCall(result) - context = "${result["context"]}" - } - + callback.onFunctionCallResult(functionCall, result?: JSONObject().apply { put("context","工具调用失败") }) // 不论是本地函数还是MCP工具,都直接将结果发送回OpenAI sendFunctionCallResult( messages = messages, functionCall = functionCall, - functionResult = context, + functionResult = result?.getString("context")?: "工具调用失败", callback = callback ) } catch (e: Exception) { @@ -359,6 +368,9 @@ class OpenAIService(private val context: Context? = null) { return } + // 重置取消状态 + isCanceled = false + val requestBody = JSONObject().apply { put("model", model) put("messages", messages) @@ -398,12 +410,25 @@ class OpenAIService(private val context: Context? = null) { // val curlCommand = buildCurlCommand(request, requestBody.toString()) // Log.d(TAG, "curl command: $curlCommand") - client.newCall(request).enqueue(object : Callback { + val call = client.newCall(request) + // 保存当前Call对象以便后续取消 + currentStreamCall = call + + call.enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { + // 如果是由于取消导致的失败,不报告错误 + if (isCanceled) { + return + } callback.onError(OpenAIException(e.message ?: "请求失败")) } override fun onResponse(call: Call, response: Response) { + // 检查请求是否被取消 + if (isCanceled) { + response.body?.close() + return + } if (!response.isSuccessful) { callback.onError(OpenAIException("API调用失败: ${response.code}")) @@ -422,7 +447,7 @@ class OpenAIService(private val context: Context? = null) { // 逐块读取数据,而不是一次性预取 val BUFFER_SIZE = 8192L // 8KB的块大小 - while (!Thread.currentThread().isInterrupted) { + while (!Thread.currentThread().isInterrupted && !isCanceled) { // 读取一小块数据 val bytesRead = source.read(buffer, BUFFER_SIZE) if (bytesRead == -1L) break // 流结束 @@ -434,12 +459,13 @@ class OpenAIService(private val context: Context? = null) { if (line.startsWith("data:")) { val data = line.substring(5).trim() - Log.d("OpenAIService", "$data") + // Log.d("OpenAIService", "$data") // 处理[DONE]消息 if (data == "[DONE]" || data == "[\"DONE\"]") { - Log.d(TAG, "收到[DONE]消息") val hasToolCalls = processToolCalls(finalToolCalls, callback, messages) + // Log.d(TAG, "收到[DONE]消息, $hasToolCalls") + // 只有在没有工具调用时才认为对话真正完成 if (!hasToolCalls) { callback.onComplete() @@ -504,7 +530,24 @@ class OpenAIService(private val context: Context? = null) { } } } + + // 如果是因为取消导致循环结束,不继续处理 + if (isCanceled) { + return + } + + // 处理可能尚未处理的工具调用 + val hasToolCalls = processToolCalls(finalToolCalls, callback, messages) + + // 只有在没有工具调用时才认为对话真正完成 + if (!hasToolCalls) { + callback.onComplete() + } } catch (e: Exception) { + // 如果是由于取消导致的异常,不报告错误 + if (isCanceled) { + return + } callback.onError(OpenAIException("处理响应流时出错: ${e.message}")) } finally { responseBody.close() @@ -556,7 +599,6 @@ class OpenAIService(private val context: Context? = null) { put("content", functionResult) put("tool_call_id", functionCall.optString("id", "call_${System.currentTimeMillis()}")) }) - // 发送完整对话 sendMessageStream(fullMessages, callback) @@ -581,12 +623,16 @@ class OpenAIService(private val context: Context? = null) { put("arguments", firstToolCall.arguments) put("id", firstToolCall.id) } - + Log.d(TAG, "工具调用: $functionCall") // 在协程中处理工具调用 if (messages != null) { kotlinx.coroutines.GlobalScope.launch { try { + + //通知上层回调事件,但不关心结果 + callback.onFunctionCall(functionCall) autoHandleMcpToolCall(functionCall, messages, callback) + } catch (e: Exception) { Log.e(TAG, "工具调用处理失败: ${e.message}", e) } @@ -618,6 +664,34 @@ class OpenAIService(private val context: Context? = null) { fun onComplete() fun onError(e: Exception) fun onFunctionCall(functionCall: JSONObject) + fun onFunctionCallResult(functionCall: JSONObject, functionCallResult: JSONObject) + } + + /** + * 取消当前流式请求 + * + * @return 是否成功取消 + */ + fun cancelCurrentStream(): Boolean { + try { + // 标记已取消 + isCanceled = true + + // 取消当前Call对象 + val call = currentStreamCall + if (call != null && !call.isCanceled()) { + call.cancel() + Log.d(TAG, "已取消当前流式请求") + return true + } + return false + } catch (e: Exception) { + Log.e(TAG, "取消流式请求失败: ${e.message}", e) + return false + } finally { + // 清除当前Call引用 + currentStreamCall = null + } } } diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt index 8266a47d3..09a0d0650 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt @@ -83,47 +83,6 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { } - "registerFunction" -> { - val name = call.argument("name") ?: "" - val description = call.argument("description") ?: "" - val parameters = call.argument>("parameters") - - if (name.isEmpty() || parameters == null) { - result.error("INVALID_ARGUMENT", "函数注册参数无效", null) - return - } - - val parametersJson = JSONObject(parameters) - val registered = openAIService.registerFunction(name, description, parametersJson) - result.success(registered) - } - - "sendMessage" -> { - val messagesRaw = call.argument>>("messages") ?: emptyList() - - // 转换消息格式 - val messages = JSONArray() - for (message in messagesRaw) { - messages.put(JSONObject(message)) - } - - // 对于需要在后台执行的同步阻塞操作,仍然使用executor - executor.execute { - try { - val response = openAIService.sendMessage(messages) - // 在主线程返回结果 - mainHandler.post { - result.success(response) - } - } catch (e: Exception) { - // 在主线程返回错误 - mainHandler.post { - result.error("OPENAI_ERROR", e.message, null) - } - } - } - } - "sendMessageStream" -> { val messagesRaw = call.argument>>("messages") ?: emptyList() // 检查事件接收器 @@ -165,102 +124,19 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { } override fun onFunctionCall(functionCall: JSONObject) { - Log.e("OpenAIServicePlugin", "context:${functionCall.toString()}") - val context = functionCall["context"] - if (functionCall.has("meta")) { - val meta = functionCall.get("meta") - val metaJsonObject:JsonObject = meta as JsonObject - Log.e("OpenAIServicePlugin", "工具调用结果 context:${context} meta:${meta.toString()}") - // 发送函数调用事件 - mainHandler.post { - eventSink?.success(mapOf("type" to "functionCall", "content" to context,"meta" to meta.toString())) - } - if (metaJsonObject.containsKey("card_spotify") || metaJsonObject.containsKey("card_music")) { - mainHandler.post { - eventSink?.success(mapOf("type" to "complete")) - } - } - } - -// val meta: Map? = functionCall.optJSONObject("meta")?.let { metaJson -> -// metaJson.keys().asSequence().associateWith { key -> -// metaJson.get(key) // 或 opt(key),更安全 -// } -// } ?: mapOf( -// // 如果 null,就用默认值 -// "card_spotify" to { -// "url" to "spotify:track:58kNJana4w5BIjlZE2wq5m" -// }, -// ) - - } - } - ) - - // 请求已开始,直接返回结果 - result.success(true) - } catch (e: Exception) { - // 直接在当前线程处理错误 - result.error("OPENAI_ERROR", e.message, null) - } - } - - "sendFunctionCallResult" -> { - val messagesRaw = call.argument>>("messages") ?: emptyList() - val functionCallRaw = call.argument>("functionCall") ?: emptyMap() - val functionResult = call.argument("functionResult") ?: "" - - // 检查事件接收器 - if (eventSink == null) { - result.error("NO_EVENT_SINK", "没有可用的事件流接收器", null) - return - } - - // 转换消息格式 - val messages = JSONArray() - for (message in messagesRaw) { - messages.put(JSONObject(message)) - } - - // 转换函数调用 - val functionCall = JSONObject(functionCallRaw) - - // 不再在后台线程包装OkHttp的异步调用 - try { - openAIService.sendFunctionCallResult( - messages = messages, - functionCall = functionCall, - functionResult = functionResult, - callback = object : OpenAIService.StreamCallback { - override fun onToken(token: String) { - // 发送token事件 mainHandler.post { - eventSink?.success(mapOf("type" to "token", "content" to token)) - } - } - - override fun onComplete() { - // 发送完成事件 - mainHandler.post { - eventSink?.success(mapOf("type" to "complete")) + eventSink?.success(mapOf("type" to "function_call", "content" to functionCall.toString())) } } - - override fun onError(e: Exception) { - // 发送错误事件 + + override fun onFunctionCallResult(functionCall: JSONObject, functionCallResult: JSONObject) { mainHandler.post { - eventSink?.success(mapOf("type" to "error", "content" to e.message)) + eventSink?.success( + mapOf("type" to "function_call_result" + ,"function_call" to functionCall.toString() + , "result" to functionCallResult.toString())) } } - - override fun onFunctionCall(nestedFunctionCall: JSONObject) { - Log.e("OpenAIServicePlugin", "工具调用结果: ${nestedFunctionCall.toString()}") - // 发送函数调用事件 -// mainHandler.post { -// val functionCallMap = nestedFunctionCall.toMap() -// eventSink?.success(mapOf("type" to "functionCall", "content" to functionCallMap)) -// } - } } ) @@ -271,6 +147,7 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { result.error("OPENAI_ERROR", e.message, null) } } + "createUserMessage" -> { val content = call.argument("content") ?: "" @@ -283,12 +160,7 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { val message = openAIService.createAssistantMessage(content) result.success(message.toMap()) } - - "initializeMcpClient" -> initializeMcpClient(call, result) - "isMcpInitialized" -> isMcpInitialized(call, result) - "closeMcpClient" -> closeMcpClient(call, result) - "handleMcpToolCall" -> handleMcpToolCall(call, result) - + else -> { result.notImplemented() } diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt index 875895d65..e26a2c7ee 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt @@ -3,11 +3,20 @@ package com.yunqiinnovation.open_ai_service import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager import android.net.Uri +import android.os.Bundle import android.provider.ContactsContract +import android.provider.AlarmClock +import android.provider.CalendarContract +import android.provider.Settings import android.telephony.SmsManager import android.util.Log import org.json.JSONObject +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume /** * 系统功能处理器 - 负责调用系统API执行各种功能以及注册系统功能 @@ -39,6 +48,15 @@ class SystemFunctionHandler(private val context: Context) { // 注册打开应用函数 registerOpenAppFunction(client) + // 注册设置提醒函数 + registerSetReminderFunction(client) + + // 注册获取当前时间函数 + registerGetCurrentTimeFunction(client) + + // 注册获取当前位置函数 + registerGetCurrentLocationFunction(client) + Log.d(TAG, "所有系统功能注册完成") } catch (e: Exception) { Log.e(TAG, "注册系统功能失败: ${e.message}", e) @@ -52,7 +70,7 @@ class SystemFunctionHandler(private val context: Context) { private fun registerExitInteractionFunction(client: MCPClient) { client.registerLocalFunction( "exit_interaction", - "退出当前语音交互", + "结束当前交互", JSONObject(""" { "type": "object", @@ -63,9 +81,9 @@ class SystemFunctionHandler(private val context: Context) { object : FunctionHandler { override suspend fun handle(arguments: Map): String { // 调用发送广播的函数 - sendExitInteractionBroadcast() + // sendExitInteractionBroadcast() - return "{\"result\": \"已退出语音交互\"}" + return "{\"result\": \"已结束当前交互\"}" } } ) @@ -191,6 +209,100 @@ class SystemFunctionHandler(private val context: Context) { Log.d(TAG, "已注册打开应用功能") } + /** + * 注册设置提醒函数 + */ + private fun registerSetReminderFunction(client: MCPClient) { + client.registerLocalFunction( + "set_reminder", + "设置提醒事项", + JSONObject(""" + { + "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"] + } + """), + object : FunctionHandler { + override suspend fun handle(arguments: Map): String { + val title = arguments["title"] as? String ?: "" + val content = arguments["content"] as? String ?: "" + val time = arguments["time"] as? String ?: "" + val result = setReminder(title, content, time) + return "{\"result\": \"${result.message}\"}" + } + } + ) + Log.d(TAG, "已注册设置提醒功能") + } + + /** + * 注册获取当前时间函数 + */ + private fun registerGetCurrentTimeFunction(client: MCPClient) { + client.registerLocalFunction( + "get_current_time", + "获取当前日期和时间", + JSONObject(""" + { + "type": "object", + "properties": { + "format": { + "type": "string", + "description": "时间格式,可选,默认为标准格式" + } + }, + "required": [] + } + """), + object : FunctionHandler { + override suspend fun handle(arguments: Map): String { + val format = arguments["format"] as? String + val result = getCurrentTime(format) + return "{\"result\": \"${result.message}\", \"time\": \"${result.message}\"}" + } + } + ) + Log.d(TAG, "已注册获取当前时间功能") + } + + /** + * 注册获取当前位置函数 + */ + private fun registerGetCurrentLocationFunction(client: MCPClient) { + client.registerLocalFunction( + "get_current_location", + "获取当前地理位置", + JSONObject(""" + { + "type": "object", + "properties": {}, + "required": [] + } + """), + object : FunctionHandler { + override suspend fun handle(arguments: Map): String { + val result = getCurrentLocation() + return "{\"result\": \"${result.message}\", \"success\": ${result.success}}" + } + } + ) + Log.d(TAG, "已注册获取当前位置功能") + } + /** * 发送退出交互广播 @@ -412,6 +524,347 @@ class SystemFunctionHandler(private val context: Context) { return "" } + /** + * 设置提醒 + * @param title 提醒标题 + * @param content 提醒内容(可选) + * @param timeStr 提醒时间,格式为"yyyy-MM-dd HH:mm" + * @return 操作结果 + */ + fun setReminder(title: String, content: String, timeStr: String): Result { + Log.d(TAG, "设置提醒: $title, 时间: $timeStr") + + try { + // 解析时间字符串 + val time = try { + val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm", java.util.Locale.getDefault()) + dateFormat.parse(timeStr)?.time ?: System.currentTimeMillis() + } catch (e: Exception) { + Log.e(TAG, "解析时间失败: $timeStr, ${e.message}") + return Result( + success = false, + message = "提醒时间格式无效,请使用'yyyy-MM-dd HH:mm'格式" + ) + } + + // 使用系统日历应用创建提醒 + val intent = Intent(Intent.ACTION_INSERT).apply { + data = CalendarContract.Events.CONTENT_URI + putExtra(CalendarContract.Events.TITLE, title) + putExtra(CalendarContract.Events.DESCRIPTION, content) + putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, time) + putExtra(CalendarContract.EXTRA_EVENT_END_TIME, time + 3600000) // 默认1小时 + putExtra(CalendarContract.Events.HAS_ALARM, 1) + putExtra(CalendarContract.Events.AVAILABILITY, CalendarContract.Events.AVAILABILITY_BUSY) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + // 检查是否有处理该Intent的应用 + val calendarActivity = intent.resolveActivity(context.packageManager) + if (calendarActivity != null) { + context.startActivity(intent) + return Result( + success = true, + message = "已创建提醒: $title, 时间: $timeStr" + ) + } else { + // 备选方案:如果没有日历应用,尝试创建闹钟 + try { + // 解析小时和分钟 + val timeParts = timeStr.split(" ")[1].split(":") + val hour = timeParts[0].toInt() + val minute = timeParts[1].toInt() + + val alarmIntent = Intent(AlarmClock.ACTION_SET_ALARM) + alarmIntent.putExtra(AlarmClock.EXTRA_MESSAGE, "$title: $content") + alarmIntent.putExtra(AlarmClock.EXTRA_HOUR, hour) + alarmIntent.putExtra(AlarmClock.EXTRA_MINUTES, minute) + alarmIntent.putExtra(AlarmClock.EXTRA_SKIP_UI, false) + alarmIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + + if (alarmIntent.resolveActivity(context.packageManager) != null) { + context.startActivity(alarmIntent) + return Result( + success = true, + message = "已创建闹钟提醒: $title, 时间: $timeStr" + ) + } + } catch (e: Exception) { + Log.e(TAG, "创建闹钟失败: ${e.message}", e) + } + + return Result( + success = false, + message = "未找到可用的日历或闹钟应用" + ) + } + } catch (e: Exception) { + Log.e(TAG, "设置提醒失败: ${e.message}", e) + return Result( + success = false, + message = "设置提醒失败: ${e.message}" + ) + } + } + + /** + * 获取当前时间 + * @param format 时间格式(可选) + * @return 操作结果 + */ + fun getCurrentTime(format: String? = null): Result { + try { + val calendar = java.util.Calendar.getInstance() + val date = calendar.time + + val timeStr = when (format?.lowercase(java.util.Locale.getDefault())) { + "short" -> { + val timeFormat = java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) + timeFormat.format(date) + } + "date" -> { + val dateFormat = java.text.SimpleDateFormat("yyyy年MM月dd日", java.util.Locale.getDefault()) + dateFormat.format(date) + } + "time" -> { + val timeFormat = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()) + timeFormat.format(date) + } + "full" -> { + val fullFormat = java.text.SimpleDateFormat("yyyy年MM月dd日 EEEE HH:mm:ss", java.util.Locale.getDefault()) + fullFormat.format(date) + } + "iso" -> { + val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.getDefault()) + isoFormat.format(date) + } + else -> { + val defaultFormat = java.text.SimpleDateFormat("yyyy年MM月dd日 HH:mm", java.util.Locale.getDefault()) + defaultFormat.format(date) + } + } + + // 获取星期几 + val weekDay = when (calendar.get(java.util.Calendar.DAY_OF_WEEK)) { + java.util.Calendar.SUNDAY -> "星期日" + java.util.Calendar.MONDAY -> "星期一" + java.util.Calendar.TUESDAY -> "星期二" + java.util.Calendar.WEDNESDAY -> "星期三" + java.util.Calendar.THURSDAY -> "星期四" + java.util.Calendar.FRIDAY -> "星期五" + java.util.Calendar.SATURDAY -> "星期六" + else -> "" + } + + // 如果不是返回短格式,则添加星期几(除非format已经包含星期) + val resultTime = when { + format == "short" || format == "time" -> timeStr + format == "full" -> timeStr // full格式已经包含了星期 + else -> "$timeStr $weekDay" + } + + Log.d(TAG, "获取当前时间成功: $resultTime") + return Result( + success = true, + message = resultTime + ) + } catch (e: Exception) { + Log.e(TAG, "获取当前时间失败: ${e.message}", e) + return Result( + success = false, + message = "获取当前时间失败: ${e.message}" + ) + } + } + + /** + * 获取当前位置 + * @return 操作结果 + */ + suspend fun getCurrentLocation(): Result = suspendCancellableCoroutine { continuation -> + Log.d(TAG, "开始获取当前位置") + + try { + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + + // 检查位置权限 + val fineLocationPermission = android.Manifest.permission.ACCESS_FINE_LOCATION + val coarseLocationPermission = android.Manifest.permission.ACCESS_COARSE_LOCATION + + val hasFineLocationPermission = context.checkPermission( + fineLocationPermission, android.os.Process.myPid(), android.os.Process.myUid() + ) == PackageManager.PERMISSION_GRANTED + + val hasCoarseLocationPermission = context.checkPermission( + coarseLocationPermission, android.os.Process.myPid(), android.os.Process.myUid() + ) == PackageManager.PERMISSION_GRANTED + + if (!hasFineLocationPermission && !hasCoarseLocationPermission) { + Log.d(TAG, "没有位置权限") + + // 如果没有权限,返回需要授权的结果 + continuation.resume(Result( + success = false, + message = "需要位置权限才能获取当前位置" + )) + return@suspendCancellableCoroutine + } + + // 检查位置服务是否开启 + if (!locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) && + !locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + + Log.d(TAG, "位置服务未启用") + + // 提醒用户打开位置服务 + try { + val settingsIntent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + context.startActivity(settingsIntent) + } catch (e: Exception) { + Log.e(TAG, "无法打开位置设置: ${e.message}") + } + + continuation.resume(Result( + success = false, + message = "位置服务未启用,请打开GPS或网络定位" + )) + return@suspendCancellableCoroutine + } + + // 尝试先获取最后已知位置 + var bestLocation: Location? = null + + try { + // 检查网络提供者最后已知位置 + if (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + val networkLocation = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) + if (networkLocation != null) { + bestLocation = networkLocation + Log.d(TAG, "获取到网络位置: ${networkLocation.latitude}, ${networkLocation.longitude}") + } + } + + // 检查GPS提供者最后已知位置 + if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { + val gpsLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) + if (gpsLocation != null && (bestLocation == null || gpsLocation.accuracy < bestLocation.accuracy)) { + bestLocation = gpsLocation + Log.d(TAG, "获取到GPS位置: ${gpsLocation.latitude}, ${gpsLocation.longitude}") + } + } + } catch (e: Exception) { + Log.e(TAG, "获取最后已知位置失败: ${e.message}") + } + + // 如果有最后已知位置,直接返回结果 + if (bestLocation != null) { + val message = "当前位置: 经度 ${bestLocation.longitude}, 纬度 ${bestLocation.latitude}" + Log.d(TAG, message) + continuation.resume(Result( + success = true, + message = message + )) + return@suspendCancellableCoroutine + } + + // 如果没有最后已知位置,尝试请求更新位置(设置10秒超时) + val locationListener = object : LocationListener { + override fun onLocationChanged(location: Location) { + // 收到位置更新 + val message = "当前位置: 经度 ${location.longitude}, 纬度 ${location.latitude}" + Log.d(TAG, "位置更新: $message") + locationManager.removeUpdates(this) + + if (continuation.isActive) { + continuation.resume(Result( + success = true, + message = message + )) + } + } + + @Deprecated("Deprecated in Java") + override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) { + // 不需要处理 + } + + override fun onProviderEnabled(provider: String) { + // 不需要处理 + } + + override fun onProviderDisabled(provider: String) { + // 如果提供者被禁用,检查是否还有其他可用提供者 + if (!locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) && + !locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + + locationManager.removeUpdates(this) + + if (continuation.isActive) { + continuation.resume(Result( + success = false, + message = "位置服务已被禁用" + )) + } + } + } + } + + // 注册位置更新请求 + if (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + locationManager.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, + 0L, + 0f, + locationListener + ) + } + + if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { + locationManager.requestLocationUpdates( + LocationManager.GPS_PROVIDER, + 0L, + 0f, + locationListener + ) + } + + // 设置超时取消 + continuation.invokeOnCancellation { + try { + locationManager.removeUpdates(locationListener) + } catch (e: Exception) { + Log.e(TAG, "取消位置更新失败: ${e.message}") + } + } + + // 启动超时处理 + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + if (continuation.isActive) { + try { + locationManager.removeUpdates(locationListener) + continuation.resume(Result( + success = false, + message = "获取位置超时,请稍后重试" + )) + } catch (e: Exception) { + Log.e(TAG, "超时处理失败: ${e.message}") + } + } + }, 10000L) // 10秒超时 + + } catch (e: Exception) { + Log.e(TAG, "获取位置过程中发生异常: ${e.message}") + if (continuation.isActive) { + continuation.resume(Result( + success = false, + message = "获取位置失败: ${e.message}" + )) + } + } + } + /** * 操作结果数据类 */ diff --git a/local_plugins/opus/android/build.gradle.kts b/local_plugins/opus/android/build.gradle.kts index e27e0467b..93c01657a 100644 --- a/local_plugins/opus/android/build.gradle.kts +++ b/local_plugins/opus/android/build.gradle.kts @@ -12,7 +12,7 @@ android { namespace = "com.yunqiinnovation.opus" // 目标 SDK 版本 - compileSdk = 31 + compileSdk = 35 defaultConfig { // 最低 SDK 版本 diff --git a/local_plugins/spotify_sdk/android/build.gradle b/local_plugins/spotify_sdk/android/build.gradle index c11324b59..88f4f0d66 100644 --- a/local_plugins/spotify_sdk/android/build.gradle +++ b/local_plugins/spotify_sdk/android/build.gradle @@ -32,7 +32,7 @@ android { namespace 'de.minimalme.spotify_sdk' } - compileSdk 34 + compileSdk 35 compileOptions { sourceCompatibility = JavaVersion.VERSION_17 diff --git a/local_plugins/volcano_speech/android/build.gradle.kts b/local_plugins/volcano_speech/android/build.gradle.kts index d46265ee5..cf924302d 100644 --- a/local_plugins/volcano_speech/android/build.gradle.kts +++ b/local_plugins/volcano_speech/android/build.gradle.kts @@ -27,7 +27,7 @@ plugins { // 配置android扩展 configure { namespace = "com.yunqiinnovation.volcano_speech" - compileSdkVersion(33) + compileSdkVersion(35) defaultConfig { minSdk = 21 diff --git a/pubspec.yaml b/pubspec.yaml index ff72848c8..2cbc56592 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -84,6 +84,8 @@ dependencies: path: local_plugins/deepvoice_utils classic_bluetooth: path: local_plugins/classic_bluetooth + agent_service: + path: local_plugins/agent_service dev_dependencies: diff --git a/test.json b/test.json index 99bc3f2d6..8b7c60bb7 100644 --- a/test.json +++ b/test.json @@ -13,4 +13,6 @@ adb shell am broadcast \ adb shell dumpsys package com.yunqiinnovation.deepsound | grep BleWakeReceiver - adb shell dumpsys package com.yunqiinnovation.deepsound | grep receiver \ No newline at end of file + adb shell dumpsys package com.yunqiinnovation.deepsound | grep receiver + + adb logcat -v time | grep -i BleForegroundService \ No newline at end of file diff --git a/test2.json b/test2.json new file mode 100644 index 000000000..5c6e10e7f --- /dev/null +++ b/test2.json @@ -0,0 +1,101 @@ +curl -v -X POST -H 'Content-Type: application/json' -H 'Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6' -H 'Accept: text/event-stream' -d '{ + "model": "doubao-1-5-pro-32k-250115", + "messages": [ + [], + { + "role": "user", + "content": "你好,你能听到我在说话吗?" + } + ], + "temperature": 0.7, + "max_tokens": 2000, + "stream": true, + "tools": [ + { + "type": "function", + "function": { + "name": "exit_interaction", + "description": "退出当前语音交互", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "enter_translation_mode", + "description": "用户请求进入实时翻译模式时,启动实时翻译功能", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "send_text_message", + "description": "发送短信", + "parameters": { + "type": "object", + "properties": { + "contact": { + "type": "string", + "description": "联系人姓名或电话号码" + }, + "message": { + "type": "string", + "description": "短信内容" + } + }, + "required": [ + "contact", + "message" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "make_phone_call", + "description": "拨打电话", + "parameters": { + "type": "object", + "properties": { + "contact": { + "type": "string", + "description": "联系人姓名或电话号码" + } + }, + "required": [ + "contact" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "open_app", + "description": "当用户请求打开特定应用时,启动相应应用", + "parameters": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "应用名称" + } + }, + "required": [ + "app_name" + ] + } + } + } + ] +}' 'https: //ark.cn-beijing.volces.com/api/v3/chat/completions' \ No newline at end of file