Initial Android project setup with Compose, WebSocket, and VoiceInteractionService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,182 @@
|
|||||||
# Cyrene-For-Android
|
# Cyrene for Android
|
||||||
|
|
||||||
昔涟在安卓设备上的载体。
|
昔涟在安卓设备上的载体 —— 随时随地与昔涟对话、操控 IoT 设备、接收实时通知。
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
Cyrene for Android 是 [Cyrene(昔涟)](https://github.com/Cyrene/Cyrene) 项目的官方 Android 客户端。Cyrene 是一个开源的基于 LLM 平台的智能体,提供多人格对话、IoT 设备操控、记忆管理、自动化规则、知识库、语音交互等功能。
|
||||||
|
|
||||||
|
Android 客户端的目标是成为用户的**默认语音助手**,完全替代系统自带的助手(Google Assistant / Bixby 等),同时提供:
|
||||||
|
|
||||||
|
- **系统级语音助手** — 注册为 `VoiceInteractionService`,可通过长按 Home / 侧滑呼出
|
||||||
|
- **语音唤醒** — 息屏热词唤醒、免提交互(类似 "Hey Google",使用 "昔涟" 等唤醒词)
|
||||||
|
- **随时对话** — 通过文字或语音与昔涟交流
|
||||||
|
- **IoT 控制** — 远程操控家中的智能设备
|
||||||
|
- **实时通知** — 接收昔涟的主动消息、提醒、IoT 状态变更
|
||||||
|
- **后台连接** — 即使 APP 在后台,仍保持通知推送与语音服务就绪
|
||||||
|
|
||||||
|
## 开发状态
|
||||||
|
|
||||||
|
> **当前阶段**:项目初始化,尚未开始正式开发。
|
||||||
|
|
||||||
|
根据主项目的[开发路线图](../docs/dev-plan/00-development-roadmap.md),Android 客户端计划在 **Phase 5(v1.5 → v2.0)** 开始开发,预计时间窗口为 **2027 Q2-Q3**。
|
||||||
|
|
||||||
|
## 技术栈 (规划)
|
||||||
|
|
||||||
|
| 层 | 技术 |
|
||||||
|
|----|------|
|
||||||
|
| 语言 | Kotlin |
|
||||||
|
| UI 框架 | Jetpack Compose + Material Design 3 |
|
||||||
|
| 架构 | MVVM + Repository |
|
||||||
|
| 网络 | OkHttp / Retrofit + WebSocket |
|
||||||
|
| 本地存储 | Room (SQLite) + DataStore |
|
||||||
|
| 推送 | FCM (Firebase Cloud Messaging) |
|
||||||
|
| 语音 | VoiceInteractionService + 热词唤醒 + STT + TTS |
|
||||||
|
| 构建 | Gradle (Kotlin DSL) |
|
||||||
|
|
||||||
|
## 功能路线图
|
||||||
|
|
||||||
|
- [ ] 用户认证与登录
|
||||||
|
- [ ] 实时文字对话 (WebSocket)
|
||||||
|
- [ ] VoiceInteractionService 注册(替换系统语音助手)
|
||||||
|
- [ ] 间接启动悬浮窗(VoiceInteractionSession 全屏覆盖层)
|
||||||
|
- [ ] 热词唤醒(息屏 / 亮屏)+ 唤醒词自定义
|
||||||
|
- [ ] 语音识别 (STT)
|
||||||
|
- [ ] 语音合成 (TTS)
|
||||||
|
- [ ] IoT 设备控制面板
|
||||||
|
- [ ] 推送通知 (FCM)
|
||||||
|
- [ ] 后台连接与通知
|
||||||
|
- [ ] 锁屏 / 息屏语音交互
|
||||||
|
- [ ] PWA 保底方案 (短期替代)
|
||||||
|
|
||||||
|
## 交互模式
|
||||||
|
|
||||||
|
APP 有两种界面呈现方式,根据启动来源自动切换:
|
||||||
|
|
||||||
|
| 启动方式 | 界面模式 | 说明 |
|
||||||
|
|---------|---------|------|
|
||||||
|
| 桌面图标 / 最近任务 | **全屏 Activity** | 常规 APP 模式,完整功能入口 |
|
||||||
|
| 语音唤醒 / 长按 Home / 侧滑 / 长按电源键 / 耳机按键 | **悬浮窗 (VoiceInteractionSession)** | 全屏覆盖层,半透明背景透出底层 APP,不影响当前任务栈 |
|
||||||
|
|
||||||
|
悬浮窗模式的要点:
|
||||||
|
- 借助 `VoiceInteractionSession` 系统窗口,不压入 Activity 返回栈
|
||||||
|
- 对话结束后窗口收起,用户回到触发前的界面
|
||||||
|
- 底层 APP 内容半透明可见(模糊遮罩),让用户保持上下文感知
|
||||||
|
- 窗口高度自适应对话内容,类似 Google Assistant 的卡片式覆盖
|
||||||
|
|
||||||
|
## 设计规范
|
||||||
|
|
||||||
|
- **设计语言**:Material Design 3 (Material You)
|
||||||
|
- **主题**:动态配色(Dynamic Color),跟随系统壁纸或手动选择主题色
|
||||||
|
- **暗黑模式**:支持 Light / Dark 双主题,跟随系统或手动切换
|
||||||
|
- **组件**:全面使用 `androidx.compose.material3` 组件库
|
||||||
|
- **图标**:Material Icons + 自定义图标
|
||||||
|
- **动效**:遵循 MD3 动效规范(过渡动画、涟漪效果、Shared Elements)
|
||||||
|
- **字体**:系统默认字体(Roboto / Google Sans),支持动态字体缩放
|
||||||
|
|
||||||
|
## 项目结构 (规划)
|
||||||
|
|
||||||
|
```
|
||||||
|
android/
|
||||||
|
├── app/
|
||||||
|
│ ├── src/main/
|
||||||
|
│ │ ├── java/com/cyrene/app/
|
||||||
|
│ │ │ ├── ui/ # Compose UI 层
|
||||||
|
│ │ │ ├── viewmodel/ # ViewModel 层
|
||||||
|
│ │ │ ├── repository/ # 数据仓库层
|
||||||
|
│ │ │ ├── data/ # 数据模型 & API 接口
|
||||||
|
│ │ │ ├── service/ # VoiceInteractionService & WebSocket & FCM
|
||||||
|
│ │ │ ├── voice/ # 热词唤醒 (Hotword) & STT & TTS
|
||||||
|
│ │ │ └── di/ # 依赖注入
|
||||||
|
│ │ └── res/ # 资源文件
|
||||||
|
│ └── build.gradle.kts
|
||||||
|
├── build.gradle.kts
|
||||||
|
├── settings.gradle.kts
|
||||||
|
└── gradle.properties
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Android Studio Hedgehog (2023.1.1) 或更高版本
|
||||||
|
- Kotlin 2.0+
|
||||||
|
- JDK 17+
|
||||||
|
- Android SDK (target: 34+, min: 26+)
|
||||||
|
|
||||||
|
## 网络配置(中国大陆用户)
|
||||||
|
|
||||||
|
Gradle、Google Maven、Maven Central 等服务器在国内访问缓慢或不可达,首次同步前需配置镜像。
|
||||||
|
|
||||||
|
### Gradle Wrapper
|
||||||
|
|
||||||
|
`gradle/wrapper/gradle-wrapper.properties`:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
# 将 services.gradle.org 替换为腾讯/阿里镜像
|
||||||
|
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.7-bin.zip
|
||||||
|
# 或阿里: https\://mirrors.aliyun.com/macports/distfiles/gradle/gradle-8.7-bin.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
### 仓库镜像
|
||||||
|
|
||||||
|
`settings.gradle.kts` 顶部添加:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
maven { url = uri("https://mirrors.cloud.tencent.com/gradle/plugins") }
|
||||||
|
maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public") }
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public") }
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gradle 属性
|
||||||
|
|
||||||
|
`gradle.properties`:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
# HTTP 代理(如使用 Clash/V2Ray,通常不需要)
|
||||||
|
# systemProp.http.proxyHost=127.0.0.1
|
||||||
|
# systemProp.http.proxyPort=7890
|
||||||
|
# systemProp.https.proxyHost=127.0.0.1
|
||||||
|
# systemProp.https.proxyPort=7890
|
||||||
|
```
|
||||||
|
|
||||||
|
### Android SDK Proxy
|
||||||
|
|
||||||
|
若 Android SDK Manager 也下载缓慢,可在 Android Studio 中设置:
|
||||||
|
**Settings → Appearance & Behavior → System Settings → HTTP Proxy**
|
||||||
|
|
||||||
|
配置为 `mirrors.cloud.tencent.com` 或 `mirrors.neusoft.edu.cn`,端口 `80`。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
> 项目尚未包含可构建的源码,以下为后续开发的参考步骤。
|
||||||
|
|
||||||
|
1. 用 Android Studio 打开 `android/` 目录
|
||||||
|
2. 按上述"网络配置"替换国内镜像
|
||||||
|
3. 等待 Gradle 同步完成
|
||||||
|
4. 启动主项目后端服务(参考[主项目 README](../README.md))
|
||||||
|
5. 在 `local.properties` 中配置 `baseUrl` 指向 Gateway 地址
|
||||||
|
6. 选择模拟器或设备,点击 Run
|
||||||
|
|
||||||
|
## 相关链接
|
||||||
|
|
||||||
|
- [Cyrene 主项目](../) — 后端服务、前端、部署文档
|
||||||
|
- [开发路线图](../docs/dev-plan/00-development-roadmap.md)
|
||||||
|
- [多平台接入方案](../docs/dev-plan/03-multi-platform-integration.md)
|
||||||
|
- [语音系统计划](../docs/dev-plan/04-voice-system-plan.md)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache-2.0
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "top.yeij.cyrene"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "top.yeij.cyrene"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Compose BOM
|
||||||
|
val composeBom = platform(libs.compose.bom)
|
||||||
|
implementation(composeBom)
|
||||||
|
implementation(libs.compose.ui)
|
||||||
|
implementation(libs.compose.ui.graphics)
|
||||||
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.compose.material3)
|
||||||
|
implementation(libs.compose.material.icons)
|
||||||
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
|
||||||
|
// Activity & Navigation
|
||||||
|
implementation(libs.compose.activity)
|
||||||
|
implementation(libs.compose.navigation)
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
implementation(libs.lifecycle.runtime)
|
||||||
|
implementation(libs.lifecycle.viewmodel)
|
||||||
|
|
||||||
|
// Room
|
||||||
|
implementation(libs.room.runtime)
|
||||||
|
implementation(libs.room.ktx)
|
||||||
|
ksp(libs.room.compiler)
|
||||||
|
|
||||||
|
// Network
|
||||||
|
implementation(libs.retrofit)
|
||||||
|
implementation(libs.retrofit.gson)
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.okhttp.logging)
|
||||||
|
|
||||||
|
// Koin DI
|
||||||
|
implementation(libs.koin.android)
|
||||||
|
implementation(libs.koin.compose)
|
||||||
|
|
||||||
|
// DataStore
|
||||||
|
implementation(libs.datastore)
|
||||||
|
|
||||||
|
// Coroutines
|
||||||
|
implementation(libs.coroutines)
|
||||||
|
|
||||||
|
// Core
|
||||||
|
implementation(libs.core.ktx)
|
||||||
|
}
|
||||||
Vendored
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Cyrene ProGuard Rules
|
||||||
|
|
||||||
|
# Retrofit
|
||||||
|
-keepattributes Signature
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
-keep class top.yeij.cyrene.data.remote.dto.** { *; }
|
||||||
|
-dontwarn retrofit2.**
|
||||||
|
-keep class retrofit2.** { *; }
|
||||||
|
|
||||||
|
# Gson
|
||||||
|
-keep class com.google.gson.** { *; }
|
||||||
|
-keepattributes EnclosingMethod
|
||||||
|
|
||||||
|
# OkHttp
|
||||||
|
-dontwarn okhttp3.**
|
||||||
|
-dontwarn okio.**
|
||||||
|
|
||||||
|
# Room
|
||||||
|
-keep class * extends androidx.room.RoomDatabase
|
||||||
|
-dontwarn androidx.room.paging.**
|
||||||
|
|
||||||
|
# Koin
|
||||||
|
-keep class org.koin.** { *; }
|
||||||
|
|
||||||
|
# Coroutines
|
||||||
|
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||||
|
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- 网络 -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- 语音助手核心 -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.CAPTURE_AUDIO_HOTWORD" />
|
||||||
|
|
||||||
|
<!-- 后台服务 -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
|
||||||
|
<!-- 推送 -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- 锁屏交互 -->
|
||||||
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
|
|
||||||
|
<!-- 查询其他应用(检查默认助手设置) -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".CyreneApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Cyrene"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
|
<!-- 全屏主界面 -->
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:theme="@style/Theme.Cyrene">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- VoiceInteractionService:系统语音助手 -->
|
||||||
|
<service
|
||||||
|
android:name=".service.CyreneVoiceInteractionService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_VOICE_INTERACTION">
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.voice_interaction"
|
||||||
|
android:resource="@xml/voice_interaction_config" />
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package top.yeij.cyrene
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
|
import top.yeij.cyrene.data.remote.AuthInterceptor
|
||||||
|
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||||
|
import top.yeij.cyrene.di.appModule
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
|
||||||
|
class CyreneApplication : Application() {
|
||||||
|
|
||||||
|
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
startKoin {
|
||||||
|
androidContext(this@CyreneApplication)
|
||||||
|
modules(appModule)
|
||||||
|
}
|
||||||
|
|
||||||
|
initScope.launch {
|
||||||
|
val koin = org.koin.core.context.GlobalContext.get()
|
||||||
|
val prefs: PreferencesDataStore = koin.get()
|
||||||
|
val urlInterceptor: DynamicUrlInterceptor = koin.get()
|
||||||
|
val authInterceptor: AuthInterceptor = koin.get()
|
||||||
|
|
||||||
|
prefs.baseUrl.firstOrNull()?.let { url ->
|
||||||
|
if (url.isNotBlank()) urlInterceptor.baseUrl = url
|
||||||
|
}
|
||||||
|
prefs.token.firstOrNull()?.let { token ->
|
||||||
|
authInterceptor.token = token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package top.yeij.cyrene
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import top.yeij.cyrene.service.CyreneVoiceInteractionService
|
||||||
|
import top.yeij.cyrene.ui.navigation.CyreneNavGraph
|
||||||
|
import top.yeij.cyrene.ui.navigation.Routes
|
||||||
|
import top.yeij.cyrene.ui.theme.CyreneTheme
|
||||||
|
import top.yeij.cyrene.util.Constants
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
val isDefaultAssistant = checkIsDefaultAssistant()
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
CyreneTheme {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
|
||||||
|
CyreneNavGraph(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = Routes.MAIN,
|
||||||
|
isDefaultAssistant = isDefaultAssistant,
|
||||||
|
onOpenAssistantSettings = { openAssistantSettings() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkIsDefaultAssistant(): Boolean {
|
||||||
|
val componentName = ComponentName(this, CyreneVoiceInteractionService::class.java)
|
||||||
|
val intent = Intent("android.service.voice.VoiceInteractionService")
|
||||||
|
val services = packageManager.queryIntentServices(intent, 0)
|
||||||
|
return services.any { it.serviceInfo.packageName == packageName }
|
||||||
|
&& CyreneVoiceInteractionService.isActive
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openAssistantSettings() {
|
||||||
|
startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package top.yeij.cyrene.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import top.yeij.cyrene.data.local.dao.ConversationDao
|
||||||
|
import top.yeij.cyrene.data.local.dao.MessageDao
|
||||||
|
import top.yeij.cyrene.data.local.entity.ConversationEntity
|
||||||
|
import top.yeij.cyrene.data.local.entity.MessageEntity
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [ConversationEntity::class, MessageEntity::class],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = false,
|
||||||
|
)
|
||||||
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
abstract fun conversationDao(): ConversationDao
|
||||||
|
abstract fun messageDao(): MessageDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: AppDatabase? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): AppDatabase {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
INSTANCE ?: Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
AppDatabase::class.java,
|
||||||
|
"cyrene.db",
|
||||||
|
)
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build()
|
||||||
|
.also { INSTANCE = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package top.yeij.cyrene.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "cyrene_prefs")
|
||||||
|
|
||||||
|
class PreferencesDataStore(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val KEY_TOKEN = stringPreferencesKey("jwt_token")
|
||||||
|
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
|
||||||
|
private val KEY_BASE_URL = stringPreferencesKey("base_url")
|
||||||
|
private val KEY_THEME = stringPreferencesKey("theme_mode")
|
||||||
|
private val KEY_WAKE_WORD = stringPreferencesKey("wake_word")
|
||||||
|
private val KEY_USERNAME = stringPreferencesKey("username")
|
||||||
|
private val KEY_CLIENT_ID = stringPreferencesKey("client_id")
|
||||||
|
private val KEY_DEVICE_NAME = stringPreferencesKey("device_name")
|
||||||
|
}
|
||||||
|
|
||||||
|
val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
|
||||||
|
val refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
|
||||||
|
val baseUrl: Flow<String?> = context.dataStore.data.map { it[KEY_BASE_URL] }
|
||||||
|
val themeMode: Flow<String?> = context.dataStore.data.map { it[KEY_THEME] }
|
||||||
|
val wakeWord: Flow<String?> = context.dataStore.data.map { it[KEY_WAKE_WORD] }
|
||||||
|
val username: Flow<String?> = context.dataStore.data.map { it[KEY_USERNAME] }
|
||||||
|
val clientId: Flow<String?> = context.dataStore.data.map { it[KEY_CLIENT_ID] }
|
||||||
|
val deviceName: Flow<String?> = context.dataStore.data.map { it[KEY_DEVICE_NAME] }
|
||||||
|
|
||||||
|
suspend fun saveToken(token: String) {
|
||||||
|
context.dataStore.edit { it[KEY_TOKEN] = token }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveRefreshToken(token: String) {
|
||||||
|
context.dataStore.edit { it[KEY_REFRESH_TOKEN] = token }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveBaseUrl(url: String) {
|
||||||
|
context.dataStore.edit { it[KEY_BASE_URL] = url }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveThemeMode(mode: String) {
|
||||||
|
context.dataStore.edit { it[KEY_THEME] = mode }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveWakeWord(word: String) {
|
||||||
|
context.dataStore.edit { it[KEY_WAKE_WORD] = word }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveUsername(username: String) {
|
||||||
|
context.dataStore.edit { it[KEY_USERNAME] = username }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveClientId(id: String) {
|
||||||
|
context.dataStore.edit { it[KEY_CLIENT_ID] = id }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveDeviceName(name: String) {
|
||||||
|
context.dataStore.edit { it[KEY_DEVICE_NAME] = name }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearAll() {
|
||||||
|
context.dataStore.edit { it.clear() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package top.yeij.cyrene.data.local.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import top.yeij.cyrene.data.local.entity.ConversationEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ConversationDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
|
||||||
|
fun getAll(): Flow<List<ConversationEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM conversations WHERE id = :id")
|
||||||
|
suspend fun getById(id: String): ConversationEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(conversation: ConversationEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM conversations WHERE id = :id")
|
||||||
|
suspend fun deleteById(id: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM conversations")
|
||||||
|
suspend fun deleteAll()
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package top.yeij.cyrene.data.local.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import top.yeij.cyrene.data.local.entity.MessageEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface MessageDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY timestamp ASC")
|
||||||
|
fun getByConversation(conversationId: String): Flow<List<MessageEntity>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(message: MessageEntity)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsertAll(messages: List<MessageEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM messages WHERE conversationId = :conversationId")
|
||||||
|
suspend fun deleteByConversation(conversationId: String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package top.yeij.cyrene.data.local.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "conversations")
|
||||||
|
data class ConversationEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val title: String,
|
||||||
|
val lastMessage: String?,
|
||||||
|
val lastMessageType: String?,
|
||||||
|
val updatedAt: Long,
|
||||||
|
val createdAt: Long,
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package top.yeij.cyrene.data.local.entity
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "messages",
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = ConversationEntity::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["conversationId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("conversationId")],
|
||||||
|
)
|
||||||
|
data class MessageEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val conversationId: String,
|
||||||
|
val role: String,
|
||||||
|
val content: String,
|
||||||
|
val msgType: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package top.yeij.cyrene.data.remote
|
||||||
|
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import top.yeij.cyrene.data.remote.dto.AuthRequest
|
||||||
|
import top.yeij.cyrene.data.remote.dto.AuthResponse
|
||||||
|
import top.yeij.cyrene.data.remote.dto.ConversationDto
|
||||||
|
import top.yeij.cyrene.data.remote.dto.DeviceDto
|
||||||
|
import top.yeij.cyrene.data.remote.dto.IoTControlRequest
|
||||||
|
import top.yeij.cyrene.data.remote.dto.ReminderDto
|
||||||
|
|
||||||
|
interface ApiService {
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
@POST("api/v1/auth/login")
|
||||||
|
suspend fun login(@Body request: AuthRequest): Response<AuthResponse>
|
||||||
|
|
||||||
|
@POST("api/v1/auth/refresh")
|
||||||
|
suspend fun refreshToken(@Body refreshToken: String): Response<AuthResponse>
|
||||||
|
|
||||||
|
// Conversations
|
||||||
|
@GET("api/v1/conversations")
|
||||||
|
suspend fun getConversations(): Response<List<ConversationDto>>
|
||||||
|
|
||||||
|
@DELETE("api/v1/conversations/{id}")
|
||||||
|
suspend fun deleteConversation(@Path("id") id: String): Response<Unit>
|
||||||
|
|
||||||
|
// IoT
|
||||||
|
@GET("api/v1/iot/devices")
|
||||||
|
suspend fun getDevices(): Response<List<DeviceDto>>
|
||||||
|
|
||||||
|
@POST("api/v1/iot/devices/{id}/control")
|
||||||
|
suspend fun controlDevice(
|
||||||
|
@Path("id") deviceId: String,
|
||||||
|
@Body request: IoTControlRequest,
|
||||||
|
): Response<DeviceDto>
|
||||||
|
|
||||||
|
// Reminders
|
||||||
|
@GET("api/v1/reminders")
|
||||||
|
suspend fun getReminders(): Response<List<ReminderDto>>
|
||||||
|
|
||||||
|
@DELETE("api/v1/reminders/{id}")
|
||||||
|
suspend fun deleteReminder(@Path("id") id: String): Response<Unit>
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package top.yeij.cyrene.data.remote
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
class AuthInterceptor : Interceptor {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var token: String? = null
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = if (!token.isNullOrEmpty()) {
|
||||||
|
chain.request().newBuilder()
|
||||||
|
.addHeader("Authorization", "Bearer $token")
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
chain.request()
|
||||||
|
}
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package top.yeij.cyrene.data.remote
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
class DynamicUrlInterceptor : Interceptor {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "DynamicUrlInterceptor"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var baseUrl: String = "http://10.0.2.2:8080/"
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val originalRequest = chain.request()
|
||||||
|
val originalUrl = originalRequest.url
|
||||||
|
|
||||||
|
val targetBase = baseUrl.trimEnd('/')
|
||||||
|
val parsed = targetBase.toHttpUrlOrNull()
|
||||||
|
if (parsed == null) {
|
||||||
|
Log.e(TAG, "Invalid baseUrl: '$baseUrl'")
|
||||||
|
return chain.proceed(chain.request())
|
||||||
|
}
|
||||||
|
|
||||||
|
val newUrl = originalUrl.newBuilder()
|
||||||
|
.scheme(parsed.scheme)
|
||||||
|
.host(parsed.host)
|
||||||
|
.port(parsed.port)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
Log.d(TAG, "Rewriting ${originalUrl} → $newUrl (base: $targetBase)")
|
||||||
|
return chain.proceed(originalRequest.newBuilder().url(newUrl).build())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package top.yeij.cyrene.data.remote
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
object RetrofitClient {
|
||||||
|
|
||||||
|
fun provideOkHttpClient(
|
||||||
|
authInterceptor: AuthInterceptor,
|
||||||
|
dynamicUrlInterceptor: DynamicUrlInterceptor,
|
||||||
|
): OkHttpClient {
|
||||||
|
val logging = HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BODY
|
||||||
|
}
|
||||||
|
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.addInterceptor(dynamicUrlInterceptor)
|
||||||
|
.addInterceptor(authInterceptor)
|
||||||
|
.addInterceptor(logging)
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl("http://localhost/")
|
||||||
|
.client(okHttpClient)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package top.yeij.cyrene.data.remote.dto
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class AuthRequest(
|
||||||
|
@SerializedName("username") val username: String,
|
||||||
|
@SerializedName("password") val password: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AuthResponse(
|
||||||
|
@SerializedName("token") val token: String,
|
||||||
|
@SerializedName("refresh_token") val refreshToken: String?,
|
||||||
|
@SerializedName("username") val username: String?,
|
||||||
|
@SerializedName("user_id") val userId: String?,
|
||||||
|
@SerializedName("expires") val expires: Long? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package top.yeij.cyrene.data.remote.dto
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class ConversationDto(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("title") val title: String,
|
||||||
|
@SerializedName("last_message") val lastMessage: String?,
|
||||||
|
@SerializedName("last_message_type") val lastMessageType: String?,
|
||||||
|
@SerializedName("updated_at") val updatedAt: String,
|
||||||
|
@SerializedName("created_at") val createdAt: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package top.yeij.cyrene.data.remote.dto
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class DeviceDto(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("name") val name: String,
|
||||||
|
@SerializedName("type") val type: String,
|
||||||
|
@SerializedName("state") val state: DeviceState,
|
||||||
|
@SerializedName("room") val room: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DeviceState(
|
||||||
|
@SerializedName("power") val power: Boolean?,
|
||||||
|
@SerializedName("brightness") val brightness: Int?,
|
||||||
|
@SerializedName("temperature") val temperature: Float?,
|
||||||
|
@SerializedName("humidity") val humidity: Float?,
|
||||||
|
@SerializedName("colorTemp") val colorTemp: Int?,
|
||||||
|
@SerializedName("locked") val locked: Boolean?,
|
||||||
|
@SerializedName("open") val open: Boolean?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class IoTControlRequest(
|
||||||
|
@SerializedName("action") val action: String,
|
||||||
|
@SerializedName("value") val value: Any? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package top.yeij.cyrene.data.remote.dto
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class ReminderDto(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("content") val content: String,
|
||||||
|
@SerializedName("trigger_at") val triggerAt: String,
|
||||||
|
@SerializedName("completed") val completed: Boolean,
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package top.yeij.cyrene.data.remote.dto
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
// --- Client → Server ---
|
||||||
|
|
||||||
|
data class WSClientMessage(
|
||||||
|
@SerializedName("type") val type: String,
|
||||||
|
@SerializedName("session_id") val sessionId: String? = null,
|
||||||
|
@SerializedName("mode") val mode: String? = null,
|
||||||
|
@SerializedName("content") val content: String? = null,
|
||||||
|
@SerializedName("timestamp") val timestamp: Long? = null,
|
||||||
|
@SerializedName("client_id") val clientId: String? = null,
|
||||||
|
@SerializedName("device_name") val deviceName: String? = null,
|
||||||
|
@SerializedName("user_agent") val userAgent: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Server → Client ---
|
||||||
|
|
||||||
|
data class WSClientInfo(
|
||||||
|
@SerializedName("client_id") val clientId: String? = null,
|
||||||
|
@SerializedName("device_name") val deviceName: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WSServerMessage(
|
||||||
|
@SerializedName("type") val type: String?,
|
||||||
|
@SerializedName("message_id") val messageId: String? = null,
|
||||||
|
@SerializedName("text") val text: String? = null,
|
||||||
|
@SerializedName("content") val content: String? = null,
|
||||||
|
@SerializedName("role") val role: String? = null,
|
||||||
|
@SerializedName("msg_type") val msgType: String? = null,
|
||||||
|
@SerializedName("session_id") val sessionId: String? = null,
|
||||||
|
@SerializedName("error") val error: String? = null,
|
||||||
|
@SerializedName("review_messages") val reviewMessages: List<WSReviewMessage>? = null,
|
||||||
|
@SerializedName("messages") val messages: List<WSHistoryMessage>? = null,
|
||||||
|
@SerializedName("multi_message") val multiMessages: List<WSMultiItem>? = null,
|
||||||
|
@SerializedName("tool_progress") val toolProgress: WSToolProgress? = null,
|
||||||
|
@SerializedName("thinking_status") val thinkingStatus: String? = null,
|
||||||
|
@SerializedName("thinking_content") val thinkingContent: String? = null,
|
||||||
|
@SerializedName("timestamp") val timestamp: Long? = null,
|
||||||
|
@SerializedName("client_info") val clientInfo: WSClientInfo? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WSReviewMessage(
|
||||||
|
@SerializedName("role") val role: String?,
|
||||||
|
@SerializedName("text") val text: String?,
|
||||||
|
@SerializedName("content") val content: String?,
|
||||||
|
@SerializedName("msg_type") val msgType: String?,
|
||||||
|
@SerializedName("delay_ms") val delayMs: Long? = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WSHistoryMessage(
|
||||||
|
@SerializedName("id") val id: String?,
|
||||||
|
@SerializedName("role") val role: String?,
|
||||||
|
@SerializedName("content") val content: String?,
|
||||||
|
@SerializedName("msg_type") val msgType: String?,
|
||||||
|
@SerializedName("timestamp") val timestamp: Long?,
|
||||||
|
@SerializedName("name") val name: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WSMultiItem(
|
||||||
|
@SerializedName("role") val role: String?,
|
||||||
|
@SerializedName("content") val content: String?,
|
||||||
|
@SerializedName("msg_type") val msgType: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WSToolProgress(
|
||||||
|
@SerializedName("tool_name") val toolName: String?,
|
||||||
|
@SerializedName("status") val status: String?,
|
||||||
|
@SerializedName("detail") val detail: String?,
|
||||||
|
@SerializedName("message") val message: String?,
|
||||||
|
@SerializedName("progress") val progress: Int? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package top.yeij.cyrene.data.repository
|
||||||
|
|
||||||
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
|
import top.yeij.cyrene.data.remote.ApiService
|
||||||
|
import top.yeij.cyrene.data.remote.AuthInterceptor
|
||||||
|
import top.yeij.cyrene.data.remote.dto.AuthRequest
|
||||||
|
import top.yeij.cyrene.domain.model.AuthResult
|
||||||
|
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
|
||||||
|
class AuthRepositoryImpl(
|
||||||
|
private val apiService: ApiService,
|
||||||
|
private val preferencesDataStore: PreferencesDataStore,
|
||||||
|
private val authInterceptor: AuthInterceptor,
|
||||||
|
) : AuthRepository {
|
||||||
|
|
||||||
|
override suspend fun login(username: String, password: String): Result<AuthResult> {
|
||||||
|
return try {
|
||||||
|
val response = apiService.login(AuthRequest(username, password))
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val body = response.body()!!
|
||||||
|
authInterceptor.token = body.token
|
||||||
|
preferencesDataStore.saveToken(body.token)
|
||||||
|
body.refreshToken?.let { preferencesDataStore.saveRefreshToken(it) }
|
||||||
|
preferencesDataStore.saveUsername(body.username ?: body.userId ?: "开拓者")
|
||||||
|
Result.success(
|
||||||
|
AuthResult(
|
||||||
|
token = body.token,
|
||||||
|
refreshToken = body.refreshToken,
|
||||||
|
username = body.username ?: body.userId ?: "开拓者",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("登录失败: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun logout() {
|
||||||
|
authInterceptor.token = null
|
||||||
|
preferencesDataStore.clearAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun isLoggedIn(): Boolean {
|
||||||
|
return preferencesDataStore.token.firstOrNull() != null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package top.yeij.cyrene.data.repository
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yeij.cyrene.data.local.dao.ConversationDao
|
||||||
|
import top.yeij.cyrene.data.local.dao.MessageDao
|
||||||
|
import top.yeij.cyrene.data.local.entity.ConversationEntity
|
||||||
|
import top.yeij.cyrene.data.local.entity.MessageEntity
|
||||||
|
import top.yeij.cyrene.data.remote.ApiService
|
||||||
|
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||||
|
import top.yeij.cyrene.domain.model.Conversation
|
||||||
|
import top.yeij.cyrene.domain.model.Message
|
||||||
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
|
import top.yeij.cyrene.service.WebSocketService
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class ChatRepositoryImpl(
|
||||||
|
private val conversationDao: ConversationDao,
|
||||||
|
private val messageDao: MessageDao,
|
||||||
|
private val webSocketService: WebSocketService,
|
||||||
|
private val apiService: ApiService,
|
||||||
|
) : ChatRepository {
|
||||||
|
|
||||||
|
private val exceptionHandler = CoroutineExceptionHandler { _, e ->
|
||||||
|
Log.e("ChatRepository", "Unhandled exception", e)
|
||||||
|
}
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)
|
||||||
|
|
||||||
|
private val _connectionState = MutableStateFlow(false)
|
||||||
|
override val connectionState: StateFlow<Boolean> = _connectionState.asStateFlow()
|
||||||
|
|
||||||
|
private val _incomingMessages = MutableSharedFlow<Message>(extraBufferCapacity = 64)
|
||||||
|
override fun observeMessages(): Flow<Message> = _incomingMessages
|
||||||
|
|
||||||
|
private var streamingContent = ""
|
||||||
|
private var streamingMessageId: String? = null
|
||||||
|
private var currentSessionId: String? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
webSocketService.isConnected.collect { connected ->
|
||||||
|
_connectionState.value = connected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
webSocketService.incomingMessages.collect { wsMsg ->
|
||||||
|
try {
|
||||||
|
handleServerMessage(wsMsg)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ChatRepository", "Error handling ${wsMsg.type}: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getConversations(): Flow<List<Conversation>> {
|
||||||
|
return conversationDao.getAll().map { entities ->
|
||||||
|
entities.map { it.toDomain() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getMessages(conversationId: String): Flow<List<Message>> {
|
||||||
|
return messageDao.getByConversation(conversationId).map { entities ->
|
||||||
|
entities.map { it.toDomain() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteConversation(id: String) {
|
||||||
|
conversationDao.deleteById(id)
|
||||||
|
try { apiService.deleteConversation(id) } catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun connectWebSocket(sessionId: String?) {
|
||||||
|
currentSessionId = sessionId
|
||||||
|
webSocketService.connect(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun disconnectWebSocket() {
|
||||||
|
webSocketService.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendMessage(content: String, sessionId: String?) {
|
||||||
|
val messageId = UUID.randomUUID().toString()
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val sid = sessionId ?: currentSessionId ?: "default"
|
||||||
|
currentSessionId = sid
|
||||||
|
|
||||||
|
conversationDao.upsert(
|
||||||
|
ConversationEntity(
|
||||||
|
id = sid,
|
||||||
|
title = "对话",
|
||||||
|
lastMessage = content,
|
||||||
|
lastMessageType = "chat",
|
||||||
|
updatedAt = now,
|
||||||
|
createdAt = now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
messageDao.upsert(
|
||||||
|
MessageEntity(
|
||||||
|
id = messageId,
|
||||||
|
conversationId = sid,
|
||||||
|
role = "user",
|
||||||
|
content = content,
|
||||||
|
msgType = "chat",
|
||||||
|
timestamp = now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Emit user message to UI
|
||||||
|
emitMessage(
|
||||||
|
id = messageId,
|
||||||
|
sessionId = sid,
|
||||||
|
role = "user",
|
||||||
|
content = content,
|
||||||
|
msgType = "chat",
|
||||||
|
timestamp = now,
|
||||||
|
isStreaming = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
webSocketService.sendMessage(content, sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun loadConversationsFromServer() {
|
||||||
|
try {
|
||||||
|
val response = apiService.getConversations()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.forEach { dto ->
|
||||||
|
val timestamp = try { dto.updatedAt.toLong() } catch (_: Exception) { System.currentTimeMillis() }
|
||||||
|
conversationDao.upsert(
|
||||||
|
ConversationEntity(
|
||||||
|
id = dto.id,
|
||||||
|
title = dto.title,
|
||||||
|
lastMessage = dto.lastMessage,
|
||||||
|
lastMessageType = dto.lastMessageType,
|
||||||
|
updatedAt = timestamp,
|
||||||
|
createdAt = timestamp,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun loadMessagesFromServer(sessionId: String): List<Message> {
|
||||||
|
currentSessionId = sessionId
|
||||||
|
// Send history request via WebSocket
|
||||||
|
webSocketService.requestHistory(sessionId)
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureConversation(sessionId: String, lastMessage: String = "") {
|
||||||
|
val existing = conversationDao.getById(sessionId)
|
||||||
|
if (existing == null) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
conversationDao.upsert(
|
||||||
|
ConversationEntity(
|
||||||
|
id = sessionId,
|
||||||
|
title = "对话",
|
||||||
|
lastMessage = lastMessage,
|
||||||
|
lastMessageType = "chat",
|
||||||
|
updatedAt = now,
|
||||||
|
createdAt = now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleServerMessage(wsMsg: WSServerMessage) {
|
||||||
|
when (wsMsg.type) {
|
||||||
|
"stream_start" -> {
|
||||||
|
streamingContent = ""
|
||||||
|
streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
"stream_chunk" -> {
|
||||||
|
val delta = wsMsg.content ?: wsMsg.text ?: return
|
||||||
|
streamingContent += delta
|
||||||
|
emitMessage(
|
||||||
|
id = streamingMessageId ?: "s_${System.currentTimeMillis()}",
|
||||||
|
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||||
|
role = "assistant",
|
||||||
|
content = streamingContent,
|
||||||
|
msgType = "chat",
|
||||||
|
isStreaming = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"stream_end" -> {
|
||||||
|
val msgId = wsMsg.messageId ?: streamingMessageId ?: "s_${System.currentTimeMillis()}"
|
||||||
|
val content = streamingContent.ifEmpty { wsMsg.content ?: wsMsg.text ?: "" }
|
||||||
|
streamingContent = ""
|
||||||
|
streamingMessageId = null
|
||||||
|
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||||
|
currentSessionId = sid
|
||||||
|
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
|
||||||
|
|
||||||
|
ensureConversation(sid, content)
|
||||||
|
messageDao.upsert(
|
||||||
|
MessageEntity(
|
||||||
|
id = msgId,
|
||||||
|
conversationId = sid,
|
||||||
|
role = "assistant",
|
||||||
|
content = content,
|
||||||
|
msgType = "chat",
|
||||||
|
timestamp = ts,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = "chat", timestamp = ts, isStreaming = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
"response" -> {
|
||||||
|
val text = wsMsg.content ?: wsMsg.text ?: return
|
||||||
|
val role = wsMsg.role ?: "assistant"
|
||||||
|
val replyMsgType = wsMsg.msgType ?: "chat"
|
||||||
|
val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}"
|
||||||
|
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||||
|
currentSessionId = sid
|
||||||
|
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
|
||||||
|
|
||||||
|
ensureConversation(sid, text)
|
||||||
|
messageDao.upsert(
|
||||||
|
MessageEntity(
|
||||||
|
id = msgId,
|
||||||
|
conversationId = sid,
|
||||||
|
role = role,
|
||||||
|
content = text,
|
||||||
|
msgType = replyMsgType,
|
||||||
|
timestamp = ts,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
"review" -> {
|
||||||
|
wsMsg.reviewMessages?.forEach { review ->
|
||||||
|
val text = review.content ?: review.text ?: return@forEach
|
||||||
|
val role = review.role ?: "action"
|
||||||
|
val rvMsgType = review.msgType ?: review.role ?: "action"
|
||||||
|
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
|
||||||
|
|
||||||
|
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = text, msgType = rvMsgType, isStreaming = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"thinking" -> {
|
||||||
|
val text = wsMsg.thinkingContent ?: wsMsg.content ?: wsMsg.text
|
||||||
|
if (text != null) {
|
||||||
|
emitMessage(
|
||||||
|
id = wsMsg.messageId ?: "think_${System.currentTimeMillis()}",
|
||||||
|
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||||
|
role = "assistant",
|
||||||
|
content = text,
|
||||||
|
msgType = "thinking",
|
||||||
|
isStreaming = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"tool_progress" -> {
|
||||||
|
val detail = wsMsg.toolProgress?.message
|
||||||
|
?: wsMsg.toolProgress?.detail
|
||||||
|
?: wsMsg.toolProgress?.toolName
|
||||||
|
?: "正在执行操作..."
|
||||||
|
emitMessage(
|
||||||
|
id = wsMsg.messageId ?: "tool_${System.currentTimeMillis()}",
|
||||||
|
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||||
|
role = "system",
|
||||||
|
content = detail,
|
||||||
|
msgType = "tool_progress",
|
||||||
|
isStreaming = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"error" -> {
|
||||||
|
emitMessage(
|
||||||
|
id = "err_${System.currentTimeMillis()}",
|
||||||
|
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||||
|
role = "system",
|
||||||
|
content = wsMsg.error ?: "未知错误",
|
||||||
|
msgType = "system_info",
|
||||||
|
isStreaming = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"history_response" -> {
|
||||||
|
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||||
|
ensureConversation(sid)
|
||||||
|
wsMsg.messages?.forEach { hist ->
|
||||||
|
val msgId = hist.id ?: "hist_${System.currentTimeMillis()}_${hist.hashCode()}"
|
||||||
|
val role = hist.role ?: "system"
|
||||||
|
val content = hist.content ?: ""
|
||||||
|
val msgType = hist.msgType ?: "chat"
|
||||||
|
val ts = hist.timestamp ?: System.currentTimeMillis()
|
||||||
|
|
||||||
|
messageDao.upsert(
|
||||||
|
MessageEntity(
|
||||||
|
id = msgId,
|
||||||
|
conversationId = sid,
|
||||||
|
role = role,
|
||||||
|
content = content,
|
||||||
|
msgType = msgType,
|
||||||
|
timestamp = ts,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
emitMessage(id = msgId, sessionId = sid, role = role, content = content, msgType = msgType, timestamp = ts, isStreaming = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"multi_message" -> {
|
||||||
|
wsMsg.multiMessages?.forEach { item ->
|
||||||
|
emitMessage(
|
||||||
|
id = "mm_${System.currentTimeMillis()}_${item.hashCode()}",
|
||||||
|
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||||
|
role = item.role ?: "assistant",
|
||||||
|
content = item.content ?: "",
|
||||||
|
msgType = item.msgType ?: "chat",
|
||||||
|
timestamp = wsMsg.timestamp ?: System.currentTimeMillis(),
|
||||||
|
isStreaming = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitMessage(
|
||||||
|
id: String,
|
||||||
|
sessionId: String,
|
||||||
|
role: String,
|
||||||
|
content: String,
|
||||||
|
msgType: String,
|
||||||
|
isStreaming: Boolean = false,
|
||||||
|
timestamp: Long = System.currentTimeMillis(),
|
||||||
|
) {
|
||||||
|
// Skip messages with empty content to prevent empty bubbles
|
||||||
|
if (content.isBlank() && msgType == "chat") return
|
||||||
|
val message = Message(
|
||||||
|
id = id,
|
||||||
|
conversationId = sessionId,
|
||||||
|
role = role,
|
||||||
|
content = content,
|
||||||
|
msgType = msgType,
|
||||||
|
timestamp = timestamp,
|
||||||
|
isStreaming = isStreaming,
|
||||||
|
)
|
||||||
|
_incomingMessages.tryEmit(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ConversationEntity.toDomain() = Conversation(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
lastMessage = lastMessage,
|
||||||
|
lastMessageType = lastMessageType,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
createdAt = createdAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun MessageEntity.toDomain() = Message(
|
||||||
|
id = id,
|
||||||
|
conversationId = conversationId,
|
||||||
|
role = role,
|
||||||
|
content = content,
|
||||||
|
msgType = msgType,
|
||||||
|
timestamp = timestamp,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package top.yeij.cyrene.data.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import top.yeij.cyrene.data.remote.ApiService
|
||||||
|
import top.yeij.cyrene.data.remote.dto.IoTControlRequest
|
||||||
|
import top.yeij.cyrene.domain.model.Device
|
||||||
|
import top.yeij.cyrene.domain.model.DeviceState
|
||||||
|
import top.yeij.cyrene.domain.model.DeviceType
|
||||||
|
import top.yeij.cyrene.domain.repository.IoTRepository
|
||||||
|
|
||||||
|
class IoTRepositoryImpl(
|
||||||
|
private val apiService: ApiService,
|
||||||
|
private val webSocketService: top.yeij.cyrene.service.WebSocketService,
|
||||||
|
) : IoTRepository {
|
||||||
|
|
||||||
|
private val _devices = MutableStateFlow<List<Device>>(emptyList())
|
||||||
|
override fun getDevices(): Flow<List<Device>> = _devices.asStateFlow()
|
||||||
|
|
||||||
|
override suspend fun controlDevice(
|
||||||
|
deviceId: String,
|
||||||
|
action: String,
|
||||||
|
value: Any?,
|
||||||
|
): Result<Device> {
|
||||||
|
return try {
|
||||||
|
val response = apiService.controlDevice(deviceId, IoTControlRequest(action, value))
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val updated = response.body()!!.toDomain()
|
||||||
|
_devices.value = _devices.value.map {
|
||||||
|
if (it.id == deviceId) updated else it
|
||||||
|
}
|
||||||
|
Result.success(updated)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("设备控制失败: ${response.code()}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun refreshDevices() {
|
||||||
|
try {
|
||||||
|
val response = apiService.getDevices()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
_devices.value = response.body()!!.map { it.toDomain() }
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun top.yeij.cyrene.data.remote.dto.DeviceDto.toDomain() = Device(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
type = try {
|
||||||
|
DeviceType.valueOf(type.uppercase())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
DeviceType.UNKNOWN
|
||||||
|
},
|
||||||
|
state = DeviceState(
|
||||||
|
power = state.power,
|
||||||
|
brightness = state.brightness,
|
||||||
|
temperature = state.temperature,
|
||||||
|
humidity = state.humidity,
|
||||||
|
colorTemp = state.colorTemp,
|
||||||
|
locked = state.locked,
|
||||||
|
open = state.open,
|
||||||
|
),
|
||||||
|
room = room,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package top.yeij.cyrene.di
|
||||||
|
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.module.dsl.viewModel
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import top.yeij.cyrene.data.local.AppDatabase
|
||||||
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
|
import top.yeij.cyrene.data.remote.ApiService
|
||||||
|
import top.yeij.cyrene.data.remote.AuthInterceptor
|
||||||
|
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||||
|
import top.yeij.cyrene.data.remote.RetrofitClient
|
||||||
|
import top.yeij.cyrene.data.repository.AuthRepositoryImpl
|
||||||
|
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||||
|
import top.yeij.cyrene.data.repository.IoTRepositoryImpl
|
||||||
|
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||||
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
|
import top.yeij.cyrene.domain.repository.IoTRepository
|
||||||
|
import top.yeij.cyrene.domain.usecase.GetConversationsUseCase
|
||||||
|
import top.yeij.cyrene.domain.usecase.LoginUseCase
|
||||||
|
import top.yeij.cyrene.domain.usecase.SendMessageUseCase
|
||||||
|
import top.yeij.cyrene.service.WebSocketService
|
||||||
|
import top.yeij.cyrene.viewmodel.ChatViewModel
|
||||||
|
import top.yeij.cyrene.viewmodel.IoTViewModel
|
||||||
|
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||||
|
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||||
|
import top.yeij.cyrene.voice.stt.SpeechRecognizer
|
||||||
|
import top.yeij.cyrene.voice.tts.TextToSpeechEngine
|
||||||
|
|
||||||
|
val appModule = module {
|
||||||
|
|
||||||
|
// DataStore
|
||||||
|
single { PreferencesDataStore(androidContext()) }
|
||||||
|
|
||||||
|
// Database
|
||||||
|
single { AppDatabase.getInstance(androidContext()) }
|
||||||
|
single { get<AppDatabase>().conversationDao() }
|
||||||
|
single { get<AppDatabase>().messageDao() }
|
||||||
|
|
||||||
|
// Network interceptors (no runBlocking — using @Volatile caches)
|
||||||
|
single { AuthInterceptor() }
|
||||||
|
single { DynamicUrlInterceptor() }
|
||||||
|
single { RetrofitClient.provideOkHttpClient(get(), get()) }
|
||||||
|
single { RetrofitClient.provideRetrofit(get()) }
|
||||||
|
single { get<retrofit2.Retrofit>().create(ApiService::class.java) }
|
||||||
|
|
||||||
|
// WebSocket
|
||||||
|
single { WebSocketService(get()) }
|
||||||
|
|
||||||
|
// Voice
|
||||||
|
single { SpeechRecognizer() }
|
||||||
|
single { TextToSpeechEngine(androidContext()) }
|
||||||
|
|
||||||
|
// Repositories
|
||||||
|
single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) }
|
||||||
|
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get(), get()) }
|
||||||
|
single<IoTRepository> { IoTRepositoryImpl(get(), get()) }
|
||||||
|
|
||||||
|
// UseCases
|
||||||
|
factory { LoginUseCase(get()) }
|
||||||
|
factory { SendMessageUseCase(get()) }
|
||||||
|
factory { GetConversationsUseCase(get()) }
|
||||||
|
|
||||||
|
// ViewModels
|
||||||
|
viewModel { ChatViewModel(get()) }
|
||||||
|
viewModel { IoTViewModel(get()) }
|
||||||
|
viewModel { OverlayViewModel(get()) }
|
||||||
|
single { SettingsViewModel(get(), get(), get()) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package top.yeij.cyrene.domain.model
|
||||||
|
|
||||||
|
data class AuthResult(
|
||||||
|
val token: String,
|
||||||
|
val refreshToken: String?,
|
||||||
|
val username: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package top.yeij.cyrene.domain.model
|
||||||
|
|
||||||
|
data class Conversation(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val lastMessage: String?,
|
||||||
|
val lastMessageType: String?,
|
||||||
|
val updatedAt: Long,
|
||||||
|
val createdAt: Long,
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package top.yeij.cyrene.domain.model
|
||||||
|
|
||||||
|
data class Device(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val type: DeviceType,
|
||||||
|
val state: DeviceState,
|
||||||
|
val room: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class DeviceType {
|
||||||
|
LIGHT,
|
||||||
|
AC,
|
||||||
|
CURTAIN,
|
||||||
|
SENSOR,
|
||||||
|
DOOR_LOCK,
|
||||||
|
UNKNOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DeviceState(
|
||||||
|
val power: Boolean? = null,
|
||||||
|
val brightness: Int? = null,
|
||||||
|
val temperature: Float? = null,
|
||||||
|
val humidity: Float? = null,
|
||||||
|
val colorTemp: Int? = null,
|
||||||
|
val locked: Boolean? = null,
|
||||||
|
val open: Boolean? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package top.yeij.cyrene.domain.model
|
||||||
|
|
||||||
|
data class Message(
|
||||||
|
val id: String,
|
||||||
|
val conversationId: String,
|
||||||
|
val role: String,
|
||||||
|
val content: String,
|
||||||
|
val msgType: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
val isStreaming: Boolean = false,
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package top.yeij.cyrene.domain.repository
|
||||||
|
|
||||||
|
import top.yeij.cyrene.domain.model.AuthResult
|
||||||
|
|
||||||
|
interface AuthRepository {
|
||||||
|
|
||||||
|
suspend fun login(username: String, password: String): Result<AuthResult>
|
||||||
|
|
||||||
|
suspend fun logout()
|
||||||
|
|
||||||
|
suspend fun isLoggedIn(): Boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package top.yeij.cyrene.domain.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import top.yeij.cyrene.domain.model.Conversation
|
||||||
|
import top.yeij.cyrene.domain.model.Message
|
||||||
|
|
||||||
|
interface ChatRepository {
|
||||||
|
|
||||||
|
val connectionState: StateFlow<Boolean>
|
||||||
|
|
||||||
|
fun getConversations(): Flow<List<Conversation>>
|
||||||
|
|
||||||
|
suspend fun getMessages(conversationId: String): Flow<List<Message>>
|
||||||
|
|
||||||
|
suspend fun deleteConversation(id: String)
|
||||||
|
|
||||||
|
suspend fun connectWebSocket(sessionId: String?)
|
||||||
|
|
||||||
|
suspend fun disconnectWebSocket()
|
||||||
|
|
||||||
|
suspend fun sendMessage(content: String, sessionId: String?)
|
||||||
|
|
||||||
|
fun observeMessages(): Flow<Message>
|
||||||
|
|
||||||
|
suspend fun loadConversationsFromServer()
|
||||||
|
|
||||||
|
suspend fun loadMessagesFromServer(sessionId: String): List<Message>
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package top.yeij.cyrene.domain.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import top.yeij.cyrene.domain.model.Device
|
||||||
|
|
||||||
|
interface IoTRepository {
|
||||||
|
|
||||||
|
fun getDevices(): Flow<List<Device>>
|
||||||
|
|
||||||
|
suspend fun controlDevice(deviceId: String, action: String, value: Any? = null): Result<Device>
|
||||||
|
|
||||||
|
suspend fun refreshDevices()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package top.yeij.cyrene.domain.usecase
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import top.yeij.cyrene.domain.model.Conversation
|
||||||
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
|
|
||||||
|
class GetConversationsUseCase(
|
||||||
|
private val chatRepository: ChatRepository,
|
||||||
|
) {
|
||||||
|
operator fun invoke(): Flow<List<Conversation>> = chatRepository.getConversations()
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package top.yeij.cyrene.domain.usecase
|
||||||
|
|
||||||
|
import top.yeij.cyrene.domain.model.AuthResult
|
||||||
|
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||||
|
|
||||||
|
class LoginUseCase(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(username: String, password: String): Result<AuthResult> {
|
||||||
|
if (username.isBlank() || password.isBlank()) {
|
||||||
|
return Result.failure(IllegalArgumentException("用户名和密码不能为空"))
|
||||||
|
}
|
||||||
|
return authRepository.login(username, password)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package top.yeij.cyrene.domain.usecase
|
||||||
|
|
||||||
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
|
|
||||||
|
class SendMessageUseCase(
|
||||||
|
private val chatRepository: ChatRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(content: String, sessionId: String? = null) {
|
||||||
|
if (content.isNotBlank()) {
|
||||||
|
chatRepository.sendMessage(content, sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package top.yeij.cyrene.service
|
||||||
|
|
||||||
|
import android.service.voice.VoiceInteractionService
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recognition service for always-on hotword detection.
|
||||||
|
*
|
||||||
|
* In API 36, AlwaysOnHotwordDetector was removed from the public SDK.
|
||||||
|
* The system manages low-level hotword detection internally and invokes
|
||||||
|
* the session service declared in voice_interaction_config.xml when triggered.
|
||||||
|
* For custom hotword models (Porcupine / openWakeWord), integrate via
|
||||||
|
* a foreground service with microphone capture instead.
|
||||||
|
*/
|
||||||
|
class CyreneRecognitionService : VoiceInteractionService() {
|
||||||
|
|
||||||
|
override fun onReady() {
|
||||||
|
super.onReady()
|
||||||
|
isActive = true
|
||||||
|
Log.i(TAG, "Recognition service ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShutdown() {
|
||||||
|
isActive = false
|
||||||
|
super.onShutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "CyreneRecognition"
|
||||||
|
var isActive: Boolean = false
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package top.yeij.cyrene.service
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.service.voice.VoiceInteractionService
|
||||||
|
import top.yeij.cyrene.MainActivity
|
||||||
|
import top.yeij.cyrene.util.Constants
|
||||||
|
|
||||||
|
class CyreneVoiceInteractionService : VoiceInteractionService() {
|
||||||
|
|
||||||
|
override fun onReady() {
|
||||||
|
super.onReady()
|
||||||
|
isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPrepareToShowSession(args: Bundle, showFlags: Int) {
|
||||||
|
// Called before the session is shown — populate args for the session.
|
||||||
|
// Starting from API 36, session creation is handled by the system
|
||||||
|
// based on android:sessionService in voice_interaction_config.xml.
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShowSessionFailed(args: Bundle) {
|
||||||
|
// Session failed to show — could be due to permissions or system state.
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLaunchVoiceAssistFromKeyguard() {
|
||||||
|
val intent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
putExtra(Constants.EXTRA_VOICE_ASSIST, true)
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShutdown() {
|
||||||
|
isActive = false
|
||||||
|
super.onShutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
var isActive: Boolean = false
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package top.yeij.cyrene.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.service.voice.VoiceInteractionSession
|
||||||
|
import android.view.View
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
|
import org.koin.core.context.GlobalContext
|
||||||
|
import top.yeij.cyrene.ui.overlay.OverlayContent
|
||||||
|
import top.yeij.cyrene.ui.theme.CyreneTheme
|
||||||
|
import top.yeij.cyrene.util.Constants
|
||||||
|
import top.yeij.cyrene.voice.stt.SpeechRecognizer
|
||||||
|
import top.yeij.cyrene.voice.tts.TextToSpeechEngine
|
||||||
|
|
||||||
|
class CyreneVoiceInteractionSession(context: Context) :
|
||||||
|
VoiceInteractionSession(context) {
|
||||||
|
|
||||||
|
private val speechRecognizer: SpeechRecognizer by lazy {
|
||||||
|
GlobalContext.get().get()
|
||||||
|
}
|
||||||
|
private val ttsEngine: TextToSpeechEngine by lazy {
|
||||||
|
GlobalContext.get().get()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateContentView(): View {
|
||||||
|
return ComposeView(context).apply {
|
||||||
|
setContent {
|
||||||
|
CyreneTheme {
|
||||||
|
OverlayContent(
|
||||||
|
onDismiss = { finish() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShow(args: Bundle?, showFlags: Int) {
|
||||||
|
super.onShow(args, showFlags)
|
||||||
|
val startListening = args?.getBoolean(Constants.EXTRA_START_LISTENING, false) ?: false
|
||||||
|
if (startListening) {
|
||||||
|
speechRecognizer.startListening()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHide() {
|
||||||
|
super.onHide()
|
||||||
|
speechRecognizer.stopListening()
|
||||||
|
ttsEngine.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package top.yeij.cyrene.service
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
|
import top.yeij.cyrene.data.remote.dto.WSClientMessage
|
||||||
|
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class WebSocketService(
|
||||||
|
private val preferencesDataStore: PreferencesDataStore,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val httpClient = OkHttpClient.Builder()
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
.writeTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
.pingInterval(25, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
private var webSocket: WebSocket? = null
|
||||||
|
private var heartbeatJob: Job? = null
|
||||||
|
private var reconnecting = false
|
||||||
|
private var shouldReconnect = true
|
||||||
|
private var currentSessionId: String? = null
|
||||||
|
|
||||||
|
private var clientId: String = ""
|
||||||
|
private var deviceName: String = ""
|
||||||
|
|
||||||
|
private val _isConnected = MutableStateFlow(false)
|
||||||
|
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||||
|
|
||||||
|
private val _incomingMessages = MutableSharedFlow<WSServerMessage>(extraBufferCapacity = 64)
|
||||||
|
val incomingMessages: SharedFlow<WSServerMessage> = _incomingMessages.asSharedFlow()
|
||||||
|
|
||||||
|
private suspend fun initClientIdentity() {
|
||||||
|
clientId = preferencesDataStore.clientId.firstOrNull() ?: run {
|
||||||
|
val id = "cl_" + System.currentTimeMillis().toString(36) + "_" +
|
||||||
|
(100000..999999).random().toString(36)
|
||||||
|
preferencesDataStore.saveClientId(id)
|
||||||
|
id
|
||||||
|
}
|
||||||
|
deviceName = preferencesDataStore.deviceName.firstOrNull() ?: run {
|
||||||
|
val name = "Android " + (Build.MODEL ?: "Device")
|
||||||
|
preferencesDataStore.saveDeviceName(name)
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getClientId(): String = clientId
|
||||||
|
fun getDeviceName(): String = deviceName
|
||||||
|
|
||||||
|
suspend fun connect(sessionId: String? = null) {
|
||||||
|
currentSessionId = sessionId
|
||||||
|
shouldReconnect = true
|
||||||
|
reconnecting = false
|
||||||
|
|
||||||
|
initClientIdentity()
|
||||||
|
|
||||||
|
val baseUrl = preferencesDataStore.baseUrl.firstOrNull()
|
||||||
|
?: "http://10.0.2.2:8080"
|
||||||
|
val token = preferencesDataStore.token.firstOrNull() ?: ""
|
||||||
|
val wsBase = baseUrl
|
||||||
|
.replace("https://", "wss://")
|
||||||
|
.replace("http://", "ws://")
|
||||||
|
.trimEnd('/')
|
||||||
|
val urlBuilder = StringBuilder("$wsBase/ws/chat")
|
||||||
|
val params = mutableListOf<String>()
|
||||||
|
if (token.isNotBlank()) {
|
||||||
|
params.add("token=" + encode(token))
|
||||||
|
}
|
||||||
|
sessionId?.let {
|
||||||
|
if (it.isNotBlank()) params.add("session_id=" + encode(it))
|
||||||
|
}
|
||||||
|
if (clientId.isNotBlank()) {
|
||||||
|
params.add("client_id=" + encode(clientId))
|
||||||
|
}
|
||||||
|
if (deviceName.isNotBlank()) {
|
||||||
|
params.add("device_name=" + encode(deviceName))
|
||||||
|
}
|
||||||
|
if (params.isNotEmpty()) {
|
||||||
|
urlBuilder.append("?")
|
||||||
|
urlBuilder.append(params.joinToString("&"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val url = urlBuilder.toString()
|
||||||
|
Log.i(TAG, "Connecting to $url")
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("User-Agent", "Cyrene-Android/${Build.MODEL ?: "Device"}")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
cancelHeartbeat()
|
||||||
|
webSocket?.close(1000, "Reconnecting")
|
||||||
|
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
|
Log.i(TAG, "Connected")
|
||||||
|
reconnecting = false
|
||||||
|
_isConnected.value = true
|
||||||
|
startHeartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
try {
|
||||||
|
val msg = gson.fromJson(text, WSServerMessage::class.java)
|
||||||
|
_incomingMessages.tryEmit(msg)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to parse message: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
Log.i(TAG, "Server closing: code=$code reason=$reason")
|
||||||
|
_isConnected.value = false
|
||||||
|
cancelHeartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
Log.i(TAG, "Closed: code=$code reason=$reason")
|
||||||
|
_isConnected.value = false
|
||||||
|
cancelHeartbeat()
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
Log.e(TAG, "Connection failure: ${t.message} (response=${response?.code})", t)
|
||||||
|
_isConnected.value = false
|
||||||
|
cancelHeartbeat()
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
|
||||||
|
|
||||||
|
private fun buildMessage(
|
||||||
|
type: String,
|
||||||
|
sessionId: String? = null,
|
||||||
|
mode: String? = null,
|
||||||
|
content: String? = null,
|
||||||
|
): WSClientMessage = WSClientMessage(
|
||||||
|
type = type,
|
||||||
|
sessionId = sessionId ?: currentSessionId,
|
||||||
|
mode = mode,
|
||||||
|
content = content,
|
||||||
|
timestamp = System.currentTimeMillis(),
|
||||||
|
clientId = clientId.ifBlank { null },
|
||||||
|
deviceName = deviceName.ifBlank { null },
|
||||||
|
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun sendMessage(content: String, sessionId: String? = null, mode: String = "text") {
|
||||||
|
val msg = buildMessage("message", sessionId, mode, content)
|
||||||
|
webSocket?.send(gson.toJson(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestHistory(sessionId: String?) {
|
||||||
|
val msg = buildMessage("history", sessionId)
|
||||||
|
webSocket?.send(gson.toJson(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendPing() {
|
||||||
|
val msg = buildMessage("ping")
|
||||||
|
webSocket?.send(gson.toJson(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
shouldReconnect = false
|
||||||
|
reconnecting = false
|
||||||
|
cancelHeartbeat()
|
||||||
|
webSocket?.close(1000, "User disconnected")
|
||||||
|
webSocket = null
|
||||||
|
_isConnected.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startHeartbeat() {
|
||||||
|
cancelHeartbeat()
|
||||||
|
heartbeatJob = scope.launch {
|
||||||
|
while (_isConnected.value) {
|
||||||
|
delay(30_000)
|
||||||
|
if (_isConnected.value) {
|
||||||
|
sendPing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelHeartbeat() {
|
||||||
|
heartbeatJob?.cancel()
|
||||||
|
heartbeatJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleReconnect() {
|
||||||
|
if (reconnecting || !shouldReconnect) return
|
||||||
|
reconnecting = true
|
||||||
|
scope.launch {
|
||||||
|
var attempt = 0
|
||||||
|
while (attempt < 5 && shouldReconnect && !_isConnected.value) {
|
||||||
|
val delayMs = (Math.pow(2.0, attempt.toDouble()) * 1000).toLong()
|
||||||
|
Log.i(TAG, "Reconnecting in ${delayMs}ms (attempt ${attempt + 1}/5)")
|
||||||
|
delay(delayMs)
|
||||||
|
attempt++
|
||||||
|
if (shouldReconnect && !_isConnected.value) {
|
||||||
|
try {
|
||||||
|
connect(currentSessionId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Reconnect attempt $attempt failed: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reconnecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "CyreneWS"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package top.yeij.cyrene.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatBubble(
|
||||||
|
content: String,
|
||||||
|
role: String,
|
||||||
|
msgType: String,
|
||||||
|
timestamp: Long,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val isUser = role == "user"
|
||||||
|
val formattedTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
|
||||||
|
|
||||||
|
when (msgType) {
|
||||||
|
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier)
|
||||||
|
"action" -> ActionMessage(content, modifier)
|
||||||
|
"thinking" -> ThinkingBubble(content, modifier)
|
||||||
|
"tool_progress" -> ToolProgressBubble(content, modifier)
|
||||||
|
"system_info" -> SystemInfoBubble(content, modifier)
|
||||||
|
else -> ChatMessageBubble(content, isUser, formattedTime, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChatMessageBubble(
|
||||||
|
content: String,
|
||||||
|
isUser: Boolean,
|
||||||
|
time: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = if (isUser)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.widthIn(max = 300.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = content,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
color = if (isUser)
|
||||||
|
MaterialTheme.colorScheme.onPrimary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = time,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = content,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp)
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = content,
|
||||||
|
modifier = Modifier.padding(10.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = content,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = content,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package top.yeij.cyrene.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AcUnit
|
||||||
|
import androidx.compose.material.icons.filled.Curtains
|
||||||
|
import androidx.compose.material.icons.filled.Lightbulb
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.Sensors
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import top.yeij.cyrene.domain.model.Device
|
||||||
|
import top.yeij.cyrene.domain.model.DeviceType
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeviceCard(
|
||||||
|
device: Device,
|
||||||
|
onTogglePower: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = device.type.toIcon(),
|
||||||
|
contentDescription = device.type.name,
|
||||||
|
tint = if (device.state.power == true)
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = device.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
device.room?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
device.state.temperature?.let { temp ->
|
||||||
|
Text(
|
||||||
|
text = "${"%.1f".format(temp)}°C",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.state.power != null) {
|
||||||
|
Switch(
|
||||||
|
checked = device.state.power,
|
||||||
|
onCheckedChange = { onTogglePower() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DeviceType.toIcon() = when (this) {
|
||||||
|
DeviceType.LIGHT -> Icons.Filled.Lightbulb
|
||||||
|
DeviceType.AC -> Icons.Filled.AcUnit
|
||||||
|
DeviceType.CURTAIN -> Icons.Filled.Curtains
|
||||||
|
DeviceType.SENSOR -> Icons.Filled.Sensors
|
||||||
|
DeviceType.DOOR_LOCK -> Icons.Filled.Lock
|
||||||
|
DeviceType.UNKNOWN -> Icons.Filled.Sensors
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package top.yeij.cyrene.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Circle
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
enum class CyreneStatus {
|
||||||
|
ONLINE,
|
||||||
|
THINKING,
|
||||||
|
SPEAKING,
|
||||||
|
OFFLINE,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StatusIndicator(
|
||||||
|
status: CyreneStatus,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
when (status) {
|
||||||
|
CyreneStatus.ONLINE -> {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Circle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(8.dp),
|
||||||
|
tint = Color(0xFF4CAF50),
|
||||||
|
)
|
||||||
|
Text("昔涟", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
CyreneStatus.THINKING -> {
|
||||||
|
PulsingDot(Color(0xFFFFA726))
|
||||||
|
Text("思考中…", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
CyreneStatus.SPEAKING -> {
|
||||||
|
PulsingDot(Color(0xFF42A5F5))
|
||||||
|
Text("正在说话…", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
CyreneStatus.OFFLINE -> {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Circle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(8.dp),
|
||||||
|
tint = Color(0xFF9E9E9E),
|
||||||
|
)
|
||||||
|
Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PulsingDot(color: Color) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||||
|
val alpha by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0.3f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(600),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "pulse_alpha",
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.alpha(alpha)
|
||||||
|
.background(color, CircleShape),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package top.yeij.cyrene.ui.navigation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||||
|
import androidx.compose.material.icons.filled.DevicesOther
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import top.yeij.cyrene.ui.screens.chat.ChatScreen
|
||||||
|
import top.yeij.cyrene.ui.screens.iot.IoTScreen
|
||||||
|
import top.yeij.cyrene.ui.screens.login.LoginScreen
|
||||||
|
import top.yeij.cyrene.ui.screens.profile.ProfileScreen
|
||||||
|
import top.yeij.cyrene.ui.screens.settings.SettingsScreen
|
||||||
|
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||||
|
|
||||||
|
object Routes {
|
||||||
|
const val LOGIN = "login"
|
||||||
|
const val MAIN = "main"
|
||||||
|
const val CHAT = "chat"
|
||||||
|
const val IOT = "iot"
|
||||||
|
const val SETTINGS = "settings"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CyreneNavGraph(
|
||||||
|
navController: NavHostController,
|
||||||
|
startDestination: String,
|
||||||
|
isDefaultAssistant: Boolean,
|
||||||
|
onOpenAssistantSettings: () -> Unit,
|
||||||
|
) {
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = startDestination,
|
||||||
|
) {
|
||||||
|
composable(Routes.LOGIN) {
|
||||||
|
LoginScreen(
|
||||||
|
onLoginSuccess = {
|
||||||
|
navController.navigate(Routes.MAIN) {
|
||||||
|
popUpTo(Routes.LOGIN) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Routes.MAIN) {
|
||||||
|
MainScreen(
|
||||||
|
navController = navController,
|
||||||
|
isDefaultAssistant = isDefaultAssistant,
|
||||||
|
onOpenAssistantSettings = onOpenAssistantSettings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Routes.SETTINGS) {
|
||||||
|
SettingsScreen(
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BottomNavItem(
|
||||||
|
val label: String,
|
||||||
|
val icon: @Composable () -> Unit,
|
||||||
|
val route: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainScreen(
|
||||||
|
navController: NavHostController,
|
||||||
|
isDefaultAssistant: Boolean,
|
||||||
|
onOpenAssistantSettings: () -> Unit,
|
||||||
|
) {
|
||||||
|
val settingsViewModel: SettingsViewModel = koinInject()
|
||||||
|
|
||||||
|
val items = listOf(
|
||||||
|
BottomNavItem(
|
||||||
|
label = "对话",
|
||||||
|
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = "对话") },
|
||||||
|
route = Routes.CHAT,
|
||||||
|
),
|
||||||
|
BottomNavItem(
|
||||||
|
label = "设备",
|
||||||
|
icon = { Icon(Icons.Filled.DevicesOther, contentDescription = "设备") },
|
||||||
|
route = Routes.IOT,
|
||||||
|
),
|
||||||
|
BottomNavItem(
|
||||||
|
label = "我的",
|
||||||
|
icon = { Icon(Icons.Filled.Person, contentDescription = "我的") },
|
||||||
|
route = "profile",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
NavigationBar {
|
||||||
|
items.forEachIndexed { index, item ->
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selectedTab == index,
|
||||||
|
onClick = { selectedTab = index },
|
||||||
|
icon = item.icon,
|
||||||
|
label = { Text(item.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Box(modifier = Modifier.padding(padding)) {
|
||||||
|
when (selectedTab) {
|
||||||
|
0 -> ChatScreen()
|
||||||
|
1 -> IoTScreen()
|
||||||
|
2 -> ProfileScreen(
|
||||||
|
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
|
||||||
|
onLogout = {
|
||||||
|
settingsViewModel.logout()
|
||||||
|
navController.navigate(Routes.LOGIN) {
|
||||||
|
popUpTo(Routes.MAIN) { inclusive = true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateToLogin = {
|
||||||
|
navController.navigate(Routes.LOGIN)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package top.yeij.cyrene.ui.overlay
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import top.yeij.cyrene.ui.components.ChatBubble
|
||||||
|
import top.yeij.cyrene.ui.components.CyreneStatus
|
||||||
|
import top.yeij.cyrene.ui.components.StatusIndicator
|
||||||
|
import top.yeij.cyrene.viewmodel.OverlayState
|
||||||
|
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OverlayContent(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
viewModel: OverlayViewModel = koinInject(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val messages by viewModel.messages.collectAsState()
|
||||||
|
val recognizedText by viewModel.recognizedText.collectAsState()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
LaunchedEffect(messages.size) {
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
listState.animateScrollToItem(messages.size - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
if (state == OverlayState.IDLE) {
|
||||||
|
viewModel.finish()
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = state != OverlayState.IDLE,
|
||||||
|
enter = fadeIn() + slideInVertically { it },
|
||||||
|
exit = fadeOut() + slideOutVertically { it },
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f))
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
) { onDismiss() },
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
) { /* consume click */ },
|
||||||
|
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
|
||||||
|
shadowElevation = 8.dp,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
StatusIndicator(
|
||||||
|
status = when (state) {
|
||||||
|
OverlayState.LISTENING -> CyreneStatus.ONLINE
|
||||||
|
OverlayState.PROCESSING -> CyreneStatus.THINKING
|
||||||
|
OverlayState.SPEAKING -> CyreneStatus.SPEAKING
|
||||||
|
OverlayState.WAITING -> CyreneStatus.ONLINE
|
||||||
|
OverlayState.IDLE -> CyreneStatus.ONLINE
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
IconButton(onClick = { onDismiss() }) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = "关闭")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f, fill = false)
|
||||||
|
.height(200.dp),
|
||||||
|
state = listState,
|
||||||
|
) {
|
||||||
|
items(messages, key = { it.id }) { message ->
|
||||||
|
ChatBubble(
|
||||||
|
content = message.content,
|
||||||
|
role = message.role,
|
||||||
|
msgType = message.msgType,
|
||||||
|
timestamp = message.timestamp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recognized text display
|
||||||
|
if (recognizedText.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = recognizedText,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action button
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
when (state) {
|
||||||
|
OverlayState.LISTENING -> {
|
||||||
|
viewModel.onSpeechFinal(recognizedText)
|
||||||
|
}
|
||||||
|
OverlayState.WAITING -> {
|
||||||
|
viewModel.startListening()
|
||||||
|
}
|
||||||
|
else -> { }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = CircleShape,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = when (state) {
|
||||||
|
OverlayState.LISTENING -> MaterialTheme.colorScheme.error
|
||||||
|
OverlayState.PROCESSING -> MaterialTheme.colorScheme.secondary
|
||||||
|
else -> MaterialTheme.colorScheme.primary
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Mic,
|
||||||
|
contentDescription = "语音",
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = when (state) {
|
||||||
|
OverlayState.IDLE -> ""
|
||||||
|
OverlayState.LISTENING -> "我在听…"
|
||||||
|
OverlayState.PROCESSING -> "思考中…"
|
||||||
|
OverlayState.SPEAKING -> "正在说话…"
|
||||||
|
OverlayState.WAITING -> "点击继续说话"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package top.yeij.cyrene.ui.screens.chat
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import top.yeij.cyrene.ui.components.ChatBubble
|
||||||
|
import top.yeij.cyrene.ui.components.CyreneStatus
|
||||||
|
import top.yeij.cyrene.ui.components.StatusIndicator
|
||||||
|
import top.yeij.cyrene.viewmodel.ChatViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatScreen(
|
||||||
|
viewModel: ChatViewModel = koinInject(),
|
||||||
|
) {
|
||||||
|
val messages by viewModel.currentMessages.collectAsState()
|
||||||
|
val inputText by viewModel.inputText.collectAsState()
|
||||||
|
val isStreaming by viewModel.isStreaming.collectAsState()
|
||||||
|
val isConnected by viewModel.isConnected.collectAsState()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
LaunchedEffect(messages.size, isStreaming) {
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
val targetIndex = if (isStreaming) messages.size else messages.size - 1
|
||||||
|
listState.animateScrollToItem(targetIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val status = when {
|
||||||
|
isStreaming -> CyreneStatus.THINKING
|
||||||
|
isConnected -> CyreneStatus.ONLINE
|
||||||
|
else -> CyreneStatus.OFFLINE
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
StatusIndicator(status = status)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = inputText,
|
||||||
|
onValueChange = { viewModel.onInputChanged(it) },
|
||||||
|
placeholder = { Text("输入消息...") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 4,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.sendMessage() },
|
||||||
|
enabled = inputText.isNotBlank() && !isStreaming,
|
||||||
|
) {
|
||||||
|
if (isStreaming) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.padding(4.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
if (messages.isEmpty() && !isStreaming) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "开始和昔涟对话吧",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
state = listState,
|
||||||
|
) {
|
||||||
|
items(messages, key = { it.id }) { message ->
|
||||||
|
ChatBubble(
|
||||||
|
content = message.content,
|
||||||
|
role = message.role,
|
||||||
|
msgType = message.msgType,
|
||||||
|
timestamp = message.timestamp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isStreaming) {
|
||||||
|
item(key = "typing_indicator") {
|
||||||
|
TypingIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TypingIndicator(modifier: Modifier = Modifier) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "typing")
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.Start,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "昔涟正在输入",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
repeat(3) { index ->
|
||||||
|
val alpha by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0.2f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(
|
||||||
|
durationMillis = 400,
|
||||||
|
delayMillis = index * 200,
|
||||||
|
),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "dot_$index",
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(5.dp)
|
||||||
|
.alpha(alpha)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
CircleShape,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package top.yeij.cyrene.ui.screens.iot
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import top.yeij.cyrene.ui.components.DeviceCard
|
||||||
|
import top.yeij.cyrene.viewmodel.IoTViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun IoTScreen(
|
||||||
|
viewModel: IoTViewModel = koinInject(),
|
||||||
|
) {
|
||||||
|
val devices by viewModel.devices.collectAsState()
|
||||||
|
val isLoading by viewModel.isLoading.collectAsState()
|
||||||
|
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = isLoading,
|
||||||
|
onRefresh = { viewModel.refreshDevices() },
|
||||||
|
) {
|
||||||
|
if (devices.isEmpty() && !isLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "暂无设备数据\n请确认已连接到服务器",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn {
|
||||||
|
items(devices, key = { it.id }) { device ->
|
||||||
|
DeviceCard(
|
||||||
|
device = device,
|
||||||
|
onTogglePower = { viewModel.togglePower(device) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package top.yeij.cyrene.ui.screens.login
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import top.yeij.cyrene.domain.usecase.LoginUseCase
|
||||||
|
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(
|
||||||
|
onLoginSuccess: () -> Unit,
|
||||||
|
loginUseCase: LoginUseCase = koinInject(),
|
||||||
|
settingsViewModel: SettingsViewModel = koinInject(),
|
||||||
|
) {
|
||||||
|
var username by remember { mutableStateOf("") }
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var showPassword by remember { mutableStateOf(false) }
|
||||||
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(80.dp))
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Person,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Cyrene",
|
||||||
|
style = MaterialTheme.typography.displayLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "昔涟",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
label = { Text("用户名") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
singleLine = true,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("密码") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
visualTransformation = if (showPassword)
|
||||||
|
VisualTransformation.None
|
||||||
|
else
|
||||||
|
PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { showPassword = !showPassword }) {
|
||||||
|
Icon(
|
||||||
|
if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||||
|
contentDescription = if (showPassword) "隐藏密码" else "显示密码",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
isLoading = true
|
||||||
|
scope.launch {
|
||||||
|
loginUseCase(username, password).fold(
|
||||||
|
onSuccess = {
|
||||||
|
isLoading = false
|
||||||
|
onLoginSuccess()
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
isLoading = false
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showSnackbar(
|
||||||
|
error.message ?: "登录失败",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||||
|
enabled = username.isNotBlank() && password.isNotBlank() && !isLoading,
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("登录")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package top.yeij.cyrene.ui.screens.profile
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Help
|
||||||
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileScreen(
|
||||||
|
onNavigateToSettings: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
onNavigateToLogin: () -> Unit,
|
||||||
|
settingsViewModel: SettingsViewModel = koinInject(),
|
||||||
|
) {
|
||||||
|
val username by settingsViewModel.username.collectAsState()
|
||||||
|
val isLoggedIn by settingsViewModel.isLoggedIn.collectAsState()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
// Profile header
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (isLoggedIn) username.ifEmpty { "开拓者" } else "未登录",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = if (!isLoggedIn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = if (!isLoggedIn) {
|
||||||
|
Modifier.clickable { onNavigateToLogin() }
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "与昔涟同行",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item { HorizontalDivider() }
|
||||||
|
item { Spacer(modifier = Modifier.height(8.dp)) }
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("设置") },
|
||||||
|
leadingContent = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||||
|
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
|
||||||
|
modifier = Modifier.clickable { onNavigateToSettings() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reminders
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("提醒") },
|
||||||
|
leadingContent = { Icon(Icons.Filled.Notifications, contentDescription = null) },
|
||||||
|
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item { HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) }
|
||||||
|
|
||||||
|
// About
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("关于") },
|
||||||
|
leadingContent = { Icon(Icons.Filled.Info, contentDescription = null) },
|
||||||
|
supportingContent = { Text("Cyrene v0.1.0") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Help
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("使用帮助") },
|
||||||
|
leadingContent = { Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item { Spacer(modifier = Modifier.height(24.dp)) }
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
if (isLoggedIn) {
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = "退出登录",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ExitToApp,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { onLogout() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package top.yeij.cyrene.ui.screens.settings
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.DarkMode
|
||||||
|
import androidx.compose.material.icons.filled.LightMode
|
||||||
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material.icons.filled.SettingsBrightness
|
||||||
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: SettingsViewModel = koinInject(),
|
||||||
|
) {
|
||||||
|
val baseUrl by viewModel.baseUrl.collectAsState()
|
||||||
|
val themeMode by viewModel.themeMode.collectAsState()
|
||||||
|
val wakeWord by viewModel.wakeWord.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
fun sanitizeUrl(raw: String): String? {
|
||||||
|
var url = raw.trim()
|
||||||
|
if (url.isEmpty()) return null
|
||||||
|
val hasScheme = url.contains("://")
|
||||||
|
if (!hasScheme) url = "http://$url"
|
||||||
|
return try {
|
||||||
|
val parsed = java.net.URL(url)
|
||||||
|
val host = parsed.host ?: return null
|
||||||
|
if (host.isEmpty()) return null
|
||||||
|
val scheme = parsed.protocol
|
||||||
|
val port = if (parsed.port > 0) ":${parsed.port}" else ""
|
||||||
|
val path = parsed.path?.trimEnd('/') ?: ""
|
||||||
|
val query = parsed.query?.let { "?$it" } ?: ""
|
||||||
|
"$scheme://$host$port$path$query"
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
title = { Text("设置") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
// Server
|
||||||
|
Text(
|
||||||
|
text = "服务器",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = baseUrl,
|
||||||
|
onValueChange = { viewModel.saveBaseUrl(it) },
|
||||||
|
label = { Text("服务器地址") },
|
||||||
|
placeholder = { Text("http://192.168.1.x:8080") },
|
||||||
|
singleLine = true,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Uri,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
scope.launch {
|
||||||
|
val sanitized = sanitizeUrl(baseUrl)
|
||||||
|
if (sanitized != null) {
|
||||||
|
viewModel.saveBaseUrl(sanitized)
|
||||||
|
Toast.makeText(context, "地址已保存", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
FilledTonalIconButton(onClick = {
|
||||||
|
val sanitized = sanitizeUrl(baseUrl)
|
||||||
|
if (sanitized != null) {
|
||||||
|
viewModel.saveBaseUrl(sanitized)
|
||||||
|
Toast.makeText(context, "地址已保存", Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, "地址格式无效", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Filled.Check, contentDescription = "确认")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Appearance
|
||||||
|
Text(
|
||||||
|
text = "外观",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
val themeLabel = when (themeMode) {
|
||||||
|
"light" -> "浅色模式"
|
||||||
|
"dark" -> "深色模式"
|
||||||
|
else -> "跟随系统"
|
||||||
|
}
|
||||||
|
val themeIcon = when (themeMode) {
|
||||||
|
"light" -> Icons.Filled.LightMode
|
||||||
|
"dark" -> Icons.Filled.DarkMode
|
||||||
|
else -> Icons.Filled.SettingsBrightness
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("主题") },
|
||||||
|
supportingContent = { Text(themeLabel) },
|
||||||
|
leadingContent = { Icon(themeIcon, contentDescription = null) },
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
val next = when (themeMode) {
|
||||||
|
"light" -> "dark"
|
||||||
|
"dark" -> "auto"
|
||||||
|
else -> "light"
|
||||||
|
}
|
||||||
|
viewModel.saveThemeMode(next)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("主题色") },
|
||||||
|
supportingContent = { Text("昔涟紫") },
|
||||||
|
leadingContent = { Icon(Icons.Filled.Palette, contentDescription = null) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Voice
|
||||||
|
Text(
|
||||||
|
text = "语音",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = wakeWord,
|
||||||
|
onValueChange = { viewModel.saveWakeWord(it) },
|
||||||
|
label = { Text("唤醒词") },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package top.yeij.cyrene.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// Light theme
|
||||||
|
val LightPrimary = Color(0xFF6D3BC0)
|
||||||
|
val LightOnPrimary = Color(0xFFFFFFFF)
|
||||||
|
val LightPrimaryContainer = Color(0xFFEEDCFF)
|
||||||
|
val LightOnPrimaryContainer = Color(0xFF250058)
|
||||||
|
val LightSecondary = Color(0xFF625B71)
|
||||||
|
val LightOnSecondary = Color(0xFFFFFFFF)
|
||||||
|
val LightSecondaryContainer = Color(0xFFE8DEF8)
|
||||||
|
val LightOnSecondaryContainer = Color(0xFF1E192B)
|
||||||
|
val LightTertiary = Color(0xFF7E5260)
|
||||||
|
val LightOnTertiary = Color(0xFFFFFFFF)
|
||||||
|
val LightTertiaryContainer = Color(0xFFFFD9E3)
|
||||||
|
val LightOnTertiaryContainer = Color(0xFF31101D)
|
||||||
|
val LightBackground = Color(0xFFFFFBFF)
|
||||||
|
val LightOnBackground = Color(0xFF1C1B1F)
|
||||||
|
val LightSurface = Color(0xFFFFFBFF)
|
||||||
|
val LightOnSurface = Color(0xFF1C1B1F)
|
||||||
|
val LightSurfaceVariant = Color(0xFFE7E0EC)
|
||||||
|
val LightOnSurfaceVariant = Color(0xFF49454F)
|
||||||
|
val LightError = Color(0xFFBA1A1A)
|
||||||
|
val LightOutline = Color(0xFF79747E)
|
||||||
|
val LightOutlineVariant = Color(0xFFCAC4D0)
|
||||||
|
|
||||||
|
// Dark theme
|
||||||
|
val DarkPrimary = Color(0xFFD3BBFF)
|
||||||
|
val DarkOnPrimary = Color(0xFF3D0089)
|
||||||
|
val DarkPrimaryContainer = Color(0xFF541BA6)
|
||||||
|
val DarkOnPrimaryContainer = Color(0xFFEEDCFF)
|
||||||
|
val DarkSecondary = Color(0xFFCBC2DC)
|
||||||
|
val DarkOnSecondary = Color(0xFF332D41)
|
||||||
|
val DarkSecondaryContainer = Color(0xFF4A4458)
|
||||||
|
val DarkOnSecondaryContainer = Color(0xFFE8DEF8)
|
||||||
|
val DarkTertiary = Color(0xFFEFB8C8)
|
||||||
|
val DarkOnTertiary = Color(0xFF4A2532)
|
||||||
|
val DarkTertiaryContainer = Color(0xFF633B48)
|
||||||
|
val DarkOnTertiaryContainer = Color(0xFFFFD9E3)
|
||||||
|
val DarkBackground = Color(0xFF1C1B1F)
|
||||||
|
val DarkOnBackground = Color(0xFFE6E1E5)
|
||||||
|
val DarkSurface = Color(0xFF1C1B1F)
|
||||||
|
val DarkOnSurface = Color(0xFFE6E1E5)
|
||||||
|
val DarkSurfaceVariant = Color(0xFF49454F)
|
||||||
|
val DarkOnSurfaceVariant = Color(0xFFCAC4D0)
|
||||||
|
val DarkError = Color(0xFFFFB4AB)
|
||||||
|
val DarkOutline = Color(0xFF938F99)
|
||||||
|
val DarkOutlineVariant = Color(0xFF49454F)
|
||||||
|
|
||||||
|
// Preset seed colors for manual theme selection
|
||||||
|
val SeedColors = mapOf(
|
||||||
|
"default" to 0xFF6D3BC0, // Lavender
|
||||||
|
"sakura" to 0xFFFFB4C8, // Pink
|
||||||
|
"ocean" to 0xFF6BA4FF, // Blue
|
||||||
|
"forest" to 0xFF6BCF7C, // Green
|
||||||
|
"sunset" to 0xFFFF9E6B, // Orange
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package top.yeij.cyrene.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Shapes
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
val CyreneShapes = Shapes(
|
||||||
|
extraSmall = RoundedCornerShape(8.dp),
|
||||||
|
small = RoundedCornerShape(12.dp),
|
||||||
|
medium = RoundedCornerShape(16.dp),
|
||||||
|
large = RoundedCornerShape(24.dp),
|
||||||
|
extraLarge = RoundedCornerShape(32.dp),
|
||||||
|
)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package top.yeij.cyrene.ui.theme
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = LightPrimary,
|
||||||
|
onPrimary = LightOnPrimary,
|
||||||
|
primaryContainer = LightPrimaryContainer,
|
||||||
|
onPrimaryContainer = LightOnPrimaryContainer,
|
||||||
|
secondary = LightSecondary,
|
||||||
|
onSecondary = LightOnSecondary,
|
||||||
|
secondaryContainer = LightSecondaryContainer,
|
||||||
|
onSecondaryContainer = LightOnSecondaryContainer,
|
||||||
|
tertiary = LightTertiary,
|
||||||
|
onTertiary = LightOnTertiary,
|
||||||
|
tertiaryContainer = LightTertiaryContainer,
|
||||||
|
onTertiaryContainer = LightOnTertiaryContainer,
|
||||||
|
background = LightBackground,
|
||||||
|
onBackground = LightOnBackground,
|
||||||
|
surface = LightSurface,
|
||||||
|
onSurface = LightOnSurface,
|
||||||
|
surfaceVariant = LightSurfaceVariant,
|
||||||
|
onSurfaceVariant = LightOnSurfaceVariant,
|
||||||
|
error = LightError,
|
||||||
|
outline = LightOutline,
|
||||||
|
outlineVariant = LightOutlineVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = DarkPrimary,
|
||||||
|
onPrimary = DarkOnPrimary,
|
||||||
|
primaryContainer = DarkPrimaryContainer,
|
||||||
|
onPrimaryContainer = DarkOnPrimaryContainer,
|
||||||
|
secondary = DarkSecondary,
|
||||||
|
onSecondary = DarkOnSecondary,
|
||||||
|
secondaryContainer = DarkSecondaryContainer,
|
||||||
|
onSecondaryContainer = DarkOnSecondaryContainer,
|
||||||
|
tertiary = DarkTertiary,
|
||||||
|
onTertiary = DarkOnTertiary,
|
||||||
|
tertiaryContainer = DarkTertiaryContainer,
|
||||||
|
onTertiaryContainer = DarkOnTertiaryContainer,
|
||||||
|
background = DarkBackground,
|
||||||
|
onBackground = DarkOnBackground,
|
||||||
|
surface = DarkSurface,
|
||||||
|
onSurface = DarkOnSurface,
|
||||||
|
surfaceVariant = DarkSurfaceVariant,
|
||||||
|
onSurfaceVariant = DarkOnSurfaceVariant,
|
||||||
|
error = DarkError,
|
||||||
|
outline = DarkOutline,
|
||||||
|
outlineVariant = DarkOutlineVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CyreneTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
dynamicColor: Boolean = true,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
darkTheme -> DarkColorScheme
|
||||||
|
else -> LightColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = CyreneTypography,
|
||||||
|
shapes = CyreneShapes,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package top.yeij.cyrene.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
val CyreneTypography = Typography(
|
||||||
|
displayLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 57.sp,
|
||||||
|
lineHeight = 64.sp,
|
||||||
|
),
|
||||||
|
headlineMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
lineHeight = 36.sp,
|
||||||
|
),
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
lineHeight = 28.sp,
|
||||||
|
),
|
||||||
|
titleMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
),
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
),
|
||||||
|
bodyMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
),
|
||||||
|
labelLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
),
|
||||||
|
labelMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
),
|
||||||
|
labelSmall = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package top.yeij.cyrene.util
|
||||||
|
|
||||||
|
object Constants {
|
||||||
|
const val DEFAULT_WAKE_WORD = "昔涟"
|
||||||
|
const val SILENCE_TIMEOUT_MS = 10_000L
|
||||||
|
const val WS_RECONNECT_MAX_ATTEMPTS = 5
|
||||||
|
const val WS_PING_INTERVAL_SECONDS = 30L
|
||||||
|
const val HOTWORD_INACTIVITY_TIMEOUT_MINUTES = 10L
|
||||||
|
const val OVERLAY_ANIM_DURATION_MS = 300
|
||||||
|
const val STT_SILENCE_THRESHOLD_MS = 1500L
|
||||||
|
|
||||||
|
// Intent extras
|
||||||
|
const val EXTRA_VOICE_ASSIST = "voice_assist"
|
||||||
|
const val EXTRA_START_LISTENING = "start_listening"
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package top.yeij.cyrene.viewmodel
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yeij.cyrene.domain.model.Conversation
|
||||||
|
import top.yeij.cyrene.domain.model.Message
|
||||||
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
|
|
||||||
|
class ChatViewModel(
|
||||||
|
private val chatRepository: ChatRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val isConnected: StateFlow<Boolean> = chatRepository.connectionState
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
val conversations: StateFlow<List<Conversation>> = chatRepository.getConversations()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
|
private val _currentMessages = MutableStateFlow<List<Message>>(emptyList())
|
||||||
|
val currentMessages: StateFlow<List<Message>> = _currentMessages.asStateFlow()
|
||||||
|
|
||||||
|
private val _inputText = MutableStateFlow("")
|
||||||
|
val inputText: StateFlow<String> = _inputText.asStateFlow()
|
||||||
|
|
||||||
|
private val _isStreaming = MutableStateFlow(false)
|
||||||
|
val isStreaming: StateFlow<Boolean> = _isStreaming.asStateFlow()
|
||||||
|
|
||||||
|
private var currentSessionId: String? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
connectAndLoad()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connectAndLoad(sessionId: String? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.connectWebSocket(sessionId)
|
||||||
|
chatRepository.loadConversationsFromServer()
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.observeMessages().collect { message ->
|
||||||
|
try {
|
||||||
|
val list = _currentMessages.value.toMutableList()
|
||||||
|
val existingIdx = list.indexOfLast { it.id == message.id }
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
list[existingIdx] = message
|
||||||
|
} else {
|
||||||
|
list.add(message)
|
||||||
|
}
|
||||||
|
_currentMessages.value = list
|
||||||
|
_isStreaming.value = list.any { it.isStreaming }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ChatViewModel", "Error processing message: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onInputChanged(text: String) {
|
||||||
|
_inputText.value = text
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage() {
|
||||||
|
val text = _inputText.value.trim()
|
||||||
|
if (text.isEmpty()) return
|
||||||
|
|
||||||
|
_inputText.value = ""
|
||||||
|
_isStreaming.value = true
|
||||||
|
val sid = currentSessionId
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.sendMessage(text, sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun switchSession(sessionId: String) {
|
||||||
|
currentSessionId = sessionId
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.disconnectWebSocket()
|
||||||
|
chatRepository.connectWebSocket(sessionId)
|
||||||
|
chatRepository.loadMessagesFromServer(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteConversation(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.deleteConversation(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.disconnectWebSocket()
|
||||||
|
}
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package top.yeij.cyrene.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yeij.cyrene.domain.model.Device
|
||||||
|
import top.yeij.cyrene.domain.repository.IoTRepository
|
||||||
|
|
||||||
|
class IoTViewModel(
|
||||||
|
private val ioTRepository: IoTRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val devices: StateFlow<List<Device>> = ioTRepository.getDevices()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||||
|
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
|
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||||
|
|
||||||
|
private val _error = MutableStateFlow<String?>(null)
|
||||||
|
val error: StateFlow<String?> = _error.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
refreshDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshDevices() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isLoading.value = true
|
||||||
|
ioTRepository.refreshDevices()
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun controlDevice(deviceId: String, action: String, value: Any? = null) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isLoading.value = true
|
||||||
|
_error.value = null
|
||||||
|
ioTRepository.controlDevice(deviceId, action, value).fold(
|
||||||
|
onSuccess = { },
|
||||||
|
onFailure = { _error.value = it.message },
|
||||||
|
)
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePower(device: Device) {
|
||||||
|
val action = if (device.state.power == true) "off" else "on"
|
||||||
|
controlDevice(device.id, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_error.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package top.yeij.cyrene.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yeij.cyrene.domain.model.Message
|
||||||
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
|
import top.yeij.cyrene.util.Constants
|
||||||
|
|
||||||
|
enum class OverlayState {
|
||||||
|
IDLE,
|
||||||
|
LISTENING,
|
||||||
|
PROCESSING,
|
||||||
|
SPEAKING,
|
||||||
|
WAITING,
|
||||||
|
}
|
||||||
|
|
||||||
|
class OverlayViewModel(
|
||||||
|
private val chatRepository: ChatRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(OverlayState.IDLE)
|
||||||
|
val state: StateFlow<OverlayState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _messages = MutableStateFlow<List<Message>>(emptyList())
|
||||||
|
val messages: StateFlow<List<Message>> = _messages.asStateFlow()
|
||||||
|
|
||||||
|
private val _recognizedText = MutableStateFlow("")
|
||||||
|
val recognizedText: StateFlow<String> = _recognizedText.asStateFlow()
|
||||||
|
|
||||||
|
private var silenceTimer: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.connectWebSocket(null)
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.observeMessages().collect { message ->
|
||||||
|
_messages.value = _messages.value + message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startListening() {
|
||||||
|
_state.value = OverlayState.LISTENING
|
||||||
|
resetSilenceTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSpeechPartial(text: String) {
|
||||||
|
_recognizedText.value = text
|
||||||
|
resetSilenceTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSpeechFinal(text: String) {
|
||||||
|
_recognizedText.value = text
|
||||||
|
_state.value = OverlayState.PROCESSING
|
||||||
|
cancelSilenceTimer()
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.sendMessage(text, null)
|
||||||
|
_recognizedText.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendText(text: String) {
|
||||||
|
_state.value = OverlayState.PROCESSING
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.sendMessage(text, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpeaking() {
|
||||||
|
_state.value = OverlayState.SPEAKING
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWaiting() {
|
||||||
|
_state.value = OverlayState.WAITING
|
||||||
|
startSilenceTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finish() {
|
||||||
|
_state.value = OverlayState.IDLE
|
||||||
|
cancelSilenceTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startSilenceTimer() {
|
||||||
|
cancelSilenceTimer()
|
||||||
|
silenceTimer = viewModelScope.launch {
|
||||||
|
delay(Constants.SILENCE_TIMEOUT_MS)
|
||||||
|
_state.value = OverlayState.IDLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetSilenceTimer() {
|
||||||
|
cancelSilenceTimer()
|
||||||
|
startSilenceTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelSilenceTimer() {
|
||||||
|
silenceTimer?.cancel()
|
||||||
|
silenceTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.disconnectWebSocket()
|
||||||
|
}
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package top.yeij.cyrene.viewmodel
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
|
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||||
|
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||||
|
|
||||||
|
class SettingsViewModel(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val preferencesDataStore: PreferencesDataStore,
|
||||||
|
private val dynamicUrlInterceptor: DynamicUrlInterceptor,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
|
||||||
|
private val _baseUrl = MutableStateFlow("")
|
||||||
|
val baseUrl: StateFlow<String> = _baseUrl.asStateFlow()
|
||||||
|
|
||||||
|
private val _themeMode = MutableStateFlow("auto")
|
||||||
|
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
|
||||||
|
|
||||||
|
private val _wakeWord = MutableStateFlow("昔涟")
|
||||||
|
val wakeWord: StateFlow<String> = _wakeWord.asStateFlow()
|
||||||
|
|
||||||
|
private val _username = MutableStateFlow("")
|
||||||
|
val username: StateFlow<String> = _username.asStateFlow()
|
||||||
|
|
||||||
|
private val _isLoggedIn = MutableStateFlow(false)
|
||||||
|
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
_isLoggedIn.value = authRepository.isLoggedIn()
|
||||||
|
}
|
||||||
|
// Single collector for all DataStore preferences — avoids subscriber explosion
|
||||||
|
scope.launch {
|
||||||
|
combine(
|
||||||
|
preferencesDataStore.baseUrl,
|
||||||
|
preferencesDataStore.themeMode,
|
||||||
|
preferencesDataStore.wakeWord,
|
||||||
|
preferencesDataStore.username,
|
||||||
|
) { baseUrl, themeMode, wakeWord, username ->
|
||||||
|
baseUrl?.let { url ->
|
||||||
|
if (url.isNotBlank()) {
|
||||||
|
_baseUrl.value = url
|
||||||
|
dynamicUrlInterceptor.baseUrl = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
themeMode?.let { _themeMode.value = it }
|
||||||
|
wakeWord?.let { word ->
|
||||||
|
if (word.isNotBlank()) _wakeWord.value = word
|
||||||
|
}
|
||||||
|
username?.let { _username.value = it }
|
||||||
|
}.collect { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveBaseUrl(url: String) {
|
||||||
|
_baseUrl.value = url
|
||||||
|
dynamicUrlInterceptor.baseUrl = url
|
||||||
|
scope.launch { preferencesDataStore.saveBaseUrl(url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveThemeMode(mode: String) {
|
||||||
|
_themeMode.value = mode
|
||||||
|
scope.launch { preferencesDataStore.saveThemeMode(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveWakeWord(word: String) {
|
||||||
|
_wakeWord.value = word
|
||||||
|
scope.launch { preferencesDataStore.saveWakeWord(word) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
scope.launch {
|
||||||
|
authRepository.logout()
|
||||||
|
_isLoggedIn.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package top.yeij.cyrene.voice.hotword
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
class HotwordDetector {
|
||||||
|
|
||||||
|
private val _isListening = MutableStateFlow(false)
|
||||||
|
val isListening = _isListening.asStateFlow()
|
||||||
|
|
||||||
|
private val _onDetected = MutableStateFlow(false)
|
||||||
|
val onDetected = _onDetected.asStateFlow()
|
||||||
|
|
||||||
|
fun startListening(wakeWord: String) {
|
||||||
|
_isListening.value = true
|
||||||
|
// Integrate system AlwaysOnHotwordDetector or Porcupine SDK here
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopListening() {
|
||||||
|
_isListening.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateWakeWord(newWord: String) {
|
||||||
|
stopListening()
|
||||||
|
startListening(newWord)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package top.yeij.cyrene.voice.stt
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
class SpeechRecognizer {
|
||||||
|
|
||||||
|
private val _isListening = MutableStateFlow(false)
|
||||||
|
val isListening = _isListening.asStateFlow()
|
||||||
|
|
||||||
|
private val _partialResult = MutableStateFlow("")
|
||||||
|
val partialResult = _partialResult.asStateFlow()
|
||||||
|
|
||||||
|
fun startListening() {
|
||||||
|
_isListening.value = true
|
||||||
|
// Integrate Android SpeechRecognizer or server-side Whisper API
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopListening(): String {
|
||||||
|
_isListening.value = false
|
||||||
|
val result = _partialResult.value
|
||||||
|
_partialResult.value = ""
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
_isListening.value = false
|
||||||
|
_partialResult.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package top.yeij.cyrene.voice.tts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFocusRequest
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.speech.tts.TextToSpeech
|
||||||
|
import android.speech.tts.UtteranceProgressListener
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class TextToSpeechEngine(private val context: Context) {
|
||||||
|
|
||||||
|
private var tts: TextToSpeech? = null
|
||||||
|
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
|
||||||
|
private val _isSpeaking = MutableStateFlow(false)
|
||||||
|
val isSpeaking = _isSpeaking.asStateFlow()
|
||||||
|
|
||||||
|
private val _onDone = MutableStateFlow(false)
|
||||||
|
val onDone = _onDone.asStateFlow()
|
||||||
|
|
||||||
|
fun initialize(onReady: () -> Unit) {
|
||||||
|
tts = TextToSpeech(context) { status ->
|
||||||
|
if (status == TextToSpeech.SUCCESS) {
|
||||||
|
tts?.language = Locale.CHINESE
|
||||||
|
tts?.setSpeechRate(0.9f)
|
||||||
|
tts?.setPitch(1.1f)
|
||||||
|
|
||||||
|
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||||
|
override fun onStart(utteranceId: String?) {
|
||||||
|
requestAudioFocus()
|
||||||
|
_isSpeaking.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDone(utteranceId: String?) {
|
||||||
|
_isSpeaking.value = false
|
||||||
|
_onDone.value = true
|
||||||
|
abandonAudioFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(utteranceId: String?) {
|
||||||
|
_isSpeaking.value = false
|
||||||
|
abandonAudioFocus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun speak(text: String, utteranceId: String = System.currentTimeMillis().toString()) {
|
||||||
|
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
tts?.stop()
|
||||||
|
_isSpeaking.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shutdown() {
|
||||||
|
tts?.stop()
|
||||||
|
tts?.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestAudioFocus() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
|
||||||
|
.setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_ASSISTANT)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
audioManager.requestAudioFocus(focusRequest)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
audioManager.requestAudioFocus(
|
||||||
|
null,
|
||||||
|
AudioManager.STREAM_MUSIC,
|
||||||
|
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abandonAudioFocus() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
// Focus released with the request
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
audioManager.abandonAudioFocus(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#6D3BC0"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<!-- 昔涟首字母 C,圆形背景 -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M54,30 C67.255,30 78,40.745 78,54 C78,67.255 67.255,78 54,78 C40.745,78 30,67.255 30,54 C30,40.745 40.745,30 54,30 Z M54,36 C44.059,36 36,44.059 36,54 C36,63.941 44.059,72 54,72 C58.935,72 63.437,70.1 66.878,66.878 C68.523,65.301 69.761,63.394 70.505,61.289 C70.963,59.947 71.213,58.524 71.233,57.067 C71.239,55.712 71.041,54.369 70.646,53.084 L66.757,58.243 L58.243,49.729 L53.084,53.619 L44.57,45.106 L45.398,44.278 C47.881,41.795 51.235,40.233 54,40.233 Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Cyrene</string>
|
||||||
|
<string name="voice_assistant_name">昔涟</string>
|
||||||
|
<string name="voice_assistant_description">昔涟 —— 你的智能语音助手</string>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<string name="tab_chat">对话</string>
|
||||||
|
<string name="tab_iot">设备</string>
|
||||||
|
<string name="tab_profile">我的</string>
|
||||||
|
|
||||||
|
<!-- Voice -->
|
||||||
|
<string name="hotword_listening">正在聆听唤醒词…</string>
|
||||||
|
<string name="listening">我在听…</string>
|
||||||
|
<string name="thinking">思考中…</string>
|
||||||
|
<string name="speaking">正在说话…</string>
|
||||||
|
<string name="tap_to_speak">点击说话</string>
|
||||||
|
<string name="hold_to_speak">按住说话</string>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<string name="login">登录</string>
|
||||||
|
<string name="logout">退出登录</string>
|
||||||
|
<string name="settings">设置</string>
|
||||||
|
<string name="cancel">取消</string>
|
||||||
|
<string name="confirm">确认</string>
|
||||||
|
<string name="save">保存</string>
|
||||||
|
<string name="retry">重试</string>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<string name="set_default_assistant">设为默认语音助手</string>
|
||||||
|
<string name="set_default_assistant_desc">将昔涟替换为系统默认助手</string>
|
||||||
|
<string name="appearance">外观</string>
|
||||||
|
<string name="voice_settings">语音设置</string>
|
||||||
|
<string name="wake_word">唤醒词</string>
|
||||||
|
<string name="account">账号</string>
|
||||||
|
<string name="about">关于</string>
|
||||||
|
<string name="server_address">服务器地址</string>
|
||||||
|
<string name="theme">主题</string>
|
||||||
|
<string name="theme_light">浅色</string>
|
||||||
|
<string name="theme_dark">深色</string>
|
||||||
|
<string name="theme_auto">跟随系统</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Cyrene" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<voice-interaction-service
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:sessionService=".service.CyreneVoiceInteractionSession"
|
||||||
|
android:recognitionService=".service.CyreneRecognitionService"
|
||||||
|
android:supportsAssist="true"
|
||||||
|
android:supportsLaunchVoiceAssistFromKeyguard="true"
|
||||||
|
/>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.ksp) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# 00 — 项目概述与架构
|
||||||
|
|
||||||
|
> 对应主项目 Phase 5(v1.5 → v2.0)Android 客户端
|
||||||
|
> 主项目文档:`../../docs/dev-plan/04-voice-system-plan.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目定位
|
||||||
|
|
||||||
|
Cyrene for Android 是昔涟的官方 Android 客户端,定位为 **可替换系统自带语音助手的智能体 APP**(替代 Google Assistant / Bixby)。
|
||||||
|
|
||||||
|
核心差异点:
|
||||||
|
- 不是传统 APP(以桌面图标为主要入口),而是**系统级语音助手**
|
||||||
|
- 除常规全屏 Activity 外,通过 `VoiceInteractionSession` 提供悬浮式覆盖层交互
|
||||||
|
- 支持息屏热词唤醒、长按 Home / 电源键呼出
|
||||||
|
|
||||||
|
## 2. 技术栈
|
||||||
|
|
||||||
|
| 层 | 技术 | 版本要求 |
|
||||||
|
|----|------|---------|
|
||||||
|
| 语言 | Kotlin | 2.0+ |
|
||||||
|
| UI 框架 | Jetpack Compose + Material Design 3 | BOM 2025+ |
|
||||||
|
| 构建 | Gradle (Kotlin DSL) | 8.7+ |
|
||||||
|
| 架构模式 | MVVM + Repository | — |
|
||||||
|
| 依赖注入 | Hilt / Koin (待定) | — |
|
||||||
|
| 网络 | Retrofit + OkHttp | — |
|
||||||
|
| 实时通信 | OkHttp WebSocket | — |
|
||||||
|
| 本地存储 | Room (SQLite) + DataStore | — |
|
||||||
|
| 推送 | FCM (Firebase Cloud Messaging) | — |
|
||||||
|
| 系统语音 | VoiceInteractionService | API 23+ |
|
||||||
|
| 热词唤醒 | Always-On Hotword Detection | API 23+ |
|
||||||
|
| 语音识别 | 服务端 (Whisper API) + 本地兜底 | — |
|
||||||
|
|
||||||
|
## 3. 最低系统要求
|
||||||
|
|
||||||
|
| 项目 | 要求 |
|
||||||
|
|------|------|
|
||||||
|
| minSdk | 26 (Android 8.0) |
|
||||||
|
| targetSdk | 35+ |
|
||||||
|
| compileSdk | 35+ |
|
||||||
|
| JDK | 17 |
|
||||||
|
| Gradle | 8.7+ |
|
||||||
|
|
||||||
|
注:`VoiceInteractionService` 基础 API 要求 23,`AssistAction` 热词唤醒要求 23。minSdk 设为 26 以覆盖绝大多数活跃设备并简化兼容性。
|
||||||
|
|
||||||
|
## 4. 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
android/
|
||||||
|
├── app/
|
||||||
|
│ ├── src/main/
|
||||||
|
│ │ ├── java/com/cyrene/app/
|
||||||
|
│ │ │ ├── CyreneApplication.kt # Application 初始化
|
||||||
|
│ │ │ ├── MainActivity.kt # 全屏主界面 (桌面图标入口)
|
||||||
|
│ │ │ ├── ui/
|
||||||
|
│ │ │ │ ├── theme/ # MD3 主题 (Color / Type / Shape)
|
||||||
|
│ │ │ │ ├── screens/ # 全屏页面 (Compose)
|
||||||
|
│ │ │ │ │ ├── chat/ # 对话页
|
||||||
|
│ │ │ │ │ ├── home/ # 首页 / IoT 面板
|
||||||
|
│ │ │ │ │ ├── settings/ # 设置页
|
||||||
|
│ │ │ │ │ └── login/ # 登录/注册
|
||||||
|
│ │ │ │ ├── overlay/ # 悬浮窗对话界面 (VoiceInteractionSession)
|
||||||
|
│ │ │ │ └── components/ # 共享组件库
|
||||||
|
│ │ │ ├── viewmodel/ # ViewModel 层
|
||||||
|
│ │ │ ├── domain/ # 领域层 (UseCase / Repository 接口)
|
||||||
|
│ │ │ ├── data/
|
||||||
|
│ │ │ │ ├── remote/ # API 接口定义 + DTO
|
||||||
|
│ │ │ │ ├── local/ # Room DAO + DataStore
|
||||||
|
│ │ │ │ └── repository/ # Repository 实现
|
||||||
|
│ │ │ ├── service/
|
||||||
|
│ │ │ │ ├── CyreneVoiceInteractionService.kt
|
||||||
|
│ │ │ │ ├── CyreneVoiceInteractionSession.kt
|
||||||
|
│ │ │ │ ├── CyreneAssistService.kt
|
||||||
|
│ │ │ │ └── WebSocketService.kt
|
||||||
|
│ │ │ ├── voice/
|
||||||
|
│ │ │ │ ├── hotword/ # 热词唤醒引擎
|
||||||
|
│ │ │ │ ├── stt/ # 语音识别客户端
|
||||||
|
│ │ │ │ └── tts/ # 语音合成客户端
|
||||||
|
│ │ │ ├── di/ # DI 模块定义
|
||||||
|
│ │ │ └── util/ # 工具类
|
||||||
|
│ │ ├── res/ # 资源文件
|
||||||
|
│ │ └── AndroidManifest.xml
|
||||||
|
│ └── build.gradle.kts
|
||||||
|
├── gradle/
|
||||||
|
│ ├── libs.versions.toml # 版本目录
|
||||||
|
│ └── wrapper/
|
||||||
|
├── build.gradle.kts # 根构建脚本
|
||||||
|
├── settings.gradle.kts
|
||||||
|
└── gradle.properties
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 架构分层
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ UI Layer (Compose) │
|
||||||
|
│ ├─ Screens (全屏 Activity) │
|
||||||
|
│ └─ Overlay (VoiceInteractionSession 悬浮窗) │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ ViewModel Layer │
|
||||||
|
│ ├─ ChatViewModel │
|
||||||
|
│ ├─ HomeViewModel (IoT) │
|
||||||
|
│ ├─ SettingsViewModel │
|
||||||
|
│ └─ OverlayViewModel │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ Domain Layer │
|
||||||
|
│ ├─ UseCase (SendMessage, ControlIoT, ...) │
|
||||||
|
│ └─ Repository Interfaces │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ Data Layer │
|
||||||
|
│ ├─ Remote: Retrofit API + WebSocket │
|
||||||
|
│ ├─ Local: Room + DataStore │
|
||||||
|
│ └─ Repository Implementation │
|
||||||
|
├──────────────────────────────────────────────┤
|
||||||
|
│ Service Layer │
|
||||||
|
│ ├─ VoiceInteractionService (系统助手) │
|
||||||
|
│ ├─ WebSocketService (长连接) │
|
||||||
|
│ └─ FCMMessagingService (推送) │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
UI 层和 Service 层通过 ViewModel 解耦——全屏 Activity 和悬浮窗 Overlay 复用同一组 ViewModel,只是 UI 布局不同。
|
||||||
|
|
||||||
|
## 6. 与后端的关系
|
||||||
|
|
||||||
|
```
|
||||||
|
Android Client
|
||||||
|
│
|
||||||
|
├─ HTTP REST ──────────► Gateway (:8080) # 登录、CRUD、配置
|
||||||
|
├─ WebSocket ──────────► Gateway (:8080) # 实时对话、IoT 状态推送
|
||||||
|
├─ STT Audio ──────────► Voice Service (:8093) # 语音识别
|
||||||
|
└─ TTS Stream ◄──────── Voice Service (:8093) # 语音合成
|
||||||
|
```
|
||||||
|
|
||||||
|
WebSocket 长连接是核心通信通道:对话消息、通知、IoT 状态广播均通过同一连接。HTTP 仅用于一次性操作(登录、文件上传/下载)。
|
||||||
|
|
||||||
|
## 7. 关键设计决策
|
||||||
|
|
||||||
|
| 决策 | 选择 | 理由 |
|
||||||
|
|------|------|------|
|
||||||
|
| UI 框架 | Jetpack Compose + MD3 | 声明式 UI,与悬浮窗的 ComposeView 集成简单 |
|
||||||
|
| 架构 | MVVM + Repository | Google 官方推荐,ViewModel 可在 Activity 和 Session 间复用 |
|
||||||
|
| 语音框架 | VoiceInteractionService(系统 API) | 原生支持替换系统助手,无需自定义悬浮窗权限 |
|
||||||
|
| 热词方案 | 系统 Always-On Hotword API | 息屏低功耗监听,不用自建音频采集 |
|
||||||
|
| 网络 | OkHttp WebSocket | 比 FCM 更实时,与主项目 Gateway 已有 WS Hub 对应 |
|
||||||
|
| 最低 API | 26 | 覆盖 95%+ 活跃设备,VoiceInteractionService 兼容 |
|
||||||
|
|
||||||
|
## 8. 排期参考
|
||||||
|
|
||||||
|
对应主项目路线图:
|
||||||
|
|
||||||
|
```
|
||||||
|
2026 Q4 ─ v1.3 多平台接入(前置依赖)
|
||||||
|
2027 Q1 ─ v1.8 语音模型训练完成(后端依赖)
|
||||||
|
2027 Q2 ─ v2.0 开始 Android 客户端开发
|
||||||
|
2027 Q3 ─ v2.3 语音助手 APP MVP 版本
|
||||||
|
2027 Q4 ─ v3.0 APP 上架(Google Play / 国内应用商店)
|
||||||
|
```
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# 01 — 系统语音助手集成规范
|
||||||
|
|
||||||
|
> **目标**:让昔涟成为 Android 系统级默认语音助手,替换 Google Assistant / Bixby
|
||||||
|
> **核心 API**:`VoiceInteractionService` + `VoiceInteractionSession`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 功能目标
|
||||||
|
|
||||||
|
- 用户可在 **系统设置 → 默认应用 → 数字助理** 中选择昔涟
|
||||||
|
- 长按 Home 键呼出昔涟(非全屏,悬浮覆盖层)
|
||||||
|
- 屏幕底部两角向内滑动触发昔涟
|
||||||
|
- 长按电源键可配置为呼出昔涟
|
||||||
|
- 息屏状态下热词唤醒昔涟
|
||||||
|
- 有线/蓝牙耳机按键呼出昔涟
|
||||||
|
|
||||||
|
## 2. AndroidManifest.xml 声明
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- VoiceInteractionService -->
|
||||||
|
<service
|
||||||
|
android:name=".service.CyreneVoiceInteractionService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_VOICE_INTERACTION">
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.voice_interaction"
|
||||||
|
android:resource="@xml/voice_interaction_config" />
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- AssistService (Android 14+) -->
|
||||||
|
<service
|
||||||
|
android:name=".service.CyreneAssistService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_ASSIST">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.voice.AssistService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 配置文件
|
||||||
|
|
||||||
|
### res/xml/voice_interaction_config.xml
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<voice-interaction-service
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:sessionService=".service.CyreneVoiceInteractionSession"
|
||||||
|
android:recognitionService=".service.CyreneRecognitionService"
|
||||||
|
android:supportsAssist="true"
|
||||||
|
android:supportsLaunchVoiceAssistFromKeyguard="true"
|
||||||
|
android:supportsLocalRecognition="true"
|
||||||
|
android:serviceIcon="@drawable/ic_cyrene"
|
||||||
|
android:serviceLabel="@string/voice_assistant_name" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. VoiceInteractionService 实现
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class CyreneVoiceInteractionService : VoiceInteractionService() {
|
||||||
|
|
||||||
|
override fun onReady() {
|
||||||
|
super.onReady()
|
||||||
|
// 服务就绪,可在此初始化 TTS 引擎等
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateSession(args: Bundle?): VoiceInteractionSession {
|
||||||
|
return CyreneVoiceInteractionSession(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLaunchVoiceAssistFromKeyguard() {
|
||||||
|
// 锁屏启动 → 进入简化模式,仅显示对话,IoT 控制等需先解锁
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android 14+: AssistAction 回调
|
||||||
|
override fun onHandleAssist(
|
||||||
|
request: AssistRequest?,
|
||||||
|
cancellationSignal: CancellationSignal?,
|
||||||
|
callback: OutcomeCallback<AssistResult?>?
|
||||||
|
) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
request?.let {
|
||||||
|
val assistContent = it.assistContent
|
||||||
|
// 提取当前屏幕上下文(可选,用于后续上下文感知)
|
||||||
|
callback?.onResult(AssistResult(assistContent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. VoiceInteractionSession 实现(悬浮窗界面)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class CyreneVoiceInteractionSession(context: Context) :
|
||||||
|
VoiceInteractionSession(context) {
|
||||||
|
|
||||||
|
override fun onCreateContentView(): View {
|
||||||
|
// 返回 ComposeView 作为悬浮窗的内容
|
||||||
|
return ComposeView(context).apply {
|
||||||
|
setContent {
|
||||||
|
CyreneTheme {
|
||||||
|
OverlayScreen(
|
||||||
|
viewModel = overlayViewModel,
|
||||||
|
onDismiss = { finish() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShow(args: Bundle?, showFlags: Int) {
|
||||||
|
super.onShow(args, showFlags)
|
||||||
|
// 设置窗口属性:透明背景 + 底部卡片式布局
|
||||||
|
window?.apply {
|
||||||
|
// 半透明遮罩
|
||||||
|
setBackgroundDrawable(ColorDrawable(0x80000000.toInt()))
|
||||||
|
// FLAG_DIM_BEHIND 可实现模糊效果
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
addSystemGestureExclusionRects(...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onComputeInsets(outInsets: Insets?) {
|
||||||
|
super.onComputeInsets(outInsets)
|
||||||
|
// 控制悬浮窗内容区域
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHide() {
|
||||||
|
super.onHide()
|
||||||
|
// 悬浮窗隐藏时清理状态
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键窗口属性
|
||||||
|
|
||||||
|
| 属性 | 值 | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 背景 | `ColorDrawable(0x80000000)` | 半透明黑色遮罩,透出底层 APP |
|
||||||
|
| 内容区域 | 自适应高度 | 底部弹出,类似 Google Assistant |
|
||||||
|
| 触摸外区域行为 | 关闭悬浮窗 | 用户点击遮罩区域关闭 |
|
||||||
|
| 键盘弹出 | 推高内容区域 | 文本输入时自动调整 |
|
||||||
|
|
||||||
|
## 6. 权限清单
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- 核心语音助手权限 -->
|
||||||
|
<uses-permission android:name="android.permission.BIND_VOICE_INTERACTION" />
|
||||||
|
<uses-permission android:name="android.permission.BIND_ASSIST" />
|
||||||
|
|
||||||
|
<!-- 音频相关 -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|
||||||
|
<!-- 热词唤醒 -->
|
||||||
|
<uses-permission android:name="android.permission.CAPTURE_AUDIO_HOTWORD" />
|
||||||
|
|
||||||
|
<!-- 后台服务 -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
|
||||||
|
<!-- 网络 -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<!-- 推送 -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- 锁屏交互 -->
|
||||||
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 引导用户设为默认助手
|
||||||
|
|
||||||
|
首次启动时检测并引导:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun checkAndPromptDefaultAssistant(context: Context) {
|
||||||
|
val isDefault = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
val componentName = ComponentName(context, CyreneVoiceInteractionService::class.java)
|
||||||
|
context.packageManager
|
||||||
|
.queryIntentServices(
|
||||||
|
Intent(VoiceInteractionService.SERVICE_INTERFACE),
|
||||||
|
PackageManager.MATCH_DEFAULT_ONLY
|
||||||
|
)
|
||||||
|
.any { it.serviceInfo.packageName == context.packageName }
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDefault) {
|
||||||
|
// 显示引导 UI → 跳转到 Settings.ACTION_VOICE_INPUT_SETTINGS
|
||||||
|
val intent = Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 热词唤醒检测
|
||||||
|
|
||||||
|
### 方案选型
|
||||||
|
|
||||||
|
| 方案 | 优点 | 缺点 | 适用场景 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| 系统 Always-On Hotword API | 低功耗、系统级支持 | 限 Android 8+,某些 ROM 不支持 | **首选** |
|
||||||
|
| Porcupine (Picovoice) | 跨平台、离线 | 商业许可,需额外集成 | 兜底 |
|
||||||
|
| 自建模型 (openWakeWord) | 完全可控、低成本 | 需要本地推理能力 | 长期方案 |
|
||||||
|
|
||||||
|
### 唤醒词配置
|
||||||
|
|
||||||
|
| 优先级 | 唤醒词 | 说明 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| P0 | "昔涟" (Xī Lián) | 角色名,默认唤醒词 |
|
||||||
|
| P1 | "Hey 昔涟" | 与 "Hey Google" 习惯对齐 |
|
||||||
|
| P2 | 自定义 | 用户可在设置中自定义 |
|
||||||
|
|
||||||
|
### 息屏唤醒流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户说出唤醒词
|
||||||
|
→ HotwordDetector 识别成功(<800ms)
|
||||||
|
→ 系统触发 VoiceInteractionService
|
||||||
|
→ CyreneVoiceInteractionSession.onCreateContentView()
|
||||||
|
→ Overlay 显示,播放连接提示音
|
||||||
|
→ 用户说话 → STT → AI-Core → TTS → 语音回复
|
||||||
|
→ 对话结束 → finish() → 息屏
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Dismiss 时机
|
||||||
|
|
||||||
|
悬浮窗在以下情况关闭:
|
||||||
|
|
||||||
|
| 条件 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| 用户说"再见" / "退下" | 自然对话结束,收起悬浮窗 |
|
||||||
|
| 用户点击遮罩区域 | 立即关闭 |
|
||||||
|
| 对话静默 10 秒 | 自动收起 |
|
||||||
|
| 用户主动滑动关闭 | 手势关闭,同 Google Assistant |
|
||||||
|
| 收到系统电话等中断 | 暂停语音,进入后台等待 |
|
||||||
|
|
||||||
|
## 10. 降级策略
|
||||||
|
|
||||||
|
当系统不支持 `VoiceInteractionService` 或未设为默认助手时:
|
||||||
|
|
||||||
|
- **保底方案**:PWA(利用主项目已有的 PWA 支持)
|
||||||
|
- **WebView 封装**:内嵌 H5 对话界面作为过渡
|
||||||
|
- **通知栏常驻**:提供快速对话入口,但功能受限
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
# 02 — 交互流程与导航设计
|
||||||
|
|
||||||
|
> **核心原则**:间接启动(语音/手势)不进入全屏 APP,而是以悬浮覆盖层呈现
|
||||||
|
> **关联文档**:[01-voice-assistant-system.md](01-voice-assistant-system.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 启动来源 → 界面模式 映射表
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ 启动来源 │
|
||||||
|
├───────────────────────┬──────────────────────────────────┤
|
||||||
|
│ 直接启动 (Explicit) │ 间接启动 (Implicit) │
|
||||||
|
├───────────────────────┼──────────────────────────────────┤
|
||||||
|
│ · 桌面图标 │ · 语音唤醒 ("昔涟") │
|
||||||
|
│ · 最近任务列表 │ · 长按 Home 键 │
|
||||||
|
│ · 通知栏点击 │ · 底部两角向内滑动 │
|
||||||
|
│ · Deep Link │ · 长按电源键 (配置后) │
|
||||||
|
│ │ · 耳机按键 (单击/长按) │
|
||||||
|
│ │ · 锁屏右滑助手 │
|
||||||
|
├───────────────────────┼──────────────────────────────────┤
|
||||||
|
│ ▼ │ ▼ │
|
||||||
|
│ 全屏 Activity │ VoiceInteractionSession │
|
||||||
|
│ (MainActivity) │ (全屏悬浮覆盖层) │
|
||||||
|
│ · 完整导航栏 │ · 无导航栏 │
|
||||||
|
│ · Tab 切换 │ · 仅对话卡片 │
|
||||||
|
│ · 设置/IoT 面板 │ · 半透明遮罩透出底层 │
|
||||||
|
│ · 压入返回栈 │ · 不压入返回栈 │
|
||||||
|
└───────────────────────┴──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 全屏 Activity 模式
|
||||||
|
|
||||||
|
### 2.1 导航结构
|
||||||
|
|
||||||
|
```
|
||||||
|
MainActivity
|
||||||
|
├── BottomNavigation
|
||||||
|
│ ├── Tab 1: 对话 (ChatScreen) ← 默认页
|
||||||
|
│ ├── Tab 2: IoT 面板 (IoTScreen)
|
||||||
|
│ └── Tab 3: 我的 (ProfileScreen)
|
||||||
|
├── TopAppBar
|
||||||
|
│ ├── 昔涟状态指示器 (在线/思考中/离线)
|
||||||
|
│ └── 快捷操作 (设置、通知)
|
||||||
|
└── 子页面 (通过 NavHost 导航)
|
||||||
|
├── SettingsScreen
|
||||||
|
├── MemoryScreen (记忆查看)
|
||||||
|
├── KnowledgeScreen (知识库)
|
||||||
|
├── AutomationScreen (自动化规则)
|
||||||
|
├── ReminderScreen (提醒列表)
|
||||||
|
└── LoginScreen
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 导航图 (NavGraph)
|
||||||
|
|
||||||
|
```
|
||||||
|
LoginScreen ──(登录成功)──► MainScreen (带 BottomNav)
|
||||||
|
│
|
||||||
|
┌────────────┼────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
ChatScreen IoTScreen ProfileScreen
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
MemoryScreen SettingsScreen
|
||||||
|
KnowledgeScreen ├─ Account
|
||||||
|
AutomationScreen ├─ Appearance (主题)
|
||||||
|
ReminderScreen ├─ Voice (唤醒词/语音)
|
||||||
|
├─ IoT Config
|
||||||
|
└─ About
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 登录流程
|
||||||
|
|
||||||
|
```
|
||||||
|
APP 首次启动
|
||||||
|
→ 检查本地 Token
|
||||||
|
├─ 有效 → 直接进入 MainScreen
|
||||||
|
└─ 无效 → LoginScreen
|
||||||
|
├─ 输入 Gateway 地址 + 账号密码
|
||||||
|
├─ POST /api/v1/auth/login
|
||||||
|
├─ 保存 Token 到 DataStore
|
||||||
|
└─ 进入 MainScreen
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 悬浮覆盖层模式 (VoiceInteractionSession)
|
||||||
|
|
||||||
|
### 3.1 视觉层级
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ 底层 APP (半透明可见) │
|
||||||
|
│ ┌──────────────────────────────┐ │
|
||||||
|
│ │ 半透明黑色遮罩 (80% 不透明度) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌──────────────────────┐ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ 对话卡片区域 │ │ │
|
||||||
|
│ │ │ (圆角顶部 28dp) │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ · 昔涟状态条 │ │ │
|
||||||
|
│ │ │ · 对话消息流 │ │ │
|
||||||
|
│ │ │ · 文本输入框 │ │ │
|
||||||
|
│ │ │ · 语音输入按钮 │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ └──────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └──────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 覆盖层生命周期
|
||||||
|
|
||||||
|
```
|
||||||
|
Trigger (唤醒词/手势/按键)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
VoiceInteractionSession.onCreateContentView()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
onShow() → 设置窗口属性 → 播放出现动画(底部滑入)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
OverlayScreen Compose 渲染
|
||||||
|
│
|
||||||
|
├→ 用户说话 → STT → 显示识别文本 → 发送到 AI-Core
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ SSE 流式响应
|
||||||
|
│ │
|
||||||
|
├← TTS 语音播放 ←── 流式合成 ←────────────┘
|
||||||
|
│
|
||||||
|
├→ 用户打字输入 → WebSocket 发送 → 显示回复气泡
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
对话结束
|
||||||
|
├→ 用户主动关闭 (说"再见"/点击遮罩/下滑)
|
||||||
|
└→ 超时自动关闭 (静默 10 秒)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
onHide() → 播放消失动画(底部滑出)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
finish() → 返回触发前界面
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 覆盖层状态机
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐
|
||||||
|
│ IDLE │ (覆盖层不可见)
|
||||||
|
└────┬─────┘
|
||||||
|
│ 触发
|
||||||
|
▼
|
||||||
|
┌──────────┐
|
||||||
|
│LISTENING │ (等待语音输入,波形动画)
|
||||||
|
└────┬─────┘
|
||||||
|
│ 检测到语音 / 用户开始打字
|
||||||
|
▼
|
||||||
|
┌──────────┐
|
||||||
|
│PROCESSING│ (STT 识别中 / LLM 思考中)
|
||||||
|
└────┬─────┘
|
||||||
|
│ 收到回复
|
||||||
|
▼
|
||||||
|
┌──────────┐
|
||||||
|
│SPEAKING │ (TTS 播放中)
|
||||||
|
└────┬─────┘
|
||||||
|
│ 播放完毕,等待下一轮
|
||||||
|
▼
|
||||||
|
┌──────────┐
|
||||||
|
│ WAITING │ (等待用户继续或关闭)
|
||||||
|
└────┬─────┘
|
||||||
|
│
|
||||||
|
┌───────┼───────┐
|
||||||
|
│ │
|
||||||
|
用户继续说话 10s 静默
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
LISTENING IDLE
|
||||||
|
(覆盖层关闭)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 与全屏 Activity 的切换
|
||||||
|
|
||||||
|
```
|
||||||
|
悬浮窗中用户点击 "打开完整 APP"
|
||||||
|
→ finish() 关闭悬浮窗
|
||||||
|
→ startActivity(MainActivity)
|
||||||
|
→ 用户在全屏模式下继续操作
|
||||||
|
|
||||||
|
全屏 APP 中用户按 Home 返回桌面
|
||||||
|
→ onStop() → 进入后台
|
||||||
|
→ WebSocket 保持连接
|
||||||
|
→ 推送/FCM 通知到达时,点击通知 → 恢复 MainActivity
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 锁屏交互
|
||||||
|
|
||||||
|
### 4.1 锁屏唤醒
|
||||||
|
|
||||||
|
```
|
||||||
|
设备锁屏 + 息屏
|
||||||
|
│
|
||||||
|
├→ 说出唤醒词 "昔涟"
|
||||||
|
│ └→ onLaunchVoiceAssistFromKeyguard()
|
||||||
|
│ └→ 简化版覆盖层 (仅对话,无 IoT / 敏感操作)
|
||||||
|
│
|
||||||
|
└→ 长按电源键
|
||||||
|
└→ 同理
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 锁屏安全策略
|
||||||
|
|
||||||
|
| 操作 | 锁屏状态 | 行为 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 查询天气/时间 | 允许 | 直接回复 |
|
||||||
|
| 简单闲聊 | 允许 | 直接回复 |
|
||||||
|
| IoT 查询(状态) | 允许 | 回复设备状态 |
|
||||||
|
| IoT 控制(开关) | **禁止** | 提示"请先解锁设备" |
|
||||||
|
| 查看记忆 | **禁止** | 提示"请先解锁设备" |
|
||||||
|
| 修改设置 | **禁止** | 提示"请先解锁设备" |
|
||||||
|
| 宿主命令 | **禁止** | 提示"请先解锁设备" |
|
||||||
|
|
||||||
|
## 5. 多窗口与分屏
|
||||||
|
|
||||||
|
- **分屏模式**:悬浮窗模式下不支持(本身已是覆盖层);全屏 Activity 支持分屏
|
||||||
|
- **画中画**:语音通话场景支持画中画(PIP),显示昔涟头像 + 波形动画
|
||||||
|
|
||||||
|
## 6. 手势交互
|
||||||
|
|
||||||
|
| 手势 | 悬浮窗模式 | 全屏 Activity |
|
||||||
|
|------|-----------|---------------|
|
||||||
|
| 下滑覆盖层 | 关闭悬浮窗 | — |
|
||||||
|
| 点击遮罩区域 | 关闭悬浮窗 | — |
|
||||||
|
| 长按消息 | 复制/分享菜单 | 复制/分享/删除 |
|
||||||
|
| 左滑消息 | — | 查看消息详情/时间戳 |
|
||||||
|
| 双击昔涟头像 | 切换输入模式(语音↔文字) | 同左 |
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# 03 — 设计系统规范 (Material Design 3)
|
||||||
|
|
||||||
|
> **设计语言**:Material Design 3 (Material You)
|
||||||
|
> **组件库**:`androidx.compose.material3`
|
||||||
|
> **最低 API**:26(不支持 Monet 的设备回退为手动主题色)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 色彩系统
|
||||||
|
|
||||||
|
### 1.1 动态配色 (Dynamic Color)
|
||||||
|
|
||||||
|
```
|
||||||
|
首选:androidx.compose.material3.dynamicColor
|
||||||
|
→ 系统壁纸提取 Primary / Secondary / Tertiary
|
||||||
|
→ 支持 Android 12+ (API 31+)
|
||||||
|
→ API 26-30 回退为预设主题色
|
||||||
|
|
||||||
|
备选:用户在设置中手动选择 Seed Color
|
||||||
|
→ 通过 MaterialTheme.colorScheme 的 lightColorScheme/darkColorScheme 生成
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 预设主题色
|
||||||
|
|
||||||
|
| 主题名 | Seed Color | 氛围 |
|
||||||
|
|--------|-----------|------|
|
||||||
|
| 默认(昔涟紫) | `#9C6BFF` (Lavender) | 温柔、亲切 |
|
||||||
|
| 樱花粉 | `#FFB4C8` (Sakura) | 甜美 |
|
||||||
|
| 海洋蓝 | `#6BA4FF` (Ocean) | 清爽 |
|
||||||
|
| 森林绿 | `#6BCF7C` (Forest) | 自然 |
|
||||||
|
| 日落橙 | `#FF9E6B` (Sunset) | 温暖 |
|
||||||
|
|
||||||
|
### 1.3 暗黑模式
|
||||||
|
|
||||||
|
| 属性 | Light | Dark |
|
||||||
|
|------|-------|------|
|
||||||
|
| Surface | `#FFFBFF` | `#1C1B1F` |
|
||||||
|
| Background | `#FFFBFF` | `#1C1B1F` |
|
||||||
|
| Primary | Dynamic | Dynamic (暗黑自适应) |
|
||||||
|
| OnSurface | `#1C1B1F` | `#E6E1E5` |
|
||||||
|
| SurfaceVariant | `#E7E0EC` | `#49454F` |
|
||||||
|
| 遮罩颜色 | `rgba(0,0,0,0.5)` | `rgba(0,0,0,0.7)` |
|
||||||
|
|
||||||
|
### 1.4 悬浮窗专用色
|
||||||
|
|
||||||
|
```
|
||||||
|
覆盖层背景遮罩:
|
||||||
|
Light: rgba(0, 0, 0, 0.5) // 50% 不透明度
|
||||||
|
Dark: rgba(0, 0, 0, 0.7) // 70% 不透明度
|
||||||
|
|
||||||
|
对话卡片背景:
|
||||||
|
Light: MaterialTheme.colorScheme.surface
|
||||||
|
Dark: MaterialTheme.colorScheme.surface
|
||||||
|
|
||||||
|
卡片圆角:28dp (顶部) / 0dp (底部)
|
||||||
|
卡片阴影 (Light):8dp elevation
|
||||||
|
卡片阴影 (Dark):无阴影,用 1dp outline 代替
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 字体系统 (Typography)
|
||||||
|
|
||||||
|
| 角色 | 字号 | 字重 | 行高 | 用途 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| displayLarge | 57sp | 400 | 64sp | 欢迎页标题 |
|
||||||
|
| headlineMedium | 28sp | 400 | 36sp | 设置页标题 |
|
||||||
|
| titleLarge | 22sp | 400 | 28sp | 对话框标题 |
|
||||||
|
| titleMedium | 16sp | 500 | 24sp | 列表标题 |
|
||||||
|
| bodyLarge | 16sp | 400 | 24sp | 对话气泡文字 |
|
||||||
|
| bodyMedium | 14sp | 400 | 20sp | 辅助文字、时间戳 |
|
||||||
|
| labelLarge | 14sp | 500 | 20sp | 按钮文字 |
|
||||||
|
| labelMedium | 12sp | 500 | 16sp | Tab 标签 |
|
||||||
|
| labelSmall | 11sp | 500 | 16sp | 状态标签 |
|
||||||
|
|
||||||
|
字体家族:`system-ui`(默认),不支持自定义字体以保证加载速度和系统一致性。
|
||||||
|
|
||||||
|
## 3. 形状系统 (Shapes)
|
||||||
|
|
||||||
|
| 角色 | 圆角 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| extraSmall | 4dp | 小标签、芯片 |
|
||||||
|
| small | 8dp | 输入框、按钮 |
|
||||||
|
| medium | 12dp | 卡片、对话框 |
|
||||||
|
| large | 16dp | 大卡片 |
|
||||||
|
| extraLarge | 28dp | 底部弹出卡片、Sheet |
|
||||||
|
|
||||||
|
## 4. 组件规范
|
||||||
|
|
||||||
|
### 4.1 对话气泡
|
||||||
|
|
||||||
|
```
|
||||||
|
昔涟消息 (左侧):
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ 🤖 昔涟 10:32 │
|
||||||
|
│ ┌─────────────────────────┐ │
|
||||||
|
│ │ 开拓者,今天心情怎么样? │ │ ← 圆角: 12dp (top-start 4dp)
|
||||||
|
│ └─────────────────────────┘ │ 背景: PrimaryContainer
|
||||||
|
│ │ 文字: OnPrimaryContainer
|
||||||
|
└─────────────────────────────┘
|
||||||
|
|
||||||
|
用户消息 (右侧):
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ 你 10:33 │
|
||||||
|
│ ┌─────────────────────┐ │
|
||||||
|
│ │ 还不错!你呢? │ │ ← 圆角: 12dp (top-end 4dp)
|
||||||
|
│ └─────────────────────┘ │ 背景: Primary
|
||||||
|
│ │ 文字: OnPrimary
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 消息类型样式
|
||||||
|
|
||||||
|
| 类型 | 样式 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| `chat` | 普通气泡 | 对话内容 |
|
||||||
|
| `action` | 居中斜体、灰色 | *昔涟正在查看客厅灯光状态* |
|
||||||
|
| `thinking` | 折叠面板、虚线边框 | 可展开/折叠 |
|
||||||
|
| `system_info` | Toast 样式 | 服务状态告知 |
|
||||||
|
| `tool_progress` | 进度条 + 图标 | IoT 操作进行中 |
|
||||||
|
|
||||||
|
### 4.3 语音输入按钮 (悬浮窗核心组件)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ 🎤 波形动画 │ (LISTENING 状态)
|
||||||
|
│ "我在听..." │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────────────┐ │
|
||||||
|
│ │ 输入文字或直接说话... │ │ (IDLE 状态,点击切换语音)
|
||||||
|
│ └───────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌────┐ ┌──┐ │
|
||||||
|
│ │ 🎤 │ (按住说话) │⌨️│ │ (WAITING 状态)
|
||||||
|
│ └────┘ └──┘ │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 昔涟状态指示器
|
||||||
|
|
||||||
|
```
|
||||||
|
在线 (绿色点 + "昔涟"):
|
||||||
|
● 昔涟
|
||||||
|
|
||||||
|
思考中 (黄色脉冲 + "思考中..."):
|
||||||
|
◉ 思考中...
|
||||||
|
|
||||||
|
离线 (灰色 + "离线"):
|
||||||
|
○ 昔涟 · 离线
|
||||||
|
|
||||||
|
说话中 (蓝色波纹 + "正在说话..."):
|
||||||
|
〰 正在说话...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 IoT 设备卡片
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ 💡 客厅灯 ● ON │
|
||||||
|
│ ┌──────────────────────┐ │
|
||||||
|
│ │ 亮度 ████████░░ 80% │ │
|
||||||
|
│ │ 色温 ████░░░░░░ 4000K│ │
|
||||||
|
│ └──────────────────────┘ │
|
||||||
|
│ [💡 开关] │
|
||||||
|
└──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 动效规范
|
||||||
|
|
||||||
|
| 动效 | 时长 | 曲线 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 覆盖层出现 | 300ms | `FastOutSlowInEasing` | 底部滑入 |
|
||||||
|
| 覆盖层消失 | 250ms | `FastOutLinearInEasing` | 底部滑落 |
|
||||||
|
| 气泡出现 | 200ms | `LinearOutSlowInEasing` | 淡入 + 微上移 |
|
||||||
|
| 涟漪效果 | 400ms | `LinearEasing` | 标准 MD3 ripple |
|
||||||
|
| 页面切换 | 300ms | `FastOutSlowInEasing` | 淡入淡出 |
|
||||||
|
| 波形动画 | 循环 | — | 录制时音频可视化 |
|
||||||
|
| 状态指示脉冲 | 2s 循环 | — | 思考中 / 说话中的呼吸灯效果 |
|
||||||
|
|
||||||
|
## 6. 图标系统
|
||||||
|
|
||||||
|
| 来源 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `Icons.Filled` | 导航栏、主要操作按钮 |
|
||||||
|
| `Icons.Outlined` | 列表项、辅助操作 |
|
||||||
|
| `Icons.Rounded` | 芯片、标签 |
|
||||||
|
| 自定义 Lottie | 昔涟头像动画、情感表达 |
|
||||||
|
| 自定义 Vector | 品牌 LOGO、IoT 设备图标 |
|
||||||
|
|
||||||
|
## 7. 悬浮窗 vs 全屏 布局差异
|
||||||
|
|
||||||
|
| 元素 | 全屏 Activity | 悬浮窗 Overlay |
|
||||||
|
|------|-------------|---------------|
|
||||||
|
| TopAppBar | 显示(标题 + 操作) | 不显示 |
|
||||||
|
| BottomNav | 显示(三 Tab) | 不显示 |
|
||||||
|
| 对话区域 | 全屏滚动 | 自适应高度,最大 70% 屏幕 |
|
||||||
|
| 背景 | Surface 纯色 | 半透明遮罩 + 卡片 |
|
||||||
|
| 圆角 | 无 | 顶部 28dp |
|
||||||
|
| 导航返回 | 系统返回键 | 关闭覆盖层 |
|
||||||
|
| IoT 面板 | 完整功能 | 仅限查询,无控制 |
|
||||||
|
| 设置入口 | 完整 | 无(需打开 APP) |
|
||||||
|
|
||||||
|
## 8. 无障碍规范
|
||||||
|
|
||||||
|
- 所有可交互元素提供 `contentDescription`
|
||||||
|
- 语音按钮提供大点击区域(最小 48dp × 48dp)
|
||||||
|
- 支持 TalkBack 导航
|
||||||
|
- 字体缩放:支持系统字体大小设置(最大 200%)
|
||||||
|
- 色彩对比度:满足 WCAG AA 标准(正文 ≥ 4.5:1,大文字 ≥ 3:1)
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
# 04 — 功能规格说明书
|
||||||
|
|
||||||
|
> **版本**:MVP v0.1 → Stable v1.0
|
||||||
|
> **优先级定义**:P0 = 不可缺失 | P1 = 首个正式版必需 | P2 = 后续版本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 功能总览
|
||||||
|
|
||||||
|
### MVP (v0.1) — 核心语音助手
|
||||||
|
|
||||||
|
| # | 功能 | 优先级 | 说明 |
|
||||||
|
|---|------|--------|------|
|
||||||
|
| F01 | VoiceInteractionService 注册 | P0 | 系统可识别并设为默认助手 |
|
||||||
|
| F02 | 语音唤醒(热词"昔涟") | P0 | 息屏/亮屏唤醒 |
|
||||||
|
| F03 | 悬浮覆盖层对话 | P0 | VoiceInteractionSession 界面 |
|
||||||
|
| F04 | STT 语音识别 | P0 | 实时语音转文字 |
|
||||||
|
| F05 | TTS 语音合成 | P0 | 文字转语音回复 |
|
||||||
|
| F06 | 实时文字对话 | P0 | WebSocket 双向通信 |
|
||||||
|
| F07 | 用户认证与登录 | P0 | Token 持久化 |
|
||||||
|
|
||||||
|
### v0.5 — 功能完善
|
||||||
|
|
||||||
|
| # | 功能 | 优先级 | 说明 |
|
||||||
|
|---|------|--------|------|
|
||||||
|
| F08 | IoT 设备状态查询 | P1 | 只读查询设备状态 |
|
||||||
|
| F09 | IoT 设备控制 | P1 | 开关/调节设备 |
|
||||||
|
| F10 | 推送通知 (FCM) | P1 | 昔涟主动消息、提醒 |
|
||||||
|
| F11 | 多会话历史 | P1 | 查看历史对话记录 |
|
||||||
|
| F12 | 提醒管理 | P1 | 创建/查看/删除提醒 |
|
||||||
|
| F13 | 全屏 Activity 模式 | P1 | 桌面图标入口、完整功能 |
|
||||||
|
| F14 | 暗黑模式 | P1 | 跟随系统 / 手动切换 |
|
||||||
|
| F15 | 自定义唤醒词 | P2 | 用户可修改唤醒词 |
|
||||||
|
|
||||||
|
### v1.0 — 正式版
|
||||||
|
|
||||||
|
| # | 功能 | 优先级 | 说明 |
|
||||||
|
|---|------|--------|------|
|
||||||
|
| F16 | 记忆查看 | P1 | 浏览昔涟的记忆 |
|
||||||
|
| F17 | 自动化规则 | P2 | 查看/触发自动化场景 |
|
||||||
|
| F18 | 知识库查询 | P2 | 检索知识文档 |
|
||||||
|
| F19 | 文件上传 | P2 | 图片上传与分析 |
|
||||||
|
| F20 | 后台思考展示 | P2 | 查看昔涟的思考内容 |
|
||||||
|
| F21 | 多设备同步 | P2 | Web 端和 Android 端对话同步 |
|
||||||
|
| F22 | 画中画语音通话 | P2 | 持续语音对话的 PIP 模式 |
|
||||||
|
| F23 | 主题自定义 | P2 | 预设主题色切换 |
|
||||||
|
| F24 | 离线兜底 | P2 | 无网络时的本地回复 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. P0 功能详细规格
|
||||||
|
|
||||||
|
### F01 · VoiceInteractionService 注册
|
||||||
|
|
||||||
|
**用户故事**:作为用户,我可以在系统设置中将昔涟设为默认语音助手。
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] AndroidManifest.xml 正确声明 `VoiceInteractionService`
|
||||||
|
- [ ] 系统 **设置 → 默认应用 → 数字助理** 列表中可见 "昔涟"
|
||||||
|
- [ ] 选中后,长按 Home 键可触发昔涟
|
||||||
|
- [ ] 选中后,底部两角滑动可触发昔涟
|
||||||
|
- [ ] 未设为默认时,APP 内显示引导卡片并一键跳转设置页
|
||||||
|
|
||||||
|
**技术依赖**:无
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F02 · 语音唤醒
|
||||||
|
|
||||||
|
**用户故事**:作为用户,我可以在息屏或使用其他 APP 时说"昔涟"直接唤起助手。
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 息屏状态下说出"昔涟"可唤醒(成功率 ≥ 95%,安静环境)
|
||||||
|
- [ ] 亮屏使用其他 APP 时说出"昔涟"可唤醒
|
||||||
|
- [ ] 唤醒后显示悬浮覆盖层,播放提示音
|
||||||
|
- [ ] 10 分钟无交互自动停止热词监听以省电
|
||||||
|
- [ ] 用户可在设置中开启/关闭息屏唤醒
|
||||||
|
- [ ] 误唤醒率 ≤ 5 次/天(正常使用环境)
|
||||||
|
|
||||||
|
**技术依赖**:F01 (VoiceInteractionService),`CAPTURE_AUDIO_HOTWORD` 权限
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F03 · 悬浮覆盖层对话
|
||||||
|
|
||||||
|
**用户故事**:作为用户,语音唤醒昔涟后看到半透明覆盖层,不影响当前使用的 APP。
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 底部滑入动画 300ms,覆盖层显示
|
||||||
|
- [ ] 半透明黑色遮罩透出底层 APP 内容
|
||||||
|
- [ ] 对话卡片顶部圆角 28dp,自适应高度(最大 70% 屏幕)
|
||||||
|
- [ ] 点击遮罩区域关闭覆盖层
|
||||||
|
- [ ] 下滑卡片关闭覆盖层
|
||||||
|
- [ ] 静默 10 秒自动收起
|
||||||
|
- [ ] 用户说"再见"/"退下"自然结束对话
|
||||||
|
- [ ] 关闭后回到触发前状态,不压入任何 Activity 栈
|
||||||
|
|
||||||
|
**技术依赖**:F01 (VoiceInteractionService)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F04 · STT 语音识别
|
||||||
|
|
||||||
|
**用户故事**:作为用户,我可以对昔涟说话,她会实时将我的语音转成文字。
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 覆盖层显示时自动开始监听
|
||||||
|
- [ ] 实时显示识别中间结果(流式 STT)
|
||||||
|
- [ ] 语音结束(静默 1.5s)后自动提交识别结果
|
||||||
|
- [ ] 识别结果以用户气泡形式显示在对话中
|
||||||
|
- [ ] 安静环境识别准确率 ≥ 95%(中文普通话)
|
||||||
|
- [ ] 支持噪音环境降噪
|
||||||
|
|
||||||
|
**技术依赖**:后端 Whisper API (voice-service :8093),`RECORD_AUDIO` 权限
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F05 · TTS 语音合成
|
||||||
|
|
||||||
|
**用户故事**:作为用户,昔涟可以用自然的声音读出她的回复。
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 流式 TTS:收到 LLM 第一个 token 即开始合成
|
||||||
|
- [ ] 语音自然流畅,无机械感(使用后端 Edge-TTS 或训练模型)
|
||||||
|
- [ ] 播放完毕自动进入下一轮监听
|
||||||
|
- [ ] 播放期间自动降低其他音频音量(Audio Focus)
|
||||||
|
- [ ] 用户可在设置中调整语速、音量
|
||||||
|
|
||||||
|
**技术依赖**:后端 TTS API (voice-service :8093)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F06 · 实时文字对话
|
||||||
|
|
||||||
|
**用户故事**:作为用户,我可以打字与昔涟交流,并实时看到她的回复。
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] WebSocket 连接建立后保持心跳 (30s ping)
|
||||||
|
- [ ] 文本消息发送后 200ms 内显示用户气泡
|
||||||
|
- [ ] 昔涟回复流式显示(逐字/逐 token 渲染)
|
||||||
|
- [ ] 正确处理 `chat`、`action`、`thinking` 三种消息类型
|
||||||
|
- [ ] 断线自动重连(指数退避,最多 5 次)
|
||||||
|
- [ ] 连接状态在界面实时指示
|
||||||
|
- [ ] 多条消息按时间顺序排列
|
||||||
|
- [ ] 支持消息滚动到顶部加载历史
|
||||||
|
|
||||||
|
**技术依赖**:后端 Gateway WebSocket (:8080)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F07 · 用户认证与登录
|
||||||
|
|
||||||
|
**用户故事**:作为用户,我可以登录我的 Cyrene 账号以同步数据。
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 首次启动显示登录页
|
||||||
|
- [ ] 支持输入 Gateway 地址 + 账号 + 密码
|
||||||
|
- [ ] 登录成功保存 JWT Token 到 EncryptedDataStore
|
||||||
|
- [ ] Token 过期自动刷新(refresh token)
|
||||||
|
- [ ] 再次启动自动登录(skip login page)
|
||||||
|
- [ ] 登录失败显示明确错误信息
|
||||||
|
- [ ] 支持退出登录并清除本地数据
|
||||||
|
|
||||||
|
**技术依赖**:后端 Gateway Auth API (:8080)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. P1 功能详细规格
|
||||||
|
|
||||||
|
### F08 · IoT 设备状态查询
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 全屏 Activity 中 IoT Tab 显示所有设备卡片
|
||||||
|
- [ ] 实时显示设备状态(开/关、亮度、温度等)
|
||||||
|
- [ ] 通过 WebSocket 接收状态变更推送
|
||||||
|
- [ ] 覆盖层模式下支持语音查询("灯开着吗?")
|
||||||
|
- [ ] 覆盖层模式下 IoT 查询结果以文字+卡片形式展示
|
||||||
|
- [ ] 锁屏状态下仅允许查询
|
||||||
|
|
||||||
|
### F09 · IoT 设备控制
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 设备卡片上可直接开关/调节
|
||||||
|
- [ ] 支持语音控制("打开客厅灯")
|
||||||
|
- [ ] 控制结果实时反馈(成功/失败)
|
||||||
|
- [ ] 锁屏状态下禁止控制,提示解锁
|
||||||
|
- [ ] 支持设备白名单(每用户可控制的设备不同)
|
||||||
|
|
||||||
|
### F10 · 推送通知 (FCM)
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 接收昔涟主动消息推送
|
||||||
|
- [ ] 接收提醒到期推送
|
||||||
|
- [ ] 接收 IoT 状态变更推送
|
||||||
|
- [ ] 点击通知打开对应界面
|
||||||
|
- [ ] 通知渠道分组(对话 / 提醒 / IoT / 系统)
|
||||||
|
- [ ] 用户可独立控制各渠道开关
|
||||||
|
|
||||||
|
### F11 · 多会话历史
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 对话列表页展示所有历史会话
|
||||||
|
- [ ] 每条会话显示标题、最后一条消息预览、时间
|
||||||
|
- [ ] 点击进入对应会话详情
|
||||||
|
- [ ] 支持删除会话
|
||||||
|
- [ ] 与 Web 端历史同步
|
||||||
|
|
||||||
|
### F12 · 提醒管理
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 可以语音创建提醒("提醒我下午三点开会")
|
||||||
|
- [ ] 列表展示所有活跃提醒
|
||||||
|
- [ ] 支持删除/标记完成
|
||||||
|
- [ ] 到期时 FCM 推送 + 覆盖层显示
|
||||||
|
|
||||||
|
### F13 · 全屏 Activity 模式
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 桌面图标启动进入全屏界面
|
||||||
|
- [ ] BottomNav 三 Tab(对话 / IoT / 我的)
|
||||||
|
- [ ] 完整的设置页
|
||||||
|
- [ ] 与覆盖层共享同一 WebSocket 连接和 ViewModel
|
||||||
|
|
||||||
|
### F14 · 暗黑模式
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [ ] 跟随系统暗黑模式自动切换
|
||||||
|
- [ ] 用户可在设置中手动选择 Light / Dark / Auto
|
||||||
|
- [ ] 覆盖层同步使用当前主题
|
||||||
|
- [ ] 对话气泡、卡片、输入框颜色正确适配
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. P2 功能概要
|
||||||
|
|
||||||
|
| # | 功能 | 关键验收标准 |
|
||||||
|
|---|------|------------|
|
||||||
|
| F15 | 自定义唤醒词 | 设置页可输入自定义词,验证唯一性,测试唤醒效果 |
|
||||||
|
| F16 | 记忆查看 | 时间线展示昔涟记忆,支持搜索过滤 |
|
||||||
|
| F17 | 自动化规则 | 查看规则列表,手动触发,查看执行日志 |
|
||||||
|
| F18 | 知识库查询 | 搜索文档,查看内容,语音问答 |
|
||||||
|
| F19 | 文件上传 | 图片选择/拍照,缩略图预览,AI 分析结果 |
|
||||||
|
| F20 | 后台思考 | 展示昔涟后台思考片段,可折叠面板 |
|
||||||
|
| F21 | 多设备同步 | Web 端和 Android 端对话实时同步 |
|
||||||
|
| F22 | PIP 语音通话 | 切换到 PIP 窗口进行持续语音对话 |
|
||||||
|
| F23 | 主题自定义 | 从预设色中选择,实时预览 |
|
||||||
|
| F24 | 离线兜底 | 无网络时显示离线提示,缓存本地回复模板 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 按开发阶段分组
|
||||||
|
|
||||||
|
### Sprint 1 (MVP):F01 → F07 (P0 全部)
|
||||||
|
目标:可设为系统默认助手,能语音唤醒并对话
|
||||||
|
|
||||||
|
### Sprint 2:F08 → F14 (P1 全部)
|
||||||
|
目标:IoT 控制、全屏界面、推送通知上线
|
||||||
|
|
||||||
|
### Sprint 3+:F15 → F24 (P2 逐个)
|
||||||
|
目标:体验完善、高级功能
|
||||||
|
|
||||||
|
## 6. 非功能需求
|
||||||
|
|
||||||
|
| 类别 | 需求 | 指标 |
|
||||||
|
|------|------|------|
|
||||||
|
| 性能 | 覆盖层冷启动 | < 500ms |
|
||||||
|
| 性能 | 语音识别端到端延迟 | < 2s (STT + LLM + TTS) |
|
||||||
|
| 性能 | WebSocket 消息延迟 | < 100ms |
|
||||||
|
| 稳定性 | 崩溃率 | < 0.5% |
|
||||||
|
| 电量 | 热词监听功耗 | < 3% 电池/小时 (息屏) |
|
||||||
|
| 网络 | 支持弱网 | 切换到低码率 TTS |
|
||||||
|
| 兼容性 | 国内 ROM 适配 | MIUI / ColorOS / OriginOS / HarmonyOS |
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Gradle
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.daemon=true
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.internal.http.connectionTimeout=60000
|
||||||
|
org.gradle.internal.http.socketTimeout=60000
|
||||||
|
|
||||||
|
# Kotlin
|
||||||
|
kotlin.code.style=official
|
||||||
|
|
||||||
|
# Android
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
android.suppressUnsupportedCompileSdk=36
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.7.3"
|
||||||
|
kotlin = "2.0.21"
|
||||||
|
ksp = "2.0.21-1.0.27"
|
||||||
|
composeBom = "2024.12.01"
|
||||||
|
composeActivity = "1.9.3"
|
||||||
|
composeNavigation = "2.8.5"
|
||||||
|
lifecycle = "2.8.7"
|
||||||
|
room = "2.6.1"
|
||||||
|
retrofit = "2.11.0"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
koin = "4.0.0"
|
||||||
|
datastore = "1.1.1"
|
||||||
|
coroutines = "1.9.0"
|
||||||
|
material3 = "1.3.1"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
# Compose BOM
|
||||||
|
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
|
||||||
|
# Activity & Navigation
|
||||||
|
compose-activity = { group = "androidx.activity", name = "activity-compose", version.ref = "composeActivity" }
|
||||||
|
compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "composeNavigation" }
|
||||||
|
|
||||||
|
# Lifecycle
|
||||||
|
lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
|
lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
|
||||||
|
# Room
|
||||||
|
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
|
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
|
||||||
|
# Network
|
||||||
|
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||||
|
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
|
||||||
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
|
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||||
|
|
||||||
|
# Koin DI
|
||||||
|
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
|
||||||
|
koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
|
||||||
|
|
||||||
|
# DataStore
|
||||||
|
datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
|
|
||||||
|
# Coroutines
|
||||||
|
coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
|
||||||
|
# Core
|
||||||
|
core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.11.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
#
|
||||||
|
# https://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.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||||
|
' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+94
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
maven { url = uri("https://maven.aliyun.com/repository/public") }
|
||||||
|
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||||
|
maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") }
|
||||||
|
maven { url = uri("https://maven.aliyun.com/repository/central") }
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
maven { url = uri("https://maven.aliyun.com/repository/public") }
|
||||||
|
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||||
|
maven { url = uri("https://maven.aliyun.com/repository/central") }
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "Cyrene"
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user