Browse Source

Merge branch 'liwei' of https://github.com/deepcloud2048/deep_voice

newdev_shunjiawei
tanlongsheng 2 years ago
parent
commit
7f15769a9a
  1. 79
      .cursorrules
  2. 12
      .mcp.json
  3. 13
      android/app/build.gradle.kts
  4. 4
      android/app/src/main/AndroidManifest.xml
  5. 6
      android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt
  6. 456
      android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler copy.kt.bak
  7. 9
      android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt
  8. 6
      android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt
  9. 3
      android/build.gradle.kts
  10. 11
      android/settings.gradle.kts
  11. 5
      lib/core/bindings/initial_binding.dart
  12. 89
      lib/core/utils/jieli_constants.dart
  13. 2
      lib/core/widgets/message_bubble.dart
  14. 105
      lib/data/services/chat_history_service.dart
  15. 2
      lib/data/services/deapsound_ai_service.dart
  16. 97
      lib/data/services/function_service.dart
  17. 2
      lib/data/services/network/nw_api.dart
  18. 191
      lib/data/services/open_ai_service_adapter.dart
  19. 15
      lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart
  20. 8
      lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart
  21. 59
      lib/data/services/voice_interaction_service.dart
  22. 319
      lib/modules/chat/controllers/chat_controller.dart
  23. 20
      lib/modules/chat/models/message_model.dart
  24. 2
      lib/modules/chat/views/chat_view.dart
  25. 11
      lib/modules/jieli_test/bindings/jieli_test_binding.dart
  26. 323
      lib/modules/jieli_test/controllers/jieli_test_controller.dart
  27. 568
      lib/modules/jieli_test/views/jieli_test_view.dart
  28. 4
      lib/modules/pairing/controllers/pairing_controller.dart
  29. 45
      lib/modules/profile/controllers/profile_controller.dart
  30. 22
      lib/modules/settings/views/settings_view.dart
  31. 2
      lib/modules/splash/controllers/splash_controller.dart
  32. 7
      lib/routes/app_pages.dart
  33. 1
      lib/routes/app_routes.dart
  34. 0
      local_plugins/azure_speech/LICENSE
  35. 42
      local_plugins/azure_speech/android/build.gradle.kts
  36. 0
      local_plugins/azure_speech/ios/Classes/.gitkeep
  37. 24
      local_plugins/azure_speech/ios/azure_speech.podspec
  38. 0
      local_plugins/chat_storage/LICENSE
  39. 80
      local_plugins/chat_storage/README.md
  40. 60
      local_plugins/chat_storage/android/build.gradle.kts
  41. 1
      local_plugins/chat_storage/android/settings.gradle.kts
  42. 8
      local_plugins/chat_storage/android/src/main/AndroidManifest.xml
  43. 236
      local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt
  44. 0
      local_plugins/chat_storage/ios/Classes/.gitkeep
  45. 23
      local_plugins/chat_storage/ios/chat_storage.podspec
  46. 154
      local_plugins/chat_storage/lib/chat_storage.dart
  47. 30
      local_plugins/chat_storage/pubspec.yaml
  48. 144
      local_plugins/jieli/README.md
  49. 55
      local_plugins/jieli/android/build.gradle.kts
  50. 1
      local_plugins/jieli/android/settings.gradle.kts
  51. 22
      local_plugins/jieli/android/src/main/AndroidManifest.xml
  52. 549
      local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt
  53. 292
      local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt
  54. 272
      local_plugins/jieli/lib/jieli.dart
  55. 23
      local_plugins/jieli/pubspec.yaml
  56. 0
      local_plugins/open_ai_service/LICENSE
  57. 12
      local_plugins/open_ai_service/android/AndroidManifest.xml
  58. 29
      local_plugins/open_ai_service/android/build.gradle.kts
  59. 892
      local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt
  60. 372
      local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt
  61. 252
      local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt
  62. 422
      local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt
  63. 357
      local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/mcp/CustomSseClientTransport.kt
  64. 0
      local_plugins/open_ai_service/ios/Classes/.gitkeep
  65. 296
      local_plugins/open_ai_service/ios/Classes/OpenAIService.swift
  66. 23
      local_plugins/open_ai_service/ios/open_ai_service.podspec
  67. 194
      local_plugins/open_ai_service/lib/open_ai_service.dart
  68. 62
      local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/bug_report.md
  69. 1
      local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/config.yml
  70. 20
      local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/feature_request.md
  71. 41
      local_plugins/spotify_sdk/.github/actions/publish_flutter_package/action.yaml
  72. 42
      local_plugins/spotify_sdk/.github/dependabot.yml
  73. 16
      local_plugins/spotify_sdk/.github/scripts/verify_pub_score.sh
  74. 38
      local_plugins/spotify_sdk/.github/workflows/pull_request.yml
  75. 34
      local_plugins/spotify_sdk/.github/workflows/release.yml
  76. 50
      local_plugins/spotify_sdk/.gitignore
  77. 10
      local_plugins/spotify_sdk/.metadata
  78. 184
      local_plugins/spotify_sdk/CHANGELOG.md
  79. 76
      local_plugins/spotify_sdk/CODE_OF_CONDUCT.md
  80. 48
      local_plugins/spotify_sdk/CONTRIBUTING.md
  81. 201
      local_plugins/spotify_sdk/LICENSE
  82. 257
      local_plugins/spotify_sdk/README.md
  83. 12
      local_plugins/spotify_sdk/analysis_options.yaml
  84. 8
      local_plugins/spotify_sdk/android/.gitignore
  85. 74
      local_plugins/spotify_sdk/android/build.gradle
  86. 3
      local_plugins/spotify_sdk/android/gradle.properties
  87. 7
      local_plugins/spotify_sdk/android/gradle/wrapper/gradle-wrapper.properties
  88. BIN
      local_plugins/spotify_sdk/android/libs/spotify-app-remote-release-0.8.0.aar
  89. 1
      local_plugins/spotify_sdk/android/settings.gradle
  90. 10
      local_plugins/spotify_sdk/android/src/main/AndroidManifest.xml
  91. 13
      local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/BaseSpotifyApi.kt
  92. 25
      local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyConnectApi.kt
  93. 35
      local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyImagesApi.kt
  94. 210
      local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyPlayerApi.kt
  95. 381
      local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifySdkPlugin.kt
  96. 59
      local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyUserApi.kt
  97. 23
      local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/CapabilitiesChannel.kt
  98. 27
      local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/ConnectionStatusChannel.kt
  99. 22
      local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/PlayerContextChannel.kt
  100. 22
      local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/PlayerStateChannel.kt

79
.cursorrules

@ -1,10 +1,73 @@
1. 不要主动运行程序, 如flutter run 1. 请严格遵循 Effective Dart 的风格指南来编写代码
2. 不要改动android, iOS项目的配置信息,包括SDK, NDK版本等 2. 不要主动运行程序, 如flutter run
3. 不要使用print, 而是使用logger 3. 不要改动android, iOS项目的配置信息,包括SDK, NDK, JAVA版本等
4. 尽可能使用GetX提供的各种功能, 而不是使用原生的StatefulWidget 4. 不要使用print, 而是使用logger, 尽可能避免打印日志,仅在出错时记录日志
5. 不要增加重试逻辑,出现失败直接返回错误 5. 尽可能使用GetX提供的各种功能, 而不是使用原生的StatefulWidget
6. 尽可能避免使用try catch 6. 不要增加重试逻辑,出现失败直接返回错误
7. 尽可能避免打印日志,仅在出错时记录日志 7. 尽可能避免使用try catch
8. 请使用较小的代码量改动,最安全的方法进行代码编写和优化 8. 请使用较小的代码量改动,最安全的方法进行代码编写和优化
9. 请尽可能使用中文回复问题 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文件管理
- 支持不同环境的配置切换
- 敏感信息不应直接硬编码在代码中

12
.mcp.json

@ -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"
}
}
}

13
android/app/build.gradle.kts

