diff --git a/README.md b/README.md
index d2adb83..3e59fe9 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,182 @@
-# Cyrene-For-Android
+# Cyrene for Android
-昔涟在安卓设备上的载体。
\ No newline at end of file
+昔涟在安卓设备上的载体 —— 随时随地与昔涟对话、操控 IoT 设备、接收实时通知。
+
+## 概述
+
+Cyrene for Android 是 [Cyrene(昔涟)](https://github.com/Cyrene/Cyrene) 项目的官方 Android 客户端。Cyrene 是一个开源的基于 LLM 平台的智能体,提供多人格对话、IoT 设备操控、记忆管理、自动化规则、知识库、语音交互等功能。
+
+Android 客户端的目标是成为用户的**默认语音助手**,完全替代系统自带的助手(Google Assistant / Bixby 等),同时提供:
+
+- **系统级语音助手** — 注册为 `VoiceInteractionService`,可通过长按 Home / 侧滑呼出
+- **语音唤醒** — 息屏热词唤醒、免提交互(类似 "Hey Google",使用 "昔涟" 等唤醒词)
+- **随时对话** — 通过文字或语音与昔涟交流
+- **IoT 控制** — 远程操控家中的智能设备
+- **实时通知** — 接收昔涟的主动消息、提醒、IoT 状态变更
+- **后台连接** — 即使 APP 在后台,仍保持通知推送与语音服务就绪
+
+## 开发状态
+
+> **当前阶段**:项目初始化,尚未开始正式开发。
+
+根据主项目的[开发路线图](../docs/dev-plan/00-development-roadmap.md),Android 客户端计划在 **Phase 5(v1.5 → v2.0)** 开始开发,预计时间窗口为 **2027 Q2-Q3**。
+
+## 技术栈 (规划)
+
+| 层 | 技术 |
+|----|------|
+| 语言 | Kotlin |
+| UI 框架 | Jetpack Compose + Material Design 3 |
+| 架构 | MVVM + Repository |
+| 网络 | OkHttp / Retrofit + WebSocket |
+| 本地存储 | Room (SQLite) + DataStore |
+| 推送 | FCM (Firebase Cloud Messaging) |
+| 语音 | VoiceInteractionService + 热词唤醒 + STT + TTS |
+| 构建 | Gradle (Kotlin DSL) |
+
+## 功能路线图
+
+- [ ] 用户认证与登录
+- [ ] 实时文字对话 (WebSocket)
+- [ ] VoiceInteractionService 注册(替换系统语音助手)
+- [ ] 间接启动悬浮窗(VoiceInteractionSession 全屏覆盖层)
+- [ ] 热词唤醒(息屏 / 亮屏)+ 唤醒词自定义
+- [ ] 语音识别 (STT)
+- [ ] 语音合成 (TTS)
+- [ ] IoT 设备控制面板
+- [ ] 推送通知 (FCM)
+- [ ] 后台连接与通知
+- [ ] 锁屏 / 息屏语音交互
+- [ ] PWA 保底方案 (短期替代)
+
+## 交互模式
+
+APP 有两种界面呈现方式,根据启动来源自动切换:
+
+| 启动方式 | 界面模式 | 说明 |
+|---------|---------|------|
+| 桌面图标 / 最近任务 | **全屏 Activity** | 常规 APP 模式,完整功能入口 |
+| 语音唤醒 / 长按 Home / 侧滑 / 长按电源键 / 耳机按键 | **悬浮窗 (VoiceInteractionSession)** | 全屏覆盖层,半透明背景透出底层 APP,不影响当前任务栈 |
+
+悬浮窗模式的要点:
+- 借助 `VoiceInteractionSession` 系统窗口,不压入 Activity 返回栈
+- 对话结束后窗口收起,用户回到触发前的界面
+- 底层 APP 内容半透明可见(模糊遮罩),让用户保持上下文感知
+- 窗口高度自适应对话内容,类似 Google Assistant 的卡片式覆盖
+
+## 设计规范
+
+- **设计语言**:Material Design 3 (Material You)
+- **主题**:动态配色(Dynamic Color),跟随系统壁纸或手动选择主题色
+- **暗黑模式**:支持 Light / Dark 双主题,跟随系统或手动切换
+- **组件**:全面使用 `androidx.compose.material3` 组件库
+- **图标**:Material Icons + 自定义图标
+- **动效**:遵循 MD3 动效规范(过渡动画、涟漪效果、Shared Elements)
+- **字体**:系统默认字体(Roboto / Google Sans),支持动态字体缩放
+
+## 项目结构 (规划)
+
+```
+android/
+├── app/
+│ ├── src/main/
+│ │ ├── java/com/cyrene/app/
+│ │ │ ├── ui/ # Compose UI 层
+│ │ │ ├── viewmodel/ # ViewModel 层
+│ │ │ ├── repository/ # 数据仓库层
+│ │ │ ├── data/ # 数据模型 & API 接口
+│ │ │ ├── service/ # VoiceInteractionService & WebSocket & FCM
+│ │ │ ├── voice/ # 热词唤醒 (Hotword) & STT & TTS
+│ │ │ └── di/ # 依赖注入
+│ │ └── res/ # 资源文件
+│ └── build.gradle.kts
+├── build.gradle.kts
+├── settings.gradle.kts
+└── gradle.properties
+```
+
+## 环境要求
+
+- Android Studio Hedgehog (2023.1.1) 或更高版本
+- Kotlin 2.0+
+- JDK 17+
+- Android SDK (target: 34+, min: 26+)
+
+## 网络配置(中国大陆用户)
+
+Gradle、Google Maven、Maven Central 等服务器在国内访问缓慢或不可达,首次同步前需配置镜像。
+
+### Gradle Wrapper
+
+`gradle/wrapper/gradle-wrapper.properties`:
+
+```properties
+# 将 services.gradle.org 替换为腾讯/阿里镜像
+distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.7-bin.zip
+# 或阿里: https\://mirrors.aliyun.com/macports/distfiles/gradle/gradle-8.7-bin.zip
+```
+
+### 仓库镜像
+
+`settings.gradle.kts` 顶部添加:
+
+```kotlin
+pluginManagement {
+ repositories {
+ maven { url = uri("https://mirrors.cloud.tencent.com/gradle/plugins") }
+ maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public") }
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public") }
+ google()
+ mavenCentral()
+ }
+}
+```
+
+### Gradle 属性
+
+`gradle.properties`:
+
+```properties
+# HTTP 代理(如使用 Clash/V2Ray,通常不需要)
+# systemProp.http.proxyHost=127.0.0.1
+# systemProp.http.proxyPort=7890
+# systemProp.https.proxyHost=127.0.0.1
+# systemProp.https.proxyPort=7890
+```
+
+### Android SDK Proxy
+
+若 Android SDK Manager 也下载缓慢,可在 Android Studio 中设置:
+**Settings → Appearance & Behavior → System Settings → HTTP Proxy**
+
+配置为 `mirrors.cloud.tencent.com` 或 `mirrors.neusoft.edu.cn`,端口 `80`。
+
+## 快速开始
+
+> 项目尚未包含可构建的源码,以下为后续开发的参考步骤。
+
+1. 用 Android Studio 打开 `android/` 目录
+2. 按上述"网络配置"替换国内镜像
+3. 等待 Gradle 同步完成
+4. 启动主项目后端服务(参考[主项目 README](../README.md))
+5. 在 `local.properties` 中配置 `baseUrl` 指向 Gateway 地址
+6. 选择模拟器或设备,点击 Run
+
+## 相关链接
+
+- [Cyrene 主项目](../) — 后端服务、前端、部署文档
+- [开发路线图](../docs/dev-plan/00-development-roadmap.md)
+- [多平台接入方案](../docs/dev-plan/03-multi-platform-integration.md)
+- [语音系统计划](../docs/dev-plan/04-voice-system-plan.md)
+
+## License
+
+Apache-2.0
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..6f463bf
--- /dev/null
+++ b/app/build.gradle.kts
@@ -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)
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..2d2c2df
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -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 {}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..62d63f6
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt b/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt
new file mode 100644
index 0000000..423470f
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt
@@ -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
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/MainActivity.kt b/app/src/main/java/top/yeij/cyrene/MainActivity.kt
new file mode 100644
index 0000000..4be0779
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/MainActivity.kt
@@ -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))
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/data/local/AppDatabase.kt b/app/src/main/java/top/yeij/cyrene/data/local/AppDatabase.kt
new file mode 100644
index 0000000..786a073
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/local/AppDatabase.kt
@@ -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 }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt b/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt
new file mode 100644
index 0000000..44fbe21
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt
@@ -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 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 = context.dataStore.data.map { it[KEY_TOKEN] }
+ val refreshToken: Flow = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
+ val baseUrl: Flow = context.dataStore.data.map { it[KEY_BASE_URL] }
+ val themeMode: Flow = context.dataStore.data.map { it[KEY_THEME] }
+ val wakeWord: Flow = context.dataStore.data.map { it[KEY_WAKE_WORD] }
+ val username: Flow = context.dataStore.data.map { it[KEY_USERNAME] }
+ val clientId: Flow = context.dataStore.data.map { it[KEY_CLIENT_ID] }
+ val deviceName: Flow = 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() }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/data/local/dao/ConversationDao.kt b/app/src/main/java/top/yeij/cyrene/data/local/dao/ConversationDao.kt
new file mode 100644
index 0000000..69ee23d
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/local/dao/ConversationDao.kt
@@ -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>
+
+ @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()
+}
diff --git a/app/src/main/java/top/yeij/cyrene/data/local/dao/MessageDao.kt b/app/src/main/java/top/yeij/cyrene/data/local/dao/MessageDao.kt
new file mode 100644
index 0000000..02c4173
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/local/dao/MessageDao.kt
@@ -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>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(message: MessageEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsertAll(messages: List)
+
+ @Query("DELETE FROM messages WHERE conversationId = :conversationId")
+ suspend fun deleteByConversation(conversationId: String)
+}
diff --git a/app/src/main/java/top/yeij/cyrene/data/local/entity/ConversationEntity.kt b/app/src/main/java/top/yeij/cyrene/data/local/entity/ConversationEntity.kt
new file mode 100644
index 0000000..10fc4ee
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/local/entity/ConversationEntity.kt
@@ -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,
+)
diff --git a/app/src/main/java/top/yeij/cyrene/data/local/entity/MessageEntity.kt b/app/src/main/java/top/yeij/cyrene/data/local/entity/MessageEntity.kt
new file mode 100644
index 0000000..328aa94
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/local/entity/MessageEntity.kt
@@ -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,
+)
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/ApiService.kt b/app/src/main/java/top/yeij/cyrene/data/remote/ApiService.kt
new file mode 100644
index 0000000..6de0fcc
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/remote/ApiService.kt
@@ -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
+
+ @POST("api/v1/auth/refresh")
+ suspend fun refreshToken(@Body refreshToken: String): Response
+
+ // Conversations
+ @GET("api/v1/conversations")
+ suspend fun getConversations(): Response>
+
+ @DELETE("api/v1/conversations/{id}")
+ suspend fun deleteConversation(@Path("id") id: String): Response
+
+ // IoT
+ @GET("api/v1/iot/devices")
+ suspend fun getDevices(): Response>
+
+ @POST("api/v1/iot/devices/{id}/control")
+ suspend fun controlDevice(
+ @Path("id") deviceId: String,
+ @Body request: IoTControlRequest,
+ ): Response
+
+ // Reminders
+ @GET("api/v1/reminders")
+ suspend fun getReminders(): Response>
+
+ @DELETE("api/v1/reminders/{id}")
+ suspend fun deleteReminder(@Path("id") id: String): Response
+}
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/AuthInterceptor.kt b/app/src/main/java/top/yeij/cyrene/data/remote/AuthInterceptor.kt
new file mode 100644
index 0000000..a76cd05
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/remote/AuthInterceptor.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/DynamicUrlInterceptor.kt b/app/src/main/java/top/yeij/cyrene/data/remote/DynamicUrlInterceptor.kt
new file mode 100644
index 0000000..1a017d9
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/remote/DynamicUrlInterceptor.kt
@@ -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())
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/RetrofitClient.kt b/app/src/main/java/top/yeij/cyrene/data/remote/RetrofitClient.kt
new file mode 100644
index 0000000..a68174a
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/remote/RetrofitClient.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/AuthDtos.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/AuthDtos.kt
new file mode 100644
index 0000000..037f509
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/AuthDtos.kt
@@ -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,
+)
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/ConversationDto.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/ConversationDto.kt
new file mode 100644
index 0000000..fffd7d5
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/ConversationDto.kt
@@ -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,
+)
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/DeviceDto.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/DeviceDto.kt
new file mode 100644
index 0000000..16cc8bd
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/DeviceDto.kt
@@ -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,
+)
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/ReminderDto.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/ReminderDto.kt
new file mode 100644
index 0000000..4e23e9b
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/ReminderDto.kt
@@ -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,
+)
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt
new file mode 100644
index 0000000..67846a8
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt
@@ -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? = null,
+ @SerializedName("messages") val messages: List? = null,
+ @SerializedName("multi_message") val multiMessages: List? = 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,
+)
diff --git a/app/src/main/java/top/yeij/cyrene/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/top/yeij/cyrene/data/repository/AuthRepositoryImpl.kt
new file mode 100644
index 0000000..744ea8a
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/repository/AuthRepositoryImpl.kt
@@ -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 {
+ 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
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt b/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt
new file mode 100644
index 0000000..9d57603
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt
@@ -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 = _connectionState.asStateFlow()
+
+ private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64)
+ override fun observeMessages(): Flow = _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> {
+ return conversationDao.getAll().map { entities ->
+ entities.map { it.toDomain() }
+ }
+ }
+
+ override suspend fun getMessages(conversationId: String): Flow> {
+ 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 {
+ 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,
+ )
+}
diff --git a/app/src/main/java/top/yeij/cyrene/data/repository/IoTRepositoryImpl.kt b/app/src/main/java/top/yeij/cyrene/data/repository/IoTRepositoryImpl.kt
new file mode 100644
index 0000000..05b8b8c
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/repository/IoTRepositoryImpl.kt
@@ -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>(emptyList())
+ override fun getDevices(): Flow> = _devices.asStateFlow()
+
+ override suspend fun controlDevice(
+ deviceId: String,
+ action: String,
+ value: Any?,
+ ): Result {
+ 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,
+ )
+}
diff --git a/app/src/main/java/top/yeij/cyrene/di/AppModule.kt b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt
new file mode 100644
index 0000000..e8b03ef
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt
@@ -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().conversationDao() }
+ single { get().messageDao() }
+
+ // Network interceptors (no runBlocking — using @Volatile caches)
+ single { AuthInterceptor() }
+ single { DynamicUrlInterceptor() }
+ single { RetrofitClient.provideOkHttpClient(get(), get()) }
+ single { RetrofitClient.provideRetrofit(get()) }
+ single { get().create(ApiService::class.java) }
+
+ // WebSocket
+ single { WebSocketService(get()) }
+
+ // Voice
+ single { SpeechRecognizer() }
+ single { TextToSpeechEngine(androidContext()) }
+
+ // Repositories
+ single { AuthRepositoryImpl(get(), get(), get()) }
+ single { ChatRepositoryImpl(get(), get(), get(), get()) }
+ single { 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()) }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/domain/model/AuthResult.kt b/app/src/main/java/top/yeij/cyrene/domain/model/AuthResult.kt
new file mode 100644
index 0000000..2f720ff
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/domain/model/AuthResult.kt
@@ -0,0 +1,7 @@
+package top.yeij.cyrene.domain.model
+
+data class AuthResult(
+ val token: String,
+ val refreshToken: String?,
+ val username: String,
+)
diff --git a/app/src/main/java/top/yeij/cyrene/domain/model/Conversation.kt b/app/src/main/java/top/yeij/cyrene/domain/model/Conversation.kt
new file mode 100644
index 0000000..cef744d
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/domain/model/Conversation.kt
@@ -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,
+)
diff --git a/app/src/main/java/top/yeij/cyrene/domain/model/Device.kt b/app/src/main/java/top/yeij/cyrene/domain/model/Device.kt
new file mode 100644
index 0000000..1b41b25
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/domain/model/Device.kt
@@ -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,
+)
diff --git a/app/src/main/java/top/yeij/cyrene/domain/model/Message.kt b/app/src/main/java/top/yeij/cyrene/domain/model/Message.kt
new file mode 100644
index 0000000..5e874d1
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/domain/model/Message.kt
@@ -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,
+)
diff --git a/app/src/main/java/top/yeij/cyrene/domain/repository/AuthRepository.kt b/app/src/main/java/top/yeij/cyrene/domain/repository/AuthRepository.kt
new file mode 100644
index 0000000..9cb5f04
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/domain/repository/AuthRepository.kt
@@ -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
+
+ suspend fun logout()
+
+ suspend fun isLoggedIn(): Boolean
+}
diff --git a/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt b/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt
new file mode 100644
index 0000000..de77254
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt
@@ -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
+
+ fun getConversations(): Flow>
+
+ suspend fun getMessages(conversationId: String): Flow>
+
+ suspend fun deleteConversation(id: String)
+
+ suspend fun connectWebSocket(sessionId: String?)
+
+ suspend fun disconnectWebSocket()
+
+ suspend fun sendMessage(content: String, sessionId: String?)
+
+ fun observeMessages(): Flow
+
+ suspend fun loadConversationsFromServer()
+
+ suspend fun loadMessagesFromServer(sessionId: String): List
+}
diff --git a/app/src/main/java/top/yeij/cyrene/domain/repository/IoTRepository.kt b/app/src/main/java/top/yeij/cyrene/domain/repository/IoTRepository.kt
new file mode 100644
index 0000000..912f7f8
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/domain/repository/IoTRepository.kt
@@ -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>
+
+ suspend fun controlDevice(deviceId: String, action: String, value: Any? = null): Result
+
+ suspend fun refreshDevices()
+}
diff --git a/app/src/main/java/top/yeij/cyrene/domain/usecase/GetConversationsUseCase.kt b/app/src/main/java/top/yeij/cyrene/domain/usecase/GetConversationsUseCase.kt
new file mode 100644
index 0000000..5224bcd
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/domain/usecase/GetConversationsUseCase.kt
@@ -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> = chatRepository.getConversations()
+}
diff --git a/app/src/main/java/top/yeij/cyrene/domain/usecase/LoginUseCase.kt b/app/src/main/java/top/yeij/cyrene/domain/usecase/LoginUseCase.kt
new file mode 100644
index 0000000..6dfd418
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/domain/usecase/LoginUseCase.kt
@@ -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 {
+ if (username.isBlank() || password.isBlank()) {
+ return Result.failure(IllegalArgumentException("用户名和密码不能为空"))
+ }
+ return authRepository.login(username, password)
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/domain/usecase/SendMessageUseCase.kt b/app/src/main/java/top/yeij/cyrene/domain/usecase/SendMessageUseCase.kt
new file mode 100644
index 0000000..081c26f
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/domain/usecase/SendMessageUseCase.kt
@@ -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)
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneRecognitionService.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneRecognitionService.kt
new file mode 100644
index 0000000..66b6ce0
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/service/CyreneRecognitionService.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionService.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionService.kt
new file mode 100644
index 0000000..d783021
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionService.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt
new file mode 100644
index 0000000..c174523
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt b/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt
new file mode 100644
index 0000000..b32dee4
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt
@@ -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 = _isConnected.asStateFlow()
+
+ private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64)
+ val incomingMessages: SharedFlow = _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()
+ 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"
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt b/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt
new file mode 100644
index 0000000..522b362
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt
@@ -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,
+ )
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/components/DeviceCard.kt b/app/src/main/java/top/yeij/cyrene/ui/components/DeviceCard.kt
new file mode 100644
index 0000000..d05447c
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/components/DeviceCard.kt
@@ -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
+}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/components/StatusIndicator.kt b/app/src/main/java/top/yeij/cyrene/ui/components/StatusIndicator.kt
new file mode 100644
index 0000000..d23a0b4
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/components/StatusIndicator.kt
@@ -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),
+ )
+}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt b/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt
new file mode 100644
index 0000000..ea73a23
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt
@@ -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)
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt b/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt
new file mode 100644
index 0000000..33b570d
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt
@@ -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,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt
new file mode 100644
index 0000000..869eb94
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt
@@ -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,
+ ),
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt
new file mode 100644
index 0000000..5ca770a
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt
@@ -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) },
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/login/LoginScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/login/LoginScreen.kt
new file mode 100644
index 0000000..302404e
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/screens/login/LoginScreen.kt
@@ -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("登录")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt
new file mode 100644
index 0000000..8549079
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt
@@ -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() },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt
new file mode 100644
index 0000000..efd7a50
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt
@@ -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,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/theme/Color.kt b/app/src/main/java/top/yeij/cyrene/ui/theme/Color.kt
new file mode 100644
index 0000000..8f73bc5
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/theme/Color.kt
@@ -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
+)
diff --git a/app/src/main/java/top/yeij/cyrene/ui/theme/Shape.kt b/app/src/main/java/top/yeij/cyrene/ui/theme/Shape.kt
new file mode 100644
index 0000000..0c84969
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/theme/Shape.kt
@@ -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),
+)
diff --git a/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt b/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt
new file mode 100644
index 0000000..ea6b3bf
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt
@@ -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,
+ )
+}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/theme/Type.kt b/app/src/main/java/top/yeij/cyrene/ui/theme/Type.kt
new file mode 100644
index 0000000..2822d77
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/theme/Type.kt
@@ -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,
+ ),
+)
diff --git a/app/src/main/java/top/yeij/cyrene/util/Constants.kt b/app/src/main/java/top/yeij/cyrene/util/Constants.kt
new file mode 100644
index 0000000..283ba9d
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/util/Constants.kt
@@ -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"
+}
diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt
new file mode 100644
index 0000000..c79571f
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt
@@ -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 = chatRepository.connectionState
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
+
+ val conversations: StateFlow> = chatRepository.getConversations()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
+
+ private val _currentMessages = MutableStateFlow>(emptyList())
+ val currentMessages: StateFlow> = _currentMessages.asStateFlow()
+
+ private val _inputText = MutableStateFlow("")
+ val inputText: StateFlow = _inputText.asStateFlow()
+
+ private val _isStreaming = MutableStateFlow(false)
+ val isStreaming: StateFlow = _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()
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/IoTViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/IoTViewModel.kt
new file mode 100644
index 0000000..a0a3299
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/viewmodel/IoTViewModel.kt
@@ -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> = ioTRepository.getDevices()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
+
+ private val _isLoading = MutableStateFlow(false)
+ val isLoading: StateFlow = _isLoading.asStateFlow()
+
+ private val _error = MutableStateFlow(null)
+ val error: StateFlow = _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
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt
new file mode 100644
index 0000000..1d31134
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt
@@ -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 = _state.asStateFlow()
+
+ private val _messages = MutableStateFlow>(emptyList())
+ val messages: StateFlow> = _messages.asStateFlow()
+
+ private val _recognizedText = MutableStateFlow("")
+ val recognizedText: StateFlow = _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()
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt
new file mode 100644
index 0000000..21ebae2
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt
@@ -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 = _baseUrl.asStateFlow()
+
+ private val _themeMode = MutableStateFlow("auto")
+ val themeMode: StateFlow = _themeMode.asStateFlow()
+
+ private val _wakeWord = MutableStateFlow("昔涟")
+ val wakeWord: StateFlow = _wakeWord.asStateFlow()
+
+ private val _username = MutableStateFlow("")
+ val username: StateFlow = _username.asStateFlow()
+
+ private val _isLoggedIn = MutableStateFlow(false)
+ val isLoggedIn: StateFlow = _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
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/voice/hotword/HotwordDetector.kt b/app/src/main/java/top/yeij/cyrene/voice/hotword/HotwordDetector.kt
new file mode 100644
index 0000000..f388f01
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/voice/hotword/HotwordDetector.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/voice/stt/SpeechRecognizer.kt b/app/src/main/java/top/yeij/cyrene/voice/stt/SpeechRecognizer.kt
new file mode 100644
index 0000000..e5b5811
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/voice/stt/SpeechRecognizer.kt
@@ -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 = ""
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/voice/tts/TextToSpeechEngine.kt b/app/src/main/java/top/yeij/cyrene/voice/tts/TextToSpeechEngine.kt
new file mode 100644
index 0000000..03e58a9
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/voice/tts/TextToSpeechEngine.kt
@@ -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)
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..a4933e5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..11606d0
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..6b78462
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..00d0e98
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,42 @@
+
+
+ Cyrene
+ 昔涟
+ 昔涟 —— 你的智能语音助手
+
+
+ 对话
+ 设备
+ 我的
+
+
+ 正在聆听唤醒词…
+ 我在听…
+ 思考中…
+ 正在说话…
+ 点击说话
+ 按住说话
+
+
+ 登录
+ 退出登录
+ 设置
+ 取消
+ 确认
+ 保存
+ 重试
+
+
+ 设为默认语音助手
+ 将昔涟替换为系统默认助手
+ 外观
+ 语音设置
+ 唤醒词
+ 账号
+ 关于
+ 服务器地址
+ 主题
+ 浅色
+ 深色
+ 跟随系统
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..59e6e36
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/main/res/xml/voice_interaction_config.xml b/app/src/main/res/xml/voice_interaction_config.xml
new file mode 100644
index 0000000..485253a
--- /dev/null
+++ b/app/src/main/res/xml/voice_interaction_config.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..0ae4042
--- /dev/null
+++ b/build.gradle.kts
@@ -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
+}
diff --git a/devdocs/00-development-overview.md b/devdocs/00-development-overview.md
new file mode 100644
index 0000000..feaba43
--- /dev/null
+++ b/devdocs/00-development-overview.md
@@ -0,0 +1,158 @@
+# 00 — 项目概述与架构
+
+> 对应主项目 Phase 5(v1.5 → v2.0)Android 客户端
+> 主项目文档:`../../docs/dev-plan/04-voice-system-plan.md`
+
+---
+
+## 1. 项目定位
+
+Cyrene for Android 是昔涟的官方 Android 客户端,定位为 **可替换系统自带语音助手的智能体 APP**(替代 Google Assistant / Bixby)。
+
+核心差异点:
+- 不是传统 APP(以桌面图标为主要入口),而是**系统级语音助手**
+- 除常规全屏 Activity 外,通过 `VoiceInteractionSession` 提供悬浮式覆盖层交互
+- 支持息屏热词唤醒、长按 Home / 电源键呼出
+
+## 2. 技术栈
+
+| 层 | 技术 | 版本要求 |
+|----|------|---------|
+| 语言 | Kotlin | 2.0+ |
+| UI 框架 | Jetpack Compose + Material Design 3 | BOM 2025+ |
+| 构建 | Gradle (Kotlin DSL) | 8.7+ |
+| 架构模式 | MVVM + Repository | — |
+| 依赖注入 | Hilt / Koin (待定) | — |
+| 网络 | Retrofit + OkHttp | — |
+| 实时通信 | OkHttp WebSocket | — |
+| 本地存储 | Room (SQLite) + DataStore | — |
+| 推送 | FCM (Firebase Cloud Messaging) | — |
+| 系统语音 | VoiceInteractionService | API 23+ |
+| 热词唤醒 | Always-On Hotword Detection | API 23+ |
+| 语音识别 | 服务端 (Whisper API) + 本地兜底 | — |
+
+## 3. 最低系统要求
+
+| 项目 | 要求 |
+|------|------|
+| minSdk | 26 (Android 8.0) |
+| targetSdk | 35+ |
+| compileSdk | 35+ |
+| JDK | 17 |
+| Gradle | 8.7+ |
+
+注:`VoiceInteractionService` 基础 API 要求 23,`AssistAction` 热词唤醒要求 23。minSdk 设为 26 以覆盖绝大多数活跃设备并简化兼容性。
+
+## 4. 项目结构
+
+```
+android/
+├── app/
+│ ├── src/main/
+│ │ ├── java/com/cyrene/app/
+│ │ │ ├── CyreneApplication.kt # Application 初始化
+│ │ │ ├── MainActivity.kt # 全屏主界面 (桌面图标入口)
+│ │ │ ├── ui/
+│ │ │ │ ├── theme/ # MD3 主题 (Color / Type / Shape)
+│ │ │ │ ├── screens/ # 全屏页面 (Compose)
+│ │ │ │ │ ├── chat/ # 对话页
+│ │ │ │ │ ├── home/ # 首页 / IoT 面板
+│ │ │ │ │ ├── settings/ # 设置页
+│ │ │ │ │ └── login/ # 登录/注册
+│ │ │ │ ├── overlay/ # 悬浮窗对话界面 (VoiceInteractionSession)
+│ │ │ │ └── components/ # 共享组件库
+│ │ │ ├── viewmodel/ # ViewModel 层
+│ │ │ ├── domain/ # 领域层 (UseCase / Repository 接口)
+│ │ │ ├── data/
+│ │ │ │ ├── remote/ # API 接口定义 + DTO
+│ │ │ │ ├── local/ # Room DAO + DataStore
+│ │ │ │ └── repository/ # Repository 实现
+│ │ │ ├── service/
+│ │ │ │ ├── CyreneVoiceInteractionService.kt
+│ │ │ │ ├── CyreneVoiceInteractionSession.kt
+│ │ │ │ ├── CyreneAssistService.kt
+│ │ │ │ └── WebSocketService.kt
+│ │ │ ├── voice/
+│ │ │ │ ├── hotword/ # 热词唤醒引擎
+│ │ │ │ ├── stt/ # 语音识别客户端
+│ │ │ │ └── tts/ # 语音合成客户端
+│ │ │ ├── di/ # DI 模块定义
+│ │ │ └── util/ # 工具类
+│ │ ├── res/ # 资源文件
+│ │ └── AndroidManifest.xml
+│ └── build.gradle.kts
+├── gradle/
+│ ├── libs.versions.toml # 版本目录
+│ └── wrapper/
+├── build.gradle.kts # 根构建脚本
+├── settings.gradle.kts
+└── gradle.properties
+```
+
+## 5. 架构分层
+
+```
+┌──────────────────────────────────────────────┐
+│ UI Layer (Compose) │
+│ ├─ Screens (全屏 Activity) │
+│ └─ Overlay (VoiceInteractionSession 悬浮窗) │
+├──────────────────────────────────────────────┤
+│ ViewModel Layer │
+│ ├─ ChatViewModel │
+│ ├─ HomeViewModel (IoT) │
+│ ├─ SettingsViewModel │
+│ └─ OverlayViewModel │
+├──────────────────────────────────────────────┤
+│ Domain Layer │
+│ ├─ UseCase (SendMessage, ControlIoT, ...) │
+│ └─ Repository Interfaces │
+├──────────────────────────────────────────────┤
+│ Data Layer │
+│ ├─ Remote: Retrofit API + WebSocket │
+│ ├─ Local: Room + DataStore │
+│ └─ Repository Implementation │
+├──────────────────────────────────────────────┤
+│ Service Layer │
+│ ├─ VoiceInteractionService (系统助手) │
+│ ├─ WebSocketService (长连接) │
+│ └─ FCMMessagingService (推送) │
+└──────────────────────────────────────────────┘
+```
+
+UI 层和 Service 层通过 ViewModel 解耦——全屏 Activity 和悬浮窗 Overlay 复用同一组 ViewModel,只是 UI 布局不同。
+
+## 6. 与后端的关系
+
+```
+Android Client
+ │
+ ├─ HTTP REST ──────────► Gateway (:8080) # 登录、CRUD、配置
+ ├─ WebSocket ──────────► Gateway (:8080) # 实时对话、IoT 状态推送
+ ├─ STT Audio ──────────► Voice Service (:8093) # 语音识别
+ └─ TTS Stream ◄──────── Voice Service (:8093) # 语音合成
+```
+
+WebSocket 长连接是核心通信通道:对话消息、通知、IoT 状态广播均通过同一连接。HTTP 仅用于一次性操作(登录、文件上传/下载)。
+
+## 7. 关键设计决策
+
+| 决策 | 选择 | 理由 |
+|------|------|------|
+| UI 框架 | Jetpack Compose + MD3 | 声明式 UI,与悬浮窗的 ComposeView 集成简单 |
+| 架构 | MVVM + Repository | Google 官方推荐,ViewModel 可在 Activity 和 Session 间复用 |
+| 语音框架 | VoiceInteractionService(系统 API) | 原生支持替换系统助手,无需自定义悬浮窗权限 |
+| 热词方案 | 系统 Always-On Hotword API | 息屏低功耗监听,不用自建音频采集 |
+| 网络 | OkHttp WebSocket | 比 FCM 更实时,与主项目 Gateway 已有 WS Hub 对应 |
+| 最低 API | 26 | 覆盖 95%+ 活跃设备,VoiceInteractionService 兼容 |
+
+## 8. 排期参考
+
+对应主项目路线图:
+
+```
+2026 Q4 ─ v1.3 多平台接入(前置依赖)
+2027 Q1 ─ v1.8 语音模型训练完成(后端依赖)
+2027 Q2 ─ v2.0 开始 Android 客户端开发
+2027 Q3 ─ v2.3 语音助手 APP MVP 版本
+2027 Q4 ─ v3.0 APP 上架(Google Play / 国内应用商店)
+```
diff --git a/devdocs/01-voice-assistant-system.md b/devdocs/01-voice-assistant-system.md
new file mode 100644
index 0000000..3d8b75e
--- /dev/null
+++ b/devdocs/01-voice-assistant-system.md
@@ -0,0 +1,256 @@
+# 01 — 系统语音助手集成规范
+
+> **目标**:让昔涟成为 Android 系统级默认语音助手,替换 Google Assistant / Bixby
+> **核心 API**:`VoiceInteractionService` + `VoiceInteractionSession`
+
+---
+
+## 1. 功能目标
+
+- 用户可在 **系统设置 → 默认应用 → 数字助理** 中选择昔涟
+- 长按 Home 键呼出昔涟(非全屏,悬浮覆盖层)
+- 屏幕底部两角向内滑动触发昔涟
+- 长按电源键可配置为呼出昔涟
+- 息屏状态下热词唤醒昔涟
+- 有线/蓝牙耳机按键呼出昔涟
+
+## 2. AndroidManifest.xml 声明
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## 3. 配置文件
+
+### res/xml/voice_interaction_config.xml
+
+```xml
+
+
+```
+
+## 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?
+ ) {
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## 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 对话界面作为过渡
+- **通知栏常驻**:提供快速对话入口,但功能受限
diff --git a/devdocs/02-interaction-flow.md b/devdocs/02-interaction-flow.md
new file mode 100644
index 0000000..9415b62
--- /dev/null
+++ b/devdocs/02-interaction-flow.md
@@ -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 |
+|------|-----------|---------------|
+| 下滑覆盖层 | 关闭悬浮窗 | — |
+| 点击遮罩区域 | 关闭悬浮窗 | — |
+| 长按消息 | 复制/分享菜单 | 复制/分享/删除 |
+| 左滑消息 | — | 查看消息详情/时间戳 |
+| 双击昔涟头像 | 切换输入模式(语音↔文字) | 同左 |
diff --git a/devdocs/03-design-system.md b/devdocs/03-design-system.md
new file mode 100644
index 0000000..51344ac
--- /dev/null
+++ b/devdocs/03-design-system.md
@@ -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)
diff --git a/devdocs/04-feature-spec.md b/devdocs/04-feature-spec.md
new file mode 100644
index 0000000..a3ed26a
--- /dev/null
+++ b/devdocs/04-feature-spec.md
@@ -0,0 +1,272 @@
+# 04 — 功能规格说明书
+
+> **版本**:MVP v0.1 → Stable v1.0
+> **优先级定义**:P0 = 不可缺失 | P1 = 首个正式版必需 | P2 = 后续版本
+
+---
+
+## 1. 功能总览
+
+### MVP (v0.1) — 核心语音助手
+
+| # | 功能 | 优先级 | 说明 |
+|---|------|--------|------|
+| F01 | VoiceInteractionService 注册 | P0 | 系统可识别并设为默认助手 |
+| F02 | 语音唤醒(热词"昔涟") | P0 | 息屏/亮屏唤醒 |
+| F03 | 悬浮覆盖层对话 | P0 | VoiceInteractionSession 界面 |
+| F04 | STT 语音识别 | P0 | 实时语音转文字 |
+| F05 | TTS 语音合成 | P0 | 文字转语音回复 |
+| F06 | 实时文字对话 | P0 | WebSocket 双向通信 |
+| F07 | 用户认证与登录 | P0 | Token 持久化 |
+
+### v0.5 — 功能完善
+
+| # | 功能 | 优先级 | 说明 |
+|---|------|--------|------|
+| F08 | IoT 设备状态查询 | P1 | 只读查询设备状态 |
+| F09 | IoT 设备控制 | P1 | 开关/调节设备 |
+| F10 | 推送通知 (FCM) | P1 | 昔涟主动消息、提醒 |
+| F11 | 多会话历史 | P1 | 查看历史对话记录 |
+| F12 | 提醒管理 | P1 | 创建/查看/删除提醒 |
+| F13 | 全屏 Activity 模式 | P1 | 桌面图标入口、完整功能 |
+| F14 | 暗黑模式 | P1 | 跟随系统 / 手动切换 |
+| F15 | 自定义唤醒词 | P2 | 用户可修改唤醒词 |
+
+### v1.0 — 正式版
+
+| # | 功能 | 优先级 | 说明 |
+|---|------|--------|------|
+| F16 | 记忆查看 | P1 | 浏览昔涟的记忆 |
+| F17 | 自动化规则 | P2 | 查看/触发自动化场景 |
+| F18 | 知识库查询 | P2 | 检索知识文档 |
+| F19 | 文件上传 | P2 | 图片上传与分析 |
+| F20 | 后台思考展示 | P2 | 查看昔涟的思考内容 |
+| F21 | 多设备同步 | P2 | Web 端和 Android 端对话同步 |
+| F22 | 画中画语音通话 | P2 | 持续语音对话的 PIP 模式 |
+| F23 | 主题自定义 | P2 | 预设主题色切换 |
+| F24 | 离线兜底 | P2 | 无网络时的本地回复 |
+
+---
+
+## 2. P0 功能详细规格
+
+### F01 · VoiceInteractionService 注册
+
+**用户故事**:作为用户,我可以在系统设置中将昔涟设为默认语音助手。
+
+**验收标准**:
+- [ ] AndroidManifest.xml 正确声明 `VoiceInteractionService`
+- [ ] 系统 **设置 → 默认应用 → 数字助理** 列表中可见 "昔涟"
+- [ ] 选中后,长按 Home 键可触发昔涟
+- [ ] 选中后,底部两角滑动可触发昔涟
+- [ ] 未设为默认时,APP 内显示引导卡片并一键跳转设置页
+
+**技术依赖**:无
+
+---
+
+### F02 · 语音唤醒
+
+**用户故事**:作为用户,我可以在息屏或使用其他 APP 时说"昔涟"直接唤起助手。
+
+**验收标准**:
+- [ ] 息屏状态下说出"昔涟"可唤醒(成功率 ≥ 95%,安静环境)
+- [ ] 亮屏使用其他 APP 时说出"昔涟"可唤醒
+- [ ] 唤醒后显示悬浮覆盖层,播放提示音
+- [ ] 10 分钟无交互自动停止热词监听以省电
+- [ ] 用户可在设置中开启/关闭息屏唤醒
+- [ ] 误唤醒率 ≤ 5 次/天(正常使用环境)
+
+**技术依赖**:F01 (VoiceInteractionService),`CAPTURE_AUDIO_HOTWORD` 权限
+
+---
+
+### F03 · 悬浮覆盖层对话
+
+**用户故事**:作为用户,语音唤醒昔涟后看到半透明覆盖层,不影响当前使用的 APP。
+
+**验收标准**:
+- [ ] 底部滑入动画 300ms,覆盖层显示
+- [ ] 半透明黑色遮罩透出底层 APP 内容
+- [ ] 对话卡片顶部圆角 28dp,自适应高度(最大 70% 屏幕)
+- [ ] 点击遮罩区域关闭覆盖层
+- [ ] 下滑卡片关闭覆盖层
+- [ ] 静默 10 秒自动收起
+- [ ] 用户说"再见"/"退下"自然结束对话
+- [ ] 关闭后回到触发前状态,不压入任何 Activity 栈
+
+**技术依赖**:F01 (VoiceInteractionService)
+
+---
+
+### F04 · STT 语音识别
+
+**用户故事**:作为用户,我可以对昔涟说话,她会实时将我的语音转成文字。
+
+**验收标准**:
+- [ ] 覆盖层显示时自动开始监听
+- [ ] 实时显示识别中间结果(流式 STT)
+- [ ] 语音结束(静默 1.5s)后自动提交识别结果
+- [ ] 识别结果以用户气泡形式显示在对话中
+- [ ] 安静环境识别准确率 ≥ 95%(中文普通话)
+- [ ] 支持噪音环境降噪
+
+**技术依赖**:后端 Whisper API (voice-service :8093),`RECORD_AUDIO` 权限
+
+---
+
+### F05 · TTS 语音合成
+
+**用户故事**:作为用户,昔涟可以用自然的声音读出她的回复。
+
+**验收标准**:
+- [ ] 流式 TTS:收到 LLM 第一个 token 即开始合成
+- [ ] 语音自然流畅,无机械感(使用后端 Edge-TTS 或训练模型)
+- [ ] 播放完毕自动进入下一轮监听
+- [ ] 播放期间自动降低其他音频音量(Audio Focus)
+- [ ] 用户可在设置中调整语速、音量
+
+**技术依赖**:后端 TTS API (voice-service :8093)
+
+---
+
+### F06 · 实时文字对话
+
+**用户故事**:作为用户,我可以打字与昔涟交流,并实时看到她的回复。
+
+**验收标准**:
+- [ ] WebSocket 连接建立后保持心跳 (30s ping)
+- [ ] 文本消息发送后 200ms 内显示用户气泡
+- [ ] 昔涟回复流式显示(逐字/逐 token 渲染)
+- [ ] 正确处理 `chat`、`action`、`thinking` 三种消息类型
+- [ ] 断线自动重连(指数退避,最多 5 次)
+- [ ] 连接状态在界面实时指示
+- [ ] 多条消息按时间顺序排列
+- [ ] 支持消息滚动到顶部加载历史
+
+**技术依赖**:后端 Gateway WebSocket (:8080)
+
+---
+
+### F07 · 用户认证与登录
+
+**用户故事**:作为用户,我可以登录我的 Cyrene 账号以同步数据。
+
+**验收标准**:
+- [ ] 首次启动显示登录页
+- [ ] 支持输入 Gateway 地址 + 账号 + 密码
+- [ ] 登录成功保存 JWT Token 到 EncryptedDataStore
+- [ ] Token 过期自动刷新(refresh token)
+- [ ] 再次启动自动登录(skip login page)
+- [ ] 登录失败显示明确错误信息
+- [ ] 支持退出登录并清除本地数据
+
+**技术依赖**:后端 Gateway Auth API (:8080)
+
+---
+
+## 3. P1 功能详细规格
+
+### F08 · IoT 设备状态查询
+
+**验收标准**:
+- [ ] 全屏 Activity 中 IoT Tab 显示所有设备卡片
+- [ ] 实时显示设备状态(开/关、亮度、温度等)
+- [ ] 通过 WebSocket 接收状态变更推送
+- [ ] 覆盖层模式下支持语音查询("灯开着吗?")
+- [ ] 覆盖层模式下 IoT 查询结果以文字+卡片形式展示
+- [ ] 锁屏状态下仅允许查询
+
+### F09 · IoT 设备控制
+
+**验收标准**:
+- [ ] 设备卡片上可直接开关/调节
+- [ ] 支持语音控制("打开客厅灯")
+- [ ] 控制结果实时反馈(成功/失败)
+- [ ] 锁屏状态下禁止控制,提示解锁
+- [ ] 支持设备白名单(每用户可控制的设备不同)
+
+### F10 · 推送通知 (FCM)
+
+**验收标准**:
+- [ ] 接收昔涟主动消息推送
+- [ ] 接收提醒到期推送
+- [ ] 接收 IoT 状态变更推送
+- [ ] 点击通知打开对应界面
+- [ ] 通知渠道分组(对话 / 提醒 / IoT / 系统)
+- [ ] 用户可独立控制各渠道开关
+
+### F11 · 多会话历史
+
+**验收标准**:
+- [ ] 对话列表页展示所有历史会话
+- [ ] 每条会话显示标题、最后一条消息预览、时间
+- [ ] 点击进入对应会话详情
+- [ ] 支持删除会话
+- [ ] 与 Web 端历史同步
+
+### F12 · 提醒管理
+
+**验收标准**:
+- [ ] 可以语音创建提醒("提醒我下午三点开会")
+- [ ] 列表展示所有活跃提醒
+- [ ] 支持删除/标记完成
+- [ ] 到期时 FCM 推送 + 覆盖层显示
+
+### F13 · 全屏 Activity 模式
+
+**验收标准**:
+- [ ] 桌面图标启动进入全屏界面
+- [ ] BottomNav 三 Tab(对话 / IoT / 我的)
+- [ ] 完整的设置页
+- [ ] 与覆盖层共享同一 WebSocket 连接和 ViewModel
+
+### F14 · 暗黑模式
+
+**验收标准**:
+- [ ] 跟随系统暗黑模式自动切换
+- [ ] 用户可在设置中手动选择 Light / Dark / Auto
+- [ ] 覆盖层同步使用当前主题
+- [ ] 对话气泡、卡片、输入框颜色正确适配
+
+---
+
+## 4. P2 功能概要
+
+| # | 功能 | 关键验收标准 |
+|---|------|------------|
+| F15 | 自定义唤醒词 | 设置页可输入自定义词,验证唯一性,测试唤醒效果 |
+| F16 | 记忆查看 | 时间线展示昔涟记忆,支持搜索过滤 |
+| F17 | 自动化规则 | 查看规则列表,手动触发,查看执行日志 |
+| F18 | 知识库查询 | 搜索文档,查看内容,语音问答 |
+| F19 | 文件上传 | 图片选择/拍照,缩略图预览,AI 分析结果 |
+| F20 | 后台思考 | 展示昔涟后台思考片段,可折叠面板 |
+| F21 | 多设备同步 | Web 端和 Android 端对话实时同步 |
+| F22 | PIP 语音通话 | 切换到 PIP 窗口进行持续语音对话 |
+| F23 | 主题自定义 | 从预设色中选择,实时预览 |
+| F24 | 离线兜底 | 无网络时显示离线提示,缓存本地回复模板 |
+
+---
+
+## 5. 按开发阶段分组
+
+### Sprint 1 (MVP):F01 → F07 (P0 全部)
+目标:可设为系统默认助手,能语音唤醒并对话
+
+### Sprint 2:F08 → F14 (P1 全部)
+目标:IoT 控制、全屏界面、推送通知上线
+
+### Sprint 3+:F15 → F24 (P2 逐个)
+目标:体验完善、高级功能
+
+## 6. 非功能需求
+
+| 类别 | 需求 | 指标 |
+|------|------|------|
+| 性能 | 覆盖层冷启动 | < 500ms |
+| 性能 | 语音识别端到端延迟 | < 2s (STT + LLM + TTS) |
+| 性能 | WebSocket 消息延迟 | < 100ms |
+| 稳定性 | 崩溃率 | < 0.5% |
+| 电量 | 热词监听功耗 | < 3% 电池/小时 (息屏) |
+| 网络 | 支持弱网 | 切换到低码率 TTS |
+| 兼容性 | 国内 ROM 适配 | MIUI / ColorOS / OriginOS / HarmonyOS |
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..1e8e761
--- /dev/null
+++ b/gradle.properties
@@ -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
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..3ce25f7
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -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" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..85b9a26
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -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
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..f5feea6
--- /dev/null
+++ b/gradlew
@@ -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" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..9d21a21
--- /dev/null
+++ b/gradlew.bat
@@ -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
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..e093824
--- /dev/null
+++ b/settings.gradle.kts
@@ -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")