167 changed files with 12373 additions and 1258 deletions
@ -1,10 +1,73 @@ |
|||
1. 不要主动运行程序, 如flutter run |
|||
2. 不要改动android, iOS项目的配置信息,包括SDK, NDK版本等 |
|||
3. 不要使用print, 而是使用logger |
|||
4. 尽可能使用GetX提供的各种功能, 而不是使用原生的StatefulWidget |
|||
5. 不要增加重试逻辑,出现失败直接返回错误 |
|||
6. 尽可能避免使用try catch |
|||
7. 尽可能避免打印日志,仅在出错时记录日志 |
|||
1. 请严格遵循 Effective Dart 的风格指南来编写代码 |
|||
2. 不要主动运行程序, 如flutter run |
|||
3. 不要改动android, iOS项目的配置信息,包括SDK, NDK, JAVA版本等 |
|||
4. 不要使用print, 而是使用logger, 尽可能避免打印日志,仅在出错时记录日志 |
|||
5. 尽可能使用GetX提供的各种功能, 而不是使用原生的StatefulWidget |
|||
6. 不要增加重试逻辑,出现失败直接返回错误 |
|||
7. 尽可能避免使用try catch |
|||
8. 请使用较小的代码量改动,最安全的方法进行代码编写和优化 |
|||
9. 请尽可能使用中文回复问题 |
|||
10. 重要:不要过度设计!! |
|||
10. 重要:不要过度设计!! |
|||
11. 本项目使用 Kotlin DSL 进行开发 |
|||
|
|||
# 项目架构概述 |
|||
DeepSound是一个基于Flutter和GetX构建的多平台语音应用,具有语音合成、语音识别、语音聊天等功能。 |
|||
|
|||
## 总体架构 |
|||
- 采用GetX状态管理框架 |
|||
- 遵循模块化设计,各功能模块相对独立 |
|||
- 使用依赖注入实现组件解耦 |
|||
- 多语言支持,使用GetX国际化方案 |
|||
|
|||
## 主要目录结构 |
|||
- lib/core: 核心组件、工具和通用功能 |
|||
- lib/data: 数据模型、服务和提供者 |
|||
- lib/modules: 功能模块,按业务划分 |
|||
- lib/routes: 路由定义和管理 |
|||
- local_plugins: 本地插件,提供原生功能支持 |
|||
|
|||
# 核心模块说明 |
|||
## 语音处理 |
|||
- 火山语音服务: 提供语音识别和合成核心能力 |
|||
- Azure语音服务: 提供备选的语音识别和合成能力 |
|||
- 离线TTS支持: 在线语音合成失败时的备选方案 |
|||
|
|||
## 聊天功能 |
|||
- 基于OpenAI的对话功能 |
|||
- 支持语音输入、文字输入和语音输出 |
|||
- 支持多种角色和语音类型 |
|||
|
|||
## 用户系统 |
|||
- 支持多种登录方式:Firebase、Google、Facebook |
|||
- 用户资料管理和设置 |
|||
- 主题切换和语言设置 |
|||
|
|||
## 蓝牙配对 |
|||
- 支持蓝牙设备发现和连接 |
|||
- 提供简化的配对流程 |
|||
- 设备管理和状态监控 |
|||
|
|||
## 音频处理 |
|||
- 音频录制和播放 |
|||
- 支持波形显示 |
|||
- 音量控制和音频质量设置 |
|||
|
|||
# 技术栈 |
|||
- Flutter: UI框架 |
|||
- GetX: 状态管理和依赖注入 |
|||
- Firebase: 用户认证和后端服务 |
|||
- WebSocket: 实时通信 |
|||
- SQLite: 本地数据存储 |
|||
- 原生插件: 语音识别、合成和蓝牙功能 |
|||
|
|||
# 插件说明 |
|||
- jieli: 杰理蓝牙设备连接和控制插件 |
|||
- volcano_speech: 火山语音服务插件,提供语音识别和合成 |
|||
- azure_speech: 微软Azure语音服务插件 |
|||
- open_ai_service: OpenAI服务插件,提供AI对话能力 |
|||
- speech_to_text: 通用语音识别插件 |
|||
|
|||
# 环境配置 |
|||
- 环境变量通过.env文件管理 |
|||
- 支持不同环境的配置切换 |
|||
- 敏感信息不应直接硬编码在代码中 |
|||
@ -0,0 +1,12 @@ |
|||
{ |
|||
"mcpServers": { |
|||
|
|||
"amap-amap-sse": { |
|||
"url": "https://mcp.amap.com/sse?key=e5fdc9605eabdeb5626f18f5721f343d" |
|||
}, |
|||
"web-search": { |
|||
"url": "http://mcp.ideapsound.com:8000/sse" |
|||
} |
|||
|
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -1,4 +1,4 @@ |
|||
class NWApi { |
|||
// static const baseApi = "https://www.deapsound.com"; |
|||
static const baseApi = "https://www.deapsound.com"; |
|||
static const baseApi = "https://api.deapsound.com"; |
|||
} |
|||
|
|||
@ -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,323 @@ |
|||
import 'dart:async'; |
|||
import 'dart:typed_data'; |
|||
import 'package:flutter/material.dart' hide ConnectionState; |
|||
import 'package:get/get.dart'; |
|||
import 'package:jieli/jieli.dart'; |
|||
|
|||
/// 杰理测试控制器 |
|||
class JieliTestController extends GetxController { |
|||
// 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; |
|||
var volumeCommandResult = ''.obs; |
|||
|
|||
// 事件订阅 |
|||
StreamSubscription? _scanSubscription; |
|||
StreamSubscription? _connectionSubscription; |
|||
StreamSubscription? _wakeupSubscription; |
|||
StreamSubscription? _audioSubscription; |
|||
StreamSubscription? _commandResultSubscription; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_initializeJieli(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
_cancelAllSubscriptions(); |
|||
_disposeJieli(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
/// 取消所有订阅 |
|||
void _cancelAllSubscriptions() { |
|||
_scanSubscription?.cancel(); |
|||
_connectionSubscription?.cancel(); |
|||
_wakeupSubscription?.cancel(); |
|||
_audioSubscription?.cancel(); |
|||
_commandResultSubscription?.cancel(); |
|||
} |
|||
|
|||
/// 初始化杰理SDK |
|||
void _initializeJieli() { |
|||
try { |
|||
jieli = Jieli(); |
|||
_setupEventListeners(); |
|||
} catch (e) { |
|||
// 初始化错误处理 |
|||
} |
|||
} |
|||
|
|||
/// 设置事件监听器 |
|||
void _setupEventListeners() { |
|||
// 监听扫描结果 |
|||
_scanSubscription = jieli.scanResults.listen((devices) { |
|||
scannedDevices.assignAll(devices); |
|||
}); |
|||
|
|||
// 监听连接状态 |
|||
_connectionSubscription = jieli.connectionState.listen((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) { |
|||
wakeupReceived.value = true; |
|||
Get.snackbar('收到唤醒信号', '耳机发送了唤醒信号'); |
|||
}); |
|||
|
|||
// 监听音频数据 |
|||
_audioSubscription = jieli.audioDataStream.listen((data) { |
|||
// 更新音频数据统计 |
|||
audioDataSize.value += data.length; |
|||
receivedFrames.value++; |
|||
audioDataReceiving.value = true; |
|||
}); |
|||
|
|||
// 监听命令结果 |
|||
_commandResultSubscription = jieli.commandResultStream.listen((result) { |
|||
// 处理音量调整命令结果 |
|||
if (result.commandType == Jieli.commandTypeVolume) { |
|||
volumeCommandResult.value = result.success ? '音量调整成功' : '音量调整失败'; |
|||
Get.snackbar( |
|||
result.success ? '音量调整成功' : '音量调整失败', |
|||
result.success ? '设备已更新音量' : '设备未能更新音量', |
|||
snackPosition: SnackPosition.BOTTOM, |
|||
backgroundColor: result.success ? Colors.green.withOpacity(0.7) : Colors.red.withOpacity(0.7), |
|||
colorText: Colors.white, |
|||
duration: Duration(seconds: 2), |
|||
); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
/// 检查蓝牙状态 |
|||
Future<bool> checkBluetoothStatus() async { |
|||
try { |
|||
final isEnabled = await jieli.isBluetoothEnabled(); |
|||
if (!isEnabled) { |
|||
return await jieli.requestBluetoothEnable(); |
|||
} |
|||
return true; |
|||
} catch (e) { |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 开始扫描设备 |
|||
Future<void> startScan() async { |
|||
if (isScanning.value) return; |
|||
|
|||
final bluetoothReady = await checkBluetoothStatus(); |
|||
if (!bluetoothReady) { |
|||
Get.snackbar('蓝牙未启用', '请启用蓝牙后再试'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
isScanning.value = true; |
|||
|
|||
// 清空之前的设备列表 |
|||
scannedDevices.clear(); |
|||
|
|||
// 开始扫描,10秒后自动停止 |
|||
final success = await jieli.startScan(scanTimeout: 10000); |
|||
|
|||
if (!success) { |
|||
isScanning.value = false; |
|||
Get.snackbar('扫描失败', '启动扫描设备失败,请检查蓝牙权限'); |
|||
} |
|||
|
|||
// 10秒后自动更新UI状态 |
|||
Future.delayed(Duration(milliseconds: 10000), () { |
|||
isScanning.value = false; |
|||
}); |
|||
|
|||
} catch (e) { |
|||
isScanning.value = false; |
|||
Get.snackbar('扫描错误', e.toString()); |
|||
} |
|||
} |
|||
|
|||
/// 停止扫描 |
|||
Future<void> stopScan() async { |
|||
if (!isScanning.value) return; |
|||
|
|||
try { |
|||
await jieli.stopScan(); |
|||
isScanning.value = false; |
|||
} catch (e) { |
|||
// 停止扫描错误处理 |
|||
} |
|||
} |
|||
|
|||
/// 连接到指定设备 |
|||
Future<void> connectToDevice(BleDevice device) async { |
|||
try { |
|||
// 先断开当前连接 |
|||
if (currentDevice.value != null) { |
|||
await disconnectDevice(); |
|||
} |
|||
|
|||
// 更新UI状态 |
|||
connectStatus.value = '连接中...'; |
|||
|
|||
// 重置音频和唤醒相关的状态 |
|||
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'; |
|||
} else { |
|||
deviceInfo.value = '连接失败'; |
|||
} |
|||
} catch (e) { |
|||
deviceInfo.value = '连接错误: ${e.toString()}'; |
|||
} |
|||
} |
|||
|
|||
/// 断开设备连接 |
|||
Future<void> disconnectDevice() async { |
|||
if (currentDevice.value == null) return; |
|||
|
|||
try { |
|||
connectStatus.value = '断开中...'; |
|||
|
|||
final success = await jieli.disconnect(); |
|||
|
|||
if (success) { |
|||
deviceInfo.value = '未连接设备'; |
|||
} |
|||
|
|||
} catch (e) { |
|||
// 断开连接错误处理 |
|||
} |
|||
} |
|||
|
|||
/// 发送开始录音命令 |
|||
Future<void> sendStartRecordingCommand() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
final success = await jieli.sendStartRecordingCommand(); |
|||
|
|||
if (success) { |
|||
Get.snackbar('命令已发送', '已发送开始录音命令'); |
|||
} else { |
|||
Get.snackbar('命令发送失败', '发送开始录音命令失败'); |
|||
} |
|||
} catch (e) { |
|||
Get.snackbar('命令错误', e.toString()); |
|||
} |
|||
} |
|||
|
|||
/// 发送停止录音命令 |
|||
Future<void> sendStopRecordingCommand() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
final success = await jieli.sendStopRecordingCommand(); |
|||
|
|||
if (success) { |
|||
Get.snackbar('命令已发送', '已发送停止录音命令'); |
|||
} else { |
|||
Get.snackbar('命令发送失败', '发送停止录音命令失败'); |
|||
} |
|||
} catch (e) { |
|||
Get.snackbar('命令错误', e.toString()); |
|||
} |
|||
} |
|||
|
|||
/// 增加音量 |
|||
Future<void> increaseVolume() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
volumeCommandResult.value = '发送中...'; |
|||
final success = await jieli.increaseVolume(); |
|||
|
|||
if (!success) { |
|||
Get.snackbar('命令发送失败', '发送增加音量命令失败'); |
|||
volumeCommandResult.value = '发送失败'; |
|||
} |
|||
} catch (e) { |
|||
Get.snackbar('命令错误', e.toString()); |
|||
volumeCommandResult.value = '发送出错'; |
|||
} |
|||
} |
|||
|
|||
/// 减小音量 |
|||
Future<void> decreaseVolume() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
volumeCommandResult.value = '发送中...'; |
|||
final success = await jieli.decreaseVolume(); |
|||
|
|||
if (!success) { |
|||
Get.snackbar('命令发送失败', '发送减小音量命令失败'); |
|||
volumeCommandResult.value = '发送失败'; |
|||
} |
|||
} catch (e) { |
|||
Get.snackbar('命令错误', e.toString()); |
|||
volumeCommandResult.value = '发送出错'; |
|||
} |
|||
} |
|||
|
|||
/// 清理资源 |
|||
void _disposeJieli() { |
|||
try { |
|||
jieli.dispose(); |
|||
} catch (e) { |
|||
// 释放资源错误处理 |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,568 @@ |
|||
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), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: controller.increaseVolume, |
|||
icon: Icon( |
|||
Icons.volume_up, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
'增加音量', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.blue[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: controller.decreaseVolume, |
|||
icon: Icon( |
|||
Icons.volume_down, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
'减小音量', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.orange[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Obx(() => controller.volumeCommandResult.value.isNotEmpty |
|||
? Container( |
|||
width: double.infinity, |
|||
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 16.w), |
|||
decoration: BoxDecoration( |
|||
color: controller.volumeCommandResult.value.contains('成功') |
|||
? Colors.green[100] |
|||
: (controller.volumeCommandResult.value == '发送中...' |
|||
? Colors.blue[100] |
|||
: Colors.red[100]), |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
child: Text( |
|||
'音量控制: ${controller.volumeCommandResult.value}', |
|||
style: TextStyle( |
|||
fontSize: 13.sp, |
|||
color: controller.volumeCommandResult.value.contains('成功') |
|||
? Colors.green[900] |
|||
: (controller.volumeCommandResult.value == '发送中...' |
|||
? Colors.blue[900] |
|||
: Colors.red[900]), |
|||
), |
|||
textAlign: TextAlign.center, |
|||
), |
|||
) |
|||
: SizedBox.shrink() |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
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,24 @@ |
|||
# |
|||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. |
|||
# Run `pod lib lint azure_speech.podspec` to validate before publishing. |
|||
# |
|||
Pod::Spec.new do |s| |
|||
s.name = 'azure_speech' |
|||
s.version = '0.0.1' |
|||
s.summary = 'Azure Speech plugin' |
|||
s.description = <<-DESC |
|||
A Flutter plugin for Azure Speech services. |
|||
DESC |
|||
s.homepage = 'http://example.com' |
|||
s.license = { :file => '../LICENSE' } |
|||
s.author = { 'Your Company' => 'email@example.com' } |
|||
s.source = { :path => '.' } |
|||
s.source_files = 'Classes/**/*' |
|||
s.dependency 'Flutter' |
|||
s.dependency 'MicrosoftCognitiveServicesSpeech-iOS' |
|||
s.platform = :ios, '12.0' |
|||
|
|||
# Flutter.framework does not contain a i386 slice. |
|||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } |
|||
s.swift_version = '5.0' |
|||
end |
|||
@ -0,0 +1,80 @@ |
|||
# chat_storage_sqlite |
|||
|
|||
SQLite存储插件,用于存储聊天记录并支持分页查询。 |
|||
|
|||
## 功能特点 |
|||
|
|||
- 使用SQLite数据库存储聊天记录 |
|||
- 提供分页查询功能,可指定获取对应页码的记录 |
|||
- 支持按会话ID组织和查询聊天记录 |
|||
- 提供添加、查询、删除等基本操作 |
|||
|
|||
## 使用方法 |
|||
|
|||
### 保存聊天记录 |
|||
|
|||
```dart |
|||
import 'package:chat_storage/chat_storage.dart'; |
|||
|
|||
// 保存一条聊天记录 |
|||
final result = await ChatStorage.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 ChatStorage.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 ChatStorage.deleteMessages( |
|||
messageIds: [1, 2, 3], |
|||
); |
|||
|
|||
// 方式2:删除整个会话的所有消息 |
|||
final deleteResult2 = await ChatStorage.deleteMessages( |
|||
sessionId: 'session_123', |
|||
); |
|||
|
|||
// 方式3:清空整个数据库 |
|||
final clearResult = await ChatStorage.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" |
|||
|
|||
// 目标 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" |
|||
@ -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相关权限 --> |
|||
<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 |
|||
|
|||
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 ChatStoragePlugin: 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") |
|||
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,23 @@ |
|||
# |
|||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. |
|||
# Run `pod lib lint chat_storage.podspec` to validate before publishing. |
|||
# |
|||
Pod::Spec.new do |s| |
|||
s.name = 'chat_storage' |
|||
s.version = '0.0.1' |
|||
s.summary = 'Chat Storage plugin' |
|||
s.description = <<-DESC |
|||
A Flutter plugin for chat storage services. |
|||
DESC |
|||
s.homepage = 'http://example.com' |
|||
s.license = { :file => '../LICENSE' } |
|||
s.author = { 'Your Company' => 'email@example.com' } |
|||
s.source = { :path => '.' } |
|||
s.source_files = 'Classes/**/*' |
|||
s.dependency 'Flutter' |
|||
s.platform = :ios, '12.0' |
|||
|
|||
# Flutter.framework does not contain a i386 slice. |
|||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } |
|||
s.swift_version = '5.0' |
|||
end |
|||
@ -0,0 +1,154 @@ |
|||
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 ChatStorage { |
|||
static const MethodChannel _channel = MethodChannel('chat_storage'); |
|||
|
|||
/// 保存一条聊天记录 |
|||
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, |
|||
}); |
|||
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 |
|||
description: A Flutter plugin for storing chat messages using 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 |
|||
pluginClass: ChatStoragePlugin |
|||
ios: |
|||
pluginClass: ChatStoragePlugin |
|||
@ -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,549 @@ |
|||
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服务类,提供核心蓝牙功能 |
|||
*/ |
|||
class BleService(private val context: Context) { |
|||
companion object { |
|||
private const val TAG = "JieliBleService" |
|||
|
|||
// 服务和特征UUID - 根据协议规范重新定义 |
|||
private const val AUDIO_SERVICE_UUID = "0000faa0-0000-1000-8000-00805f9b34fb" // 主服务UUID |
|||
private const val NOTIFY_CHAR_UUID = "0000faa2-0000-1000-8000-00805f9b34fb" // 用于接收数据的特征(notify) |
|||
private const val WRITE_CHAR_UUID = "0000faa1-0000-1000-8000-00805f9b34fb" // 用于发送命令的特征(write without response) |
|||
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 |
|||
|
|||
// 命令类型常量 |
|||
const val COMMAND_VOLUME = 1 |
|||
} |
|||
|
|||
/** |
|||
* 回调接口 |
|||
*/ |
|||
interface Callback { |
|||
fun onScanResult(devices: List<Map<String, Any>>) |
|||
fun onConnectionStateChanged(state: Int) |
|||
fun onAudioDataReceived(data: ByteArray) |
|||
fun onWakeupSignalReceived() |
|||
fun onCommandResult(commandType: Int, success: Boolean) |
|||
} |
|||
|
|||
// 蓝牙管理器和适配器 |
|||
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 notifyChar: BluetoothGattCharacteristic? = null |
|||
private var writeChar: 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: Callback? = 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 |
|||
notifyChar = null |
|||
writeChar = 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, "找到音频服务") |
|||
|
|||
// 获取特征 |
|||
notifyChar = audioService.getCharacteristic(UUID.fromString(NOTIFY_CHAR_UUID)) |
|||
writeChar = audioService.getCharacteristic(UUID.fromString(WRITE_CHAR_UUID)) |
|||
|
|||
if (notifyChar != null && writeChar != null) { |
|||
Log.i(TAG, "找到所需特征") |
|||
|
|||
// 设置特征写入类型为无响应模式 |
|||
writeChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE |
|||
|
|||
// 注册特征通知 |
|||
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() |
|||
Log.d(TAG, "收到特征变化通知: $characteristicUuid") |
|||
|
|||
when (characteristicUuid) { |
|||
// NOTIFY_CHAR_UUID -> { |
|||
// // 收到音频数据 |
|||
// val audioData = characteristic.value |
|||
// Log.d(TAG, "收到音频数据帧,大小: ${audioData.size} 字节") |
|||
|
|||
// callback?.onAudioDataReceived(audioData) |
|||
// } |
|||
NOTIFY_CHAR_UUID -> { |
|||
// 处理控制消息 |
|||
val value = characteristic.value |
|||
Log.i(TAG, "收到控制通知: ${value.contentToString()}") |
|||
|
|||
// 判断是否是协议格式响应 |
|||
if (value.isNotEmpty() && value[0].toInt() and 0xFF == 0xA5) { |
|||
// 处理设备返回的协议数据 |
|||
processResponseData(value) |
|||
} |
|||
// 假设0x01表示唤醒信号 |
|||
else 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() == WRITE_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}") |
|||
Log.i(TAG, "特征通知设置成功,设备就绪") |
|||
} else { |
|||
Log.e(TAG, "描述符写入失败,status=$status") |
|||
updateConnectionState(STATE_ERROR) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 设置特征通知 |
|||
*/ |
|||
private fun setupNotifications(gatt: BluetoothGatt) { |
|||
// 启用 notify 特征通知 |
|||
if (notifyChar?.properties?.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { |
|||
gatt.setCharacteristicNotification(notifyChar, true) |
|||
|
|||
val descriptor = notifyChar?.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, "数据通知特征不支持通知") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 处理设备返回的数据 |
|||
* 协议格式: |
|||
* 协议头(0xA5) + 字段类型(1byte) + 数据长度(1byte) + 数据域(N byte) + 校验和(1byte) |
|||
*/ |
|||
private fun processResponseData(data: ByteArray) { |
|||
if (data.size < 4) { |
|||
Log.e(TAG, "返回数据格式错误,长度不足") |
|||
return |
|||
} |
|||
|
|||
// 判断协议头 |
|||
if (data[0].toInt() and 0xFF != 0xA5) { |
|||
Log.e(TAG, "返回数据协议头错误: ${data[0].toInt() and 0xFF}") |
|||
return |
|||
} |
|||
|
|||
val fieldType = data[1].toInt() and 0xFF |
|||
val dataLen = data[2].toInt() and 0xFF |
|||
|
|||
// 验证长度 |
|||
if (data.size != dataLen + 4) { // 协议头+字段类型+数据长度+数据域+校验和 |
|||
Log.e(TAG, "返回数据长度不匹配: 应为${dataLen + 4},实际为${data.size}") |
|||
return |
|||
} |
|||
|
|||
// 计算校验和 |
|||
var checksum = 0 |
|||
for (i in 0 until data.size - 1) { |
|||
checksum += data[i].toInt() and 0xFF |
|||
} |
|||
checksum = checksum and 0xFF |
|||
|
|||
val receivedChecksum = data[data.size - 1].toInt() and 0xFF |
|||
|
|||
if (checksum != receivedChecksum) { |
|||
Log.e(TAG, "校验和错误: 计算值=$checksum, 接收值=$receivedChecksum") |
|||
return |
|||
} |
|||
|
|||
// 解析字段类型 |
|||
when (fieldType) { |
|||
0x01 -> { |
|||
// 音量调整命令的响应 |
|||
val result = data[3].toInt() and 0xFF |
|||
val success = result == 0 |
|||
Log.i(TAG, "音量调整命令响应: ${if (success) "成功" else "失败"}") |
|||
callback?.onCommandResult(COMMAND_VOLUME, success) |
|||
} |
|||
// 其他命令类型可在此处添加 |
|||
else -> { |
|||
Log.d(TAG, "未知命令类型: ${String.format("0x%02X", fieldType)}") |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送开始录音命令 |
|||
*/ |
|||
fun sendStartRecordingCommand(): Boolean { |
|||
if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { |
|||
Log.e(TAG, "无法发送开始录音命令,设备未连接") |
|||
return false |
|||
} |
|||
|
|||
try { |
|||
val commandData = byteArrayOf(0x01) |
|||
writeChar?.value = commandData |
|||
|
|||
val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false |
|||
|
|||
if (success) { |
|||
Log.i(TAG, "开始录音命令已发送") |
|||
} else { |
|||
Log.e(TAG, "开始录音命令发送失败") |
|||
} |
|||
|
|||
return success |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "发送开始录音命令时发生异常: ${e.message}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送停止录音命令 |
|||
*/ |
|||
fun sendStopRecordingCommand(): Boolean { |
|||
if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { |
|||
Log.e(TAG, "无法发送停止录音命令,设备未连接") |
|||
return false |
|||
} |
|||
|
|||
try { |
|||
val commandData = byteArrayOf(0x02) |
|||
writeChar?.value = commandData |
|||
|
|||
val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false |
|||
|
|||
if (success) { |
|||
Log.i(TAG, "停止录音命令已发送") |
|||
} else { |
|||
Log.e(TAG, "停止录音命令发送失败") |
|||
} |
|||
|
|||
return success |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "发送停止录音命令时发生异常: ${e.message}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送音量调整命令 |
|||
* 协议格式: |
|||
* 协议头(0xAA) + 字段类型(0x01) + 数据长度(0x01) + 音量值(0~16) + 校验和 |
|||
*/ |
|||
fun adjustVolume(volume: Int): Boolean { |
|||
if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { |
|||
Log.e(TAG, "无法发送音量调整命令,设备未连接") |
|||
return false |
|||
} |
|||
|
|||
// 限制音量范围为0-16 |
|||
val safeVolume = volume.coerceIn(0, 16) |
|||
|
|||
try { |
|||
// 构建命令数据 |
|||
val header: Byte = 0xAA.toByte() |
|||
val fieldType: Byte = 0x01 |
|||
val dataLen: Byte = 0x01 |
|||
val volumeData: Byte = safeVolume.toByte() |
|||
|
|||
// 计算校验和 |
|||
val checksum = ((header.toInt() and 0xFF) + |
|||
(fieldType.toInt() and 0xFF) + |
|||
(dataLen.toInt() and 0xFF) + |
|||
(volumeData.toInt() and 0xFF)) and 0xFF |
|||
|
|||
// 创建完整命令 |
|||
val commandData = byteArrayOf(header, fieldType, dataLen, volumeData, checksum.toByte()) |
|||
|
|||
// 写入特征 |
|||
writeChar?.value = commandData |
|||
|
|||
val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false |
|||
|
|||
if (success) { |
|||
Log.i(TAG, "音量调整命令已发送: $safeVolume") |
|||
} else { |
|||
Log.e(TAG, "音量调整命令发送失败") |
|||
} |
|||
|
|||
return success |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "发送音量调整命令时发生异常: ${e.message}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 增加音量 |
|||
*/ |
|||
fun increaseVolume(): Boolean { |
|||
// 假设当前音量保存在设备中,我们只需发送增加命令 |
|||
return adjustVolume(16) // 发送最大音量命令 |
|||
} |
|||
|
|||
/** |
|||
* 减小音量 |
|||
*/ |
|||
fun decreaseVolume(): Boolean { |
|||
// 假设当前音量保存在设备中,我们只需发送减小命令 |
|||
return adjustVolume(0) // 发送最小音量命令 |
|||
} |
|||
|
|||
/** |
|||
* 清理资源 |
|||
*/ |
|||
fun cleanup() { |
|||
stopScan() |
|||
scanHandler.removeCallbacks(scanResultUpdateRunnable) |
|||
bluetoothGatt?.close() |
|||
bluetoothGatt = null |
|||
notifyChar = null |
|||
writeChar = null |
|||
} |
|||
} |
|||
@ -0,0 +1,292 @@ |
|||
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 const val EVENT_TYPE_COMMAND_RESULT = "command_result" |
|||
} |
|||
|
|||
// 蓝牙相关请求码 |
|||
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 : BleService.Callback { |
|||
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) |
|||
} |
|||
} |
|||
|
|||
override fun onCommandResult(commandType: Int, success: Boolean) { |
|||
pluginScope.launch(Dispatchers.Main) { |
|||
sendEvent(EVENT_TYPE_COMMAND_RESULT, mapOf( |
|||
"commandType" to commandType, |
|||
"success" to success |
|||
)) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送事件到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) |
|||
} |
|||
"adjustVolume" -> { |
|||
val volume = call.argument<Int>("volume") ?: 0 |
|||
val success = bleService.adjustVolume(volume) |
|||
result.success(success) |
|||
} |
|||
"increaseVolume" -> { |
|||
val success = bleService.increaseVolume() |
|||
result.success(success) |
|||
} |
|||
"decreaseVolume" -> { |
|||
val success = bleService.decreaseVolume() |
|||
result.success(success) |
|||
} |
|||
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,272 @@ |
|||
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'; |
|||
static const String commandResult = 'command_result'; |
|||
} |
|||
|
|||
/// 杰理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; |
|||
|
|||
/// 命令结果流 |
|||
static final StreamController<CommandResult> _commandResultController = |
|||
StreamController<CommandResult>.broadcast(); |
|||
Stream<CommandResult> get commandResultStream => _commandResultController.stream; |
|||
|
|||
/// 原始事件流 |
|||
Stream<Map<String, dynamic>>? _eventStream; |
|||
|
|||
/// 事件流订阅 |
|||
StreamSubscription? _eventSubscription; |
|||
|
|||
/// 命令类型常量 |
|||
static const int commandTypeVolume = 1; |
|||
|
|||
/// 构造函数,初始化平台通道和事件监听 |
|||
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; |
|||
case EventType.commandResult: |
|||
if (data is Map<dynamic, dynamic>) { |
|||
final commandType = data['commandType'] as int; |
|||
final success = data['success'] as bool; |
|||
_commandResultController.add(CommandResult( |
|||
commandType: commandType, |
|||
success: success, |
|||
)); |
|||
} |
|||
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(); |
|||
_commandResultController.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; |
|||
} |
|||
|
|||
/// 调整音量 |
|||
/// [volume] 音量值,范围0-16 |
|||
Future<bool> adjustVolume(int volume) async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'adjustVolume', |
|||
{'volume': volume}, |
|||
); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('调整音量失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 增加音量 |
|||
Future<bool> increaseVolume() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('increaseVolume'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('增加音量失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 减小音量 |
|||
Future<bool> decreaseVolume() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('decreaseVolume'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('减小音量失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 命令结果回调 |
|||
Stream<CommandResult> get onCommandResult => commandResultStream |
|||
.where((event) => event.commandType == commandTypeVolume) |
|||
.map((event) => event); |
|||
} |
|||
|
|||
/// 蓝牙设备模型 |
|||
class BluetoothDevice { |
|||
// ... existing code ... |
|||
} |
|||
|
|||
/// 命令结果 |
|||
class CommandResult { |
|||
final int commandType; |
|||
final bool success; |
|||
|
|||
CommandResult({ |
|||
required this.commandType, |
|||
required this.success, |
|||
}); |
|||
|
|||
@override |
|||
String toString() => 'CommandResult{commandType: $commandType, success: $success}'; |
|||
} |
|||
@ -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,892 @@ |
|||
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.ktor.client.plugins.sse.* |
|||
import io.modelcontextprotocol.kotlin.sdk.Implementation |
|||
import io.modelcontextprotocol.kotlin.sdk.ListToolsResult |
|||
import io.modelcontextprotocol.kotlin.sdk.TextContent |
|||
import io.modelcontextprotocol.kotlin.sdk.client.Client |
|||
import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport |
|||
import kotlinx.coroutines.runBlocking |
|||
import kotlinx.serialization.json.JsonObject |
|||
import org.json.JSONObject |
|||
import kotlin.jvm.optionals.getOrNull |
|||
import java.io.File |
|||
import io.ktor.client.engine.okhttp.* |
|||
import okhttp3.OkHttpClient |
|||
import java.security.cert.X509Certificate |
|||
import javax.net.ssl.SSLContext |
|||
import javax.net.ssl.TrustManager |
|||
import javax.net.ssl.X509TrustManager |
|||
import java.security.SecureRandom |
|||
import java.util.concurrent.TimeUnit |
|||
import okhttp3.logging.HttpLoggingInterceptor |
|||
import okhttp3.Interceptor |
|||
import io.ktor.client.request.* |
|||
import io.ktor.http.* |
|||
import com.yunqiinnovation.open_ai_service.mcp.CustomSseClientTransport |
|||
import io.ktor.util.valuesOf |
|||
|
|||
/** |
|||
* 工具类型枚举 |
|||
*/ |
|||
enum class ToolType { |
|||
LOCAL_FUNCTION, // 本地函数 |
|||
MCP_TOOL // MCP工具 |
|||
} |
|||
|
|||
/** |
|||
* 函数处理器接口 |
|||
*/ |
|||
interface FunctionHandler { |
|||
/** |
|||
* 处理函数调用 |
|||
* @param arguments 函数参数,Map格式 |
|||
* @return 函数执行结果,字符串 |
|||
*/ |
|||
suspend fun handle(arguments: Map<String, Any>): String |
|||
} |
|||
|
|||
/** |
|||
* 单个MCP子客户端 |
|||
* 负责连接到一个具体的MCP服务器并管理其工具 |
|||
*/ |
|||
class MCPSubClient( |
|||
private val serverId: String, |
|||
serverUrl: String, |
|||
private val customClient: HttpClient? = null |
|||
) : AutoCloseable { |
|||
private val TAG = "MCPSubClient[$serverId]" |
|||
|
|||
// 保存服务器URL |
|||
private val serverUrl: String = serverUrl |
|||
|
|||
init { |
|||
Log.d(TAG, "MCPSubClient初始化,URL: $serverUrl") |
|||
} |
|||
|
|||
// 初始化MCP客户端 |
|||
private val mcp: Client = Client( |
|||
clientInfo = Implementation( |
|||
name = "deep-voice-mcp-client-$serverId", |
|||
version = "1.0.0" |
|||
) |
|||
) |
|||
|
|||
// 服务器提供的工具列表 |
|||
private var tools: List<ToolUnion> = emptyList() |
|||
|
|||
// 工具Map列表,用于OpenAI请求 |
|||
private val toolMaps = mutableListOf<Map<String, Any>>() |
|||
|
|||
// 是否已连接 |
|||
private var isConnected = false |
|||
|
|||
// SSE传输层,使用自定义的传输层 |
|||
private var sseTransport: CustomSseClientTransport? = null |
|||
|
|||
// 工具名称到工具的映射,用于快速查找 |
|||
private val toolMapping = mutableMapOf<String, ToolUnion>() |
|||
|
|||
/** |
|||
* 将JsonObject转换为JsonValue |
|||
*/ |
|||
private fun JsonObject.toJsonValue(): JsonValue { |
|||
val mapper = ObjectMapper() |
|||
val node = mapper.readTree(this.toString()) |
|||
return JsonValue.fromJsonNode(node) |
|||
} |
|||
|
|||
/** |
|||
* 连接到MCP服务器(通过SSE) |
|||
* @return 是否连接成功 |
|||
*/ |
|||
suspend fun connect(): Boolean { |
|||
try { |
|||
Log.d(TAG, "开始连接到服务器: $serverUrl") |
|||
|
|||
// 使用提供的客户端或创建带SSL配置的客户端 |
|||
val client = customClient ?: createSslTrustAllClient() |
|||
|
|||
// 使用自定义传输层,直接传递完整URL |
|||
sseTransport = CustomSseClientTransport( |
|||
client = client, |
|||
urlString = serverUrl, |
|||
requestBuilder = { |
|||
// 添加基本请求头 |
|||
headers { |
|||
append(HttpHeaders.Accept, "text/event-stream") |
|||
append(HttpHeaders.CacheControl, "no-cache") |
|||
} |
|||
} |
|||
) |
|||
|
|||
Log.d(TAG, "创建自定义SSE传输层成功") |
|||
|
|||
// 连接MCP客户端到服务器 |
|||
mcp.connect(sseTransport!!) |
|||
|
|||
// 获取服务器提供的工具列表 |
|||
val toolsResult = mcp.listTools() |
|||
Log.d(TAG, "获取到工具列表: $toolsResult") |
|||
|
|||
// 处理工具列表 |
|||
processToolsList(toolsResult) |
|||
|
|||
isConnected = true |
|||
Log.d(TAG, "成功连接到MCP服务器,可用工具: ${tools.map { it.tool().get().name() }.joinToString(", ")}") |
|||
return true |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "连接服务器失败: ${e.message}", e) |
|||
// 记录详细的错误堆栈 |
|||
Log.e(TAG, "详细错误: ${e.stackTraceToString()}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 处理工具列表 |
|||
*/ |
|||
private fun processToolsList(toolsResult: 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() |
|||
|
|||
// 更新工具映射 |
|||
tools.forEach { toolUnion -> |
|||
val tool = toolUnion.tool().get() |
|||
toolMapping[tool.name()] = toolUnion |
|||
} |
|||
|
|||
// 更新工具Map列表 |
|||
updateToolMaps() |
|||
} |
|||
|
|||
/** |
|||
* 更新工具Map列表,用于OpenAI请求 |
|||
*/ |
|||
private fun updateToolMaps() { |
|||
toolMaps.clear() |
|||
|
|||
// 添加MCP工具 |
|||
tools.forEach { toolUnion -> |
|||
try { |
|||
val toolOptional = toolUnion.tool() |
|||
if (!toolOptional.isPresent) { |
|||
return@forEach |
|||
} |
|||
|
|||
val tool = toolOptional.get() |
|||
|
|||
// 创建基本参数结构 |
|||
val parametersMap = mutableMapOf<String, Any>() |
|||
parametersMap["type"] = "object" // 默认值 |
|||
parametersMap["properties"] = mutableMapOf<String, Any>() |
|||
parametersMap["required"] = emptyList<String>() |
|||
|
|||
// 从工具的输入模式中提取信息 |
|||
try { |
|||
// 获取输入模式并转换为字符串 |
|||
val schema = tool.inputSchema() |
|||
val schemaStr = schema.toString() |
|||
Log.d(TAG, "解析工具Schema: $schemaStr") |
|||
|
|||
// 如果toString()提供了足够的信息,就使用正则表达式解析 |
|||
// 这是最可靠的方法,因为它不依赖于API的细节变化 |
|||
|
|||
// 解析类型 |
|||
val typePattern = "type=([^,}]+)".toRegex() |
|||
val typeMatch = typePattern.find(schemaStr) |
|||
if (typeMatch != null) { |
|||
parametersMap["type"] = typeMatch.groupValues[1].trim() |
|||
} |
|||
|
|||
// 尝试从字符串中解析完整的JSON结构 |
|||
try { |
|||
// 直接从Schema字符串中提取属性结构,手动构建properties映射 |
|||
val propertiesMap = mutableMapOf<String, Any>() |
|||
|
|||
// 匹配属性定义: 形如 fieldName={...} |
|||
val propertiesPattern = "properties=\\{([^}]+)\\}".toRegex() |
|||
val propertiesMatch = propertiesPattern.find(schemaStr) |
|||
|
|||
if (propertiesMatch != null) { |
|||
// 匹配属性定义: 形如 fieldName={...} |
|||
val fieldPattern = "([\\w]+)=\\{([^{}]+(?:\\{[^{}]*\\}[^{}]*)*)\\}".toRegex() |
|||
val fieldMatches = fieldPattern.findAll(propertiesMatch.groupValues[1]) |
|||
|
|||
fieldMatches.forEach { fieldMatch -> |
|||
val fieldName = fieldMatch.groupValues[1] |
|||
val fieldContent = fieldMatch.groupValues[2] |
|||
|
|||
// 为属性创建一个Map |
|||
val fieldMap = mutableMapOf<String, Any>() |
|||
|
|||
// 解析字段内容中的键值对 |
|||
val keyValuePattern = "(\\w+)=([^,}]+)".toRegex() |
|||
val keyValueMatches = keyValuePattern.findAll(fieldContent) |
|||
|
|||
keyValueMatches.forEach { kvMatch -> |
|||
val key = kvMatch.groupValues[1] |
|||
val value = kvMatch.groupValues[2].trim() |
|||
|
|||
// 根据值的类型进行适当处理 |
|||
val processedValue: Any = when { |
|||
value == "true" -> true |
|||
value == "false" -> false |
|||
value.matches("\\d+".toRegex()) -> value.toIntOrNull() ?: value |
|||
value.matches("\\d+\\.\\d+".toRegex()) -> value.toDoubleOrNull() ?: value |
|||
else -> value // 保持为字符串 |
|||
} |
|||
|
|||
fieldMap[key] = processedValue |
|||
} |
|||
|
|||
propertiesMap[fieldName] = fieldMap |
|||
} |
|||
parametersMap["properties"] = propertiesMap |
|||
Log.d(TAG, "成功解析属性: $propertiesMap") |
|||
} else { |
|||
parametersMap["properties"] = emptyMap<String, Any>() |
|||
} |
|||
|
|||
// 提取required部分 |
|||
val requiredPattern = "required=\\[([^\\]]+)\\]".toRegex() |
|||
val requiredMatch = requiredPattern.find(schemaStr) |
|||
|
|||
if (requiredMatch != null) { |
|||
val requiredStr = requiredMatch.groupValues[1] |
|||
val requiredList = requiredStr.split(",").map { it.trim() } |
|||
parametersMap["required"] = requiredList |
|||
} else { |
|||
parametersMap["required"] = emptyList<String>() |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "从字符串解析schema细节失败: ${e.message}") |
|||
// 使用默认值 |
|||
parametersMap["properties"] = emptyMap<String, Any>() |
|||
parametersMap["required"] = emptyList<String>() |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "解析工具输入模式失败: ${e.message}", e) |
|||
// 使用默认值继续 |
|||
} |
|||
|
|||
// 创建工具Map |
|||
toolMaps.add(mapOf( |
|||
"type" to "function", |
|||
"function" to mapOf( |
|||
"name" to tool.name(), |
|||
"description" to (tool.description() ?: ""), |
|||
"parameters" to parametersMap |
|||
) |
|||
)) |
|||
|
|||
Log.d(TAG, "工具 ${tool.name()} 成功添加到工具映射") |
|||
|
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "转换工具到Map失败: ${e.message}") |
|||
} |
|||
} |
|||
|
|||
Log.d(TAG, "更新工具映射完成,共 ${toolMaps.size} 个工具") |
|||
} |
|||
|
|||
/** |
|||
* 将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 |
|||
} |
|||
|
|||
/** |
|||
* 获取可用的工具列表 |
|||
*/ |
|||
fun getTools(): List<ToolUnion> { |
|||
return tools |
|||
} |
|||
|
|||
/** |
|||
* 检查是否包含指定工具 |
|||
*/ |
|||
fun containsTool(name: String): Boolean { |
|||
return toolMapping.containsKey(name) |
|||
} |
|||
|
|||
/** |
|||
* 调用工具 |
|||
*/ |
|||
suspend fun callTool(name: String, arguments: Map<String, Any>): JSONObject? { |
|||
if (!isConnected) { |
|||
Log.e(TAG, "未连接到MCP服务器", null) |
|||
return JSONObject().apply { put("context","未连接到MCP服务器") } |
|||
} |
|||
|
|||
if (!containsTool(name)) { |
|||
Log.w(TAG, "此客户端不包含工具: $name") |
|||
return null |
|||
} |
|||
|
|||
return try { |
|||
val result = mcp.callTool( |
|||
name = name, |
|||
arguments = arguments |
|||
) |
|||
// 处理工具调用结果 |
|||
val context = result?.content?.joinToString("\n") { content -> |
|||
when (content) { |
|||
is TextContent -> content.text ?: "" |
|||
else -> "" |
|||
} |
|||
} |
|||
JSONObject().apply {put("meta",result?._meta?.toMap()); put("context",context) } |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "调用MCP工具失败: ${e.message}", e) |
|||
JSONObject().apply { put("context", "调用MCP工具失败: ${e.message}") } |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 检查是否已连接 |
|||
*/ |
|||
fun isConnected(): Boolean { |
|||
return isConnected |
|||
} |
|||
|
|||
/** |
|||
* 关闭连接 |
|||
*/ |
|||
override fun close() { |
|||
runBlocking { |
|||
try { |
|||
mcp.close() |
|||
sseTransport?.close() |
|||
isConnected = false |
|||
Log.d(TAG, "已关闭MCP服务器连接") |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "关闭MCP服务器连接失败: ${e.message}", e) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* MCP客户端,用于连接Model Context Protocol服务器和管理本地函数 |
|||
*/ |
|||
class MCPClient(private val context: Context? = null) : AutoCloseable { |
|||
private val TAG = "MCPClient" |
|||
|
|||
// 本地函数Map,函数名 -> 处理器 |
|||
private val localFunctions = mutableMapOf<String, FunctionHandler>() |
|||
|
|||
// 本地函数定义Map,函数名 -> 定义 |
|||
private val localFunctionDefs = mutableMapOf<String, String>() |
|||
|
|||
// 子客户端列表,每个连接一个MCP服务器 |
|||
private val subClients = mutableMapOf<String, MCPSubClient>() |
|||
|
|||
// 是否已连接 |
|||
private var isConnected = false |
|||
|
|||
// 系统功能处理器 |
|||
private val systemFunctionHandler = SystemFunctionHandler(context!!) |
|||
|
|||
// 共享的HttpClient,用于所有子客户端 |
|||
private val sharedHttpClient by lazy { createSslTrustAllClient() } |
|||
|
|||
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) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 连接到MCP服务器(通过SSE) |
|||
* 直接接收完整的JSON配置字符串 |
|||
* |
|||
* @param mcpConfigJson 包含mcpServers字段的JSON配置字符串 |
|||
* @return 是否连接成功 |
|||
*/ |
|||
suspend fun connectToSSE(mcpConfigJson: String): Boolean { |
|||
// 清除现有连接 |
|||
closeAllConnections() |
|||
|
|||
val serverMap = mutableMapOf<String, String>() |
|||
var configLoaded = false |
|||
|
|||
// 尝试解析传入的JSON配置 |
|||
try { |
|||
if (mcpConfigJson.isNotEmpty()) { |
|||
val mapper = ObjectMapper() |
|||
try { |
|||
val config = mapper.readValue(mcpConfigJson, Map::class.java) |
|||
|
|||
// 处理mcpServers格式 |
|||
@Suppress("UNCHECKED_CAST") |
|||
val mcpServers = config["mcpServers"] as? Map<String, Map<String, String>> |
|||
if (mcpServers != null && mcpServers.isNotEmpty()) { |
|||
// 从服务器配置中提取URL |
|||
mcpServers.forEach { (serverId, serverConfig) -> |
|||
val url = serverConfig["url"] |
|||
if (!url.isNullOrEmpty()) { |
|||
serverMap[serverId] = url |
|||
Log.d(TAG, "从配置中添加服务器 [$serverId]: $url") |
|||
} |
|||
} |
|||
configLoaded = serverMap.isNotEmpty() |
|||
Log.d(TAG, "从传入的JSON配置加载了 ${serverMap.size} 个服务器") |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "解析传入的JSON配置失败: ${e.message}") |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "处理传入的配置失败: ${e.message}") |
|||
} |
|||
|
|||
|
|||
// 创建并连接所有子客户端 |
|||
val connectionResults = mutableMapOf<String, Boolean>() |
|||
|
|||
for ((serverId, serverUrl) in serverMap) { |
|||
try { |
|||
Log.d(TAG, "为服务器 [$serverId],[$serverUrl] 创建子客户端") |
|||
val subClient = MCPSubClient(serverId, serverUrl, sharedHttpClient) |
|||
val connected = subClient.connect() |
|||
|
|||
if (connected) { |
|||
subClients[serverId] = subClient |
|||
connectionResults[serverId] = true |
|||
Log.d(TAG, "子客户端 [$serverId] 连接成功") |
|||
} else { |
|||
subClient.close() |
|||
connectionResults[serverId] = false |
|||
Log.w(TAG, "子客户端 [$serverId] 连接失败") |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "创建子客户端 [$serverId] 失败: ${e.message}", e) |
|||
connectionResults[serverId] = false |
|||
} |
|||
} |
|||
|
|||
// 检查是否有任何客户端连接成功 |
|||
val successCount = connectionResults.count { it.value } |
|||
isConnected = successCount > 0 |
|||
|
|||
if (isConnected) { |
|||
Log.d(TAG, "已成功连接 $successCount/${serverMap.size} 个MCP服务器") |
|||
|
|||
// 记录所有可用工具 |
|||
val allToolNames = getAllToolNames() |
|||
Log.d(TAG, "所有可用工具: ${allToolNames.joinToString(", ")}") |
|||
|
|||
return true |
|||
} else { |
|||
Log.e(TAG, "所有MCP服务器连接均失败") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 关闭所有连接 |
|||
*/ |
|||
private fun closeAllConnections() { |
|||
subClients.forEach { (serverId, client) -> |
|||
try { |
|||
client.close() |
|||
Log.d(TAG, "已关闭子客户端 [$serverId]") |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "关闭子客户端 [$serverId] 失败: ${e.message}") |
|||
} |
|||
} |
|||
subClients.clear() |
|||
} |
|||
|
|||
/** |
|||
* 获取所有工具名称 |
|||
*/ |
|||
private fun getAllToolNames(): List<String> { |
|||
val toolNames = mutableSetOf<String>() |
|||
|
|||
// 添加本地函数 |
|||
toolNames.addAll(localFunctions.keys) |
|||
|
|||
// 添加所有子客户端的工具 |
|||
subClients.forEach { (_, client) -> |
|||
client.getTools().forEach { toolUnion -> |
|||
val tool = toolUnion.tool().get() |
|||
toolNames.add(tool.name()) |
|||
} |
|||
} |
|||
|
|||
return toolNames.toList() |
|||
} |
|||
|
|||
/** |
|||
* 注册本地函数 |
|||
* @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) |
|||
|
|||
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) |
|||
Log.d(TAG, "取消注册本地函数成功: $name") |
|||
} |
|||
return removed |
|||
} |
|||
|
|||
/** |
|||
* 获取工具Map列表,用于OpenAI请求 |
|||
*/ |
|||
fun getToolMaps(): List<Map<String, Any>> { |
|||
val allToolMaps = mutableListOf<Map<String, Any>>() |
|||
|
|||
// 首先添加本地函数 |
|||
localFunctionDefs.forEach { (name, functionDefJson) -> |
|||
try { |
|||
val mapper = ObjectMapper() |
|||
val functionDef = mapper.readValue(functionDefJson, object : TypeReference<Map<String, Any>>() {}) |
|||
allToolMaps.add(mapOf( |
|||
"type" to "function", |
|||
"function" to functionDef |
|||
)) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "解析本地函数定义失败: $name, ${e.message}") |
|||
} |
|||
} |
|||
|
|||
// 然后添加所有子客户端的工具 |
|||
subClients.forEach { (serverId, client) -> |
|||
val clientTools = client.getToolMaps() |
|||
allToolMaps.addAll(clientTools) |
|||
Log.d(TAG, "添加子客户端 [$serverId] 的 ${clientTools.size} 个工具") |
|||
} |
|||
|
|||
return allToolMaps |
|||
} |
|||
|
|||
/** |
|||
* 获取工具类型 |
|||
* @param name 工具名称 |
|||
* @return 工具类型,如果不存在则返回null |
|||
*/ |
|||
fun getToolType(name: String): ToolType? { |
|||
if (localFunctions.containsKey(name)) { |
|||
return ToolType.LOCAL_FUNCTION |
|||
} |
|||
|
|||
if (subClients.any { it.value.containsTool(name) }) { |
|||
return ToolType.MCP_TOOL |
|||
} |
|||
|
|||
return null |
|||
} |
|||
|
|||
/** |
|||
* 调用工具(统一入口) |
|||
* @param name 工具名称 |
|||
* @param arguments 参数 |
|||
* @return 调用结果 |
|||
*/ |
|||
suspend fun callTool(name: String, arguments: Map<String, Any>): JSONObject? { |
|||
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>): JSONObject? { |
|||
return try { |
|||
val handler = localFunctions[name] |
|||
if (handler != null) { |
|||
val context = handler.handle(arguments) |
|||
JSONObject().apply { put("context", context) } |
|||
} else { |
|||
Log.e(TAG, "未找到本地函数: $name") |
|||
null |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "调用本地函数失败: ${e.message}", e) |
|||
|
|||
JSONObject().apply { put("context", "调用本地函数失败: ${e.message}") } |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 调用MCP工具 |
|||
*/ |
|||
private suspend fun callMcpTool(name: String, arguments: Map<String, Any>): JSONObject? { |
|||
if (!isConnected) { |
|||
Log.e(TAG, "未连接到MCP服务器", null) |
|||
return JSONObject().apply { put("context", "未连接到MCP服务器") } |
|||
} |
|||
|
|||
// 查找包含此工具的子客户端 |
|||
val clientEntry = subClients.entries.find { it.value.containsTool(name) } |
|||
|
|||
if (clientEntry != null) { |
|||
val (serverId, client) = clientEntry |
|||
Log.d(TAG, "通过子客户端 [$serverId] 调用工具: $name") |
|||
return client.callTool(name, arguments) |
|||
} |
|||
|
|||
// 没有找到包含此工具的客户端 |
|||
Log.e(TAG, "没有子客户端包含工具: $name") |
|||
return JSONObject().apply { put("context", "没有可用的服务器提供工具: $name") } |
|||
} |
|||
|
|||
/** |
|||
* 获取可用的工具列表 |
|||
*/ |
|||
fun getTools(): List<ToolUnion> { |
|||
val allTools = mutableListOf<ToolUnion>() |
|||
|
|||
// 收集所有子客户端的工具 |
|||
subClients.forEach { (_, client) -> |
|||
allTools.addAll(client.getTools()) |
|||
} |
|||
|
|||
return allTools |
|||
} |
|||
|
|||
/** |
|||
* 检查工具是否存在 |
|||
*/ |
|||
fun hasToolWithName(name: String): Boolean { |
|||
return localFunctions.containsKey(name) || |
|||
subClients.any { it.value.containsTool(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 { |
|||
closeAllConnections() |
|||
isConnected = false |
|||
Log.d(TAG, "已关闭所有MCP服务器连接") |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "关闭MCP服务器连接失败: ${e.message}", e) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 创建一个信任所有SSL证书的HttpClient |
|||
*/ |
|||
private fun createSslTrustAllClient(): HttpClient { |
|||
// 创建信任所有证书的TrustManager |
|||
val trustAllCerts = arrayOf<TrustManager>( |
|||
object : X509TrustManager { |
|||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {} |
|||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {} |
|||
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf() |
|||
} |
|||
) |
|||
|
|||
// 创建SSL上下文并初始化它 |
|||
val sslContext = SSLContext.getInstance("TLS") |
|||
sslContext.init(null, trustAllCerts, SecureRandom()) |
|||
|
|||
// 创建HTTP日志拦截器 |
|||
val loggingInterceptor = HttpLoggingInterceptor { message -> |
|||
Log.d("OkHttp", message) |
|||
}.apply { |
|||
level = HttpLoggingInterceptor.Level.HEADERS |
|||
} |
|||
|
|||
// 创建自定义拦截器显示实际请求URL |
|||
val urlLoggingInterceptor = Interceptor { chain -> |
|||
val request = chain.request() |
|||
Log.d("OkHttp-URL", "实际请求URL: ${request.url}") |
|||
Log.d("OkHttp-Headers", "请求头: ${request.headers}") |
|||
|
|||
val response = chain.proceed(request) |
|||
|
|||
Log.d("OkHttp-Response", "状态码: ${response.code} - ${response.message}") |
|||
Log.d("OkHttp-Response", "响应头: ${response.headers}") |
|||
|
|||
response |
|||
} |
|||
|
|||
// 创建OkHttpClient并配置信任所有证书 |
|||
val okHttpClient = OkHttpClient.Builder() |
|||
.sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager) |
|||
.hostnameVerifier { _, _ -> true } |
|||
.connectTimeout(30, TimeUnit.SECONDS) |
|||
.readTimeout(30, TimeUnit.SECONDS) |
|||
.addInterceptor(loggingInterceptor) // 添加HTTP日志拦截器 |
|||
.addInterceptor(urlLoggingInterceptor) // 添加URL日志拦截器 |
|||
.build() |
|||
|
|||
// 创建使用OkHttp引擎的HttpClient |
|||
return HttpClient(OkHttp) { |
|||
engine { |
|||
preconfigured = okHttpClient |
|||
} |
|||
|
|||
// 安装SSE插件 |
|||
install(SSE) |
|||
} |
|||
} |
|||
@ -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 |
|||
) |
|||
} |
|||
@ -0,0 +1,357 @@ |
|||
package com.yunqiinnovation.open_ai_service.mcp |
|||
|
|||
import android.util.Log |
|||
import io.ktor.client.* |
|||
import io.ktor.client.plugins.sse.* |
|||
import io.ktor.client.request.* |
|||
import io.ktor.client.statement.* |
|||
import io.ktor.http.* |
|||
import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage |
|||
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport |
|||
import kotlinx.coroutines.* |
|||
import kotlinx.serialization.encodeToString |
|||
import kotlinx.serialization.json.Json |
|||
import kotlinx.serialization.decodeFromString |
|||
import kotlin.properties.Delegates |
|||
import kotlin.time.Duration |
|||
import java.util.concurrent.atomic.AtomicBoolean |
|||
import org.json.JSONObject |
|||
|
|||
/** |
|||
* 自定义SSE客户端传输层,修复原始SseClientTransport中的URL拼接问题 |
|||
* 解决URL查询参数与路径拼接错误的问题,确保消息端点URL格式正确 |
|||
*/ |
|||
class CustomSseClientTransport( |
|||
private val client: HttpClient, |
|||
private val urlString: String?, |
|||
private val reconnectionTime: Duration? = null, |
|||
private val requestBuilder: HttpRequestBuilder.() -> Unit = {}, |
|||
) : AbstractTransport() { |
|||
private val TAG = "CustomSseClientTransport" |
|||
|
|||
private val scope by lazy { |
|||
CoroutineScope(session.coroutineContext + SupervisorJob()) |
|||
} |
|||
|
|||
// 使用Java标准库的AtomicBoolean替代kotlinx.atomicfu |
|||
private val initialized = AtomicBoolean(false) |
|||
private var session: ClientSSESession by Delegates.notNull() |
|||
private val endpoint = CompletableDeferred<String>() |
|||
|
|||
private var job: Job? = null |
|||
|
|||
// 创建JSON解析器,增强灵活性设置 |
|||
private val json = Json { |
|||
ignoreUnknownKeys = true // 忽略未知字段 |
|||
isLenient = true // 宽松解析模式 |
|||
coerceInputValues = true // 尝试强制转换类型 |
|||
encodeDefaults = true // 编码默认值 |
|||
explicitNulls = false // 不要求显式null值 |
|||
} |
|||
|
|||
// 保存基础URL(不包含查询参数)和查询参数 |
|||
private var baseUrlWithoutParams: String? = null |
|||
private var queryParams: Map<String, String> = emptyMap() |
|||
private var hostPart: String = "" // 添加类级别变量 |
|||
private var pathPart: String = "" // 添加类级别变量 |
|||
|
|||
/** |
|||
* 解析URL,分离基础URL、路径和查询参数 |
|||
* 返回三元组: (主机部分URL, 路径部分, 查询参数Map) |
|||
*/ |
|||
private fun parseUrl(url: String): Triple<String, String, Map<String, String>> { |
|||
return try { |
|||
val params = mutableMapOf<String, String>() |
|||
|
|||
// 确保URL有协议部分 |
|||
var processedUrl = url.trim() |
|||
if (!processedUrl.startsWith("http://") && !processedUrl.startsWith("https://")) { |
|||
processedUrl = "https://$processedUrl" |
|||
Log.d(TAG, "添加默认协议: $processedUrl") |
|||
} |
|||
|
|||
val urlObj = java.net.URL(processedUrl) |
|||
|
|||
// 解析查询参数 |
|||
if (urlObj.query != null) { |
|||
urlObj.query.split("&").forEach { param -> |
|||
val parts = param.split("=", limit = 2) |
|||
if (parts.size == 2) { |
|||
params[parts[0]] = parts[1] |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 构建主机部分URL(协议+主机+端口) |
|||
val port = if (urlObj.port == -1) "" else ":${urlObj.port}" |
|||
val hostUrl = "${urlObj.protocol}://${urlObj.host}$port" |
|||
|
|||
// 路径部分 |
|||
val path = urlObj.path |
|||
|
|||
Triple(hostUrl, path, params) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "解析URL失败: $url, ${e.message}") |
|||
Triple(url, "", emptyMap()) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 收集SSE事件 |
|||
*/ |
|||
private suspend fun collectEvents() { |
|||
job = scope.launch(CoroutineName("CustomSseMcpClientTransport.collect#${hashCode()}")) { |
|||
session.incoming.collect { event -> |
|||
when (event.event) { |
|||
"error" -> { |
|||
val e = IllegalStateException("SSE error: ${event.data}") |
|||
Log.e(TAG, "SSE错误: ${event.data}") |
|||
_onError(e) |
|||
throw e |
|||
} |
|||
|
|||
"open" -> { |
|||
Log.d(TAG, "SSE连接已打开") |
|||
// 连接已打开,等待endpoint事件 |
|||
} |
|||
|
|||
"endpoint" -> { |
|||
try { |
|||
val eventData = event.data ?: "" |
|||
Log.d(TAG, "收到endpoint事件: $eventData") |
|||
|
|||
// 使用主机部分构建endpoint |
|||
val fullEndpoint = if (eventData.startsWith("/")) { |
|||
"$hostPart$eventData" |
|||
} else { |
|||
"$eventData" |
|||
} |
|||
|
|||
Log.d(TAG, "构建的endpoint路径(不含参数): $fullEndpoint") |
|||
|
|||
// 添加查询参数到endpoint |
|||
val endpointWithParams = if (queryParams.isNotEmpty()) { |
|||
// 检查endpoint是否已有查询参数 |
|||
if (fullEndpoint.contains("?")) { |
|||
// 已有查询参数,添加&并附加其他参数 |
|||
val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } |
|||
"$fullEndpoint&$queryString" |
|||
} else { |
|||
// 没有查询参数,添加?并附加参数 |
|||
val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } |
|||
"$fullEndpoint?$queryString" |
|||
} |
|||
} else { |
|||
fullEndpoint |
|||
} |
|||
|
|||
Log.d(TAG, "最终消息端点: $endpointWithParams") |
|||
endpoint.complete(endpointWithParams) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "处理endpoint事件失败: ${e.message}", e) |
|||
_onError(e) |
|||
close() |
|||
error(e) |
|||
} |
|||
} |
|||
|
|||
else -> { |
|||
try { |
|||
// 解析JSON-RPC消息 |
|||
val data = event.data |
|||
if (data != null) { |
|||
Log.d(TAG, "收到事件数据: $data") |
|||
try { |
|||
// 尝试安全地解析JSON消息 |
|||
safeParseMessage(data) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "解析JSON-RPC消息失败: ${e.message}", e) |
|||
// 错误已记录,但不中断连接,只发送错误通知 |
|||
_onError(e) |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "处理事件失败: ${e.message}", e) |
|||
_onError(e) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 安全解析JSON-RPC消息 |
|||
*/ |
|||
private suspend fun safeParseMessage(data: String) { |
|||
try { |
|||
// 先尝试使用标准解析 |
|||
val message = json.decodeFromString<JSONRPCMessage>(data) |
|||
_onMessage(message) |
|||
} catch (e: Exception) { |
|||
// 如果标准解析失败,记录错误并尝试使用备用解析方式 |
|||
Log.w(TAG, "标准解析失败,尝试备用解析: ${e.message}") |
|||
|
|||
try { |
|||
// 尝试修复nextCursor缺失问题 |
|||
if (e.message?.contains("nextCursor") == true) { |
|||
// 尝试手动添加缺失的nextCursor字段 |
|||
val jsonObj = JSONObject(data) |
|||
|
|||
// 只有在解析ListToolsResult时处理 |
|||
if (data.contains("\"tools\"")) { |
|||
Log.d(TAG, "尝试修复ListToolsResult缺少nextCursor字段的问题") |
|||
|
|||
// 手动解析result部分并添加nextCursor |
|||
val resultJson = try { |
|||
if (jsonObj.has("result")) { |
|||
val resultObj = jsonObj.getJSONObject("result") |
|||
if (!resultObj.has("nextCursor")) { |
|||
resultObj.put("nextCursor", "") |
|||
jsonObj.put("result", resultObj) |
|||
} |
|||
jsonObj.toString() |
|||
} else { |
|||
// 如果没有result字段,可能是其他类型的消息 |
|||
data |
|||
} |
|||
} catch (ex: Exception) { |
|||
Log.e(TAG, "手动修复JSON失败: ${ex.message}") |
|||
data |
|||
} |
|||
|
|||
// 重新尝试解析修复后的JSON |
|||
val fixedMessage = json.decodeFromString<JSONRPCMessage>(resultJson) |
|||
_onMessage(fixedMessage) |
|||
return |
|||
} |
|||
} |
|||
|
|||
// 通用错误处理 |
|||
Log.e(TAG, "无法解析消息,跳过: $data") |
|||
} catch (ex: Exception) { |
|||
Log.e(TAG, "备用解析也失败: ${ex.message}", ex) |
|||
// 不抛出异常,只记录错误 |
|||
_onError(e) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 启动传输层 |
|||
*/ |
|||
override suspend fun start() { |
|||
if (!initialized.compareAndSet(false, true)) { |
|||
Log.e(TAG, "传输层已经启动,不能重复启动") |
|||
error("CustomSseClientTransport already started!") |
|||
} |
|||
|
|||
// 解析URL和参数 |
|||
if (urlString != null) { |
|||
// 解析URL,提取主机部分、路径部分和查询参数 |
|||
val urlInfo = parseUrl(urlString) |
|||
hostPart = urlInfo.first |
|||
pathPart = urlInfo.second |
|||
queryParams = urlInfo.third |
|||
|
|||
// 存储不带查询参数的基础URL(主机+路径) |
|||
baseUrlWithoutParams = hostPart + pathPart |
|||
|
|||
Log.d(TAG, "原始URL: $urlString") |
|||
Log.d(TAG, "主机部分: $hostPart") |
|||
Log.d(TAG, "路径部分: $pathPart") |
|||
Log.d(TAG, "查询参数: $queryParams") |
|||
Log.d(TAG, "完整基础URL: $baseUrlWithoutParams") |
|||
} |
|||
|
|||
// 创建SSE会话 - 直接使用原始URL,不添加/sse后缀 |
|||
session = urlString?.let { |
|||
// 完整的SSE连接URL(主机部分+原始路径+查询参数) |
|||
val sseConnectUrl = if (queryParams.isNotEmpty()) { |
|||
// 如果路径已包含查询参数,就不再添加 |
|||
if (pathPart.contains("?")) { |
|||
"$hostPart$pathPart" |
|||
} else { |
|||
val queryString = queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } |
|||
"$hostPart$pathPart?$queryString" |
|||
} |
|||
} else { |
|||
"$hostPart$pathPart" |
|||
} |
|||
|
|||
Log.d(TAG, "SSE连接URL: $sseConnectUrl") |
|||
|
|||
client.sseSession( |
|||
urlString = sseConnectUrl, |
|||
reconnectionTime = reconnectionTime, |
|||
block = requestBuilder, |
|||
) |
|||
} ?: client.sseSession( |
|||
reconnectionTime = reconnectionTime, |
|||
block = requestBuilder, |
|||
) |
|||
|
|||
// 收集SSE事件 |
|||
collectEvents() |
|||
|
|||
// 等待endpoint就绪 |
|||
endpoint.await() |
|||
Log.d(TAG, "传输层启动完成,消息端点已就绪") |
|||
} |
|||
|
|||
/** |
|||
* 发送消息 |
|||
*/ |
|||
@OptIn(ExperimentalCoroutinesApi::class) |
|||
override suspend fun send(message: JSONRPCMessage) { |
|||
if (!endpoint.isCompleted) { |
|||
Log.e(TAG, "发送失败: 未连接") |
|||
error("Not connected") |
|||
} |
|||
|
|||
try { |
|||
val messageEndpoint = endpoint.getCompleted() |
|||
Log.d(TAG, "发送消息到: $messageEndpoint") |
|||
|
|||
// 序列化消息 |
|||
val jsonString = json.encodeToString(message) |
|||
|
|||
val response = client.post(messageEndpoint) { |
|||
headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) |
|||
setBody(jsonString) |
|||
} |
|||
|
|||
if (!response.status.isSuccess()) { |
|||
val text = response.bodyAsText() |
|||
Log.e(TAG, "发送消息失败: HTTP ${response.status}, $text") |
|||
error("Error POSTing to endpoint (HTTP ${response.status}): $text") |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "发送消息异常: ${e.message}", e) |
|||
_onError(e) |
|||
throw e |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 关闭传输层 |
|||
*/ |
|||
override suspend fun close() { |
|||
if (!initialized.get()) { |
|||
Log.e(TAG, "关闭失败: 传输层未初始化") |
|||
error("CustomSseClientTransport is not initialized!") |
|||
} |
|||
|
|||
session.cancel() |
|||
_onClose() |
|||
job?.cancelAndJoin() |
|||
Log.d(TAG, "传输层已关闭") |
|||
} |
|||
|
|||
/** |
|||
* 检查传输层是否已初始化 |
|||
*/ |
|||
fun isInitialized(): Boolean { |
|||
return initialized.get() |
|||
} |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
# |
|||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. |
|||
# Run `pod lib lint open_ai_service.podspec` to validate before publishing. |
|||
# |
|||
Pod::Spec.new do |s| |
|||
s.name = 'open_ai_service' |
|||
s.version = '0.0.1' |
|||
s.summary = 'OpenAI Service plugin' |
|||
s.description = <<-DESC |
|||
A Flutter plugin for OpenAI services. |
|||
DESC |
|||
s.homepage = 'http://example.com' |
|||
s.license = { :file => '../LICENSE' } |
|||
s.author = { 'Your Company' => 'email@example.com' } |
|||
s.source = { :path => '.' } |
|||
s.source_files = 'Classes/**/*' |
|||
s.dependency 'Flutter' |
|||
s.platform = :ios, '12.0' |
|||
|
|||
# Flutter.framework does not contain a i386 slice. |
|||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } |
|||
s.swift_version = '5.0' |
|||
end |
|||
@ -0,0 +1,62 @@ |
|||
--- |
|||
name: Bug report |
|||
about: Create a report to help us improve |
|||
title: '' |
|||
labels: 'bug' |
|||
assignees: '' |
|||
|
|||
--- |
|||
|
|||
**!! Attention !!** |
|||
FILL IN ALL SECTIONS OF THIS TEMPLATE OR YOUR ISSUE WILL BE CLOSED WITHOUT INVESTIGATION |
|||
This is a flutter package that wraps the native [iOS](https://github.com/spotify/ios-sdk) and [Android](https://github.com/spotify/android-sdk) Spotify "remote" SDKs as well as the [Spotify Web Playback SDK](https://developer.spotify.com/documentation/web-playback-sdk/) for web. Since it wraps the native SDKs it has the same features and limitations. Please use the search functionality in the native SDK repos to find corresponding issues before opening an issue here. If your issue is deriving from the native SDKs you should open an issue there and not in this repo. |
|||
|
|||
**Is this a how to question?** |
|||
Yes/No |
|||
If Yes, please use Stack Overflow instead. |
|||
|
|||
**Is this a native Spotify SDK issue?** |
|||
Yes/No/I don't know |
|||
If you answered "Yes" then your issue belongs to the native SDK repo and not here. |
|||
If you answered "I don't know" then you should visit the corresponding native SDK repo and make sure that this is not a native SDK issue. |
|||
|
|||
**Have you searched for existing issues?** |
|||
Yes/No |
|||
If you answered "No" you should that first before opening a new issue. |
|||
|
|||
**Are you using a Free or Premium Spotify account?** |
|||
Free/Premium |
|||
|
|||
**Are you aware of the limitations of Free Spotify accounts?** |
|||
Yes/No |
|||
If you answered "No" and you are using a Free account then you make sure that this is not behavior that intentionally limited by the native SDK. |
|||
|
|||
**Expected behavior** |
|||
A clear and concise description of what you expected to happen. |
|||
|
|||
**Describe the bug** |
|||
A clear and concise description of what happened instead. |
|||
|
|||
**Steps to Reproduce** |
|||
Steps to reproduce the behavior: |
|||
1. Go to '...' |
|||
2. Click on '....' |
|||
3. Scroll down to '....' |
|||
4. See error |
|||
|
|||
**Screenshots** |
|||
If applicable, add screenshots to help explain your problem. |
|||
|
|||
**Spotify_sdk Flutter package version** |
|||
Check you `pubspeck.lock` file to verify the exact version [e.g 2.1, commit ID] |
|||
|
|||
**Target Platform, Version & Device** |
|||
- OS & Version: [e.g. iOS 8.1, Android 9, Web] |
|||
- Browser & Version (if applicable) [e.g. Safari 15, Chrome 22] |
|||
- Device: [e.g. iPhone 6, Pixel 5, PC] |
|||
|
|||
**Development OS** |
|||
- OS: [e.g. Mac OS, Windows, Linux] |
|||
|
|||
**Additional context** |
|||
Add any other information about the problem here. |
|||
@ -0,0 +1 @@ |
|||
blank_issues_enabled: false |
|||
@ -0,0 +1,20 @@ |
|||
--- |
|||
name: Feature request |
|||
about: Suggest an idea for this project |
|||
title: '' |
|||
labels: 'enhancement' |
|||
assignees: '' |
|||
|
|||
--- |
|||
|
|||
**Is your feature request related to a problem? Please describe.** |
|||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] |
|||
|
|||
**Describe the solution you'd like** |
|||
A clear and concise description of what you want to happen. |
|||
|
|||
**Describe alternatives you've considered** |
|||
A clear and concise description of any alternative solutions or features you've considered. |
|||
|
|||
**Additional context** |
|||
Add any other context or screenshots about the feature request here. |
|||
@ -0,0 +1,41 @@ |
|||
name: Publish Flutter package to pub.dev |
|||
description: Publish your Flutter package to pub.dev |
|||
|
|||
inputs: |
|||
working-directory: |
|||
description: directory with-in the repository where the package is located (if not in the repository root) |
|||
required: false |
|||
|
|||
runs: |
|||
using: "composite" |
|||
steps: |
|||
- name: 📚 Git Checkout |
|||
uses: actions/checkout@v4 |
|||
- name: 🐦 Setup Flutter |
|||
uses: subosito/flutter-action@v2 |
|||
|
|||
- name: 🪪 Get Id Token |
|||
uses: actions/github-script@v6 |
|||
with: |
|||
script: | |
|||
let pub_token = await core.getIDToken('https://pub.dev') |
|||
core.exportVariable('PUB_TOKEN', pub_token) |
|||
|
|||
- name: 📢 Authenticate |
|||
shell: ${{ inputs.shell }} |
|||
run: flutter pub pub token add https://pub.dev --env-var PUB_TOKEN |
|||
|
|||
- name: 📦 Install dependencies |
|||
shell: ${{ inputs.shell }} |
|||
run: flutter pub get |
|||
working-directory: ${{ inputs.working-directory }} |
|||
|
|||
- name: 🌵 Dry Run |
|||
shell: ${{ inputs.shell }} |
|||
run: flutter pub publish --dry-run |
|||
working-directory: ${{ inputs.working-directory }} |
|||
|
|||
- name: 📢 Publish |
|||
shell: ${{ inputs.shell }} |
|||
run: flutter pub publish -f |
|||
working-directory: ${{ inputs.working-directory }} |
|||
@ -0,0 +1,42 @@ |
|||
# To get started with Dependabot version updates, you'll need to specify which |
|||
# package ecosystems to update and where the package manifests are located. |
|||
# Please see the documentation for all configuration options: |
|||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates |
|||
|
|||
version: 2 |
|||
updates: |
|||
- package-ecosystem: "pub" # See documentation for possible values |
|||
directory: "/" # Location of package manifests |
|||
schedule: |
|||
interval: "weekly" |
|||
commit-message: |
|||
prefix: "chore" |
|||
include: "scope" |
|||
groups: |
|||
spotify_sdk: |
|||
patterns: |
|||
- "*" |
|||
|
|||
- package-ecosystem: "pub" |
|||
directory: "/example" # Location of package manifests in the example directory |
|||
schedule: |
|||
interval: "weekly" |
|||
commit-message: |
|||
prefix: "chore" |
|||
include: "scope" |
|||
groups: |
|||
spotify_sdk_example: |
|||
patterns: |
|||
- "*" |
|||
|
|||
- package-ecosystem: "github-actions" |
|||
directory: "/" |
|||
schedule: |
|||
interval: "weekly" |
|||
commit-message: |
|||
prefix: "chore" |
|||
include: "scope" |
|||
groups: |
|||
github_actions: |
|||
patterns: |
|||
- "*" |
|||
@ -0,0 +1,16 @@ |
|||
#!/bin/bash |
|||
# Runs `pana . --no-warning` and verifies that the package score |
|||
# is greater or equal to the desired score. By default the desired score is |
|||
# a perfect score but it can be overridden by passing the desired score as an argument. |
|||
# |
|||
# Ensure the package has a score of at least a 100 |
|||
# `./verify_pub_score.sh 100` |
|||
# |
|||
# Ensure the package has a perfect score |
|||
# `./verify_pub_score.sh` |
|||
|
|||
PANA=$(pana . --no-warning); PANA_SCORE=$(echo $PANA | sed -n "s/.*Points: \([0-9]*\)\/\([0-9]*\)./\1\/\2/p") |
|||
echo "score: $PANA_SCORE" |
|||
IFS='/'; read -a SCORE_ARR <<< "$PANA_SCORE"; SCORE=SCORE_ARR[0]; TOTAL=SCORE_ARR[1] |
|||
if [ -z "$1" ]; then MINIMUM_SCORE=TOTAL; else MINIMUM_SCORE=$1; fi |
|||
if (( $SCORE < $MINIMUM_SCORE )); then echo "minimum score $MINIMUM_SCORE was not met!"; exit 1; fi |
|||
@ -0,0 +1,38 @@ |
|||
name: spotify_sdk |
|||
on: pull_request |
|||
|
|||
jobs: |
|||
build: |
|||
runs-on: ubuntu-latest |
|||
steps: |
|||
- uses: actions/checkout@v4 |
|||
- uses: subosito/flutter-action@v2 |
|||
with: |
|||
channel: 'stable' |
|||
- name: Install Dependencies |
|||
run: flutter packages get |
|||
- name: Format |
|||
run: dart format --set-exit-if-changed lib test example |
|||
- name: Analyze |
|||
run: flutter analyze lib test example |
|||
- name: Publish dry run |
|||
run: flutter pub publish --dry-run |
|||
- name: Build web version |
|||
run: | |
|||
cd example |
|||
flutter build web |
|||
|
|||
pana: |
|||
runs-on: ubuntu-latest |
|||
steps: |
|||
- uses: actions/checkout@v4 |
|||
- uses: subosito/flutter-action@v2 |
|||
|
|||
- name: Install Dependencies |
|||
run: | |
|||
flutter packages get |
|||
flutter pub global activate pana |
|||
sudo apt-get install webp |
|||
|
|||
- name: Verify Pub Score |
|||
run: ./.github/scripts/verify_pub_score.sh |
|||
@ -0,0 +1,34 @@ |
|||
name: Publish to pub.dev |
|||
on: |
|||
push: |
|||
tags: |
|||
- "[0-9]+.[0-9]+.[0-9]+*" # tag-pattern on pub.dev: '' |
|||
|
|||
jobs: |
|||
pana: |
|||
runs-on: ubuntu-latest |
|||
steps: |
|||
- uses: actions/checkout@v4 |
|||
- uses: subosito/flutter-action@v2 |
|||
|
|||
- name: Install Dependencies |
|||
run: | |
|||
flutter packages get |
|||
flutter pub global activate pana |
|||
sudo apt-get install webp |
|||
|
|||
- name: Verify Pub Score |
|||
run: ./.github/scripts/verify_pub_score.sh |
|||
|
|||
publish: |
|||
needs: pana |
|||
environment: 'pub.dev' |
|||
permissions: |
|||
id-token: write # Required for authentication using OIDC |
|||
runs-on: ubuntu-latest |
|||
steps: |
|||
- name: 📚 Git Checkout |
|||
uses: actions/checkout@v4 |
|||
with: |
|||
submodules: recursive |
|||
- uses: ./.github/actions/publish_flutter_package |
|||
@ -0,0 +1,50 @@ |
|||
.DS_Store |
|||
.atom/ |
|||
.idea/ |
|||
.vscode/ |
|||
|
|||
.packages |
|||
.pub/ |
|||
.dart_tool/ |
|||
pubspec.lock |
|||
flutter_export_environment.sh |
|||
|
|||
examples/all_plugins/pubspec.yaml |
|||
|
|||
Podfile |
|||
Podfile.lock |
|||
Pods/ |
|||
.symlinks/ |
|||
**/Flutter/App.framework/ |
|||
**/Flutter/Flutter.framework/ |
|||
**/Flutter/Generated.xcconfig |
|||
**/Flutter/flutter_assets/ |
|||
ServiceDefinitions.json |
|||
xcuserdata/ |
|||
*.xcworkspace |
|||
**/DerivedData/ |
|||
|
|||
local.properties |
|||
keystore.properties |
|||
.gradle/ |
|||
gradlew |
|||
gradlew.bat |
|||
gradle-wrapper.jar |
|||
.flutter-plugins-dependencies |
|||
*.iml |
|||
|
|||
GeneratedPluginRegistrant.h |
|||
GeneratedPluginRegistrant.m |
|||
GeneratedPluginRegistrant.java |
|||
build/ |
|||
.flutter-plugins |
|||
|
|||
.project |
|||
.classpath |
|||
.settings |
|||
*.sqlite |
|||
/.vs/spotify_sdk/v16/.suo |
|||
|
|||
SpotifyiOS.framework |
|||
**/doc/api/ |
|||
/ios/ios-sdk/ |
|||
@ -0,0 +1,10 @@ |
|||
# This file tracks properties of this Flutter project. |
|||
# Used by Flutter tool to assess capabilities and perform upgrades etc. |
|||
# |
|||
# This file should be version controlled and should not be manually edited. |
|||
|
|||
version: |
|||
revision: 856a90e67c9284124d44d2be6c785bacd3a1c772 |
|||
channel: beta |
|||
|
|||
project_type: plugin |
|||
@ -0,0 +1,184 @@ |
|||
## 3.0.2 |
|||
* chore: update deprications (#224) |
|||
|
|||
## 3.0.1 |
|||
* **BREAKING**: `isSpotifyAppActive` is removed from iOS and Android (#222) and therefore removed from the sdk |
|||
* chore: Bump native iOS SDK to 3.0.0 (#222) |
|||
|
|||
## 3.0.0 |
|||
* All changes from dev builds |
|||
* chore: Bump native iOS SDK to 1.2.4 (#208) |
|||
* chore: Update Spotify iOS SDK to v2.1.7 (#216) |
|||
* chore: Update android versions (#219) |
|||
|
|||
## 3.0.0-dev.3 |
|||
* Fix: prevent multiple iOS initializations (#203) |
|||
* Chore: Update libraries (#202) |
|||
* Feat: Automatic setup of the android integration of the spotify_sdk (#204) |
|||
|
|||
## 3.0.0-dev.2 |
|||
* Feat: add set podcastPlaybackSpeed and switchToLocalDevice for android (#160) |
|||
|
|||
## 3.0.0-dev.1 |
|||
* **BREAKING**:feat: update spotify.android:auth from 1.2.6 to 2.1.0 and spotify.app.remote from 0.7.2 to 0.8.0 |
|||
In the app/build.gradle add the following to the default config for auth to work as described [here](https://github.com/spotify/android-auth#integrating-the-library-into-your-project) |
|||
```groovy |
|||
defaultConfig { |
|||
manifestPlaceholders = [redirectSchemeName: "spotify-sdk", redirectHostName: "auth"] |
|||
... |
|||
} |
|||
``` |
|||
* Update android target sdk to 34 |
|||
|
|||
## 2.3.1 |
|||
* Fix: null album when getting advertisement on android (#179) |
|||
* Fix: queue endpoint for web (#167) |
|||
* Update license to apache-2.0 |
|||
|
|||
## 2.3.0 |
|||
* iOS, Android and Web |
|||
* getAuthenticationToken is deprecated in favor of getAccessToken |
|||
* Android: |
|||
* `spotify-auth` SDK is now retrieved via Maven Central instead of being sourced from an AAR file |
|||
* Steps to remove the `spotify-auth` SDK: |
|||
* android/settings.gradle -> remove `':spotify-auth'` |
|||
* android/spotify-auth/build.gradle -> remove file |
|||
* android/spotify-auth/spotify-auth-release-x.x.x.aar -> remove file |
|||
|
|||
## 2.2.0 |
|||
* iOS and Android |
|||
* adds SkipToIndex and getCapabilities |
|||
* adds isSpotifyAppActive |
|||
* adds getLibraryState on iOS |
|||
* Switch to native Spotify iOS XCFramework |
|||
* fixes android json mapping error on release builds |
|||
* fixes skipPrevious on iOS |
|||
* web |
|||
* adds use accessToken which implies reusing _spotifyToken from getAuthenticationToken |
|||
* adds optional tokenSwapURL and tokenRefreshURL for Authorization Code (without PKCE) |
|||
* adds support for track relinking |
|||
* fixes browser autoplay error |
|||
* updates libraries |
|||
* updates documentation |
|||
|
|||
## 2.1.0 |
|||
* BREAKING: setShuffle now does not expect a named argument |
|||
* fixes accessToken being ignored in connectToSpotify() |
|||
* exposes spotifyURI on connectToSpotify() method |
|||
* fixes the web implementation not using the authentication scopes supplied by the user |
|||
* fix a crash when calling subscribeToConnectionStatus |
|||
* fix a bug where the webplayer would not dispose |
|||
* add null safety to example app |
|||
|
|||
## 2.0.0 |
|||
* BREAKING: opt into null safety |
|||
* upgrade Dart SDK constraints to >=2.12.0-0 <3.0.0 |
|||
|
|||
## 1.0.2 |
|||
* fixes image dimension exception on getImage |
|||
|
|||
## 1.0.1 |
|||
* improves error reporting on iOS |
|||
* supports adding additional scopes for iOS |
|||
* offers option to start radio while connecting to Spotify |
|||
* updates packages |
|||
|
|||
## 1.0.0 |
|||
* __adds support for iOS__ 🎉 (thanks [fotiDim](https://github.com/fotiDim)) |
|||
* adapts to breaking changes in the spotify android sdk (thanks [itsMatoosh](https://github.com/itsMatoosh)) |
|||
* implements PKCE auth flow for the web implementation (thanks [itsMatoosh](https://github.com/itsMatoosh)) |
|||
* adds the `setShuffle()` and `setRepeatMode()` APIs for Android (thanks [Joran-Dob](https://github.com/Joran-Dob)) |
|||
* renames logout to disconnect |
|||
* adds `accessToken` as an optional parameter to `connectToSpotifyRemote()` (only supported on iOS) |
|||
* some minor bug fixing |
|||
|
|||
## 0.5.0 |
|||
* adds support for web (thanks [itsMatoosh](https://github.com/itsMatoosh)) |
|||
* adds custom scopes for the web api (thanks [arnav-sh](https://github.com/arnav-sh)) |
|||
* adds logout functionality for android |
|||
* moved from [pedantic](https://pub.dev/packages/pedantic) to [lint](https://pub.dev/packages/lint) for static analyses |
|||
* some minor bug fixing |
|||
|
|||
## 0.3.4 |
|||
|
|||
* adds handling of unexpected disconnects from Spotify via subscribeConnectionStatus()-Stream(thanks [itsMatoosh](https://github.com/itsMatoosh)) |
|||
* adds usage of .env file for the example project |
|||
* fixes some minor error message issues |
|||
|
|||
## 0.3.3 |
|||
|
|||
* adds getImage to get an Image from any spotifyURI (thanks [eddwhite](https://github.com/eddwhite)) |
|||
* fixes some minor issues |
|||
* raised dart-sdk version to 2.7.0 |
|||
|
|||
## 0.3.2 |
|||
|
|||
* fixes compatibility with spotify-auth dependency above version 1.2.0 (thanks [itsMatoosh](https://github.com/itsMatoosh)) |
|||
* spotify introduced some breaking changes: Rename classes from Authentication<code>ClassName</code> to Authorization<code>ClassName</code> |
|||
|
|||
## 0.3.1 |
|||
|
|||
* fixes wrong links and incorrect docs |
|||
|
|||
## 0.3.0 |
|||
|
|||
* android user api implementation finished |
|||
* remove from library |
|||
* subscribe to user status |
|||
* subscribe to capabilities |
|||
* get librarystate |
|||
* updated package references |
|||
|
|||
## 0.2.0 |
|||
|
|||
* android player api implementation subscriptions finished |
|||
* subscribe to playerContext and playerState now possible |
|||
* added more instructions for android |
|||
* code refactoring |
|||
* extended the example |
|||
* extended the documentation |
|||
|
|||
## 0.1.0 |
|||
|
|||
* android player api implementation finished |
|||
* error handling finished |
|||
* setup instructions for android finished |
|||
* naming for modules finished |
|||
|
|||
## 0.0.4 |
|||
|
|||
* added instructions for android |
|||
* fixed naming for modules |
|||
|
|||
## 0.0.3 |
|||
|
|||
* added the following implementations for android: |
|||
* getCrossfadeState |
|||
* getPlayerState |
|||
* general refactoring of the native android implementation |
|||
* adds documentation to all methods |
|||
* adds json_annotation: ^3.0.0, build_runner: ^1.0.0, json_serializable: ^3.2.0 to make use of some json serializing functionality for the crossfadeState and PlayerState |
|||
|
|||
## 0.0.2 |
|||
|
|||
* added the following implementations for android: |
|||
* resume |
|||
* skip next |
|||
* skip previous |
|||
* seek to |
|||
* seek to relative |
|||
* splitted remote authorization and token retrieval in two seperate functions |
|||
* implemented the example project |
|||
* added logger package for prettier logs |
|||
|
|||
## 0.0.1 |
|||
|
|||
* Added latests spotify-app-remote (v7.0.0) and spotify-auth (v1.1.0) from <https://github.com/spotify/android-sdk/releases> |
|||
* finished android native implementation for |
|||
* authorization with token response |
|||
* play |
|||
* pause |
|||
* queue |
|||
* toggleShuffle |
|||
* toggleRepeat |
|||
* addToLibrary |
|||
@ -0,0 +1,76 @@ |
|||
# Contributor Covenant Code of Conduct |
|||
|
|||
## Our Pledge |
|||
|
|||
In the interest of fostering an open and welcoming environment, we as |
|||
contributors and maintainers pledge to making participation in our project and |
|||
our community a harassment-free experience for everyone, regardless of age, body |
|||
size, disability, ethnicity, sex characteristics, gender identity and expression, |
|||
level of experience, education, socio-economic status, nationality, personal |
|||
appearance, race, religion, or sexual identity and orientation. |
|||
|
|||
## Our Standards |
|||
|
|||
Examples of behavior that contributes to creating a positive environment |
|||
include: |
|||
|
|||
* Using welcoming and inclusive language |
|||
* Being respectful of differing viewpoints and experiences |
|||
* Gracefully accepting constructive criticism |
|||
* Focusing on what is best for the community |
|||
* Showing empathy towards other community members |
|||
|
|||
Examples of unacceptable behavior by participants include: |
|||
|
|||
* The use of sexualized language or imagery and unwelcome sexual attention or |
|||
advances |
|||
* Trolling, insulting/derogatory comments, and personal or political attacks |
|||
* Public or private harassment |
|||
* Publishing others' private information, such as a physical or electronic |
|||
address, without explicit permission |
|||
* Other conduct which could reasonably be considered inappropriate in a |
|||
professional setting |
|||
|
|||
## Our Responsibilities |
|||
|
|||
Project maintainers are responsible for clarifying the standards of acceptable |
|||
behavior and are expected to take appropriate and fair corrective action in |
|||
response to any instances of unacceptable behavior. |
|||
|
|||
Project maintainers have the right and responsibility to remove, edit, or |
|||
reject comments, commits, code, wiki edits, issues, and other contributions |
|||
that are not aligned to this Code of Conduct, or to ban temporarily or |
|||
permanently any contributor for other behaviors that they deem inappropriate, |
|||
threatening, offensive, or harmful. |
|||
|
|||
## Scope |
|||
|
|||
This Code of Conduct applies both within project spaces and in public spaces |
|||
when an individual is representing the project or its community. Examples of |
|||
representing a project or community include using an official project e-mail |
|||
address, posting via an official social media account, or acting as an appointed |
|||
representative at an online or offline event. Representation of a project may be |
|||
further defined and clarified by project maintainers. |
|||
|
|||
## Enforcement |
|||
|
|||
Instances of abusive, harassing, or otherwise unacceptable behavior may be |
|||
reported by contacting the project team at tobi@minimalme.de. All |
|||
complaints will be reviewed and investigated and will result in a response that |
|||
is deemed necessary and appropriate to the circumstances. The project team is |
|||
obligated to maintain confidentiality with regard to the reporter of an incident. |
|||
Further details of specific enforcement policies may be posted separately. |
|||
|
|||
Project maintainers who do not follow or enforce the Code of Conduct in good |
|||
faith may face temporary or permanent repercussions as determined by other |
|||
members of the project's leadership. |
|||
|
|||
## Attribution |
|||
|
|||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, |
|||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html |
|||
|
|||
[homepage]: https://www.contributor-covenant.org |
|||
|
|||
For answers to common questions about this code of conduct, see |
|||
https://www.contributor-covenant.org/faq |
|||
@ -0,0 +1,48 @@ |
|||
# How you can contribute to spotify_sdk |
|||
|
|||
Hi nice to see you here. 🙌🎉 |
|||
|
|||
Thank you for taking the time to contribute to this package ! 👍 |
|||
|
|||
This document should be a set of guidelines that can help you to create a meaningful |
|||
pull request or issue. Don't see this as any kind of rules, this is a living document, |
|||
so if you have an idea how to optimize this propose changes in a pull request. |
|||
|
|||
## How do I propose a change? |
|||
|
|||
Changes to the public API should be done via an issue. So we can discuss the proposed |
|||
changes before you put any work into them. |
|||
|
|||
If you are fixing a bug, you can just submit a pull request. We do recommend filing an issue |
|||
as well to get an overview what it is that you are fixing. |
|||
This is helpful in case we don’t accept that specific fix but want to keep |
|||
track of the issue. |
|||
|
|||
## What do I do before creating a pull request |
|||
|
|||
1. Fork the repository and branch out of `master`, prefixing your branch's name with `feature/`, `bug/` or `task/` to indicate the scope of the PR. |
|||
1. Install all dependencies (`flutter packages get` or `pub get`) |
|||
1. Ensure you have a meaningful PR name using the [imperative mood](https://chris.beams.io/posts/git-commit/#imperative) as all your commits will be squashed upon merge and the PR's name will be used as the merge commit's message. |
|||
1. If the PR can be broken down into multiple meaningful PRs please do so so that it is easier to review. |
|||
1. If you’ve fixed a bug or added code that should be tested, add tests! |
|||
1. If you've changed the public API, make sure to update/add documentation (for now that is the [Readme](README.md)) |
|||
1. If you've made breaking changes give us a heads up in the pull request. Try to provide a compatibility path for the deprecated APIs and if necessary provide migration instructions in the [Readme](README.md). |
|||
1. Format your code (`dartfmt -w .`) |
|||
1. Analyze your code (`flutter analyze`) |
|||
1. Create the Pull Request |
|||
1. Verify that all status checks are passing |
|||
|
|||
While the prerequisites above must be satisfied prior to having your |
|||
pull request reviewed, the reviewer(s) may ask you to complete additional |
|||
design work, tests, or other changes before your pull request can be ultimately |
|||
accepted. |
|||
|
|||
## Getting in Touch |
|||
|
|||
If you want to just ask a question or get feedback on an idea you can post it |
|||
on [Slack](https://join.slack.com/t/spotifysdk/shared_invite/zt-gibgpkf9-o2ZEJBMPqXNTvqqPONYUQA). |
|||
|
|||
## License |
|||
|
|||
By contributing to spotify_sdk, you agree that your contributions will be licensed |
|||
under its [MIT license](LICENSE). |
|||
@ -0,0 +1,201 @@ |
|||
Apache License |
|||
Version 2.0, January 2004 |
|||
http://www.apache.org/licenses/ |
|||
|
|||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION |
|||
|
|||
1. Definitions. |
|||
|
|||
"License" shall mean the terms and conditions for use, reproduction, |
|||
and distribution as defined by Sections 1 through 9 of this document. |
|||
|
|||
"Licensor" shall mean the copyright owner or entity authorized by |
|||
the copyright owner that is granting the License. |
|||
|
|||
"Legal Entity" shall mean the union of the acting entity and all |
|||
other entities that control, are controlled by, or are under common |
|||
control with that entity. For the purposes of this definition, |
|||
"control" means (i) the power, direct or indirect, to cause the |
|||
direction or management of such entity, whether by contract or |
|||
otherwise, or (ii) ownership of fifty percent (50%) or more of the |
|||
outstanding shares, or (iii) beneficial ownership of such entity. |
|||
|
|||
"You" (or "Your") shall mean an individual or Legal Entity |
|||
exercising permissions granted by this License. |
|||
|
|||
"Source" form shall mean the preferred form for making modifications, |
|||
including but not limited to software source code, documentation |
|||
source, and configuration files. |
|||
|
|||
"Object" form shall mean any form resulting from mechanical |
|||
transformation or translation of a Source form, including but |
|||
not limited to compiled object code, generated documentation, |
|||
and conversions to other media types. |
|||
|
|||
"Work" shall mean the work of authorship, whether in Source or |
|||
Object form, made available under the License, as indicated by a |
|||
copyright notice that is included in or attached to the work |
|||
(an example is provided in the Appendix below). |
|||
|
|||
"Derivative Works" shall mean any work, whether in Source or Object |
|||
form, that is based on (or derived from) the Work and for which the |
|||
editorial revisions, annotations, elaborations, or other modifications |
|||
represent, as a whole, an original work of authorship. For the purposes |
|||
of this License, Derivative Works shall not include works that remain |
|||
separable from, or merely link (or bind by name) to the interfaces of, |
|||
the Work and Derivative Works thereof. |
|||
|
|||
"Contribution" shall mean any work of authorship, including |
|||
the original version of the Work and any modifications or additions |
|||
to that Work or Derivative Works thereof, that is intentionally |
|||
submitted to Licensor for inclusion in the Work by the copyright owner |
|||
or by an individual or Legal Entity authorized to submit on behalf of |
|||
the copyright owner. For the purposes of this definition, "submitted" |
|||
means any form of electronic, verbal, or written communication sent |
|||
to the Licensor or its representatives, including but not limited to |
|||
communication on electronic mailing lists, source code control systems, |
|||
and issue tracking systems that are managed by, or on behalf of, the |
|||
Licensor for the purpose of discussing and improving the Work, but |
|||
excluding communication that is conspicuously marked or otherwise |
|||
designated in writing by the copyright owner as "Not a Contribution." |
|||
|
|||
"Contributor" shall mean Licensor and any individual or Legal Entity |
|||
on behalf of whom a Contribution has been received by Licensor and |
|||
subsequently incorporated within the Work. |
|||
|
|||
2. Grant of Copyright License. Subject to the terms and conditions of |
|||
this License, each Contributor hereby grants to You a perpetual, |
|||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable |
|||
copyright license to reproduce, prepare Derivative Works of, |
|||
publicly display, publicly perform, sublicense, and distribute the |
|||
Work and such Derivative Works in Source or Object form. |
|||
|
|||
3. Grant of Patent License. Subject to the terms and conditions of |
|||
this License, each Contributor hereby grants to You a perpetual, |
|||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable |
|||
(except as stated in this section) patent license to make, have made, |
|||
use, offer to sell, sell, import, and otherwise transfer the Work, |
|||
where such license applies only to those patent claims licensable |
|||
by such Contributor that are necessarily infringed by their |
|||
Contribution(s) alone or by combination of their Contribution(s) |
|||
with the Work to which such Contribution(s) was submitted. If You |
|||
institute patent litigation against any entity (including a |
|||
cross-claim or counterclaim in a lawsuit) alleging that the Work |
|||
or a Contribution incorporated within the Work constitutes direct |
|||
or contributory patent infringement, then any patent licenses |
|||
granted to You under this License for that Work shall terminate |
|||
as of the date such litigation is filed. |
|||
|
|||
4. Redistribution. You may reproduce and distribute copies of the |
|||
Work or Derivative Works thereof in any medium, with or without |
|||
modifications, and in Source or Object form, provided that You |
|||
meet the following conditions: |
|||
|
|||
(a) You must give any other recipients of the Work or |
|||
Derivative Works a copy of this License; and |
|||
|
|||
(b) You must cause any modified files to carry prominent notices |
|||
stating that You changed the files; and |
|||
|
|||
(c) You must retain, in the Source form of any Derivative Works |
|||
that You distribute, all copyright, patent, trademark, and |
|||
attribution notices from the Source form of the Work, |
|||
excluding those notices that do not pertain to any part of |
|||
the Derivative Works; and |
|||
|
|||
(d) If the Work includes a "NOTICE" text file as part of its |
|||
distribution, then any Derivative Works that You distribute must |
|||
include a readable copy of the attribution notices contained |
|||
within such NOTICE file, excluding those notices that do not |
|||
pertain to any part of the Derivative Works, in at least one |
|||
of the following places: within a NOTICE text file distributed |
|||
as part of the Derivative Works; within the Source form or |
|||
documentation, if provided along with the Derivative Works; or, |
|||
within a display generated by the Derivative Works, if and |
|||
wherever such third-party notices normally appear. The contents |
|||
of the NOTICE file are for informational purposes only and |
|||
do not modify the License. You may add Your own attribution |
|||
notices within Derivative Works that You distribute, alongside |
|||
or as an addendum to the NOTICE text from the Work, provided |
|||
that such additional attribution notices cannot be construed |
|||
as modifying the License. |
|||
|
|||
You may add Your own copyright statement to Your modifications and |
|||
may provide additional or different license terms and conditions |
|||
for use, reproduction, or distribution of Your modifications, or |
|||
for any such Derivative Works as a whole, provided Your use, |
|||
reproduction, and distribution of the Work otherwise complies with |
|||
the conditions stated in this License. |
|||
|
|||
5. Submission of Contributions. Unless You explicitly state otherwise, |
|||
any Contribution intentionally submitted for inclusion in the Work |
|||
by You to the Licensor shall be under the terms and conditions of |
|||
this License, without any additional terms or conditions. |
|||
Notwithstanding the above, nothing herein shall supersede or modify |
|||
the terms of any separate license agreement you may have executed |
|||
with Licensor regarding such Contributions. |
|||
|
|||
6. Trademarks. This License does not grant permission to use the trade |
|||
names, trademarks, service marks, or product names of the Licensor, |
|||
except as required for reasonable and customary use in describing the |
|||
origin of the Work and reproducing the content of the NOTICE file. |
|||
|
|||
7. Disclaimer of Warranty. Unless required by applicable law or |
|||
agreed to in writing, Licensor provides the Work (and each |
|||
Contributor provides its Contributions) on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or |
|||
implied, including, without limitation, any warranties or conditions |
|||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A |
|||
PARTICULAR PURPOSE. You are solely responsible for determining the |
|||
appropriateness of using or redistributing the Work and assume any |
|||
risks associated with Your exercise of permissions under this License. |
|||
|
|||
8. Limitation of Liability. In no event and under no legal theory, |
|||
whether in tort (including negligence), contract, or otherwise, |
|||
unless required by applicable law (such as deliberate and grossly |
|||
negligent acts) or agreed to in writing, shall any Contributor be |
|||
liable to You for damages, including any direct, indirect, special, |
|||
incidental, or consequential damages of any character arising as a |
|||
result of this License or out of the use or inability to use the |
|||
Work (including but not limited to damages for loss of goodwill, |
|||
work stoppage, computer failure or malfunction, or any and all |
|||
other commercial damages or losses), even if such Contributor |
|||
has been advised of the possibility of such damages. |
|||
|
|||
9. Accepting Warranty or Additional Liability. While redistributing |
|||
the Work or Derivative Works thereof, You may choose to offer, |
|||
and charge a fee for, acceptance of support, warranty, indemnity, |
|||
or other liability obligations and/or rights consistent with this |
|||
License. However, in accepting such obligations, You may act only |
|||
on Your own behalf and on Your sole responsibility, not on behalf |
|||
of any other Contributor, and only if You agree to indemnify, |
|||
defend, and hold each Contributor harmless for any liability |
|||
incurred by, or claims asserted against, such Contributor by reason |
|||
of your accepting any such warranty or additional liability. |
|||
|
|||
END OF TERMS AND CONDITIONS |
|||
|
|||
APPENDIX: How to apply the Apache License to your work. |
|||
|
|||
To apply the Apache License to your work, attach the following |
|||
boilerplate notice, with the fields enclosed by brackets "[]" |
|||
replaced with your own identifying information. (Don't include |
|||
the brackets!) The text should be enclosed in the appropriate |
|||
comment syntax for the file format. We also recommend that a |
|||
file or class name and description of purpose be included on the |
|||
same "printed page" as the copyright notice for easier |
|||
identification within third-party archives. |
|||
|
|||
Copyright [yyyy] [name of copyright owner] |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
@ -0,0 +1,257 @@ |
|||
# spotify_sdk |
|||
|
|||
<p align="center"> |
|||
<a href="https://pub.dev/packages/spotify_sdk"><img src="https://img.shields.io/badge/pub-3.0.2-green" alt="build"></a> |
|||
<a href="https://github.com/brim-borium/spotify_sdk"><img src="https://img.shields.io/github/stars/brim-borium/spotify_sdk?color=deeppink" alt="build"></a> |
|||
<a href="https://github.com/brim-borium/spotify_sdk/blob/main/LICENSE"><img src="https://img.shields.io/github/license/brim-borium/spotify_sdk?color=blue" alt="build"></a> |
|||
</p> |
|||
|
|||
--- |
|||
|
|||
## Description |
|||
|
|||
This is a flutter package that wraps the native [iOS](https://github.com/spotify/ios-sdk) and [Android](https://github.com/spotify/android-sdk) Spotify "remote" SDKs as well as the [Spotify Web Playback SDK](https://developer.spotify.com/documentation/web-playback-sdk/) for web. Since it wraps the native SDKs it has the same features and limitations. |
|||
|
|||
## Setup |
|||
|
|||
### Android |
|||
|
|||
This package is using both the spotify-app-remote sdk and spotify-auth library. The auth library is needed to get the access token to work with the web api. |
|||
|
|||
From the [Spotify Android SDK Quick Start](https://developer.spotify.com/documentation/android/quick-start/). You need two things: |
|||
|
|||
1. Register your app in the [spotify developer portal](https://developer.spotify.com/dashboard/). You also need to create a sha-1 fingerprint and add this and your package name to the app settings on the dashboard as well as a redirect url. |
|||
2. Follow the steps below for either [Option A: Auto setup](#option-a-auto-setup) or [Option B: Manual setup](#option-b-manual-setup-instructions-for-android-studio-42). |
|||
|
|||
#### Option A: Auto setup |
|||
|
|||
Use the provided setup script to automatically download the latest version of the spotify-app-remote sdk from GitHub and setup the gradle files inside your android project. Run the following command in the root folder of your flutter project. |
|||
|
|||
```bash |
|||
dart run spotify_sdk:android_setup |
|||
``` |
|||
|
|||
Use the `--help` flag to see all available options. |
|||
|
|||
#### Option B: Manual setup, instructions for Android Studio 4.2+ |
|||
|
|||
Download the current [Spotify Android SDK](https://github.com/spotify/android-sdk/releases). Here you need the `spotify-app-remote-\*.aar``. |
|||
|
|||
After you are all setup you need to add the SDKs `\*.aar`` file to your Android Project as Module. See the [Spotify Android SDK Quick Start](https://developer.spotify.com/documentation/android/quick-start/) for detailed information. |
|||
|
|||
Since Android Studio 4.2 you need to manually perform these steps in order to add .jar/.aar files: |
|||
|
|||
1. Open the android folder of your flutter project as an Android Studio project |
|||
2. In the android root folder create a single folder for `spotify-app-remote`, place the corresponding aar file and create an empty build.gradle file, like on the screenshot below: |
|||
 |
|||
|
|||
3. Content of the `spotify-app-remote/build.gradle` file: |
|||
|
|||
```groovy |
|||
configurations.maybeCreate("default") |
|||
artifacts.add("default", file('spotify-app-remote-release-x.x.x.aar')) |
|||
``` |
|||
|
|||
4. In the android root folder find `settings.gradle` file, open it and add the following line at the top of the file: |
|||
|
|||
```groovy |
|||
include ':spotify-app-remote' |
|||
``` |
|||
|
|||
5. In the app/build.gradle add the following to the default config |
|||
|
|||
```groovy |
|||
defaultConfig { |
|||
manifestPlaceholders = [redirectSchemeName: "spotify-sdk", redirectHostName: "auth"] |
|||
... |
|||
} |
|||
``` |
|||
|
|||
### iOS |
|||
|
|||
Register your app in the [spotify developer portal](https://developer.spotify.com/dashboard/). You also need to register your Bundle ID as well as a Redirect URI. |
|||
|
|||
Follow the instructions in the section `Setup the iOS SDK` of [Spotify iOS SDK Quick Start](https://developer.spotify.com/documentation/ios/quick-start/). |
|||
|
|||
### Web |
|||
|
|||
1. Register your app in the [spotify developer portal](https://developer.spotify.com/dashboard/). You need to provide a redirect URL which points to a dedicated page on a website you own. |
|||
|
|||
2. Paste the following onto the webpage, which you linked to in your redirect URL. |
|||
```html |
|||
<!DOCTYPE html> |
|||
<html> |
|||
<head> |
|||
<title>Authenticating Spotify</title> |
|||
</head> |
|||
<body> |
|||
<p>Please wait while we authenticate Spotify...</p> |
|||
<script type="text/javascript"> |
|||
if(window.opener) { |
|||
window.opener.postMessage('?' + window.location.href.split('?')[1], "*"); |
|||
} else { |
|||
window.close(); |
|||
} |
|||
</script> |
|||
</body> |
|||
</html> |
|||
``` |
|||
|
|||
3. Optionally add this to your Flutter app web/index.html to avoid a Javascript `TypeError: r.__extends is not a function` error in development mode. |
|||
|
|||
```html |
|||
<script src="https://sdk.scdn.co/spotify-player.js"></script> |
|||
<script> |
|||
window.onSpotifyWebPlaybackSDKReady = (evt) => {}; |
|||
</script> |
|||
``` |
|||
|
|||
[You need Spotify Premium to access the Web SDK.](https://developer.spotify.com/documentation/web-playback-sdk/quick-start/) |
|||
|
|||
## Usage |
|||
|
|||
To start using this package first import it in your Dart file. |
|||
|
|||
```dart |
|||
import 'package:spotify_sdk/spotify_sdk.dart'; |
|||
``` |
|||
|
|||
To connect to the Spotify app you can call connectToSpotifyRemote(...) or getAccessToken(...). In both of these methods you need the client id, which you will find in the Spotify developer dashboard and the redirect url you set there for that specific client. |
|||
|
|||
```dart |
|||
await SpotifySdk.connectToSpotifyRemote(clientId: "", redirectUrl: "") |
|||
``` |
|||
|
|||
Subscribe to `PlayerState` or `PlayerContext` streams only after connecting successfully |
|||
|
|||
```dart |
|||
SpotifySdk.subscribePlayerState(); |
|||
SpotifySdk.subscribePlayerContext(); |
|||
``` |
|||
|
|||
If you want to use the web api as well you have to use this method to get the access token. |
|||
You can specify multiple scopes by separating them with a comma "," as shown below. For more information on scopes you can refer to [Spotify Authorization Scopes Guide](https://developer.spotify.com/documentation/general/guides/authorization/scopes/) |
|||
|
|||
```dart |
|||
final accessToken = await SpotifySdk.getAccessToken(clientId: "", redirectUrl: "", scope: "app-remote-control,user-modify-playback-state,playlist-read-private"); |
|||
``` |
|||
|
|||
On Web you can use the token that you get from `getAccessToken(...)` and then pass it to `connectToSpotifyRemote(...)`. This will avoid having to send user through two Spotify OAuth prompts. You should not persist this token, nor supply a different token, because the refresh token is only set interally by `getAccessToken` or `connectToSpotifyRemote`. |
|||
|
|||
On iOS you can store the token that you get from `getAccessToken(...)` and then pass it to `connectToSpotifyRemote(...)` during the next session. This will avoid having to switch to the Spotify app for establishing the connection. This library does not handle storing the token. It is up to you to persist it wherever you see fit. Keep in mind that this feature is currently quite buggy in the native iOS SDK and has many side effects like random disconnections. Proceed with caution. |
|||
|
|||
On iOS Spotify starts playing music when attempting connection. This is a default behavior and there is no official way to prevent this with the currently supported authentication flows. You have the option to pass a Spotify URI upon connection or set it to a blank string to play the last played song. There is an undocumented workaround if you don't want music to start playing which is to pass an invalid Spotify URI instead. This is not officially supported by the Spotify SDK or this library and it can fail or stop working at any time! |
|||
|
|||
Have a look [in the example](example/lib/main.dart) for detailed insights on how you can use this package. |
|||
|
|||
### Token Swap |
|||
|
|||
You can optionally specify "token swap" URLs to manage tokens with a backend service that protects your OAuth client secret. For more information refer to the [Spotify Token Swap and Refresh Guide](https://developer.spotify.com/documentation/ios/guides/token-swap-and-refresh/) |
|||
|
|||
```dart |
|||
SpotifySdkPlugin.tokenSwapURL = 'https://example.com/api/spotify/token'; |
|||
SpotifySdkPlugin.tokenRefreshURL = 'https://example.com/api/spotify/refresh'; |
|||
```` |
|||
|
|||
On web, this package will perform an Authorization Code (without PKCE) flow, then exchange the code and refresh the token with a backend service you run at the URLs provided. |
|||
|
|||
Token Swap is for now "web only". While the iOS SDK also supports the "token swap", this flow is not yet supported. |
|||
|
|||
### Api |
|||
|
|||
#### Connecting/Authenticating |
|||
|
|||
| Function | Description| Android | iOS | Web | |
|||
|---|---|---|---|---| |
|||
| connectToSpotifyRemote | Connects the App to Spotify | ✔ | ✔ | ✔ | |
|||
| getAccessToken | Gets the Access Token that you can use to work with the [Web Api](https://developer.spotify.com/documentation/web-api/) | ✔ | ✔ | ✔ | |
|||
| disconnect | Disconnects the app connection | ✔ | ✔ | ✔ | |
|||
| subscribeConnectionStatus | Subscribes to the current player state. | ✔ | ✔ | 🚧 | |
|||
|
|||
#### Player Api |
|||
|
|||
The playerApi as described [here](https://spotify.github.io/android-sdk/app-remote-lib/docs/com/spotify/android/appremote/api/PlayerApi.html). |
|||
|
|||
| Function | Description | Android | iOS | Web | |
|||
|-------------------------|---|--|---|---| |
|||
| getCrossfadeState | Gets the current crossfade state | ✔ | ✔ | ❌ | |
|||
| getPlayerState | Gets the current player state |✔ | ✔ | ✔ | |
|||
| pause | Pauses the current track |✔ | ✔ | ✔ | |
|||
| play | Plays the given spotifyUri |✔ | ✔ | ✔ | |
|||
| playWithStreamType | Play the given Spotify uri with specific behaviour for that streamtype | 🚧 | 🚧 | 🚧 | |
|||
| queue | Queues given spotifyUri |✔ | ✔ | ✔ | |
|||
| resume | Resumes the current track |✔ | ✔ | ✔ | |
|||
| seekTo | Seeks the current track to the given position in milliseconds | ✔ | ✔ | 🚧 | |
|||
| seekToRelativePosition | Adds to the current position of the track the given milliseconds | ✔ | ❌ | 🚧 | |
|||
| setPodcastPlaybackSpeed | Set playback speed for Podcast | ✔ | 🚧 | 🚧 | |
|||
| setRepeatMode | Set the repeat mode | ✔ | ✔ | ✔ | |
|||
| setShuffle | Set the shuffle mode | ✔ | ✔ | ✔ | |
|||
| skipNext | Skips to next track | ✔ | ✔ | ✔ | |
|||
| skipPrevious | Skips to previous track |✔ | ✔ | ✔ | |
|||
| skipToIndex | Skips to track at specified index in album or playlist |✔ | ✔ | 🚧 | |
|||
| subscribePlayerContext | Subscribes to the current player context | ✔ | ✔ | ✔ | |
|||
| subscribePlayerState | Subscribes to the current player state | ✔ | ✔ | ✔ | |
|||
| toggleRepeat | Cycles through the repeat modes | ✔ | ✔ | ❌ | |
|||
| toggleShuffle | Cycles through the shuffle modes | ✔ | ❌ | ❌ | |
|||
|
|||
On Web, an automatic call to play may not work due to media activation policies which send an error: "Authentication Error: Browser prevented autoplay due to lack of interaction". This error is ignored by the SDK so you can still present a button for the user to click to `play` or `resume` to start playback. See the [Web SDK Troubleshooting guide](https://developer.spotify.com/documentation/web-playback-sdk/reference/#troubleshooting) for more details. |
|||
|
|||
#### Images Api |
|||
|
|||
The imagesApi as described [here](https://spotify.github.io/android-sdk/app-remote-lib/docs/com/spotify/android/appremote/api/ImagesApi.html). |
|||
|
|||
| Function | Description| Android | iOS | Web | |
|||
|---|---|---|---|---| |
|||
| getImage | Get the image from the given spotifyUri | ✔ | ✔ | 🚧 | |
|||
|
|||
#### User Api |
|||
|
|||
The userApi as described [here](https://spotify.github.io/android-sdk/app-remote-lib/docs/com/spotify/android/appremote/api/UserApi.html). |
|||
|
|||
| Function | Description| Android | iOS | Web | |
|||
|---|---|---|---|---| |
|||
| addToLibrary | Adds the given spotifyUri to the users library | ✔ | ✔ | 🚧 | |
|||
| getCapabilities | Gets the current users capabilities | ✔ | ✔ | 🚧 | |
|||
| getLibraryState | Gets the current library state | ✔ | ✔ | 🚧 | |
|||
| removeFromLibrary | Removes the given spotifyUri to the users library | ✔ | ✔ | 🚧 | |
|||
| subscribeCapabilities | Subscribes to the current users capabilities | ✔ | 🚧 | 🚧 | |
|||
| subscribeUserStatus | Subscribes to the current users status | ✔ | 🚧 | 🚧 | |
|||
|
|||
#### Connect Api |
|||
|
|||
The connectApi as described [here](https://spotify.github.io/android-sdk/app-remote-lib/docs/com/spotify/android/appremote/api/ConnectApi.html). |
|||
|
|||
| Function | Description | Android | iOS | Web | |
|||
|----------------------------|--------------------------------------------|---|---|---| |
|||
| connectDecreaseVolume | Decrease volume by a step size determined | 🚧 | 🚧 | 🚧 | |
|||
| connectIncreaseVolume | Increase volume by a step size determined | 🚧 | 🚧 | 🚧 | |
|||
| connectSetVolume | Set a volume on the currently active device | 🚧 | 🚧 | 🚧 | |
|||
| connectSwitchToLocalDevice | Switch to play music on this (local) device | ✔ | 🚧 | 🚧 | |
|||
| subscribeToVolumeState | Subscribe to volume state | 🚧 | 🚧 | 🚧 | |
|||
|
|||
#### Content Api |
|||
|
|||
The contentApi as described [here](https://spotify.github.io/android-sdk/app-remote-lib/docs/com/spotify/android/appremote/api/ContentApi.html). |
|||
|
|||
| Function | Description| Android | iOS | Web | |
|||
|---|---|---|---|---| |
|||
| getChildrenOfItem | tbd | 🚧 | 🚧 | 🚧 | |
|||
| getRecommendedContentItems | tbd | 🚧 | 🚧 | 🚧 | |
|||
| playContentItem | tbd | 🚧 | 🚧 | 🚧 | |
|||
|
|||
## Migration |
|||
|
|||
### spotify-auth: moving from locally sourced aar to maven central |
|||
`spotify-auth` SDK is now retrieved via Maven Central instead of being sourced from an AAR file |
|||
* Steps to remove the locally sourced `spotify-auth` SDK: |
|||
* android/settings.gradle -> remove `':spotify-auth'` |
|||
* android/spotify-auth/build.gradle -> remove file |
|||
* android/spotify-auth/spotify-auth-release-x.x.x.aar -> remove file |
|||
|
|||
## Official Spotify Docs |
|||
|
|||
- [Auth](https://spotify.github.io/android-sdk/auth-lib/docs/index.html) |
|||
- [App Remote](https://spotify.github.io/android-sdk/app-remote-lib/docs/index.html) |
|||
- [Web Playback SDK](https://developer.spotify.com/documentation/web-playback-sdk/) |
|||
|
|||
|
|||
@ -0,0 +1,12 @@ |
|||
include: package:flutter_lints/flutter.yaml |
|||
|
|||
analyzer: |
|||
exclude: |
|||
# Ignore generated files |
|||
- "**/*.g.dart" |
|||
- "lib/generated/**" |
|||
errors: |
|||
close_sinks: ignore |
|||
linter: |
|||
rules: |
|||
unsafe_html: false |
|||
@ -0,0 +1,8 @@ |
|||
*.iml |
|||
.gradle |
|||
/local.properties |
|||
/.idea/workspace.xml |
|||
/.idea/libraries |
|||
.DS_Store |
|||
/build |
|||
/captures |
|||
@ -0,0 +1,74 @@ |
|||
group 'de.minimalme.spotify_sdk' |
|||
version '1.0-SNAPSHOT' |
|||
|
|||
buildscript { |
|||
ext.kotlin_version = '1.8.22' |
|||
repositories { |
|||
google() |
|||
mavenCentral() |
|||
maven { url "https://jitpack.io" } |
|||
} |
|||
|
|||
dependencies { |
|||
classpath 'com.android.tools.build:gradle:8.1.4' |
|||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" |
|||
} |
|||
} |
|||
|
|||
rootProject.allprojects { |
|||
repositories { |
|||
google() |
|||
mavenCentral() |
|||
maven { url "https://jitpack.io" } |
|||
} |
|||
|
|||
} |
|||
|
|||
apply plugin: 'com.android.library' |
|||
apply plugin: 'kotlin-android' |
|||
|
|||
android { |
|||
if (project.android.hasProperty("namespace")) { |
|||
namespace 'de.minimalme.spotify_sdk' |
|||
} |
|||
|
|||
compileSdk 34 |
|||
|
|||
compileOptions { |
|||
sourceCompatibility = JavaVersion.VERSION_17 |
|||
targetCompatibility = JavaVersion.VERSION_17 |
|||
} |
|||
|
|||
kotlinOptions { |
|||
jvmTarget = JavaVersion.VERSION_17 |
|||
} |
|||
|
|||
sourceSets { |
|||
main.java.srcDirs += 'src/main/kotlin' |
|||
} |
|||
|
|||
defaultConfig { |
|||
minSdkVersion 21 |
|||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" |
|||
} |
|||
|
|||
lintOptions { |
|||
disable 'InvalidPackage' |
|||
} |
|||
} |
|||
|
|||
dependencies { |
|||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" |
|||
// -- spotify |
|||
implementation "com.spotify.android:auth:2.1.0" |
|||
implementation(name: 'spotify-app-remote-release-0.8.0', ext: 'aar') |
|||
implementation 'com.google.code.gson:gson:2.10.1' |
|||
// -- events |
|||
implementation "com.github.stuhlmeier:kotlin-events:v2.0" |
|||
} |
|||
|
|||
repositories { |
|||
flatDir { |
|||
dirs 'libs' |
|||
} |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
org.gradle.jvmargs=-Xmx1536M |
|||
android.useAndroidX=true |
|||
android.enableJetifier=true |
|||
@ -0,0 +1,7 @@ |
|||
distributionBase=GRADLE_USER_HOME |
|||
distributionPath=wrapper/dists |
|||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip |
|||
networkTimeout=10000 |
|||
validateDistributionUrl=true |
|||
zipStoreBase=GRADLE_USER_HOME |
|||
zipStorePath=wrapper/dists |
|||
Binary file not shown.
@ -0,0 +1 @@ |
|||
rootProject.name = 'spotify_sdk' |
|||
@ -0,0 +1,10 @@ |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
|||
<application> |
|||
<activity |
|||
android:name="com.spotify.sdk.android.authentication.LoginActivity" |
|||
android:theme="@android:style/Theme.Translucent.NoTitleBar" /> |
|||
</application> |
|||
<queries> |
|||
<package android:name="com.spotify.music" /> |
|||
</queries> |
|||
</manifest> |
|||
@ -0,0 +1,13 @@ |
|||
package de.minimalme.spotify_sdk |
|||
|
|||
import com.spotify.android.appremote.api.SpotifyAppRemote |
|||
import io.flutter.plugin.common.MethodChannel |
|||
|
|||
open class BaseSpotifyApi(internal val spotifyAppRemote: SpotifyAppRemote?, internal val result: MethodChannel.Result) { |
|||
|
|||
private val errorAppRemoteNull = "spotifyAppRemoteNull" |
|||
|
|||
internal fun spotifyRemoteAppNotSetError() { |
|||
result.error(errorAppRemoteNull, "spotifyAppRemote is null", "") |
|||
} |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
package de.minimalme.spotify_sdk |
|||
|
|||
import com.spotify.android.appremote.api.SpotifyAppRemote |
|||
import com.spotify.protocol.types.Image.Dimension |
|||
import com.spotify.protocol.types.ImageUri |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import java.io.ByteArrayOutputStream |
|||
import android.graphics.Bitmap |
|||
|
|||
class SpotifyConnectApi(spotifyAppRemote: SpotifyAppRemote?, result: MethodChannel.Result) : BaseSpotifyApi(spotifyAppRemote, result) { |
|||
|
|||
private val errorConnectSwitchToLocalDevice = "errorConnectSwitchToLocalDevice" |
|||
|
|||
private val connectApi = spotifyAppRemote?.connectApi |
|||
|
|||
fun switchToLocalDevice() { |
|||
if (connectApi != null) { |
|||
connectApi.connectSwitchToLocalDevice() |
|||
.setResultCallback { result.success(true) } |
|||
.setErrorCallback { throwable -> result.error(errorConnectSwitchToLocalDevice, "error when switching to local device", throwable.toString()) } |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
package de.minimalme.spotify_sdk |
|||
|
|||
import com.spotify.android.appremote.api.SpotifyAppRemote |
|||
import com.spotify.protocol.types.Image.Dimension |
|||
import com.spotify.protocol.types.ImageUri |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import java.io.ByteArrayOutputStream |
|||
import android.graphics.Bitmap |
|||
|
|||
class SpotifyImagesApi(spotifyAppRemote: SpotifyAppRemote?, result: MethodChannel.Result) : BaseSpotifyApi(spotifyAppRemote, result) { |
|||
|
|||
private val errorGetImage = "errorGetImage" |
|||
private val errorImageUri = "errorImageUri" |
|||
private val errorImageDimension = "errorImageDimensionUri" |
|||
|
|||
private val imagesApi = spotifyAppRemote?.imagesApi |
|||
|
|||
fun getImage(imageUri: String?, dimension: Int?) { |
|||
if (imagesApi != null && imageUri != null && dimension != null) { |
|||
imagesApi.getImage(ImageUri(imageUri), Dimension.values().first{it.value == dimension}) |
|||
.setResultCallback { bitmap -> |
|||
val stream = ByteArrayOutputStream() |
|||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) |
|||
result.success(stream.toByteArray()) |
|||
} |
|||
.setErrorCallback { throwable -> result.error(errorGetImage, "error when getting the image", throwable.toString()) } |
|||
} else if (imageUri == null) { |
|||
result.error(errorImageUri, "imageUri has invalid format or is not set", "") |
|||
} else if (dimension == null) { |
|||
result.error(errorImageDimension, "imageDimension has invalid format or is not set", "") |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,210 @@ |
|||
package de.minimalme.spotify_sdk |
|||
|
|||
import com.google.gson.Gson |
|||
import com.spotify.android.appremote.api.SpotifyAppRemote |
|||
import com.spotify.protocol.types.PlaybackSpeed |
|||
import io.flutter.plugin.common.MethodChannel |
|||
|
|||
class SpotifyPlayerApi(spotifyAppRemote: SpotifyAppRemote?, result: MethodChannel.Result) : BaseSpotifyApi(spotifyAppRemote, result) { |
|||
|
|||
|
|||
private val errorCrossfadeState = "crossfadeStateError" |
|||
private val errorPlayerState = "PlayerStateError" |
|||
private val errorQue = "queueError" |
|||
private val errorPlay = "playError" |
|||
private val errorPause = "pauseError" |
|||
private val errorResume = "resumeError" |
|||
private val errorSkipNext = "skipNextError" |
|||
private val errorSkipPrevious = "skipPreviousError" |
|||
private val errorSeekTo = "seekToError" |
|||
private val errorSkipToIndex = "skipToIndexError" |
|||
private val errorPodcastPlaybackSpeed = "podcastPlaybackSpeedError" |
|||
private val errorToggleShuffle = "toggleShuffleError" |
|||
private val errorToggleRepeat = "toggleRepeatError" |
|||
|
|||
private val playerApi = spotifyAppRemote?.playerApi |
|||
|
|||
internal fun getCrossfadeState() { |
|||
if (playerApi != null) { |
|||
playerApi.crossfadeState |
|||
.setResultCallback { crossfadeState -> |
|||
result.success(Gson().toJson(crossfadeState)) |
|||
} |
|||
.setErrorCallback { throwable -> result.error(errorCrossfadeState, "error when getting the current state of crossfade setting", throwable.toString()) } |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
internal fun getPlayerState() { |
|||
if (playerApi != null) { |
|||
playerApi.playerState |
|||
.setResultCallback { playerState -> |
|||
result.success(Gson().toJson(playerState)) |
|||
|
|||
} |
|||
.setErrorCallback { throwable -> result.error(errorPlayerState, "error when getting the current state of the player", throwable.toString()) } |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
internal fun queue(spotifyUri: String?) { |
|||
if (playerApi != null && !spotifyUri.isNullOrBlank()) { |
|||
playerApi.queue(spotifyUri) |
|||
.setResultCallback { result.success(true) } |
|||
.setErrorCallback { throwable -> result.error(errorQue, "error when adding uri: $spotifyUri to queue", throwable.toString()) } |
|||
} else if (spotifyUri.isNullOrBlank()) { |
|||
result.error(errorQue, "spotifyUri has invalid format or is not set", "") |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
internal fun play(spotifyUri: String?) { |
|||
if (playerApi != null && !spotifyUri.isNullOrBlank()) { |
|||
playerApi.play(spotifyUri) |
|||
.setResultCallback { result.success(true) } |
|||
.setErrorCallback { throwable -> result.error(errorPlay, "error when playing uri: $spotifyUri", throwable.toString()) } |
|||
} else if (spotifyUri.isNullOrBlank()) { |
|||
result.error(errorPlay, "spotifyUri has invalid format or is not set", "") |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
internal fun pause() { |
|||
if (playerApi != null) { |
|||
playerApi.pause() |
|||
.setResultCallback { result.success(true) } |
|||
.setErrorCallback { throwable -> result.error(errorPause, "error when pausing", throwable.toString()) } |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
internal fun resume() { |
|||
if (playerApi != null) { |
|||
playerApi.resume() |
|||
.setResultCallback { result.success(true) } |
|||
.setErrorCallback { throwable -> result.error(errorResume, "error when resuming", throwable.toString()) } |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
internal fun seekTo(milliseconds: Int?) { |
|||
val castedMilliseconds = milliseconds?.toLong() |
|||
if (playerApi != null && castedMilliseconds != null) { |
|||
playerApi.seekTo(castedMilliseconds) |
|||
.setResultCallback { result.success(true) } |
|||
.setErrorCallback { throwable -> result.error(errorResume, "error when seeking to: $castedMilliseconds", throwable.toString()) } |
|||
} else if (castedMilliseconds == null) { |
|||
result.error(errorSeekTo, "positionMS is not set", "") |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
internal fun seekToRelativePosition(milliseconds: Int?) { |
|||
val castedMilliseconds = milliseconds?.toLong() |
|||
if (playerApi != null && castedMilliseconds != null) { |
|||
playerApi.seekToRelativePosition(castedMilliseconds) |
|||
.setResultCallback { result.success(true) } |
|||
.setErrorCallback { throwable -> result.error(errorResume, "error when seeking relative to: $castedMilliseconds", throwable.toString()) } |
|||
} else if (castedMilliseconds == null) { |
|||
result.error(errorSeekTo, "milliseconds is not set", "") |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
internal fun setPodcastPlaybackSpeed(podcastPlaybackSpeedValue: Int?) { |
|||
if (playerApi != null && podcastPlaybackSpeedValue != null) { |
|||
|
|||
val podcastPlaybackSpeed = PlaybackSpeed.PodcastPlaybackSpeed.values().firstOrNull{ it.value == podcastPlaybackSpeedValue } |
|||
|
|||
playerApi.setPodcastPlaybackSpeed(podcastPlaybackSpeed) |
|||
.setResultCallback { result.success(true) } |
|||
.setErrorCallback { throwable -> result.error(errorPodcastPlaybackSpeed, "error when setting the podcastPlaybackSpeed to: $podcastPlaybackSpeed", throwable.toString()) } |
|||
} else if (podcastPlaybackSpeedValue == null) { |
|||
result.error(errorPodcastPlaybackSpeed, "podcastPlaybackSpeedValue is not set", "") |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
internal fun skipNext() { |
|||
if (playerApi != null) { |
|||
playerApi.skipNext() |
|||
.setResultCallback { result.success(true) } |
|||
.setErrorCallback { throwable -> result.error(errorSkipNext, "error when skipping next", throwable.toString()) } |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
internal fun skipPrevious() { |
|||
if (playerApi != null) { |
|||
playerApi.skipPrevious() |
|||
.setResultCallback { result.success(true) } |
|||
.setErrorCallback { throwable -> result.error(errorSkipPrevious, "error when skipping previous", throwable.toString()) } |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
internal fun skipToIndex(uri: String?, index: Int?) { |
|||
if (playerApi != null && !uri.isNullOrBlank() && index != null) { |
|||
playerApi.skipToIndex(uri, index) |
|||
.setResultCallback { result.success(true) } |
|||
.setErrorCallback { throwable -> result.error(errorSkipToIndex, "error when skipping to index", throwable.toString()) } |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
internal fun toggleShuffle() { |
|||
if (playerApi != null) { |
|||
playerApi.toggleShuffle() |
|||
.setResultCallback { result.success(true) } |
|||
.setErrorCallback { throwable -> result.error(errorToggleShuffle, "error when toggle shuffle", throwable.toString()) } |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
internal fun setShuffle(shuffle: Boolean?) { |
|||
if (playerApi != null && shuffle != null) { |
|||
playerApi.setShuffle(shuffle) |
|||
.setResultCallback { result.success(true) } |
|||
.setErrorCallback { throwable -> result.error(errorToggleRepeat, "error when toggle shuffle", throwable.toString()) } |
|||
} else if (shuffle == null) { |
|||
result.error(errorQue, "shuffle has invalid format or is not set", "") |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
internal fun toggleRepeat() { |
|||
if (playerApi != null) { |
|||
playerApi.toggleRepeat() |
|||
.setResultCallback { result.success(true) } |
|||
.setErrorCallback { throwable -> result.error(errorToggleRepeat, "error when toggle repeat", throwable.toString()) } |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
internal fun setRepeatMode(repeatMode: Int?) { |
|||
if (playerApi != null && repeatMode != null) { |
|||
playerApi.setRepeat(repeatMode) |
|||
.setResultCallback { result.success(true) } |
|||
.setErrorCallback { throwable -> result.error(errorToggleRepeat, "error when toggle repeat", throwable.toString()) } |
|||
} else if (repeatMode == null) { |
|||
result.error(errorQue, "repeatMode has invalid format or is not set", "") |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,381 @@ |
|||
package de.minimalme.spotify_sdk |
|||
|
|||
import android.app.Activity |
|||
import android.content.Context |
|||
import android.content.Intent |
|||
import android.util.Log |
|||
import com.spotify.android.appremote.api.ConnectionParams |
|||
import com.spotify.android.appremote.api.Connector.ConnectionListener |
|||
import com.spotify.android.appremote.api.SpotifyAppRemote |
|||
import com.spotify.android.appremote.api.error.* |
|||
import com.spotify.sdk.android.auth.AuthorizationClient |
|||
import com.spotify.sdk.android.auth.AuthorizationRequest |
|||
import com.spotify.sdk.android.auth.AuthorizationResponse |
|||
import de.minimalme.spotify_sdk.subscriptions.* |
|||
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 io.flutter.plugin.common.PluginRegistry |
|||
import kotlinx.event.SetEvent |
|||
import kotlinx.event.event |
|||
|
|||
class SpotifySdkPlugin : MethodCallHandler, FlutterPlugin, ActivityAware, PluginRegistry.ActivityResultListener { |
|||
|
|||
// application context |
|||
private var applicationContext : Context? = null |
|||
private var applicationActivity : Activity? = null |
|||
|
|||
// method channel |
|||
private lateinit var methodChannel : MethodChannel |
|||
private val channelName = "spotify_sdk" |
|||
private val loggingTag = "spotify_sdk" |
|||
|
|||
// event channels |
|||
private var playerContextChannel : EventChannel? = null |
|||
private var playerStateChannel : EventChannel? = null |
|||
private var capabilitiesChannel : EventChannel? = null |
|||
private var userStatusChannel : EventChannel? = null |
|||
private var connectionStatusChannel : EventChannel? = null |
|||
|
|||
private val playerContextSubscription = "player_context_subscription" |
|||
private val playerStateSubscription = "player_state_subscription" |
|||
private val capabilitiesSubscription = "capabilities_subscription" |
|||
private val userStatusSubscription = "user_status_subscription" |
|||
private val connectionStatusSubscription = "connection_status_subscription" |
|||
|
|||
//connecting |
|||
private val methodConnectToSpotify = "connectToSpotify" |
|||
private val methodGetAccessToken = "getAccessToken" |
|||
private val methodDisconnectFromSpotify = "disconnectFromSpotify" |
|||
|
|||
// connectApi |
|||
private val methodSwitchToLocalDevice = "switchToLocalDevice" |
|||
|
|||
//playerApi |
|||
private val methodGetCrossfadeState = "getCrossfadeState" |
|||
private val methodGetPlayerState = "getPlayerState" |
|||
private val methodPlay = "play" |
|||
private val methodPause = "pause" |
|||
private val methodQueueTrack = "queueTrack" |
|||
private val methodResume = "resume" |
|||
private val methodSeekToRelativePosition = "seekToRelativePosition" |
|||
private val methodSetPodcastPlaybackSpeed = "setPodcastPlaybackSpeed" |
|||
private val methodSkipNext = "skipNext" |
|||
private val methodSkipPrevious = "skipPrevious" |
|||
private val methodSkipToIndex = "skipToIndex" |
|||
private val methodSeekTo = "seekTo" |
|||
private val methodToggleRepeat = "toggleRepeat" |
|||
private val methodToggleShuffle = "toggleShuffle" |
|||
private val methodSetShuffle = "setShuffle" |
|||
private val methodSetRepeatMode = "setRepeatMode" |
|||
|
|||
//userApi |
|||
private val methodAddToLibrary = "addToLibrary" |
|||
private val methodRemoveFromLibrary = "removeFromLibrary" |
|||
private val methodGetCapabilities = "getCapabilities" |
|||
private val methodGetLibraryState = "getLibraryState" |
|||
|
|||
//imagesApi |
|||
private val methodGetImage = "getImage" |
|||
|
|||
private val paramClientId = "clientId" |
|||
private val paramRedirectUrl = "redirectUrl" |
|||
private val paramScope = "scope" |
|||
private val paramSpotifyUri = "spotifyUri" |
|||
private val paramImageUri = "imageUri" |
|||
private val paramImageDimension = "imageDimension" |
|||
private val paramPositionedMilliseconds = "positionedMilliseconds" |
|||
private val paramRelativeMilliseconds = "relativeMilliseconds" |
|||
private val paramPodcastPlaybackSpeed = "podcastPlaybackSpeed" |
|||
private val paramTrackIndex = "trackIndex" |
|||
private val paramRepeatMode = "repeatMode" |
|||
private val paramShuffle = "shuffle" |
|||
|
|||
private val errorConnecting = "errorConnecting" |
|||
private val errorDisconnecting = "errorDisconnecting" |
|||
private val errorConnection = "errorConnection" |
|||
private val errorAuthenticationToken = "authenticationTokenError" |
|||
|
|||
private var connStatusEventChannel: SetEvent<ConnectionStatusChannel.ConnectionEvent> = event() |
|||
|
|||
private val requestCodeAuthentication = 1337 |
|||
|
|||
private var pendingOperation: PendingOperation? = null |
|||
private var spotifyAppRemote: SpotifyAppRemote? = null |
|||
private var spotifyPlayerApi: SpotifyPlayerApi? = null |
|||
private var spotifyConnectApi: SpotifyConnectApi? = null |
|||
private var spotifyUserApi: SpotifyUserApi? = null |
|||
private var spotifyImagesApi: SpotifyImagesApi? = null |
|||
|
|||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { |
|||
this.applicationContext = binding.applicationContext |
|||
|
|||
methodChannel = MethodChannel(binding.binaryMessenger, channelName) |
|||
methodChannel.setMethodCallHandler(this) |
|||
|
|||
playerContextChannel = EventChannel(binding.binaryMessenger, playerContextSubscription) |
|||
playerStateChannel = EventChannel(binding.binaryMessenger, playerStateSubscription) |
|||
capabilitiesChannel = EventChannel(binding.binaryMessenger, capabilitiesSubscription) |
|||
userStatusChannel = EventChannel(binding.binaryMessenger, userStatusSubscription) |
|||
connectionStatusChannel = EventChannel(binding.binaryMessenger, connectionStatusSubscription) |
|||
|
|||
connectionStatusChannel?.setStreamHandler(ConnectionStatusChannel(connStatusEventChannel)) |
|||
} |
|||
|
|||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { |
|||
|
|||
applicationContext = null |
|||
|
|||
methodChannel.setMethodCallHandler(null) |
|||
|
|||
playerContextChannel?.setStreamHandler(null) |
|||
playerStateChannel?.setStreamHandler(null) |
|||
capabilitiesChannel?.setStreamHandler(null) |
|||
userStatusChannel?.setStreamHandler(null) |
|||
connectionStatusChannel?.setStreamHandler(null) |
|||
|
|||
playerContextChannel = null |
|||
playerStateChannel = null |
|||
capabilitiesChannel = null |
|||
userStatusChannel = null |
|||
connectionStatusChannel = null |
|||
} |
|||
|
|||
override fun onAttachedToActivity(binding: ActivityPluginBinding) { |
|||
binding.addActivityResultListener(this) |
|||
applicationActivity = binding.activity |
|||
} |
|||
|
|||
override fun onDetachedFromActivityForConfigChanges() { |
|||
applicationActivity = null |
|||
} |
|||
|
|||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { |
|||
applicationActivity = binding.activity |
|||
} |
|||
|
|||
override fun onDetachedFromActivity() { |
|||
applicationActivity = null |
|||
} |
|||
|
|||
override fun onMethodCall(call: MethodCall, result: Result) { |
|||
|
|||
if (spotifyAppRemote != null) { |
|||
spotifyPlayerApi = SpotifyPlayerApi(spotifyAppRemote, result) |
|||
spotifyUserApi = SpotifyUserApi(spotifyAppRemote, result) |
|||
spotifyImagesApi = SpotifyImagesApi(spotifyAppRemote, result) |
|||
spotifyConnectApi = SpotifyConnectApi(spotifyAppRemote, result) |
|||
} |
|||
|
|||
when (call.method) { |
|||
//connecting to spotify |
|||
methodConnectToSpotify -> connectToSpotify(call.argument(paramClientId), call.argument(paramRedirectUrl), result) |
|||
methodGetAccessToken -> getAccessToken(call.argument(paramClientId), call.argument(paramRedirectUrl), call.argument(paramScope), result) |
|||
methodDisconnectFromSpotify -> disconnectFromSpotify(result) |
|||
//connectApi calls |
|||
methodSwitchToLocalDevice -> spotifyConnectApi?.switchToLocalDevice() |
|||
//playerApi calls |
|||
methodGetCrossfadeState -> spotifyPlayerApi?.getCrossfadeState() |
|||
methodGetPlayerState -> spotifyPlayerApi?.getPlayerState() |
|||
methodPlay -> spotifyPlayerApi?.play(call.argument(paramSpotifyUri)) |
|||
methodPause -> spotifyPlayerApi?.pause() |
|||
methodQueueTrack -> spotifyPlayerApi?.queue(call.argument(paramSpotifyUri)) |
|||
methodResume -> spotifyPlayerApi?.resume() |
|||
methodSeekTo -> spotifyPlayerApi?.seekTo(call.argument(paramPositionedMilliseconds)) |
|||
methodSeekToRelativePosition -> spotifyPlayerApi?.seekToRelativePosition(call.argument(paramRelativeMilliseconds)) |
|||
methodSetPodcastPlaybackSpeed -> spotifyPlayerApi?.setPodcastPlaybackSpeed(call.argument(paramPodcastPlaybackSpeed)) |
|||
methodSkipNext -> spotifyPlayerApi?.skipNext() |
|||
methodSkipPrevious -> spotifyPlayerApi?.skipPrevious() |
|||
methodSkipToIndex -> spotifyPlayerApi?.skipToIndex(call.argument(paramSpotifyUri), call.argument(paramTrackIndex)) |
|||
methodToggleShuffle -> spotifyPlayerApi?.toggleShuffle() |
|||
methodSetShuffle -> spotifyPlayerApi?.setShuffle(call.argument(paramShuffle)) |
|||
methodToggleRepeat -> spotifyPlayerApi?.toggleRepeat() |
|||
methodSetRepeatMode -> spotifyPlayerApi?.setRepeatMode(call.argument(paramRepeatMode)) |
|||
//userApi calls |
|||
methodAddToLibrary -> spotifyUserApi?.addToUserLibrary(call.argument(paramSpotifyUri)) |
|||
methodRemoveFromLibrary -> spotifyUserApi?.removeFromUserLibrary(call.argument(paramSpotifyUri)) |
|||
methodGetCapabilities -> spotifyUserApi?.getCapabilities() |
|||
methodGetLibraryState -> spotifyUserApi?.getLibraryState(call.argument(paramSpotifyUri)) |
|||
//imageApi calls |
|||
methodGetImage -> spotifyImagesApi?.getImage(call.argument(paramImageUri), call.argument(paramImageDimension)) |
|||
// method call is not implemented yet |
|||
else -> result.notImplemented() |
|||
} |
|||
} |
|||
|
|||
//-- Method implementations |
|||
private fun connectToSpotify(clientId: String?, redirectUrl: String?, result: Result) { |
|||
|
|||
if (clientId.isNullOrBlank() || redirectUrl.isNullOrBlank()) { |
|||
result.error(errorConnecting, "client id or redirectUrl are not set or have invalid format", "") |
|||
} else { |
|||
val connectionParams = ConnectionParams.Builder(clientId) |
|||
.setRedirectUri(redirectUrl) |
|||
.showAuthView(true) |
|||
.build() |
|||
SpotifyAppRemote.disconnect(spotifyAppRemote) |
|||
var initiallyConnected = false |
|||
SpotifyAppRemote.connect(applicationContext, connectionParams, |
|||
object : ConnectionListener { |
|||
override fun onConnected(spotifyAppRemoteValue: SpotifyAppRemote) { |
|||
spotifyAppRemote = spotifyAppRemoteValue |
|||
|
|||
playerContextChannel?.setStreamHandler(PlayerContextChannel(spotifyAppRemote!!.playerApi)) |
|||
Log.i(loggingTag, "Set stream handler for PlayerContextChannel") |
|||
playerStateChannel?.setStreamHandler(PlayerStateChannel(spotifyAppRemote!!.playerApi)) |
|||
Log.i(loggingTag, "Set stream handler for PlayerStateChannel") |
|||
capabilitiesChannel?.setStreamHandler(CapabilitiesChannel(spotifyAppRemote!!.userApi)) |
|||
Log.i(loggingTag, "Set stream handler for CapabilitiesChannel") |
|||
userStatusChannel?.setStreamHandler(UserStatusChannel(spotifyAppRemote!!.userApi)) |
|||
Log.i(loggingTag, "Set stream handler for UserStatusChannel") |
|||
initiallyConnected = true |
|||
|
|||
Log.i(loggingTag, "App Remote successfully connected") |
|||
// emit connection established event |
|||
connStatusEventChannel(ConnectionStatusChannel.ConnectionEvent(true, "Successfully connected to Spotify.", null, null)) |
|||
// method success |
|||
result.success(true) |
|||
} |
|||
|
|||
override fun onFailure(throwable: Throwable) { |
|||
val errorDetails = throwable.toString() |
|||
// determine the error |
|||
val errorMessage: String |
|||
val errorCode: String |
|||
var connected = false |
|||
when (throwable) { |
|||
is SpotifyDisconnectedException, is SpotifyConnectionTerminatedException -> { |
|||
// The Spotify app was/is disconnected by the Spotify app. |
|||
// This indicates typically that the Spotify app was closed by the user or for other reasons. |
|||
// You need to reconnect to continue using Spotify App Remote. |
|||
errorMessage = "The Spotify app was/is disconnected by the Spotify app.Reconnect necessary" |
|||
errorCode = "SpotifyDisconnectedException" |
|||
} |
|||
is CouldNotFindSpotifyApp -> { |
|||
errorMessage = "The Spotify app is not installed on the device" |
|||
errorCode = "CouldNotFindSpotifyApp" |
|||
} |
|||
is AuthenticationFailedException -> { |
|||
errorMessage = "Partner app failed to authenticate with Spotify. Check client credentials and make sure your app is registered correctly at developer.spotify.com" |
|||
errorCode = "AuthenticationFailedException" |
|||
} |
|||
is UserNotAuthorizedException -> { |
|||
errorMessage = "Indicates the user did not authorize this client of App Remote to use Spotify on the users behalf." |
|||
errorCode = "UserNotAuthorizedException" |
|||
} |
|||
is UnsupportedFeatureVersionException -> { |
|||
errorMessage = "Spotify app can't support requested features. User should update Spotify app." |
|||
errorCode = "UnsupportedFeatureVersionException" |
|||
connected = true |
|||
} |
|||
is OfflineModeException -> { |
|||
errorMessage = "Spotify user has set their Spotify app to be in offline mode" |
|||
errorCode = "OfflineModeException" |
|||
connected = true |
|||
} |
|||
is NotLoggedInException -> { |
|||
errorMessage = "User has logged out from Spotify." |
|||
errorCode = "NotLoggedInException" |
|||
} |
|||
is SpotifyRemoteServiceException -> { |
|||
errorMessage = "Encapsulates possible SecurityException and IllegalStateException errors." |
|||
errorCode = "SpotifyRemoteServiceException" |
|||
} |
|||
else -> { |
|||
errorMessage = "Something went wrong connecting spotify remote" |
|||
errorCode = errorConnection |
|||
} |
|||
} |
|||
Log.e(loggingTag, errorMessage) |
|||
// notify plugin |
|||
if (initiallyConnected) { |
|||
// emit connection error event |
|||
connStatusEventChannel(ConnectionStatusChannel.ConnectionEvent(connected, errorMessage, errorCode, errorDetails)) |
|||
} else { |
|||
// throw exception as the connect method |
|||
result.error(errorCode, errorMessage, errorDetails) |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
private fun getAccessToken(clientId: String?, redirectUrl: String?, scope: String?, result: Result) { |
|||
if (applicationActivity == null) { |
|||
throw IllegalStateException("getAccessToken needs a foreground activity") |
|||
} |
|||
|
|||
if (clientId.isNullOrBlank() || redirectUrl.isNullOrBlank()) { |
|||
result.error(errorConnecting, "client id or redirectUrl are not set or have invalid format", "") |
|||
} else { |
|||
//Convert String? scope to Array. Delimiter set as comma "," |
|||
val scopeArray = scope?.split(",")?.toTypedArray() |
|||
methodConnectToSpotify.checkAndSetPendingOperation(result) |
|||
|
|||
val builder = AuthorizationRequest.Builder(clientId, AuthorizationResponse.Type.TOKEN, redirectUrl) |
|||
builder.setScopes(scopeArray) |
|||
val request = builder.build() |
|||
|
|||
AuthorizationClient.openLoginActivity(applicationActivity, requestCodeAuthentication, request) |
|||
} |
|||
} |
|||
|
|||
private fun disconnectFromSpotify(result: Result) { |
|||
if (spotifyAppRemote != null && spotifyAppRemote!!.isConnected) { |
|||
SpotifyAppRemote.disconnect(spotifyAppRemote) |
|||
|
|||
// emit connection terminated event |
|||
connStatusEventChannel(ConnectionStatusChannel.ConnectionEvent(false, "Successfully disconnected from Spotify.", null, null)) |
|||
// method success |
|||
result.success(true) |
|||
} else if (!spotifyAppRemote!!.isConnected) { |
|||
result.error(errorDisconnecting, "could not disconnect spotify remote", "you are not connected, no need to disconnect") |
|||
} else { |
|||
result.error(errorDisconnecting, "could not disconnect spotify remote", "spotifyAppRemote is not set") |
|||
} |
|||
} |
|||
|
|||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { |
|||
if (pendingOperation == null) { |
|||
return false |
|||
} |
|||
return when (requestCode) { |
|||
requestCodeAuthentication -> { |
|||
authFlow(resultCode, data) |
|||
return true |
|||
} |
|||
else -> false |
|||
} |
|||
} |
|||
|
|||
private fun authFlow(resultCode: Int, data: Intent?) { |
|||
|
|||
val response: AuthorizationResponse = AuthorizationClient.getResponse(resultCode, data) |
|||
val result = pendingOperation!!.result |
|||
pendingOperation = null |
|||
|
|||
when (response.type) { |
|||
AuthorizationResponse.Type.TOKEN -> { |
|||
result.success(response.accessToken) |
|||
} |
|||
AuthorizationResponse.Type.ERROR -> result.error(errorAuthenticationToken, "Authentication went wrong", response.error) |
|||
else -> result.notImplemented() |
|||
} |
|||
} |
|||
|
|||
private fun String.checkAndSetPendingOperation(result: Result) { |
|||
|
|||
check(pendingOperation == null) |
|||
{ |
|||
"Concurrent operations detected: " + pendingOperation?.method.toString() + ", " + this |
|||
} |
|||
pendingOperation = PendingOperation(this, result) |
|||
} |
|||
} |
|||
|
|||
private class PendingOperation internal constructor(val method: String, val result: Result) |
|||
@ -0,0 +1,59 @@ |
|||
package de.minimalme.spotify_sdk |
|||
|
|||
import com.google.gson.Gson |
|||
import com.spotify.android.appremote.api.SpotifyAppRemote |
|||
import io.flutter.plugin.common.MethodChannel |
|||
|
|||
class SpotifyUserApi(spotifyAppRemote: SpotifyAppRemote?, result: MethodChannel.Result) : BaseSpotifyApi(spotifyAppRemote, result) { |
|||
|
|||
private val errorAddToLibrary = "addToLibraryError" |
|||
private val errorRemoveFromLibrary = "removeFromLibraryError" |
|||
private val errorGettingCapabilities = "getCapabilitiesError" |
|||
private val errorGettingLibraryState = "getLibraryStateError" |
|||
|
|||
private val userApi = spotifyAppRemote?.userApi |
|||
|
|||
fun addToUserLibrary(spotifyUri: String?) { |
|||
if (userApi != null && !spotifyUri.isNullOrBlank()) { |
|||
userApi.addToLibrary(spotifyUri) |
|||
.setResultCallback {result.success(true)} |
|||
.setErrorCallback { throwable -> result.error(errorAddToLibrary, "error when adding uri to user library", throwable.toString()) } |
|||
} else if (spotifyUri.isNullOrBlank()) { |
|||
result.error(errorAddToLibrary, "spotifyUri has invalid format", "") |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
fun removeFromUserLibrary(spotifyUri: String?){ |
|||
if (userApi != null && !spotifyUri.isNullOrBlank()) { |
|||
userApi.removeFromLibrary(spotifyUri) |
|||
.setResultCallback {result.success(true)} |
|||
.setErrorCallback { throwable -> result.error(errorRemoveFromLibrary, "error when removing uri from user library", throwable.toString()) } |
|||
} else if (spotifyUri.isNullOrBlank()) { |
|||
result.error(errorRemoveFromLibrary, "spotifyUri has invalid format", "") |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
fun getCapabilities(){ |
|||
if (userApi != null) { |
|||
userApi.capabilities |
|||
.setResultCallback {capabilities -> result.success(Gson().toJson(capabilities))} |
|||
.setErrorCallback { throwable -> result.error(errorGettingCapabilities, "error when getting capabilities", throwable.toString()) } |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
|
|||
fun getLibraryState(spotifyUri: String?){ |
|||
if (userApi != null && !spotifyUri.isNullOrBlank()) { |
|||
userApi.getLibraryState(spotifyUri) |
|||
.setResultCallback {libraryState -> result.success(Gson().toJson(libraryState))} |
|||
.setErrorCallback { throwable -> result.error(errorGettingLibraryState, "error when getting the library state", throwable.toString()) } |
|||
} else { |
|||
spotifyRemoteAppNotSetError() |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
package de.minimalme.spotify_sdk.subscriptions |
|||
|
|||
import com.google.gson.Gson |
|||
import com.spotify.android.appremote.api.PlayerApi |
|||
import com.spotify.android.appremote.api.UserApi |
|||
import io.flutter.plugin.common.EventChannel |
|||
|
|||
class CapabilitiesChannel(private val userApi: UserApi) : EventChannel.StreamHandler { |
|||
|
|||
private val errorSubscribeCapabilities = "subscribeCapabilitiesError" |
|||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|||
userApi.subscribeToCapabilities() |
|||
.setEventCallback { |
|||
capabilities -> events?.success(Gson().toJson(capabilities)) |
|||
} |
|||
.setErrorCallback { |
|||
throwable -> events?.error(errorSubscribeCapabilities, "error when subscribing to the users capabilities", throwable.toString()) |
|||
} |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
} |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
package de.minimalme.spotify_sdk.subscriptions |
|||
|
|||
import android.util.Log |
|||
import com.google.gson.Gson |
|||
import com.google.gson.annotations.SerializedName |
|||
import io.flutter.plugin.common.EventChannel |
|||
import kotlinx.event.SetEvent |
|||
|
|||
class ConnectionStatusChannel(private val connStatusStream: SetEvent<ConnectionEvent>) : EventChannel.StreamHandler { |
|||
|
|||
data class ConnectionEvent( |
|||
@SerializedName("connected") val connected: Boolean, |
|||
@SerializedName("message" )val message: String, |
|||
@SerializedName("errorCode") val errorCode: String?, |
|||
@SerializedName("errorDetails") val errorDetails: Any? |
|||
) |
|||
|
|||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|||
connStatusStream += { event -> |
|||
// not error |
|||
events?.success(Gson().toJson(event)) |
|||
} |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
} |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
package de.minimalme.spotify_sdk.subscriptions |
|||
|
|||
import com.google.gson.Gson |
|||
import com.spotify.android.appremote.api.PlayerApi |
|||
import io.flutter.plugin.common.EventChannel |
|||
|
|||
class PlayerContextChannel(private val playerApi: PlayerApi) : EventChannel.StreamHandler { |
|||
|
|||
private val errorSubscribePlayerContext = "subscribePlayerContextError" |
|||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|||
playerApi.subscribeToPlayerContext() |
|||
.setEventCallback { |
|||
playerContext -> events?.success(Gson().toJson(playerContext)) |
|||
} |
|||
.setErrorCallback { |
|||
throwable -> events?.error(errorSubscribePlayerContext, "error when subscribing to the player context", throwable.toString()) |
|||
} |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
} |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
package de.minimalme.spotify_sdk.subscriptions |
|||
|
|||
import com.google.gson.Gson |
|||
import com.spotify.android.appremote.api.PlayerApi |
|||
import io.flutter.plugin.common.EventChannel |
|||
|
|||
class PlayerStateChannel(private val playerApi: PlayerApi) : EventChannel.StreamHandler { |
|||
|
|||
private val errorSubscribePlayerState = "subscribePlayerStateError" |
|||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|||
playerApi.subscribeToPlayerState() |
|||
.setEventCallback { |
|||
playerState -> events?.success(Gson().toJson(playerState)) |
|||
} |
|||
.setErrorCallback { |
|||
throwable -> events?.error(errorSubscribePlayerState, "error when subscribing to the player state", throwable.toString()) |
|||
} |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue