45 changed files with 4207 additions and 668 deletions
@ -1,456 +0,0 @@ |
|||
package com.yunqiinnovation.deepsound |
|||
|
|||
import android.content.Context |
|||
import org.json.JSONArray |
|||
import org.json.JSONObject |
|||
import android.util.Log |
|||
import com.yunqiinnovation.deepsound.core.utils.FileLogger |
|||
import com.yunqiinnovation.azure_speech.AzureAsrHelper |
|||
import com.yunqiinnovation.volcano_speech.VolcanoTtsHelper |
|||
import com.yunqiinnovation.open_ai_service.OpenAIService |
|||
|
|||
|
|||
/** |
|||
* 语音交互处理器 - 处理语音识别、TTS和AI对话相关逻辑 |
|||
*/ |
|||
class VoiceInteractionHandler( |
|||
private val context: Context, |
|||
private val azureSpeechKey: String, |
|||
private val azureSpeechRegion: String, |
|||
private val openaiApiKey: String, |
|||
private val openaiBaseUrl: String = "", |
|||
private val openaiModel: String = "", |
|||
private val volcanoSpeechAppId: String, |
|||
private val volcanoSpeechAppToken: String |
|||
) { |
|||
private val TAG = "VoiceInteractionHandler" |
|||
|
|||
// Azure服务 |
|||
private var azureAsrHelper: AzureAsrHelper? = null |
|||
private var volcanoTtsHelper: VolcanoTtsHelper? = null |
|||
|
|||
// OpenAI服务 |
|||
private val openAIService = OpenAIService() |
|||
|
|||
|
|||
// 语音功能处理 |
|||
private val voiceFunctionHandler = VoiceFunctionHandler(openAIService, context) |
|||
|
|||
// 当前用户输入 |
|||
private var currentUserInput = "" |
|||
|
|||
// 状态 |
|||
private var isInitialized = false |
|||
var isRecognitionActive = false |
|||
private set |
|||
var isTtsSpeaking = false |
|||
private set |
|||
var hasSpeechDetected = false |
|||
private set |
|||
|
|||
// 回调 |
|||
private var callback: InteractionCallback? = null |
|||
|
|||
/** |
|||
* 初始化 |
|||
*/ |
|||
fun initialize(): Boolean { |
|||
if (isInitialized) return true |
|||
|
|||
try { |
|||
// 初始化Azure ASR |
|||
azureAsrHelper = AzureAsrHelper(context).apply { |
|||
initialize(azureSpeechKey, azureSpeechRegion) |
|||
} |
|||
|
|||
// 初始化Volcano TTS (使用大模型TTS) |
|||
volcanoTtsHelper = VolcanoTtsHelper(context).apply { |
|||
// 这里需要替换为实际的Volcano SDK初始化参数 |
|||
// 暂时使用假参数,实际使用时需要替换为真实值 |
|||
initialize(volcanoSpeechAppId, volcanoSpeechAppToken, "volc.bigasr.sauc.duration") |
|||
|
|||
} |
|||
|
|||
// 初始化OpenAI服务 |
|||
openAIService.initialize(openaiApiKey, openaiBaseUrl, openaiModel) |
|||
|
|||
// 初始化语音功能处理器 |
|||
voiceFunctionHandler.initialize() |
|||
|
|||
isInitialized = true |
|||
return true |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "初始化失败: ${e.message}", e) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 设置回调 |
|||
*/ |
|||
fun setCallback(callback: InteractionCallback) { |
|||
this.callback = callback |
|||
} |
|||
|
|||
/** |
|||
* 开始语音识别 |
|||
*/ |
|||
fun startRecognition() { |
|||
if (isRecognitionActive) return |
|||
|
|||
// 检查录音权限 |
|||
if (!checkRecordAudioPermission()) { |
|||
callback?.onError("需要录音权限,请在设置中授予权限") |
|||
return |
|||
} |
|||
|
|||
isRecognitionActive = true |
|||
hasSpeechDetected = false |
|||
notifyStateChanged() |
|||
|
|||
try { |
|||
azureAsrHelper?.startContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { |
|||
override fun onRecognizing(recognizing: String, detectedLanguage: String) { |
|||
if (recognizing.isNotEmpty()) { |
|||
hasSpeechDetected = true |
|||
stopTts() |
|||
notifyStateChanged() |
|||
} |
|||
} |
|||
|
|||
override fun onResult(result: String, detectedLanguage: String) { |
|||
if (result.isNotEmpty()) { |
|||
notifyStateChanged() |
|||
|
|||
processWithOpenAI(result) |
|||
|
|||
} |
|||
|
|||
// 重置状态,继续识别 |
|||
hasSpeechDetected = false |
|||
} |
|||
|
|||
override fun onSessionStarted() { |
|||
notifyStateChanged() |
|||
} |
|||
|
|||
override fun onSessionStopped() { |
|||
isRecognitionActive = false |
|||
notifyStateChanged() |
|||
} |
|||
|
|||
override fun onCanceled(reason: String, errorDetails: String) { |
|||
isRecognitionActive = false |
|||
notifyStateChanged() |
|||
} |
|||
|
|||
override fun onError(error: String) { |
|||
isRecognitionActive = false |
|||
callback?.onError("语音识别出错") |
|||
notifyStateChanged() |
|||
} |
|||
|
|||
override fun onSuccess(message: String) { |
|||
// 处理成功事件 |
|||
} |
|||
}) |
|||
} catch (e: Exception) { |
|||
isRecognitionActive = false |
|||
FileLogger.e(TAG, "启动语音识别失败: ${e.message}", e) |
|||
callback?.onError("启动语音识别失败") |
|||
notifyStateChanged() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 停止语音识别 |
|||
*/ |
|||
fun stopRecognition() { |
|||
if (!isRecognitionActive) return |
|||
|
|||
FileLogger.d(TAG, "停止语音识别") |
|||
|
|||
try { |
|||
azureAsrHelper?.stopContinuousRecognition(object : AzureAsrHelper.ContinuousRecognizeCallback { |
|||
override fun onResult(result: String, detectedLanguage: String) {} |
|||
override fun onRecognizing(recognizing: String, detectedLanguage: String) {} |
|||
override fun onSessionStarted() {} |
|||
override fun onSessionStopped() { |
|||
isRecognitionActive = false |
|||
FileLogger.d(TAG, "语音识别会话已停止") |
|||
notifyStateChanged() |
|||
} |
|||
override fun onCanceled(reason: String, errorDetails: String) { |
|||
isRecognitionActive = false |
|||
FileLogger.d(TAG, "语音识别已取消: $reason") |
|||
notifyStateChanged() |
|||
} |
|||
override fun onError(error: String) { |
|||
isRecognitionActive = false |
|||
FileLogger.e(TAG, "停止语音识别时出错: $error") |
|||
notifyStateChanged() |
|||
} |
|||
override fun onSuccess(message: String) { |
|||
isRecognitionActive = false |
|||
FileLogger.d(TAG, "语音识别已停止: $message") |
|||
notifyStateChanged() |
|||
} |
|||
}) |
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "停止语音识别异常: ${e.message}", e) |
|||
// 确保状态一致性 |
|||
isRecognitionActive = false |
|||
notifyStateChanged() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 使用OpenAI处理语音识别结果 |
|||
*/ |
|||
private fun processWithOpenAI(text: String) { |
|||
// 保存当前用户输入,用于后续同步聊天记录 |
|||
currentUserInput = text |
|||
|
|||
Thread { |
|||
try { |
|||
val messages = JSONArray().apply { |
|||
put(openAIService.createUserMessage(text)) |
|||
} |
|||
|
|||
// 创建响应构建器 |
|||
val responseBuilder = StringBuilder() |
|||
|
|||
openAIService.sendMessageStream( |
|||
messages = messages, |
|||
callback = object : OpenAIService.StreamCallback { |
|||
override fun onToken(token: String) { |
|||
// 累加响应内容 |
|||
responseBuilder.append(token) |
|||
} |
|||
|
|||
override fun onComplete() { |
|||
// 处理完整响应 |
|||
val response = responseBuilder.toString() |
|||
if (response.isNotEmpty()) { |
|||
// 播放AI回复 |
|||
Log.d(TAG, "AI 回复: $response") |
|||
|
|||
speakAIResponse(response) |
|||
|
|||
// 同步聊天记录到Flutter端 |
|||
sendChatHistoryUpdate("personal_assistant", text, response) |
|||
} |
|||
} |
|||
|
|||
override fun onError(e: Exception) { |
|||
FileLogger.e(TAG, "AI流式处理出错: ${e.message}", e) |
|||
callback?.onError("AI处理出错") |
|||
} |
|||
|
|||
override fun onFunctionCall(call: JSONObject) { |
|||
FileLogger.d(TAG, "收到函数调用请求: ${call.getString("name")}") |
|||
|
|||
// 使用函数处理器处理函数调用 |
|||
val handled = voiceFunctionHandler.handleFunctionCall( |
|||
functionCall = call, |
|||
messages = messages, |
|||
callback = object : VoiceFunctionHandler.FunctionCallCallback { |
|||
override fun onTokenReceived(token: String) { |
|||
responseBuilder.append(token) |
|||
} |
|||
|
|||
override fun onComplete() { |
|||
val response = responseBuilder.toString() |
|||
if (response.isNotEmpty()) { |
|||
// 播放AI回复 |
|||
Log.d(TAG, "AI Function Call 回复: $response") |
|||
speakAIResponse(response) |
|||
|
|||
// 同步聊天记录到Flutter端 |
|||
sendChatHistoryUpdate("personal_assistant", text, response) |
|||
} |
|||
notifyStateChanged() |
|||
} |
|||
|
|||
override fun onError(message: String) { |
|||
FileLogger.e(TAG, "函数处理出错: $message") |
|||
callback?.onError(message) |
|||
} |
|||
|
|||
override fun onFunctionCall(nestedCall: JSONObject) { |
|||
FileLogger.d(TAG, "收到嵌套函数调用: ${nestedCall.getString("name")}") |
|||
// 不处理嵌套函数调用,直接返回错误提示 |
|||
// speakAIResponse("抱歉,暂不支持嵌套函数调用") |
|||
} |
|||
|
|||
override fun onExitWithMessage(farewell: String) { |
|||
// 播放退出消息 |
|||
speakAIResponse(farewell) |
|||
|
|||
// 同步聊天记录 |
|||
sendChatHistoryUpdate("personal_assistant", text, farewell) |
|||
|
|||
// 停止语音识别 |
|||
stopRecognition() |
|||
} |
|||
} |
|||
) |
|||
|
|||
if (!handled) { |
|||
// 如果函数没有被处理,作为普通文本处理 |
|||
FileLogger.d(TAG, "函数未处理,作为普通文本处理") |
|||
speakAIResponse("我无法处理这个请求") |
|||
sendChatHistoryUpdate("personal_assistant", text, "我无法处理这个请求") |
|||
} |
|||
} |
|||
} |
|||
) |
|||
|
|||
} catch (e: Exception) { |
|||
FileLogger.e(TAG, "AI处理出错: ${e.message}", e) |
|||
callback?.onError("AI处理出错") |
|||
} |
|||
}.start() |
|||
} |
|||
|
|||
/** |
|||
* 播放TTS |
|||
*/ |
|||
fun playTts(text: String, callback: TtsCallback? = null) { |
|||
isTtsSpeaking = true |
|||
notifyStateChanged() |
|||
|
|||
volcanoTtsHelper?.speak(text, object : VolcanoTtsHelper.TTSCallback { |
|||
override fun onStart(reqId: String) { |
|||
// TTS开始播放事件 |
|||
} |
|||
|
|||
override fun onProgress(reqId: String, progress: Double) { |
|||
// TTS播放进度事件 |
|||
} |
|||
|
|||
override fun onComplete(reqId: String) { |
|||
isTtsSpeaking = false |
|||
notifyStateChanged() |
|||
callback?.onComplete() |
|||
} |
|||
|
|||
override fun onError(reqId: String, errorCode: Int, errorMsg: String) { |
|||
isTtsSpeaking = false |
|||
notifyStateChanged() |
|||
callback?.onError(errorMsg) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* 停止TTS播放 |
|||
*/ |
|||
fun stopTts() { |
|||
if (isTtsSpeaking) { |
|||
volcanoTtsHelper?.stop() |
|||
isTtsSpeaking = false |
|||
notifyStateChanged() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 播放AI回复 |
|||
*/ |
|||
private fun speakAIResponse(text: String) { |
|||
isTtsSpeaking = true |
|||
notifyStateChanged() |
|||
|
|||
volcanoTtsHelper?.speak(text, object : VolcanoTtsHelper.TTSCallback { |
|||
override fun onStart(reqId: String) { |
|||
// TTS开始播放事件 |
|||
} |
|||
|
|||
override fun onProgress(reqId: String, progress: Double) { |
|||
// TTS播放进度事件 |
|||
} |
|||
|
|||
override fun onComplete(reqId: String) { |
|||
isTtsSpeaking = false |
|||
notifyStateChanged() |
|||
} |
|||
|
|||
override fun onError(reqId: String, errorCode: Int, errorMsg: String) { |
|||
isTtsSpeaking = false |
|||
notifyStateChanged() |
|||
} |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* 释放资源 |
|||
*/ |
|||
fun dispose() { |
|||
// 停止语音识别 |
|||
stopRecognition() |
|||
|
|||
// 停止TTS播放 |
|||
stopTts() |
|||
|
|||
// 释放Azure资源 |
|||
azureAsrHelper?.let { |
|||
FileLogger.d(TAG, "关闭Azure ASR服务") |
|||
it.dispose() |
|||
} |
|||
|
|||
volcanoTtsHelper?.let { |
|||
FileLogger.d(TAG, "关闭Volcano TTS服务") |
|||
it.release() |
|||
} |
|||
|
|||
FileLogger.d(TAG, "语音交互处理器资源已释放") |
|||
} |
|||
|
|||
/** |
|||
* 检查录音权限 |
|||
*/ |
|||
private fun checkRecordAudioPermission(): Boolean { |
|||
val permission = android.Manifest.permission.RECORD_AUDIO |
|||
val result = context.checkCallingOrSelfPermission(permission) |
|||
return result == android.content.pm.PackageManager.PERMISSION_GRANTED |
|||
} |
|||
|
|||
/** |
|||
* 通知状态变化 |
|||
*/ |
|||
private fun notifyStateChanged() { |
|||
callback?.onStateChanged(isRecognitionActive, isTtsSpeaking, hasSpeechDetected) |
|||
} |
|||
|
|||
/** |
|||
* 发送聊天历史更新 |
|||
*/ |
|||
private fun sendChatHistoryUpdate(agentId: String, userMessage: String, assistantMessage: String) { |
|||
val intent = android.content.Intent(VoiceInteractionService.ACTION_CHAT_HISTORY_UPDATED).apply { |
|||
putExtra("agentId", agentId) |
|||
putExtra("userMessage", userMessage) |
|||
putExtra("assistantMessage", assistantMessage) |
|||
putExtra("timestamp", System.currentTimeMillis()) |
|||
} |
|||
|
|||
// 发送广播 |
|||
context.sendBroadcast(intent) |
|||
} |
|||
|
|||
/** |
|||
* 交互回调接口 |
|||
*/ |
|||
interface InteractionCallback { |
|||
fun onStateChanged(isRecognitionActive: Boolean, isTtsSpeaking: Boolean, hasSpeechDetected: Boolean) |
|||
fun onError(message: String) |
|||
fun onPromptRequest(message: String) |
|||
} |
|||
|
|||
/** |
|||
* TTS回调接口 |
|||
*/ |
|||
interface TtsCallback { |
|||
fun onComplete() |
|||
fun onError(error: String) |
|||
} |
|||
} |
|||
@ -0,0 +1,89 @@ |
|||
/// 杰里(Jieli)设备相关常量定义 |
|||
/// 用于标准化系统属性类型和功能码 |
|||
|
|||
/// 系统属性类型常量 |
|||
class JieliAttrType { |
|||
// 公共属性 |
|||
static const int VOLUME = 1; // 音量 |
|||
static const int PLAYING_TIME = 2; // 播放时间 |
|||
static const int BATTERY_LEVEL = 3; // 电池电量 |
|||
static const int WORK_MODE = 4; // 工作模式 |
|||
static const int PLAYBACK_MODE = 5; // 播放模式(循环模式) |
|||
static const int EQ_MODE = 6; // EQ模式 |
|||
static const int LANGUAGE = 7; // 语言设置 |
|||
static const int CONTRAST = 8; // 对比度 |
|||
static const int LED_ONOFF = 9; // LED开关 |
|||
static const int POWER_OFF_TIME = 10; // 关机时间 |
|||
static const int LIGHT_ONOFF = 11; // 背光开关 |
|||
static const int BLETYPE_SETTING = 12; // BLE类型设置 |
|||
static const int TIME_OUT = 13; // 超时设置 |
|||
static const int DEVICE_NAME = 14; // 设备名称 |
|||
static const int ALARM_ENABLE = 15; // 闹钟开关 |
|||
static const int DEV_MAC = 16; // 设备MAC地址 |
|||
|
|||
// 音乐相关属性 |
|||
static const int MUSIC_STATUS = 20; // 音乐状态 |
|||
static const int MUSIC_CURR_DEVICE = 21; // 当前音乐设备 |
|||
static const int MUSIC_TOTAL_TIME = 22; // 音乐总时长 |
|||
static const int MUSIC_CURR_PLAY_TIME = 23; // 当前播放时间 |
|||
static const int MUSIC_NUM = 24; // 音乐数量 |
|||
static const int MUSIC_DEV_STATUS = 25; // 设备存储状态 |
|||
static const int MUSIC_BREAK_POINT = 26; // 断点信息 |
|||
static const int MUSIC_CURR_FILENUM = 27; // 当前文件编号 |
|||
static const int MUSIC_TOTAL_FILENUM = 28; // 总文件数 |
|||
static const int MEDIA_TIMESTAMP = 29; // 媒体时间戳 |
|||
|
|||
// 模式相关 |
|||
static const int CUR_MODE = 30; // 当前模式 |
|||
|
|||
// TWS相关 |
|||
static const int TWS_CONN_STATUS = 60; // TWS连接状态 |
|||
static const int LEFT_CHN_BAT = 61; // 左耳电量 |
|||
static const int RIGHT_CHN_BAT = 62; // 右耳电量 |
|||
static const int TWS_STATUS = 63; // TWS状态 |
|||
} |
|||
|
|||
/// 功能码 |
|||
class JieliFunctionCode { |
|||
static const int COMMON = 0; // 公共功能 |
|||
static const int MUSIC = 1; // 音乐功能 |
|||
static const int RADIO = 2; // 收音机功能 |
|||
static const int LINEIN = 3; // 线路输入功能 |
|||
static const int RTC = 4; // 时钟功能 |
|||
static const int BT = 5; // 蓝牙功能 |
|||
static const int PC = 6; // PC功能 |
|||
static const int RECORD = 7; // 录音功能 |
|||
static const int UDISK = 8; // U盘功能 |
|||
static const int TALK = 9; // 通话功能 |
|||
static const int PHOTO = 10; // 拍照功能 |
|||
static const int VIDEO = 11; // 视频功能 |
|||
} |
|||
|
|||
/// 音乐播放状态 |
|||
class JieliMusicStatus { |
|||
static const int STOP = 0; // 停止状态 |
|||
static const int PLAY = 1; // 播放状态 |
|||
static const int PAUSE = 2; // 暂停状态 |
|||
static const int FAST_FORWARD = 3; // 快进状态 |
|||
static const int FAST_BACKWARD = 4; // 快退状态 |
|||
} |
|||
|
|||
/// 循环播放模式 |
|||
class JieliPlaybackMode { |
|||
static const int ALL = 0; // 全部循环 |
|||
static const int FOLDER = 1; // 文件夹循环 |
|||
static const int ONE = 2; // 单曲循环 |
|||
static const int RANDOM = 3; // 随机播放 |
|||
static const int BROWSE = 4; // 浏览播放 |
|||
} |
|||
|
|||
/// EQ模式 |
|||
class JieliEQMode { |
|||
static const int NORMAL = 0; // 正常 |
|||
static const int ROCK = 1; // 摇滚 |
|||
static const int POP = 2; // 流行 |
|||
static const int CLASSIC = 3; // 经典 |
|||
static const int JAZZ = 4; // 爵士 |
|||
static const int COUNTRY = 5; // 乡村 |
|||
static const int CUSTOM = 6; // 自定义 |
|||
} |
|||
@ -1,97 +0,0 @@ |
|||
import 'dart:convert'; |
|||
|
|||
/// 函数服务 |
|||
/// 仅实现各种功能函数,不包含AI交互逻辑 |
|||
class FunctionService { |
|||
/// 天气查询工具定义 |
|||
/// 返回一个可直接用于tools参数的天气查询工具定义 |
|||
static Map<String, dynamic> getWeatherToolDefinition() { |
|||
return { |
|||
'type': 'function', |
|||
'function': { |
|||
'name': 'get_current_weather', |
|||
'description': '获取指定地点的当前天气信息', |
|||
'parameters': { |
|||
'type': 'object', |
|||
'properties': { |
|||
'location': { |
|||
'type': 'string', |
|||
'description': '地理位置,例如:北京市', |
|||
}, |
|||
'unit': { |
|||
'type': 'string', |
|||
'description': '温度单位', |
|||
'enum': ['celsius', 'fahrenheit'], |
|||
}, |
|||
}, |
|||
'required': ['location'], |
|||
}, |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
/// 模拟天气API实现 |
|||
/// 返回一个模拟的天气信息,实际应用中应该调用真实的天气API |
|||
static Future<Map<String, dynamic>> getWeatherInfo(Map<String, dynamic> args) async { |
|||
final location = args['location'] ?? '未知地点'; |
|||
final unit = args['unit'] ?? 'celsius'; |
|||
|
|||
// 在实际应用中,这里应该调用真实的天气API |
|||
return { |
|||
'location': location, |
|||
'temperature': unit == 'celsius' ? '25°C' : '77°F', |
|||
'conditions': '晴转多云', |
|||
'humidity': '55%', |
|||
'wind': '西南风 3级', |
|||
'date': DateTime.now().toString().split(' ')[0], |
|||
}; |
|||
} |
|||
|
|||
/// 解析函数调用参数 |
|||
/// 从AI响应中提取函数调用参数 |
|||
static Map<String, dynamic> parseFunctionCallArguments(Map<String, dynamic> functionCallResponse) { |
|||
if (functionCallResponse['tool_calls'] == null || |
|||
functionCallResponse['tool_calls'].isEmpty) { |
|||
throw FunctionServiceException('No tool calls found in response'); |
|||
} |
|||
|
|||
final toolCall = functionCallResponse['tool_calls'][0]; |
|||
if (toolCall['function'] == null || toolCall['function']['arguments'] == null) { |
|||
throw FunctionServiceException('Invalid function call format'); |
|||
} |
|||
|
|||
try { |
|||
return json.decode(toolCall['function']['arguments']); |
|||
} catch (e) { |
|||
throw FunctionServiceException('Failed to parse function arguments: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 获取函数调用ID和名称 |
|||
/// 用于构建工具响应消息 |
|||
static Map<String, String> getFunctionCallInfo(Map<String, dynamic> functionCallResponse) { |
|||
if (functionCallResponse['tool_calls'] == null || |
|||
functionCallResponse['tool_calls'].isEmpty) { |
|||
throw FunctionServiceException('No tool calls found in response'); |
|||
} |
|||
|
|||
final toolCall = functionCallResponse['tool_calls'][0]; |
|||
if (toolCall['id'] == null || toolCall['function'] == null || toolCall['function']['name'] == null) { |
|||
throw FunctionServiceException('Invalid function call format'); |
|||
} |
|||
|
|||
return { |
|||
'id': toolCall['id'], |
|||
'name': toolCall['function']['name'], |
|||
}; |
|||
} |
|||
} |
|||
|
|||
/// 函数调用服务异常类 |
|||
class FunctionServiceException implements Exception { |
|||
final String message; |
|||
FunctionServiceException(this.message); |
|||
|
|||
@override |
|||
String toString() => message; |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/jieli_test_controller.dart'; |
|||
|
|||
class JieliTestBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.lazyPut<JieliTestController>( |
|||
() => JieliTestController(), |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,298 @@ |
|||
import 'dart:async'; |
|||
import 'dart:typed_data'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:logger/logger.dart'; |
|||
import 'package:jieli/jieli.dart'; |
|||
|
|||
/// 杰理测试控制器 |
|||
class JieliTestController extends GetxController { |
|||
static final Logger logger = Logger(); |
|||
|
|||
// Jieli插件实例 |
|||
late Jieli jieli; |
|||
|
|||
// 状态变量 |
|||
var isScanning = false.obs; |
|||
var deviceInfo = '未获取'.obs; |
|||
var connectStatus = '未连接'.obs; |
|||
var currentDevice = Rx<BleDevice?>(null); |
|||
var scannedDevices = RxList<BleDevice>([]); |
|||
var wakeupReceived = false.obs; |
|||
var audioDataSize = 0.obs; |
|||
var receivedFrames = 0.obs; |
|||
var audioDataReceiving = false.obs; |
|||
|
|||
// 事件订阅 |
|||
StreamSubscription? _scanSubscription; |
|||
StreamSubscription? _connectionSubscription; |
|||
StreamSubscription? _wakeupSubscription; |
|||
StreamSubscription? _audioSubscription; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
logger.d('JieliTestController 初始化'); |
|||
_initializeJieli(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
logger.d('JieliTestController 关闭'); |
|||
_cancelAllSubscriptions(); |
|||
_disposeJieli(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
/// 取消所有订阅 |
|||
void _cancelAllSubscriptions() { |
|||
_scanSubscription?.cancel(); |
|||
_connectionSubscription?.cancel(); |
|||
_wakeupSubscription?.cancel(); |
|||
_audioSubscription?.cancel(); |
|||
} |
|||
|
|||
/// 初始化杰理SDK |
|||
void _initializeJieli() { |
|||
try { |
|||
logger.d('初始化Jieli插件'); |
|||
jieli = Jieli(); |
|||
_setupEventListeners(); |
|||
} catch (e) { |
|||
logger.e('初始化Jieli插件错误', error: e); |
|||
} |
|||
} |
|||
|
|||
/// 设置事件监听器 |
|||
void _setupEventListeners() { |
|||
// 监听扫描结果 |
|||
_scanSubscription = jieli.scanResults.listen((devices) { |
|||
// logger.d('扫描到设备: ${devices.length}个'); |
|||
scannedDevices.assignAll(devices); |
|||
}); |
|||
|
|||
// 监听连接状态 |
|||
_connectionSubscription = jieli.connectionState.listen((state) { |
|||
logger.d('连接状态变化: $state'); |
|||
switch (state) { |
|||
case ConnectionState.connected: |
|||
connectStatus.value = '已连接'; |
|||
break; |
|||
case ConnectionState.disconnected: |
|||
connectStatus.value = '已断开'; |
|||
currentDevice.value = null; |
|||
break; |
|||
case ConnectionState.connecting: |
|||
connectStatus.value = '连接中...'; |
|||
break; |
|||
case ConnectionState.disconnecting: |
|||
connectStatus.value = '断开中...'; |
|||
break; |
|||
case ConnectionState.error: |
|||
connectStatus.value = '连接错误'; |
|||
break; |
|||
} |
|||
}); |
|||
|
|||
// 监听唤醒信号 |
|||
_wakeupSubscription = jieli.wakeupStream.listen((wakeup) { |
|||
logger.d('收到唤醒信号'); |
|||
wakeupReceived.value = true; |
|||
Get.snackbar('收到唤醒信号', '耳机发送了唤醒信号'); |
|||
}); |
|||
|
|||
// 监听音频数据 |
|||
_audioSubscription = jieli.audioDataStream.listen((data) { |
|||
// 更新音频数据统计 |
|||
audioDataSize.value += data.length; |
|||
receivedFrames.value++; |
|||
audioDataReceiving.value = true; |
|||
|
|||
// 每100帧更新一次日志,避免日志过多 |
|||
if (receivedFrames.value % 100 == 0) { |
|||
logger.d('已接收音频数据: ${audioDataSize.value} 字节, ${receivedFrames.value} 帧'); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
/// 检查蓝牙状态 |
|||
Future<bool> checkBluetoothStatus() async { |
|||
try { |
|||
final isEnabled = await jieli.isBluetoothEnabled(); |
|||
if (!isEnabled) { |
|||
logger.d('蓝牙未启用,请求启用蓝牙'); |
|||
return await jieli.requestBluetoothEnable(); |
|||
} |
|||
return true; |
|||
} catch (e) { |
|||
logger.e('检查蓝牙状态错误', error: e); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 开始扫描设备 |
|||
Future<void> startScan() async { |
|||
if (isScanning.value) return; |
|||
|
|||
final bluetoothReady = await checkBluetoothStatus(); |
|||
if (!bluetoothReady) { |
|||
Get.snackbar('蓝牙未启用', '请启用蓝牙后再试'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
logger.d('开始扫描设备'); |
|||
isScanning.value = true; |
|||
|
|||
// 清空之前的设备列表 |
|||
scannedDevices.clear(); |
|||
|
|||
// 开始扫描,10秒后自动停止 |
|||
final success = await jieli.startScan(scanTimeout: 10000); |
|||
|
|||
if (!success) { |
|||
logger.e('启动扫描失败'); |
|||
isScanning.value = false; |
|||
Get.snackbar('扫描失败', '启动扫描设备失败,请检查蓝牙权限'); |
|||
} |
|||
|
|||
// 10秒后自动更新UI状态 |
|||
Future.delayed(Duration(milliseconds: 10000), () { |
|||
isScanning.value = false; |
|||
}); |
|||
|
|||
} catch (e) { |
|||
logger.e('扫描设备错误', error: e); |
|||
isScanning.value = false; |
|||
Get.snackbar('扫描错误', e.toString()); |
|||
} |
|||
} |
|||
|
|||
/// 停止扫描 |
|||
Future<void> stopScan() async { |
|||
if (!isScanning.value) return; |
|||
|
|||
try { |
|||
logger.d('停止扫描'); |
|||
await jieli.stopScan(); |
|||
isScanning.value = false; |
|||
} catch (e) { |
|||
logger.e('停止扫描错误', error: e); |
|||
} |
|||
} |
|||
|
|||
/// 连接到指定设备 |
|||
Future<void> connectToDevice(BleDevice device) async { |
|||
try { |
|||
// 先断开当前连接 |
|||
if (currentDevice.value != null) { |
|||
await disconnectDevice(); |
|||
} |
|||
|
|||
// 更新UI状态 |
|||
connectStatus.value = '连接中...'; |
|||
logger.d('连接设备: ${device.name} (${device.id})'); |
|||
|
|||
// 重置音频和唤醒相关的状态 |
|||
wakeupReceived.value = false; |
|||
audioDataSize.value = 0; |
|||
receivedFrames.value = 0; |
|||
audioDataReceiving.value = false; |
|||
|
|||
// 连接设备 |
|||
final success = await jieli.connect(device.id); |
|||
|
|||
if (success) { |
|||
// 连接成功,更新当前设备 |
|||
currentDevice.value = device; |
|||
deviceInfo.value = '设备名称: ${device.name}\n设备ID: ${device.id}\nRSSI: ${device.rssi} dBm'; |
|||
logger.d('设备连接成功'); |
|||
} else { |
|||
deviceInfo.value = '连接失败'; |
|||
logger.d('设备连接失败'); |
|||
} |
|||
} catch (e) { |
|||
deviceInfo.value = '连接错误: ${e.toString()}'; |
|||
logger.e('连接设备错误', error: e); |
|||
} |
|||
} |
|||
|
|||
/// 断开设备连接 |
|||
Future<void> disconnectDevice() async { |
|||
if (currentDevice.value == null) return; |
|||
|
|||
try { |
|||
connectStatus.value = '断开中...'; |
|||
logger.d('断开设备连接'); |
|||
|
|||
final success = await jieli.disconnect(); |
|||
|
|||
if (success) { |
|||
deviceInfo.value = '未连接设备'; |
|||
logger.d('设备断开连接成功'); |
|||
} else { |
|||
logger.d('设备断开连接失败'); |
|||
} |
|||
|
|||
} catch (e) { |
|||
logger.e('断开设备错误', error: e); |
|||
} |
|||
} |
|||
|
|||
/// 发送开始录音命令 |
|||
Future<void> sendStartRecordingCommand() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
logger.d('发送开始录音命令'); |
|||
final success = await jieli.sendStartRecordingCommand(); |
|||
|
|||
if (success) { |
|||
Get.snackbar('命令已发送', '已发送开始录音命令'); |
|||
logger.d('开始录音命令发送成功'); |
|||
} else { |
|||
Get.snackbar('命令发送失败', '发送开始录音命令失败'); |
|||
logger.d('开始录音命令发送失败'); |
|||
} |
|||
} catch (e) { |
|||
logger.e('发送开始录音命令错误', error: e); |
|||
Get.snackbar('命令错误', e.toString()); |
|||
} |
|||
} |
|||
|
|||
/// 发送停止录音命令 |
|||
Future<void> sendStopRecordingCommand() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
logger.d('发送停止录音命令'); |
|||
final success = await jieli.sendStopRecordingCommand(); |
|||
|
|||
if (success) { |
|||
Get.snackbar('命令已发送', '已发送停止录音命令'); |
|||
logger.d('停止录音命令发送成功'); |
|||
} else { |
|||
Get.snackbar('命令发送失败', '发送停止录音命令失败'); |
|||
logger.d('停止录音命令发送失败'); |
|||
} |
|||
} catch (e) { |
|||
logger.e('发送停止录音命令错误', error: e); |
|||
Get.snackbar('命令错误', e.toString()); |
|||
} |
|||
} |
|||
|
|||
/// 清理资源 |
|||
void _disposeJieli() { |
|||
try { |
|||
logger.d('释放Jieli插件资源'); |
|||
jieli.dispose(); |
|||
} catch (e) { |
|||
logger.e('释放Jieli插件资源错误', error: e); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,494 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import '../controllers/jieli_test_controller.dart'; |
|||
import 'package:jieli/jieli.dart'; |
|||
|
|||
class JieliTestView extends GetView<JieliTestController> { |
|||
const JieliTestView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return Scaffold( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
|||
appBar: AppBar( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|||
elevation: 0.5, |
|||
title: Text( |
|||
'杰理蓝牙测试', |
|||
style: TextStyle( |
|||
fontSize: 17.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
centerTitle: true, |
|||
leading: IconButton( |
|||
icon: Icon( |
|||
Icons.arrow_back_ios, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
size: 20.sp, |
|||
), |
|||
onPressed: () => Get.back(), |
|||
), |
|||
), |
|||
body: Padding( |
|||
padding: EdgeInsets.all(16.w), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.stretch, |
|||
children: [ |
|||
_buildStatusCard(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
_buildActionButtons(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
_buildDeviceInfo(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
_buildDevicesList(isDarkMode), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildStatusCard(bool isDarkMode) { |
|||
return Container( |
|||
padding: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[900] : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Text( |
|||
'连接状态: ', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
Obx(() => Text( |
|||
controller.connectStatus.value, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
fontWeight: FontWeight.bold, |
|||
color: _getStatusColor(controller.connectStatus.value), |
|||
), |
|||
)), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Obx(() => _buildStatusChip( |
|||
label: '唤醒信号', |
|||
isActive: controller.wakeupReceived.value, |
|||
activeColor: Colors.orange, |
|||
isDarkMode: isDarkMode, |
|||
)), |
|||
Obx(() => _buildStatusChip( |
|||
label: '音频数据', |
|||
isActive: controller.audioDataReceiving.value, |
|||
activeColor: Colors.green, |
|||
isDarkMode: isDarkMode, |
|||
)), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Obx(() => controller.audioDataReceiving.value |
|||
? Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'音频数据统计:', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
SizedBox(height: 4.h), |
|||
Text( |
|||
'已接收 ${(controller.audioDataSize.value / 1024).toStringAsFixed(2)} KB', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
Text( |
|||
'共 ${controller.receivedFrames.value} 帧', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
) |
|||
: SizedBox.shrink() |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildStatusChip({ |
|||
required String label, |
|||
required bool isActive, |
|||
required Color activeColor, |
|||
required bool isDarkMode, |
|||
}) { |
|||
return Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), |
|||
decoration: BoxDecoration( |
|||
color: isActive |
|||
? activeColor |
|||
: (isDarkMode ? Colors.grey[800] : Colors.grey[200]), |
|||
borderRadius: BorderRadius.circular(16.r), |
|||
), |
|||
child: Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Container( |
|||
width: 8.w, |
|||
height: 8.w, |
|||
decoration: BoxDecoration( |
|||
color: isActive ? Colors.white : Colors.grey, |
|||
shape: BoxShape.circle, |
|||
), |
|||
), |
|||
SizedBox(width: 6.w), |
|||
Text( |
|||
label, |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isActive |
|||
? Colors.white |
|||
: (isDarkMode ? Colors.white70 : Colors.black54), |
|||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildActionButtons(bool isDarkMode) { |
|||
return Column( |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: Obx(() => ElevatedButton.icon( |
|||
onPressed: controller.isScanning.value |
|||
? controller.stopScan |
|||
: controller.startScan, |
|||
icon: Icon( |
|||
controller.isScanning.value ? Icons.stop : Icons.bluetooth_searching, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
controller.isScanning.value ? '停止扫描' : '扫描设备', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: controller.isScanning.value |
|||
? Colors.red[600] |
|||
: Colors.blue[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
)), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: controller.sendStartRecordingCommand, |
|||
icon: Icon( |
|||
Icons.mic, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
'开始录音', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.green[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: controller.sendStopRecordingCommand, |
|||
icon: Icon( |
|||
Icons.stop_circle, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
'停止录音', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.red[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
Widget _buildDeviceInfo(bool isDarkMode) { |
|||
return Container( |
|||
padding: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[900] : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'设备信息:', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Container( |
|||
padding: EdgeInsets.all(8.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.black : Colors.grey[100], |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
child: Obx(() => Text( |
|||
controller.deviceInfo.value, |
|||
style: TextStyle( |
|||
fontSize: 13.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
height: 1.4, |
|||
), |
|||
)), |
|||
), |
|||
SizedBox(height: 16.h), |
|||
Obx(() => controller.currentDevice.value != null |
|||
? ElevatedButton.icon( |
|||
onPressed: controller.disconnectDevice, |
|||
icon: Icon( |
|||
Icons.bluetooth_disabled, |
|||
size: 16.sp, |
|||
), |
|||
label: Text( |
|||
'断开连接', |
|||
style: TextStyle(fontSize: 13.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.red[600], |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
) |
|||
: SizedBox.shrink() |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildDevicesList(bool isDarkMode) { |
|||
return Expanded( |
|||
child: Container( |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[900] : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Padding( |
|||
padding: EdgeInsets.all(16.w), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Text( |
|||
'扫描到的设备', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
Obx(() => Text( |
|||
'${controller.scannedDevices.length}个设备', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
)), |
|||
], |
|||
), |
|||
), |
|||
Divider(height: 1, thickness: 1, color: isDarkMode ? Colors.grey[800] : Colors.grey[200]), |
|||
Expanded( |
|||
child: Obx(() => controller.scannedDevices.isEmpty |
|||
? Center( |
|||
child: controller.isScanning.value |
|||
? Column( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
CircularProgressIndicator(), |
|||
SizedBox(height: 16.h), |
|||
Text( |
|||
'正在扫描...', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
) |
|||
: Text( |
|||
'未扫描到设备', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
) |
|||
: ListView.separated( |
|||
padding: EdgeInsets.symmetric(vertical: 8.h), |
|||
itemCount: controller.scannedDevices.length, |
|||
separatorBuilder: (context, index) => Divider( |
|||
height: 1, |
|||
thickness: 1, |
|||
color: isDarkMode ? Colors.grey[800] : Colors.grey[200], |
|||
), |
|||
itemBuilder: (context, index) { |
|||
final device = controller.scannedDevices[index]; |
|||
return ListTile( |
|||
dense: true, |
|||
title: Text( |
|||
device.name.isEmpty ? '未知设备' : device.name, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
fontWeight: FontWeight.w500, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
subtitle: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'设备ID: ${device.id}', |
|||
style: TextStyle( |
|||
fontSize: 11.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
Text( |
|||
'RSSI: ${device.rssi} dBm', |
|||
style: TextStyle( |
|||
fontSize: 11.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
trailing: Obx(() => controller.currentDevice.value?.id == device.id |
|||
? Chip( |
|||
label: Text( |
|||
'已连接', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
backgroundColor: Colors.green, |
|||
padding: EdgeInsets.symmetric(horizontal: 8.w), |
|||
) |
|||
: ElevatedButton( |
|||
onPressed: () => controller.connectToDevice(device), |
|||
child: Text( |
|||
'连接', |
|||
style: TextStyle(fontSize: 12.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.green[600], |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(6.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
contentPadding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
|||
isThreeLine: true, |
|||
); |
|||
}, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Color _getStatusColor(String status) { |
|||
switch (status) { |
|||
case '连接中...': |
|||
return Colors.orange; |
|||
case '已连接': |
|||
return Colors.green; |
|||
case '连接失败': |
|||
return Colors.red; |
|||
case '已断开': |
|||
return Colors.grey; |
|||
default: |
|||
return Colors.grey; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,80 @@ |
|||
# chat_storage_sqlite |
|||
|
|||
SQLite存储插件,用于存储聊天记录并支持分页查询。 |
|||
|
|||
## 功能特点 |
|||
|
|||
- 使用SQLite数据库存储聊天记录 |
|||
- 提供分页查询功能,可指定获取对应页码的记录 |
|||
- 支持按会话ID组织和查询聊天记录 |
|||
- 提供添加、查询、删除等基本操作 |
|||
|
|||
## 使用方法 |
|||
|
|||
### 保存聊天记录 |
|||
|
|||
```dart |
|||
import 'package:chat_storage_sqlite/chat_storage_sqlite.dart'; |
|||
|
|||
// 保存一条聊天记录 |
|||
final result = await ChatStorageSqlite.saveMessage( |
|||
sessionId: 'session_123', |
|||
message: '你好,这是一条测试消息', |
|||
sender: 'user', |
|||
metadata: '{"timestamp": 1630000000000}' // 可选的元数据,支持JSON字符串 |
|||
); |
|||
|
|||
if (result['success']) { |
|||
print('消息保存成功,ID: ${result['id']}'); |
|||
} else { |
|||
print('消息保存失败: ${result['error']}'); |
|||
} |
|||
``` |
|||
|
|||
### 获取聊天记录(支持分页) |
|||
|
|||
```dart |
|||
// 获取指定会话的聊天记录,支持分页 |
|||
final pagedResult = await ChatStorageSqlite.getMessages( |
|||
sessionId: 'session_123', |
|||
page: 1, // 当前页码,从1开始 |
|||
pageSize: 20, // 每页记录数 |
|||
); |
|||
|
|||
// 使用结果 |
|||
print('总记录数: ${pagedResult.totalCount}'); |
|||
print('总页数: ${pagedResult.totalPages}'); |
|||
print('当前页: ${pagedResult.page}'); |
|||
|
|||
// 遍历消息列表 |
|||
for (var message in pagedResult.messages) { |
|||
print('ID: ${message.id}'); |
|||
print('发送者: ${message.sender}'); |
|||
print('内容: ${message.message}'); |
|||
print('时间: ${message.timestamp}'); |
|||
print('---'); |
|||
} |
|||
``` |
|||
|
|||
### 删除聊天记录 |
|||
|
|||
```dart |
|||
// 方式1:删除指定ID的消息 |
|||
final deleteResult1 = await ChatStorageSqlite.deleteMessages( |
|||
messageIds: [1, 2, 3], |
|||
); |
|||
|
|||
// 方式2:删除整个会话的所有消息 |
|||
final deleteResult2 = await ChatStorageSqlite.deleteMessages( |
|||
sessionId: 'session_123', |
|||
); |
|||
|
|||
// 方式3:清空整个数据库 |
|||
final clearResult = await ChatStorageSqlite.clearDatabase(); |
|||
``` |
|||
|
|||
## 注意事项 |
|||
|
|||
- 插件会自动创建和管理SQLite数据库,无需手动初始化 |
|||
- 所有操作都是异步的,请使用async/await处理返回结果 |
|||
- 插件内部已处理异常情况,但建议在使用时添加适当的错误处理 |
|||
@ -0,0 +1,60 @@ |
|||
plugins { |
|||
// Android Library 插件 |
|||
id("com.android.library") |
|||
// Kotlin Android 插件 |
|||
id("org.jetbrains.kotlin.android") |
|||
id("com.github.johnrengelman.shadow") version "8.1.1" |
|||
} |
|||
|
|||
android { |
|||
// 命名空间,对应你插件的包名(需与代码内包名保持一致) |
|||
namespace = "com.yunqiinnovation.chat_storage_sqlite" |
|||
|
|||
// 目标 SDK 版本 |
|||
compileSdk = 33 |
|||
|
|||
defaultConfig { |
|||
// 最低 SDK 版本 |
|||
minSdk = 21 |
|||
targetSdk = 33 |
|||
} |
|||
|
|||
// Java 语言级别兼容配置 |
|||
compileOptions { |
|||
sourceCompatibility = JavaVersion.VERSION_11 |
|||
targetCompatibility = JavaVersion.VERSION_11 |
|||
} |
|||
|
|||
// Kotlin 语言级别 |
|||
kotlinOptions { |
|||
jvmTarget = "11" |
|||
} |
|||
|
|||
// 添加packaging配置,排除冲突文件 |
|||
packaging { |
|||
resources { |
|||
excludes.add("META-INF/DEPENDENCIES") |
|||
excludes.add("META-INF/LICENSE") |
|||
excludes.add("META-INF/LICENSE.txt") |
|||
excludes.add("META-INF/license.txt") |
|||
excludes.add("META-INF/NOTICE") |
|||
excludes.add("META-INF/NOTICE.txt") |
|||
excludes.add("META-INF/notice.txt") |
|||
excludes.add("META-INF/*.kotlin_module") |
|||
} |
|||
} |
|||
} |
|||
|
|||
dependencies { |
|||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") |
|||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") |
|||
implementation("com.fasterxml.jackson.core:jackson-databind:2.14.2") |
|||
implementation("com.squareup.okhttp3:okhttp:4.9.3") |
|||
implementation("com.anthropic:anthropic-java:0.8.0") |
|||
implementation("io.modelcontextprotocol:kotlin-sdk:0.4.0") |
|||
} |
|||
|
|||
dependencies { |
|||
// SQLite相关依赖 |
|||
implementation("androidx.sqlite:sqlite:2.3.1") |
|||
} |
|||
@ -0,0 +1 @@ |
|||
rootProject.name = "chat_storage_sqlite" |
|||
@ -0,0 +1,8 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
|||
package="com.yunqiinnovation.chat_storage_sqlite"> |
|||
|
|||
<!-- 添加sqlite相关权限 --> |
|||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> |
|||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> |
|||
</manifest> |
|||
@ -0,0 +1,236 @@ |
|||
package com.yunqiinnovation.chat_storage_sqlite |
|||
|
|||
import android.content.Context |
|||
import android.database.Cursor |
|||
import android.database.sqlite.SQLiteDatabase |
|||
import android.database.sqlite.SQLiteOpenHelper |
|||
import android.util.Log |
|||
import io.flutter.embedding.engine.plugins.FlutterPlugin |
|||
import io.flutter.plugin.common.MethodCall |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler |
|||
import io.flutter.plugin.common.MethodChannel.Result |
|||
import org.json.JSONArray |
|||
import org.json.JSONObject |
|||
import java.text.SimpleDateFormat |
|||
import java.util.* |
|||
|
|||
// 定义数据库帮助类 |
|||
private class ChatDatabaseHelper(context: Context) : |
|||
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { |
|||
|
|||
companion object { |
|||
private const val DATABASE_NAME = "chat_messages.db" |
|||
private const val DATABASE_VERSION = 1 |
|||
|
|||
// 定义消息表 |
|||
private const val TABLE_MESSAGES = "messages" |
|||
private const val COLUMN_ID = "id" |
|||
private const val COLUMN_SESSION_ID = "session_id" |
|||
private const val COLUMN_MESSAGE = "message" |
|||
private const val COLUMN_SENDER = "sender" |
|||
private const val COLUMN_TIMESTAMP = "timestamp" |
|||
private const val COLUMN_METADATA = "metadata" |
|||
} |
|||
|
|||
override fun onCreate(db: SQLiteDatabase) { |
|||
// 创建消息表 |
|||
val createMessagesTable = """ |
|||
CREATE TABLE $TABLE_MESSAGES ( |
|||
$COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, |
|||
$COLUMN_SESSION_ID TEXT NOT NULL, |
|||
$COLUMN_MESSAGE TEXT NOT NULL, |
|||
$COLUMN_SENDER TEXT NOT NULL, |
|||
$COLUMN_TIMESTAMP TEXT NOT NULL, |
|||
$COLUMN_METADATA TEXT |
|||
) |
|||
""".trimIndent() |
|||
|
|||
db.execSQL(createMessagesTable) |
|||
|
|||
// 创建索引以加快查询速度 |
|||
db.execSQL("CREATE INDEX idx_session_id ON $TABLE_MESSAGES ($COLUMN_SESSION_ID)") |
|||
db.execSQL("CREATE INDEX idx_timestamp ON $TABLE_MESSAGES ($COLUMN_TIMESTAMP)") |
|||
} |
|||
|
|||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { |
|||
// 处理数据库升级逻辑 |
|||
if (oldVersion < newVersion) { |
|||
// 简单的升级策略 - 删除旧表并创建新表 |
|||
// 在实际应用中,应该实现数据迁移而非删除 |
|||
db.execSQL("DROP TABLE IF EXISTS $TABLE_MESSAGES") |
|||
onCreate(db) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** ChatStorageSqlitePlugin */ |
|||
class ChatStorageSqlitePlugin: FlutterPlugin, MethodCallHandler { |
|||
private lateinit var channel : MethodChannel |
|||
private lateinit var context: Context |
|||
private lateinit var dbHelper: ChatDatabaseHelper |
|||
|
|||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { |
|||
context = flutterPluginBinding.applicationContext |
|||
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "chat_storage_sqlite") |
|||
channel.setMethodCallHandler(this) |
|||
dbHelper = ChatDatabaseHelper(context) |
|||
} |
|||
|
|||
override fun onMethodCall(call: MethodCall, result: Result) { |
|||
when (call.method) { |
|||
"saveMessage" -> { |
|||
try { |
|||
val sessionId = call.argument<String>("sessionId") ?: "" |
|||
val message = call.argument<String>("message") ?: "" |
|||
val sender = call.argument<String>("sender") ?: "" |
|||
val metadata = call.argument<String>("metadata") |
|||
|
|||
val db = dbHelper.writableDatabase |
|||
|
|||
// 获取当前时间作为时间戳 |
|||
val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) |
|||
|
|||
// 执行插入操作 |
|||
val values = android.content.ContentValues().apply { |
|||
put("session_id", sessionId) |
|||
put("message", message) |
|||
put("sender", sender) |
|||
put("timestamp", timestamp) |
|||
put("metadata", metadata) |
|||
} |
|||
|
|||
val id = db.insert("messages", null, values) |
|||
if (id != -1L) { |
|||
result.success(mapOf("success" to true, "id" to id)) |
|||
} else { |
|||
result.error("DB_ERROR", "插入消息失败", null) |
|||
} |
|||
} catch (e: Exception) { |
|||
result.error("EXCEPTION", "保存消息时发生错误: ${e.message}", null) |
|||
} |
|||
} |
|||
"getMessages" -> { |
|||
try { |
|||
val sessionId = call.argument<String>("sessionId") ?: "" |
|||
val page = call.argument<Int>("page") ?: 1 |
|||
val pageSize = call.argument<Int>("pageSize") ?: 20 |
|||
|
|||
val db = dbHelper.readableDatabase |
|||
val offset = (page - 1) * pageSize |
|||
|
|||
// 构建查询语句 |
|||
val query = """ |
|||
SELECT * FROM messages |
|||
WHERE session_id = ? |
|||
ORDER BY timestamp DESC |
|||
LIMIT ? OFFSET ? |
|||
""".trimIndent() |
|||
|
|||
val selectionArgs = arrayOf(sessionId, pageSize.toString(), offset.toString()) |
|||
val cursor = db.rawQuery(query, selectionArgs) |
|||
|
|||
val messages = cursorToJsonArray(cursor) |
|||
|
|||
// 获取总记录数 |
|||
val countQuery = "SELECT COUNT(*) FROM messages WHERE session_id = ?" |
|||
val countCursor = db.rawQuery(countQuery, arrayOf(sessionId)) |
|||
var totalCount = 0 |
|||
if (countCursor.moveToFirst()) { |
|||
totalCount = countCursor.getInt(0) |
|||
} |
|||
countCursor.close() |
|||
|
|||
// 计算总页数 |
|||
val totalPages = if (totalCount % pageSize == 0) { |
|||
totalCount / pageSize |
|||
} else { |
|||
(totalCount / pageSize) + 1 |
|||
} |
|||
|
|||
val response = JSONObject().apply { |
|||
put("messages", messages) |
|||
put("page", page) |
|||
put("pageSize", pageSize) |
|||
put("totalCount", totalCount) |
|||
put("totalPages", totalPages) |
|||
} |
|||
|
|||
result.success(response.toString()) |
|||
} catch (e: Exception) { |
|||
result.error("EXCEPTION", "获取消息时发生错误: ${e.message}", null) |
|||
} |
|||
} |
|||
"deleteMessages" -> { |
|||
try { |
|||
val sessionId = call.argument<String>("sessionId") ?: "" |
|||
val messageIds = call.argument<List<Int>>("messageIds") |
|||
|
|||
val db = dbHelper.writableDatabase |
|||
|
|||
if (messageIds != null && messageIds.isNotEmpty()) { |
|||
// 删除指定ID的消息 |
|||
val idsStr = messageIds.joinToString(",") |
|||
val deleteQuery = "DELETE FROM messages WHERE id IN ($idsStr)" |
|||
db.execSQL(deleteQuery) |
|||
result.success(mapOf("success" to true, "count" to messageIds.size)) |
|||
} else if (sessionId.isNotEmpty()) { |
|||
// 删除会话中的所有消息 |
|||
val deleteQuery = "DELETE FROM messages WHERE session_id = ?" |
|||
db.execSQL(deleteQuery, arrayOf(sessionId)) |
|||
result.success(mapOf("success" to true)) |
|||
} else { |
|||
result.error("INVALID_ARGS", "必须提供sessionId或messageIds", null) |
|||
} |
|||
} catch (e: Exception) { |
|||
result.error("EXCEPTION", "删除消息时发生错误: ${e.message}", null) |
|||
} |
|||
} |
|||
"clearDatabase" -> { |
|||
try { |
|||
val db = dbHelper.writableDatabase |
|||
db.execSQL("DELETE FROM messages") |
|||
result.success(mapOf("success" to true)) |
|||
} catch (e: Exception) { |
|||
result.error("EXCEPTION", "清空数据库时发生错误: ${e.message}", null) |
|||
} |
|||
} |
|||
else -> { |
|||
result.notImplemented() |
|||
} |
|||
} |
|||
} |
|||
|
|||
private fun cursorToJsonArray(cursor: Cursor): JSONArray { |
|||
val jsonArray = JSONArray() |
|||
|
|||
val idIndex = cursor.getColumnIndex("id") |
|||
val sessionIdIndex = cursor.getColumnIndex("session_id") |
|||
val messageIndex = cursor.getColumnIndex("message") |
|||
val senderIndex = cursor.getColumnIndex("sender") |
|||
val timestampIndex = cursor.getColumnIndex("timestamp") |
|||
val metadataIndex = cursor.getColumnIndex("metadata") |
|||
|
|||
while (cursor.moveToNext()) { |
|||
val jsonObject = JSONObject().apply { |
|||
put("id", cursor.getInt(idIndex)) |
|||
put("sessionId", cursor.getString(sessionIdIndex)) |
|||
put("message", cursor.getString(messageIndex)) |
|||
put("sender", cursor.getString(senderIndex)) |
|||
put("timestamp", cursor.getString(timestampIndex)) |
|||
|
|||
if (metadataIndex != -1 && !cursor.isNull(metadataIndex)) { |
|||
put("metadata", cursor.getString(metadataIndex)) |
|||
} |
|||
} |
|||
jsonArray.put(jsonObject) |
|||
} |
|||
|
|||
cursor.close() |
|||
return jsonArray |
|||
} |
|||
|
|||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { |
|||
channel.setMethodCallHandler(null) |
|||
} |
|||
} |
|||
@ -0,0 +1,155 @@ |
|||
import 'dart:async'; |
|||
import 'dart:convert'; |
|||
|
|||
import 'package:flutter/services.dart'; |
|||
|
|||
/// 聊天记录数据模型 |
|||
class ChatMessage { |
|||
final int? id; |
|||
final String sessionId; |
|||
final String message; |
|||
final String sender; |
|||
final String timestamp; |
|||
final String? metadata; |
|||
|
|||
ChatMessage({ |
|||
this.id, |
|||
required this.sessionId, |
|||
required this.message, |
|||
required this.sender, |
|||
required this.timestamp, |
|||
this.metadata, |
|||
}); |
|||
|
|||
Map<String, dynamic> toMap() { |
|||
return { |
|||
'id': id, |
|||
'sessionId': sessionId, |
|||
'message': message, |
|||
'sender': sender, |
|||
'timestamp': timestamp, |
|||
'metadata': metadata, |
|||
}; |
|||
} |
|||
|
|||
factory ChatMessage.fromMap(Map<String, dynamic> map) { |
|||
return ChatMessage( |
|||
id: map['id'], |
|||
sessionId: map['sessionId'], |
|||
message: map['message'], |
|||
sender: map['sender'], |
|||
timestamp: map['timestamp'], |
|||
metadata: map['metadata'], |
|||
); |
|||
} |
|||
} |
|||
|
|||
/// 分页结果数据模型 |
|||
class PagedResult { |
|||
final List<ChatMessage> messages; |
|||
final int page; |
|||
final int pageSize; |
|||
final int totalCount; |
|||
final int totalPages; |
|||
|
|||
PagedResult({ |
|||
required this.messages, |
|||
required this.page, |
|||
required this.pageSize, |
|||
required this.totalCount, |
|||
required this.totalPages, |
|||
}); |
|||
} |
|||
|
|||
/// SQLite聊天记录存储插件 |
|||
class ChatStorageSqlite { |
|||
static const MethodChannel _channel = MethodChannel('chat_storage_sqlite'); |
|||
|
|||
/// 保存一条聊天记录 |
|||
static Future<Map<String, dynamic>> saveMessage({ |
|||
required String sessionId, |
|||
required String message, |
|||
required String sender, |
|||
String? metadata, |
|||
}) async { |
|||
try { |
|||
final result = await _channel.invokeMethod('saveMessage', { |
|||
'sessionId': sessionId, |
|||
'message': message, |
|||
'sender': sender, |
|||
'metadata': metadata, |
|||
}); |
|||
return Map<String, dynamic>.from(result); |
|||
} catch (e) { |
|||
return {'success': false, 'error': e.toString()}; |
|||
} |
|||
} |
|||
|
|||
/// 获取指定会话的聊天记录(支持分页) |
|||
static Future<PagedResult> getMessages({ |
|||
required String sessionId, |
|||
int page = 1, |
|||
int pageSize = 20, |
|||
}) async { |
|||
try { |
|||
final String result = await _channel.invokeMethod('getMessages', { |
|||
'sessionId': sessionId, |
|||
'page': page, |
|||
'pageSize': pageSize, |
|||
}); |
|||
|
|||
final Map<String, dynamic> data = jsonDecode(result); |
|||
final List<dynamic> messagesList = data['messages']; |
|||
|
|||
List<ChatMessage> messages = messagesList.map((item) { |
|||
return ChatMessage.fromMap(Map<String, dynamic>.from(item)); |
|||
}).toList(); |
|||
|
|||
return PagedResult( |
|||
messages: messages, |
|||
page: data['page'], |
|||
pageSize: data['pageSize'], |
|||
totalCount: data['totalCount'], |
|||
totalPages: data['totalPages'], |
|||
); |
|||
} catch (e) { |
|||
return PagedResult( |
|||
messages: [], |
|||
page: page, |
|||
pageSize: pageSize, |
|||
totalCount: 0, |
|||
totalPages: 0, |
|||
); |
|||
} |
|||
} |
|||
|
|||
/// 删除聊天记录,可按会话ID或消息ID列表删除 |
|||
static Future<Map<String, dynamic>> deleteMessages({ |
|||
String? sessionId, |
|||
List<int>? messageIds, |
|||
}) async { |
|||
try { |
|||
if (sessionId == null && (messageIds == null || messageIds.isEmpty)) { |
|||
return {'success': false, 'error': '必须提供sessionId或messageIds'}; |
|||
} |
|||
|
|||
final result = await _channel.invokeMethod('deleteMessages', { |
|||
'sessionId': sessionId, |
|||
'messageIds': messageIds, |
|||
}); |
|||
return Map<String, dynamic>.from(result); |
|||
} catch (e) { |
|||
return {'success': false, 'error': e.toString()}; |
|||
} |
|||
} |
|||
|
|||
/// 清空整个数据库 |
|||
static Future<Map<String, dynamic>> clearDatabase() async { |
|||
try { |
|||
final result = await _channel.invokeMethod('clearDatabase'); |
|||
return Map<String, dynamic>.from(result); |
|||
} catch (e) { |
|||
return {'success': false, 'error': e.toString()}; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
name: chat_storage_sqlite |
|||
description: SQLite存储插件,用于存储聊天记录并支持分页查询 |
|||
version: 0.0.1 |
|||
homepage: |
|||
|
|||
environment: |
|||
sdk: ">=2.17.0 <3.0.0" |
|||
flutter: ">=2.5.0" |
|||
|
|||
dependencies: |
|||
flutter: |
|||
sdk: flutter |
|||
sqflite: ^2.3.0 |
|||
path: ^1.8.3 |
|||
|
|||
dev_dependencies: |
|||
flutter_test: |
|||
sdk: flutter |
|||
flutter_lints: ^2.0.0 |
|||
|
|||
# The following section is specific to Flutter packages. |
|||
flutter: |
|||
# This section identifies this Flutter project as a plugin project. |
|||
plugin: |
|||
platforms: |
|||
android: |
|||
package: com.yunqiinnovation.chat_storage_sqlite |
|||
pluginClass: ChatStorageSqlitePlugin |
|||
ios: |
|||
pluginClass: ChatStorageSqlitePlugin |
|||
@ -0,0 +1,144 @@ |
|||
# Jieli 插件 |
|||
|
|||
一个用于连接杰理蓝牙耳机的Flutter插件,支持Android 12+的BLE通信。 |
|||
|
|||
## 功能 |
|||
|
|||
- 扫描支持特定BLE服务的耳机设备 |
|||
- 连接设备并发现服务 |
|||
- 接收耳机的唤醒通知 |
|||
- 向耳机发送控制命令启动录音 |
|||
- 接收音频数据流 |
|||
|
|||
## 技术规范 |
|||
|
|||
此插件实现了以下BLE特性: |
|||
|
|||
- **服务UUID**: 0000A100-0000-1000-8000-00805F9B34FB |
|||
- **音频数据特征UUID**: 0000A101-0000-1000-8000-00805F9B34FB |
|||
- **控制特征UUID**: 0000A102-0000-1000-8000-00805F9B34FB |
|||
|
|||
## 使用方法 |
|||
|
|||
### 初始化插件 |
|||
|
|||
```dart |
|||
final jieli = Jieli(); |
|||
``` |
|||
|
|||
### 检查蓝牙状态 |
|||
|
|||
```dart |
|||
bool isEnabled = await jieli.isBluetoothEnabled(); |
|||
if (!isEnabled) { |
|||
await jieli.requestBluetoothEnable(); |
|||
} |
|||
``` |
|||
|
|||
### 扫描设备 |
|||
|
|||
```dart |
|||
// 监听扫描结果 |
|||
jieli.scanResults.listen((devices) { |
|||
// 处理扫描到的设备列表 |
|||
for (var device in devices) { |
|||
print('发现设备: ${device.name} (${device.id}), RSSI: ${device.rssi}'); |
|||
} |
|||
}); |
|||
|
|||
// 开始扫描,10秒后自动停止 |
|||
await jieli.startScan(scanTimeout: 10000); |
|||
|
|||
// 手动停止扫描 |
|||
await jieli.stopScan(); |
|||
``` |
|||
|
|||
### 连接设备 |
|||
|
|||
```dart |
|||
// 监听连接状态 |
|||
jieli.connectionState.listen((state) { |
|||
switch (state) { |
|||
case ConnectionState.connected: |
|||
print('设备已连接'); |
|||
break; |
|||
case ConnectionState.disconnected: |
|||
print('设备已断开'); |
|||
break; |
|||
case ConnectionState.connecting: |
|||
print('正在连接...'); |
|||
break; |
|||
case ConnectionState.disconnecting: |
|||
print('正在断开...'); |
|||
break; |
|||
case ConnectionState.error: |
|||
print('连接错误'); |
|||
break; |
|||
} |
|||
}); |
|||
|
|||
// 连接到设备 |
|||
await jieli.connect(deviceId); |
|||
|
|||
// 断开连接 |
|||
await jieli.disconnect(); |
|||
``` |
|||
|
|||
### 接收唤醒信号 |
|||
|
|||
```dart |
|||
// 监听唤醒信号 |
|||
jieli.wakeupStream.listen((wakeup) { |
|||
print('收到唤醒信号'); |
|||
// 通常在收到唤醒后自动发送开始录音命令 |
|||
}); |
|||
``` |
|||
|
|||
### 发送录音命令 |
|||
|
|||
```dart |
|||
// 发送开始录音命令 |
|||
await jieli.sendStartRecordingCommand(); |
|||
|
|||
// 发送停止录音命令 |
|||
await jieli.sendStopRecordingCommand(); |
|||
``` |
|||
|
|||
### 接收音频数据 |
|||
|
|||
```dart |
|||
// 监听音频数据流 |
|||
jieli.audioDataStream.listen((data) { |
|||
print('收到音频数据: ${data.length} 字节'); |
|||
// 处理音频数据 |
|||
}); |
|||
``` |
|||
|
|||
### 资源清理 |
|||
|
|||
```dart |
|||
// 在不再需要插件时释放资源 |
|||
await jieli.dispose(); |
|||
``` |
|||
|
|||
## 权限 |
|||
|
|||
使用此插件需要以下Android权限: |
|||
|
|||
- Android 12+ (API 31+): `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT` |
|||
- Android 11及以下: `BLUETOOTH`, `BLUETOOTH_ADMIN` |
|||
|
|||
插件会在需要时自动请求权限,但建议在应用清单文件中声明这些权限: |
|||
|
|||
```xml |
|||
<!-- 针对 Android 11 及以下的传统蓝牙权限 --> |
|||
<uses-permission android:name="android.permission.BLUETOOTH" |
|||
android:maxSdkVersion="30" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" |
|||
android:maxSdkVersion="30" /> |
|||
|
|||
<!-- Android 12+ 新的 BLE 扫描和连接权限 --> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" |
|||
android:usesPermissionFlags="neverForLocation" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> |
|||
``` |
|||
@ -0,0 +1,55 @@ |
|||
plugins { |
|||
// Android Library 插件 |
|||
id("com.android.library") |
|||
// Kotlin Android 插件 |
|||
id("org.jetbrains.kotlin.android") |
|||
id("com.github.johnrengelman.shadow") version "8.1.1" |
|||
} |
|||
|
|||
android { |
|||
// 命名空间,对应你插件的包名(需与代码内包名保持一致) |
|||
namespace = "com.yunqiinnovation.jieli" |
|||
|
|||
// 目标 SDK 版本 |
|||
compileSdk = 33 |
|||
|
|||
defaultConfig { |
|||
// 最低 SDK 版本 |
|||
minSdk = 21 |
|||
targetSdk = 33 |
|||
} |
|||
|
|||
// Java 语言级别兼容配置 |
|||
compileOptions { |
|||
sourceCompatibility = JavaVersion.VERSION_11 |
|||
targetCompatibility = JavaVersion.VERSION_11 |
|||
} |
|||
|
|||
// Kotlin 语言级别 |
|||
kotlinOptions { |
|||
jvmTarget = "11" |
|||
} |
|||
|
|||
// 添加packaging配置,排除冲突文件 |
|||
packaging { |
|||
resources { |
|||
excludes.add("META-INF/DEPENDENCIES") |
|||
excludes.add("META-INF/LICENSE") |
|||
excludes.add("META-INF/LICENSE.txt") |
|||
excludes.add("META-INF/license.txt") |
|||
excludes.add("META-INF/NOTICE") |
|||
excludes.add("META-INF/NOTICE.txt") |
|||
excludes.add("META-INF/notice.txt") |
|||
excludes.add("META-INF/*.kotlin_module") |
|||
} |
|||
} |
|||
} |
|||
|
|||
dependencies { |
|||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.10") |
|||
implementation("androidx.core:core-ktx:1.7.0") |
|||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0") |
|||
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.4.0") |
|||
implementation("androidx.lifecycle:lifecycle-service:2.4.0") |
|||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2") |
|||
} |
|||
@ -0,0 +1 @@ |
|||
rootProject.name = "jieli" |
|||
@ -0,0 +1,22 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
|||
package="com.yunqiinnovation.jieli"> |
|||
|
|||
<!-- 声明需要蓝牙功能 --> |
|||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" /> |
|||
|
|||
<!-- 针对 Android 11 及以下的传统蓝牙权限 --> |
|||
<uses-permission android:name="android.permission.BLUETOOTH" |
|||
android:maxSdkVersion="30" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" |
|||
android:maxSdkVersion="30" /> |
|||
|
|||
<!-- Android 12+ 新的 BLE 扫描和连接权限 --> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" |
|||
android:usesPermissionFlags="neverForLocation" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> |
|||
|
|||
<!-- 前台服务权限 --> |
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> |
|||
|
|||
</manifest> |
|||
@ -0,0 +1,460 @@ |
|||
package com.yunqiinnovation.jieli |
|||
|
|||
import android.bluetooth.* |
|||
import android.bluetooth.le.* |
|||
import android.content.Context |
|||
import android.os.Handler |
|||
import android.os.Looper |
|||
import android.os.ParcelUuid |
|||
import android.util.Log |
|||
import androidx.lifecycle.LifecycleService |
|||
import androidx.lifecycle.MutableLiveData |
|||
import kotlinx.coroutines.* |
|||
import java.util.* |
|||
|
|||
/** |
|||
* BLE回调接口,统一处理蓝牙事件 |
|||
*/ |
|||
interface BleCallback { |
|||
/** |
|||
* 扫描结果回调 |
|||
* @param devices 扫描到的设备列表 |
|||
*/ |
|||
fun onScanResult(devices: List<Map<String, Any>>) |
|||
|
|||
/** |
|||
* 连接状态变化回调 |
|||
* @param state 连接状态 |
|||
*/ |
|||
fun onConnectionStateChanged(state: Int) |
|||
|
|||
/** |
|||
* 音频数据回调 |
|||
* @param data 音频数据字节数组 |
|||
*/ |
|||
fun onAudioDataReceived(data: ByteArray) |
|||
|
|||
/** |
|||
* 唤醒信号回调 |
|||
*/ |
|||
fun onWakeupSignalReceived() |
|||
} |
|||
|
|||
/** |
|||
* BLE服务类,提供核心蓝牙功能 |
|||
*/ |
|||
class BleService(private val context: Context) { |
|||
companion object { |
|||
private const val TAG = "JieliBleCoreService" |
|||
|
|||
// 服务和特征UUID |
|||
private const val AUDIO_SERVICE_UUID = "0000A100-0000-1000-8000-00805F9B34FB" |
|||
private const val AUDIO_DATA_CHAR_UUID = "0000A101-0000-1000-8000-00805F9B34FB" |
|||
private const val CONTROL_CHAR_UUID = "0000A102-0000-1000-8000-00805F9B34FB" |
|||
private const val CLIENT_CHAR_CONFIG_UUID = "00002902-0000-1000-8000-00805f9b34fb" |
|||
|
|||
// 连接状态常量 |
|||
const val STATE_DISCONNECTED = 0 |
|||
const val STATE_CONNECTING = 1 |
|||
const val STATE_CONNECTED = 2 |
|||
const val STATE_DISCONNECTING = 3 |
|||
const val STATE_ERROR = 4 |
|||
|
|||
// 扫描结果更新间隔(毫秒) |
|||
private const val SCAN_RESULT_UPDATE_INTERVAL = 500L |
|||
} |
|||
|
|||
// 蓝牙管理器和适配器 |
|||
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager |
|||
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter |
|||
private val bluetoothLeScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner |
|||
|
|||
// GATT连接和特征 |
|||
private var bluetoothGatt: BluetoothGatt? = null |
|||
private var audioDataChar: BluetoothGattCharacteristic? = null |
|||
private var controlChar: BluetoothGattCharacteristic? = null |
|||
|
|||
// 扫描相关 |
|||
private val scanHandler = Handler(Looper.getMainLooper()) |
|||
private val scanResults = ArrayList<ScanResult>() |
|||
private var isScanning = false |
|||
private var isPendingScanResultUpdate = false |
|||
private val scanResultUpdateRunnable = Runnable { |
|||
notifyScanResults() |
|||
isPendingScanResultUpdate = false |
|||
} |
|||
|
|||
// 连接状态LiveData |
|||
val connectionState = MutableLiveData<Int>(STATE_DISCONNECTED) |
|||
|
|||
// 统一回调接口 |
|||
var callback: BleCallback? = null |
|||
|
|||
/** |
|||
* 开始扫描BLE设备 |
|||
*/ |
|||
fun startScan(scanTimeout: Long = 5000): Boolean { |
|||
if (bluetoothLeScanner == null || isScanning) { |
|||
return false |
|||
} |
|||
|
|||
Log.i(TAG, "开始扫描BLE设备...") |
|||
scanResults.clear() |
|||
|
|||
val serviceUuid = UUID.fromString(AUDIO_SERVICE_UUID) |
|||
val scanFilter = ScanFilter.Builder() |
|||
// .setServiceUuid(ParcelUuid(serviceUuid)) |
|||
.build() |
|||
val scanFilters = listOf(scanFilter) |
|||
|
|||
val scanSettings = ScanSettings.Builder() |
|||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) |
|||
.build() |
|||
|
|||
bluetoothLeScanner.startScan(scanFilters, scanSettings, scanCallback) |
|||
isScanning = true |
|||
|
|||
// 设置超时停止扫描 |
|||
scanHandler.postDelayed({ |
|||
Log.d(TAG, "定时器触发,准备停止扫描 [主线程:${Looper.myLooper() == Looper.getMainLooper()}]") |
|||
stopScan() |
|||
}, scanTimeout) |
|||
|
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 停止扫描 |
|||
*/ |
|||
fun stopScan(): Boolean { |
|||
Log.i(TAG, "停止扫描BLE设备") |
|||
|
|||
if (bluetoothLeScanner == null || !isScanning) { |
|||
return false |
|||
} |
|||
|
|||
bluetoothLeScanner.stopScan(scanCallback) |
|||
scanHandler.removeCallbacksAndMessages(null) |
|||
isScanning = false |
|||
|
|||
// 最后一次发送扫描结果 |
|||
notifyScanResults() |
|||
|
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 扫描回调 |
|||
*/ |
|||
private val scanCallback = object : ScanCallback() { |
|||
override fun onScanResult(callbackType: Int, result: ScanResult) { |
|||
val device = result.device |
|||
val deviceName = device.name ?: "未知设备" |
|||
val deviceAddress = device.address |
|||
val rssi = result.rssi |
|||
|
|||
// Log.d(TAG, "发现设备: $deviceName [$deviceAddress], RSSI: $rssi") |
|||
|
|||
// 避免重复添加 |
|||
val existingIndex = scanResults.indexOfFirst { it.device.address == deviceAddress } |
|||
if (existingIndex >= 0) { |
|||
scanResults[existingIndex] = result |
|||
} else { |
|||
scanResults.add(result) |
|||
} |
|||
|
|||
// 如果没有待处理的更新,安排一个 |
|||
if (!isPendingScanResultUpdate) { |
|||
isPendingScanResultUpdate = true |
|||
scanHandler.postDelayed(scanResultUpdateRunnable, SCAN_RESULT_UPDATE_INTERVAL) |
|||
} |
|||
} |
|||
|
|||
override fun onScanFailed(errorCode: Int) { |
|||
Log.e(TAG, "扫描失败,错误码: $errorCode") |
|||
isScanning = false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 将扫描结果通知回调 |
|||
*/ |
|||
private fun notifyScanResults() { |
|||
val devicesJson = scanResults.map { result -> |
|||
mapOf( |
|||
"id" to result.device.address, |
|||
"name" to (result.device.name ?: "未知设备"), |
|||
"rssi" to result.rssi |
|||
) |
|||
} |
|||
|
|||
callback?.onScanResult(devicesJson) |
|||
} |
|||
|
|||
/** |
|||
* 连接到指定设备 |
|||
*/ |
|||
fun connect(deviceAddress: String) { |
|||
if (bluetoothAdapter == null) { |
|||
Log.e(TAG, "蓝牙适配器不可用") |
|||
updateConnectionState(STATE_ERROR) |
|||
return |
|||
} |
|||
|
|||
// 先断开现有连接 |
|||
bluetoothGatt?.close() |
|||
bluetoothGatt = null |
|||
|
|||
try { |
|||
val device = bluetoothAdapter.getRemoteDevice(deviceAddress) |
|||
updateConnectionState(STATE_CONNECTING) |
|||
|
|||
// 连接GATT服务器 |
|||
Log.i(TAG, "正在连接设备: ${device.name ?: "未知设备"} [$deviceAddress]") |
|||
bluetoothGatt = device.connectGatt(context, false, gattCallback) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "连接设备时发生错误: ${e.message}") |
|||
updateConnectionState(STATE_ERROR) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 断开连接 |
|||
*/ |
|||
fun disconnect() { |
|||
bluetoothGatt?.disconnect() |
|||
updateConnectionState(STATE_DISCONNECTING) |
|||
} |
|||
|
|||
/** |
|||
* 更新连接状态并通知回调 |
|||
*/ |
|||
private fun updateConnectionState(state: Int) { |
|||
connectionState.postValue(state) |
|||
callback?.onConnectionStateChanged(state) |
|||
} |
|||
|
|||
/** |
|||
* GATT回调 |
|||
*/ |
|||
private val gattCallback = object : BluetoothGattCallback() { |
|||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { |
|||
if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) { |
|||
Log.i(TAG, "设备已连接,开始发现服务...") |
|||
updateConnectionState(STATE_CONNECTED) |
|||
|
|||
// 开始搜索服务 |
|||
gatt.discoverServices() |
|||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) { |
|||
Log.i(TAG, "设备已断开连接") |
|||
updateConnectionState(STATE_DISCONNECTED) |
|||
|
|||
// 清理资源 |
|||
gatt.close() |
|||
bluetoothGatt = null |
|||
audioDataChar = null |
|||
controlChar = null |
|||
} else if (status != BluetoothGatt.GATT_SUCCESS) { |
|||
Log.e(TAG, "连接失败,status=$status") |
|||
updateConnectionState(STATE_ERROR) |
|||
|
|||
gatt.close() |
|||
bluetoothGatt = null |
|||
} |
|||
} |
|||
|
|||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { |
|||
if (status == BluetoothGatt.GATT_SUCCESS) { |
|||
Log.i(TAG, "服务发现成功") |
|||
|
|||
// 查找目标服务 |
|||
val audioService = gatt.getService(UUID.fromString(AUDIO_SERVICE_UUID)) |
|||
if (audioService != null) { |
|||
Log.i(TAG, "找到音频服务") |
|||
|
|||
// 获取音频数据和控制特征 |
|||
audioDataChar = audioService.getCharacteristic(UUID.fromString(AUDIO_DATA_CHAR_UUID)) |
|||
controlChar = audioService.getCharacteristic(UUID.fromString(CONTROL_CHAR_UUID)) |
|||
|
|||
if (audioDataChar != null && controlChar != null) { |
|||
Log.i(TAG, "找到所需特征") |
|||
|
|||
// 注册特征通知 |
|||
setupNotifications(gatt) |
|||
} else { |
|||
Log.e(TAG, "未找到所需特征") |
|||
updateConnectionState(STATE_ERROR) |
|||
} |
|||
} else { |
|||
Log.e(TAG, "未找到音频服务") |
|||
updateConnectionState(STATE_ERROR) |
|||
} |
|||
} else { |
|||
Log.e(TAG, "服务发现失败,status=$status") |
|||
updateConnectionState(STATE_ERROR) |
|||
} |
|||
} |
|||
|
|||
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { |
|||
val characteristicUuid = characteristic.uuid.toString() |
|||
|
|||
when (characteristicUuid) { |
|||
AUDIO_DATA_CHAR_UUID -> { |
|||
// 收到音频数据 |
|||
val audioData = characteristic.value |
|||
Log.d(TAG, "收到音频数据帧,大小: ${audioData.size} 字节") |
|||
|
|||
callback?.onAudioDataReceived(audioData) |
|||
} |
|||
CONTROL_CHAR_UUID -> { |
|||
// 收到唤醒信号 |
|||
val value = characteristic.value |
|||
Log.i(TAG, "收到控制通知: ${value.contentToString()}") |
|||
|
|||
// 假设0x01表示唤醒信号 |
|||
if (value.isNotEmpty() && value[0].toInt() == 0x01) { |
|||
Log.i(TAG, "收到唤醒信号") |
|||
|
|||
callback?.onWakeupSignalReceived() |
|||
|
|||
// 收到唤醒后自动发送开始录音命令 |
|||
sendStartRecordingCommand() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { |
|||
if (characteristic.uuid.toString() == CONTROL_CHAR_UUID) { |
|||
if (status == BluetoothGatt.GATT_SUCCESS) { |
|||
Log.i(TAG, "命令发送成功: ${characteristic.value.contentToString()}") |
|||
} else { |
|||
Log.e(TAG, "命令发送失败,status=$status") |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { |
|||
if (status == BluetoothGatt.GATT_SUCCESS) { |
|||
Log.i(TAG, "描述符写入成功: ${descriptor.uuid}") |
|||
|
|||
// 如果是音频数据特征的描述符,再订阅控制特征 |
|||
if (descriptor.characteristic.uuid.toString() == AUDIO_DATA_CHAR_UUID) { |
|||
setupControlNotification(gatt) |
|||
} |
|||
} else { |
|||
Log.e(TAG, "描述符写入失败,status=$status") |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 设置特征通知 |
|||
*/ |
|||
private fun setupNotifications(gatt: BluetoothGatt) { |
|||
// 先订阅音频数据通知 |
|||
if (audioDataChar?.properties?.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { |
|||
gatt.setCharacteristicNotification(audioDataChar, true) |
|||
|
|||
val descriptor = audioDataChar?.getDescriptor(UUID.fromString(CLIENT_CHAR_CONFIG_UUID)) |
|||
descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE |
|||
|
|||
if (descriptor != null) { |
|||
gatt.writeDescriptor(descriptor) |
|||
Log.i(TAG, "设置音频数据特征通知") |
|||
} |
|||
} else { |
|||
Log.e(TAG, "音频数据特征不支持通知") |
|||
setupControlNotification(gatt) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 设置控制特征通知 |
|||
*/ |
|||
private fun setupControlNotification(gatt: BluetoothGatt) { |
|||
if (controlChar?.properties?.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { |
|||
gatt.setCharacteristicNotification(controlChar, true) |
|||
|
|||
val descriptor = controlChar?.getDescriptor(UUID.fromString(CLIENT_CHAR_CONFIG_UUID)) |
|||
descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE |
|||
|
|||
if (descriptor != null) { |
|||
gatt.writeDescriptor(descriptor) |
|||
Log.i(TAG, "设置控制特征通知") |
|||
} |
|||
} else { |
|||
Log.e(TAG, "控制特征不支持通知") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送开始录音命令(向控制特征写入0x01) |
|||
*/ |
|||
fun sendStartRecordingCommand(): Boolean { |
|||
if (bluetoothGatt == null || controlChar == null || connectionState.value != STATE_CONNECTED) { |
|||
Log.e(TAG, "无法发送开始录音命令,设备未连接") |
|||
return false |
|||
} |
|||
|
|||
try { |
|||
val commandData = byteArrayOf(0x01) |
|||
controlChar?.value = commandData |
|||
controlChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT |
|||
|
|||
val success = bluetoothGatt?.writeCharacteristic(controlChar) ?: false |
|||
|
|||
if (success) { |
|||
Log.i(TAG, "开始录音命令已发送") |
|||
} else { |
|||
Log.e(TAG, "开始录音命令发送失败") |
|||
} |
|||
|
|||
return success |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "发送开始录音命令时发生异常: ${e.message}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送停止录音命令(向控制特征写入0x02) |
|||
*/ |
|||
fun sendStopRecordingCommand(): Boolean { |
|||
if (bluetoothGatt == null || controlChar == null || connectionState.value != STATE_CONNECTED) { |
|||
Log.e(TAG, "无法发送停止录音命令,设备未连接") |
|||
return false |
|||
} |
|||
|
|||
try { |
|||
val commandData = byteArrayOf(0x02) |
|||
controlChar?.value = commandData |
|||
controlChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT |
|||
|
|||
val success = bluetoothGatt?.writeCharacteristic(controlChar) ?: false |
|||
|
|||
if (success) { |
|||
Log.i(TAG, "停止录音命令已发送") |
|||
} else { |
|||
Log.e(TAG, "停止录音命令发送失败") |
|||
} |
|||
|
|||
return success |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "发送停止录音命令时发生异常: ${e.message}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 清理资源 |
|||
*/ |
|||
fun cleanup() { |
|||
stopScan() |
|||
scanHandler.removeCallbacks(scanResultUpdateRunnable) |
|||
bluetoothGatt?.close() |
|||
bluetoothGatt = null |
|||
audioDataChar = null |
|||
controlChar = null |
|||
} |
|||
} |
|||
@ -0,0 +1,269 @@ |
|||
package com.yunqiinnovation.jieli |
|||
|
|||
import android.app.Activity |
|||
import android.bluetooth.BluetoothAdapter |
|||
import android.bluetooth.BluetoothManager |
|||
import android.content.Context |
|||
import android.content.Intent |
|||
import android.content.pm.PackageManager |
|||
import androidx.annotation.NonNull |
|||
import androidx.core.app.ActivityCompat |
|||
import androidx.core.content.ContextCompat |
|||
import io.flutter.embedding.engine.plugins.FlutterPlugin |
|||
import io.flutter.embedding.engine.plugins.activity.ActivityAware |
|||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding |
|||
import io.flutter.plugin.common.EventChannel |
|||
import io.flutter.plugin.common.MethodCall |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler |
|||
import io.flutter.plugin.common.MethodChannel.Result |
|||
import kotlinx.coroutines.* |
|||
|
|||
/** JieliPlugin */ |
|||
class JieliPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, EventChannel.StreamHandler { |
|||
/// 方法通道 |
|||
private lateinit var methodChannel: MethodChannel |
|||
/// 统一事件通道 |
|||
private lateinit var eventChannel: EventChannel |
|||
|
|||
private lateinit var context: Context |
|||
private var activity: Activity? = null |
|||
|
|||
/// BLE服务实例 |
|||
private lateinit var bleService: BleService |
|||
|
|||
// 协程作用域 |
|||
private val pluginScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) |
|||
|
|||
// 事件接收器 |
|||
private var eventSink: EventChannel.EventSink? = null |
|||
|
|||
// 事件类型常量 |
|||
companion object { |
|||
private const val EVENT_TYPE_AUDIO_DATA = "audio_data" |
|||
private const val EVENT_TYPE_CONNECTION_STATE = "connection_state" |
|||
private const val EVENT_TYPE_SCAN_RESULT = "scan_result" |
|||
private const val EVENT_TYPE_WAKEUP_SIGNAL = "wakeup_signal" |
|||
} |
|||
|
|||
// 蓝牙相关请求码 |
|||
private val REQUEST_ENABLE_BT = 1 |
|||
private val REQUEST_PERMISSION_BLE = 2 |
|||
|
|||
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { |
|||
val binaryMessenger = flutterPluginBinding.binaryMessenger |
|||
|
|||
// 设置方法通道 |
|||
methodChannel = MethodChannel(binaryMessenger, "com.yunqiinnovation.jieli") |
|||
methodChannel.setMethodCallHandler(this) |
|||
|
|||
// 设置统一事件通道 |
|||
eventChannel = EventChannel(binaryMessenger, "com.yunqiinnovation.jieli/events") |
|||
eventChannel.setStreamHandler(this) |
|||
|
|||
context = flutterPluginBinding.applicationContext |
|||
|
|||
// 初始化BLE服务 |
|||
bleService = BleService(context) |
|||
|
|||
// 设置回调 |
|||
setupBleCallbacks() |
|||
} |
|||
|
|||
/** |
|||
* 设置BLE服务回调 |
|||
*/ |
|||
private fun setupBleCallbacks() { |
|||
// 设置统一回调接口 |
|||
bleService.callback = object : BleCallback { |
|||
override fun onScanResult(devices: List<Map<String, Any>>) { |
|||
pluginScope.launch(Dispatchers.Main) { |
|||
sendEvent(EVENT_TYPE_SCAN_RESULT, devices) |
|||
} |
|||
} |
|||
|
|||
override fun onConnectionStateChanged(state: Int) { |
|||
pluginScope.launch(Dispatchers.Main) { |
|||
sendEvent(EVENT_TYPE_CONNECTION_STATE, state) |
|||
} |
|||
} |
|||
|
|||
override fun onAudioDataReceived(data: ByteArray) { |
|||
pluginScope.launch(Dispatchers.Main) { |
|||
sendEvent(EVENT_TYPE_AUDIO_DATA, data) |
|||
} |
|||
} |
|||
|
|||
override fun onWakeupSignalReceived() { |
|||
pluginScope.launch(Dispatchers.Main) { |
|||
sendEvent(EVENT_TYPE_WAKEUP_SIGNAL, true) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送事件到Flutter端 |
|||
*/ |
|||
private fun sendEvent(eventType: String, data: Any?) { |
|||
eventSink?.success(mapOf( |
|||
"type" to eventType, |
|||
"data" to data |
|||
)) |
|||
} |
|||
|
|||
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { |
|||
when (call.method) { |
|||
"startScan" -> { |
|||
val scanTimeout = call.argument<Int>("scanTimeout") ?: 10000 |
|||
|
|||
if (!hasRequiredPermissions()) { |
|||
requestBlePermissions() |
|||
result.error("PERMISSION_DENIED", "需要蓝牙权限才能扫描设备", null) |
|||
return |
|||
} |
|||
|
|||
val success = bleService.startScan(scanTimeout.toLong()) |
|||
result.success(success) |
|||
} |
|||
"stopScan" -> { |
|||
val success = bleService.stopScan() |
|||
result.success(success) |
|||
} |
|||
"connect" -> { |
|||
val deviceId = call.argument<String>("deviceId") |
|||
if (deviceId == null) { |
|||
result.error("INVALID_ARGUMENT", "设备ID不能为空", null) |
|||
return |
|||
} |
|||
|
|||
if (!hasRequiredPermissions()) { |
|||
requestBlePermissions() |
|||
result.error("PERMISSION_DENIED", "需要蓝牙权限才能连接设备", null) |
|||
return |
|||
} |
|||
|
|||
bleService.connect(deviceId) |
|||
result.success(true) |
|||
} |
|||
"disconnect" -> { |
|||
bleService.disconnect() |
|||
result.success(true) |
|||
} |
|||
"sendStartRecordingCommand" -> { |
|||
val success = bleService.sendStartRecordingCommand() |
|||
result.success(success) |
|||
} |
|||
"sendStopRecordingCommand" -> { |
|||
val success = bleService.sendStopRecordingCommand() |
|||
result.success(success) |
|||
} |
|||
"isBluetoothEnabled" -> { |
|||
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager |
|||
val bluetoothAdapter = bluetoothManager.adapter |
|||
result.success(bluetoothAdapter?.isEnabled == true) |
|||
} |
|||
"requestBluetoothEnable" -> { |
|||
if (activity == null) { |
|||
result.error("ACTIVITY_NOT_AVAILABLE", "Activity不可用,无法请求打开蓝牙", null) |
|||
return |
|||
} |
|||
|
|||
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager |
|||
val bluetoothAdapter = bluetoothManager.adapter |
|||
|
|||
if (bluetoothAdapter == null) { |
|||
result.error("BLUETOOTH_UNAVAILABLE", "设备不支持蓝牙", null) |
|||
return |
|||
} |
|||
|
|||
if (bluetoothAdapter.isEnabled) { |
|||
result.success(true) |
|||
return |
|||
} |
|||
|
|||
// 请求打开蓝牙 |
|||
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) |
|||
activity?.startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) |
|||
result.success(true) |
|||
} |
|||
else -> { |
|||
result.notImplemented() |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { |
|||
methodChannel.setMethodCallHandler(null) |
|||
eventChannel.setStreamHandler(null) |
|||
|
|||
bleService.cleanup() |
|||
pluginScope.cancel() |
|||
} |
|||
|
|||
override fun onAttachedToActivity(binding: ActivityPluginBinding) { |
|||
activity = binding.activity |
|||
} |
|||
|
|||
override fun onDetachedFromActivityForConfigChanges() { |
|||
activity = null |
|||
} |
|||
|
|||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { |
|||
activity = binding.activity |
|||
} |
|||
|
|||
override fun onDetachedFromActivity() { |
|||
activity = null |
|||
} |
|||
|
|||
/// EventChannel.StreamHandler接口实现 |
|||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|||
eventSink = events |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
eventSink = null |
|||
} |
|||
|
|||
/// 检查是否拥有所需权限 |
|||
private fun hasRequiredPermissions(): Boolean { |
|||
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { |
|||
hasPermission(android.Manifest.permission.BLUETOOTH_SCAN) && |
|||
hasPermission(android.Manifest.permission.BLUETOOTH_CONNECT) |
|||
} else { |
|||
hasPermission(android.Manifest.permission.BLUETOOTH) && |
|||
hasPermission(android.Manifest.permission.BLUETOOTH_ADMIN) |
|||
} |
|||
} |
|||
|
|||
/// 检查单个权限 |
|||
private fun hasPermission(permission: String): Boolean { |
|||
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED |
|||
} |
|||
|
|||
/// 请求BLE权限 |
|||
private fun requestBlePermissions() { |
|||
activity?.let { |
|||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { |
|||
ActivityCompat.requestPermissions( |
|||
it, |
|||
arrayOf( |
|||
android.Manifest.permission.BLUETOOTH_SCAN, |
|||
android.Manifest.permission.BLUETOOTH_CONNECT |
|||
), |
|||
REQUEST_PERMISSION_BLE |
|||
) |
|||
} else { |
|||
ActivityCompat.requestPermissions( |
|||
it, |
|||
arrayOf( |
|||
android.Manifest.permission.BLUETOOTH, |
|||
android.Manifest.permission.BLUETOOTH_ADMIN |
|||
), |
|||
REQUEST_PERMISSION_BLE |
|||
) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,188 @@ |
|||
import 'dart:async'; |
|||
import 'dart:typed_data'; |
|||
|
|||
import 'package:flutter/services.dart'; |
|||
|
|||
/// 设备类,代表一个BLE设备 |
|||
class BleDevice { |
|||
final String id; |
|||
final String name; |
|||
final int rssi; |
|||
|
|||
BleDevice({required this.id, required this.name, required this.rssi}); |
|||
|
|||
factory BleDevice.fromJson(Map<String, dynamic> json) { |
|||
return BleDevice( |
|||
id: json['id'] as String, |
|||
name: json['name'] as String? ?? 'Unknown Device', |
|||
rssi: json['rssi'] as int? ?? 0, |
|||
); |
|||
} |
|||
|
|||
Map<String, dynamic> toJson() { |
|||
return { |
|||
'id': id, |
|||
'name': name, |
|||
'rssi': rssi, |
|||
}; |
|||
} |
|||
|
|||
@override |
|||
String toString() => 'BleDevice(id: $id, name: $name, rssi: $rssi)'; |
|||
} |
|||
|
|||
/// 连接状态 |
|||
enum ConnectionState { |
|||
disconnected, |
|||
connecting, |
|||
connected, |
|||
disconnecting, |
|||
error |
|||
} |
|||
|
|||
/// 事件类型 |
|||
class EventType { |
|||
static const String audioData = 'audio_data'; |
|||
static const String connectionState = 'connection_state'; |
|||
static const String scanResult = 'scan_result'; |
|||
static const String wakeupSignal = 'wakeup_signal'; |
|||
} |
|||
|
|||
/// 杰理BLE插件主类 |
|||
class Jieli { |
|||
static const MethodChannel _methodChannel = MethodChannel('com.yunqiinnovation.jieli'); |
|||
static const EventChannel _eventChannel = EventChannel('com.yunqiinnovation.jieli/events'); |
|||
|
|||
/// 扫描结果流 |
|||
static final StreamController<List<BleDevice>> _scanController = |
|||
StreamController<List<BleDevice>>.broadcast(); |
|||
Stream<List<BleDevice>> get scanResults => _scanController.stream; |
|||
|
|||
/// 连接状态变化流 |
|||
static final StreamController<ConnectionState> _connectionController = |
|||
StreamController<ConnectionState>.broadcast(); |
|||
Stream<ConnectionState> get connectionState => _connectionController.stream; |
|||
|
|||
/// 音频数据流 |
|||
static final StreamController<Uint8List> _audioController = |
|||
StreamController<Uint8List>.broadcast(); |
|||
Stream<Uint8List> get audioDataStream => _audioController.stream; |
|||
|
|||
/// 唤醒信号流 |
|||
static final StreamController<bool> _wakeupController = |
|||
StreamController<bool>.broadcast(); |
|||
Stream<bool> get wakeupStream => _wakeupController.stream; |
|||
|
|||
/// 事件流订阅 |
|||
StreamSubscription? _eventSubscription; |
|||
|
|||
/// 构造函数,初始化平台通道和事件监听 |
|||
Jieli() { |
|||
_setupEventListener(); |
|||
} |
|||
|
|||
/// 设置事件监听器 |
|||
void _setupEventListener() { |
|||
_eventSubscription = _eventChannel.receiveBroadcastStream().listen(_handleEvent); |
|||
} |
|||
|
|||
/// 处理来自原生平台的事件 |
|||
void _handleEvent(dynamic event) { |
|||
if (event is! Map<dynamic, dynamic>) return; |
|||
|
|||
final eventData = Map<String, dynamic>.from(event); |
|||
final String eventType = eventData['type'] as String; |
|||
final dynamic data = eventData['data']; |
|||
|
|||
switch (eventType) { |
|||
case EventType.scanResult: |
|||
if (data is List<dynamic>) { |
|||
final deviceList = data |
|||
.map((item) => BleDevice.fromJson(Map<String, dynamic>.from(item))) |
|||
.toList(); |
|||
_scanController.add(deviceList); |
|||
} |
|||
break; |
|||
case EventType.connectionState: |
|||
if (data is int && data >= 0 && data < ConnectionState.values.length) { |
|||
_connectionController.add(ConnectionState.values[data]); |
|||
} |
|||
break; |
|||
case EventType.audioData: |
|||
if (data is Uint8List) { |
|||
_audioController.add(data); |
|||
} |
|||
break; |
|||
case EventType.wakeupSignal: |
|||
if (data is bool) { |
|||
_wakeupController.add(data); |
|||
} |
|||
break; |
|||
} |
|||
} |
|||
|
|||
/// 开始扫描BLE设备 |
|||
Future<bool> startScan({int scanTimeout = 10000}) async { |
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'startScan', |
|||
{'scanTimeout': scanTimeout}, |
|||
); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 停止扫描BLE设备 |
|||
Future<bool> stopScan() async { |
|||
final result = await _methodChannel.invokeMethod<bool>('stopScan'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 连接到指定设备 |
|||
Future<bool> connect(String deviceId) async { |
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'connect', |
|||
{'deviceId': deviceId}, |
|||
); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 断开连接 |
|||
Future<bool> disconnect() async { |
|||
final result = await _methodChannel.invokeMethod<bool>('disconnect'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 发送开始录音命令 |
|||
Future<bool> sendStartRecordingCommand() async { |
|||
final result = await _methodChannel.invokeMethod<bool>('sendStartRecordingCommand'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 发送停止录音命令 |
|||
Future<bool> sendStopRecordingCommand() async { |
|||
final result = await _methodChannel.invokeMethod<bool>('sendStopRecordingCommand'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 关闭插件,释放资源 |
|||
Future<void> dispose() async { |
|||
await stopScan(); |
|||
await disconnect(); |
|||
_eventSubscription?.cancel(); |
|||
_scanController.close(); |
|||
_connectionController.close(); |
|||
_audioController.close(); |
|||
_wakeupController.close(); |
|||
} |
|||
|
|||
/// 检查蓝牙是否已启用 |
|||
Future<bool> isBluetoothEnabled() async { |
|||
final result = await _methodChannel.invokeMethod<bool>('isBluetoothEnabled'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 请求打开蓝牙 |
|||
Future<bool> requestBluetoothEnable() async { |
|||
final result = await _methodChannel.invokeMethod<bool>('requestBluetoothEnable'); |
|||
return result ?? false; |
|||
} |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
name: jieli |
|||
description: A Flutter plugin for connecting with Jieli BLE earphones |
|||
version: 0.0.1 |
|||
homepage: |
|||
|
|||
environment: |
|||
sdk: ">=2.12.0 <3.0.0" |
|||
flutter: ">=2.0.0" |
|||
|
|||
dependencies: |
|||
flutter: |
|||
sdk: flutter |
|||
|
|||
dev_dependencies: |
|||
flutter_test: |
|||
sdk: flutter |
|||
|
|||
flutter: |
|||
plugin: |
|||
platforms: |
|||
android: |
|||
package: com.yunqiinnovation.jieli |
|||
pluginClass: JieliPlugin |
|||
@ -0,0 +1,12 @@ |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
|||
package="com.yunqiinnovation.open_ai_service"> |
|||
|
|||
<!-- 拨打电话权限 --> |
|||
<uses-permission android:name="android.permission.CALL_PHONE" /> |
|||
|
|||
<!-- 发送短信权限 --> |
|||
<uses-permission android:name="android.permission.SEND_SMS" /> |
|||
|
|||
<!-- 读取联系人权限 --> |
|||
<uses-permission android:name="android.permission.READ_CONTACTS" /> |
|||
</manifest> |
|||
@ -0,0 +1,496 @@ |
|||
package com.yunqiinnovation.open_ai_service |
|||
|
|||
import android.content.Context |
|||
import android.util.Log |
|||
import com.anthropic.core.JsonValue |
|||
import com.anthropic.models.messages.Tool |
|||
import com.anthropic.models.messages.ToolUnion |
|||
import com.fasterxml.jackson.core.type.TypeReference |
|||
import com.fasterxml.jackson.databind.JsonNode |
|||
import com.fasterxml.jackson.databind.ObjectMapper |
|||
import io.ktor.client.HttpClient |
|||
import io.modelcontextprotocol.kotlin.sdk.Implementation |
|||
import io.modelcontextprotocol.kotlin.sdk.TextContent |
|||
import io.modelcontextprotocol.kotlin.sdk.client.Client |
|||
import io.modelcontextprotocol.kotlin.sdk.client.WebSocketClientTransport |
|||
import kotlinx.coroutines.runBlocking |
|||
import kotlinx.serialization.json.JsonObject |
|||
import org.json.JSONObject |
|||
import kotlin.jvm.optionals.getOrNull |
|||
|
|||
/** |
|||
* 工具类型枚举 |
|||
*/ |
|||
enum class ToolType { |
|||
LOCAL_FUNCTION, // 本地函数 |
|||
MCP_TOOL // MCP工具 |
|||
} |
|||
|
|||
/** |
|||
* 函数处理器接口 |
|||
*/ |
|||
interface FunctionHandler { |
|||
/** |
|||
* 处理函数调用 |
|||
* @param arguments 函数参数,Map格式 |
|||
* @return 函数执行结果,字符串 |
|||
*/ |
|||
suspend fun handle(arguments: Map<String, Any>): String |
|||
} |
|||
|
|||
/** |
|||
* MCP客户端,用于连接Model Context Protocol服务器和管理本地函数 |
|||
*/ |
|||
class MCPClient(private val context: Context? = null) : AutoCloseable { |
|||
private val TAG = "MCPClient" |
|||
|
|||
// 初始化MCP客户端 |
|||
private val mcp: Client = Client( |
|||
clientInfo = Implementation( |
|||
name = "deep-voice-mcp-client", |
|||
version = "1.0.0" |
|||
) |
|||
) |
|||
|
|||
// 服务器提供的工具列表 |
|||
private var tools: List<ToolUnion> = emptyList() |
|||
|
|||
// 工具Map列表,用于OpenAI请求 |
|||
private val toolMaps = mutableListOf<Map<String, Any>>() |
|||
|
|||
// 本地函数Map,函数名 -> 处理器 |
|||
private val localFunctions = mutableMapOf<String, FunctionHandler>() |
|||
|
|||
// 本地函数定义Map,函数名 -> 定义 |
|||
private val localFunctionDefs = mutableMapOf<String, String>() |
|||
|
|||
// 是否已连接 |
|||
private var isConnected = false |
|||
|
|||
// 系统功能处理器 |
|||
private val systemFunctionHandler = SystemFunctionHandler(context!!) |
|||
|
|||
init { |
|||
// 如果提供了Context,自动注册语音功能处理器 |
|||
context?.let { initializeVoiceFunctions(it) } |
|||
} |
|||
|
|||
/** |
|||
* 初始化语音功能处理器 |
|||
*/ |
|||
private fun initializeVoiceFunctions(context: Context) { |
|||
try { |
|||
// 使用SystemFunctionHandler注册所有功能 |
|||
systemFunctionHandler.registerAllFunctions(this) |
|||
|
|||
Log.d(TAG, "语音功能初始化完成") |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "语音功能初始化失败: ${e.message}", e) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 将JsonObject转换为JsonValue |
|||
*/ |
|||
private fun JsonObject.toJsonValue(): JsonValue { |
|||
val mapper = ObjectMapper() |
|||
val node = mapper.readTree(this.toString()) |
|||
return JsonValue.fromJsonNode(node) |
|||
} |
|||
|
|||
/** |
|||
* 连接到MCP服务器(通过WebSocket) |
|||
*/ |
|||
suspend fun connectToWebSocket(serverUrl: String): Boolean { |
|||
try { |
|||
// 创建WebSocket传输层 |
|||
val transport = WebSocketClientTransport( |
|||
client = HttpClient(), |
|||
urlString = serverUrl |
|||
) |
|||
|
|||
// 连接MCP客户端到服务器 |
|||
mcp.connect(transport) |
|||
|
|||
// 获取服务器提供的工具列表 |
|||
val toolsResult = mcp.listTools() |
|||
Log.d(TAG, "获取到工具列表: $toolsResult") |
|||
|
|||
// 处理工具列表 |
|||
processToolsList(toolsResult) |
|||
|
|||
isConnected = true |
|||
Log.d(TAG, "已连接到WebSocket服务器,可用工具: ${tools.map { it.tool().get().name() }.joinToString(", ")}") |
|||
return true |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "连接WebSocket服务器失败: ${e.message}", e) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 处理工具列表 |
|||
*/ |
|||
private fun processToolsList(toolsResult: io.modelcontextprotocol.kotlin.sdk.ListToolsResult?) { |
|||
tools = toolsResult?.tools?.map { tool -> |
|||
// 确保输入模式有效 |
|||
val inputType = tool.inputSchema?.type ?: "object" |
|||
val inputProperties = tool.inputSchema?.properties ?: emptyMap() |
|||
val inputRequired = tool.inputSchema?.required ?: emptyList<String>() |
|||
|
|||
ToolUnion.ofTool( |
|||
Tool.builder() |
|||
.name(tool.name) |
|||
.description(tool.description ?: "") |
|||
.inputSchema( |
|||
Tool.InputSchema.builder() |
|||
.type(JsonValue.from(inputType)) |
|||
.properties(if (inputProperties is JsonObject) inputProperties.toJsonValue() else JsonValue.from(emptyMap<String, String>())) |
|||
.putAdditionalProperty("required", JsonValue.from(inputRequired)) |
|||
.build() |
|||
) |
|||
.build() |
|||
) |
|||
} ?: emptyList() |
|||
|
|||
// 更新用于OpenAI的工具映射 |
|||
updateToolMaps() |
|||
} |
|||
|
|||
|
|||
/** |
|||
* 注册本地函数 |
|||
* @param name 函数名称 |
|||
* @param description 函数描述 |
|||
* @param parameters 函数参数定义(Map或JSONObject) |
|||
* @param handler 函数处理器 |
|||
* @return 是否注册成功 |
|||
*/ |
|||
fun registerLocalFunction( |
|||
name: String, |
|||
description: String, |
|||
parameters: Any, |
|||
handler: FunctionHandler |
|||
): Boolean { |
|||
try { |
|||
// 将参数统一转换为Map格式 |
|||
val parametersMap: Map<String, Any> = when (parameters) { |
|||
is Map<*, *> -> { |
|||
@Suppress("UNCHECKED_CAST") |
|||
parameters as Map<String, Any> |
|||
} |
|||
is JSONObject -> { |
|||
val mapper = ObjectMapper() |
|||
mapper.readValue( |
|||
parameters.toString(), |
|||
object : TypeReference<Map<String, Any>>() {} |
|||
) |
|||
} |
|||
else -> { |
|||
Log.e(TAG, "参数类型不支持: ${parameters.javaClass.name}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
// 检查参数是否包含必要字段 |
|||
if (!parametersMap.containsKey("type") || (parametersMap["type"] != "object")) { |
|||
Log.e(TAG, "参数必须是object类型") |
|||
return false |
|||
} |
|||
|
|||
// 注册函数处理器 |
|||
localFunctions[name] = handler |
|||
|
|||
// 创建函数定义 |
|||
val functionDef = mapOf( |
|||
"name" to name, |
|||
"description" to description, |
|||
"parameters" to parametersMap |
|||
) |
|||
|
|||
// 保存函数定义 |
|||
localFunctionDefs[name] = ObjectMapper().writeValueAsString(functionDef) |
|||
|
|||
// 更新工具映射 |
|||
updateToolMaps() |
|||
|
|||
Log.d(TAG, "注册本地函数成功: $name") |
|||
return true |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "注册本地函数失败: ${e.message}", e) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 取消注册本地函数 |
|||
* @param name 函数名称 |
|||
* @return 是否取消成功 |
|||
*/ |
|||
fun unregisterLocalFunction(name: String): Boolean { |
|||
val removed = localFunctions.remove(name) != null |
|||
if (removed) { |
|||
localFunctionDefs.remove(name) |
|||
updateToolMaps() |
|||
Log.d(TAG, "取消注册本地函数成功: $name") |
|||
} |
|||
return removed |
|||
} |
|||
|
|||
/** |
|||
* 更新工具Map列表,用于OpenAI请求 |
|||
*/ |
|||
private fun updateToolMaps() { |
|||
toolMaps.clear() |
|||
|
|||
// 首先添加本地函数 |
|||
localFunctionDefs.forEach { (name, functionDefJson) -> |
|||
try { |
|||
val mapper = ObjectMapper() |
|||
val functionDef = mapper.readValue(functionDefJson, object : TypeReference<Map<String, Any>>() {}) |
|||
val toolMap = mapOf( |
|||
"type" to "function", |
|||
"function" to functionDef |
|||
) |
|||
toolMaps.add(toolMap) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "解析本地函数定义失败: $name, ${e.message}", e) |
|||
} |
|||
} |
|||
|
|||
// 然后添加MCP工具 |
|||
tools.forEach { toolUnion -> |
|||
try { |
|||
val tool = toolUnion.tool().get() |
|||
val parametersMap = mutableMapOf<String, Any>() |
|||
|
|||
// 提取工具类型及参数 |
|||
try { |
|||
val inputSchema = tool.inputSchema() |
|||
// 解析JsonValue |
|||
val mapper = ObjectMapper() |
|||
val inputSchemaStr = inputSchema.toString() |
|||
val inputSchemaJson = mapper.readTree(inputSchemaStr) |
|||
val typeValue = inputSchemaJson.path("type") |
|||
parametersMap["type"] = if (typeValue.isTextual) typeValue.asText() else "object" |
|||
|
|||
// 提取properties字段 |
|||
val propertiesMap = mutableMapOf<String, Any>() |
|||
val propertiesNode = inputSchemaJson.path("properties") |
|||
if (propertiesNode.isObject) { |
|||
propertiesNode.fieldNames().forEach { fieldName -> |
|||
val fieldNode = propertiesNode.get(fieldName) |
|||
propertiesMap[fieldName] = convertJsonNodeToMap(fieldNode) |
|||
} |
|||
} |
|||
parametersMap["properties"] = propertiesMap |
|||
|
|||
// 提取required字段 |
|||
val requiredList = mutableListOf<String>() |
|||
val requiredNode = inputSchemaJson.path("required") |
|||
if (requiredNode.isArray) { |
|||
for (i in 0 until requiredNode.size()) { |
|||
if (requiredNode.get(i).isTextual) { |
|||
requiredList.add(requiredNode.get(i).asText()) |
|||
} |
|||
} |
|||
} |
|||
parametersMap["required"] = requiredList |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "解析工具参数失败: ${e.message}", e) |
|||
// 设置默认参数 |
|||
parametersMap["type"] = "object" |
|||
parametersMap["properties"] = emptyMap<String, Any>() |
|||
parametersMap["required"] = emptyList<String>() |
|||
} |
|||
|
|||
// 创建工具Map |
|||
val toolMap = mapOf( |
|||
"type" to "function", |
|||
"function" to mapOf( |
|||
"name" to tool.name(), |
|||
"description" to (tool.description() ?: ""), |
|||
"parameters" to parametersMap |
|||
) |
|||
) |
|||
|
|||
toolMaps.add(toolMap) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "转换工具到Map失败: ${e.message}", e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 将JsonNode转换为Map |
|||
*/ |
|||
private fun convertJsonNodeToMap(node: JsonNode): Any { |
|||
return when { |
|||
node.isTextual() -> node.asText() |
|||
node.isNumber() -> node.asDouble() |
|||
node.isBoolean() -> node.asBoolean() |
|||
node.isObject() -> { |
|||
val result = mutableMapOf<String, Any>() |
|||
node.fieldNames().forEach { fieldName -> |
|||
result[fieldName] = convertJsonNodeToMap(node.get(fieldName)) |
|||
} |
|||
result |
|||
} |
|||
node.isArray() -> { |
|||
val result = mutableListOf<Any>() |
|||
for (i in 0 until node.size()) { |
|||
result.add(convertJsonNodeToMap(node.get(i))) |
|||
} |
|||
result |
|||
} |
|||
node.isNull() -> "null" |
|||
else -> node.toString() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取工具Map列表,用于OpenAI请求 |
|||
*/ |
|||
fun getToolMaps(): List<Map<String, Any>> { |
|||
return toolMaps |
|||
} |
|||
|
|||
/** |
|||
* 获取工具类型 |
|||
* @param name 工具名称 |
|||
* @return 工具类型,如果不存在则返回null |
|||
*/ |
|||
fun getToolType(name: String): ToolType? { |
|||
if (localFunctions.containsKey(name)) { |
|||
return ToolType.LOCAL_FUNCTION |
|||
} |
|||
|
|||
if (tools.any { it.tool().get().name() == name }) { |
|||
return ToolType.MCP_TOOL |
|||
} |
|||
|
|||
return null |
|||
} |
|||
|
|||
/** |
|||
* 调用工具(统一入口) |
|||
* @param name 工具名称 |
|||
* @param arguments 参数 |
|||
* @return 调用结果 |
|||
*/ |
|||
suspend fun callTool(name: String, arguments: Map<String, Any>): String? { |
|||
val toolType = getToolType(name) |
|||
|
|||
return when (toolType) { |
|||
ToolType.LOCAL_FUNCTION -> { |
|||
callLocalFunction(name, arguments) |
|||
} |
|||
ToolType.MCP_TOOL -> { |
|||
callMcpTool(name, arguments) |
|||
} |
|||
null -> { |
|||
Log.e(TAG, "未找到工具: $name") |
|||
null |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 调用本地函数 |
|||
*/ |
|||
private suspend fun callLocalFunction(name: String, arguments: Map<String, Any>): String? { |
|||
return try { |
|||
val handler = localFunctions[name] |
|||
if (handler != null) { |
|||
handler.handle(arguments) |
|||
} else { |
|||
Log.e(TAG, "未找到本地函数: $name") |
|||
null |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "调用本地函数失败: ${e.message}", e) |
|||
"调用本地函数失败: ${e.message}" |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 调用MCP工具 |
|||
*/ |
|||
private suspend fun callMcpTool(name: String, arguments: Map<String, Any>): String? { |
|||
if (!isConnected) { |
|||
Log.e(TAG, "未连接到MCP服务器", null) |
|||
return "未连接到MCP服务器" |
|||
} |
|||
|
|||
return try { |
|||
val result = mcp.callTool( |
|||
name = name, |
|||
arguments = arguments |
|||
) |
|||
|
|||
// 处理工具调用结果 |
|||
result?.content?.joinToString("\n") { content -> |
|||
when (content) { |
|||
is TextContent -> content.text ?: "" |
|||
else -> "" |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "调用MCP工具失败: ${e.message}", e) |
|||
"调用MCP工具失败: ${e.message}" |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取可用的工具列表 |
|||
*/ |
|||
fun getTools(): List<ToolUnion> { |
|||
return tools |
|||
} |
|||
|
|||
/** |
|||
* 检查工具是否存在 |
|||
*/ |
|||
fun hasToolWithName(name: String): Boolean { |
|||
return localFunctions.containsKey(name) || toolMaps.any { |
|||
val functionMap = it["function"] as? Map<String, Any> |
|||
functionMap?.get("name") == name |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 从字符串解析JSON参数 |
|||
*/ |
|||
fun parseJsonArguments(json: String): Map<String, Any> { |
|||
return try { |
|||
ObjectMapper().readValue(json, object : TypeReference<Map<String, Any>>() {}) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "解析JSON参数失败: ${e.message}", e) |
|||
emptyMap() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 检查是否已连接到MCP服务器 |
|||
*/ |
|||
fun isConnected(): Boolean { |
|||
return isConnected |
|||
} |
|||
|
|||
/** |
|||
* 关闭连接 |
|||
*/ |
|||
override fun close() { |
|||
runBlocking { |
|||
try { |
|||
mcp.close() |
|||
isConnected = false |
|||
tools = emptyList() |
|||
toolMaps.clear() |
|||
Log.d(TAG, "已关闭MCP服务器连接", null) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "关闭MCP服务器连接失败: ${e.message}", e) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,422 @@ |
|||
package com.yunqiinnovation.open_ai_service |
|||
|
|||
import android.content.Context |
|||
import android.content.Intent |
|||
import android.content.pm.PackageManager |
|||
import android.net.Uri |
|||
import android.provider.ContactsContract |
|||
import android.telephony.SmsManager |
|||
import android.util.Log |
|||
import org.json.JSONObject |
|||
|
|||
/** |
|||
* 系统功能处理器 - 负责调用系统API执行各种功能以及注册系统功能 |
|||
*/ |
|||
class SystemFunctionHandler(private val context: Context) { |
|||
companion object { |
|||
private const val TAG = "SystemFunctionHandler" |
|||
const val ACTION_EXIT_INTERACTION = "com.yunqiinnovation.deepsound.action.EXIT_INTERACTION" |
|||
} |
|||
|
|||
/** |
|||
* 在MCPClient中注册所有系统功能 |
|||
* @param client MCPClient实例 |
|||
*/ |
|||
fun registerAllFunctions(client: MCPClient) { |
|||
try { |
|||
// 注册退出交互函数 |
|||
registerExitInteractionFunction(client) |
|||
|
|||
// 注册翻译模式函数 |
|||
registerTranslationModeFunction(client) |
|||
|
|||
// 注册发送短信函数 |
|||
registerSendTextMessageFunction(client) |
|||
|
|||
// 注册拨打电话函数 |
|||
registerMakePhoneCallFunction(client) |
|||
|
|||
// 注册打开应用函数 |
|||
registerOpenAppFunction(client) |
|||
|
|||
Log.d(TAG, "所有系统功能注册完成") |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "注册系统功能失败: ${e.message}", e) |
|||
} |
|||
} |
|||
|
|||
|
|||
/** |
|||
* 注册退出交互函数 |
|||
*/ |
|||
private fun registerExitInteractionFunction(client: MCPClient) { |
|||
client.registerLocalFunction( |
|||
"exit_interaction", |
|||
"退出当前语音交互", |
|||
JSONObject(""" |
|||
{ |
|||
"type": "object", |
|||
"properties": {}, |
|||
"required": [] |
|||
} |
|||
"""), |
|||
object : FunctionHandler { |
|||
override suspend fun handle(arguments: Map<String, Any>): String { |
|||
// 调用发送广播的函数 |
|||
sendExitInteractionBroadcast() |
|||
|
|||
return "{\"result\": \"已退出语音交互\"}" |
|||
} |
|||
} |
|||
) |
|||
Log.d(TAG, "已注册退出交互功能") |
|||
} |
|||
|
|||
/** |
|||
* 注册翻译模式函数 |
|||
*/ |
|||
private fun registerTranslationModeFunction(client: MCPClient) { |
|||
client.registerLocalFunction( |
|||
"enter_translation_mode", |
|||
"进入实时翻译模式", |
|||
JSONObject(""" |
|||
{ |
|||
"type": "object", |
|||
"properties": {}, |
|||
"required": [] |
|||
} |
|||
"""), |
|||
object : FunctionHandler { |
|||
override suspend fun handle(arguments: Map<String, Any>): String { |
|||
val result = enterTranslationMode() |
|||
return "{\"result\": \"${result.message}\"}" |
|||
} |
|||
} |
|||
) |
|||
Log.d(TAG, "已注册翻译模式功能") |
|||
} |
|||
|
|||
/** |
|||
* 注册发送短信函数 |
|||
*/ |
|||
private fun registerSendTextMessageFunction(client: MCPClient) { |
|||
client.registerLocalFunction( |
|||
"send_text_message", |
|||
"发送短信", |
|||
JSONObject(""" |
|||
{ |
|||
"type": "object", |
|||
"properties": { |
|||
"contact": { |
|||
"type": "string", |
|||
"description": "联系人姓名或电话号码" |
|||
}, |
|||
"message": { |
|||
"type": "string", |
|||
"description": "短信内容" |
|||
} |
|||
}, |
|||
"required": ["contact", "message"] |
|||
} |
|||
"""), |
|||
object : FunctionHandler { |
|||
override suspend fun handle(arguments: Map<String, Any>): String { |
|||
val contact = arguments["contact"] as? String ?: "" |
|||
val message = arguments["message"] as? String ?: "" |
|||
val result = sendTextMessage(contact, message) |
|||
return "{\"result\": \"${result.message}\"}" |
|||
} |
|||
} |
|||
) |
|||
Log.d(TAG, "已注册发送短信功能") |
|||
} |
|||
|
|||
/** |
|||
* 注册拨打电话函数 |
|||
*/ |
|||
private fun registerMakePhoneCallFunction(client: MCPClient) { |
|||
client.registerLocalFunction( |
|||
"make_phone_call", |
|||
"拨打电话", |
|||
JSONObject(""" |
|||
{ |
|||
"type": "object", |
|||
"properties": { |
|||
"contact": { |
|||
"type": "string", |
|||
"description": "联系人姓名或电话号码" |
|||
} |
|||
}, |
|||
"required": ["contact"] |
|||
} |
|||
"""), |
|||
object : FunctionHandler { |
|||
override suspend fun handle(arguments: Map<String, Any>): String { |
|||
val contact = arguments["contact"] as? String ?: "" |
|||
val result = makePhoneCall(contact) |
|||
return "{\"result\": \"${result.message}\"}" |
|||
} |
|||
} |
|||
) |
|||
Log.d(TAG, "已注册拨打电话功能") |
|||
} |
|||
|
|||
/** |
|||
* 注册打开应用函数 |
|||
*/ |
|||
private fun registerOpenAppFunction(client: MCPClient) { |
|||
client.registerLocalFunction( |
|||
"open_app", |
|||
"打开应用", |
|||
JSONObject(""" |
|||
{ |
|||
"type": "object", |
|||
"properties": { |
|||
"app_name": { |
|||
"type": "string", |
|||
"description": "应用名称" |
|||
} |
|||
}, |
|||
"required": ["app_name"] |
|||
} |
|||
"""), |
|||
object : FunctionHandler { |
|||
override suspend fun handle(arguments: Map<String, Any>): String { |
|||
val appName = arguments["app_name"] as? String ?: "" |
|||
val result = openApp(appName) |
|||
return "{\"result\": \"${result.message}\"}" |
|||
} |
|||
} |
|||
) |
|||
Log.d(TAG, "已注册打开应用功能") |
|||
} |
|||
|
|||
|
|||
/** |
|||
* 发送退出交互广播 |
|||
*/ |
|||
private fun sendExitInteractionBroadcast() { |
|||
// 发送广播通知VoiceInteractionHandler停止语音识别 |
|||
val intent = Intent(ACTION_EXIT_INTERACTION).apply { |
|||
// 明确指定接收广播的包名 |
|||
setPackage(context.packageName) |
|||
putExtra("timestamp", System.currentTimeMillis()) |
|||
// 添加FLAG_RECEIVER_FOREGROUND确保及时处理 |
|||
addFlags(Intent.FLAG_RECEIVER_FOREGROUND) |
|||
} |
|||
Log.d(TAG, "准备发送停止交互广播,包名: ${context.packageName}") |
|||
context.sendBroadcast(intent) |
|||
Log.d(TAG, "已发送停止交互广播") |
|||
} |
|||
|
|||
|
|||
|
|||
/** |
|||
* 启动翻译模式 |
|||
* @return 操作结果 |
|||
*/ |
|||
fun enterTranslationMode(): Result { |
|||
Log.d(TAG, "进入翻译模式") |
|||
// 此处应添加实际的翻译模式实现 |
|||
// 可能需要启动一个专门的翻译服务或活动 |
|||
|
|||
// 广播通知APP的其他部分进入翻译模式 |
|||
val intent = Intent("com.yunqiinnovation.deepsound.ACTION_ENTER_TRANSLATION_MODE").apply { |
|||
setPackage(context.packageName) |
|||
|
|||
putExtra("timestamp", System.currentTimeMillis()) |
|||
// 添加FLAG_RECEIVER_FOREGROUND确保及时处理 |
|||
addFlags(Intent.FLAG_RECEIVER_FOREGROUND) |
|||
} |
|||
|
|||
// 发送广播 |
|||
context.sendBroadcast(intent) |
|||
Log.d(TAG, "已发送广播: ENTER_TRANSLATION_MODE") |
|||
|
|||
return Result( |
|||
success = true, |
|||
message = "已进入实时翻译模式" |
|||
) |
|||
} |
|||
|
|||
/** |
|||
* 发送短信 |
|||
* @param contact 联系人(名称或电话号码) |
|||
* @param message 短信内容 |
|||
* @return 操作结果 |
|||
*/ |
|||
fun sendTextMessage(contact: String, message: String): Result { |
|||
Log.d(TAG, "发送短信给 $contact: $message") |
|||
|
|||
try { |
|||
val phoneNumber = getPhoneNumberForContact(contact) |
|||
if (phoneNumber.isEmpty()) { |
|||
return Result( |
|||
success = false, |
|||
message = "未找到联系人信息: $contact" |
|||
) |
|||
} |
|||
|
|||
// 使用SmsManager发送短信 |
|||
val smsManager = SmsManager.getDefault() |
|||
smsManager.sendTextMessage(phoneNumber, null, message, null, null) |
|||
|
|||
return Result( |
|||
success = true, |
|||
message = "已向${contact}发送短信: $message" |
|||
) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "发送短信失败: ${e.message}", e) |
|||
return Result( |
|||
success = false, |
|||
message = "发送短信失败: ${e.message}" |
|||
) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 拨打电话 |
|||
* @param contact 联系人(名称或电话号码) |
|||
* @return 操作结果 |
|||
*/ |
|||
fun makePhoneCall(contact: String): Result { |
|||
Log.d(TAG, "拨打电话给 $contact") |
|||
|
|||
try { |
|||
val phoneNumber = getPhoneNumberForContact(contact) |
|||
if (phoneNumber.isEmpty()) { |
|||
return Result( |
|||
success = false, |
|||
message = "未找到联系人信息: $contact" |
|||
) |
|||
} |
|||
|
|||
// 创建拨号意图 |
|||
val intent = Intent(Intent.ACTION_CALL) |
|||
intent.data = Uri.parse("tel:$phoneNumber") |
|||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK |
|||
context.startActivity(intent) |
|||
|
|||
return Result( |
|||
success = true, |
|||
message = "正在拨打${contact}的电话" |
|||
) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "拨打电话失败: ${e.message}", e) |
|||
return Result( |
|||
success = false, |
|||
message = "拨打电话失败: ${e.message}" |
|||
) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 打开应用程序 |
|||
* @param appName 应用名称 |
|||
* @return 操作结果 |
|||
*/ |
|||
fun openApp(appName: String): Result { |
|||
Log.d(TAG, "打开应用: $appName") |
|||
|
|||
try { |
|||
// 查找应用的包名 |
|||
val packageManager = context.packageManager |
|||
val intent = Intent(Intent.ACTION_MAIN, null) |
|||
intent.addCategory(Intent.CATEGORY_LAUNCHER) |
|||
|
|||
val apps = packageManager.queryIntentActivities(intent, 0) |
|||
|
|||
for (resolveInfo in apps) { |
|||
val appLabel = resolveInfo.loadLabel(packageManager).toString() |
|||
if (appLabel.equals(appName, ignoreCase = true) || |
|||
appLabel.contains(appName, ignoreCase = true)) { |
|||
|
|||
// 找到匹配的应用 |
|||
val packageName = resolveInfo.activityInfo.packageName |
|||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName) |
|||
|
|||
if (launchIntent != null) { |
|||
launchIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK |
|||
context.startActivity(launchIntent) |
|||
|
|||
return Result( |
|||
success = true, |
|||
message = "正在打开${appName}应用" |
|||
) |
|||
} |
|||
} |
|||
} |
|||
|
|||
return Result( |
|||
success = false, |
|||
message = "未找到应用: $appName" |
|||
) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "打开应用失败: ${e.message}", e) |
|||
return Result( |
|||
success = false, |
|||
message = "打开应用失败: ${e.message}" |
|||
) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 根据联系人名称获取电话号码 |
|||
* @param contact 联系人名称或电话号码 |
|||
* @return 电话号码 |
|||
*/ |
|||
private fun getPhoneNumberForContact(contact: String): String { |
|||
// 如果输入的是电话号码(简单判断,包含数字和特殊符号) |
|||
if (contact.matches(Regex("^[+\\d\\s\\-()]*\$")) && contact.any { it.isDigit() }) { |
|||
// 移除非数字字符 |
|||
return contact.replace(Regex("[^+\\d]"), "") |
|||
} |
|||
|
|||
// 从联系人数据库查询 |
|||
try { |
|||
val contentResolver = context.contentResolver |
|||
val uri = Uri.withAppendedPath( |
|||
ContactsContract.Contacts.CONTENT_FILTER_URI, |
|||
Uri.encode(contact) |
|||
) |
|||
|
|||
val projection = arrayOf( |
|||
ContactsContract.Contacts._ID, |
|||
ContactsContract.Contacts.DISPLAY_NAME, |
|||
ContactsContract.Contacts.HAS_PHONE_NUMBER |
|||
) |
|||
|
|||
contentResolver.query(uri, projection, null, null, null)?.use { cursor -> |
|||
if (cursor.moveToFirst()) { |
|||
val hasPhoneNumber = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.Contacts.HAS_PHONE_NUMBER)) |
|||
|
|||
if (hasPhoneNumber > 0) { |
|||
val contactId = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID)) |
|||
val phoneUri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI |
|||
val phoneProjection = arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER) |
|||
val phoneSelection = "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?" |
|||
val phoneSelectionArgs = arrayOf(contactId) |
|||
|
|||
contentResolver.query(phoneUri, phoneProjection, phoneSelection, phoneSelectionArgs, null)?.use { phoneCursor -> |
|||
if (phoneCursor.moveToFirst()) { |
|||
return phoneCursor.getString(phoneCursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER)) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "查询联系人信息失败: ${e.message}", e) |
|||
} |
|||
|
|||
return "" |
|||
} |
|||
|
|||
/** |
|||
* 操作结果数据类 |
|||
*/ |
|||
data class Result( |
|||
val success: Boolean, |
|||
val message: String |
|||
) |
|||
} |
|||
@ -1 +1,57 @@ |
|||
curl -v -X POST -H 'Content-Type: application/json' -H 'Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6' -H 'Accept: text/event-stream' -d '{"model":"bot-20250405211523-l7c9r","messages":[{"role":"system","content":" 你是一个智能语音助手,能够简洁明了地回答用户的问题。\n时刻关心用户的情绪和需求,主动提供鼓励和温暖。\n\n语言风格活泼、亲切,能够幽默地互动,陪伴用户,缓解压力,增添生活乐趣。\n\n请始终以用户为中心,保持回应的高效性、准确性和温暖体贴,成为用户真正的灵魂伴侣。\n \n 当用户说\"退出\"、\"再见\"、\"结束对话\"等类似意图时,你应该使用exit_interaction函数来结束对话,\n 并在结束前说一句友好的告别语,例如\"再见,有需要随时找我\"。"},{"role":"system","content":" 你是一个智能语音助手,能够简洁明了地回答用户的问题。\n时刻关心用户的情绪和需求,主动提供鼓励和温暖。\n\n语言风格活泼、亲切,能够幽默地互动,陪伴用户,缓解压力,增添生活乐趣。\n\n请始终以用户为中心,保持回应的高效性、准确性和温暖体贴,成为用户真正的灵魂伴侣。\n \n 当用户说\"退出\"、\"再见\"、\"结束对话\"等类似意图时,你应该使用exit_interaction函数来结束对话,\n 并在结束前说一句友好的告别语,例如\"再见,有需要随时找我\"。"},{"role":"user","content":"退下吧。"},{"role":"assistant","content":"","tool_calls":[{"id":"call_8k680azmfc4thqrrnwpwqxah","type":"function","function":{"name":"exit_interaction","arguments":" {}"}}]},{"role":"tool","content":"{\"result\": \"已退出语音交互\"}","tool_call_id":"call_8k680azmfc4thqrrnwpwqxah"}],"temperature":0.7,"max_tokens":2000,"stream":true,"tools":[{"type":"function","function":{"name":"exit_interaction","description":"退出当前语音交互","parameters":{"type":"object","properties":{},"required":[]}}}]}' 'https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions' |
|||
curl -v -X POST -H 'Content-Type: application/json' -H 'Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6' -H 'Accept: text/event-stream' -d '{ |
|||
"model": "bot-20250405211523-l7c9r", |
|||
"messages": [ |
|||
{ |
|||
"role": "system", |
|||
"content": " 你是一个智能语音助手,能够简洁明了地回答用户的问题。\n时刻关心用户的情绪和需求,主动提供鼓励和温暖。\n\n语言风格活泼、亲切,能够幽默地互动,陪伴用户,缓解压力,增添生活乐趣。\n\n请始终以用户为中心,保持回应的高效性、准确性和温暖体贴,成为用户真正的灵魂伴侣。\n \n 当用户说\"退出\"、\"再见\"、\"结束对话\"等类似意图时,你应该使用exit_interaction函数来结束对话,\n 并在结束前说一句友好的告别语,例如\"再见,有需要随时找我\"。" |
|||
}, |
|||
{ |
|||
"role": "system", |
|||
"content": " 你是一个智能语音助手,能够简洁明了地回答用户的问题。\n时刻关心用户的情绪和需求,主动提供鼓励和温暖。\n\n语言风格活泼、亲切,能够幽默地互动,陪伴用户,缓解压力,增添生活乐趣。\n\n请始终以用户为中心,保持回应的高效性、准确性和温暖体贴,成为用户真正的灵魂伴侣。\n \n 当用户说\"退出\"、\"再见\"、\"结束对话\"等类似意图时,你应该使用exit_interaction函数来结束对话,\n 并在结束前说一句友好的告别语,例如\"再见,有需要随时找我\"。" |
|||
}, |
|||
{ |
|||
"role": "user", |
|||
"content": "退下吧。" |
|||
}, |
|||
{ |
|||
"role": "assistant", |
|||
"content": "", |
|||
"tool_calls": [ |
|||
{ |
|||
"id": "call_8k680azmfc4thqrrnwpwqxah", |
|||
"type": "function", |
|||
"function": { |
|||
"name": "exit_interaction", |
|||
"arguments": " {}" |
|||
} |
|||
} |
|||
] |
|||
}, |
|||
{ |
|||
"role": "tool", |
|||
"content": "{\"result\": \"已退出语音交互\"}", |
|||
"tool_call_id": "call_8k680azmfc4thqrrnwpwqxah" |
|||
} |
|||
], |
|||
"temperature": 0.7, |
|||
"max_tokens": 2000, |
|||
"stream": true, |
|||
"tools": [ |
|||
{ |
|||
"type": "function", |
|||
"function": { |
|||
"name": "exit_interaction", |
|||
"description": "退出当前语音交互", |
|||
"parameters": { |
|||
"type": "object", |
|||
"properties": {}, |
|||
"required": [] |
|||
} |
|||
} |
|||
} |
|||
] |
|||
}' 'https: //ark.cn-beijing.volces.com/api/v3/bots/chat/completions' |
|||
{ |
|||
"code": 0, |
|||
"message": "Error during WebSocket handshake: Unexpected response code: 400", |
|||
"request_log":"{\"ab_test\":{},\"base\":{\"has_upload\":false,\"is_pending\":true,\"load_flags\":18,\"load_state\":0,\"method\":\"GET\",\"net_error\":-320,\"network_isolation_key\":\"wss://openspeech.bytedance.com wss://openspeech.bytedance.com\",\"origin_url\":\"wss://openspeech.bytedance.com/api/v3/sauc/bigmodel\",\"redirect_times\":0,\"status\":\"FAILED\",\"traffic_annotation\":77012883},\"dns\":{\"address_list\":[\"163.181.50.229:443\",\"163.181.50.223:443\",\"163.181.50.221:443\",\"163.181.50.224:443\",\"163.181.50.226:443\",\"163.181.50.222:443\",\"163.181.50.228:443\",\"163.181.50.227:443\"],\"async_batch_httpdns\":0,\"cache_source\":6,\"cache_stale_reason\":0,\"from_stale_cache\":0,\"hijacked_error\":0,\"is_auth\":false,\"nameservers\":[],\"net_type\":0,\"preload_batch_httpdns\":0,\"queried_with_cip\":\"\",\"race_dns_stale_cache\":0,\"skip_prefer_ip_error\":0,\"source\":2},\"event\":{\"engine_startup\":1744019237.421243,\"network_change\":0.0},\"header\":{\"X-Tt-Logid\":\"2025040717474815E3294F926C737834FA\",\"server-timing\":\"inner; dur=5, cdn-cache;desc=MISS,edge;dur=653,origin;dur=14\",\"x-tt-trace-host\":\"012d6b6cae97bb056dd2540a62f7f6e52ccfb433febe931be5ddf1ac674ca77c57b7dee570790477696b7b5c17bbdd77bf841100de6d411ef8ccc26681b9799e4aee97897e0681b3a8b304853d1848fcbd9fec458fdd375cddc4a7873cdbcef3a0\",\"x-tt-trace-id\":\"00-0fa6216304269466036ad7b0703104d2-0fa6216304269466-01\",\"x-tt-trace-tag\":\"id=03;cdn-cache=miss;type=dyn\"},\"ifconfig\":{\"ipv4_reachable\":true,\"ipv6_reachable\":false},\"multinetwork\":{},\"nqe\":{\"bs_sys_state\":false,\"downlink_throughput\":2658,\"effective_net_type\":5,\"group_rtt_estimate\":[],\"http_rtt\":116,\"pending_requests\":\"1\",\"real_net_type\":2,\"tcp_rtt\":46,\"total_requests\":\"6\"},\"other\":{\"core_ver\":\"87.0.4273.1\",\"gateway\":\"0.0.0.0\",\"is_background\":false,\"is_internal_retry\":false,\"is_main_process\":true,\"libcore\":\"cronet\",\"net_thread_priority\":0,\"quic_commit\":\"6ea2111b/2023-03-16\",\"resume_time\":\"-1\",\"retry_attempts\":0,\"ttnet_build_timestamp\":\"1689596445\",\"ttnet_version\":\"4.1.127.2-tob\",\"version\":\"534436af/2023-07-17\"},\"response\":{\"cached\":false,\"code\":400,\"connection_info\":1,\"network_accessed\":true,\"received_bytes\":0,\"sent_bytes\":0,\"via_proxy\":false},\"session\":{},\"socket\":{\"address_family\":1,\"cip\":\"\",\"connection_attemps\":[],\"happy_eyeballs_result\":2,\"remote\":\"163.181.50.229:443\",\"socket_reused\":false},\"socketpool\":{\"assign_type\":0,\"consume_time\":0,\"is_backup_job\":false,\"pending_reason\":0,\"pending_time\":0,\"reuse_type\":0,\"socket_idle_time\":0},\"ssl\":{\"cert_status\":0,\"connection_status\":6296321,\"handshake_type\":1,\"is_issued_by_known_root\":true,\"server_cert_common_name\":\"*.bytedance.com\"},\"store_idc\":{\"init\":1},\"timing\":{\"detailed_duration\":{\"before_stream\":0,\"body_recv\":-1,\"cdn-cache\":\"miss\",\"connect_delay\":0,\"create_stream\":98,\"dispatch\":0,\"dns\":0,\"edge\":653,\"header_recv\":-1,\"inner\":5,\"origin\":9,\"proxy\":0,\"rtt\":43,\"security_callback\":0,\"send\":0,\"ssl\":48,\"tcp\":49,\"ttfb\":710},\"request\":{\"create_stream_finish\":1744019267.633028,\"create_stream_start\":1744019267.534112,\"duration\":812,\"http_transaction_start\":1744019267.534081,\"request_sent_time\":1744019267.633497,\"response_recv_time\":1744019268.344466,\"start_time\":1744019267.533685}},\"tnc\":{\"data\":{\"canary\":\"\",\"cold_start\":1,\"config\":\"\",\"config_updated\":0,\"local_probe_version\":\"\",\"rules\":\"\",\"source\":0,\"update_time\":\"-1\"},\"probe\":{\"cmd\":\"0\",\"get_time\":\"-1\",\"send_time\":\"-1\",\"state\":1,\"version\":\"0\"},\"request\":{\"probe_version\":\"0\",\"source\":0,\"start_time\":\"-1\",\"state\":1}},\"t |
|||
Loading…
Reference in new issue