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