Initial Android project setup with Compose, WebSocket, and VoiceInteractionService

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 19:58:59 +08:00
parent 9b8c8ab37d
commit a57692353c
80 changed files with 5906 additions and 2 deletions
+181 -2
View File
@@ -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 5v1.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
+91
View File
@@ -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)
}
+27
View File
@@ -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 {}
+69
View File
@@ -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>
+42
View File
@@ -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>
+7
View File
@@ -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"
/>
+6
View File
@@ -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
}
+158
View File
@@ -0,0 +1,158 @@
# 00 — 项目概述与架构
> 对应主项目 Phase 5v1.5 → v2.0Android 客户端
> 主项目文档:`../../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 / 国内应用商店)
```
+256
View File
@@ -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 对话界面作为过渡
- **通知栏常驻**:提供快速对话入口,但功能受限
+237
View File
@@ -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 |
|------|-----------|---------------|
| 下滑覆盖层 | 关闭悬浮窗 | — |
| 点击遮罩区域 | 关闭悬浮窗 | — |
| 长按消息 | 复制/分享菜单 | 复制/分享/删除 |
| 左滑消息 | — | 查看消息详情/时间戳 |
| 双击昔涟头像 | 切换输入模式(语音↔文字) | 同左 |
+208
View File
@@ -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)
+272
View File
@@ -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 2F08 → 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 |
+14
View File
@@ -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
+63
View File
@@ -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" }
Binary file not shown.
+7
View File
@@ -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
Vendored
+252
View File
@@ -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
View File
@@ -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
+25
View File
@@ -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")