@ -3,6 +3,9 @@ import java.io.FileInputStream
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
flatDir {
dirs(project(":spotify_sdk").projectDir.resolve("libs"))
}
} }
plugins { plugins {
@ -54,6 +57,12 @@ android {
} }
defaultConfig { defaultConfig {
manifestPlaceholders.putAll(
mapOf(
"redirectSchemeName" to "spotify-sdk",
"redirectHostName" to "auth"
)
)
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.yunqiinnovation.deepsound" applicationId = "com.yunqiinnovation.deepsound"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
@ -108,6 +117,9 @@ dependencies {
implementation(project(":azure_speech")) implementation(project(":azure_speech"))
implementation(project(":open_ai_service")) implementation(project(":open_ai_service"))
implementation(project(":volcano_speech")) implementation(project(":volcano_speech"))
implementation(project(":chat_storage")) // 添加新创建的SQLite聊天存储插件
implementation(project(":jieli")) // 添加新创建的SQLite聊天存储插件
implementation(project(":spotify_sdk"))
// 添加OkHttp依赖 // 添加OkHttp依赖
implementation("com.squareup.okhttp3:okhttp:4.9.3") implementation("com.squareup.okhttp3:okhttp:4.9.3")
@ -124,7 +136,6 @@ dependencies {
// 添加 AndroidX Security 加密 SharedPreferences 依赖 // 添加 AndroidX Security 加密 SharedPreferences 依赖
implementation("androidx.security:security-crypto:1.1.0-alpha06") implementation("androidx.security:security-crypto:1.1.0-alpha06")
} }
flutter { flutter {

4
android/app/src/main/AndroidManifest.xml

@ -92,6 +92,10 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="yqcxdeepsound"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.

6
android/app/src/main/kotlin/com/yunqiinnovation/deepsound/MainActivity.kt

@ -172,6 +172,7 @@ class MainActivity: FlutterActivity() {
var openaiModel: String? = null var openaiModel: String? = null
var volcanoSpeechAppId: String = "" var volcanoSpeechAppId: String = ""
var volcanoSpeechAppToken: String = "" var volcanoSpeechAppToken: String = ""
var mcpServer: String = ""
// 安全存储相关常量 // 安全存储相关常量
private const val SECURE_PREFS_FILENAME = "deep_voice_secure_prefs" private const val SECURE_PREFS_FILENAME = "deep_voice_secure_prefs"
@ -184,6 +185,7 @@ class MainActivity: FlutterActivity() {
private const val KEY_MCP_SERVER_ENDPOINT = "mcp_server_endpoint" private const val KEY_MCP_SERVER_ENDPOINT = "mcp_server_endpoint"
private const val KEY_VOLCANO_SPEECH_APP_ID = "volcano_speech_app_id" private const val KEY_VOLCANO_SPEECH_APP_ID = "volcano_speech_app_id"
private const val KEY_VOLCANO_SPEECH_APP_TOKEN = "volcano_speech_app_token" private const val KEY_VOLCANO_SPEECH_APP_TOKEN = "volcano_speech_app_token"
private const val KEY_MCP_SERVER = "mcp_server"
// 会话管理 // 会话管理
private const val KEY_SESSION_ID = "session_id" private const val KEY_SESSION_ID = "session_id"
@ -224,6 +226,7 @@ class MainActivity: FlutterActivity() {
.putString(KEY_OPENAI_MODEL, openaiModel) .putString(KEY_OPENAI_MODEL, openaiModel)
.putString(KEY_VOLCANO_SPEECH_APP_ID, volcanoSpeechAppId) .putString(KEY_VOLCANO_SPEECH_APP_ID, volcanoSpeechAppId)
.putString(KEY_VOLCANO_SPEECH_APP_TOKEN, volcanoSpeechAppToken) .putString(KEY_VOLCANO_SPEECH_APP_TOKEN, volcanoSpeechAppToken)
.putString(KEY_MCP_SERVER, mcpServer)
.apply() .apply()
FileLogger.d("MainActivity", "密钥已安全保存到加密存储中") FileLogger.d("MainActivity", "密钥已安全保存到加密存储中")
@ -272,6 +275,7 @@ class MainActivity: FlutterActivity() {
openaiModel = sharedPreferences.getString(KEY_OPENAI_MODEL, null) openaiModel = sharedPreferences.getString(KEY_OPENAI_MODEL, null)
volcanoSpeechAppId = sharedPreferences.getString(KEY_VOLCANO_SPEECH_APP_ID, "") ?: "" volcanoSpeechAppId = sharedPreferences.getString(KEY_VOLCANO_SPEECH_APP_ID, "") ?: ""
volcanoSpeechAppToken = sharedPreferences.getString(KEY_VOLCANO_SPEECH_APP_TOKEN, "") ?: "" volcanoSpeechAppToken = sharedPreferences.getString(KEY_VOLCANO_SPEECH_APP_TOKEN, "") ?: ""
mcpServer = sharedPreferences.getString(KEY_MCP_SERVER, "") ?: ""
FileLogger.d("MainActivity", "已从加密存储加载密钥") FileLogger.d("MainActivity", "已从加密存储加载密钥")
// 检查是否成功获取所有必要密钥 // 检查是否成功获取所有必要密钥
@ -520,7 +524,7 @@ class MainActivity: FlutterActivity() {
openaiModel = call.argument<String>("openai_model") openaiModel = call.argument<String>("openai_model")
volcanoSpeechAppId = call.argument<String>("volcano_speech_app_id") ?: "" volcanoSpeechAppId = call.argument<String>("volcano_speech_app_id") ?: ""
volcanoSpeechAppToken = call.argument<String>("volcano_speech_app_token") ?: "" volcanoSpeechAppToken = call.argument<String>("volcano_speech_app_token") ?: ""
mcpServer = call.argument<String>("mcp_config") ?: ""
// 保存密钥到安全存储 // 保存密钥到安全存储
saveKeysToSecureStorage(applicationContext) saveKeysToSecureStorage(applicationContext)

456
android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler copy.kt.bak

@ -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)
}
}

9
android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionHandler.kt

@ -25,7 +25,8 @@ class VoiceInteractionHandler(
private val openaiBaseUrl: String = "", private val openaiBaseUrl: String = "",
private val openaiModel: String = "", private val openaiModel: String = "",
private val volcanoSpeechAppId: String, private val volcanoSpeechAppId: String,
private val volcanoSpeechAppToken: String private val volcanoSpeechAppToken: String,
private val mcpServer: String
) { ) {
private val TAG = "VoiceInteractionHandler" private val TAG = "VoiceInteractionHandler"
@ -80,7 +81,7 @@ class VoiceInteractionHandler(
} }
// 初始化OpenAI服务 // 初始化OpenAI服务
openAIService.initialize(openaiApiKey, openaiBaseUrl, openaiModel) openAIService.initialize(openaiApiKey, openaiBaseUrl, openaiModel, mcpServer)
// 注册广播接收器 // 注册广播接收器
try { try {
@ -229,7 +230,7 @@ class VoiceInteractionHandler(
private fun processWithOpenAI(text: String) { private fun processWithOpenAI(text: String) {
// 保存当前用户输入,用于后续同步聊天记录 // 保存当前用户输入,用于后续同步聊天记录
currentUserInput = text currentUserInput = text
Log.d(TAG, "用户 问题: $text")
Thread { Thread {
try { try {
val messages = JSONArray().apply { val messages = JSONArray().apply {
@ -268,8 +269,6 @@ class VoiceInteractionHandler(
override fun onFunctionCall(call: JSONObject) { override fun onFunctionCall(call: JSONObject) {
FileLogger.d(TAG, "processWithOpenAI 收到函数调用请求: ${call.getString("name")}") FileLogger.d(TAG, "processWithOpenAI 收到函数调用请求: ${call.getString("name")}")
} }
} }
) )

6
android/app/src/main/kotlin/com/yunqiinnovation/deepsound/VoiceInteractionService.kt

@ -147,6 +147,7 @@ class VoiceInteractionService : Service() {
var openaiModel = MainActivity.openaiModel ?: "" // OpenAI模型 var openaiModel = MainActivity.openaiModel ?: "" // OpenAI模型
var volcanoSpeechAppId = MainActivity.volcanoSpeechAppId ?: "" var volcanoSpeechAppId = MainActivity.volcanoSpeechAppId ?: ""
var volcanoSpeechAppToken = MainActivity.volcanoSpeechAppToken ?: "" var volcanoSpeechAppToken = MainActivity.volcanoSpeechAppToken ?: ""
var mcpServer = MainActivity.mcpServer ?: ""
// 如果静态变量中没有配置,尝试从加密存储中加载 // 如果静态变量中没有配置,尝试从加密存储中加载
if (subscriptionKey.isEmpty() || serviceRegion.isEmpty() || openaiKey.isEmpty()) { if (subscriptionKey.isEmpty() || serviceRegion.isEmpty() || openaiKey.isEmpty()) {
@ -164,7 +165,7 @@ class VoiceInteractionService : Service() {
openaiModel = MainActivity.openaiModel ?: "" openaiModel = MainActivity.openaiModel ?: ""
volcanoSpeechAppId = MainActivity.volcanoSpeechAppId ?: "" volcanoSpeechAppId = MainActivity.volcanoSpeechAppId ?: ""
volcanoSpeechAppToken = MainActivity.volcanoSpeechAppToken ?: "" volcanoSpeechAppToken = MainActivity.volcanoSpeechAppToken ?: ""
mcpServer = MainActivity.mcpServer ?: ""
FileLogger.d(TAG, "已从加密存储加载配置信息") FileLogger.d(TAG, "已从加密存储加载配置信息")
} else { } else {
FileLogger.e(TAG, "无法从加密存储加载配置信息") FileLogger.e(TAG, "无法从加密存储加载配置信息")
@ -175,7 +176,8 @@ class VoiceInteractionService : Service() {
voiceInteractionHandler = VoiceInteractionHandler(applicationContext, voiceInteractionHandler = VoiceInteractionHandler(applicationContext,
subscriptionKey, serviceRegion, subscriptionKey, serviceRegion,
openaiKey, openaiBaseUrl, openaiModel, openaiKey, openaiBaseUrl, openaiModel,
volcanoSpeechAppId, volcanoSpeechAppToken) volcanoSpeechAppId, volcanoSpeechAppToken,
mcpServer)
// 初始化回调 // 初始化回调
voiceInteractionHandler.setCallback(object : VoiceInteractionHandler.InteractionCallback { voiceInteractionHandler.setCallback(object : VoiceInteractionHandler.InteractionCallback {

3
android/build.gradle.kts

@ -1,5 +1,6 @@
allprojects { allprojects {
repositories { repositories {
mavenLocal()
google() google()
mavenCentral() mavenCentral()
maven { url = uri("https://storage.googleapis.com/download.flutter.io") } maven { url = uri("https://storage.googleapis.com/download.flutter.io") }
@ -7,6 +8,8 @@ allprojects {
maven { maven {
url = uri("https://artifact.bytedance.com/repository/Volcengine/") url = uri("https://artifact.bytedance.com/repository/Volcengine/")
} }
maven { url = uri("https://maven.pkg.jetbrains.space/public/p/ktor/eap") }
} }
} }

11
android/settings.gradle.kts

@ -16,6 +16,7 @@ pluginManagement {
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories { repositories {
mavenLocal()
google() google()
mavenCentral() mavenCentral()
gradlePluginPortal() gradlePluginPortal()
@ -24,7 +25,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") id("dev.flutter.flutter-plugin-loader")
id("com.android.application") version "8.7.0" apply false id("com.android.application") version "8.8.0" apply false
id("com.android.library") version "8.8.0" apply false
id("org.jetbrains.kotlin.android") version "2.1.10" apply false id("org.jetbrains.kotlin.android") version "2.1.10" apply false
} }
@ -32,8 +34,13 @@ include(":app")
include(":azure_speech") include(":azure_speech")
include(":open_ai_service") include(":open_ai_service")
include(":volcano_speech") include(":volcano_speech")
include(":chat_storage")
include(":jieli")
include(":spotify_sdk")
// 设置azure_speech项目的路径 // 设置azure_speech项目的路径
project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android") project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android")
project(":open_ai_service").projectDir = file("../local_plugins/open_ai_service/android") project(":open_ai_service").projectDir = file("../local_plugins/open_ai_service/android")
project(":volcano_speech").projectDir = file("../local_plugins/volcano_speech/android") project(":volcano_speech").projectDir = file("../local_plugins/volcano_speech/android")
project(":chat_storage").projectDir = file("../local_plugins/chat_storage/android")
project(":jieli").projectDir = file("../local_plugins/jieli/android")
project(":spotify_sdk").projectDir = file("../local_plugins/spotify_sdk/android")

5
lib/core/bindings/initial_binding.dart

@ -9,7 +9,6 @@ import '../../data/services/bluetooth_media_button_service.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
import '../../data/services/voice_interaction_service.dart'; import '../../data/services/voice_interaction_service.dart';
import '../../data/services/open_ai_service_adapter.dart'; import '../../data/services/open_ai_service_adapter.dart';
/// 初始绑定,用于管理全局依赖 /// 初始绑定,用于管理全局依赖
class InitialBinding extends Bindings { class InitialBinding extends Bindings {
@override @override
@ -48,11 +47,15 @@ class InitialBinding extends Bindings {
Get.lazyPut<BluetoothMediaButtonService>( Get.lazyPut<BluetoothMediaButtonService>(
() => BluetoothMediaButtonService(), () => BluetoothMediaButtonService(),
fenix: true); fenix: true);
// 语音交互服务 // 语音交互服务
Get.lazyPut<VoiceInteractionService>(() => VoiceInteractionService(), Get.lazyPut<VoiceInteractionService>(() => VoiceInteractionService(),
fenix: true); fenix: true);
// 杰理蓝牙服务
// // 语言管理器(需要最先初始化) // // 语言管理器(需要最先初始化)
// Get.put<LoginService>(LoginService(), permanent: true); // Get.put<LoginService>(LoginService(), permanent: true);
// await LoginService().FirebaseInit(); // await LoginService().FirebaseInit();

89
lib/core/utils/jieli_constants.dart

@ -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; // 自定义
}

2
lib/core/widgets/message_bubble.dart

@ -52,7 +52,7 @@ class MessageBubble extends StatelessWidget {
), ),
// 加载指示器 // 加载指示器
if (message.isLoading) if (message.isIntermediate)
Padding( Padding(
padding: EdgeInsets.only(top: message.content.isNotEmpty ? 8.h : 0), padding: EdgeInsets.only(top: message.content.isNotEmpty ? 8.h : 0),
child: _buildLoadingIndicator(), child: _buildLoadingIndicator(),

105
lib/data/services/chat_history_service.dart

@ -1,16 +1,13 @@
import 'dart:convert'; import 'dart:convert';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../modules/chat/models/message_model.dart'; import '../../modules/chat/models/message_model.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
import 'package:chat_storage/chat_storage.dart';
/// 聊天记录管理服务,负责保存和加载聊天记录 /// 聊天记录管理服务,负责保存和加载聊天记录
class ChatHistoryService extends GetxService { class ChatHistoryService extends GetxService {
final GetStorage _storage = GetStorage(); // 最大历史记录数量
static const int _maxHistoryCount = 200; // 每个对话最多保存的消息数
// 存储键名
static const String _historyKeyPrefix = 'chat_history_';
static const int _maxHistoryCount = 10; // 每个对话最多保存的消息数
@override @override
void onInit() { void onInit() {
@ -18,50 +15,95 @@ class ChatHistoryService extends GetxService {
Logger.info('ChatHistoryService 已初始化'); Logger.info('ChatHistoryService 已初始化');
} }
/// 生成历史记录存储键
String _getHistoryKey(String agentId) => '$_historyKeyPrefix$agentId';
/// 保存聊天记录 /// 保存聊天记录
/// ///
/// [agentId] 代理ID /// [agentId] 代理ID
/// [messages] 消息列表 /// [messages] 消息列表
Future<void> saveHistory(String agentId, List<Message> messages) async { Future<void> saveHistory(String agentId, List<Message> messages) async {
try { try {
// 过滤掉加载中的消息 if (messages.isEmpty) return;
final filteredMessages = messages.where((m) => !m.isLoading).toList();
// 如果消息超过最大数量,只保留最新的消息 // 获取最新的消息
final messagesToSave = filteredMessages.length > _maxHistoryCount final latestMessage = messages.last;
? filteredMessages.sublist(filteredMessages.length - _maxHistoryCount)
: filteredMessages;
// 转换为JSON // 如果是加载中的消息则跳过
final jsonList = messagesToSave.map((m) => m.toJson()).toList(); if (latestMessage.isIntermediate) return;
final jsonString = jsonEncode(jsonList);
// 保存到存储 // 直接插入最新的消息到数据库
await _storage.write(_getHistoryKey(agentId), jsonString); await ChatStorage.saveMessage(
// Logger.info('已保存聊天记录,代理: $agentId,消息数: ${messagesToSave.length}'); sessionId: agentId,
message: latestMessage.content,
sender: latestMessage.role,
);
// 检查并清理过多的历史记录
await _cleanupOldMessages(agentId);
} catch (e) { } catch (e) {
Logger.error('保存聊天记录失败', e); Logger.error('保存聊天记录失败', e);
} }
} }
/// 清理旧消息,保持每个会话的消息数量不超过最大限制
Future<void> _cleanupOldMessages(String agentId) async {
try {
final pagedResult = await ChatStorage.getMessages(
sessionId: agentId,
page: 1,
pageSize: 1, // 只获取总数信息
);
// 如果消息总数超过最大限制,删除最早的消息
if (pagedResult.totalCount > _maxHistoryCount) {
final messagesToDelete = pagedResult.totalCount - _maxHistoryCount;
// 获取最早的N条消息ID
final oldestMessages = await ChatStorage.getMessages(
sessionId: agentId,
page: 1,
pageSize: messagesToDelete,
);
// 构建要删除的消息ID列表
final List<int> messageIds = oldestMessages.messages
.where((m) => m.id != null)
.map((m) => m.id!)
.toList();
// 删除这些消息
if (messageIds.isNotEmpty) {
await ChatStorage.deleteMessages(messageIds: messageIds);
}
}
} catch (e) {
Logger.error('清理旧消息失败', e);
}
}
/// 加载聊天记录 /// 加载聊天记录
/// ///
/// [agentId] 代理ID /// [agentId] 代理ID
/// 返回消息列表,如果没有找到或解析错误则返回空列表 /// 返回消息列表,如果没有找到或解析错误则返回空列表
List<Message> loadHistory(String agentId) { Future<List<Message>> loadHistory(String agentId) async {
try { try {
final jsonString = _storage.read<String>(_getHistoryKey(agentId)); final pagedResult = await ChatStorage.getMessages(
sessionId: agentId,
page: 1,
pageSize: _maxHistoryCount,
);
if (jsonString == null) { if (pagedResult.messages.isEmpty) {
return []; return [];
} }
final jsonList = jsonDecode(jsonString) as List; // 将ChatStorage.ChatMessage转换为Message
final messages = jsonList.map((json) => Message.fromJson(json)).toList(); final messages = pagedResult.messages.map((chatMessage) {
return Message(
role: chatMessage.sender,
content: chatMessage.message,
timestamp: DateTime.parse(chatMessage.timestamp), // 直接使用chatMessage的timestamp
// isLoading 默认false
// isLoading: metadata['isLoading'] ?? false,
);
}).toList();
return messages; return messages;
} catch (e) { } catch (e) {
@ -73,7 +115,7 @@ class ChatHistoryService extends GetxService {
/// 清除特定代理的聊天记录 /// 清除特定代理的聊天记录
Future<void> clearHistory(String agentId) async { Future<void> clearHistory(String agentId) async {
try { try {
await _storage.remove(_getHistoryKey(agentId)); await ChatStorage.deleteMessages(sessionId: agentId);
Logger.info('已清除聊天记录,代理: $agentId'); Logger.info('已清除聊天记录,代理: $agentId');
} catch (e) { } catch (e) {
Logger.error('清除聊天记录失败', e); Logger.error('清除聊天记录失败', e);
@ -83,12 +125,7 @@ class ChatHistoryService extends GetxService {
/// 清除所有聊天记录 /// 清除所有聊天记录
Future<void> clearAllHistory() async { Future<void> clearAllHistory() async {
try { try {
final keys = _storage.getKeys(); await ChatStorage.clearDatabase();
for (final key in keys) {
if (key.startsWith(_historyKeyPrefix)) {
await _storage.remove(key);
}
}
Logger.info('已清除所有聊天记录'); Logger.info('已清除所有聊天记录');
} catch (e) { } catch (e) {
Logger.error('清除所有聊天记录失败', e); Logger.error('清除所有聊天记录失败', e);

2
lib/data/services/deapsound_ai_service.dart

@ -28,7 +28,7 @@ class DeapsoundAIService implements AiService {
required String systemPrompt, required String systemPrompt,
}) async* { }) async* {
final client = http.Client(); final client = http.Client();
final url = Uri.parse('https://www.deapsound.com/ai/funcchat'); final url = Uri.parse('https://api.deapsound.com/ai/funcchat');
List contextsList = []; List contextsList = [];
String msg = ''; String msg = '';

97
lib/data/services/function_service.dart

@ -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;
}

2
lib/data/services/network/nw_api.dart

@ -1,4 +1,4 @@
class NWApi { class NWApi {
// static const baseApi = "https://www.deapsound.com"; // static const baseApi = "https://www.deapsound.com";
static const baseApi = "https://www.deapsound.com"; static const baseApi = "https://api.deapsound.com";
} }

191
lib/data/services/open_ai_service_adapter.dart

@ -1,89 +1,94 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:open_ai_service/open_ai_service.dart'; import 'package:open_ai_service/open_ai_service.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'ai_service.dart'; import 'ai_service.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
/// 定义流事件类型,用于区分不同类型的事件
enum StreamEventType { token, complete, error }
/// 流事件包装类
class StreamEvent {
final StreamEventType type;
final String? content;
StreamEvent(this.type, {this.content});
}
/// OpenAI服务适配器 - 连接AiService接口与OpenAIService插件 /// OpenAI服务适配器 - 连接AiService接口与OpenAIService插件
class OpenAIServiceAdapter implements AiService { class OpenAIServiceAdapter implements AiService {
final OpenAIService _openAIService = OpenAIService(); final OpenAIService _openAIService = OpenAIService();
StreamSubscription<OpenAIEvent>? _eventSubscription; StreamSubscription<OpenAIEvent>? _eventSubscription;
final StreamController<String> _tokenStreamController = StreamController<String>.broadcast(); final StreamController<StreamEvent> _tokenStreamController =
bool _isProcessingStream = false; StreamController<StreamEvent>.broadcast();
/// 构造函数 /// 构造函数
OpenAIServiceAdapter() { OpenAIServiceAdapter() {
printInfo(info: '创建OpenAIServiceAdapter实例'); printInfo(info: '创建OpenAIServiceAdapter实例');
_setupEventListener(); _setupEventListener();
} }
/// 设置事件监听器 /// 设置事件监听器
void _setupEventListener() { void _setupEventListener() {
try { try {
// 首先确保访问eventStream以初始化底层事件通道 // 首先确保访问eventStream以初始化底层事件通道
_openAIService.eventStream; _openAIService.eventStream;
// 设置事件处理 // 设置事件处理
_eventSubscription = _openAIService.eventStream.listen( _eventSubscription = _openAIService.eventStream.listen(
(event) { (event) {
if (!_isProcessingStream) return;
try { try {
switch (event.type) { switch (event.type) {
case OpenAIEventType.token: case OpenAIEventType.token:
if (event.content is String) { if (event.content is String) {
_tokenStreamController.add(event.content as String); _tokenStreamController.add(StreamEvent(StreamEventType.token,
content: event.content as String));
} else { } else {
printInfo(info: '收到非字符串类型的token: ${event.content}'); printInfo(info: '收到非字符串类型的token: ${event.content}');
} }
break; break;
case OpenAIEventType.complete: case OpenAIEventType.complete:
_isProcessingStream = false; _tokenStreamController
.add(StreamEvent(StreamEventType.complete));
break; break;
case OpenAIEventType.error: case OpenAIEventType.error:
if (event.content is String) { _tokenStreamController.add(StreamEvent(StreamEventType.error,
_tokenStreamController.addError(event.content as String); content: '未知错误: ${event.content}'));
} else {
_tokenStreamController.addError('未知错误: ${event.content}');
}
_isProcessingStream = false;
break; break;
case OpenAIEventType.functionCall: case OpenAIEventType.functionCall:
try { printInfo(
if (event.content is Map) { info:
_tokenStreamController.addError('收到函数调用,该流仅支持文本响应'); '接收到 functionCall content: ${event.content} meta: ${event.meta}');
} else { // _tokenStreamController.add(StreamEvent(
_tokenStreamController.addError('收到未知格式的函数调用'); // StreamEventType.error,
printError(info: '函数调用格式错误: ${event.content}'); // content: '收到函数调用,该流仅支持文本响应'
} // ));
} catch (e) {
printError(info: '处理函数调用事件出错: $e');
_tokenStreamController.addError('处理函数调用失败: $e');
}
_isProcessingStream = false;
break; break;
} }
} catch (e) { } catch (e) {
printError(info: '处理事件出错: $e'); printError(info: '处理事件出错: $e');
_tokenStreamController.addError('处理事件失败: $e'); _tokenStreamController
_isProcessingStream = false; .add(StreamEvent(StreamEventType.error, content: '处理事件失败: $e'));
} }
}, },
onError: (error) { onError: (error) {
printError(info: '事件流错误: $error'); printError(info: '事件流错误: $error');
_tokenStreamController.addError('事件流错误: $error'); _tokenStreamController.add(
_isProcessingStream = false; StreamEvent(StreamEventType.error, content: '事件流错误: $error'));
}, },
onDone: () { onDone: () {
printInfo(info: '事件流已关闭'); printInfo(info: '事件流已关闭');
_isProcessingStream = false;
}, },
); );
} catch (e) { } catch (e) {
printError(info: '设置事件监听器失败: $e'); printError(info: '设置事件监听器失败: $e');
} }
} }
/// 初始化OpenAI服务 /// 初始化OpenAI服务
Future<bool> initialize() async { Future<bool> initialize() async {
try { try {
@ -91,36 +96,55 @@ class OpenAIServiceAdapter implements AiService {
final apiKey = dotenv.env['OPENAI_API_KEY'] ?? ''; final apiKey = dotenv.env['OPENAI_API_KEY'] ?? '';
final baseUrl = dotenv.env['OPENAI_BASE_URL'] ?? ''; final baseUrl = dotenv.env['OPENAI_BASE_URL'] ?? '';
final model = dotenv.env['OPENAI_MODEL'] ?? ''; final model = dotenv.env['OPENAI_MODEL'] ?? '';
printInfo(info: '从.env读取OpenAI配置'); printInfo(info: '从.env读取OpenAI配置');
printInfo(info: '基础URL: $baseUrl'); printInfo(info: '基础URL: $baseUrl');
printInfo(info: '模型名称: $model'); printInfo(info: '模型名称: $model');
if (apiKey.isEmpty) { if (apiKey.isEmpty) {
printError(info: '错误: OpenAI API密钥未配置,请在.env文件中设置OPENAI_API_KEY'); printError(info: '错误: OpenAI API密钥未配置,请在.env文件中设置OPENAI_API_KEY');
return false; return false;
} }
// 读取.mcp.json文件
String mcpConfig = '';
try {
// 从Flutter资源包中加载.mcp.json
mcpConfig = await rootBundle.loadString('.mcp.json');
printInfo(info: '成功从资源包加载.mcp.json配置文件');
// 验证JSON格式
final jsonData = jsonDecode(mcpConfig);
if (jsonData is Map && jsonData.containsKey('mcpServers')) {
printInfo(info: '解析到有效的mcpServers配置');
} else {
printInfo(info: '.mcp.json内容格式不正确,期望包含mcpServers字段');
}
} catch (e) {
printError(info: '加载.mcp.json资源文件时出错: $e');
}
// 初始化OpenAI服务 // 初始化OpenAI服务
final result = await _openAIService.initialize( final result = await _openAIService.initialize(
apiKey: apiKey, apiKey: apiKey,
baseUrl: baseUrl, baseUrl: baseUrl,
model: model, model: model,
mcpServer: mcpConfig,
); );
if (result) { if (result) {
printInfo(info: 'OpenAI服务初始化成功'); printInfo(info: 'OpenAI服务初始化成功');
} else { } else {
printError(info: 'OpenAI服务初始化失败'); printError(info: 'OpenAI服务初始化失败');
} }
return result; return result;
} catch (e) { } catch (e) {
printError(info: 'OpenAI服务初始化异常: $e'); printError(info: 'OpenAI服务初始化异常: $e');
return false; return false;
} }
} }
/// 发送消息并获取回复 /// 发送消息并获取回复
@override @override
Future<String> sendMessage({ Future<String> sendMessage({
@ -129,21 +153,21 @@ class OpenAIServiceAdapter implements AiService {
}) async { }) async {
try { try {
// 在方法内直接转换 // 在方法内直接转换
final convertedMessages = messages.map((m) => final convertedMessages =
Map<String, dynamic>.from(m)).toList(); messages.map((m) => Map<String, dynamic>.from(m)).toList();
// 发送消息并获取回复 // 发送消息并获取回复
final response = await _openAIService.sendMessage( final response = await _openAIService.sendMessage(
messages: convertedMessages, messages: convertedMessages,
); );
return response; return response;
} catch (e) { } catch (e) {
printError(info: 'OpenAI发送消息失败: $e'); printError(info: 'OpenAI发送消息失败: $e');
throw '发送消息失败: $e'; throw '发送消息失败: $e';
} }
} }
/// 发送消息并获取流式回复 /// 发送消息并获取流式回复
@override @override
Stream<String> sendMessageStream({ Stream<String> sendMessageStream({
@ -152,34 +176,40 @@ class OpenAIServiceAdapter implements AiService {
}) async* { }) async* {
try { try {
// 在方法内直接转换 // 在方法内直接转换
final convertedMessages = messages.map((m) => final convertedMessages =
Map<String, dynamic>.from(m)).toList(); messages.map((m) => Map<String, dynamic>.from(m)).toList();
// 创建用于接收token的控制器 // 创建用于接收token的控制器
final localController = StreamController<String>(); final localController = StreamController<String>();
// 标记开始处理流
_isProcessingStream = true;
// 添加从广播流到本地流的订阅 // 添加从广播流到本地流的订阅
final subscription = _tokenStreamController.stream.listen( final subscription = _tokenStreamController.stream.listen((streamEvent) {
(token) => localController.add(token), switch (streamEvent.type) {
onError: (error) { case StreamEventType.token:
printError(info: '令牌流错误: $error'); if (streamEvent.content != null && !localController.isClosed) {
localController.addError(error); localController.add(streamEvent.content!);
localController.close(); }
}, break;
onDone: () { case StreamEventType.complete:
printInfo(info: '令牌流已完成'); if (!localController.isClosed) {
localController.close(); localController.close();
}
break;
case StreamEventType.error:
if (!localController.isClosed) {
printError(info: '令牌流错误: ${streamEvent.content}');
localController.addError(streamEvent.content ?? '未知错误');
localController.close();
}
break;
} }
); });
// 当本地控制器关闭时,取消订阅 // 当本地控制器关闭时,取消订阅
localController.onCancel = () { localController.onCancel = () {
subscription.cancel(); subscription.cancel();
}; };
// 启动流式消息请求 // 启动流式消息请求
bool started = false; bool started = false;
try { try {
@ -188,20 +218,22 @@ class OpenAIServiceAdapter implements AiService {
); );
} catch (e) { } catch (e) {
printError(info: '启动消息流失败: $e'); printError(info: '启动消息流失败: $e');
localController.addError('启动消息流失败: $e'); if (!localController.isClosed) {
localController.close(); localController.addError('启动消息流失败: $e');
_isProcessingStream = false; localController.close();
}
throw '启动消息流失败: $e'; throw '启动消息流失败: $e';
} }
if (!started) { if (!started) {
printError(info: '无法启动消息流'); printError(info: '无法启动消息流');
localController.addError('无法启动消息流'); if (!localController.isClosed) {
localController.close(); localController.addError('无法启动消息流');
_isProcessingStream = false; localController.close();
}
throw '无法启动消息流'; throw '无法启动消息流';
} }
// 通过yield*将controller的流转发 // 通过yield*将controller的流转发
yield* localController.stream; yield* localController.stream;
} catch (e) { } catch (e) {
@ -209,33 +241,33 @@ class OpenAIServiceAdapter implements AiService {
throw '流式消息处理失败: $e'; throw '流式消息处理失败: $e';
} }
} }
/// 注册函数 /// 注册函数
Future<bool> registerFunction(String name, String description, Map<String, dynamic> parameters) async { Future<bool> registerFunction(
String name, String description, Map<String, dynamic> parameters) async {
try { try {
final result = await _openAIService.registerFunction( final result = await _openAIService.registerFunction(
name: name, name: name,
description: description, description: description,
parameters: parameters, parameters: parameters,
); );
if (result) { if (result) {
printInfo(info: '函数 "$name" 注册成功'); printInfo(info: '函数 "$name" 注册成功');
} else { } else {
printError(info: '函数 "$name" 注册失败'); printError(info: '函数 "$name" 注册失败');
} }
return result; return result;
} catch (e) { } catch (e) {
printError(info: '注册函数失败: $e'); printError(info: '注册函数失败: $e');
return false; return false;
} }
} }
/// 释放资源 /// 释放资源
void dispose() { void dispose() {
try { try {
_isProcessingStream = false;
_eventSubscription?.cancel(); _eventSubscription?.cancel();
_tokenStreamController.close(); _tokenStreamController.close();
printInfo(info: 'OpenAIServiceAdapter资源已释放'); printInfo(info: 'OpenAIServiceAdapter资源已释放');
@ -243,5 +275,4 @@ class OpenAIServiceAdapter implements AiService {
printError(info: '释放资源时出错: $e'); printError(info: '释放资源时出错: $e');
} }
} }
}
}

15
lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart

@ -267,12 +267,12 @@ class AndroidVoiceInteractionService extends GetxService {
} }
/// 保存聊天记录 /// 保存聊天记录
void _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) { Future<void> _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) async {
try { try {
// 获取ChatHistoryService实例 if (userMessage.isEmpty) {
final chatHistoryService = Get.find<ChatHistoryService>(); return;
}
// 创建用户消息和助手消息
final userMsg = Message( final userMsg = Message(
role: 'user', role: 'user',
content: userMessage, content: userMessage,
@ -285,14 +285,17 @@ class AndroidVoiceInteractionService extends GetxService {
timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp + 1), // 确保助手消息时间戳晚于用户消息 timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp + 1), // 确保助手消息时间戳晚于用户消息
); );
// 获取ChatHistoryService实例
final chatHistoryService = Get.find<ChatHistoryService>();
// 加载现有历史记录 // 加载现有历史记录
final existingMessages = chatHistoryService.loadHistory(agentId); final existingMessages = await chatHistoryService.loadHistory(agentId);
// 添加新消息 // 添加新消息
existingMessages.addAll([userMsg, assistantMsg]); existingMessages.addAll([userMsg, assistantMsg]);
// 保存更新后的历史记录 // 保存更新后的历史记录
chatHistoryService.saveHistory(agentId, existingMessages); await chatHistoryService.saveHistory(agentId, existingMessages);
} catch (e) { } catch (e) {
Logger.error('保存聊天记录失败: $e'); Logger.error('保存聊天记录失败: $e');
} }

8
lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart

@ -250,7 +250,7 @@ class IosVoiceInteractionService extends GetxService {
); );
// 保存聊天历史 // 保存聊天历史
_saveChatHistory(userMsg, assistantMsg); await _saveChatHistory(userMsg, assistantMsg);
// 发送聊天历史事件 // 发送聊天历史事件
_sendChatHistoryEvent(userMsg.content, assistantMsg.content); _sendChatHistoryEvent(userMsg.content, assistantMsg.content);
@ -424,16 +424,16 @@ class IosVoiceInteractionService extends GetxService {
} }
// 保存聊天历史 // 保存聊天历史
void _saveChatHistory(Message userMsg, Message assistantMsg) { Future<void> _saveChatHistory(Message userMsg, Message assistantMsg) async {
try { try {
// 加载现有历史记录 // 加载现有历史记录
final existingMessages = _chatHistoryService.loadHistory(_agentId); final existingMessages = await _chatHistoryService.loadHistory(_agentId);
// 添加新消息 // 添加新消息
existingMessages.addAll([userMsg, assistantMsg]); existingMessages.addAll([userMsg, assistantMsg]);
// 保存更新后的历史记录 // 保存更新后的历史记录
_chatHistoryService.saveHistory(_agentId, existingMessages); await _chatHistoryService.saveHistory(_agentId, existingMessages);
Logger.info('保存聊天历史记录成功'); Logger.info('保存聊天历史记录成功');
} catch (e) { } catch (e) {

59
lib/data/services/voice_interaction_service.dart

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
@ -42,6 +43,11 @@ class VoiceInteractionService extends GetxService {
late String _openaiApiKey; late String _openaiApiKey;
late String _openaiBaseUrl; late String _openaiBaseUrl;
late String _openaiModel; late String _openaiModel;
late String _volcanoSpeechAppId;
late String _volcanoSpeechAppToken;
// MCP配置信息
String? _mcpConfig;
// 聊天历史服务 // 聊天历史服务
late final ChatHistoryService _chatHistoryService; late final ChatHistoryService _chatHistoryService;
@ -62,11 +68,28 @@ class VoiceInteractionService extends GetxService {
_openaiApiKey = dotenv.env['OPENAI_API_KEY'] ?? ''; _openaiApiKey = dotenv.env['OPENAI_API_KEY'] ?? '';
_openaiBaseUrl = dotenv.env['OPENAI_BASE_URL'] ?? ''; _openaiBaseUrl = dotenv.env['OPENAI_BASE_URL'] ?? '';
_openaiModel = dotenv.env['OPENAI_MODEL'] ?? ''; _openaiModel = dotenv.env['OPENAI_MODEL'] ?? '';
_volcanoSpeechAppId = dotenv.env['VOLCANO_SPEECH_APP_ID'] ?? '';
_volcanoSpeechAppToken = dotenv.env['VOLCANO_SPEECH_APP_TOKEN'] ?? '';
if (_azureSpeechKey.isEmpty || _azureSpeechRegion.isEmpty) { if (_azureSpeechKey.isEmpty || _azureSpeechRegion.isEmpty) {
Logger.warning('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); Logger.warning('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION');
} }
// 加载MCP配置
_loadMcpConfig();
}
/// 加载MCP配置
void _loadMcpConfig() {
try {
rootBundle.loadString('.mcp.json').then((config) {
_mcpConfig = config;
Logger.info('成功加载MCP配置, 配置内容: $_mcpConfig');
});
} catch (e) {
Logger.warning('加载MCP配置失败: $e');
_mcpConfig = null;
}
} }
/// 处理来自原生层的事件 /// 处理来自原生层的事件
@ -103,7 +126,7 @@ class VoiceInteractionService extends GetxService {
_eventStreamController.add(chatHistoryEvent); _eventStreamController.add(chatHistoryEvent);
// 保存聊天历史到ChatHistoryService // 保存聊天历史到ChatHistoryService
_saveChatHistory(agentId, userMessage, assistantMessage, DateTime.now().millisecondsSinceEpoch); _saveChatHistory(agentId, userMessage, assistantMessage);
break; break;
case 'enter_translation_mode': case 'enter_translation_mode':
@ -111,11 +134,7 @@ class VoiceInteractionService extends GetxService {
Logger.info('收到进入翻译模式事件,正在导航到翻译界面'); Logger.info('收到进入翻译模式事件,正在导航到翻译界面');
_navigateToTranslation(); _navigateToTranslation();
final translationModeEvent = GenericVoiceInteractionEvent(
type: 'enter_translation_mode',
timestamp: DateTime.now().millisecondsSinceEpoch,
);
_eventStreamController.add(translationModeEvent);
break; break;
} }
} }
@ -124,7 +143,7 @@ class VoiceInteractionService extends GetxService {
void _navigateToTranslation() { void _navigateToTranslation() {
try { try {
// 使用GetX导航到翻译页面 // 使用GetX导航到翻译页面
Get.toNamed(Routes.translation); Get.offNamedUntil(Routes.translation, (route) => route.settings.name == Routes.home);
Logger.info('已导航到翻译界面'); Logger.info('已导航到翻译界面');
} catch (e) { } catch (e) {
Logger.error('导航到翻译界面失败: $e'); Logger.error('导航到翻译界面失败: $e');
@ -132,12 +151,9 @@ class VoiceInteractionService extends GetxService {
} }
/// 保存聊天历史 /// 保存聊天历史
void _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) { Future<void> _saveChatHistory(String agentId, String userMessage, String assistantMessage) async {
try { try {
// 检查参数有效性 final timestamp = DateTime.now().millisecondsSinceEpoch;
if (userMessage.isEmpty) {
return;
}
// 创建用户消息和助手消息 // 创建用户消息和助手消息
final userMsg = Message( final userMsg = Message(
@ -153,13 +169,13 @@ class VoiceInteractionService extends GetxService {
); );
// 加载现有历史记录 // 加载现有历史记录
final existingMessages = _chatHistoryService.loadHistory(agentId); final existingMessages = await _chatHistoryService.loadHistory(agentId);
// 添加新消息 // 添加新消息
existingMessages.addAll([userMsg, assistantMsg]); existingMessages.addAll([userMsg, assistantMsg]);
// 保存更新后的历史记录 // 保存更新后的历史记录
_chatHistoryService.saveHistory(agentId, existingMessages); await _chatHistoryService.saveHistory(agentId, existingMessages);
Logger.info('已保存聊天历史: agentId=$agentId'); Logger.info('已保存聊天历史: agentId=$agentId');
} catch (e) { } catch (e) {
@ -215,14 +231,23 @@ class VoiceInteractionService extends GetxService {
try { try {
Logger.info('启动语音交互服务...'); Logger.info('启动语音交互服务...');
// 使用已加载的配置信息 // 构建参数
final result = await _channel.invokeMethod<bool>('startService', { final Map<String, dynamic> params = {
'azure_speech_key': _azureSpeechKey, 'azure_speech_key': _azureSpeechKey,
'azure_speech_region': _azureSpeechRegion, 'azure_speech_region': _azureSpeechRegion,
'openai_api_key': _openaiApiKey, 'openai_api_key': _openaiApiKey,
'openai_base_url': _openaiBaseUrl, 'openai_base_url': _openaiBaseUrl,
'openai_model': _openaiModel, 'openai_model': _openaiModel,
}) ?? false; 'volcano_speech_app_id': _volcanoSpeechAppId,
'volcano_speech_app_token': _volcanoSpeechAppToken,
};
// 如果有MCP配置,添加到参数中
if (_mcpConfig != null) {
params['mcp_config'] = _mcpConfig;
}
final result = await _channel.invokeMethod<bool>('startService', params) ?? false;
if (result) { if (result) {
_isServiceRunning.value = true; _isServiceRunning.value = true;

319
lib/modules/chat/controllers/chat_controller.dart

@ -3,18 +3,17 @@ import 'package:get/get.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import 'package:chat_storage/chat_storage.dart';
import '../../../data/services/ai_service.dart'; import '../../../data/services/ai_service.dart';
import '../../../data/services/deapsound_ai_service.dart'; import '../../../data/services/deapsound_ai_service.dart';
import '../../../data/services/kouzi_ai_service.dart'; import '../../../data/services/kouzi_ai_service.dart';
import '../../../data/services/speech_impl/voice_clone_tts_service.dart'; import '../../../data/services/speech_impl/voice_clone_tts_service.dart';
import '../models/message_model.dart'; import '../models/message_model.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import '../../../data/services/volcano_ai_service.dart';
import '../../../data/services/tts_service.dart'; import '../../../data/services/tts_service.dart';
import '../../../data/providers/agent_provider.dart'; import '../../../data/providers/agent_provider.dart';
import '../../../data/models/agent.dart'; import '../../../data/models/agent.dart';
import '../../../data/services/asr_service.dart'; import '../../../data/services/asr_service.dart';
import '../../../data/services/chat_history_service.dart';
import '../../../data/models/events/voice_interaction_event.dart'; import '../../../data/models/events/voice_interaction_event.dart';
import '../../../data/services/voice_interaction_service.dart'; import '../../../data/services/voice_interaction_service.dart';
import '../../../data/services/open_ai_service_adapter.dart'; import '../../../data/services/open_ai_service_adapter.dart';
@ -24,7 +23,6 @@ class ChatController extends GetxController {
late AiService _aiService; late AiService _aiService;
late TtsService _ttsService; late TtsService _ttsService;
final AsrService _asrService = Get.find<AsrService>(); final AsrService _asrService = Get.find<AsrService>();
final ChatHistoryService _historyService = Get.find<ChatHistoryService>();
final VoiceInteractionService _voiceInteractionService = final VoiceInteractionService _voiceInteractionService =
Get.find<VoiceInteractionService>(); Get.find<VoiceInteractionService>();
@ -46,6 +44,13 @@ class ChatController extends GetxController {
final RxBool isTtsEnabled = true.obs; final RxBool isTtsEnabled = true.obs;
final RxBool isVoiceRecognitionActive = false.obs; final RxBool isVoiceRecognitionActive = false.obs;
// 分页加载状态
final RxInt _currentPage = 1.obs;
final RxInt _totalPages = 1.obs;
final RxBool _isLoadingMore = false.obs;
final RxBool _hasMoreHistory = true.obs;
static const int _pageSize = 20; // 每页加载的消息数量
// 语音交互模式 // 语音交互模式
bool _isVoiceInteractionMode = false; bool _isVoiceInteractionMode = false;
@ -54,9 +59,6 @@ class ChatController extends GetxController {
final RxBool isListening = false.obs; final RxBool isListening = false.obs;
final RxBool isSpeechDetected = false.obs; final RxBool isSpeechDetected = false.obs;
// 临时消息索引,用于更新临时消息
int _tempMessageIndex = -1;
// 防抖计时器 // 防抖计时器
Timer? _scrollDebounceTimer; Timer? _scrollDebounceTimer;
@ -112,14 +114,8 @@ class ChatController extends GetxController {
break; break;
default: default:
// 默认使用OpenAIServiceAdapter // 默认使用OpenAIServiceAdapter
try { _aiService = Get.find<OpenAIServiceAdapter>();
_aiService = Get.find<OpenAIServiceAdapter>(); Logger.info('使用OpenAIServiceAdapter');
Logger.info('使用OpenAIServiceAdapter');
} catch (e) {
// 如果找不到OpenAIServiceAdapter,则回退到VolcanoAIService
Logger.info('未找到OpenAIServiceAdapter,回退使用VolcanoAIService: $e');
_aiService = Get.find<VolcanoAIService>();
}
} }
// 使用克隆音色语音合成 // 使用克隆音色语音合成
@ -141,8 +137,11 @@ class ChatController extends GetxController {
// 设置TTS服务 // 设置TTS服务
await _ttsService.setVoice(agent.voice); await _ttsService.setVoice(agent.voice);
// 加载聊天历史 // 添加滚动监听器,用于触发加载更多
_loadChatHistory(); scrollController.addListener(_scrollListener);
// 加载聊天历史 (加载第一页)
await _loadChatHistory();
// 如果没有历史记录,添加欢迎消息 // 如果没有历史记录,添加欢迎消息
if (messages.isEmpty && agent.welcomeMessage.isNotEmpty) { if (messages.isEmpty && agent.welcomeMessage.isNotEmpty) {
@ -226,7 +225,9 @@ class ChatController extends GetxController {
messages.add(userMsg); messages.add(userMsg);
messages.add(assistantMsg); messages.add(assistantMsg);
} }
_scrollToBottom(); WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToBottom();
});
} }
/// 停止当前交互 /// 停止当前交互
@ -252,6 +253,7 @@ class ChatController extends GetxController {
Logger.info('ChatController onClose'); Logger.info('ChatController onClose');
// 清理资源 // 清理资源
scrollController.removeListener(_scrollListener); // 移除监听器
scrollController.dispose(); scrollController.dispose();
_aiResponseSubscription?.cancel(); _aiResponseSubscription?.cancel();
_recognitionSubscription?.cancel(); _recognitionSubscription?.cancel();
@ -268,7 +270,7 @@ class ChatController extends GetxController {
// 保存当前聊天记录 // 保存当前聊天记录
if (agent.id != null) { if (agent.id != null) {
_historyService.saveHistory(agent.id!, messages.toList()); // _historyService.saveHistory(agent.id!, messages.toList());
} }
player.dispose(); player.dispose();
@ -304,11 +306,12 @@ class ChatController extends GetxController {
_aiResponseSubscription!.cancel(); _aiResponseSubscription!.cancel();
_aiResponseSubscription = null; _aiResponseSubscription = null;
// 将加载中的消息转换为正常消息,保留已输出的内容 // 将AI中间消息转换为正常消息,保留已输出的内容
final index = messages.indexWhere((m) => m.isLoading); final index =
messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant');
if (index != -1) { if (index != -1) {
messages[index] = messages[index].copyWith( messages[index] = messages[index].copyWith(
isLoading: false, isIntermediate: false,
); );
messages.refresh(); messages.refresh();
} }
@ -338,10 +341,10 @@ class ChatController extends GetxController {
// 清空输入框 // 清空输入框
inputText.value = ''; inputText.value = '';
// 添加加载中的消息 // 添加中间状态的AI消息
final loadingMessage = Message.loading(); final intermediateMessage = Message.intermediate('assistant');
messages.add(loadingMessage); messages.add(intermediateMessage);
messages.refresh();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToBottom(); _scrollToBottom();
}); });
@ -369,15 +372,15 @@ class ChatController extends GetxController {
if (_shouldCancelAiResponse) return; if (_shouldCancelAiResponse) return;
if (chunk is Map) { if (chunk is Map) {
_updateLoadingMessage(data: chunk); _updateIntermediateMessage(data: chunk);
if (chunk['musicaudio'] != null) { if (chunk['musicaudio'] != null) {
play(chunk['musicaudio']); play(chunk['musicaudio']);
} }
} else { } else {
fullResponse += chunk; fullResponse += chunk;
// 更新加载中的消息内容 // 更新中间状态的消息内容
_updateLoadingMessage(content: fullResponse); _updateIntermediateMessage(content: fullResponse);
// 如果TTS启用,使用流式处理发送到TTS // 如果TTS启用,使用流式处理发送到TTS
if (isTtsEnabled.value) { if (isTtsEnabled.value) {
@ -388,21 +391,18 @@ class ChatController extends GetxController {
onDone: () { onDone: () {
if (_shouldCancelAiResponse) return; if (_shouldCancelAiResponse) return;
// 完成响应,将isLoading设为false // 完成响应,将isIntermediate设为false
_finalizeResponse(fullResponse); _finalizeResponse(fullResponse);
// 如果TTS启用,处理缓冲区中的剩余文本 // 如果TTS启用,处理缓冲区中的剩余文本
if (isTtsEnabled.value) { if (isTtsEnabled.value) {
_ttsService.flushStream(); _ttsService.flushStream();
} }
// 保存聊天历史
_saveChatHistory();
}, },
onError: (error) { onError: (error) {
Logger.error('AI响应错误', error); Logger.error('AI响应错误', error);
// 更新加载中的消息为错误消息 // 更新中间状态的消息为错误消息
_handleResponseError(); _handleResponseError();
}, },
); );
@ -418,7 +418,7 @@ class ChatController extends GetxController {
final List<Map<String, String>> messageHistory = []; final List<Map<String, String>> messageHistory = [];
// 只取最近的10条消息作为上下文 // 只取最近的10条消息作为上下文
final recentMessages = messages.where((m) => !m.isLoading).toList(); final recentMessages = messages.where((m) => !m.isIntermediate).toList();
final startIndex = final startIndex =
recentMessages.length > 10 ? recentMessages.length - 10 : 0; recentMessages.length > 10 ? recentMessages.length - 10 : 0;
@ -433,23 +433,20 @@ class ChatController extends GetxController {
return messageHistory; return messageHistory;
} }
// 更新加载中的消息 // 更新中间状态的消息
void _updateLoadingMessage({Map? data, String? content}) { void _updateIntermediateMessage({Map? data, String? content}) {
final index = messages.indexWhere((m) => m.isLoading); final index =
messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant');
if (index != -1) { if (index != -1) {
if (data != null) { if (data != null) {
Map messagesData = messages[index].data ?? {}; Map messagesData = messages[index].data ?? {};
messages[index] = messages[index].copyWith( messages[index] = messages[index].copyWith(
data: {...messagesData, ...data}, data: {...messagesData, ...data},
isLoading: true,
);
}
if (content != null) {
messages[index] = messages[index].copyWith(
content: content,
isLoading: true,
); );
} }
messages[index] = messages[index].copyWith(
content: content,
);
messages.refresh(); messages.refresh();
_scrollToBottom(); _scrollToBottom();
} }
@ -479,29 +476,51 @@ class ChatController extends GetxController {
isPlay.value = false; isPlay.value = false;
} }
// 完成响应 // 更新或添加最终响应
void _finalizeResponse(String content) { void _finalizeResponse(String content) {
final index = messages.indexWhere((m) => m.isLoading); // 查找AI的中间状态消息
final index =
messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant');
if (index != -1) { if (index != -1) {
// 更新中间状态消息为最终状态
messages[index] = messages[index].copyWith( messages[index] = messages[index].copyWith(
content: content, content: content,
isLoading: false, isIntermediate: false,
timestamp: DateTime.now(), // 更新时间戳为最终时间
); );
messages.refresh(); // 保存最终的助手消息到数据库
_scrollToBottom(); _saveMessageToDb(messages[index]);
} else {
// 如果没有中间状态消息,直接添加新消息 (理论上不应发生)
final assistantMsg = Message(
role: 'assistant',
content: content,
timestamp: DateTime.now(),
);
messages.add(assistantMsg);
_saveMessageToDb(assistantMsg); // 保存到数据库
} }
isLoading.value = false;
isSending.value = false; isSending.value = false;
_aiResponseSubscription = null; _aiResponseSubscription = null; // 清理订阅
_shouldCancelAiResponse = false; // 重置取消标志
// 响应完成后,重新启动语音识别的自动停止计时器
if (_isVoiceInteractionMode) {
_startAutoStopTimer();
}
} }
// 处理响应错误 // 处理响应错误
void _handleResponseError() { void _handleResponseError() {
final index = messages.indexWhere((m) => m.isLoading); // 查找AI的中间状态消息
final index =
messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant');
if (index != -1) { if (index != -1) {
messages[index] = messages[index].copyWith( messages[index] = messages[index].copyWith(
content: '抱歉,我遇到了一些问题,无法回应您的请求。', content: '抱歉,我遇到了一些问题,无法回应您的请求。',
isLoading: false, isIntermediate: false,
); );
messages.refresh(); messages.refresh();
} }
@ -534,9 +553,6 @@ class ChatController extends GetxController {
isListening.value = true; isListening.value = true;
recognizedText.value = ''; recognizedText.value = '';
// 重置临时消息索引
_tempMessageIndex = -1;
// 启动自动停止计时器 // 启动自动停止计时器
_startAutoStopTimer(); _startAutoStopTimer();
@ -628,24 +644,24 @@ class ChatController extends GetxController {
_interruptCurrentResponse("检测到用户开始讲话,中断当前响应"); _interruptCurrentResponse("检测到用户开始讲话,中断当前响应");
} }
// 更新或创建临时消息 // 查找或创建用户的中间状态消息
if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { final intermediateIndex =
// 更新已有的临时消息 messages.indexWhere((m) => m.isIntermediate && m.role == 'user');
messages[_tempMessageIndex] = messages[_tempMessageIndex].copyWith(
if (intermediateIndex >= 0) {
// 更新已有的中间状态消息
messages[intermediateIndex] = messages[intermediateIndex].copyWith(
content: text, content: text,
); );
messages.refresh(); messages.refresh();
} else { } else {
// 添加新的临时消息 // 添加新的中间状态消息
final tempMessage = Message( var intermediateMessage = Message.intermediate('user');
role: 'user', intermediateMessage = intermediateMessage.copyWith(content: text);
content: text, messages.add(intermediateMessage);
timestamp: DateTime.now(),
);
messages.add(tempMessage);
_tempMessageIndex = messages.length - 1;
messages.refresh(); messages.refresh();
} }
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToBottom(); _scrollToBottom();
}); });
@ -661,14 +677,13 @@ class ChatController extends GetxController {
// 更新活动时间 // 更新活动时间
_updateActivityTime(); _updateActivityTime();
// 查找并移除临时消息 // 查找并移除用户的中间状态消息
if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { final intermediateIndex =
messages.removeAt(_tempMessageIndex); messages.indexWhere((m) => m.isIntermediate && m.role == 'user');
if (intermediateIndex >= 0) {
messages.removeAt(intermediateIndex);
} }
// 重置临时消息索引
_tempMessageIndex = -1;
// 发送识别到的文本 // 发送识别到的文本
sendMessage(text); sendMessage(text);
@ -695,16 +710,21 @@ class ChatController extends GetxController {
// 清理语音识别资源 // 清理语音识别资源
void _cleanupRecognition() { void _cleanupRecognition() {
// 查找并移除临时消息 // 查找并移除用户的中间状态消息
if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { final intermediateIndex =
if (messages[_tempMessageIndex].content.trim().isEmpty) { messages.indexWhere((m) => m.isIntermediate && m.role == 'user');
messages.removeAt(_tempMessageIndex); if (intermediateIndex >= 0) {
if (messages[intermediateIndex].content.trim().isEmpty) {
messages.removeAt(intermediateIndex);
} else {
// 如果中间状态消息有内容,将其转换为正式消息
messages[intermediateIndex] = messages[intermediateIndex].copyWith(
isIntermediate: false,
);
} }
messages.refresh();
} }
// 重置临时消息索引
_tempMessageIndex = -1;
// 停止语音识别 // 停止语音识别
isVoiceRecognitionActive.value = false; isVoiceRecognitionActive.value = false;
isListening.value = false; isListening.value = false;
@ -726,15 +746,21 @@ class ChatController extends GetxController {
recognizedText.value = ''; recognizedText.value = '';
isSpeechDetected.value = false; isSpeechDetected.value = false;
// 查找并移除临时消息 // 查找并处理用户的中间状态消息
if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { final intermediateIndex =
if (messages[_tempMessageIndex].content.trim().isEmpty) { messages.indexWhere((m) => m.isIntermediate && m.role == 'user');
messages.removeAt(_tempMessageIndex); if (intermediateIndex >= 0) {
if (messages[intermediateIndex].content.trim().isEmpty) {
// 如果没有内容则移除
messages.removeAt(intermediateIndex);
} else {
// 有内容则转为正式消息
messages[intermediateIndex] = messages[intermediateIndex].copyWith(
isIntermediate: false,
);
} }
messages.refresh();
} }
// 重置临时消息索引
_tempMessageIndex = -1;
} catch (e) { } catch (e) {
Logger.error('停止语音识别失败', e); Logger.error('停止语音识别失败', e);
} }
@ -743,7 +769,7 @@ class ChatController extends GetxController {
// 清空聊天记录 // 清空聊天记录
void clearChat() { void clearChat() {
messages.clear(); messages.clear();
_ttsService.stop();
// 添加欢迎消息 // 添加欢迎消息
if (agent.welcomeMessage.isNotEmpty) { if (agent.welcomeMessage.isNotEmpty) {
messages.add(Message( messages.add(Message(
@ -752,31 +778,118 @@ class ChatController extends GetxController {
timestamp: DateTime.now(), timestamp: DateTime.now(),
)); ));
} }
// 删除数据库中的历史记录
ChatStorage.deleteMessages(sessionId: agent.id).catchError((e) {
Logger.error('清空数据库聊天记录失败: $e');
});
}
// 删除存储的历史记录 // 滚动监听,用于触发加载更多历史记录
_historyService.clearHistory(agent.id); void _scrollListener() {
// 当滚动到顶部并且有更多历史记录且不在加载中时,加载更多
if (scrollController.position.pixels ==
scrollController.position.minScrollExtent &&
_hasMoreHistory.value &&
!_isLoadingMore.value) {
_loadMoreHistory();
}
} }
// 加载聊天历史 // 加载聊天历史 (首次加载或刷新)
void _loadChatHistory() { Future<void> _loadChatHistory() async {
// 从历史服务加载 isLoading.value = true;
final history = _historyService.loadHistory(agent.id); _currentPage.value = 1; // 重置为第一页
if (history.isNotEmpty) { _hasMoreHistory.value = true; // 假设有更多
messages.clear();
messages.addAll(history); try {
final pagedResult = await ChatStorage.getMessages(
sessionId: agent.id,
page: _currentPage.value,
pageSize: _pageSize,
);
_totalPages.value = pagedResult.totalPages;
_hasMoreHistory.value = _currentPage.value < _totalPages.value;
final historyMessages = pagedResult.messages.map((chatMsg) {
return Message(
role: chatMsg.sender,
content: chatMsg.message,
timestamp: DateTime.parse(chatMsg.timestamp),
);
}).toList();
messages.assignAll(historyMessages.reversed); // 最新的在底部,所以要反转
// 滚动到底部 (如果是首次加载)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
scrollController.jumpTo(scrollController.position.maxScrollExtent);
}
});
} catch (e) {
Logger.error('加载聊天历史失败', e);
} finally {
isLoading.value = false;
} }
} }
// 保存聊天历史 // 加载更多历史记录
void _saveChatHistory() { Future<void> _loadMoreHistory() async {
_historyService.saveHistory(agent.id, messages); if (_isLoadingMore.value || !_hasMoreHistory.value) return;
_isLoadingMore.value = true;
_currentPage.value++;
try {
final pagedResult = await ChatStorage.getMessages(
sessionId: agent.id,
page: _currentPage.value,
pageSize: _pageSize,
);
_totalPages.value = pagedResult.totalPages;
_hasMoreHistory.value = _currentPage.value < _totalPages.value;
final olderMessages = pagedResult.messages.map((chatMsg) {
return Message(
role: chatMsg.sender,
content: chatMsg.message,
timestamp: DateTime.parse(chatMsg.timestamp),
);
}).toList();
// 将旧消息插入到列表顶部
messages.insertAll(0, olderMessages.reversed);
} catch (e) {
Logger.error('加载更多聊天历史失败', e);
_currentPage.value--; // 加载失败,恢复页码
} finally {
_isLoadingMore.value = false;
}
} }
// 添加消息,供外部调用 // 保存单条聊天消息到数据库
Future<void> _saveMessageToDb(Message message) async {
if (message.isIntermediate) return; // 不保存中间状态的消息
try {
await ChatStorage.saveMessage(
sessionId: agent.id,
message: message.content,
sender: message.role,
);
} catch (e) {
Logger.error('保存消息到数据库失败', e);
}
}
// 添加消息,供外部调用 (如用户发送消息、AI回复)
void addMessage(Message message) { void addMessage(Message message) {
messages.add(message); messages.add(message);
_saveChatHistory(); _saveMessageToDb(message); // 保存到数据库
_scrollToBottom(); WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToBottom();
});
} }
void _stopVoiceRecognitionAutomatically() { void _stopVoiceRecognitionAutomatically() {

20
lib/modules/chat/models/message_model.dart

@ -3,14 +3,14 @@ class Message {
final String content; final String content;
final Map? data; final Map? data;
final DateTime timestamp; final DateTime timestamp;
final bool isLoading; final bool isIntermediate; // 中间状态标记,替代isLoading
Message({ Message({
required this.role, required this.role,
required this.content, required this.content,
required this.timestamp, required this.timestamp,
this.data, this.data,
this.isLoading = false, this.isIntermediate = false, // 默认不是中间状态
}); });
// 从JSON构造函数 // 从JSON构造函数
@ -20,7 +20,7 @@ class Message {
content: json['content'] as String, content: json['content'] as String,
data: json['data'], data: json['data'],
timestamp: DateTime.parse(json['timestamp'] as String), timestamp: DateTime.parse(json['timestamp'] as String),
isLoading: json['isLoading'] as bool? ?? false, isIntermediate: json['isIntermediate'] as bool? ?? false,
); );
} }
@ -31,17 +31,17 @@ class Message {
'content': content, 'content': content,
'data': data, 'data': data,
'timestamp': timestamp.toIso8601String(), 'timestamp': timestamp.toIso8601String(),
'isLoading': isLoading, 'isIntermediate': isIntermediate,
}; };
} }
// 创建一个加载中的消息 // 创建一个中间状态的消息
factory Message.loading() { factory Message.intermediate(String role) {
return Message( return Message(
role: 'assistant', role: role,
content: '', content: '',
timestamp: DateTime.now(), timestamp: DateTime.now(),
isLoading: true, isIntermediate: true,
); );
} }
@ -51,14 +51,14 @@ class Message {
String? content, String? content,
Map? data, Map? data,
DateTime? timestamp, DateTime? timestamp,
bool? isLoading, bool? isIntermediate,
}) { }) {
return Message( return Message(
role: role ?? this.role, role: role ?? this.role,
content: content ?? this.content, content: content ?? this.content,
data: data ?? this.data, data: data ?? this.data,
timestamp: timestamp ?? this.timestamp, timestamp: timestamp ?? this.timestamp,
isLoading: isLoading ?? this.isLoading, isIntermediate: isIntermediate ?? this.isIntermediate,
); );
} }
} }

2
lib/modules/chat/views/chat_view.dart

@ -303,7 +303,7 @@ class ChatView extends GetView<ChatController> {
_buildMessageContent(message, isDarkMode, isUser), _buildMessageContent(message, isDarkMode, isUser),
// 加载中动画 // 加载中动画
if (message.isLoading) if (message.isIntermediate)
Padding( Padding(
padding: EdgeInsets.only(top: 8.h), padding: EdgeInsets.only(top: 8.h),
child: Row( child: Row(

11
lib/modules/jieli_test/bindings/jieli_test_binding.dart

@ -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(),
);
}
}

323
lib/modules/jieli_test/controllers/jieli_test_controller.dart

@ -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) {
// 释放资源错误处理
}
}
}

568
lib/modules/jieli_test/views/jieli_test_view.dart

@ -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;
}
}
}

4
lib/modules/pairing/controllers/pairing_controller.dart

@ -6,11 +6,12 @@ import '../../../data/services/classic_bluetooth_service.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import 'dart:io'; import 'dart:io';
import '../../../data/services/voice_interaction_service.dart'; import '../../../data/services/voice_interaction_service.dart';
/// 简化的配对控制器,仅用于显示已连接经典蓝牙设备和处理基本配对流程 /// 简化的配对控制器,仅用于显示已连接经典蓝牙设备和处理基本配对流程
class PairingController extends GetxController { class PairingController extends GetxController {
static PairingController get to => Get.find(); static PairingController get to => Get.find();
// 经典蓝牙服务 // 经典蓝牙服务
final ClassicBluetoothService _bluetoothService = final ClassicBluetoothService _bluetoothService =
Get.find<ClassicBluetoothService>(); Get.find<ClassicBluetoothService>();
@ -109,6 +110,7 @@ class PairingController extends GetxController {
/// 初始化流程 /// 初始化流程
Future<void> _initialize() async { Future<void> _initialize() async {
try { try {
// 初始化语音交互服务 // 初始化语音交互服务
_voiceInteractionService.initialize(); _voiceInteractionService.initialize();

45
lib/modules/profile/controllers/profile_controller.dart

@ -13,7 +13,6 @@ import 'package:path_provider/path_provider.dart';
import '../../../data/services/network/api.dart'; import '../../../data/services/network/api.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
class ProfileController extends GetxController { class ProfileController extends GetxController {
static ProfileController get to => Get.find(); static ProfileController get to => Get.find();
@ -118,23 +117,23 @@ class ProfileController extends GetxController {
// _isEditing.value = !_isEditing.value; // _isEditing.value = !_isEditing.value;
// } // }
// 验证base64图片数据 // 验证base64图片数据
// Future<void> _verifyBase64Image(String base64String) async { // Future<void> _verifyBase64Image(String base64String) async {
// try { // try {
// // 解码base64数据 // // 解码base64数据
// List<int> imageBytes = base64Decode(base64String); // List<int> imageBytes = base64Decode(base64String);
// // 获取应用文档目录 // // 获取应用文档目录
// final appDir = await getApplicationDocumentsDirectory(); // final appDir = await getApplicationDocumentsDirectory();
// final verifyPath = '${appDir.path}/verify_base64_${DateTime.now().millisecondsSinceEpoch}.jpg'; // final verifyPath = '${appDir.path}/verify_base64_${DateTime.now().millisecondsSinceEpoch}.jpg';
// // 将解码后的数据写入文件 // // 将解码后的数据写入文件
// final file = File(verifyPath); // final file = File(verifyPath);
// await file.writeAsBytes(imageBytes); // await file.writeAsBytes(imageBytes);
// // 记录验证文件的路径 // // 记录验证文件的路径
// Logger().d('Base64验证图片已保存到: $verifyPath'); // Logger().d('Base64验证图片已保存到: $verifyPath');
// // 显示图片预览对话框 // // 显示图片预览对话框
// Get.dialog( // Get.dialog(
// Dialog( // Dialog(
@ -157,7 +156,7 @@ class ProfileController extends GetxController {
// ), // ),
// ), // ),
// ); // );
// Get.snackbar('验证', '已将Base64转换回图片,请查看预览'); // Get.snackbar('验证', '已将Base64转换回图片,请查看预览');
// } catch (e) { // } catch (e) {
// Logger().e('验证Base64图片失败: $e'); // Logger().e('验证Base64图片失败: $e');
@ -171,8 +170,8 @@ class ProfileController extends GetxController {
// 准备数据 // 准备数据
Map<String, dynamic> data = { Map<String, dynamic> data = {
"image": "", // 默认空字符串 "image": "", // 默认空字符串
"language": currentLanguage.value,//当前用户语言 "language": currentLanguage.value, //当前用户语言
"name": userInfo.value.name,//当前用户名 "name": userInfo.value.name, //当前用户名
}; };
// 如果有头像,转换为base64 // 如果有头像,转换为base64
@ -195,22 +194,21 @@ class ProfileController extends GetxController {
} }
} }
Logger().d('准备更新用户信息: $data'); Logger().d('准备更新用户信息: $data');
// 调用API // 调用API
final response = await Api.updateUserSetting(data); final response = await Api.updateUserSetting(data);
Logger().d('--更新用户信息响应: $response'); Logger().d('--更新用户信息响应: $response');
if(response != null){ if (response != null) {
Logger().d('更新成功: $response'); Logger().d('更新成功: $response');
Get.snackbar('成功', '同步到服务器成功'); Get.snackbar('成功', '同步到服务器成功');
userInfo.update((u) { userInfo.update((u) {
if (u!= null) { if (u != null) {
u.name = response['name']?? ''; u.name = response['name'] ?? '';
u.avatar = response['avatar']?? ''; u.avatar = response['avatar'] ?? '';
} }
}); });
Logger().d('更新后的用户信息: ${userInfo.value.toJson()}'); Logger().d('更新后的用户信息: ${userInfo.value.toJson()}');
}else{ } else {
Logger().e('更新失败: $response'); Logger().e('更新失败: $response');
Get.snackbar('错误', '同步到服务器失败,请重试'); Get.snackbar('错误', '同步到服务器失败,请重试');
return; return;
@ -236,10 +234,10 @@ class ProfileController extends GetxController {
} }
// 下载并保存远程头像 // 下载并保存远程头像
Future<void> _downloadAndSaveAvatar(String url) async { Future<void> _downloadAndSaveAvatar(String url) async {
try { try {
Logger().d('开始下载头像: $url'); Logger().d('开始下载头像: $url');
final dio = Dio(BaseOptions( final dio = Dio(BaseOptions(
headers: { headers: {
'accept': '*/*', 'accept': '*/*',
@ -251,9 +249,9 @@ class ProfileController extends GetxController {
url, url,
options: Options(responseType: ResponseType.bytes), options: Options(responseType: ResponseType.bytes),
); );
Logger().d('下载状态: ${response.statusCode}'); Logger().d('下载状态: ${response.statusCode}');
if (response.statusCode == 200) { if (response.statusCode == 200) {
// 保存到应用文档目录 // 保存到应用文档目录
final appDir = await getApplicationDocumentsDirectory(); final appDir = await getApplicationDocumentsDirectory();
@ -282,12 +280,12 @@ class ProfileController extends GetxController {
// 更新用户头像 // 更新用户头像
Future<void> updateUserAvatar() async { Future<void> updateUserAvatar() async {
final XFile? image = await _picker.pickImage( final XFile? image = await _picker.pickImage(
source: ImageSource.gallery,// 从相册选择 source: ImageSource.gallery, // 从相册选择
maxWidth: 800, maxWidth: 800,
maxHeight: 800, maxHeight: 800,
imageQuality: 85, imageQuality: 85,
); );
if (image != null) { if (image != null) {
// 检查文件大小 // 检查文件大小
final file = File(image.path); final file = File(image.path);
@ -356,7 +354,8 @@ class ProfileController extends GetxController {
if (Get.isRegistered<SettingsController>()) { if (Get.isRegistered<SettingsController>()) {
final settingsController = Get.find<SettingsController>(); final settingsController = Get.find<SettingsController>();
settingsController.userInfo.value.name = userInfo.value.name; settingsController.userInfo.value.name = userInfo.value.name;
settingsController.userInfo.value.avatar = '${userInfo.value.avatar}?time=${DateTime.now().millisecondsSinceEpoch}'; settingsController.userInfo.value.avatar =
'${userInfo.value.avatar}?time=${DateTime.now().millisecondsSinceEpoch}';
//settingsController.loadUserInfo(); //settingsController.loadUserInfo();
settingsController.userInfo.refresh(); settingsController.userInfo.refresh();
} }

22
lib/modules/settings/views/settings_view.dart

@ -481,6 +481,28 @@ class SettingsView extends GetView<SettingsController> {
}, },
isDarkMode: isDarkMode, isDarkMode: isDarkMode,
), ),
Divider(
height: 1,
color: isDarkMode
? Colors.white.withOpacity(0.1)
: Colors.grey[200]),
// 杰理测试
_buildSimpleNavigationSetting(
title: '杰理蓝牙测试',
subtitle: '测试杰理蓝牙设备扫描、连接和功能',
icon: Icons.bluetooth,
iconBgColor: isDarkMode
? Colors.blue[900]!.withOpacity(0.3)
: Colors.blue[100]!,
iconColor:
isDarkMode ? Colors.blue[300]! : Colors.blue[600]!,
titleColor: isDarkMode ? Colors.white : null,
subtitleColor: isDarkMode ? Colors.white70 : null,
onTap: () {
Get.toNamed(Routes.jieliTest);
},
isDarkMode: isDarkMode,
),
Divider( Divider(
height: 1, height: 1,
color: isDarkMode color: isDarkMode

2
lib/modules/splash/controllers/splash_controller.dart

@ -34,7 +34,7 @@ class SplashController extends GetxController {
// 进度条动画(持续2秒) // 进度条动画(持续2秒)
void _startLoadingAnimation() { void _startLoadingAnimation() {
const totalDuration = 2000; // 动画总时长2秒 const totalDuration = 500; // 动画总时长2秒
const steps = totalDuration ~/ 50; // 40次更新(50ms间隔) const steps = totalDuration ~/ 50; // 40次更新(50ms间隔)
final stepValue = 1.0 / steps; final stepValue = 1.0 / steps;

7
lib/routes/app_pages.dart

@ -31,6 +31,8 @@ import '../modules/pairing/bindings/pairing_binding.dart';
import './app_routes.dart'; import './app_routes.dart';
import '../modules/settings/views/settings_view.dart'; import '../modules/settings/views/settings_view.dart';
import '../modules/settings/bindings/settings_binding.dart'; import '../modules/settings/bindings/settings_binding.dart';
import '../modules/jieli_test/views/jieli_test_view.dart';
import '../modules/jieli_test/bindings/jieli_test_binding.dart';
abstract class AppPages { abstract class AppPages {
static final pages = [ static final pages = [
@ -119,5 +121,10 @@ abstract class AppPages {
page: () => const SettingsView(), page: () => const SettingsView(),
binding: SettingsBinding(), binding: SettingsBinding(),
), ),
GetPage(
name: Routes.jieliTest,
page: () => const JieliTestView(),
binding: JieliTestBinding(),
),
]; ];
} }

1
lib/routes/app_routes.dart

@ -24,4 +24,5 @@ abstract class Routes {
static const FLUTTER_TTS_TEST = '/flutter_tts_test'; static const FLUTTER_TTS_TEST = '/flutter_tts_test';
static const FLUTTER_ASR_TEST = '/flutter_asr_test'; static const FLUTTER_ASR_TEST = '/flutter_asr_test';
static const editProfile = '/edit_profile'; static const editProfile = '/edit_profile';
static const jieliTest = '/jieli_test';
} }

0
local_plugins/azure_speech/LICENSE

42
local_plugins/azure_speech/android/build.gradle.kts

@ -1,32 +1,11 @@
import com.android.build.gradle.LibraryExtension
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:7.3.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.10")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
plugins { plugins {
id("com.android.library") id("com.android.library")
kotlin("android") id("org.jetbrains.kotlin.android")
} }
// 配置android扩展 android {
configure<LibraryExtension> {
namespace = "com.yunqiinnovation.azure_speech" namespace = "com.yunqiinnovation.azure_speech"
compileSdkVersion(33) compileSdk = 33
defaultConfig { defaultConfig {
minSdk = 21 minSdk = 21
@ -48,6 +27,20 @@ configure<LibraryExtension> {
lintOptions { lintOptions {
isCheckReleaseBuilds = false isCheckReleaseBuilds = false
} }
// 添加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")
}
}
} }
// 显式设置Kotlin JVM目标版本 // 显式设置Kotlin JVM目标版本
@ -58,7 +51,6 @@ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
} }
dependencies { dependencies {
// 添加Microsoft语音SDK // 添加Microsoft语音SDK
implementation("com.microsoft.cognitiveservices.speech:client-sdk:1.30.0") implementation("com.microsoft.cognitiveservices.speech:client-sdk:1.30.0")
} }

0
local_plugins/azure_speech/ios/Classes/.gitkeep

24
local_plugins/azure_speech/ios/azure_speech.podspec

@ -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
local_plugins/chat_storage/LICENSE

80
local_plugins/chat_storage/README.md

@ -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处理返回结果
- 插件内部已处理异常情况,但建议在使用时添加适当的错误处理

60
local_plugins/chat_storage/android/build.gradle.kts

@ -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")
}

1
local_plugins/chat_storage/android/settings.gradle.kts

@ -0,0 +1 @@
rootProject.name = "chat_storage"

8
local_plugins/chat_storage/android/src/main/AndroidManifest.xml

@ -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>

236
local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt

@ -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
local_plugins/chat_storage/ios/Classes/.gitkeep

23
local_plugins/chat_storage/ios/chat_storage.podspec

@ -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

154
local_plugins/chat_storage/lib/chat_storage.dart

@ -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()};
}
}
}

30
local_plugins/chat_storage/pubspec.yaml

@ -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

144
local_plugins/jieli/README.md

@ -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" />
```

55
local_plugins/jieli/android/build.gradle.kts

@ -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")
}

1
local_plugins/jieli/android/settings.gradle.kts

@ -0,0 +1 @@
rootProject.name = "jieli"

22
local_plugins/jieli/android/src/main/AndroidManifest.xml

@ -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>

549
local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt

@ -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
}
}

292
local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/JieliPlugin.kt

@ -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
)
}
}
}
}

272
local_plugins/jieli/lib/jieli.dart

@ -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}';
}

23
local_plugins/jieli/pubspec.yaml

@ -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
local_plugins/open_ai_service/LICENSE

12
local_plugins/open_ai_service/android/AndroidManifest.xml

@ -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>

29
local_plugins/open_ai_service/android/build.gradle.kts

@ -3,6 +3,7 @@ plugins {
id("com.android.library") id("com.android.library")
// Kotlin Android 插件 // Kotlin Android 插件
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("com.github.johnrengelman.shadow") version "8.1.1"
} }
android { android {
@ -28,10 +29,32 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = "11" 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 { dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
implementation("com.squareup.okhttp3:okhttp:4.10.0") 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.squareup.okhttp3:logging-interceptor:4.9.3")
implementation("com.anthropic:anthropic-java:0.8.0")
implementation("io.modelcontextprotocol:kotlin-sdk:0.4.0")
implementation("io.ktor:ktor-client-core:3.1.2")
implementation("io.ktor:ktor-client-cio:3.1.2")
implementation("io.ktor:ktor-client-okhttp:3.1.2")
implementation("io.ktor:ktor-client-logging:3.1.2")
} }

892
local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/MCPClient.kt

@ -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)
}
}

372
local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt

@ -1,19 +1,29 @@
package com.yunqiinnovation.open_ai_service package com.yunqiinnovation.open_ai_service
import android.content.Context
import android.util.Log import android.util.Log
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.* import okhttp3.*
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okio.Buffer
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
* OpenAI服务的原生实现 * OpenAI服务的原生实现
*/ */
class OpenAIService() { class OpenAIService(private val context: Context? = null) {
private val TAG = "OpenAIService" private val TAG = "OpenAIService"
private var baseUrl = "" private var baseUrl = ""
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
@ -25,8 +35,12 @@ class OpenAIService() {
private var isInitialized = false private var isInitialized = false
private var model: String = "" // 默认模型 private var model: String = "" // 默认模型
// 用于存储注册的函数 // MCP客户端
private val registeredFunctions = mutableListOf<JSONObject>() private var mcpClient: MCPClient? = null
private var isMcpInitialized = false
// 是否自动处理MCP工具调用
private var autoHandleMcpTools = true
/** /**
* 构建curl命令用于测试 * 构建curl命令用于测试
@ -80,7 +94,7 @@ class OpenAIService() {
/** /**
* 初始化OpenAI服务 * 初始化OpenAI服务
*/ */
fun initialize(apiKey: String, baseUrl: String, model: String): Boolean { fun initialize(apiKey: String, baseUrl: String, model: String, mcpServer: String): Boolean {
this.apiKey = apiKey this.apiKey = apiKey
if (baseUrl.isNotEmpty()) { if (baseUrl.isNotEmpty()) {
this.baseUrl = baseUrl this.baseUrl = baseUrl
@ -88,40 +102,156 @@ class OpenAIService() {
if (model.isNotEmpty()) { if (model.isNotEmpty()) {
this.model = model this.model = model
} }
// 初始化MCPClient
if (mcpClient == null) {
mcpClient = MCPClient(context)
}
initializeMcpClient(mcpServer)
isInitialized = apiKey.isNotEmpty() isInitialized = apiKey.isNotEmpty()
return isInitialized return isInitialized
} }
/**
* MCP客户端是否已初始化
*/
fun isMcpInitialized(): Boolean {
return isMcpInitialized && mcpClient?.isConnected() == true
}
/**
* 关闭MCP客户端
*/
fun closeMcpClient() {
mcpClient?.close()
mcpClient = null
isMcpInitialized = false
}
/**
* 初始化MCP客户端
*/
fun initializeMcpClient(mcpServer: String): Boolean {
if (mcpClient != null) {
mcpClient?.close()
}
mcpClient = MCPClient(context)
// 在后台线程中初始化MCP客户端
val scope = CoroutineScope(Dispatchers.IO)
var result = false
scope.launch {
try {
result = mcpClient?.connectToSSE(mcpServer) ?: false
isMcpInitialized = result
Log.d(TAG, "MCP客户端初始化${if (result) "成功" else "失败"}")
} catch (e: Exception) {
Log.e(TAG, "MCP客户端初始化失败: ${e.message}", e)
isMcpInitialized = false
}
}
return true // 立即返回,实际连接在后台进行
}
/**
* 处理MCP工具调用
*
* @param functionCall 函数调用JSON对象
* @return 处理结果,如果处理失败则返回null
*/
suspend fun handleMcpToolCall(functionCall: JSONObject): JSONObject? {
if (mcpClient == null || !isMcpInitialized) {
return JSONObject().apply { put("context", "MCP客户端未初始化") }
}
try {
// 获取函数名称
val name = functionCall.getString("name")
// 获取参数
val argumentsJson = functionCall.getString("arguments")
val arguments = mcpClient?.parseJsonArguments(argumentsJson) ?: mapOf()
// 调用工具
return mcpClient?.callTool(name, arguments)
} catch (e: Exception) {
Log.e(TAG, "处理MCP工具调用失败: ${e.message}", e)
return JSONObject().apply { put("context", "处理MCP工具调用失败: ${e.message}") }
}
}
/** /**
* 注册函数 * 注册函数
*/ */
fun registerFunction(name: String, description: String, parameters: JSONObject): Boolean { fun registerFunction(name: String, description: String, parameters: JSONObject): Boolean {
try { try {
val function = JSONObject().apply { // 确保MCPClient已初始化
put("name", name) if (mcpClient == null) {
put("description", description) mcpClient = MCPClient()
put("parameters", parameters)
}
// 检查是否已存在相同名称的函数
val existingIndex = registeredFunctions.indexOfFirst {
it.getString("name") == name
} }
if (existingIndex >= 0) { // 创建函数处理器
// 如果已存在,则替换 val handler = object : FunctionHandler {
registeredFunctions[existingIndex] = function override suspend fun handle(arguments: Map<String, Any>): String {
} else { // 由于本地函数的实际处理是在Flutter端完成的
// 如果不存在,则添加 // 这里只需返回一个标记,表示该函数是本地函数
registeredFunctions.add(function) return "LOCAL_FUNCTION:$name"
}
} }
return true // 直接委托给MCPClient处理
return mcpClient?.registerLocalFunction(name, description, parameters, handler) ?: false
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "注册函数失败: ${e.message}", e)
return false return false
} }
} }
/**
* 自动处理MCP工具调用
*/
private suspend fun autoHandleMcpToolCall(functionCall: JSONObject, messages: JSONArray, callback: StreamCallback) {
try {
// 获取函数名称
val name = functionCall.getString("name")
// 获取参数
val argumentsJson = functionCall.getString("arguments")
val arguments = mcpClient?.parseJsonArguments(argumentsJson) ?: mapOf()
// 调用工具(统一通过MCPClient处理)
val result = mcpClient?.callTool(name, arguments)
var context = "工具调用失败";
if (result != null){
callback.onFunctionCall(result)
context = "${result["context"]}"
}
// 不论是本地函数还是MCP工具,都直接将结果发送回OpenAI
sendFunctionCallResult(
messages = messages,
functionCall = functionCall,
functionResult = context,
callback = callback
)
} catch (e: Exception) {
Log.e(TAG, "自动处理工具调用失败: ${e.message}", e)
// 失败时返回错误给回调函数
val errorMessage = "工具调用失败: ${e.message}"
sendFunctionCallResult(
messages = messages,
functionCall = functionCall,
functionResult = errorMessage,
callback = callback
)
}
}
/** /**
* 发送消息(非流式输出) * 发送消息(非流式输出)
*/ */
@ -138,16 +268,21 @@ class OpenAIService() {
put("max_tokens", 2000) put("max_tokens", 2000)
put("stream", false) put("stream", false)
// 如果有注册的函数,则添加到请求中 // 添加工具列表
if (registeredFunctions.isNotEmpty()) { val tools = JSONArray()
val tools = JSONArray()
for (function in registeredFunctions) { // 使用MCPClient提供的所有工具
val tool = JSONObject().apply { mcpClient?.getToolMaps()?.forEach { toolMap ->
put("type", "function") try {
put("function", function) val tool = JSONObject(toolMap)
}
tools.put(tool) tools.put(tool)
} catch (e: Exception) {
Log.e(TAG, "转换工具失败: ${e.message}", e)
} }
}
// 如果有工具,则添加到请求中
if (tools.length() > 0) {
put("tools", tools) put("tools", tools)
} }
} }
@ -231,16 +366,21 @@ class OpenAIService() {
put("max_tokens", 2000) put("max_tokens", 2000)
put("stream", true) put("stream", true)
// 如果有注册的函数,则添加到请求中 // 添加工具列表
if (registeredFunctions.isNotEmpty()) { val tools = JSONArray()
val tools = JSONArray()
for (function in registeredFunctions) { // 使用MCPClient提供的所有工具
val tool = JSONObject().apply { mcpClient?.getToolMaps()?.forEach { toolMap ->
put("type", "function") try {
put("function", function) val tool = JSONObject(toolMap)
}
tools.put(tool) tools.put(tool)
} catch (e: Exception) {
Log.e(TAG, "转换工具失败: ${e.message}", e)
} }
}
// 如果有工具,则添加到请求中
if (tools.length() > 0) {
put("tools", tools) put("tools", tools)
} }
} }
@ -255,8 +395,8 @@ class OpenAIService() {
.build() .build()
// 输出用于测试的curl命令 // 输出用于测试的curl命令
// val curlCommand = buildCurlCommand(request, requestBody.toString()) val curlCommand = buildCurlCommand(request, requestBody.toString())
// Log.d(TAG, "curl command: $curlCommand") Log.d(TAG, "curl command: $curlCommand")
client.newCall(request).enqueue(object : Callback { client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
@ -272,81 +412,95 @@ class OpenAIService() {
val responseBody = response.body ?: return val responseBody = response.body ?: return
val source = responseBody.source() val source = responseBody.source()
val buffer = Buffer()
try { try {
// 预取数据到缓冲区
source.request(Long.MAX_VALUE)
val bufferedSource = source.buffer
// 用于存储函数调用的各个部分 // 用于存储函数调用的各个部分
val finalToolCalls = mutableMapOf<Int, ToolCallInfo>() val finalToolCalls = mutableMapOf<Int, ToolCallInfo>()
val lineBuffer = StringBuilder()
while (!bufferedSource.exhausted()) { // 逐块读取数据,而不是一次性预取
val line = bufferedSource.readUtf8Line()?.trim() ?: continue val BUFFER_SIZE = 8192L // 8KB的块大小
if (line.isEmpty()) continue
if (line.startsWith("data:")) { while (!Thread.currentThread().isInterrupted) {
val data = line.substring(5).trim() // 读取一小块数据
val bytesRead = source.read(buffer, BUFFER_SIZE)
// 处理[DONE]消息 if (bytesRead == -1L) break // 流结束
if (data == "[DONE]" || data == "[\"DONE\"]") {
processToolCalls(finalToolCalls, callback) // 处理接收到的数据
callback.onComplete() while (true) {
break val line = buffer.readUtf8Line() ?: break
} if (line.trim().isEmpty()) continue
try { if (line.startsWith("data:")) {
val jsonData = JSONObject(data) val data = line.substring(5).trim()
Log.d("OpenAIService", "$data")
// 处理消息内容 // 处理[DONE]消息
if (jsonData.has("choices")) { if (data == "[DONE]" || data == "[\"DONE\"]") {
val choices = jsonData.getJSONArray("choices") Log.d(TAG, "收到[DONE]消息")
if (choices.length() > 0) { val hasToolCalls = processToolCalls(finalToolCalls, callback, messages)
val choice = choices.getJSONObject(0) // 只有在没有工具调用时才认为对话真正完成
if (!hasToolCalls) {
if (choice.has("delta")) { callback.onComplete()
val delta = choice.getJSONObject("delta") }
return
// 处理普通文本内容 }
if (delta.has("content")) {
val content = delta.getString("content") try {
callback.onToken(content) val jsonData = JSONObject(data)
}
// 处理消息内容
if (jsonData.has("choices")) {
val choices = jsonData.getJSONArray("choices")
if (choices.length() > 0) {
val choice = choices.getJSONObject(0)
// 处理工具调用(函数调用) if (choice.has("delta")) {
if (delta.has("tool_calls")) { val delta = choice.getJSONObject("delta")
val toolCalls = delta.getJSONArray("tool_calls")
for (i in 0 until toolCalls.length()) { // 处理普通文本内容
val toolCall = toolCalls.getJSONObject(i) if (delta.has("content")) {
val index = toolCall.getInt("index") val content = delta.getString("content")
callback.onToken(content)
// 创建或获取现有的工具调用信息 }
val toolCallInfo = finalToolCalls.getOrPut(index) { ToolCallInfo() }
// 处理工具调用(函数调用)
// 更新ID if (delta.has("tool_calls")) {
if (toolCall.has("id")) { val toolCalls = delta.getJSONArray("tool_calls")
toolCallInfo.id = toolCall.getString("id") for (i in 0 until toolCalls.length()) {
} val toolCall = toolCalls.getJSONObject(i)
val index = toolCall.getInt("index")
// 更新函数信息
if (toolCall.has("function")) { // 创建或获取现有的工具调用信息
val function = toolCall.getJSONObject("function") val toolCallInfo = finalToolCalls.getOrPut(index) { ToolCallInfo() }
if (function.has("name")) { // 更新ID
toolCallInfo.name = function.getString("name") if (toolCall.has("id")) {
toolCallInfo.id = toolCall.getString("id")
} }
if (function.has("arguments")) { // 更新函数信息
toolCallInfo.arguments += function.getString("arguments") if (toolCall.has("function")) {
val function = toolCall.getJSONObject("function")
if (function.has("name")) {
toolCallInfo.name = function.getString("name")
}
if (function.has("arguments")) {
toolCallInfo.arguments += function.getString("arguments")
}
} }
} }
} }
} }
} }
} }
} catch (e: Exception) {
// 忽略解析错误
Log.e(TAG, "解析JSON出错: ${e.message}")
} }
} catch (e: Exception) {
// 忽略解析错误
Log.e(TAG, "解析JSON出错: ${e.message}")
} }
} }
} }
@ -403,12 +557,6 @@ class OpenAIService() {
put("tool_call_id", functionCall.optString("id", "call_${System.currentTimeMillis()}")) put("tool_call_id", functionCall.optString("id", "call_${System.currentTimeMillis()}"))
}) })
// 添加一个带有content的assistant消息,确保API请求不会因为缺少content而失败
fullMessages.put(JSONObject().apply {
put("role", "assistant")
put("content", "") // 空内容,让模型生成新的回复
})
// 发送完整对话 // 发送完整对话
sendMessageStream(fullMessages, callback) sendMessageStream(fullMessages, callback)
@ -418,13 +566,13 @@ class OpenAIService() {
} }
/** /**
* 处理工具调用(处理完整的函数调用并回调) * 处理工具调用结果
*/ */
private fun processToolCalls(toolCalls: Map<Int, ToolCallInfo>, callback: StreamCallback) { private fun processToolCalls(toolCalls: Map<Int, ToolCallInfo>, callback: StreamCallback, messages: JSONArray? = null): Boolean {
if (toolCalls.isEmpty()) return if (toolCalls.isEmpty()) return false
// 只处理第一个工具调用 // 只处理第一个工具调用
val firstToolCall = toolCalls.entries.firstOrNull()?.value ?: return val firstToolCall = toolCalls.entries.firstOrNull()?.value ?: return false
if (firstToolCall.isValid()) { if (firstToolCall.isValid()) {
// 创建函数调用JSON对象 // 创建函数调用JSON对象
@ -434,9 +582,19 @@ class OpenAIService() {
put("id", firstToolCall.id) put("id", firstToolCall.id)
} }
// 回调 // 在协程中处理工具调用
callback.onFunctionCall(functionCall) if (messages != null) {
kotlinx.coroutines.GlobalScope.launch {
try {
autoHandleMcpToolCall(functionCall, messages, callback)
} catch (e: Exception) {
Log.e(TAG, "工具调用处理失败: ${e.message}", e)
}
}
}
return true
} }
return false
} }
/** /**

252
local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt

@ -1,7 +1,10 @@
package com.yunqiinnovation.open_ai_service package com.yunqiinnovation.open_ai_service
import android.content.Context import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import kotlinx.serialization.json.Json
import androidx.annotation.NonNull import androidx.annotation.NonNull
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@ -15,6 +18,10 @@ import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** OpenAIServicePlugin */ /** OpenAIServicePlugin */
class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler {
@ -34,18 +41,24 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler {
private lateinit var context: Context private lateinit var context: Context
/// OpenAI服务实例 /// OpenAI服务实例
private val openAIService = OpenAIService() private lateinit var openAIService: OpenAIService
/// 事件接收器(用于流式输出) /// 事件接收器(用于流式输出)
private var eventSink: EventSink? = null private var eventSink: EventSink? = null
/// 执行器(用于后台线程) /// 执行器(用于真正需要在后台线程处理的任务)
private val executor = Executors.newSingleThreadExecutor() private val executor = Executors.newSingleThreadExecutor()
/// 主线程Handler
private val mainHandler = Handler(Looper.getMainLooper())
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
// 保存上下文 // 保存上下文
context = flutterPluginBinding.applicationContext context = flutterPluginBinding.applicationContext
// 初始化OpenAI服务
openAIService = OpenAIService(context)
// 初始化方法通道 // 初始化方法通道
methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, methodChannelName) methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, methodChannelName)
methodChannel.setMethodCallHandler(this) methodChannel.setMethodCallHandler(this)
@ -61,11 +74,13 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler {
val apiKey = call.argument<String>("apiKey") ?: "" val apiKey = call.argument<String>("apiKey") ?: ""
val baseUrl = call.argument<String>("baseUrl") ?: "" val baseUrl = call.argument<String>("baseUrl") ?: ""
val model = call.argument<String>("model") ?: "" val model = call.argument<String>("model") ?: ""
val mcpServer = call.argument<String>("mcpServer") ?: ""
val initialized = openAIService.initialize(apiKey, baseUrl, model) val initialized = openAIService.initialize(apiKey, baseUrl, model, mcpServer)
result.success(initialized) result.success(initialized)
} }
"registerFunction" -> { "registerFunction" -> {
val name = call.argument<String>("name") ?: "" val name = call.argument<String>("name") ?: ""
val description = call.argument<String>("description") ?: "" val description = call.argument<String>("description") ?: ""
@ -90,17 +105,17 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler {
messages.put(JSONObject(message)) messages.put(JSONObject(message))
} }
// 在后台线程执行请求 // 对于需要在后台执行的同步阻塞操作,仍然使用executor
executor.execute { executor.execute {
try { try {
val response = openAIService.sendMessage(messages) val response = openAIService.sendMessage(messages)
// 在主线程返回结果 // 在主线程返回结果
android.os.Handler(android.os.Looper.getMainLooper()).post { mainHandler.post {
result.success(response) result.success(response)
} }
} catch (e: Exception) { } catch (e: Exception) {
// 在主线程返回错误 // 在主线程返回错误
android.os.Handler(android.os.Looper.getMainLooper()).post { mainHandler.post {
result.error("OPENAI_ERROR", e.message, null) result.error("OPENAI_ERROR", e.message, null)
} }
} }
@ -122,53 +137,60 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler {
messages.put(JSONObject(message)) messages.put(JSONObject(message))
} }
// 在后台线程执行请求 // 不再在后台线程包装OkHttp的异步调用
executor.execute { try {
try { openAIService.sendMessageStream(
openAIService.sendMessageStream( messages = messages,
messages = messages, callback = object : OpenAIService.StreamCallback {
callback = object : OpenAIService.StreamCallback { override fun onToken(token: String) {
override fun onToken(token: String) { // 发送token事件
// 发送token事件 mainHandler.post {
android.os.Handler(android.os.Looper.getMainLooper()).post { eventSink?.success(mapOf("type" to "token", "content" to token))
eventSink?.success(mapOf("type" to "token", "content" to token))
}
} }
}
override fun onComplete() {
// 发送完成事件 override fun onComplete() {
android.os.Handler(android.os.Looper.getMainLooper()).post { // 发送完成事件
eventSink?.success(mapOf("type" to "complete")) mainHandler.post {
} eventSink?.success(mapOf("type" to "complete"))
} }
}
override fun onError(e: Exception) {
// 发送错误事件 override fun onError(e: Exception) {
android.os.Handler(android.os.Looper.getMainLooper()).post { // 发送错误事件
eventSink?.success(mapOf("type" to "error", "content" to e.message)) mainHandler.post {
} eventSink?.success(mapOf("type" to "error", "content" to e.message))
} }
}
override fun onFunctionCall(functionCall: JSONObject) {
// 发送函数调用事件 override fun onFunctionCall(functionCall: JSONObject) {
android.os.Handler(android.os.Looper.getMainLooper()).post { Log.e("OpenAIServicePlugin", "工具调用结果: ${functionCall.toString()}")
val functionCallMap = functionCall.toMap() val context = functionCall["context"]
eventSink?.success(mapOf("type" to "functionCall", "content" to functionCallMap)) // val meta = functionCall["meta"]
val meta: Map<String, Any>? = functionCall.optJSONObject("meta")?.let { metaJson ->
metaJson.keys().asSequence().associateWith { key ->
metaJson.get(key) // 或 opt(key),更安全
} }
} }
val card_spotifyKey = meta?.containsKey("card_spotify") ?: false
if (card_spotifyKey) { //是否是音乐卡片
}
// 发送函数调用事件
mainHandler.post {
// val functionCallMap = functionCall.toMap()
eventSink?.success(mapOf("type" to "functionCall", "content" to context,"meta" to meta))
}
} }
)
// 请求已开始
android.os.Handler(android.os.Looper.getMainLooper()).post {
result.success(true)
} }
} catch (e: Exception) { )
// 在主线程返回错误
android.os.Handler(android.os.Looper.getMainLooper()).post { // 请求已开始,直接返回结果
result.error("OPENAI_ERROR", e.message, null) result.success(true)
} } catch (e: Exception) {
} // 直接在当前线程处理错误
result.error("OPENAI_ERROR", e.message, null)
} }
} }
@ -192,55 +214,50 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler {
// 转换函数调用 // 转换函数调用
val functionCall = JSONObject(functionCallRaw) val functionCall = JSONObject(functionCallRaw)
// 在后台线程执行请求 // 不再在后台线程包装OkHttp的异步调用
executor.execute { try {
try { openAIService.sendFunctionCallResult(
openAIService.sendFunctionCallResult( messages = messages,
messages = messages, functionCall = functionCall,
functionCall = functionCall, functionResult = functionResult,
functionResult = functionResult, callback = object : OpenAIService.StreamCallback {
callback = object : OpenAIService.StreamCallback { override fun onToken(token: String) {
override fun onToken(token: String) { // 发送token事件
// 发送token事件 mainHandler.post {
android.os.Handler(android.os.Looper.getMainLooper()).post { eventSink?.success(mapOf("type" to "token", "content" to token))
eventSink?.success(mapOf("type" to "token", "content" to token))
}
}
override fun onComplete() {
// 发送完成事件
android.os.Handler(android.os.Looper.getMainLooper()).post {
eventSink?.success(mapOf("type" to "complete"))
}
} }
}
override fun onError(e: Exception) {
// 发送错误事件 override fun onComplete() {
android.os.Handler(android.os.Looper.getMainLooper()).post { // 发送完成事件
eventSink?.success(mapOf("type" to "error", "content" to e.message)) mainHandler.post {
} eventSink?.success(mapOf("type" to "complete"))
} }
}
override fun onFunctionCall(nestedFunctionCall: JSONObject) {
// 发送函数调用事件 override fun onError(e: Exception) {
android.os.Handler(android.os.Looper.getMainLooper()).post { // 发送错误事件
val functionCallMap = nestedFunctionCall.toMap() mainHandler.post {
eventSink?.success(mapOf("type" to "functionCall", "content" to functionCallMap)) eventSink?.success(mapOf("type" to "error", "content" to e.message))
}
} }
} }
)
override fun onFunctionCall(nestedFunctionCall: JSONObject) {
// 请求已开始 Log.e("OpenAIServicePlugin", "工具调用结果: ${nestedFunctionCall.toString()}")
android.os.Handler(android.os.Looper.getMainLooper()).post { // 发送函数调用事件
result.success(true) // mainHandler.post {
} // val functionCallMap = nestedFunctionCall.toMap()
} catch (e: Exception) { // eventSink?.success(mapOf("type" to "functionCall", "content" to functionCallMap))
// 在主线程返回错误 // }
android.os.Handler(android.os.Looper.getMainLooper()).post { }
result.error("OPENAI_ERROR", e.message, null)
} }
} )
// 请求已开始,直接返回结果
result.success(true)
} catch (e: Exception) {
// 直接在当前线程处理错误
result.error("OPENAI_ERROR", e.message, null)
} }
} }
@ -256,6 +273,11 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler {
result.success(message.toMap()) result.success(message.toMap())
} }
"initializeMcpClient" -> initializeMcpClient(call, result)
"isMcpInitialized" -> isMcpInitialized(call, result)
"closeMcpClient" -> closeMcpClient(call, result)
"handleMcpToolCall" -> handleMcpToolCall(call, result)
else -> { else -> {
result.notImplemented() result.notImplemented()
} }
@ -314,4 +336,54 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler {
} }
return list return list
} }
private fun initializeMcpClient(call: MethodCall, result: Result) {
val serverUrl = call.argument<String>("serverUrl")
if (serverUrl == null) {
result.error("INVALID_ARGUMENT", "Missing serverUrl", null)
return
}
val success = openAIService.initializeMcpClient(serverUrl)
result.success(success)
}
private fun isMcpInitialized(call: MethodCall, result: Result) {
result.success(openAIService.isMcpInitialized())
}
private fun closeMcpClient(call: MethodCall, result: Result) {
openAIService.closeMcpClient()
result.success(true)
}
private fun handleMcpToolCall(call: MethodCall, result: Result) {
val functionCall = call.argument<String>("functionCall")
if (functionCall == null) {
result.error("INVALID_ARGUMENT", "Missing functionCall", null)
return
}
try {
val jsonFunctionCall = JSONObject(functionCall)
// 使用协程处理异步操作
CoroutineScope(Dispatchers.IO).launch {
try {
val toolResult = openAIService.handleMcpToolCall(jsonFunctionCall)
// 切换到主线程返回结果
withContext(Dispatchers.Main) {
result.success(toolResult ?: "无法处理MCP工具调用")
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
result.error("MCP_TOOL_ERROR", "处理MCP工具调用失败: ${e.message}", null)
}
}
}
} catch (e: Exception) {
result.error("INVALID_ARGUMENT", "解析functionCall失败: ${e.message}", null)
}
}
} }

422
local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt

@ -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
)
}

357
local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/mcp/CustomSseClientTransport.kt

@ -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
local_plugins/open_ai_service/ios/Classes/.gitkeep

296
local_plugins/open_ai_service/ios/Classes/OpenAIService.swift

@ -237,30 +237,47 @@ public class OpenAIService {
} }
/// 发送消息(流式输出) /// 发送消息(流式输出)
public func sendMessageStream(messages: [[String: Any]], systemPrompt: String, callback: @escaping StreamCallback) { public func sendMessageStream(
messages: [[String: Any]],
systemPrompt: String,
callback: @escaping StreamCallback
) {
guard isInitialized, !apiKey.isEmpty else { guard isInitialized, !apiKey.isEmpty else {
callback.onError(OpenAIError("OpenAI服务未初始化")) callback.onError(OpenAIError("OpenAI服务未初始化"))
return return
} }
// 构建完整消息,添加系统提示
var fullMessages: [[String: Any]] = [
["role": "system", "content": systemPrompt]
]
fullMessages.append(contentsOf: messages)
// 构建请求体 // 构建请求体
var requestDict: [String: Any] = [ var requestBody: [String: Any] = [
"model": model, "model": model,
"messages": fullMessages,
"temperature": 0.7, "temperature": 0.7,
"max_tokens": 2000, "max_tokens": 2000,
"stream": true "stream": true
] ]
// 如果有注册的函数,添加到请求中 // 构建完整消息数组,添加系统提示
if !registeredFunctions.isEmpty { var fullMessages: [[String: Any]] = [
["role": "system", "content": systemPrompt]
]
fullMessages.append(contentsOf: messages)
requestBody["messages"] = fullMessages
// 添加工具列表
if let toolMaps = mcpClient?.getToolMaps(), !toolMaps.isEmpty {
var tools: [[String: Any]] = []
for toolMap in toolMaps {
if let tool = toolMap as? [String: Any] {
tools.append(tool)
}
}
if !tools.isEmpty {
requestBody["tools"] = tools
}
} else if !registeredFunctions.isEmpty {
var tools: [[String: Any]] = [] var tools: [[String: Any]] = []
for function in registeredFunctions { for function in registeredFunctions {
let tool: [String: Any] = [ let tool: [String: Any] = [
"type": "function", "type": "function",
@ -268,11 +285,14 @@ public class OpenAIService {
] ]
tools.append(tool) tools.append(tool)
} }
requestDict["tools"] = tools
if !tools.isEmpty {
requestBody["tools"] = tools
}
} }
// 将请求数据转换为JSON数据 // 转换为JSON数据
guard let jsonData = try? JSONSerialization.data(withJSONObject: requestDict) else { guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody) else {
callback.onError(OpenAIError("无法序列化请求数据")) callback.onError(OpenAIError("无法序列化请求数据"))
return return
} }
@ -290,102 +310,10 @@ public class OpenAIService {
request.addValue("text/event-stream", forHTTPHeaderField: "Accept") request.addValue("text/event-stream", forHTTPHeaderField: "Accept")
request.httpBody = jsonData request.httpBody = jsonData
// 用于存储函数调用的各个部分 // 创建流式会话任务
var finalToolCalls: [Int: ToolCallInfo] = [:] let delegate = SSEStreamDelegate(callback: callback)
let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
// 创建数据任务 let task = session.dataTask(with: request)
let task = session.dataTask(with: request) { data, response, error in
if let error = error {
callback.onError(OpenAIError("请求失败: \(error.localizedDescription)"))
return
}
guard let httpResponse = response as? HTTPURLResponse else {
callback.onError(OpenAIError("无效的HTTP响应"))
return
}
guard httpResponse.statusCode == 200 else {
callback.onError(OpenAIError("API调用失败: \(httpResponse.statusCode)"))
return
}
guard let data = data else {
callback.onError(OpenAIError("响应数据为空"))
return
}
// 处理SSE数据流
if let text = String(data: data, encoding: .utf8) {
let lines = text.components(separatedBy: "\n")
for line in lines {
if line.isEmpty { continue }
if line.hasPrefix("data: ") {
let dataContent = line.dropFirst(6)
// 处理[DONE]消息
if dataContent == "[DONE]" {
self.processToolCalls(finalToolCalls, callback: callback)
callback.onComplete()
break
}
// 解析JSON数据
do {
if let data = dataContent.data(using: .utf8),
let jsonData = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
// 处理消息内容
if let choices = jsonData["choices"] as? [[String: Any]], !choices.isEmpty,
let choice = choices.first {
if let delta = choice["delta"] as? [String: Any] {
// 处理普通文本内容
if let content = delta["content"] as? String {
callback.onToken(content)
}
// 处理工具调用(函数调用)
if let toolCalls = delta["tool_calls"] as? [[String: Any]] {
for toolCall in toolCalls {
if let index = toolCall["index"] as? Int {
// 创建或获取现有的工具调用信息
let toolCallInfo = finalToolCalls[index] ?? ToolCallInfo()
// 更新ID
if let id = toolCall["id"] as? String {
toolCallInfo.id = id
}
// 更新函数信息
if let function = toolCall["function"] as? [String: Any] {
if let name = function["name"] as? String {
toolCallInfo.name = name
}
if let arguments = function["arguments"] as? String {
toolCallInfo.arguments += arguments
}
}
finalToolCalls[index] = toolCallInfo
}
}
}
}
}
}
} catch {
NSLog("解析JSON出错: \(error.localizedDescription)")
// 忽略解析错误,继续处理其他行
}
}
}
}
}
task.resume() task.resume()
} }
@ -447,12 +375,147 @@ public class OpenAIService {
} }
} }
/// SSE流委托实现
private class SSEStreamDelegate: NSObject, URLSessionDataDelegate {
let callback: StreamCallback
private var buffer = Data()
private var finalToolCalls: [Int: ToolCallInfo] = [:]
init(callback: @escaping StreamCallback) {
self.callback = callback
super.init()
}
// 接收数据流
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
buffer.append(data)
// 处理可能包含多行的数据
processBuffer()
}
// 处理缓冲区数据
private func processBuffer() {
// 按行分割
while let newlineIndex = buffer.firstIndex(of: 10) { // 10是换行符的ASCII码
let lineData = buffer.prefix(upTo: newlineIndex)
buffer.removeSubrange(0...newlineIndex) // 移除已处理的行,包括换行符
// 解析行数据
if let line = String(data: lineData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) {
processLine(line)
}
}
}
// 处理单行数据
private func processLine(_ line: String) {
guard !line.isEmpty else { return }
if line.hasPrefix("data: ") {
let dataContent = line.dropFirst(6)
// 处理[DONE]消息
if dataContent == "[DONE]" {
let hasToolCalls = self.processToolCalls()
// 只有在没有工具调用时才认为对话真正完成
if !hasToolCalls {
callback.onComplete()
}
return
}
// 解析JSON数据
do {
if let data = dataContent.data(using: .utf8),
let jsonData = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
// 处理消息内容
if let choices = jsonData["choices"] as? [[String: Any]], !choices.isEmpty,
let choice = choices.first {
if let delta = choice["delta"] as? [String: Any] {
// 处理普通文本内容
if let content = delta["content"] as? String {
callback.onToken(content)
}
// 处理工具调用(函数调用)
if let toolCalls = delta["tool_calls"] as? [[String: Any]] {
for toolCall in toolCalls {
if let index = toolCall["index"] as? Int {
// 创建或获取现有的工具调用信息
let toolCallInfo = finalToolCalls[index] ?? ToolCallInfo()
// 更新ID
if let id = toolCall["id"] as? String {
toolCallInfo.id = id
}
// 更新函数信息
if let function = toolCall["function"] as? [String: Any] {
if let name = function["name"] as? String {
toolCallInfo.name = name
}
if let arguments = function["arguments"] as? String {
toolCallInfo.arguments += arguments
}
}
finalToolCalls[index] = toolCallInfo
}
}
}
}
}
}
} catch {
NSLog("解析JSON出错: \(error.localizedDescription)")
// 忽略解析错误,继续处理其他行
}
}
}
// 处理工具调用
private func processToolCalls() -> Bool {
if finalToolCalls.isEmpty { return false }
// 只处理第一个工具调用
guard let firstToolCall = finalToolCalls.values.first, firstToolCall.isValid else { return false }
// 创建函数调用字典
let functionCall: [String: Any] = [
"name": firstToolCall.name,
"arguments": firstToolCall.arguments,
"id": firstToolCall.id
]
// 回调
callback.onFunctionCall(functionCall)
return true
}
// 处理完成
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
callback.onError(OpenAIError("请求失败: \(error.localizedDescription)"))
}
}
}
/// 流式输出回调协议
public typealias StreamCallback = (onToken: (String) -> Void,
onComplete: () -> Void,
onError: (Error) -> Void,
onFunctionCall: ([String: Any]) -> Void)
/// 处理工具调用(函数调用)并回调 /// 处理工具调用(函数调用)并回调
private func processToolCalls(_ toolCalls: [Int: ToolCallInfo], callback: StreamCallback) { private func processToolCalls(_ toolCalls: [Int: ToolCallInfo], callback: StreamCallback) -> Bool {
if toolCalls.isEmpty { return } if toolCalls.isEmpty { return false }
// 只处理第一个工具调用 // 只处理第一个工具调用
guard let firstToolCall = toolCalls.values.first, firstToolCall.isValid else { return } guard let firstToolCall = toolCalls.values.first, firstToolCall.isValid else { return false }
// 创建函数调用字典 // 创建函数调用字典
let functionCall: [String: Any] = [ let functionCall: [String: Any] = [
@ -463,11 +526,6 @@ public class OpenAIService {
// 回调 // 回调
callback.onFunctionCall(functionCall) callback.onFunctionCall(functionCall)
return true
} }
/// 流式输出回调协议
public typealias StreamCallback = (onToken: (String) -> Void,
onComplete: () -> Void,
onError: (Error) -> Void,
onFunctionCall: ([String: Any]) -> Void)
} }

23
local_plugins/open_ai_service/ios/open_ai_service.podspec

@ -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

194
local_plugins/open_ai_service/lib/open_ai_service.dart

@ -6,9 +6,9 @@ import 'package:flutter/services.dart';
/// OpenAI服务异常 /// OpenAI服务异常
class OpenAIException implements Exception { class OpenAIException implements Exception {
final String message; final String message;
OpenAIException(this.message); OpenAIException(this.message);
@override @override
String toString() => 'OpenAIException: $message'; String toString() => 'OpenAIException: $message';
} }
@ -25,19 +25,20 @@ enum OpenAIEventType {
class OpenAIEvent { class OpenAIEvent {
final OpenAIEventType type; final OpenAIEventType type;
final dynamic content; final dynamic content;
final dynamic meta;
OpenAIEvent({required this.type, this.content});
OpenAIEvent({required this.type, this.content, this.meta});
factory OpenAIEvent.fromMap(Map<String, dynamic> map) { factory OpenAIEvent.fromMap(Map<String, dynamic> map) {
final typeStr = map['type'] as String; final typeStr = map['type'] as String;
final content = map['content']; final content = map['content'];
return OpenAIEvent( return OpenAIEvent(
type: _typeFromString(typeStr), type: _typeFromString(typeStr),
content: content, content: content,
); );
} }
static OpenAIEventType _typeFromString(String typeStr) { static OpenAIEventType _typeFromString(String typeStr) {
switch (typeStr) { switch (typeStr) {
case 'token': case 'token':
@ -56,21 +57,23 @@ class OpenAIEvent {
/// OpenAI服务插件 /// OpenAI服务插件
class OpenAIService { class OpenAIService {
static const MethodChannel _channel = MethodChannel('com.yunqiinnovation.open_ai_service/methods'); static const MethodChannel _channel =
static const EventChannel _eventChannel = EventChannel('com.yunqiinnovation.open_ai_service/events'); MethodChannel('com.yunqiinnovation.open_ai_service/methods');
static const EventChannel _eventChannel =
EventChannel('com.yunqiinnovation.open_ai_service/events');
/// 事件流控制器 /// 事件流控制器
StreamController<OpenAIEvent>? _eventStreamController; StreamController<OpenAIEvent>? _eventStreamController;
/// 事件流 /// 事件流
Stream<OpenAIEvent>? _eventStream; Stream<OpenAIEvent>? _eventStream;
/// 获取事件流 /// 获取事件流
Stream<OpenAIEvent> get eventStream { Stream<OpenAIEvent> get eventStream {
if (_eventStream == null) { if (_eventStream == null) {
_eventStreamController = StreamController<OpenAIEvent>.broadcast(); _eventStreamController = StreamController<OpenAIEvent>.broadcast();
_eventStream = _eventStreamController!.stream; _eventStream = _eventStreamController!.stream;
// 监听原生事件 // 监听原生事件
_eventChannel.receiveBroadcastStream().listen( _eventChannel.receiveBroadcastStream().listen(
(dynamic event) { (dynamic event) {
@ -85,19 +88,21 @@ class OpenAIService {
}, },
); );
} }
return _eventStream!; return _eventStream!;
} }
/// 初始化OpenAI服务 /// 初始化OpenAI服务
/// ///
/// [apiKey] OpenAI API密钥 /// [apiKey] OpenAI API密钥
/// [baseUrl] 可选,自定义API基础URL /// [baseUrl] 可选,自定义API基础URL
/// [model] 可选,自定义使用的模型 /// [model] 可选,自定义使用的模型
/// [mcpServer] 可选,MCP服务器地址配置
Future<bool> initialize({ Future<bool> initialize({
required String apiKey, required String apiKey,
String baseUrl = '', String baseUrl = '',
String model = '', String model = '',
String mcpServer = '',
}) async { }) async {
try { try {
final result = await _channel.invokeMethod<bool>( final result = await _channel.invokeMethod<bool>(
@ -106,17 +111,18 @@ class OpenAIService {
'apiKey': apiKey, 'apiKey': apiKey,
'baseUrl': baseUrl, 'baseUrl': baseUrl,
'model': model, 'model': model,
'mcpServer': mcpServer,
}, },
); );
return result ?? false; return result ?? false;
} catch (e) { } catch (e) {
throw OpenAIException('初始化失败: $e'); throw OpenAIException('初始化失败: $e');
} }
} }
/// 注册函数 /// 注册函数
/// ///
/// [name] 函数名称 /// [name] 函数名称
/// [description] 函数描述 /// [description] 函数描述
/// [parameters] 函数参数 /// [parameters] 函数参数
@ -134,15 +140,15 @@ class OpenAIService {
'parameters': parameters, 'parameters': parameters,
}, },
); );
return result ?? false; return result ?? false;
} catch (e) { } catch (e) {
throw OpenAIException('注册函数失败: $e'); throw OpenAIException('注册函数失败: $e');
} }
} }
/// 创建用户消息 /// 创建用户消息
/// ///
/// [content] 消息内容 /// [content] 消息内容
Future<Map<String, dynamic>> createUserMessage(String content) async { Future<Map<String, dynamic>> createUserMessage(String content) async {
try { try {
@ -150,19 +156,19 @@ class OpenAIService {
'createUserMessage', 'createUserMessage',
{'content': content}, {'content': content},
); );
if (result == null) { if (result == null) {
throw OpenAIException('创建用户消息失败: 结果为空'); throw OpenAIException('创建用户消息失败: 结果为空');
} }
return Map<String, dynamic>.from(result); return Map<String, dynamic>.from(result);
} catch (e) { } catch (e) {
throw OpenAIException('创建用户消息失败: $e'); throw OpenAIException('创建用户消息失败: $e');
} }
} }
/// 创建助手消息 /// 创建助手消息
/// ///
/// [content] 消息内容 /// [content] 消息内容
Future<Map<String, dynamic>> createAssistantMessage(String content) async { Future<Map<String, dynamic>> createAssistantMessage(String content) async {
try { try {
@ -170,19 +176,19 @@ class OpenAIService {
'createAssistantMessage', 'createAssistantMessage',
{'content': content}, {'content': content},
); );
if (result == null) { if (result == null) {
throw OpenAIException('创建助手消息失败: 结果为空'); throw OpenAIException('创建助手消息失败: 结果为空');
} }
return Map<String, dynamic>.from(result); return Map<String, dynamic>.from(result);
} catch (e) { } catch (e) {
throw OpenAIException('创建助手消息失败: $e'); throw OpenAIException('创建助手消息失败: $e');
} }
} }
/// 发送消息(非流式输出) /// 发送消息(非流式输出)
/// ///
/// [messages] 消息列表 /// [messages] 消息列表
Future<String> sendMessage({ Future<String> sendMessage({
required List<Map<String, dynamic>> messages, required List<Map<String, dynamic>> messages,
@ -194,21 +200,21 @@ class OpenAIService {
'messages': messages, 'messages': messages,
}, },
); );
if (result == null) { if (result == null) {
throw OpenAIException('发送消息失败: 结果为空'); throw OpenAIException('发送消息失败: 结果为空');
} }
return result; return result;
} catch (e) { } catch (e) {
throw OpenAIException('发送消息失败: $e'); throw OpenAIException('发送消息失败: $e');
} }
} }
/// 发送消息(流式输出) /// 发送消息(流式输出)
/// ///
/// [messages] 消息列表 /// [messages] 消息列表
/// ///
/// 返回一个布尔值,表示请求是否已开始 /// 返回一个布尔值,表示请求是否已开始
Future<bool> sendMessageStream({ Future<bool> sendMessageStream({
required List<Map<String, dynamic>> messages, required List<Map<String, dynamic>> messages,
@ -220,19 +226,19 @@ class OpenAIService {
'messages': messages, 'messages': messages,
}, },
); );
return result ?? false; return result ?? false;
} catch (e) { } catch (e) {
throw OpenAIException('发送流式消息失败: $e'); throw OpenAIException('发送流式消息失败: $e');
} }
} }
/// 发送函数调用结果 /// 发送函数调用结果
/// ///
/// [messages] 消息列表 /// [messages] 消息列表
/// [functionCall] 函数调用信息 /// [functionCall] 函数调用信息
/// [functionResult] 函数调用结果 /// [functionResult] 函数调用结果
/// ///
/// 返回一个布尔值,表示请求是否已开始 /// 返回一个布尔值,表示请求是否已开始
Future<bool> sendFunctionCallResult({ Future<bool> sendFunctionCallResult({
required List<Map<String, dynamic>> messages, required List<Map<String, dynamic>> messages,
@ -248,14 +254,13 @@ class OpenAIService {
'functionResult': functionResult, 'functionResult': functionResult,
}, },
); );
return result ?? false; return result ?? false;
} catch (e) { } catch (e) {
throw OpenAIException('发送函数调用结果失败: $e'); throw OpenAIException('发送函数调用结果失败: $e');
} }
} }
/// 从JSON字符串解析函数调用 /// 从JSON字符串解析函数调用
Map<String, dynamic> parseFunctionCall(String functionCallJson) { Map<String, dynamic> parseFunctionCall(String functionCallJson) {
try { try {
@ -264,4 +269,107 @@ class OpenAIService {
throw OpenAIException('解析函数调用失败: $e'); throw OpenAIException('解析函数调用失败: $e');
} }
} }
}
/// 初始化MCP客户端
///
/// [serverUrl] MCP服务器WebSocket地址
///
/// 返回一个布尔值,表示请求是否已开始初始化(实际初始化过程是异步的)
Future<bool> initializeMcpClient(String serverUrl) async {
try {
final result = await _channel.invokeMethod<bool>(
'initializeMcpClient',
{'serverUrl': serverUrl},
);
return result ?? false;
} catch (e) {
throw OpenAIException('初始化MCP客户端失败: $e');
}
}
/// 检查MCP客户端是否已初始化
Future<bool> isMcpInitialized() async {
try {
final result = await _channel.invokeMethod<bool>('isMcpInitialized');
return result ?? false;
} catch (e) {
throw OpenAIException('检查MCP客户端状态失败: $e');
}
}
/// 关闭MCP客户端
Future<bool> closeMcpClient() async {
try {
final result = await _channel.invokeMethod<bool>('closeMcpClient');
return result ?? false;
} catch (e) {
throw OpenAIException('关闭MCP客户端失败: $e');
}
}
/// 处理MCP工具调用
///
/// [functionCall] 函数调用信息,必须包含name和arguments字段
///
/// 返回工具调用结果
Future<String> handleMcpToolCall(Map<String, dynamic> functionCall) async {
try {
final result = await _channel.invokeMethod<String>(
'handleMcpToolCall',
{'functionCall': jsonEncode(functionCall)},
);
return result ?? "处理MCP工具调用失败: 结果为空";
} catch (e) {
throw OpenAIException('处理MCP工具调用失败: $e');
}
}
/// 处理函数调用(自动处理本地函数和MCP工具)
///
/// 这是一个辅助方法,用于简化函数调用处理流程:
/// 1. MCP工具会被自动处理,不会通过回调返回
/// 2. 只有本地函数才需要手动处理
/// 3. 提供统一的处理接口
///
/// 参数:
/// [functionCall] 函数调用信息
/// [messages] 消息上下文
/// [localFunctionHandler] 本地函数处理器,接收函数名和参数,返回处理结果
///
/// 返回值:
/// 布尔值表示是否成功处理(已将结果发送回OpenAI)
Future<bool> handleFunctionCall({
required Map<String, dynamic> functionCall,
required List<Map<String, dynamic>> messages,
required Future<String> Function(
String name, Map<String, dynamic> arguments)
localFunctionHandler,
}) async {
final name = functionCall['name'] as String;
final argumentsJson = functionCall['arguments'] as String;
// 解析参数
Map<String, dynamic> arguments;
try {
arguments = json.decode(argumentsJson) as Map<String, dynamic>;
} catch (e) {
arguments = <String, dynamic>{};
}
try {
// 调用本地函数处理器
final result = await localFunctionHandler(name, arguments);
// 发送结果回OpenAI
return sendFunctionCallResult(
messages: messages,
functionCall: functionCall,
functionResult: result,
);
} catch (e) {
throw OpenAIException('处理函数调用失败: $e');
}
}
}

62
local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/bug_report.md

@ -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.

1
local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/config.yml

@ -0,0 +1 @@
blank_issues_enabled: false

20
local_plugins/spotify_sdk/.github/ISSUE_TEMPLATE/feature_request.md

@ -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.

41
local_plugins/spotify_sdk/.github/actions/publish_flutter_package/action.yaml

@ -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 }}

42
local_plugins/spotify_sdk/.github/dependabot.yml

@ -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:
- "*"

16
local_plugins/spotify_sdk/.github/scripts/verify_pub_score.sh

@ -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

38
local_plugins/spotify_sdk/.github/workflows/pull_request.yml

@ -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

34
local_plugins/spotify_sdk/.github/workflows/release.yml

@ -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

50
local_plugins/spotify_sdk/.gitignore

@ -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/

10
local_plugins/spotify_sdk/.metadata

@ -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

184
local_plugins/spotify_sdk/CHANGELOG.md

@ -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

76
local_plugins/spotify_sdk/CODE_OF_CONDUCT.md

@ -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

48
local_plugins/spotify_sdk/CONTRIBUTING.md

@ -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).

201
local_plugins/spotify_sdk/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.

257
local_plugins/spotify_sdk/README.md

@ -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:
![image](https://user-images.githubusercontent.com/42183561/125422846-24e03bf0-ec7f-409f-b382-0ef2d0213d08.png)
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/)

12
local_plugins/spotify_sdk/analysis_options.yaml

@ -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

8
local_plugins/spotify_sdk/android/.gitignore

@ -0,0 +1,8 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures

74
local_plugins/spotify_sdk/android/build.gradle

@ -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'
}
}

3
local_plugins/spotify_sdk/android/gradle.properties

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
android.enableJetifier=true

7
local_plugins/spotify_sdk/android/gradle/wrapper/gradle-wrapper.properties

@ -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

BIN
local_plugins/spotify_sdk/android/libs/spotify-app-remote-release-0.8.0.aar

Binary file not shown.

1
local_plugins/spotify_sdk/android/settings.gradle

@ -0,0 +1 @@
rootProject.name = 'spotify_sdk'

10
local_plugins/spotify_sdk/android/src/main/AndroidManifest.xml

@ -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>

13
local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/BaseSpotifyApi.kt

@ -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", "")
}
}

25
local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyConnectApi.kt

@ -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()
}
}
}

35
local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyImagesApi.kt

@ -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()
}
}
}

210
local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyPlayerApi.kt

@ -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()
}
}
}

381
local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifySdkPlugin.kt

@ -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)

59
local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/SpotifyUserApi.kt

@ -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()
}
}
}

23
local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/CapabilitiesChannel.kt

@ -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?) {
}
}

27
local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/ConnectionStatusChannel.kt

@ -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?) {
}
}

22
local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/PlayerContextChannel.kt

@ -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?) {
}
}

22
local_plugins/spotify_sdk/android/src/main/kotlin/de/minimalme/spotify_sdk/subscriptions/PlayerStateChannel.kt

@ -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…
Cancel
Save