19 Commits

Author SHA1 Message Date
AskaEth bc7630c43a fix: popBackStack guard, refresh dedup, action tag splitting, notification debug logging
- Navigation: guard popBackStack with currentDestination check to prevent
  double-pop during exit animation causing white screen (Settings→Main overlap)
- Chat refresh: clear all messages before reload to avoid local-UUID / server-ID
  duplication; split inline <action> tags from bulk-loaded HTTP/WS history messages
- Chat animation: restore AnimatedVisibility (fadeIn+slideInVertically) in
  AnimatedChatBubble composable
- Notification debug: add NOTIFY log category with strategic log points across
  the entire background/notification pipeline — WS lifecycle, foreground/background
  transitions, emitMessage decision reasons, keep-alive service events
- Settings UI: switch log tabs from TabRow to ScrollableTabRow, add fixed-height
  card-styled log viewer with entry count header
- Clean up obsolete launcher drawable/mipmap resources

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 08:08:53 +08:00
AskaEth 08d78c976a feat: image attachment thumbnails, send timeout recovery, action tag parsing
- Multi-image thumbnails in chat bubbles with tap-to-fullscreen preview
- 15s send timeout in ChatViewModel and OverlayViewModel to prevent
  stuck "thinking" state when server sends no response
- Strip <action> XML tags in ActionMessage rendering (new server format)
- Add file_id/thumbnail_url to WSAttachment DTO for upload-first flow
- Replace imageDataUri with imageDataUris list for multi-image support
- Remove "[图片]" placeholder text from user messages with images

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:36:13 +08:00
AskaEth 6394099e2e fix: WebSocket dead-connection recovery, notification delivery, theme system overhaul
- Detect silent WebSocket drops via 30s no-message timeout + 15s heartbeat
- Force reconnect in onAppBackground via foreground service context
- Reduce KeepAlive interval from 15min to 5min for faster background recovery
- Replace callback-based notification with direct NotificationHelper injection
- Suppress notifications during initial launch and when app is foregrounded
- 9 theme color presets (pink default) + Monet dynamic color (Android 12+)
- Full HSL-derived MD3 ColorScheme replacing stale purple-only scheme
- Inline markdown rendering for chat messages (bold, italic, code, links)
- Long-press copy on error/system messages
- Hidden root keep-alive toggle (5-tap) with system-level commands
- BootReceiver to reapply keep-alive and restart service on boot

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:56:21 +08:00
AskaEth e65a35a239 fix: remove AnimatedVisibility from chat bubbles to fix LazyColumn scroll limit
AnimatedVisibility with visible=false starts items at zero height. Inside a
reverseLayout LazyColumn, this causes the list to miscompute total content
height, preventing items beyond the visible viewport from being composed.
This was limiting the chat to ~9 visible messages that filled the screen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:43:57 +08:00
AskaEth 7fcf562648 feat: markdown/code message renderers, collapsible non-chat content, dark mode fixes, and data persistence
- Add MarkdownBubble (headings, bold/italic, code blocks, lists, quotes, links)
- Add CodeBubble with dark background + language header
- Add CollapsibleBubble wrapper for long non-chat content with fold/expand button
- Update WSReviewMessage DTO: add type and metadata fields for review messages
- Fix message dedup: apply removeWrappingDuplicates before DB insert instead of on return value
- Fix dark mode: explicit text colors on StatusIndicator, icon tints, dynamicColor=false
- Add enter-to-send toggle and typing indicator style (bubble/text) in settings
- Overlay: transparent window background, pill-shaped semi-transparent input field
- Remove PullToRefreshBox (conflicted with reverseLayout scroll), use refresh button
- Add auto-refresh when connection transitions offline→online
- Fix session ID fallback for DB message loading after APK update

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:36:06 +08:00
AskaEth 64c7018729 fix: stream_end dedup, typing indicator position, and indicator style toggle
- Fix stream_end not suppressed: populate recentParsedContents in response handler
  instead of clearing it, so the dedup check can correctly suppress wrapping text
- Fix typing indicator appearing at top (oldest) in reverseLayout: place item
  before itemsIndexed so it gets index 0 (visual bottom)
- Add typing indicator style toggle in settings: bubble (default) vs text mode,
  persisted via PreferencesDataStore, applied in ChatScreen and OverlayContent

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:02:47 +08:00
AskaEth 86d196b857 fix: align OverlayViewModel message dedup with ChatViewModel
Removed the legacy isDup check (role+content+msgType) that could suppress
multi_message items already seen as review items. Now deduplicates by
message ID only and inserts at correct timestamp position, matching
the fixed ChatViewModel behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:40:57 +08:00
AskaEth 91231834dc fix: prevent IME from hiding latest messages in chat
Changed chat layout from Box overlay to Column flow so imePadding()
applies to the whole container instead of just the input bar. Messages
area now shrinks with the keyboard, keeping latest messages visible.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:01:43 +08:00
AskaEth 5dad0cd39b fix: use backend msg_type instead of hardcoding in server message handlers
Backend now guarantees msg_type is always populated. Changed all server
message handlers (stream_chunk, stream_end, thinking, tool_progress, error,
voice_transcript, review) to use wsMsg.msgType with safe fallback defaults
instead of hardcoded values.

Also added missing ProGuard keep rules for UI screens/components/overlay to
prevent R8 from stripping composables called via navigation lambdas.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:50:49 +08:00
AskaEth ce73f68bc8 fix: add ProGuard keep rules for UI screens and components
Navigation compose lambdas in NavGraph may not be traced by R8's call
graph, causing screen composables to be stripped in release builds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:45:48 +08:00
AskaEth 3c90adae6a fix: use offset+clipToBounds instead of graphicsLayer alpha for tab keep-alive
Hidden tabs with graphicsLayer{alpha=0f} still intercepted touch events.
Replaced with offset(x=2000.dp) + parent clipToBounds() so hidden composables
are off-screen and cannot capture touches meant for the visible tab.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:04:33 +08:00
AskaEth 014437760d fix: IME layout, message dedup, animation order, and overlay input positioning
- ChatScreen: restructure to Box overlay layout so only input area
  rises with IME, messages stay fixed. Add opaque background to input
  area. Use reverseLayout with newest-first animation order.
- OverlayContent: remove all manual IME detection — system forces
  adjust=pan on VoiceInteractionSession windows, so manual padding
  caused double offset. Let system handle IME naturally.
- ChatRepositoryImpl: add messageRemovals flow to clean up wrapping
  stream_end/response when review/multi_message items arrive later.
  Track lastResponseId in both stream_end and response handlers.
- ChatViewModel/OverlayViewModel: fix dedup to check by message ID
  only. Sort descending (newest first). Observe messageRemovals.
- NavGraph: keep all tabs composed with graphicsLayer alpha toggle —
  prevents ChatScreen destruction and re-render on tab switch.
- CyreneVoiceInteractionSession: defer configureWindow via post()
  to override system softInputMode flags.
- AndroidManifest: set windowSoftInputMode=adjustNothing on main
  activity.
- Add WebSocketKeepAliveService for background connection persistence.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 21:41:46 +08:00
AskaEth eb94142404 fix: add missing ProGuard keep rules to prevent release crash
R8 was stripping Android components (services, application),
Room entities, Koin modules, and ViewModels — causing crash
when VoiceInteractionService was invoked via power button.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 21:15:23 +08:00
AskaEth 1c96588d79 chore: add release signing config via keystore.properties
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 20:27:29 +08:00
AskaEth 9295fe8021 feat: dark mode background fixes, chat bubble long-press menu, notification dedup, biometric auth for clear
- Fix: dark mode white backgrounds via transparent windowBackground, decorView color, and explicit Surface backgrounds on IoT/Profile/MainScreen
- Fix: dark mode text colors in ProfileScreen (nickname, account info, assistant status)
- Feat: long-press chat bubble to copy message text via DropdownMenu
- Feat: notification deduplication — track notified message IDs, clear on foreground
- Feat: cancel all notifications when app enters foreground
- Feat: biometric/device-credential verification before clearing local messages

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 20:22:28 +08:00
AskaEth 5247eef0fc feat: auto screen context toggle + fix dark mode status bar
- Add auto_screen_context preference (boolean, default false) to DataStore
- Add Switch toggle in Settings > Voice section to control automatic screen
  content reading when assistant is invoked via power button
- CyreneVoiceInteractionSession onShow now checks the preference before
  calling AccessibilityService to read and send screen content
- Fix status bar white in dark mode: MainActivity now observes theme mode
  preference (light/dark/auto) and passes resolved boolean to CyreneTheme
- CyreneTheme sets statusBarColor, navigationBarColor, and
  isAppearanceLightStatusBars/isAppearanceLightNavigationBars explicitly

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:25:47 +08:00
AskaEth 2725fdd1d5 fix: overlay status bar coverage and IME input flying in portrait mode
- Restore FLAG_TRANSLUCENT_STATUS and FLAG_TRANSLUCENT_NAVIGATION on
  VoiceInteractionSession window to let content extend behind system bars
- Move window configuration from onCreateContentView to onShow (window
  is guaranteed available at this point)
- Replace statusBarsPadding/navigationBarsPadding with manual status bar
  height calculation — Compose WindowInsets may not receive proper values
  in VoiceInteractionSession overlay windows
- Keep SOFT_INPUT_ADJUST_NOTHING + imePadding on InputArea for correct
  IME behavior (full-screen IME pushes input, floating IME does not)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:01:58 +08:00
AskaEth 367ef7f2d6 feat: reconnection, overlay UI, profile caching, history loading, log viewer, and about page
- Reconnection: unlimited retries with capped backoff, forceReconnect on foreground
  and manual refresh when offline
- Overlay: fix status bar coverage, remove scrim, fix IME layout (messages fixed
  at top, only full-screen IME pushes input), handle process-kill by eager
  ViewModel resolution with try-catch
- Profile: cache-first rendering, cloud refresh on each visit, silent fallback
  to cache on failure
- Messages: fix message swallowing by tracking DB observer job and using atomic
  StateFlow.update(), add dedicated isAssistantStreaming state for reliable
  typing indicator
- History: history_response handler now emits to live message stream, HTTP
  fallback waits for WS connection before requesting history
- Foreground: always request history on foreground to catch cross-device messages
- Log viewer: enhanced with All tab, auto-scroll, new categories (HTTP, voice, general),
  added log points for app lifecycle and overlay events
- Settings: added About page with project link (https://git.yeij.top/AskaEth/Cyrene-For-Android)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:58:34 +08:00
AskaEth a57692353c Initial Android project setup with Compose, WebSocket, and VoiceInteractionService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:58:59 +08:00
99 changed files with 13019 additions and 2 deletions
+1
View File
@@ -33,3 +33,4 @@ google-services.json
# Android Profiling
*.hprof
keystore.properties
+181 -2
View File
@@ -1,3 +1,182 @@
# Cyrene-For-Android
# Cyrene for Android
昔涟在安卓设备上的载体
昔涟在安卓设备上的载体 —— 随时随地与昔涟对话、操控 IoT 设备、接收实时通知。
## 概述
Cyrene for Android 是 [Cyrene(昔涟)](https://github.com/Cyrene/Cyrene) 项目的官方 Android 客户端。Cyrene 是一个开源的基于 LLM 平台的智能体,提供多人格对话、IoT 设备操控、记忆管理、自动化规则、知识库、语音交互等功能。
Android 客户端的目标是成为用户的**默认语音助手**,完全替代系统自带的助手(Google Assistant / Bixby 等),同时提供:
- **系统级语音助手** — 注册为 `VoiceInteractionService`,可通过长按 Home / 侧滑呼出
- **语音唤醒** — 息屏热词唤醒、免提交互(类似 "Hey Google",使用 "昔涟" 等唤醒词)
- **随时对话** — 通过文字或语音与昔涟交流
- **IoT 控制** — 远程操控家中的智能设备
- **实时通知** — 接收昔涟的主动消息、提醒、IoT 状态变更
- **后台连接** — 即使 APP 在后台,仍保持通知推送与语音服务就绪
## 开发状态
> **当前阶段**:项目初始化,尚未开始正式开发。
根据主项目的[开发路线图](../docs/dev-plan/00-development-roadmap.md)Android 客户端计划在 **Phase 5v1.5 → v2.0** 开始开发,预计时间窗口为 **2027 Q2-Q3**
## 技术栈 (规划)
| 层 | 技术 |
|----|------|
| 语言 | Kotlin |
| UI 框架 | Jetpack Compose + Material Design 3 |
| 架构 | MVVM + Repository |
| 网络 | OkHttp / Retrofit + WebSocket |
| 本地存储 | Room (SQLite) + DataStore |
| 推送 | FCM (Firebase Cloud Messaging) |
| 语音 | VoiceInteractionService + 热词唤醒 + STT + TTS |
| 构建 | Gradle (Kotlin DSL) |
## 功能路线图
- [ ] 用户认证与登录
- [ ] 实时文字对话 (WebSocket)
- [ ] VoiceInteractionService 注册(替换系统语音助手)
- [ ] 间接启动悬浮窗(VoiceInteractionSession 全屏覆盖层)
- [ ] 热词唤醒(息屏 / 亮屏)+ 唤醒词自定义
- [ ] 语音识别 (STT)
- [ ] 语音合成 (TTS)
- [ ] IoT 设备控制面板
- [ ] 推送通知 (FCM)
- [ ] 后台连接与通知
- [ ] 锁屏 / 息屏语音交互
- [ ] PWA 保底方案 (短期替代)
## 交互模式
APP 有两种界面呈现方式,根据启动来源自动切换:
| 启动方式 | 界面模式 | 说明 |
|---------|---------|------|
| 桌面图标 / 最近任务 | **全屏 Activity** | 常规 APP 模式,完整功能入口 |
| 语音唤醒 / 长按 Home / 侧滑 / 长按电源键 / 耳机按键 | **悬浮窗 (VoiceInteractionSession)** | 全屏覆盖层,半透明背景透出底层 APP,不影响当前任务栈 |
悬浮窗模式的要点:
- 借助 `VoiceInteractionSession` 系统窗口,不压入 Activity 返回栈
- 对话结束后窗口收起,用户回到触发前的界面
- 底层 APP 内容半透明可见(模糊遮罩),让用户保持上下文感知
- 窗口高度自适应对话内容,类似 Google Assistant 的卡片式覆盖
## 设计规范
- **设计语言**Material Design 3 (Material You)
- **主题**:动态配色(Dynamic Color),跟随系统壁纸或手动选择主题色
- **暗黑模式**:支持 Light / Dark 双主题,跟随系统或手动切换
- **组件**:全面使用 `androidx.compose.material3` 组件库
- **图标**Material Icons + 自定义图标
- **动效**:遵循 MD3 动效规范(过渡动画、涟漪效果、Shared Elements
- **字体**:系统默认字体(Roboto / Google Sans),支持动态字体缩放
## 项目结构 (规划)
```
android/
├── app/
│ ├── src/main/
│ │ ├── java/com/cyrene/app/
│ │ │ ├── ui/ # Compose UI 层
│ │ │ ├── viewmodel/ # ViewModel 层
│ │ │ ├── repository/ # 数据仓库层
│ │ │ ├── data/ # 数据模型 & API 接口
│ │ │ ├── service/ # VoiceInteractionService & WebSocket & FCM
│ │ │ ├── voice/ # 热词唤醒 (Hotword) & STT & TTS
│ │ │ └── di/ # 依赖注入
│ │ └── res/ # 资源文件
│ └── build.gradle.kts
├── build.gradle.kts
├── settings.gradle.kts
└── gradle.properties
```
## 环境要求
- Android Studio Hedgehog (2023.1.1) 或更高版本
- Kotlin 2.0+
- JDK 17+
- Android SDK (target: 34+, min: 26+)
## 网络配置(中国大陆用户)
Gradle、Google Maven、Maven Central 等服务器在国内访问缓慢或不可达,首次同步前需配置镜像。
### Gradle Wrapper
`gradle/wrapper/gradle-wrapper.properties`
```properties
# 将 services.gradle.org 替换为腾讯/阿里镜像
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.7-bin.zip
# 或阿里: https\://mirrors.aliyun.com/macports/distfiles/gradle/gradle-8.7-bin.zip
```
### 仓库镜像
`settings.gradle.kts` 顶部添加:
```kotlin
pluginManagement {
repositories {
maven { url = uri("https://mirrors.cloud.tencent.com/gradle/plugins") }
maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public") }
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public") }
google()
mavenCentral()
}
}
```
### Gradle 属性
`gradle.properties`
```properties
# HTTP 代理(如使用 Clash/V2Ray,通常不需要)
# systemProp.http.proxyHost=127.0.0.1
# systemProp.http.proxyPort=7890
# systemProp.https.proxyHost=127.0.0.1
# systemProp.https.proxyPort=7890
```
### Android SDK Proxy
若 Android SDK Manager 也下载缓慢,可在 Android Studio 中设置:
**Settings → Appearance & Behavior → System Settings → HTTP Proxy**
配置为 `mirrors.cloud.tencent.com``mirrors.neusoft.edu.cn`,端口 `80`
## 快速开始
> 项目尚未包含可构建的源码,以下为后续开发的参考步骤。
1. 用 Android Studio 打开 `android/` 目录
2. 按上述"网络配置"替换国内镜像
3. 等待 Gradle 同步完成
4. 启动主项目后端服务(参考[主项目 README](../README.md)
5.`local.properties` 中配置 `baseUrl` 指向 Gateway 地址
6. 选择模拟器或设备,点击 Run
## 相关链接
- [Cyrene 主项目](../) — 后端服务、前端、部署文档
- [开发路线图](../docs/dev-plan/00-development-roadmap.md)
- [多平台接入方案](../docs/dev-plan/03-multi-platform-integration.md)
- [语音系统计划](../docs/dev-plan/04-voice-system-plan.md)
## License
Apache-2.0
+120
View File
@@ -0,0 +1,120 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
}
android {
signingConfigs {
if (keystorePropertiesFile.exists()) {
create("release") {
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
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 {
if (keystorePropertiesFile.exists()) {
signingConfig = signingConfigs.getByName("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)
// Biometric
implementation(libs.biometric)
// Coil — image loading
implementation(libs.coil.compose)
}
+104
View File
@@ -0,0 +1,104 @@
# Cyrene ProGuard Rules
# --- Keep Android components declared in manifest ---
# These are instantiated by the Android system via reflection
-keep class top.yeij.cyrene.CyreneApplication { *; }
-keep class top.yeij.cyrene.MainActivity { *; }
-keep class top.yeij.cyrene.service.** { *; }
# --- Kotlin ---
-keepattributes *Annotation*
-keepattributes Signature
-keepattributes InnerClasses
-keepattributes EnclosingMethod
-keepattributes RuntimeVisibleAnnotations
-keepattributes RuntimeVisibleParameterAnnotations
-keepattributes AnnotationDefault
-keepattributes KotlinMetadata
-dontwarn kotlin.**
-keep class kotlin.Metadata { *; }
-keep class kotlin.coroutines.Continuation
-keep class kotlinx.coroutines.** { *; }
# --- Retrofit ---
-keep class retrofit2.** { *; }
-dontwarn retrofit2.**
# --- Gson ---
-keep class com.google.gson.** { *; }
-keepclassmembers,allowobfuscation class * {
@com.google.gson.annotations.SerializedName <fields>;
}
# Keep all DTO classes and their members for Gson serialization
-keep class top.yeij.cyrene.data.remote.dto.** { *; }
-keepclassmembers class top.yeij.cyrene.data.remote.dto.** { *; }
# --- OkHttp ---
-dontwarn okhttp3.**
-dontwarn okio.**
# --- Room ---
-keep class * extends androidx.room.RoomDatabase { *; }
-keep class top.yeij.cyrene.data.local.entity.** { *; }
-keepclassmembers class top.yeij.cyrene.data.local.entity.** { *; }
-dontwarn androidx.room.paging.**
# --- Koin ---
-keep class org.koin.** { *; }
-keep class top.yeij.cyrene.di.** { *; }
-keepclassmembers class top.yeij.cyrene.di.** { *; }
# --- Compose ---
-dontwarn androidx.compose.**
-keep class androidx.compose.** { *; }
# --- Coroutines ---
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
# --- Keep domain models (used in StateFlow, SharedFlow, etc.) ---
-keep class top.yeij.cyrene.domain.model.** { *; }
# --- Keep ViewModels (Koin instantiates via reflection) ---
-keep class top.yeij.cyrene.viewmodel.** { *; }
-keepclassmembers class top.yeij.cyrene.viewmodel.** { *; }
# --- Keep repository implementations (Koin binds by interface) ---
-keep class top.yeij.cyrene.data.repository.** { *; }
-keep class top.yeij.cyrene.domain.repository.** { *; }
# --- Keep PreferencesDataStore (Koin injects) ---
-keep class top.yeij.cyrene.data.local.PreferencesDataStore { *; }
# --- Keep utility classes (VoiceRecorder, RuntimeLog, etc. injected by Koin) ---
-keep class top.yeij.cyrene.util.** { *; }
-keepclassmembers class top.yeij.cyrene.util.** { *; }
# --- Keep voice/TTS/STT classes (injected by Koin into OverlayViewModel) ---
-keep class top.yeij.cyrene.voice.** { *; }
-keepclassmembers class top.yeij.cyrene.voice.** { *; }
# --- Keep domain use cases (injected by Koin into ViewModels) ---
-keep class top.yeij.cyrene.domain.usecase.** { *; }
-keepclassmembers class top.yeij.cyrene.domain.usecase.** { *; }
# --- Keep network interceptors and ApiService (Koin singletons) ---
-keep class top.yeij.cyrene.data.remote.RetrofitClient { *; }
-keep class top.yeij.cyrene.data.remote.ApiService { *; }
-keep class top.yeij.cyrene.data.remote.AuthInterceptor { *; }
-keep class top.yeij.cyrene.data.remote.DynamicUrlInterceptor { *; }
-keep class top.yeij.cyrene.data.remote.TokenAuthenticator { *; }
# --- Keep WebSocketService (injected into ChatRepositoryImpl) ---
-keep class top.yeij.cyrene.service.WebSocketService { *; }
# --- UI screens & components (called via Navigation compose lambda R8 may not trace) ---
-keep class top.yeij.cyrene.ui.screens.** { *; }
-keep class top.yeij.cyrene.ui.components.** { *; }
-keep class top.yeij.cyrene.ui.overlay.** { *; }
-keep class top.yeij.cyrene.ui.navigation.** { *; }
-keep class top.yeij.cyrene.ui.theme.** { *; }
# --- General AndroidX ---
-keep class androidx.lifecycle.** { *; }
-dontwarn androidx.lifecycle.**
+131
View File
@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 网络 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 语音助手核心 -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.CAPTURE_AUDIO_HOTWORD" />
<!-- 后台服务 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- 推送 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- 锁屏交互 -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- 激进保活 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- 查询其他应用(检查默认助手设置) -->
<queries>
<intent>
<action android:name="android.service.voice.VoiceInteractionService" />
</intent>
</queries>
<application
android:name=".CyreneApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Cyrene"
android:usesCleartextTraffic="true">
<!-- 全屏主界面 -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustNothing"
android:theme="@style/Theme.Cyrene">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- VoiceInteractionService:系统语音助手 -->
<service
android:name=".service.CyreneVoiceInteractionService"
android:exported="true"
android:permission="android.permission.BIND_VOICE_INTERACTION">
<meta-data
android:name="android.voice_interaction"
android:resource="@xml/voice_interaction_config" />
<intent-filter>
<action android:name="android.service.voice.VoiceInteractionService" />
</intent-filter>
</service>
<!-- VoiceInteractionSessionService -->
<service
android:name=".service.CyreneSessionService"
android:exported="true"
android:permission="android.permission.BIND_VOICE_INTERACTION" />
<!-- AccessibilityService:读取屏幕内容 -->
<service
android:name=".service.CyreneAccessibilityService"
android:exported="true"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_config" />
</service>
<!-- WebSocket 后台保活 -->
<service
android:name=".service.WebSocketKeepAliveService"
android:exported="false"
android:foregroundServiceType="dataSync|specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="websocket_keepalive_for_push_message_delivery" />
</service>
<!-- 开机自启 -->
<receiver
android:name=".service.BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- 定时保活唤醒 -->
<receiver
android:name=".service.KeepAliveReceiver"
android:exported="false" />
<!-- FileProvider:日志分享 -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,138 @@
package top.yeij.cyrene
import android.Manifest
import android.app.Activity
import android.app.Application
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.GlobalContext
import org.koin.core.context.startKoin
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.data.repository.ChatRepositoryImpl
import top.yeij.cyrene.di.appModule
import top.yeij.cyrene.service.KeepAliveReceiver
import top.yeij.cyrene.util.RootKeepAliveHelper
import top.yeij.cyrene.util.RuntimeLog
import java.util.concurrent.atomic.AtomicInteger
class CyreneApplication : Application() {
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val activityCount = AtomicInteger(0)
override fun onCreate() {
super.onCreate()
RuntimeLog.general("app", "Application onCreate")
startKoin {
androidContext(this@CyreneApplication)
modules(appModule)
}
// Track foreground/background state
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityResumed(activity: Activity) {
if (activityCount.incrementAndGet() == 1) {
RuntimeLog.general("app", "App in foreground")
getRepo()?.cancelNotifications()
getRepo()?.onAppForeground()
}
}
override fun onActivityPaused(activity: Activity) {
if (activityCount.decrementAndGet() == 0) {
RuntimeLog.general("app", "App in background")
getRepo()?.onAppBackground()
}
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
})
initScope.launch {
val koin = 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
}
}
// Schedule periodic keep-alive on first launch
scheduleInitialKeepAlive()
// Check battery optimization
checkBatteryOptimization()
// Apply root keep-alive if enabled
initScope.launch {
val koin = GlobalContext.get()
val prefs: PreferencesDataStore = koin.get()
val enabled = prefs.rootKeepAlive.firstOrNull() ?: false
if (enabled) {
val ok = RootKeepAliveHelper.applyRootKeepAlive(packageName)
if (ok) {
RuntimeLog.general("app", "Root keep-alive re-applied on boot")
} else if (RootKeepAliveHelper.isRootAvailable()) {
RuntimeLog.general("app", "Root keep-alive re-apply failed despite root being available")
}
// Only attempt system wakelock if root keep-alive was enabled
// We don't persist the wakelock across reboots since it's per-session
}
}
}
private fun scheduleInitialKeepAlive() {
try {
KeepAliveReceiver.schedule(this)
RuntimeLog.general("app", "Initial keep-alive alarm scheduled")
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule initial keep-alive: ${e.message}")
}
}
private fun checkBatteryOptimization() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = getSystemService(PowerManager::class.java)
if (pm?.isIgnoringBatteryOptimizations(packageName) == false) {
Log.i(TAG, "App is NOT exempt from battery optimization")
// Note: we can't request exemption from Application context directly.
// SettingsScreen should offer a button to open the exemption dialog.
}
}
}
private fun getRepo(): ChatRepositoryImpl? {
return try {
GlobalContext.get().get()
} catch (_: Throwable) {
null
}
}
companion object {
private const val TAG = "CyreneApp"
}
}
@@ -0,0 +1,104 @@
package top.yeij.cyrene
import android.Manifest
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat
import androidx.navigation.compose.rememberNavController
import org.koin.compose.koinInject
import top.yeij.cyrene.data.local.PreferencesDataStore
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() {
private val isDefaultAssistant = mutableStateOf(false)
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { /* granted or denied — either way we continue */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestNotificationPermission()
isDefaultAssistant.value = checkIsDefaultAssistant()
setContent {
val prefs: PreferencesDataStore = koinInject()
val themeMode by prefs.themeMode.collectAsState(initial = null)
val themeColor by prefs.themeColor.collectAsState(initial = "pink")
val darkTheme = when (themeMode) {
"light" -> false
"dark" -> true
else -> isSystemInDarkTheme()
}
val useDynamic = themeColor == "monet" && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
CyreneTheme(
darkTheme = darkTheme,
presetKey = themeColor,
useDynamicColor = useDynamic,
) {
val navController = rememberNavController()
CyreneNavGraph(
navController = navController,
startDestination = Routes.MAIN,
isDefaultAssistant = isDefaultAssistant.value,
onOpenAssistantSettings = { openAssistantSettings() },
)
}
}
}
override fun onResume() {
super.onResume()
isDefaultAssistant.value = checkIsDefaultAssistant()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
private fun checkIsDefaultAssistant(): Boolean {
// Standard Android check
val flat = ComponentName(this, CyreneVoiceInteractionService::class.java).flattenToString()
val current = Settings.Secure.getString(contentResolver, "voice_interaction_service")
if (current == flat) return true
// Fallback for COS and other custom OS: check persisted flag from service
if (CyreneVoiceInteractionService.wasEverActive(this)) return true
return false
}
private fun openAssistantSettings() {
startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS))
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}
@@ -0,0 +1,38 @@
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",
)
.build()
.also { INSTANCE = it }
}
}
}
}
@@ -0,0 +1,166 @@
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.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "cyrene_prefs")
class PreferencesDataStore(private val context: Context) {
companion object {
private val KEY_TOKEN = stringPreferencesKey("jwt_token")
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
private val KEY_BASE_URL = stringPreferencesKey("base_url")
private val KEY_THEME = stringPreferencesKey("theme_mode")
private val KEY_WAKE_WORD = stringPreferencesKey("wake_word")
private val KEY_USERNAME = stringPreferencesKey("username")
private val KEY_CLIENT_ID = stringPreferencesKey("client_id")
private val KEY_DEVICE_NAME = stringPreferencesKey("device_name")
private val KEY_CURRENT_SESSION_ID = stringPreferencesKey("current_session_id")
private val KEY_DASHSCOPE_API_KEY = stringPreferencesKey("dashscope_api_key")
private val KEY_DASHSCOPE_ENDPOINT = stringPreferencesKey("dashscope_endpoint")
private val KEY_DASHSCOPE_MODEL = stringPreferencesKey("dashscope_model")
private val KEY_LAST_CLEARED_TIMESTAMP = stringPreferencesKey("last_cleared_timestamp")
private val KEY_PROFILE_USER_ID = stringPreferencesKey("profile_user_id")
private val KEY_PROFILE_NICKNAME = stringPreferencesKey("profile_nickname")
private val KEY_PROFILE_IS_ADMIN = stringPreferencesKey("profile_is_admin")
private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at")
private val KEY_AUTO_SCREEN_CONTEXT = booleanPreferencesKey("auto_screen_context")
private val KEY_TYPING_INDICATOR_STYLE = stringPreferencesKey("typing_indicator_style")
private val KEY_ENTER_TO_SEND = booleanPreferencesKey("enter_to_send")
private val KEY_ROOT_KEEPALIVE = booleanPreferencesKey("root_keepalive")
private val KEY_THEME_COLOR = stringPreferencesKey("theme_color")
}
val typingIndicatorStyle: Flow<String> = context.dataStore.data.map { it[KEY_TYPING_INDICATOR_STYLE] ?: "bubble" }
suspend fun saveTypingIndicatorStyle(style: String) {
context.dataStore.edit { it[KEY_TYPING_INDICATOR_STYLE] = style }
}
val enterToSend: Flow<Boolean> = context.dataStore.data.map { it[KEY_ENTER_TO_SEND] ?: false }
suspend fun saveEnterToSend(enabled: Boolean) {
context.dataStore.edit { it[KEY_ENTER_TO_SEND] = enabled }
}
val rootKeepAlive: Flow<Boolean> = context.dataStore.data.map { it[KEY_ROOT_KEEPALIVE] ?: false }
suspend fun saveRootKeepAlive(enabled: Boolean) {
context.dataStore.edit { it[KEY_ROOT_KEEPALIVE] = enabled }
}
val themeColor: Flow<String> = context.dataStore.data.map { it[KEY_THEME_COLOR] ?: "pink" }
suspend fun saveThemeColor(color: String) {
context.dataStore.edit { it[KEY_THEME_COLOR] = color }
}
val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
val refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
val baseUrl: Flow<String?> = context.dataStore.data.map { it[KEY_BASE_URL] }
val themeMode: Flow<String?> = context.dataStore.data.map { it[KEY_THEME] }
val wakeWord: Flow<String?> = context.dataStore.data.map { it[KEY_WAKE_WORD] }
val username: Flow<String?> = context.dataStore.data.map { it[KEY_USERNAME] }
val clientId: Flow<String?> = context.dataStore.data.map { it[KEY_CLIENT_ID] }
val deviceName: Flow<String?> = context.dataStore.data.map { it[KEY_DEVICE_NAME] }
val currentSessionId: Flow<String?> = context.dataStore.data.map { it[KEY_CURRENT_SESSION_ID] }
val dashScopeApiKey: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_API_KEY] }
val dashScopeEndpoint: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_ENDPOINT] }
val dashScopeModel: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_MODEL] }
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 saveDashScopeApiKey(key: String) {
context.dataStore.edit { it[KEY_DASHSCOPE_API_KEY] = key }
}
suspend fun saveDashScopeEndpoint(endpoint: String) {
context.dataStore.edit { it[KEY_DASHSCOPE_ENDPOINT] = endpoint }
}
suspend fun saveDashScopeModel(model: String) {
context.dataStore.edit { it[KEY_DASHSCOPE_MODEL] = model }
}
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 saveCurrentSessionId(id: String) {
context.dataStore.edit { it[KEY_CURRENT_SESSION_ID] = id }
}
val lastClearedTimestamp: Flow<String?> = context.dataStore.data.map { it[KEY_LAST_CLEARED_TIMESTAMP] }
suspend fun saveLastClearedTimestamp(timestamp: Long) {
context.dataStore.edit { it[KEY_LAST_CLEARED_TIMESTAMP] = timestamp.toString() }
}
// Cached profile for offline / local-first rendering
val profileUserId: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_USER_ID] }
val profileNickname: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_NICKNAME] }
val profileIsAdmin: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_IS_ADMIN] }
val profileCreatedAt: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_CREATED_AT] }
suspend fun saveProfileCache(userId: String, nickname: String, isAdmin: Boolean, createdAt: String) {
context.dataStore.edit {
it[KEY_PROFILE_USER_ID] = userId
it[KEY_PROFILE_NICKNAME] = nickname
it[KEY_PROFILE_IS_ADMIN] = isAdmin.toString()
it[KEY_PROFILE_CREATED_AT] = createdAt
}
}
val autoScreenContext: Flow<Boolean> = context.dataStore.data.map { it[KEY_AUTO_SCREEN_CONTEXT] ?: false }
suspend fun saveAutoScreenContext(enabled: Boolean) {
context.dataStore.edit { it[KEY_AUTO_SCREEN_CONTEXT] = enabled }
}
suspend fun clearProfileCache() {
context.dataStore.edit {
it.remove(KEY_PROFILE_USER_ID)
it.remove(KEY_PROFILE_NICKNAME)
it.remove(KEY_PROFILE_IS_ADMIN)
it.remove(KEY_PROFILE_CREATED_AT)
}
}
suspend fun clearAll() {
context.dataStore.edit { it.clear() }
}
}
@@ -0,0 +1,30 @@
package top.yeij.cyrene.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import top.yeij.cyrene.data.local.entity.ConversationEntity
@Dao
interface ConversationDao {
@Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
fun getAll(): Flow<List<ConversationEntity>>
@Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
suspend fun getAllSnapshot(): List<ConversationEntity>
@Query("SELECT * FROM conversations WHERE id = :id")
suspend fun getById(id: String): ConversationEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(conversation: ConversationEntity)
@Query("DELETE FROM conversations WHERE id = :id")
suspend fun deleteById(id: String)
@Query("DELETE FROM conversations")
suspend fun deleteAll()
}
@@ -0,0 +1,36 @@
package top.yeij.cyrene.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import top.yeij.cyrene.data.local.entity.MessageEntity
@Dao
interface MessageDao {
@Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY timestamp ASC")
fun getByConversation(conversationId: String): Flow<List<MessageEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(message: MessageEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(messages: List<MessageEntity>)
@Query("DELETE FROM messages WHERE conversationId = :conversationId")
suspend fun deleteByConversation(conversationId: String)
@Query("UPDATE messages SET conversationId = :newId WHERE conversationId = :oldId")
suspend fun migrateConversationId(oldId: String, newId: String)
@Query("DELETE FROM messages WHERE conversationId = :conversationId AND role = 'user'")
suspend fun deleteUserMessagesByConversation(conversationId: String)
@Query("DELETE FROM messages WHERE id = :id")
suspend fun deleteById(id: String)
@Query("DELETE FROM messages")
suspend fun deleteAll()
}
@@ -0,0 +1,14 @@
package top.yeij.cyrene.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "conversations")
data class ConversationEntity(
@PrimaryKey val id: String,
val title: String,
val lastMessage: String?,
val lastMessageType: String?,
val updatedAt: Long,
val createdAt: Long,
)
@@ -0,0 +1,27 @@
package top.yeij.cyrene.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "messages",
foreignKeys = [
ForeignKey(
entity = ConversationEntity::class,
parentColumns = ["id"],
childColumns = ["conversationId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("conversationId")],
)
data class MessageEntity(
@PrimaryKey val id: String,
val conversationId: String,
val role: String,
val content: String,
val msgType: String,
val timestamp: Long,
)
@@ -0,0 +1,73 @@
package top.yeij.cyrene.data.remote
import okhttp3.MultipartBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import top.yeij.cyrene.data.remote.dto.AuthRequest
import top.yeij.cyrene.data.remote.dto.AuthResponse
import top.yeij.cyrene.data.remote.dto.ProfileResponse
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
import top.yeij.cyrene.data.remote.dto.DeviceDto
import top.yeij.cyrene.data.remote.dto.FileUploadResponse
import top.yeij.cyrene.data.remote.dto.IoTControlRequest
import top.yeij.cyrene.data.remote.dto.MessagesListResponse
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
import top.yeij.cyrene.data.remote.dto.SessionDto
import top.yeij.cyrene.data.remote.dto.SessionsListResponse
interface ApiService {
// Auth
@POST("api/v1/auth/login")
suspend fun login(@Body request: AuthRequest): Response<AuthResponse>
@POST("api/v1/auth/refresh")
suspend fun refreshToken(@Body request: RefreshTokenRequest): Response<AuthResponse>
@GET("api/v1/profile")
suspend fun getProfile(): Response<ProfileResponse>
// Sessions
@GET("api/v1/sessions")
suspend fun getSessions(): Response<SessionsListResponse>
@POST("api/v1/sessions")
suspend fun createSession(@Body request: CreateSessionRequest): Response<SessionDto>
@DELETE("api/v1/sessions/{id}")
suspend fun deleteSession(@Path("id") id: String): Response<Unit>
@DELETE("api/v1/sessions/{id}/messages")
suspend fun clearSessionMessages(@Path("id") sessionId: String): Response<Unit>
@GET("api/v1/sessions/{id}/messages")
suspend fun getSessionMessages(
@Path("id") sessionId: String,
@Query("limit") limit: Int = 500,
@Query("offset") offset: Int = 0,
): Response<MessagesListResponse>
// Files
@Multipart
@POST("api/v1/files/upload")
suspend fun uploadFile(
@Part file: MultipartBody.Part,
): Response<FileUploadResponse>
// IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/*
@GET("api/v1/iot/devices")
suspend fun getDevices(): Response<List<DeviceDto>>
@POST("api/v1/iot/devices/{id}/control")
suspend fun controlDevice(
@Path("id") deviceId: String,
@Body request: IoTControlRequest,
): Response<DeviceDto>
}
@@ -0,0 +1,21 @@
package top.yeij.cyrene.data.remote
import okhttp3.Interceptor
import okhttp3.Response
class AuthInterceptor : Interceptor {
@Volatile
var token: String? = null
override fun intercept(chain: Interceptor.Chain): Response {
val request = if (!token.isNullOrEmpty()) {
chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
} else {
chain.request()
}
return chain.proceed(request)
}
}
@@ -0,0 +1,37 @@
package top.yeij.cyrene.data.remote
import android.util.Log
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Response
class DynamicUrlInterceptor : Interceptor {
companion object {
private const val TAG = "DynamicUrlInterceptor"
}
@Volatile
var baseUrl: String = "http://10.0.2.2:8080/"
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val originalUrl = originalRequest.url
val targetBase = baseUrl.trimEnd('/')
val parsed = targetBase.toHttpUrlOrNull()
if (parsed == null) {
Log.e(TAG, "Invalid baseUrl: '$baseUrl'")
return chain.proceed(chain.request())
}
val newUrl = originalUrl.newBuilder()
.scheme(parsed.scheme)
.host(parsed.host)
.port(parsed.port)
.build()
Log.d(TAG, "Rewriting ${originalUrl}$newUrl (base: $targetBase)")
return chain.proceed(originalRequest.newBuilder().url(newUrl).build())
}
}
@@ -0,0 +1,38 @@
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,
tokenAuthenticator: TokenAuthenticator,
): OkHttpClient {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
return OkHttpClient.Builder()
.addInterceptor(dynamicUrlInterceptor)
.addInterceptor(authInterceptor)
.authenticator(tokenAuthenticator)
.addInterceptor(logging)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl("http://localhost/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
}
@@ -0,0 +1,84 @@
package top.yeij.cyrene.data.remote
import com.google.gson.Gson
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Route
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.dto.AuthResponse
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
import android.util.Log
class TokenAuthenticator(
private val authInterceptor: AuthInterceptor,
private val preferencesDataStore: PreferencesDataStore,
private val dynamicUrlInterceptor: DynamicUrlInterceptor,
) : Authenticator {
private val gson = Gson()
private val client = OkHttpClient()
override fun authenticate(route: Route?, response: okhttp3.Response): Request? {
if (response.code != 401) return null
synchronized(this) {
// Double-check: if token changed while we were waiting for lock, use it
val currentToken = authInterceptor.token
val requestAuthHeader = response.request.header("Authorization")
if (requestAuthHeader != null && currentToken != null &&
requestAuthHeader != "Bearer $currentToken"
) {
return response.request.newBuilder()
.header("Authorization", "Bearer $currentToken")
.build()
}
// Try refresh
return try {
val refreshToken = runBlocking { preferencesDataStore.refreshToken.firstOrNull() }
?: return null
val baseUrl = dynamicUrlInterceptor.baseUrl.trimEnd('/')
val refreshUrl = "$baseUrl/api/v1/auth/refresh"
val jsonBody = gson.toJson(RefreshTokenRequest(refreshToken))
val body = jsonBody.toRequestBody("application/json".toMediaType())
val refreshRequest = Request.Builder()
.url(refreshUrl)
.post(body)
.build()
val refreshResponse = client.newCall(refreshRequest).execute()
if (refreshResponse.isSuccessful) {
val authResponse = gson.fromJson(
refreshResponse.body?.string(),
AuthResponse::class.java
)
authInterceptor.token = authResponse.token
runBlocking {
preferencesDataStore.saveToken(authResponse.token)
authResponse.refreshToken?.let {
preferencesDataStore.saveRefreshToken(it)
}
}
Log.i("TokenAuthenticator", "Token refreshed successfully")
response.request.newBuilder()
.header("Authorization", "Bearer ${authResponse.token}")
.build()
} else {
Log.w("TokenAuthenticator", "Token refresh failed: ${refreshResponse.code}")
null
}
} catch (e: Exception) {
Log.e("TokenAuthenticator", "Token refresh error: ${e.message}", e)
null
}
}
}
}
@@ -0,0 +1,29 @@
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("nickname") val nickname: String?,
@SerializedName("user_id") val userId: String?,
@SerializedName("expires") val expires: Long? = null,
)
data class RefreshTokenRequest(
@SerializedName("refresh_token") val refreshToken: String,
)
data class ProfileResponse(
@SerializedName("user_id") val userId: String,
@SerializedName("username") val username: String,
@SerializedName("nickname") val nickname: String?,
@SerializedName("is_admin") val isAdmin: Boolean? = false,
@SerializedName("created_at") val createdAt: String? = null,
)
@@ -0,0 +1,26 @@
package top.yeij.cyrene.data.remote.dto
import com.google.gson.annotations.SerializedName
data class DeviceDto(
@SerializedName("id") val id: String,
@SerializedName("name") val name: String,
@SerializedName("type") val type: String,
@SerializedName("state") val state: DeviceState,
@SerializedName("room") val room: String?,
)
data class DeviceState(
@SerializedName("power") val power: Boolean?,
@SerializedName("brightness") val brightness: Int?,
@SerializedName("temperature") val temperature: Float?,
@SerializedName("humidity") val humidity: Float?,
@SerializedName("colorTemp") val colorTemp: Int?,
@SerializedName("locked") val locked: Boolean?,
@SerializedName("open") val open: Boolean?,
)
data class IoTControlRequest(
@SerializedName("action") val action: String,
@SerializedName("value") val value: Any? = null,
)
@@ -0,0 +1,47 @@
package top.yeij.cyrene.data.remote.dto
import com.google.gson.annotations.SerializedName
// POST /api/v1/sessions — request body
data class CreateSessionRequest(
@SerializedName("session_id") val sessionId: String? = null,
@SerializedName("title") val title: String = "新的对话",
@SerializedName("is_main") val isMain: Boolean = false,
)
// GET /api/v1/sessions — response wrapper
data class SessionsListResponse(
@SerializedName("sessions") val sessions: List<SessionDto>,
)
data class SessionDto(
@SerializedName("id") val id: String,
@SerializedName("user_id") val userId: String?,
@SerializedName("title") val title: String,
@SerializedName("is_main") val isMain: Boolean?,
@SerializedName("created_at") val createdAt: Long,
@SerializedName("updated_at") val updatedAt: Long,
)
// GET /api/v1/sessions/{id}/messages — response wrapper
data class MessagesListResponse(
@SerializedName("messages") val messages: List<SessionMessageDto>,
)
data class SessionMessageDto(
@SerializedName("id") val id: String,
@SerializedName("session_id") val sessionId: String,
@SerializedName("role") val role: String,
@SerializedName("msg_type") val msgType: String?,
@SerializedName("content") val content: String,
@SerializedName("created_at") val createdAt: Long,
)
// POST /api/v1/files/upload — response
data class FileUploadResponse(
@SerializedName("id") val id: String,
@SerializedName("filename") val filename: String? = null,
@SerializedName("mime_type") val mimeType: String? = null,
@SerializedName("size") val size: Long? = null,
@SerializedName("url") val url: String? = null,
)
@@ -0,0 +1,94 @@
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("audio_data") val audioData: String? = null,
@SerializedName("attachments") val attachments: List<WSAttachment>? = 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,
)
data class WSAttachment(
@SerializedName("type") val type: String,
@SerializedName("url") val url: String? = null,
@SerializedName("file_id") val fileId: String? = null,
@SerializedName("thumbnail_url") val thumbnailUrl: String? = null,
@SerializedName("filename") val filename: String? = null,
@SerializedName("width") val width: Int? = null,
@SerializedName("height") val height: Int? = null,
@SerializedName("size") val size: Long? = null,
@SerializedName("description") val description: String? = null,
)
// --- Server → Client ---
data class WSClientInfo(
@SerializedName("client_id") val clientId: String? = null,
@SerializedName("device_name") val deviceName: String? = null,
)
data class WSServerMessage(
@SerializedName("type") val type: String?,
@SerializedName("message_id") val messageId: String? = null,
@SerializedName("text") val text: String? = null,
@SerializedName("content") val content: String? = null,
@SerializedName("role") val role: String? = null,
@SerializedName("msg_type") val msgType: String? = null,
@SerializedName("session_id") val sessionId: String? = null,
@SerializedName("error") val error: String? = null,
@SerializedName("review_messages") val reviewMessages: List<WSReviewMessage>? = null,
@SerializedName("messages") val messages: List<WSHistoryMessage>? = null,
@SerializedName("multi_message") val multiMessages: List<WSMultiItem>? = null,
@SerializedName("tool_progress") val toolProgress: WSToolProgress? = null,
@SerializedName("thinking_status") val thinkingStatus: String? = null,
@SerializedName("thinking_content") val thinkingContent: String? = null,
@SerializedName("timestamp") val timestamp: Long? = null,
@SerializedName("client_info") val clientInfo: WSClientInfo? = null,
)
data class WSReviewMessage(
@SerializedName("type") val type: String? = null,
@SerializedName("role") val role: String? = null,
@SerializedName("text") val text: String? = null,
@SerializedName("content") val content: String? = null,
@SerializedName("msg_type") val msgType: String? = null,
@SerializedName("delay_ms") val delayMs: Long? = 0,
@SerializedName("metadata") val metadata: WSReviewMetadata? = null,
)
data class WSReviewMetadata(
@SerializedName("language") val language: String? = null,
@SerializedName("url") val url: String? = null,
)
data class WSHistoryMessage(
@SerializedName("id") val id: String?,
@SerializedName("role") val role: String?,
@SerializedName("content") val content: String?,
@SerializedName("msg_type") val msgType: String?,
@SerializedName("timestamp") val timestamp: Long?,
@SerializedName("name") val name: String?,
)
data class WSMultiItem(
@SerializedName("role") val role: String?,
@SerializedName("content") val content: String?,
@SerializedName("msg_type") val msgType: String?,
)
data class WSToolProgress(
@SerializedName("tool_name") val toolName: String?,
@SerializedName("status") val status: String?,
@SerializedName("detail") val detail: String?,
@SerializedName("message") val message: String?,
@SerializedName("progress") val progress: Int? = null,
)
@@ -0,0 +1,50 @@
package top.yeij.cyrene.data.repository
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.data.remote.AuthInterceptor
import top.yeij.cyrene.data.remote.dto.AuthRequest
import top.yeij.cyrene.domain.model.AuthResult
import top.yeij.cyrene.domain.repository.AuthRepository
import kotlinx.coroutines.flow.firstOrNull
class AuthRepositoryImpl(
private val apiService: ApiService,
private val preferencesDataStore: PreferencesDataStore,
private val authInterceptor: AuthInterceptor,
) : AuthRepository {
override suspend fun login(username: String, password: String): Result<AuthResult> {
return try {
val response = apiService.login(AuthRequest(username, password))
if (response.isSuccessful) {
val body = response.body()!!
authInterceptor.token = body.token
preferencesDataStore.saveToken(body.token)
body.refreshToken?.let { preferencesDataStore.saveRefreshToken(it) }
val displayName = body.nickname ?: body.username ?: body.userId ?: "开拓者"
preferencesDataStore.saveUsername(displayName)
Result.success(
AuthResult(
token = body.token,
refreshToken = body.refreshToken,
username = displayName,
)
)
} else {
Result.failure(Exception("登录失败: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun logout() {
authInterceptor.token = null
preferencesDataStore.clearAll()
}
override suspend fun isLoggedIn(): Boolean {
return preferencesDataStore.token.firstOrNull() != null
}
}
@@ -0,0 +1,867 @@
package top.yeij.cyrene.data.repository
import android.app.Application
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.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withTimeoutOrNull
import top.yeij.cyrene.data.local.PreferencesDataStore
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.CreateSessionRequest
import top.yeij.cyrene.data.remote.dto.WSAttachment
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.KeepAliveReceiver
import top.yeij.cyrene.service.WebSocketKeepAliveService
import top.yeij.cyrene.service.WebSocketService
import top.yeij.cyrene.util.NotificationHelper
import top.yeij.cyrene.util.RuntimeLog
import java.util.UUID
class ChatRepositoryImpl(
private val app: Application,
private val conversationDao: ConversationDao,
private val messageDao: MessageDao,
private val webSocketService: WebSocketService,
private val apiService: ApiService,
private val preferencesDataStore: PreferencesDataStore,
private val notificationHelper: NotificationHelper,
) : ChatRepository {
private val exceptionHandler = CoroutineExceptionHandler { _, e ->
Log.e("ChatRepository", "Unhandled exception", e)
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)
private val _connectionState = MutableStateFlow(false)
override val connectionState: StateFlow<Boolean> = _connectionState.asStateFlow()
override val connectionError: StateFlow<String?> = webSocketService.connectionError
private val _incomingMessages = MutableSharedFlow<Message>(extraBufferCapacity = 64)
override fun observeMessages(): Flow<Message> = _incomingMessages
private val _messageClearEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
override val messageClearEvents: Flow<Unit> = _messageClearEvents
private val _messageRemovals = MutableSharedFlow<String>(extraBufferCapacity = 16)
override val messageRemovals: Flow<String> = _messageRemovals
private val _isAssistantStreaming = MutableStateFlow(false)
override val isAssistantStreaming: StateFlow<Boolean> = _isAssistantStreaming.asStateFlow()
private var streamingContent = ""
private var streamingMessageId: String? = null
private var streamTimeoutJob: kotlinx.coroutines.Job? = null
override var currentSessionId: String? = null
private var isAppInForeground = false
private var hasEverBeenForeground = false
private var historyRequested = false
private val notifiedMessageIds = mutableSetOf<String>()
// Duplicate suppression: track items from review/multi_message to skip wrapping response
private val recentParsedContents = mutableListOf<String>()
private var lastParsedTime = 0L
// Track last response to clean up if review/multi_message arrives after
private var lastResponseId: String? = null
private var lastResponseContent: String? = null
private var lastResponseTime = 0L
fun cancelNotifications() {
notificationHelper.cancelAll()
}
private fun resetStreamTimeout() {
cancelStreamTimeout()
streamTimeoutJob = scope.launch {
kotlinx.coroutines.delay(120_000L) // 2 min timeout
if (_isAssistantStreaming.value) {
RuntimeLog.chat("stream", "Stream timeout — no chunk or end for 120s, resetting")
streamingContent = ""
streamingMessageId = null
_isAssistantStreaming.value = false
emitMessage(
id = "timeout_${System.currentTimeMillis()}",
sessionId = currentSessionId ?: "default",
role = "system",
content = "AI 响应超时,请重试",
msgType = "system_info",
isStreaming = false,
)
}
}
}
private fun cancelStreamTimeout() {
streamTimeoutJob?.cancel()
streamTimeoutJob = null
}
override fun onAppForeground() {
RuntimeLog.notify("state", "onAppForeground: wasForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground")
isAppInForeground = true
hasEverBeenForeground = true
notifiedMessageIds.clear()
notificationHelper.cancelAll()
KeepAliveReceiver.cancel(app)
WebSocketKeepAliveService.stop(app)
RuntimeLog.notify("state", "onAppForeground: notifications cleared, keep-alive stopped")
scope.launch {
val sid = currentSessionId ?: return@launch
RuntimeLog.general("app", "Foreground — requesting history for session=$sid")
requestHistoryViaWs(sid)
}
}
override fun onAppBackground() {
isAppInForeground = false
WebSocketKeepAliveService.start(app)
KeepAliveReceiver.schedule(app)
val currentlyConnected = _connectionState.value
RuntimeLog.notify("state", "onAppBackground: connected=$currentlyConnected hasEverBeen=$hasEverBeenForeground keepAliveStarted=true")
// Only reconnect if the WS is already dead. Tearing down a healthy
// connection creates a message loss window with no benefit.
if (!currentlyConnected) {
scope.launch {
kotlinx.coroutines.delay(1500) // let the service start first
webSocketService.forceReconnect()
RuntimeLog.general("app", "Background reconnect done, connected=${_connectionState.value}")
}
} else {
RuntimeLog.general("app", "WS healthy — skipping background reconnect to avoid message loss")
}
}
init {
// Restore persisted session ID, then connect and load history
scope.launch {
val persistedSid = preferencesDataStore.currentSessionId.firstOrNull()
if (!persistedSid.isNullOrBlank()) {
currentSessionId = persistedSid
}
RuntimeLog.ws("init", "Connecting WebSocket session=$currentSessionId")
webSocketService.connect(currentSessionId)
loadConversationsFromServer()
}
scope.launch {
webSocketService.isConnected.collect { connected ->
_connectionState.value = connected
RuntimeLog.ws("connection", "Connected=$connected")
}
}
scope.launch {
webSocketService.incomingMessages.collect { wsMsg ->
try {
RuntimeLog.ws("receive", "type=${wsMsg.type} msgId=${wsMsg.messageId ?: "-"}")
handleServerMessage(wsMsg)
} catch (e: Exception) {
Log.e("ChatRepository", "Error handling ${wsMsg.type}: ${e.message}", e)
RuntimeLog.ws("error", "Handle error type=${wsMsg.type}: ${e.message}")
}
}
}
}
override fun getConversations(): Flow<List<Conversation>> {
return conversationDao.getAll().map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun getMessages(conversationId: String): Flow<List<Message>> {
return messageDao.getByConversation(conversationId).map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun deleteConversation(id: String) {
conversationDao.deleteById(id)
try { apiService.deleteSession(id) } catch (_: Exception) { }
}
override suspend fun clearLocalMessages() {
val now = System.currentTimeMillis()
messageDao.deleteAll()
preferencesDataStore.saveLastClearedTimestamp(now)
_messageClearEvents.tryEmit(Unit)
RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now")
Log.i("ChatRepository", "Local messages cleared, timestamp: $now")
}
private suspend fun changeSessionId(newId: String) {
val oldId = currentSessionId
if (oldId != null && oldId != newId) {
messageDao.migrateConversationId(oldId, newId)
conversationDao.deleteById(oldId)
}
currentSessionId = newId
preferencesDataStore.saveCurrentSessionId(newId)
}
override suspend fun connectWebSocket(sessionId: String?) {
currentSessionId = sessionId
webSocketService.connect(sessionId)
}
override suspend fun reconnectWebSocket() {
webSocketService.disconnect()
webSocketService.connect(currentSessionId)
}
override suspend fun ensureConnected() {
// Always force reconnect — connectionState may be stuck at true on a silently dead socket
webSocketService.forceReconnect()
}
override suspend fun sendMessage(content: String, sessionId: String?, attachments: List<WSAttachment>?, localImageUris: List<String>) {
val messageId = UUID.randomUUID().toString()
val now = System.currentTimeMillis()
val sid = sessionId ?: currentSessionId ?: "default"
if (currentSessionId == null) {
currentSessionId = sid
scope.launch { preferencesDataStore.saveCurrentSessionId(sid) }
}
val hasImages = localImageUris.isNotEmpty()
val displayContent = content.ifBlank { "" }
val lastMsg = when {
hasImages && content.isBlank() -> "[图片]"
hasImages -> content
else -> content
}
RuntimeLog.chat("send", "session=$sid msgId=$messageId content=${content.take(80)} attachments=${attachments?.size ?: 0}")
conversationDao.upsert(
ConversationEntity(
id = sid,
title = "对话",
lastMessage = lastMsg,
lastMessageType = "chat",
updatedAt = now,
createdAt = now,
)
)
messageDao.upsert(
MessageEntity(
id = messageId,
conversationId = sid,
role = "user",
content = displayContent,
msgType = "chat",
timestamp = now,
)
)
emitMessage(
id = messageId,
sessionId = sid,
role = "user",
content = displayContent,
msgType = "chat",
timestamp = now,
isStreaming = false,
imageDataUris = localImageUris,
)
webSocketService.sendMessage(content, sid, attachments = attachments)
}
override suspend fun loadConversationsFromServer() {
try {
val response = apiService.getSessions()
if (response.isSuccessful) {
response.body()?.sessions?.forEach { dto ->
conversationDao.upsert(
ConversationEntity(
id = dto.id,
title = dto.title,
lastMessage = "",
lastMessageType = "chat",
updatedAt = dto.updatedAt,
createdAt = dto.createdAt,
)
)
}
}
} catch (_: Exception) { }
}
override suspend fun sendScreenContext(content: String) {
webSocketService.sendScreenContext(content, currentSessionId)
}
override suspend fun sendVoiceInput(audioBase64: String, mode: String) {
webSocketService.sendVoiceInput(audioBase64, currentSessionId, mode)
}
override suspend fun initializeSession(): String {
// Try to find an existing main session on the server
try {
val response = apiService.getSessions()
if (response.isSuccessful) {
val sessions = response.body()?.sessions ?: emptyList()
val mainSession = sessions.find { it.isMain == true }
if (mainSession != null) {
currentSessionId = mainSession.id
preferencesDataStore.saveCurrentSessionId(mainSession.id)
Log.i("ChatRepository", "Found main session: ${mainSession.id}")
return mainSession.id
}
}
} catch (_: Exception) { }
// No main session found on server, create one with deterministic ID
val sessionId = "session_admin_main"
try {
apiService.createSession(
CreateSessionRequest(
sessionId = sessionId,
title = "主对话",
isMain = true,
)
)
Log.i("ChatRepository", "Created main session: $sessionId")
} catch (e: Exception) {
Log.w("ChatRepository", "Failed to create main session: ${e.message}")
}
currentSessionId = sessionId
preferencesDataStore.saveCurrentSessionId(sessionId)
return sessionId
}
override suspend fun loadMessagesFromServer(sessionId: String): List<Message> {
currentSessionId = sessionId
return try {
val response = apiService.getSessionMessages(sessionId)
if (response.isSuccessful) {
val messageDtos = response.body()?.messages ?: emptyList()
val lastCleared = preferencesDataStore.lastClearedTimestamp.firstOrNull()
?.toLongOrNull() ?: 0L
val filteredDtos = messageDtos.filter { it.createdAt > lastCleared }
ensureConversation(sessionId)
val messages = filteredDtos.map { dto ->
Message(
id = "${dto.id}",
conversationId = sessionId,
role = dto.role,
content = dto.content,
msgType = dto.msgType ?: "chat",
timestamp = dto.createdAt,
)
}
val deduped = messages.removeWrappingDuplicates().splitInlineActions()
messageDao.deleteUserMessagesByConversation(sessionId)
messageDao.upsertAll(deduped.map { msg ->
MessageEntity(
id = msg.id,
conversationId = msg.conversationId,
role = msg.role,
content = msg.content,
msgType = msg.msgType,
timestamp = msg.timestamp,
)
})
RuntimeLog.http("loadMessages", "HTTP loaded ${deduped.size} messages (${messages.size} before dedup) for session=$sessionId")
deduped
} else {
RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS")
requestHistoryViaWs(sessionId)
emptyList()
}
} catch (e: Exception) {
RuntimeLog.http("loadMessages", "HTTP error: ${e.message}, trying WS")
requestHistoryViaWs(sessionId)
emptyList()
}
}
private suspend fun requestHistoryViaWs(sessionId: String) {
if (!webSocketService.isConnected.value) {
val connected = withTimeoutOrNull(5000) {
webSocketService.isConnected.first { it }
}
if (connected != true) {
// WS couldn't connect, fall back to REST API
RuntimeLog.chat("history", "WS not connected after 5s, falling back to REST")
loadMessagesFromServer(sessionId)
return
}
}
webSocketService.requestHistory(sessionId)
}
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()}"
_isAssistantStreaming.value = true
recentParsedContents.clear()
resetStreamTimeout()
RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
}
"stream_chunk" -> {
val delta = wsMsg.content ?: wsMsg.text ?: return
streamingContent += delta
resetStreamTimeout()
emitMessage(
id = streamingMessageId ?: "s_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "assistant",
content = streamingContent,
msgType = wsMsg.msgType ?: "chat",
isStreaming = true,
)
}
"stream_end" -> {
cancelStreamTimeout()
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"
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
changeSessionId(sid)
}
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
// Dedup: suppress if streaming content wraps already-shown multi_message/review items
val timeSinceParsed = System.currentTimeMillis() - lastParsedTime
if (timeSinceParsed < 3000 && recentParsedContents.isNotEmpty()) {
val allContained = recentParsedContents.all { content.contains(it) }
if (allContained) {
RuntimeLog.chat("dedup", "Suppressed stream_end wrapping, ${recentParsedContents.size} items already shown")
recentParsedContents.clear()
_isAssistantStreaming.value = false
return
}
}
recentParsedContents.clear()
if (content.isNotBlank()) {
ensureConversation(sid, content)
messageDao.upsert(
MessageEntity(
id = msgId,
conversationId = sid,
role = "assistant",
content = content,
msgType = wsMsg.msgType ?: "chat",
timestamp = ts,
)
)
}
lastResponseId = msgId
lastResponseContent = content
lastResponseTime = System.currentTimeMillis()
RuntimeLog.notify("trigger", "stream_end: id=$msgId isForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground content='${content.take(50)}'")
emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = wsMsg.msgType ?: "chat", timestamp = ts, isStreaming = false, shouldNotify = true)
_isAssistantStreaming.value = 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"
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
changeSessionId(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,
)
)
lastResponseId = msgId
lastResponseContent = text
lastResponseTime = System.currentTimeMillis()
// Track parsed content so stream_end can suppress the wrapping full text
recentParsedContents.add(text)
lastParsedTime = System.currentTimeMillis()
RuntimeLog.notify("trigger", "reply: id=$msgId role=$role isForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground content='${text.take(50)}'")
emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false, shouldNotify = true)
}
"review" -> {
recentParsedContents.clear()
wsMsg.reviewMessages?.forEachIndexed { index, review ->
if (index > 0) delay(1000L)
val rawText = review.content ?: review.text ?: return@forEachIndexed
val role = review.role ?: "assistant"
val rvMsgType = review.type ?: review.msgType ?: "action"
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
// Encode code language metadata into content for the renderer
val content = if (rvMsgType == "code" && review.metadata?.language != null) {
"[lang:${review.metadata.language}]\n$rawText"
} else rawText
recentParsedContents.add(rawText)
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = content, msgType = rvMsgType, isStreaming = false)
}
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
// Clean up wrapping response that arrived before this review
cleanupWrappingResponse()
}
"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 = wsMsg.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 = wsMsg.msgType ?: "tool_progress",
isStreaming = false,
)
}
"queued" -> {
emitMessage(
id = "queued_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "system",
content = "消息已加入处理队列",
msgType = "system_info",
isStreaming = false,
)
}
"error" -> {
cancelStreamTimeout()
streamingContent = ""
streamingMessageId = null
_isAssistantStreaming.value = false
RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}")
emitMessage(
id = "err_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "system",
content = wsMsg.error ?: "未知错误",
msgType = wsMsg.msgType ?: "system_info",
isStreaming = false,
)
}
"voice_transcript" -> {
val text = wsMsg.text ?: wsMsg.content ?: return
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
val msgId = wsMsg.messageId ?: "vt_${System.currentTimeMillis()}"
ensureConversation(sid)
messageDao.upsert(
MessageEntity(
id = msgId,
conversationId = sid,
role = "user",
content = text,
msgType = wsMsg.msgType ?: "chat",
timestamp = ts,
)
)
emitMessage(id = msgId, sessionId = sid, role = "user", content = text, msgType = wsMsg.msgType ?: "chat", timestamp = ts, isStreaming = false)
}
"history_response" -> {
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
changeSessionId(sid)
}
ensureConversation(sid)
val messages = wsMsg.messages ?: return
val messageList = messages.map { hist ->
val msgId = hist.id ?: "hist_${System.currentTimeMillis()}_${hist.hashCode()}"
Message(
id = msgId,
conversationId = sid,
role = hist.role ?: "system",
content = hist.content ?: "",
msgType = hist.msgType ?: "chat",
timestamp = hist.timestamp ?: System.currentTimeMillis(),
)
}
val deduped = messageList.removeWrappingDuplicates().splitInlineActions()
messageDao.upsertAll(deduped.map { msg ->
MessageEntity(
id = msg.id,
conversationId = msg.conversationId,
role = msg.role,
content = msg.content,
msgType = msg.msgType,
timestamp = msg.timestamp,
)
})
deduped.forEach { msg ->
emitMessage(
id = msg.id,
sessionId = msg.conversationId,
role = msg.role,
content = msg.content,
msgType = msg.msgType,
timestamp = msg.timestamp,
isStreaming = false,
shouldNotify = false,
)
}
RuntimeLog.chat("history", "Loaded ${deduped.size} messages from server history")
}
"multi_message" -> {
recentParsedContents.clear()
var isFirst = true
wsMsg.multiMessages?.forEachIndexed { index, item ->
if (index > 0) delay(1000L)
val content = item.content ?: ""
recentParsedContents.add(content)
emitMessage(
id = "mm_${System.currentTimeMillis()}_${item.hashCode()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = item.role ?: "assistant",
content = content,
msgType = item.msgType ?: "chat",
timestamp = wsMsg.timestamp ?: System.currentTimeMillis(),
isStreaming = false,
shouldNotify = isFirst,
)
isFirst = false
}
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
cleanupWrappingResponse()
}
}
}
private suspend fun cleanupWrappingResponse() {
val respId = lastResponseId ?: return
val respContent = lastResponseContent ?: return
val timeSinceResponse = System.currentTimeMillis() - lastResponseTime
if (timeSinceResponse > 5000 || recentParsedContents.size < 2) return
val allContained = recentParsedContents.all { respContent.contains(it) }
if (allContained) {
messageDao.deleteById(respId)
_messageRemovals.tryEmit(respId)
RuntimeLog.chat("dedup", "Cleaned up wrapping response from DB and live state id=$respId")
}
}
private fun emitMessage(
id: String,
sessionId: String,
role: String,
content: String,
msgType: String,
isStreaming: Boolean = false,
timestamp: Long = System.currentTimeMillis(),
shouldNotify: Boolean = false,
imageDataUris: List<String> = emptyList(),
) {
if (content.isBlank() && msgType == "chat" && imageDataUris.isEmpty()) return
// Fallback: detect inline <action> tags missed by server parsing
if (role == "assistant" && msgType == "chat") {
val actionRegex = Regex("""<action>(.*?)</action>\s*""")
val match = actionRegex.find(content)
if (match != null) {
val actionText = match.groupValues[1].trim()
val remaining = actionRegex.replaceFirst(content, "").trim()
RuntimeLog.chat("receive", "Split inline <action> from chat: action='${actionText.take(40)}' remaining='${remaining.take(40)}'")
if (actionText.isNotEmpty()) {
emitMessage(
id = "${id}_action",
sessionId = sessionId,
role = "assistant",
content = actionText,
msgType = "action",
timestamp = timestamp,
shouldNotify = false,
)
}
if (remaining.isNotEmpty()) {
emitMessage(
id = id,
sessionId = sessionId,
role = role,
content = remaining,
msgType = msgType,
timestamp = timestamp + 1,
isStreaming = isStreaming,
shouldNotify = shouldNotify,
)
}
return
}
}
val message = Message(
id = id,
conversationId = sessionId,
role = role,
content = content,
msgType = msgType,
timestamp = timestamp,
isStreaming = isStreaming,
imageDataUris = imageDataUris,
)
_incomingMessages.tryEmit(message)
if (shouldNotify && role == "assistant" && !isStreaming) {
if (!hasEverBeenForeground) {
RuntimeLog.notify("skip", "Not showing notification for $id: app has never been foregrounded")
} else if (isAppInForeground) {
RuntimeLog.notify("skip", "Not showing notification for $id: app is in foreground")
} else {
if (notifiedMessageIds.add(id)) {
notificationHelper.showMessageNotification(message)
RuntimeLog.notify("show", "Notification sent: id=$id content='${content.take(40)}'")
} else {
RuntimeLog.notify("dup", "Notification already sent for $id, skipping")
}
}
} else if (shouldNotify && role == "assistant" && isStreaming) {
RuntimeLog.notify("skip", "Not showing notification for $id: still streaming")
}
}
/**
* Remove wrapper messages whose content contains the content of 2+ other messages.
* This handles the case where the server sends both a combined "response" and
* parsed "review"/"multi_message" items for the same turn.
*/
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
if (size < 3) return this
val toRemove = mutableSetOf<String>()
for (msg in this) {
val containedCount = count { other ->
other.id != msg.id &&
other.content.isNotBlank() &&
other.content.length < msg.content.length &&
msg.content.contains(other.content) &&
kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
}
if (containedCount >= 2) {
toRemove.add(msg.id)
}
}
return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
}
private fun ConversationEntity.toDomain() = Conversation(
id = id,
title = title,
lastMessage = lastMessage,
lastMessageType = lastMessageType,
updatedAt = updatedAt,
createdAt = createdAt,
)
/**
* Split inline `<action>` tags from assistant chat messages into separate messages.
* Used for bulk-loaded messages (HTTP history, WS history_response) that bypass emitMessage.
*/
private fun List<Message>.splitInlineActions(): List<Message> {
val actionRegex = Regex("""<action>(.*?)</action>\s*""")
return flatMap { msg ->
if (msg.role == "assistant" && msg.msgType == "chat") {
val match = actionRegex.find(msg.content)
if (match != null) {
val actionText = match.groupValues[1].trim()
val remaining = actionRegex.replaceFirst(msg.content, "").trim()
val result = mutableListOf<Message>()
if (actionText.isNotEmpty()) {
result.add(msg.copy(id = "${msg.id}_action", content = actionText, msgType = "action"))
}
if (remaining.isNotEmpty()) {
result.add(msg.copy(content = remaining))
}
if (result.isEmpty()) listOf(msg) else result
} else {
listOf(msg)
}
} else {
listOf(msg)
}
}
}
private fun MessageEntity.toDomain() = Message(
id = id,
conversationId = conversationId,
role = role,
content = content,
msgType = msgType,
timestamp = timestamp,
)
}
@@ -0,0 +1,70 @@
package top.yeij.cyrene.data.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.data.remote.dto.IoTControlRequest
import top.yeij.cyrene.domain.model.Device
import top.yeij.cyrene.domain.model.DeviceState
import top.yeij.cyrene.domain.model.DeviceType
import top.yeij.cyrene.domain.repository.IoTRepository
class IoTRepositoryImpl(
private val apiService: ApiService,
private val webSocketService: top.yeij.cyrene.service.WebSocketService,
) : IoTRepository {
private val _devices = MutableStateFlow<List<Device>>(emptyList())
override fun getDevices(): Flow<List<Device>> = _devices.asStateFlow()
override suspend fun controlDevice(
deviceId: String,
action: String,
value: Any?,
): Result<Device> {
return try {
val response = apiService.controlDevice(deviceId, IoTControlRequest(action, value))
if (response.isSuccessful) {
val updated = response.body()!!.toDomain()
_devices.value = _devices.value.map {
if (it.id == deviceId) updated else it
}
Result.success(updated)
} else {
Result.failure(Exception("设备控制失败: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun refreshDevices() {
try {
val response = apiService.getDevices()
if (response.isSuccessful) {
_devices.value = response.body()!!.map { it.toDomain() }
}
} catch (_: Exception) { }
}
private fun top.yeij.cyrene.data.remote.dto.DeviceDto.toDomain() = Device(
id = id,
name = name,
type = try {
DeviceType.valueOf(type.uppercase())
} catch (_: Exception) {
DeviceType.UNKNOWN
},
state = DeviceState(
power = state.power,
brightness = state.brightness,
temperature = state.temperature,
humidity = state.humidity,
colorTemp = state.colorTemp,
locked = state.locked,
open = state.open,
),
room = room,
)
}
@@ -0,0 +1,84 @@
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.remote.TokenAuthenticator
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.ProfileViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel
import top.yeij.cyrene.util.NotificationHelper
import top.yeij.cyrene.util.VoiceRecorder
import top.yeij.cyrene.voice.stt.BackendSttProvider
import top.yeij.cyrene.voice.stt.DashScopeSttService
import top.yeij.cyrene.voice.stt.SpeechRecognizer
import top.yeij.cyrene.voice.stt.SttManager
import top.yeij.cyrene.voice.tts.TextToSpeechEngine
val appModule = module {
// Notifications
single { NotificationHelper(androidContext()) }
// DataStore
single { PreferencesDataStore(androidContext()) }
// Database
single { AppDatabase.getInstance(androidContext()) }
single { get<AppDatabase>().conversationDao() }
single { get<AppDatabase>().messageDao() }
// Network interceptors
single { AuthInterceptor() }
single { DynamicUrlInterceptor() }
single { TokenAuthenticator(get(), get(), get()) }
single { RetrofitClient.provideOkHttpClient(get(), get(), get()) }
single { RetrofitClient.provideRetrofit(get()) }
single { get<retrofit2.Retrofit>().create(ApiService::class.java) }
// WebSocket
single { WebSocketService(get()) }
// Voice
single { VoiceRecorder(androidContext()) }
single { SpeechRecognizer(androidContext()) }
single { TextToSpeechEngine(androidContext()) }
single { DashScopeSttService(androidContext()) }
single { BackendSttProvider(androidContext(), get()) }
single { SttManager(get(), get(), get()) }
// Repositories
single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) }
single<ChatRepository> { ChatRepositoryImpl(androidContext() as android.app.Application, get(), get(), get(), get(), get(), get()) }
single<IoTRepository> { IoTRepositoryImpl(get(), get()) }
// UseCases
factory { LoginUseCase(get()) }
factory { SendMessageUseCase(get()) }
factory { GetConversationsUseCase(get()) }
// ViewModels
viewModel { ChatViewModel(androidContext() as android.app.Application, get(), get(), get(), get()) }
viewModel { IoTViewModel(get()) }
viewModel { OverlayViewModel(get(), get(), get()) }
viewModel { ProfileViewModel(get(), get(), get()) }
single { SettingsViewModel(get(), get(), get(), get(), get()) }
}
@@ -0,0 +1,7 @@
package top.yeij.cyrene.domain.model
data class AuthResult(
val token: String,
val refreshToken: String?,
val username: String,
)
@@ -0,0 +1,10 @@
package top.yeij.cyrene.domain.model
data class Conversation(
val id: String,
val title: String,
val lastMessage: String?,
val lastMessageType: String?,
val updatedAt: Long,
val createdAt: Long,
)
@@ -0,0 +1,28 @@
package top.yeij.cyrene.domain.model
data class Device(
val id: String,
val name: String,
val type: DeviceType,
val state: DeviceState,
val room: String?,
)
enum class DeviceType {
LIGHT,
AC,
CURTAIN,
SENSOR,
DOOR_LOCK,
UNKNOWN,
}
data class DeviceState(
val power: Boolean? = null,
val brightness: Int? = null,
val temperature: Float? = null,
val humidity: Float? = null,
val colorTemp: Int? = null,
val locked: Boolean? = null,
val open: Boolean? = null,
)
@@ -0,0 +1,12 @@
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,
val imageDataUris: List<String> = emptyList(),
)
@@ -0,0 +1,12 @@
package top.yeij.cyrene.domain.repository
import top.yeij.cyrene.domain.model.AuthResult
interface AuthRepository {
suspend fun login(username: String, password: String): Result<AuthResult>
suspend fun logout()
suspend fun isLoggedIn(): Boolean
}
@@ -0,0 +1,47 @@
package top.yeij.cyrene.domain.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.model.Message
interface ChatRepository {
val connectionState: StateFlow<Boolean>
val connectionError: StateFlow<String?>
val isAssistantStreaming: StateFlow<Boolean>
val messageClearEvents: Flow<Unit>
val messageRemovals: Flow<String>
var currentSessionId: String?
fun getConversations(): Flow<List<Conversation>>
suspend fun getMessages(conversationId: String): Flow<List<Message>>
suspend fun deleteConversation(id: String)
suspend fun connectWebSocket(sessionId: String?)
suspend fun sendMessage(content: String, sessionId: String?, attachments: List<top.yeij.cyrene.data.remote.dto.WSAttachment>? = null, localImageUris: List<String> = emptyList())
fun observeMessages(): Flow<Message>
suspend fun loadConversationsFromServer()
suspend fun loadMessagesFromServer(sessionId: String): List<Message>
suspend fun initializeSession(): String
suspend fun reconnectWebSocket()
suspend fun ensureConnected()
suspend fun sendScreenContext(content: String)
suspend fun sendVoiceInput(audioBase64: String, mode: String = "voice_msg")
suspend fun clearLocalMessages()
fun onAppForeground()
fun onAppBackground()
}
@@ -0,0 +1,13 @@
package top.yeij.cyrene.domain.repository
import kotlinx.coroutines.flow.Flow
import top.yeij.cyrene.domain.model.Device
interface IoTRepository {
fun getDevices(): Flow<List<Device>>
suspend fun controlDevice(deviceId: String, action: String, value: Any? = null): Result<Device>
suspend fun refreshDevices()
}
@@ -0,0 +1,11 @@
package top.yeij.cyrene.domain.usecase
import kotlinx.coroutines.flow.Flow
import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.repository.ChatRepository
class GetConversationsUseCase(
private val chatRepository: ChatRepository,
) {
operator fun invoke(): Flow<List<Conversation>> = chatRepository.getConversations()
}
@@ -0,0 +1,15 @@
package top.yeij.cyrene.domain.usecase
import top.yeij.cyrene.domain.model.AuthResult
import top.yeij.cyrene.domain.repository.AuthRepository
class LoginUseCase(
private val authRepository: AuthRepository,
) {
suspend operator fun invoke(username: String, password: String): Result<AuthResult> {
if (username.isBlank() || password.isBlank()) {
return Result.failure(IllegalArgumentException("用户名和密码不能为空"))
}
return authRepository.login(username, password)
}
}
@@ -0,0 +1,13 @@
package top.yeij.cyrene.domain.usecase
import top.yeij.cyrene.domain.repository.ChatRepository
class SendMessageUseCase(
private val chatRepository: ChatRepository,
) {
suspend operator fun invoke(content: String, sessionId: String? = null) {
if (content.isNotBlank()) {
chatRepository.sendMessage(content, sessionId)
}
}
}
@@ -0,0 +1,59 @@
package top.yeij.cyrene.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.koin.core.context.GlobalContext
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
class BootReceiver : BroadcastReceiver() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
Log.i(TAG, "Boot completed, restoring background connection")
scope.launch {
try {
// Wait a moment for Koin to initialize
kotlinx.coroutines.delay(5000)
val prefs: PreferencesDataStore = GlobalContext.get().get()
val token = prefs.token.firstOrNull()
if (token.isNullOrBlank()) {
Log.i(TAG, "No auth token, skipping auto-connect")
return@launch
}
// Start keep-alive service
WebSocketKeepAliveService.start(context)
// Always reconnect — session may be stale
val repo: ChatRepositoryImpl = GlobalContext.get().get()
repo.ensureConnected()
Log.i(TAG, "Boot connection restored, connected=${repo.connectionState.value}")
// Schedule periodic wake-up
KeepAliveReceiver.schedule(context)
Log.i(TAG, "Background connection restored")
} catch (e: Throwable) {
Log.e(TAG, "Failed to restore connection on boot: ${e.message}", e)
// Fallback: still try to start the service
try { WebSocketKeepAliveService.start(context) } catch (_: Exception) { }
}
}
}
companion object {
private const val TAG = "CyreneBoot"
}
}
@@ -0,0 +1,67 @@
package top.yeij.cyrene.service
import android.accessibilityservice.AccessibilityService
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
class CyreneAccessibilityService : AccessibilityService() {
companion object {
@Volatile
private var instance: CyreneAccessibilityService? = null
fun getScreenContent(): String {
return instance?.captureScreenContent() ?: ""
}
fun isRunning(): Boolean = instance != null
}
override fun onServiceConnected() {
super.onServiceConnected()
instance = this
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {}
override fun onInterrupt() {}
override fun onDestroy() {
instance = null
super.onDestroy()
}
private fun captureScreenContent(): String {
val root = rootInActiveWindow ?: return ""
return try {
val sb = StringBuilder()
collectNodeText(root, sb, depth = 0)
sb.toString().trim()
} finally {
root.recycle()
}
}
private fun collectNodeText(node: AccessibilityNodeInfo, sb: StringBuilder, depth: Int) {
// Skip the root decor view itself content, and limit depth
if (depth > 16) return
val text = node.text?.toString()?.trim()
val contentDesc = node.contentDescription?.toString()?.trim()
if (!text.isNullOrEmpty() && text != contentDesc) {
if (sb.isNotEmpty()) sb.append("\n")
sb.append(text)
} else if (!contentDesc.isNullOrEmpty() && depth > 0) {
// Only include content descriptions for non-root nodes
if (sb.isNotEmpty()) sb.append("\n")
sb.append(contentDesc)
}
for (i in 0 until node.childCount) {
val child = node.getChild(i) ?: continue
collectNodeText(child, sb, depth + 1)
child.recycle()
}
}
}
@@ -0,0 +1,33 @@
package top.yeij.cyrene.service
import android.service.voice.VoiceInteractionService
import android.util.Log
/**
* Recognition service for always-on hotword detection.
*
* In API 36, AlwaysOnHotwordDetector was removed from the public SDK.
* The system manages low-level hotword detection internally and invokes
* the session service declared in voice_interaction_config.xml when triggered.
* For custom hotword models (Porcupine / openWakeWord), integrate via
* a foreground service with microphone capture instead.
*/
class CyreneRecognitionService : VoiceInteractionService() {
override fun onReady() {
super.onReady()
isActive = true
Log.i(TAG, "Recognition service ready")
}
override fun onShutdown() {
isActive = false
super.onShutdown()
}
companion object {
private const val TAG = "CyreneRecognition"
var isActive: Boolean = false
private set
}
}
@@ -0,0 +1,27 @@
package top.yeij.cyrene.service
import android.content.Intent
import android.os.Bundle
import android.service.voice.VoiceInteractionSessionService
import android.util.Log
class CyreneSessionService : VoiceInteractionSessionService() {
override fun onCreate() {
super.onCreate()
Log.i(TAG, "Session service created")
}
override fun onNewSession(args: Bundle?): CyreneVoiceInteractionSession {
return CyreneVoiceInteractionSession(this)
}
override fun onDestroy() {
super.onDestroy()
Log.i(TAG, "Session service destroyed")
}
companion object {
private const val TAG = "CyreneSession"
}
}
@@ -0,0 +1,60 @@
package top.yeij.cyrene.service
import android.content.Intent
import android.os.Bundle
import android.service.voice.VoiceInteractionService
import android.util.Log
import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.util.Constants
class CyreneVoiceInteractionService : VoiceInteractionService() {
override fun onCreate() {
super.onCreate()
Log.i(TAG, "Service created")
}
override fun onReady() {
super.onReady()
isActive = true
getSharedPreferences(PREF_NAME, MODE_PRIVATE)
.edit().putBoolean(KEY_WAS_ACTIVE, true).apply()
Log.i(TAG, "Service ready")
}
override fun onPrepareToShowSession(args: Bundle, showFlags: Int) {
Log.i(TAG, "onPrepareToShowSession")
}
override fun onShowSessionFailed(args: Bundle) {
Log.e(TAG, "onShowSessionFailed")
}
override fun onLaunchVoiceAssistFromKeyguard() {
Log.i(TAG, "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()
Log.i(TAG, "Service shutdown")
}
companion object {
var isActive: Boolean = false
private set
private const val TAG = "CyreneVIS"
private const val PREF_NAME = "cyrene_assistant"
private const val KEY_WAS_ACTIVE = "was_assistant_active"
fun wasEverActive(context: android.content.Context): Boolean {
return context.getSharedPreferences(PREF_NAME, android.content.Context.MODE_PRIVATE)
.getBoolean(KEY_WAS_ACTIVE, false)
}
}
}
@@ -0,0 +1,178 @@
package top.yeij.cyrene.service
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.service.voice.VoiceInteractionSession
import android.util.Log
import android.view.View
import android.view.WindowManager
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import android.content.res.Configuration
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext
import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.ui.overlay.OverlayContent
import top.yeij.cyrene.ui.theme.CyreneTheme
import top.yeij.cyrene.util.Constants
import top.yeij.cyrene.util.RuntimeLog
import top.yeij.cyrene.viewmodel.OverlayViewModel
class CyreneVoiceInteractionSession(context: Context) :
VoiceInteractionSession(context), LifecycleOwner, SavedStateRegistryOwner {
private val lifecycleRegistry = LifecycleRegistry(this)
override val lifecycle: Lifecycle get() = lifecycleRegistry
private val savedStateRegistryController = SavedStateRegistryController.create(this)
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
// Resolved eagerly with fallback — lazy would silently crash composition on failure
private var overlayViewModel: OverlayViewModel? = null
private set
init {
savedStateRegistryController.performAttach()
savedStateRegistryController.performRestore(null)
}
private fun resolveViewModel(): OverlayViewModel? {
return try {
GlobalContext.get().get<OverlayViewModel>()
} catch (e: Throwable) {
Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e)
null
}
}
override fun onCreateContentView(): View {
Log.d(TAG, "onCreateContentView called")
RuntimeLog.general("overlay", "onCreateContentView")
overlayViewModel = resolveViewModel()
if (overlayViewModel == null) {
Log.w(TAG, "ViewModel unavailable — overlay will be static")
RuntimeLog.general("overlay", "ViewModel unavailable, overlay static")
}
lifecycleRegistry.currentState = Lifecycle.State.CREATED
val vm = overlayViewModel
val session = this@CyreneVoiceInteractionSession
val (darkTheme, themeColorKey) = runBlocking {
val prefs = GlobalContext.get().get<PreferencesDataStore>()
val mode = prefs.themeMode.firstOrNull()
val color = prefs.themeColor.firstOrNull() ?: "pink"
val dark = when (mode) {
"light" -> false
"dark" -> true
else -> {
val nightMode = session.context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK
nightMode == Configuration.UI_MODE_NIGHT_YES
}
}
Pair(dark, color)
}
return ComposeView(context).apply {
// Configure window as soon as view is attached — before system overrides flags
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
session.configureWindow()
}
override fun onViewDetachedFromWindow(v: View) {}
})
setViewTreeLifecycleOwner(session)
setViewTreeSavedStateRegistryOwner(session)
setContent {
CyreneTheme(
darkTheme = darkTheme,
presetKey = themeColorKey,
useDynamicColor = themeColorKey == "monet",
) {
if (vm != null) {
OverlayContent(
onDismiss = { finish() },
onNavigateToMain = {
val intent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
},
viewModel = vm,
)
}
}
}
}
}
override fun onShow(args: Bundle?, showFlags: Int) {
super.onShow(args, showFlags)
RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}")
lifecycleRegistry.currentState = Lifecycle.State.STARTED
// Defer window config — system may override softInputMode after onShow returns
try {
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
method.isAccessible = true
val w = method.invoke(this) as? android.view.Window
w?.decorView?.post { configureWindow() }
} catch (e: Throwable) {
// Fallback: configure immediately
configureWindow()
}
// Only read screen content if user enabled it in settings (default off)
val autoScreenContext = try {
val prefs: PreferencesDataStore = GlobalContext.get().get()
runBlocking { prefs.autoScreenContext.firstOrNull() } ?: false
} catch (_: Throwable) {
false
}
if (autoScreenContext) {
val screenContent = CyreneAccessibilityService.getScreenContent()
if (screenContent.isNotBlank()) {
overlayViewModel?.sendScreenContext(screenContent)
RuntimeLog.general("overlay", "Screen context sent, len=${screenContent.length}")
}
}
}
fun configureWindow() {
try {
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
method.isAccessible = true
val w = method.invoke(this) as? android.view.Window ?: return
// Transparent window so the underlying screen is visible through the overlay
w.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT))
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
w.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
Log.d(TAG, "Window configured: transparent bg, translucent status/nav")
} catch (e: Throwable) {
Log.w(TAG, "Failed to configure window: ${e.message}")
}
}
override fun onHide() {
RuntimeLog.general("overlay", "onHide")
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
super.onHide()
overlayViewModel?.finish()
}
companion object {
private const val TAG = "CyreneVIS-Session"
}
}
@@ -0,0 +1,102 @@
package top.yeij.cyrene.service
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.koin.core.context.GlobalContext
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
import top.yeij.cyrene.util.RuntimeLog
class KeepAliveReceiver : BroadcastReceiver() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "Keep-alive alarm fired")
RuntimeLog.notify("keepalive", "Alarm fired in background")
scope.launch {
try {
val prefs: PreferencesDataStore = GlobalContext.get().get()
val token = prefs.token.firstOrNull()
if (token.isNullOrBlank()) {
Log.d(TAG, "No auth token, skipping wake-up")
RuntimeLog.notify("keepalive", "Skipping: no auth token")
return@launch
}
if (!WebSocketKeepAliveService.isRunning) {
WebSocketKeepAliveService.start(context)
RuntimeLog.notify("keepalive", "Foreground service restarted")
}
val repo: ChatRepositoryImpl = GlobalContext.get().get()
val wasConnected = repo.connectionState.value
repo.ensureConnected()
RuntimeLog.notify("keepalive", "WS reconnect triggered: wasConnected=$wasConnected nowConnected=${repo.connectionState.value}")
schedule(context)
} catch (e: Throwable) {
Log.e(TAG, "Keep-alive check failed: ${e.message}", e)
RuntimeLog.notify("keepalive", "Failed: ${e.message}")
schedule(context)
}
}
}
companion object {
private const val TAG = "CyreneKeepAlive"
const val INTERVAL_MS = 5 * 60 * 1000L // 5 minutes
fun schedule(context: Context) {
try {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, KeepAliveReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
// Cancel existing alarm first
alarmManager.cancel(pendingIntent)
val triggerAt = System.currentTimeMillis() + INTERVAL_MS
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent
)
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent
)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule keep-alive: ${e.message}")
}
}
fun cancel(context: Context) {
try {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, KeepAliveReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
alarmManager.cancel(pendingIntent)
} catch (e: Exception) {
Log.e(TAG, "Failed to cancel keep-alive: ${e.message}")
}
}
}
}
@@ -0,0 +1,154 @@
package top.yeij.cyrene.service
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.R
import top.yeij.cyrene.util.RuntimeLog
class WebSocketKeepAliveService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
isRunning = true
createChannel()
acquireWakeLock()
Log.i(TAG, "Service created, wakeLock held")
RuntimeLog.notify("keepalive", "WS keep-alive service created, wakeLock acquired")
}
override fun onDestroy() {
isRunning = false
releaseWakeLock()
scheduleRestart()
Log.i(TAG, "Service destroyed, restart scheduled")
RuntimeLog.notify("keepalive", "WS keep-alive service destroyed, restart scheduled in ${RESTART_DELAY_MS}ms")
super.onDestroy()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand flags=$flags startId=$startId")
startForegroundNotification()
return START_REDELIVER_INTENT
}
override fun onTaskRemoved(rootIntent: Intent?) {
// App swiped away from recents — schedule restart and let it die
Log.i(TAG, "Task removed, scheduling restart")
scheduleRestart()
super.onTaskRemoved(rootIntent)
}
private fun startForegroundNotification() {
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("昔涟")
.setContentText("后台连接中,可接收消息推送")
.setOngoing(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIFICATION_ID, notification, 0x40000001 /* dataSync | specialUse */)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"Cyrene:WebSocketKeepAlive"
).apply {
acquire(10 * 60 * 1000L) // 10 min timeout as safety net
}
}
private fun releaseWakeLock() {
try { wakeLock?.release() } catch (_: Exception) { }
wakeLock = null
}
private fun scheduleRestart() {
try {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(this, KeepAliveReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val triggerAt = System.currentTimeMillis() + RESTART_DELAY_MS
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
} else {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule restart: ${e.message}")
}
}
private fun createChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"连接状态",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "后台连接保活"
setShowBadge(false)
}
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel)
}
companion object {
private const val TAG = "CyreneKeepAlive"
private const val CHANNEL_ID = "cyrene_keepalive"
private const val NOTIFICATION_ID = 1
@Volatile
var isRunning: Boolean = false
private set
fun start(context: Context) {
if (isRunning) return
context.startForegroundService(
Intent(context, WebSocketKeepAliveService::class.java)
)
}
fun stop(context: Context) {
context.stopService(
Intent(context, WebSocketKeepAliveService::class.java)
)
}
const val RESTART_DELAY_MS = 60_000L
}
}
@@ -0,0 +1,335 @@
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 java.util.concurrent.atomic.AtomicLong
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.dto.WSAttachment
import top.yeij.cyrene.data.remote.dto.WSClientMessage
import top.yeij.cyrene.data.remote.dto.WSServerMessage
import top.yeij.cyrene.util.RuntimeLog
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
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 reconnectJob: Job? = null
private var shouldReconnect = true
private var currentSessionId: String? = null
private val connectionId = AtomicInteger(0)
@Volatile private var lastMessageReceived = System.currentTimeMillis()
private val deadConnectionTimeoutMs = 30_000L // No message for 30s = treat as dead
private var clientId: String = ""
private var deviceName: String = ""
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _connectionError = MutableStateFlow<String?>(null)
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
private val _incomingMessages = MutableSharedFlow<WSServerMessage>(extraBufferCapacity = 64)
val incomingMessages: SharedFlow<WSServerMessage> = _incomingMessages.asSharedFlow()
private suspend fun initClientIdentity() {
clientId = preferencesDataStore.clientId.firstOrNull() ?: run {
val id = "cl_" + System.currentTimeMillis().toString(36) + "_" +
(100000..999999).random().toString(36)
preferencesDataStore.saveClientId(id)
id
}
deviceName = preferencesDataStore.deviceName.firstOrNull() ?: run {
val name = "Android " + (Build.MODEL ?: "Device")
preferencesDataStore.saveDeviceName(name)
name
}
}
fun getClientId(): String = clientId
fun getDeviceName(): String = deviceName
suspend fun connect(sessionId: String? = null) {
currentSessionId = sessionId
shouldReconnect = true
initClientIdentity()
val baseUrl = preferencesDataStore.baseUrl.firstOrNull()
?: "http://10.0.2.2:8080"
val token = preferencesDataStore.token.firstOrNull() ?: ""
val wsBase = baseUrl
.replace("https://", "wss://")
.replace("http://", "ws://")
.trimEnd('/')
val urlBuilder = StringBuilder("$wsBase/ws/chat")
val params = mutableListOf<String>()
if (token.isNotBlank()) {
params.add("token=" + encode(token))
}
sessionId?.let {
if (it.isNotBlank()) params.add("session_id=" + encode(it))
}
if (clientId.isNotBlank()) {
params.add("client_id=" + encode(clientId))
}
if (deviceName.isNotBlank()) {
params.add("device_name=" + encode(deviceName))
}
if (params.isNotEmpty()) {
urlBuilder.append("?")
urlBuilder.append(params.joinToString("&"))
}
val url = urlBuilder.toString()
val connId = connectionId.incrementAndGet()
Log.i(TAG, "[#$connId] Connecting to $url")
val request = Request.Builder()
.url(url)
.header("User-Agent", "Cyrene-Android/${Build.MODEL ?: "Device"}")
.build()
// Close previous socket silently
try { webSocket?.close(1000, "Reconnecting") } catch (_: Exception) { }
cancelHeartbeat()
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
if (connectionId.get() != connId) {
Log.d(TAG, "[#$connId] onOpen ignored (stale)")
return
}
Log.i(TAG, "[#$connId] Connected")
_isConnected.value = true
_connectionError.value = null
startHeartbeat()
RuntimeLog.ws("lifecycle", "WS connected #$connId")
}
override fun onMessage(webSocket: WebSocket, text: String) {
if (connectionId.get() != connId) return
lastMessageReceived = System.currentTimeMillis()
try {
val msg = gson.fromJson(text, WSServerMessage::class.java)
val preview = text.take(100).replace("\n", "\\n")
RuntimeLog.ws("receive", "type=${msg.type} id=${msg.messageId ?: "-"} preview=$preview")
_incomingMessages.tryEmit(msg)
} catch (e: Exception) {
Log.w(TAG, "[#$connId] Failed to parse message: ${e.message}")
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
if (connectionId.get() != connId) return
Log.i(TAG, "[#$connId] Server closing: code=$code reason=$reason")
_isConnected.value = false
cancelHeartbeat()
RuntimeLog.ws("lifecycle", "WS closing #$connId code=$code reason='$reason'")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (connectionId.get() != connId) return
Log.i(TAG, "[#$connId] Closed: code=$code reason=$reason")
_isConnected.value = false
cancelHeartbeat()
scheduleReconnect()
RuntimeLog.ws("lifecycle", "WS closed #$connId code=$code")
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
if (connectionId.get() != connId) return
val httpCode = response?.code
Log.e(TAG, "[#$connId] Failure: ${t.message} (http=$httpCode)", t)
_isConnected.value = false
cancelHeartbeat()
RuntimeLog.ws("lifecycle", "WS failure #$connId http=$httpCode error='${t.message}'")
val errorMsg = when (httpCode) {
403 -> {
Log.e(TAG, "[#$connId] WebSocket 403: 仅管理员用户可连接。请使用管理员账户登录。")
"仅管理员用户可连接"
}
401 -> "认证失败,请重新登录"
else -> null
}
if (errorMsg != null) {
_connectionError.value = errorMsg
}
// onClosed may or may not follow — schedule reconnect directly
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,
attachments: List<WSAttachment>? = null,
): WSClientMessage = WSClientMessage(
type = type,
sessionId = sessionId ?: currentSessionId,
mode = mode,
content = content,
attachments = attachments,
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", attachments: List<WSAttachment>? = null) {
val msg = buildMessage("message", sessionId, mode, content, attachments = attachments)
webSocket?.send(gson.toJson(msg))
}
fun requestHistory(sessionId: String?) {
val msg = buildMessage("history", sessionId)
if (webSocket != null) {
webSocket?.send(gson.toJson(msg))
Log.i(TAG, "History requested for session=$sessionId")
} else {
Log.w(TAG, "Cannot request history: WebSocket is null")
}
}
fun sendPing() {
val msg = buildMessage("ping")
webSocket?.send(gson.toJson(msg))
}
fun sendScreenContext(content: String, sessionId: String? = null) {
val msg = buildMessage("message", sessionId, mode = "text", content = content)
webSocket?.send(gson.toJson(msg))
}
fun sendVoiceInput(audioBase64: String, sessionId: String? = null, mode: String = "voice_msg") {
val msg = WSClientMessage(
type = "voice_input",
sessionId = sessionId ?: currentSessionId,
mode = mode,
audioData = audioBase64,
timestamp = System.currentTimeMillis(),
clientId = clientId.ifBlank { null },
deviceName = deviceName.ifBlank { null },
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
)
webSocket?.send(gson.toJson(msg))
}
fun forceReconnect() {
RuntimeLog.ws("lifecycle", "forceReconnect called")
shouldReconnect = true
reconnectJob?.cancel()
reconnectJob = null
scope.launch {
try {
// Close existing socket directly without resetting shouldReconnect
cancelHeartbeat()
webSocket?.close(1000, "Reconnecting")
webSocket = null
_isConnected.value = false
connect(currentSessionId)
} catch (_: Exception) { }
}
}
fun disconnect() {
RuntimeLog.ws("lifecycle", "WS disconnect — user requested")
shouldReconnect = false
reconnectJob?.cancel()
reconnectJob = null
cancelHeartbeat()
try { webSocket?.close(1000, "User disconnected") } catch (_: Exception) { }
webSocket = null
_isConnected.value = false
}
private fun startHeartbeat() {
cancelHeartbeat()
heartbeatJob = scope.launch {
while (_isConnected.value) {
delay(15_000)
if (!_isConnected.value) break
sendPing()
// Check if connection is silently dead (no message received in 60s)
val sinceLastMsg = System.currentTimeMillis() - lastMessageReceived
if (sinceLastMsg > deadConnectionTimeoutMs) {
Log.w(TAG, "No message received for ${sinceLastMsg}ms — connection may be dead, forcing reconnect")
forceReconnect()
break
}
}
}
}
private fun cancelHeartbeat() {
heartbeatJob?.cancel()
heartbeatJob = null
}
private fun scheduleReconnect() {
if (reconnectJob?.isActive == true || !shouldReconnect) return
reconnectJob = scope.launch {
var attempt = 0
while (shouldReconnect && !_isConnected.value) {
val delayMs = minOf(
(Math.pow(2.0, attempt.toDouble()) * 1000).toLong(),
30_000L
)
Log.i(TAG, "Reconnecting in ${delayMs}ms (attempt ${attempt + 1})")
delay(delayMs)
attempt++
if (shouldReconnect && !_isConnected.value) {
try {
connect(currentSessionId)
} catch (e: Exception) {
Log.e(TAG, "Reconnect attempt $attempt failed: ${e.message}", e)
}
}
}
Log.i(TAG, "Reconnect loop ended (connected=${_isConnected.value}, shouldReconnect=$shouldReconnect)")
}
}
companion object {
private const val TAG = "CyreneWS"
}
}
@@ -0,0 +1,785 @@
package top.yeij.cyrene.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
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.ContentCopy
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.CircularProgressIndicator
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil.request.ImageRequest
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// --- Markdown block model ---
private sealed class MdBlock {
data class Heading(val level: Int, val text: String) : MdBlock()
data class Paragraph(val text: String) : MdBlock()
data class CodeBlock(val language: String?, val code: String) : MdBlock()
data class ListItem(val ordered: Boolean, val index: Int, val text: String) : MdBlock()
data class Quote(val text: String) : MdBlock()
class ThematicBreak : MdBlock()
}
private fun parseMarkdownBlocks(text: String): List<MdBlock> {
val lines = text.lines()
val blocks = mutableListOf<MdBlock>()
var i = 0
while (i < lines.size) {
val line = lines[i]
val trimmed = line.trimStart()
when {
// Fenced code block
trimmed.startsWith("```") -> {
val lang = trimmed.removePrefix("```").trim().ifBlank { null }
val codeLines = mutableListOf<String>()
i++
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
codeLines.add(lines[i])
i++
}
if (i < lines.size) i++ // skip closing ```
blocks.add(MdBlock.CodeBlock(lang, codeLines.joinToString("\n")))
}
// Heading
trimmed.startsWith("#") -> {
val match = Regex("^(#{1,6})\\s+(.+)$").find(trimmed)
if (match != null) {
val level = match.groupValues[1].length
blocks.add(MdBlock.Heading(level, match.groupValues[2]))
}
i++
}
// Thematic break
trimmed.matches(Regex("^[-*_]{3,}$")) -> {
blocks.add(MdBlock.ThematicBreak())
i++
}
// Blockquote
line.startsWith(">") || trimmed.startsWith(">") -> {
val quoteLines = mutableListOf<String>()
while (i < lines.size) {
val cur = lines[i]
if (cur.trimStart().startsWith(">")) {
quoteLines.add(cur.trimStart().removePrefix(">").trimStart())
i++
} else if (cur.isBlank()) {
i++
break
} else {
break
}
}
if (quoteLines.isNotEmpty()) {
blocks.add(MdBlock.Quote(quoteLines.joinToString("\n")))
}
}
// Unordered list
trimmed.matches(Regex("^[-*+]\\s+.*$")) -> {
while (i < lines.size && lines[i].trimStart().matches(Regex("^[-*+]\\s+.*$"))) {
val itemText = lines[i].trimStart().replaceFirst(Regex("^[-*+]\\s+"), "")
blocks.add(MdBlock.ListItem(false, blocks.size + 1, itemText))
i++
}
}
// Ordered list
trimmed.matches(Regex("^\\d+\\.\\s+.*$")) -> {
var idx = 1
while (i < lines.size && lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*$"))) {
val itemText = lines[i].trimStart().replaceFirst(Regex("^\\d+\\.\\s+"), "")
blocks.add(MdBlock.ListItem(true, idx, itemText))
idx++
i++
}
}
// Blank line — skip
line.isBlank() -> { i++ }
// Paragraph
else -> {
val paraLines = mutableListOf<String>()
while (i < lines.size &&
lines[i].isNotBlank() &&
!lines[i].trimStart().startsWith("```") &&
!lines[i].trimStart().startsWith("#") &&
!lines[i].trimStart().matches(Regex("^[-*_]{3,}$")) &&
!lines[i].trimStart().startsWith(">") &&
!lines[i].trimStart().matches(Regex("^[-*+]\\s+.*$")) &&
!lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*$"))
) {
paraLines.add(lines[i])
i++
}
if (paraLines.isNotEmpty()) {
blocks.add(MdBlock.Paragraph(paraLines.joinToString(" ")))
}
}
}
}
return blocks
}
@Composable
private fun renderInlineMarkdown(text: String): AnnotatedString {
return buildAnnotatedString {
var remaining = text
while (remaining.isNotEmpty()) {
// Bold + Italic ***
val boldItalic = Regex("""\*\*\*(.+?)\*\*\*""").find(remaining)
// Bold **
val bold = Regex("""\*\*(.+?)\*\*""").find(remaining)
// Italic *
val italic = Regex("""(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)""").find(remaining)
// Inline code `
val code = Regex("""`([^`]+)`""").find(remaining)
// Link [text](url)
val link = Regex("""\[([^\]]+)\]\(([^)]+)\)""").find(remaining)
val matches = listOfNotNull(
boldItalic?.let { "bi" to it },
bold?.let { "b" to it },
italic?.let { "i" to it },
code?.let { "c" to it },
link?.let { "l" to it },
).sortedBy { it.second.range.first }
if (matches.isEmpty()) {
append(remaining)
remaining = ""
} else {
val (kind, match) = matches.first()
// Append text before the match
if (match.range.first > 0) {
append(remaining.substring(0, match.range.first))
}
when (kind) {
"bi" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
append(match.groupValues[1])
}
"b" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(match.groupValues[1])
}
"i" -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
append(match.groupValues[1])
}
"c" -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = Color.Gray.copy(alpha = 0.2f))) {
append(match.groupValues[1])
}
"l" -> {
val label = match.groupValues[1]
val url = match.groupValues[2]
pushStringAnnotation("url", url)
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline)) {
append(label)
}
pop()
}
}
remaining = remaining.substring(match.range.last + 1)
}
}
}
}
// --- Markdown bubble ---
@Composable
private fun MarkdownBubble(content: String, modifier: Modifier = Modifier) {
val blocks = remember(content) { parseMarkdownBlocks(content) }
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f),
shadowElevation = 1.dp,
) {
Column(modifier = Modifier.padding(12.dp)) {
blocks.forEach { block ->
when (block) {
is MdBlock.Heading -> {
val fontSize = when (block.level) {
1 -> 22.sp
2 -> 19.sp
3 -> 17.sp
4 -> 15.sp
5 -> 14.sp
else -> 13.sp
}
Text(
text = renderInlineMarkdown(block.text),
fontSize = fontSize,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = if (block.level <= 2) 6.dp else 2.dp),
)
}
is MdBlock.Paragraph -> {
if (block.text.isNotBlank()) {
Text(
text = renderInlineMarkdown(block.text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is MdBlock.CodeBlock -> {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
shape = MaterialTheme.shapes.small,
color = Color(0xFF1E1E1E),
) {
Column {
if (block.language != null) {
Text(
text = block.language,
modifier = Modifier
.background(Color(0xFF333333))
.padding(horizontal = 10.dp, vertical = 4.dp),
color = Color(0xFFCCCCCC),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
)
}
Text(
text = block.code,
modifier = Modifier.padding(10.dp),
color = Color(0xFFD4D4D4),
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
)
}
}
}
is MdBlock.ListItem -> {
val prefix = if (block.ordered) "${block.index}. " else ""
Row(modifier = Modifier.padding(start = 8.dp, top = 2.dp)) {
Text(
text = prefix,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = renderInlineMarkdown(block.text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is MdBlock.Quote -> {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = MaterialTheme.shapes.small,
) {
Row {
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.5f))
.weight(0.012f)
) {}
Text(
text = renderInlineMarkdown(block.text),
modifier = Modifier
.weight(1f)
.padding(8.dp),
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
is MdBlock.ThematicBreak -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.background(
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
shape = MaterialTheme.shapes.extraSmall,
)
.weight(1f)
) {}
}
}
}
}
}
}
// --- Code bubble (standalone code block message) ---
private val codeDarkBg = Color(0xFF1E1E1E)
private val codeSurface = Color(0xFF333333)
@Composable
private fun CodeBubble(content: String, modifier: Modifier = Modifier) {
val (language, code) = remember(content) {
if (content.startsWith("[lang:")) {
val endBracket = content.indexOf("]\n")
if (endBracket > 0) {
content.substring(6, endBracket) to content.substring(endBracket + 2)
} else "Code" to content
} else "Code" to content
}
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = codeDarkBg,
shadowElevation = 2.dp,
) {
Column {
// Language header
Box(
modifier = Modifier
.background(codeSurface, MaterialTheme.shapes.medium)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = language,
color = Color(0xFFAAAAAA),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
)
}
}
// Code content
Text(
text = code,
modifier = Modifier.padding(12.dp),
color = Color(0xFFD4D4D4),
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
)
}
}
}
}
// --- Collapsible wrapper for non-chat content ---
private const val COLLAPSE_THRESHOLD = 300
@Composable
private fun CollapsibleBubble(
content: String,
modifier: Modifier = Modifier,
bubble: @Composable (String, Modifier) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val lines = content.lines()
val isLong = content.length > COLLAPSE_THRESHOLD || lines.size > 8
if (!isLong) {
bubble(content, modifier)
return
}
Column(modifier = modifier) {
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier.fillMaxWidth(),
) {
Box(modifier = Modifier.weight(1f)) {
if (expanded) {
bubble(content, Modifier)
} else {
val truncated = lines.take(5).joinToString("\n").let {
if (it.length >= content.length) it else it + "\n"
}
bubble(truncated, Modifier)
}
}
IconButton(
onClick = { expanded = !expanded },
modifier = Modifier
.padding(top = 4.dp)
.size(32.dp),
) {
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "折叠" else "展开",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
}
// --- Main ChatBubble dispatcher ---
@Composable
fun ChatBubble(
content: String,
role: String,
msgType: String,
timestamp: Long,
modifier: Modifier = Modifier,
imageDataUris: List<String> = emptyList(),
) {
val isUser = role == "user"
val formattedTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
when (msgType) {
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier, imageDataUris)
"action" -> ActionMessage(content, modifier)
"markdown" -> CollapsibleBubble(content, modifier) { text, mod ->
MarkdownBubble(text, mod)
}
"code" -> CollapsibleBubble(content, modifier) { text, mod ->
CodeBubble(text, mod)
}
"thinking" -> CollapsibleBubble(content, modifier) { text, mod ->
ThinkingBubble(text, mod)
}
"tool_progress" -> CollapsibleBubble(content, modifier) { text, mod ->
ToolProgressBubble(text, mod)
}
"system_info" -> SystemInfoBubble(content, modifier)
else -> ChatMessageBubble(content, isUser, formattedTime, modifier, imageDataUris)
}
}
// --- Full-screen image preview dialog ---
@Composable
private fun ImagePreviewDialog(
imageUri: String,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.95f))
.clickable { onDismiss() },
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = Color.White.copy(alpha = 0.6f),
modifier = Modifier.size(48.dp),
)
AsyncImage(
model = ImageRequest.Builder(context)
.data(imageUri)
.crossfade(true)
.build(),
contentDescription = "图片预览",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentScale = ContentScale.Fit,
)
IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp),
) {
Icon(
Icons.Default.Close,
contentDescription = "关闭",
tint = Color.White,
)
}
}
}
}
// --- Chat message bubble ---
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ChatMessageBubble(
content: String,
isUser: Boolean,
time: String,
modifier: Modifier = Modifier,
imageDataUris: List<String> = emptyList(),
) {
var showMenu by remember { mutableStateOf(false) }
var previewImageUri by remember { mutableStateOf<String?>(null) }
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
val hasImages = imageDataUris.isNotEmpty()
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,
) {
Box {
Surface(
shape = MaterialTheme.shapes.large,
color = if (isUser)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant,
shadowElevation = 2.dp,
modifier = Modifier
.widthIn(max = 300.dp)
.combinedClickable(
onClick = {},
onLongClick = { showMenu = true },
),
) {
Column {
if (hasImages) {
imageDataUris.forEach { uri ->
AsyncImage(
model = ImageRequest.Builder(context)
.data(uri)
.crossfade(true)
.build(),
contentDescription = "图片",
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 240.dp)
.padding(top = 6.dp, start = 6.dp, end = 6.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { previewImageUri = uri },
contentScale = ContentScale.FillWidth,
)
}
}
if (content.isNotBlank()) {
Text(
text = renderInlineMarkdown(content),
modifier = Modifier.padding(
start = 12.dp, end = 12.dp,
top = if (hasImages) 6.dp else 12.dp,
bottom = 12.dp,
),
color = if (isUser)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text("复制") },
leadingIcon = {
Icon(Icons.Default.ContentCopy, contentDescription = null)
},
onClick = {
showMenu = false
clipboardManager.setText(AnnotatedString(content))
},
)
}
}
Text(
text = time,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
}
// Full-screen image preview
if (previewImageUri != null) {
ImagePreviewDialog(
imageUri = previewImageUri!!,
onDismiss = { previewImageUri = null },
)
}
}
// --- Action message ---
private val actionTagRegex = Regex("""</?action>""", RegexOption.IGNORE_CASE)
@Composable
private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
val displayText = remember(content) {
content.replace(actionTagRegex, "").trim()
}
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.Start,
) {
Text(
text = displayText,
style = MaterialTheme.typography.bodyMedium.copy(
fontStyle = FontStyle.Italic,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Start,
)
}
}
// --- Thinking bubble ---
@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,
)
}
}
// --- Tool progress bubble ---
@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,
)
}
}
}
// --- System info bubble ---
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) {
var showMenu by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.Center,
) {
Box {
Text(
text = content,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.combinedClickable(
onClick = {},
onLongClick = { showMenu = true },
),
)
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text("复制") },
leadingIcon = {
Icon(Icons.Default.ContentCopy, contentDescription = null)
},
onClick = {
showMenu = false
clipboardManager.setText(AnnotatedString(content))
},
)
}
}
}
}
@@ -0,0 +1,98 @@
package top.yeij.cyrene.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AcUnit
import androidx.compose.material.icons.filled.Curtains
import androidx.compose.material.icons.filled.Lightbulb
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.domain.model.Device
import top.yeij.cyrene.domain.model.DeviceType
@Composable
fun DeviceCard(
device: Device,
onTogglePower: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = device.type.toIcon(),
contentDescription = device.type.name,
tint = if (device.state.power == true)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant,
)
Column {
Text(
text = device.name,
style = MaterialTheme.typography.titleMedium,
)
device.room?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
device.state.temperature?.let { temp ->
Text(
text = "${"%.1f".format(temp)}°C",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (device.state.power != null) {
Switch(
checked = device.state.power,
onCheckedChange = { onTogglePower() },
)
}
}
}
}
private fun DeviceType.toIcon() = when (this) {
DeviceType.LIGHT -> Icons.Filled.Lightbulb
DeviceType.AC -> Icons.Filled.AcUnit
DeviceType.CURTAIN -> Icons.Filled.Curtains
DeviceType.SENSOR -> Icons.Filled.Sensors
DeviceType.DOOR_LOCK -> Icons.Filled.Lock
DeviceType.UNKNOWN -> Icons.Filled.Sensors
}
@@ -0,0 +1,94 @@
package top.yeij.cyrene.ui.components
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
enum class CyreneStatus {
ONLINE,
THINKING,
SPEAKING,
OFFLINE,
}
@Composable
fun StatusIndicator(
status: CyreneStatus,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
when (status) {
CyreneStatus.ONLINE -> {
Icon(
Icons.Filled.Circle,
contentDescription = null,
modifier = Modifier.size(8.dp),
tint = Color(0xFF4CAF50),
)
Text("昔涟", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
}
CyreneStatus.THINKING -> {
PulsingDot(Color(0xFFFFA726))
Text("思考中…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
}
CyreneStatus.SPEAKING -> {
PulsingDot(Color(0xFF42A5F5))
Text("正在说话…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
}
CyreneStatus.OFFLINE -> {
Icon(
Icons.Filled.Circle,
contentDescription = null,
modifier = Modifier.size(8.dp),
tint = Color(0xFF9E9E9E),
)
Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
}
}
}
}
@Composable
private fun PulsingDot(color: Color) {
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(600),
repeatMode = RepeatMode.Reverse,
),
label = "pulse_alpha",
)
Box(
modifier = Modifier
.size(8.dp)
.alpha(alpha)
.background(color, CircleShape),
)
}
@@ -0,0 +1,77 @@
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.unit.dp
@Composable
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,
shadowElevation = 1.dp,
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "昔涟正在输入",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
repeat(3) { index ->
val alpha by infiniteTransition.animateFloat(
initialValue = 0.2f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 400,
delayMillis = index * 200,
),
repeatMode = RepeatMode.Reverse,
),
label = "dot_$index",
)
Box(
modifier = Modifier
.size(5.dp)
.alpha(alpha)
.background(
MaterialTheme.colorScheme.onSurfaceVariant,
CircleShape,
),
)
}
}
}
}
}
@@ -0,0 +1,221 @@
package top.yeij.cyrene.ui.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.statusBarsPadding
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.MaterialTheme
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.compose.ui.draw.clipToBounds
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
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.about.AboutScreen
import top.yeij.cyrene.ui.screens.profile.ProfileScreen
import top.yeij.cyrene.ui.screens.settings.KeepAlivePage
import top.yeij.cyrene.ui.screens.settings.SettingsScreen
import top.yeij.cyrene.util.RuntimeLog
object Routes {
const val LOGIN = "login"
const val MAIN = "main"
const val CHAT = "chat"
const val IOT = "iot"
const val SETTINGS = "settings"
const val ABOUT = "about"
const val KEEP_ALIVE = "keep_alive"
}
@Composable
fun CyreneNavGraph(
navController: NavHostController,
startDestination: String,
isDefaultAssistant: Boolean,
onOpenAssistantSettings: () -> Unit,
) {
// After process death, the NavController may restore a stale back stack
// (e.g. showing SETTINGS instead of MAIN). Reset to the intended start.
LaunchedEffect(Unit) {
val entries = navController.currentBackStack.value
val currentRoute = navController.currentDestination?.route
RuntimeLog.general("nav", "NavGraph start — currentRoute=$currentRoute backStackSize=${entries.size}")
if (entries.size > 1 && entries.first().destination.route != startDestination) {
RuntimeLog.general("nav", "Resetting stale back stack to $startDestination")
navController.popBackStack(startDestination, inclusive = true)
navController.navigate(startDestination)
}
}
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 = {
if (navController.currentDestination?.route == Routes.SETTINGS) {
navController.popBackStack()
}
},
onNavigateToKeepAlive = { navController.navigate(Routes.KEEP_ALIVE) },
)
}
composable(Routes.KEEP_ALIVE) {
KeepAlivePage(
onBack = {
if (navController.currentDestination?.route == Routes.KEEP_ALIVE) {
navController.popBackStack()
}
},
)
}
composable(Routes.ABOUT) {
AboutScreen(
onBack = {
if (navController.currentDestination?.route == Routes.ABOUT) {
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 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) }
Row(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding(),
) {
NavigationRail {
items.forEachIndexed { index, item ->
NavigationRailItem(
selected = selectedTab == index,
onClick = {
selectedTab = index
RuntimeLog.general("nav", "Tab switched to ${item.label} (index=$index)")
},
icon = item.icon,
label = { Text(item.label) },
)
}
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clipToBounds()
.background(MaterialTheme.colorScheme.background),
) {
// Keep all tabs alive by offsetting hidden ones off-screen.
// clipToBounds ensures they don't intercept touches outside the visible area.
Box(
modifier = Modifier
.fillMaxSize()
.offset(x = if (selectedTab == 0) 0.dp else 2000.dp),
) {
ChatScreen()
}
Box(
modifier = Modifier
.fillMaxSize()
.offset(x = if (selectedTab == 1) 0.dp else 2000.dp),
) {
IoTScreen()
}
Box(
modifier = Modifier
.fillMaxSize()
.offset(x = if (selectedTab == 2) 0.dp else 2000.dp),
) {
ProfileScreen(
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
onNavigateToAbout = { navController.navigate(Routes.ABOUT) },
onLogout = {
navController.navigate(Routes.LOGIN) {
popUpTo(Routes.MAIN) { inclusive = true }
}
},
onNavigateToLogin = {
navController.navigate(Routes.LOGIN)
},
isDefaultAssistant = isDefaultAssistant,
onOpenAssistantSettings = onOpenAssistantSettings,
)
}
}
}
}
@@ -0,0 +1,636 @@
package top.yeij.cyrene.ui.overlay
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.automirrored.filled.OpenInNew
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardVoice
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.koin.compose.koinInject
import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.ui.components.ChatBubble
import top.yeij.cyrene.ui.components.TypingIndicator
import top.yeij.cyrene.util.RecordState
import top.yeij.cyrene.viewmodel.OverlayState
import top.yeij.cyrene.viewmodel.OverlayViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel
import kotlin.math.min
@Composable
private fun AnimatedChatBubble(
message: Message,
animIndex: Int,
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(min(animIndex, 10) * 60L)
visible = true
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) +
slideInVertically(
animationSpec = androidx.compose.animation.core.tween(300),
initialOffsetY = { it / 4 },
),
) {
ChatBubble(
content = message.content,
role = message.role,
msgType = message.msgType,
timestamp = message.timestamp,
imageDataUris = message.imageDataUris,
)
}
}
@Composable
fun OverlayContent(
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel = koinInject(),
settingsViewModel: SettingsViewModel = koinInject(),
) {
val state by viewModel.state.collectAsState()
val messages by viewModel.messages.collectAsState()
val inputText by viewModel.inputText.collectAsState()
val recordState by viewModel.voiceRecordState.collectAsState()
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
val animIndex by viewModel.messageAnimIndex.collectAsState()
val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
val enterToSend by settingsViewModel.enterToSend.collectAsState()
val listState = rememberLazyListState()
val isProcessing = state == OverlayState.PROCESSING
val recordSec = recordDurationMs / 1000f
val isRecording = recordState == RecordState.RECORDING
val isLocked = recordState == RecordState.LOCKED
// Animated "昔涟正在输入..." dots
val typingDots = remember { mutableStateOf("") }
LaunchedEffect(isProcessing) {
if (isProcessing) {
val dots = arrayOf("", ".", "..", "...")
var i = 0
while (true) {
typingDots.value = dots[i % 4]
i++
delay(400)
}
} else {
typingDots.value = ""
}
}
val configuration = LocalConfiguration.current
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
// Manual status bar height — Compose WindowInsets may not work in VoiceInteractionSession
val context = androidx.compose.ui.platform.LocalContext.current
val statusBarHeight = remember {
val resourceId = context.resources.getIdentifier("status_bar_height", "dimen", "android")
if (resourceId > 0) context.resources.getDimensionPixelSize(resourceId) else 0
}
val statusBarPaddingDp = with(androidx.compose.ui.platform.LocalDensity.current) {
statusBarHeight.toDp()
}
// Manual nav bar height
val navBarHeight = remember {
val resourceId = context.resources.getIdentifier("navigation_bar_height", "dimen", "android")
if (resourceId > 0) context.resources.getDimensionPixelSize(resourceId) else 0
}
val navBarPaddingDp = with(androidx.compose.ui.platform.LocalDensity.current) {
navBarHeight.toDp()
}
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()
.padding(top = statusBarPaddingDp, bottom = navBarPaddingDp),
) {
if (isLandscape) {
LandscapeContent(
state = state,
messages = messages,
inputText = inputText,
isProcessing = isProcessing,
listState = listState,
recordSec = recordSec,
isRecording = isRecording,
isLocked = isLocked,
typingDots = typingDots.value,
typingIndicatorStyle = typingIndicatorStyle,
enterToSend = enterToSend,
animIndex = animIndex,
onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain,
viewModel = viewModel,
navBarHeightPx = navBarHeight,
)
} else {
PortraitContent(
state = state,
messages = messages,
inputText = inputText,
isProcessing = isProcessing,
listState = listState,
recordSec = recordSec,
isRecording = isRecording,
isLocked = isLocked,
typingDots = typingDots.value,
typingIndicatorStyle = typingIndicatorStyle,
enterToSend = enterToSend,
animIndex = animIndex,
onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain,
viewModel = viewModel,
navBarHeightPx = navBarHeight,
)
}
}
}
}
@Suppress("LongParameterList")
@Composable
private fun PortraitContent(
state: OverlayState,
messages: List<Message>,
inputText: String,
isProcessing: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState,
recordSec: Float,
isRecording: Boolean,
isLocked: Boolean,
typingDots: String,
typingIndicatorStyle: String,
enterToSend: Boolean,
animIndex: Map<String, Int>,
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel,
navBarHeightPx: Int,
) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
) { /* consume clicks */ },
) {
// Messages + top bar stay fixed at top
Column(modifier = Modifier.fillMaxSize()) {
MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
state = listState,
) {
if (messages.isNotEmpty()) {
items(messages, key = { it.id }) { message ->
AnimatedChatBubble(
message = message,
animIndex = animIndex[message.id] ?: 0,
)
}
}
if (isProcessing && typingIndicatorStyle != "text") {
item(key = "typing_indicator") {
TypingIndicator()
}
}
}
}
// Input area at bottom; system adjust=pan handles IME offset
InputArea(
state = state,
inputText = inputText,
viewModel = viewModel,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(),
recordSec = recordSec,
isRecording = isRecording,
isLocked = isLocked,
typingDots = typingDots,
typingIndicatorStyle = typingIndicatorStyle,
enterToSend = enterToSend,
)
}
}
@Suppress("LongParameterList")
@Composable
private fun LandscapeContent(
state: OverlayState,
messages: List<Message>,
inputText: String,
isProcessing: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState,
recordSec: Float,
isRecording: Boolean,
isLocked: Boolean,
typingDots: String,
typingIndicatorStyle: String,
enterToSend: Boolean,
animIndex: Map<String, Int>,
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel,
navBarHeightPx: Int,
) {
Row(
modifier = Modifier
.fillMaxSize()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
) { /* consume clicks */ },
) {
// Messages + top bar on the left, stay fixed
Column(modifier = Modifier.weight(1f)) {
MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
state = listState,
) {
if (messages.isNotEmpty()) {
items(messages, key = { it.id }) { message ->
AnimatedChatBubble(
message = message,
animIndex = animIndex[message.id] ?: 0,
)
}
}
if (isProcessing && typingIndicatorStyle != "text") {
item(key = "typing_indicator") {
TypingIndicator()
}
}
}
}
Box(
modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
contentAlignment = Alignment.BottomCenter,
) {
InputArea(
state = state,
inputText = inputText,
viewModel = viewModel,
modifier = Modifier
.fillMaxWidth(),
recordSec = recordSec,
isRecording = isRecording,
isLocked = isLocked,
typingDots = typingDots,
typingIndicatorStyle = typingIndicatorStyle,
enterToSend = enterToSend,
)
}
}
}
@Composable
private fun MessageTopBar(
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.3f))
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onDismiss) {
Icon(Icons.Filled.Close, contentDescription = "关闭")
}
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = onNavigateToMain) {
Icon(
Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = "进入主界面",
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
private fun InputArea(
state: OverlayState,
inputText: String,
viewModel: OverlayViewModel,
modifier: Modifier = Modifier,
recordSec: Float = 0f,
isRecording: Boolean = false,
isLocked: Boolean = false,
typingDots: String = "",
typingIndicatorStyle: String = "bubble",
enterToSend: Boolean = false,
) {
// Gesture tracking state — local to InputArea
var isDragging by remember { mutableStateOf(false) }
var dragOffsetX by remember { mutableStateOf(0f) }
var dragOffsetY by remember { mutableStateOf(0f) }
val inCancelZone = isDragging && dragOffsetY < -120f
val inLockZone = isDragging && dragOffsetX > 60f
val isProcessing = state == OverlayState.PROCESSING
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
// "昔涟正在输入..." indicator (text mode only)
if (isProcessing && typingDots.isNotEmpty() && typingIndicatorStyle == "text") {
Text(
text = "昔涟正在输入$typingDots",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
textAlign = TextAlign.Start,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
if (isRecording && isDragging) {
// Recording with drag — show recording indicator
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(
if (inCancelZone) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.surfaceVariant
)
.padding(horizontal = 16.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = when {
inCancelZone -> "松手取消"
inLockZone -> "松手录音"
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
},
style = MaterialTheme.typography.bodyMedium,
color = if (inCancelZone) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box(
modifier = Modifier
.padding(start = 8.dp)
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Mic,
contentDescription = "录音中",
tint = MaterialTheme.colorScheme.onPrimary,
)
}
} else if (isLocked) {
// Locked (hands-free) mode
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = 16.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Lock,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
IconButton(onClick = { viewModel.finishRecord() }) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "发送",
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
// Normal input mode
OutlinedTextField(
value = inputText,
onValueChange = { viewModel.onInputChanged(it) },
placeholder = { Text("输入消息...") },
modifier = Modifier.weight(1f),
maxLines = 3,
shape = RoundedCornerShape(24.dp),
colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.35f),
focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f),
),
keyboardOptions = if (enterToSend) {
KeyboardOptions(imeAction = ImeAction.Done)
} else {
KeyboardOptions.Default
},
keyboardActions = if (enterToSend) {
KeyboardActions(
onDone = { if (inputText.isNotBlank()) viewModel.sendText() },
)
} else {
KeyboardActions.Default
},
)
Spacer(modifier = Modifier.width(8.dp))
if (inputText.isNotBlank()) {
IconButton(
onClick = { viewModel.sendText() },
enabled = !isProcessing,
) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "发送",
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
Box(
modifier = Modifier
.size(48.dp)
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { _ ->
isDragging = true
dragOffsetX = 0f
dragOffsetY = 0f
viewModel.startRecord()
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetX += dragAmount.x
dragOffsetY += dragAmount.y
},
onDragEnd = {
isDragging = false
when {
dragOffsetY < -120f -> viewModel.cancelRecord()
dragOffsetX > 60f -> viewModel.lockRecord()
else -> viewModel.finishRecord()
}
dragOffsetX = 0f
dragOffsetY = 0f
},
onDragCancel = {
isDragging = false
viewModel.cancelRecord()
dragOffsetX = 0f
dragOffsetY = 0f
},
)
},
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.KeyboardVoice,
contentDescription = "按住录音",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
// Status hint
val hint = when {
isLocked -> ""
isRecording && isDragging -> ""
else -> when (state) {
OverlayState.LISTENING -> "我在听..."
OverlayState.PROCESSING -> "思考中..."
OverlayState.SPEAKING -> "正在说话..."
OverlayState.WAITING -> "长按麦克风开始说话"
OverlayState.IDLE -> ""
}
}
if (hint.isNotEmpty()) {
Text(
text = hint,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,191 @@
package top.yeij.cyrene.ui.screens.about
import android.content.Intent
import android.net.Uri
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.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
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.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(onBack: () -> Unit) {
val context = LocalContext.current
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("关于") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
Text(
text = "Cyrene",
style = MaterialTheme.typography.headlineLarge.copy(
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
),
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
)
Text(
text = "昔涟",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Text(
text = "v0.1.0",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
)
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "信息",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
ListItem(
headlineContent = { Text("简介") },
leadingContent = {
Icon(Icons.Filled.Info, contentDescription = null)
},
supportingContent = {
Text(
text = "Cyrene 是一款基于 Android 的智能语音助手,支持实时对话、IoT 设备管理和语音交互。",
style = MaterialTheme.typography.bodyMedium,
)
},
)
ListItem(
headlineContent = { Text("开发者") },
leadingContent = {
Icon(Icons.Filled.Person, contentDescription = null)
},
supportingContent = { Text("AskaEth") },
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
ListItem(
headlineContent = { Text("源代码") },
leadingContent = {
Icon(Icons.Filled.Code, contentDescription = null)
},
supportingContent = { Text("git.yeij.top/AskaEth/Cyrene-For-Android") },
trailingContent = {
Icon(
Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
},
modifier = Modifier
.fillMaxWidth()
.clickable {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse("https://git.yeij.top/AskaEth/Cyrene-For-Android")
}
context.startActivity(intent)
},
)
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "技术栈",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
val techStack = listOf(
"Kotlin" to "主语言",
"Jetpack Compose" to "UI 框架",
"Material Design 3" to "设计系统",
"Room" to "本地数据库",
"OkHttp + Retrofit" to "网络请求",
"WebSocket" to "实时通信",
"Koin" to "依赖注入",
)
techStack.forEach { (name, desc) ->
ListItem(
headlineContent = { Text(name) },
supportingContent = { Text(desc) },
)
}
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "© 2026 AskaEth. All rights reserved.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
)
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@@ -0,0 +1,513 @@
package top.yeij.cyrene.ui.screens.chat
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardVoice
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Refresh
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import kotlinx.coroutines.delay
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import top.yeij.cyrene.domain.model.Message
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.ui.components.TypingIndicator
import top.yeij.cyrene.util.RecordState
import top.yeij.cyrene.util.RuntimeLog
import top.yeij.cyrene.viewmodel.ChatViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel
import kotlin.math.min
@Composable
private fun AnimatedChatBubble(
message: Message,
animIndex: Int,
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(min(animIndex, 10) * 60L)
visible = true
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(300)) +
slideInVertically(
animationSpec = tween(300),
initialOffsetY = { it / 4 },
),
) {
ChatBubble(
content = message.content,
role = message.role,
msgType = message.msgType,
timestamp = message.timestamp,
imageDataUris = message.imageDataUris,
)
}
}
@Composable
fun ChatScreen(
viewModel: ChatViewModel = koinViewModel(),
settingsViewModel: SettingsViewModel = koinInject(),
) {
// Track composition to diagnose navigation-related issues
LaunchedEffect(Unit) {
RuntimeLog.general("chat", "ChatScreen composed, ChatViewModel instance resolved")
}
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 isRefreshing by viewModel.isRefreshing.collectAsState()
val recordState by viewModel.voiceRecordState.collectAsState()
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
val animIndex by viewModel.messageAnimIndex.collectAsState()
val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
val enterToSend by settingsViewModel.enterToSend.collectAsState()
// reverseLayout: index 0 = newest (visual bottom), index N-1 = oldest (visual top)
val listState = rememberLazyListState()
// Track whether user is near the latest messages (visual bottom = index 0)
val isNearBottom by remember {
derivedStateOf {
val info = listState.layoutInfo
if (info.totalItemsCount == 0) return@derivedStateOf true
(info.visibleItemsInfo.firstOrNull()?.index ?: 0) <= 2
}
}
// Gesture tracking state
var isDragging by remember { mutableStateOf(false) }
var dragOffsetX by remember { mutableStateOf(0f) }
var dragOffsetY by remember { mutableStateOf(0f) }
var recordButtonY by remember { mutableStateOf(0f) }
val recordSec = recordDurationMs / 1000f
val isRecording = recordState == RecordState.RECORDING
val isLocked = recordState == RecordState.LOCKED
val inCancelZone = isDragging && dragOffsetY < -120f
val inLockZone = isDragging && dragOffsetX > 60f
// Image picker
val selectedImages by viewModel.selectedImageUris.collectAsState()
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents()
) { uris: List<Uri> ->
if (uris.isNotEmpty()) {
viewModel.addImages(uris)
}
}
val context = LocalContext.current
// Stay at bottom for new messages unless user scrolled up
LaunchedEffect(Unit) {
snapshotFlow { messages.size to isNearBottom }
.collect { (_, nearBottom) ->
if (nearBottom && listState.firstVisibleItemIndex != 0) {
listState.animateScrollToItem(0)
}
}
}
// Animated "昔涟正在输入..." dots
val typingDots = remember { mutableStateOf("") }
LaunchedEffect(isStreaming) {
if (isStreaming) {
val dots = arrayOf("", ".", "..", "...")
var i = 0
while (true) {
typingDots.value = dots[i % 4]
i++
delay(400)
}
} else {
typingDots.value = ""
}
}
val status = when {
isStreaming -> CyreneStatus.THINKING
isConnected -> CyreneStatus.ONLINE
else -> CyreneStatus.OFFLINE
}
// Single column layout: everything flows together and IME shrinks the whole view
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.imePadding(),
) {
// Top status bar with refresh button
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
StatusIndicator(status = status)
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = { viewModel.refreshMessages() },
enabled = !isRefreshing,
) {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
Icons.Filled.Refresh,
contentDescription = "刷新",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Selected images preview
if (selectedImages.isNotEmpty()) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
itemsIndexed(selectedImages, key = { i, _ -> i }) { index, uri ->
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)),
) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(uri)
.crossfade(true)
.build(),
contentDescription = "已选图片",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
IconButton(
onClick = { viewModel.removeImage(index) },
modifier = Modifier
.align(Alignment.TopEnd)
.size(20.dp)
.padding(0.dp),
) {
Icon(
Icons.Default.Close,
contentDescription = "移除",
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier
.size(14.dp)
.background(
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f),
CircleShape,
),
)
}
}
}
}
}
// Messages area (fills remaining space, shrinks with IME)
Box(modifier = Modifier.weight(1f)) {
if (messages.isEmpty() && !isStreaming) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "开始和昔涟对话吧",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
reverseLayout = true,
) {
if (isStreaming && typingIndicatorStyle != "text") {
item(key = "typing_indicator") {
TypingIndicator()
}
}
itemsIndexed(messages, key = { _, msg -> msg.id }) { index, message ->
AnimatedChatBubble(
message = message,
animIndex = index.coerceAtMost(20),
)
}
}
}
}
// Input area at bottom, in flow (not overlaid)
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.navigationBarsPadding(),
) {
// "昔涟正在输入..." indicator (text mode only)
if (isStreaming && typingIndicatorStyle == "text") {
Text(
text = "昔涟正在输入${typingDots.value}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 2.dp),
textAlign = TextAlign.Start,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isRecording && isDragging) {
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(
if (inCancelZone) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.surfaceVariant
)
.padding(horizontal = 16.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = when {
inCancelZone -> "松手取消"
inLockZone -> "松手录音"
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
},
style = MaterialTheme.typography.bodyMedium,
color = if (inCancelZone) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box(
modifier = Modifier
.padding(start = 8.dp)
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Mic,
contentDescription = "录音中",
tint = MaterialTheme.colorScheme.onPrimary,
)
}
} else if (isLocked) {
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = 16.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Lock,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
IconButton(onClick = { viewModel.finishRecord() }) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "发送",
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
IconButton(
onClick = { imagePickerLauncher.launch("image/*") },
) {
Icon(
Icons.Default.AddPhotoAlternate,
contentDescription = "添加图片",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedTextField(
value = inputText,
onValueChange = { viewModel.onInputChanged(it) },
placeholder = { Text("输入消息...") },
modifier = Modifier.weight(1f),
maxLines = 4,
shape = MaterialTheme.shapes.medium,
keyboardOptions = if (enterToSend) {
KeyboardOptions(imeAction = ImeAction.Done)
} else {
KeyboardOptions.Default
},
keyboardActions = if (enterToSend) {
KeyboardActions(
onDone = { if (inputText.isNotBlank()) viewModel.sendMessage() },
)
} else {
KeyboardActions.Default
},
)
Box(
modifier = Modifier
.padding(start = 4.dp)
.size(48.dp)
.onGloballyPositioned { recordButtonY = it.positionInRoot().y }
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
isDragging = true
dragOffsetX = 0f
dragOffsetY = 0f
viewModel.startRecord()
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetX += dragAmount.x
dragOffsetY += dragAmount.y
},
onDragEnd = {
isDragging = false
when {
dragOffsetY < -120f -> viewModel.cancelRecord()
dragOffsetX > 60f -> viewModel.lockRecord()
else -> viewModel.finishRecord()
}
dragOffsetX = 0f
dragOffsetY = 0f
},
onDragCancel = {
isDragging = false
viewModel.cancelRecord()
dragOffsetX = 0f
dragOffsetY = 0f
},
)
},
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.KeyboardVoice,
contentDescription = "按住录音",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (inputText.isNotBlank()) {
IconButton(
onClick = { viewModel.sendMessage() },
enabled = !isStreaming,
) {
if (isStreaming) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "发送",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,59 @@
package top.yeij.cyrene.ui.screens.iot
import androidx.compose.foundation.background
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.androidx.compose.koinViewModel
import top.yeij.cyrene.ui.components.DeviceCard
import top.yeij.cyrene.viewmodel.IoTViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun IoTScreen(
viewModel: IoTViewModel = koinViewModel(),
) {
val devices by viewModel.devices.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
PullToRefreshBox(
isRefreshing = isLoading,
onRefresh = { viewModel.refreshDevices() },
modifier = Modifier.background(MaterialTheme.colorScheme.background),
) {
if (devices.isEmpty() && !isLoading) {
Box(
modifier = Modifier.fillMaxSize().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "暂无设备数据\n请确认已连接到服务器",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn {
items(devices, key = { it.id }) { device ->
DeviceCard(
device = device,
onTogglePower = { viewModel.togglePower(device) },
)
}
}
}
}
}
@@ -0,0 +1,165 @@
package top.yeij.cyrene.ui.screens.login
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import top.yeij.cyrene.domain.usecase.LoginUseCase
import top.yeij.cyrene.viewmodel.SettingsViewModel
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit,
loginUseCase: LoginUseCase = koinInject(),
settingsViewModel: SettingsViewModel = koinInject(),
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
var isLoading by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(80.dp))
Icon(
imageVector = Icons.Filled.Person,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Cyrene",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = "昔涟",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(48.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("用户名") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
singleLine = true,
shape = MaterialTheme.shapes.medium,
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
visualTransformation = if (showPassword)
VisualTransformation.None
else
PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
singleLine = true,
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
contentDescription = if (showPassword) "隐藏密码" else "显示密码",
)
}
},
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
isLoading = true
scope.launch {
loginUseCase(username, password).fold(
onSuccess = {
isLoading = false
onLoginSuccess()
},
onFailure = { error ->
isLoading = false
scope.launch {
snackbarHostState.showSnackbar(
error.message ?: "登录失败",
)
}
},
)
}
},
modifier = Modifier.fillMaxWidth().height(50.dp),
enabled = username.isNotBlank() && password.isNotBlank() && !isLoading,
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Text("登录")
}
}
}
}
}
@@ -0,0 +1,394 @@
package top.yeij.cyrene.ui.screens.profile
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.AdminPanelSettings
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.koin.compose.koinInject
import top.yeij.cyrene.viewmodel.ProfileViewModel
@Composable
fun ProfileScreen(
onNavigateToSettings: () -> Unit,
onLogout: () -> Unit,
onNavigateToLogin: () -> Unit,
onNavigateToAbout: () -> Unit = {},
isDefaultAssistant: Boolean = false,
onOpenAssistantSettings: () -> Unit = {},
profileViewModel: ProfileViewModel = koinInject(),
) {
val profile by profileViewModel.profile.collectAsState()
var showLogoutDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
profileViewModel.fetchFreshProfile()
}
if (showLogoutDialog) {
AlertDialog(
onDismissRequest = { showLogoutDialog = false },
title = { Text("退出登录") },
text = { Text("确定要退出登录吗?") },
confirmButton = {
TextButton(
onClick = {
showLogoutDialog = false
profileViewModel.logout()
onLogout()
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text("退出")
}
},
dismissButton = {
TextButton(onClick = { showLogoutDialog = false }) {
Text("取消")
}
},
)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(rememberScrollState()),
) {
// Profile header
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Avatar
val initials = if (profile.nickname.isNotBlank()) {
profile.nickname.take(1)
} else if (profile.username.isNotBlank()) {
profile.username.take(1)
} else {
"?"
}
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Text(
text = initials,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold,
)
}
Spacer(modifier = Modifier.height(16.dp))
if (profile.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else if (!profile.isLoggedIn) {
Text(
text = "未登录",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { onNavigateToLogin() },
)
Text(
text = "点击登录以查看个人信息",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
// Nickname
Text(
text = profile.nickname.ifEmpty { profile.username },
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
// Username
if (profile.nickname.isNotBlank() && profile.nickname != profile.username) {
Text(
text = "@${profile.username}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Admin badge
if (profile.isAdmin) {
Spacer(modifier = Modifier.height(8.dp))
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.AdminPanelSettings,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "管理员",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
}
}
HorizontalDivider()
if (profile.isLoggedIn) {
// User info card
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "账号信息",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
ProfileInfoCard(
items = listOf(
ProfileInfoItem(Icons.Filled.Tag, "用户 ID", profile.userId),
ProfileInfoItem(Icons.Filled.CalendarMonth, "注册时间", profile.createdAt.ifEmpty { "未知" }),
),
)
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
}
Spacer(modifier = Modifier.height(8.dp))
// Assistant status
Text(
text = "助手",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = RoundedCornerShape(16.dp),
color = if (isDefaultAssistant)
Color(0xFF4CAF50).copy(alpha = 0.1f)
else
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.Circle,
contentDescription = null,
modifier = Modifier.size(10.dp),
tint = if (isDefaultAssistant) Color(0xFF4CAF50) else Color(0xFFFF5722),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (isDefaultAssistant) "已设为默认助手" else "未设为默认助手",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
if (!isDefaultAssistant) {
Text(
text = "设为默认助手后,长按电源键或Home键即可呼出昔涟",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (!isDefaultAssistant) {
TextButton(onClick = onOpenAssistantSettings) {
Text("去设置")
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
// Menu items
Text(
text = "其他",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
ListItem(
headlineContent = { Text("设置") },
leadingContent = { Icon(Icons.Filled.Settings, contentDescription = null) },
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
modifier = Modifier.clickable { onNavigateToSettings() },
)
ListItem(
headlineContent = { Text("提醒") },
leadingContent = { Icon(Icons.Filled.Notifications, contentDescription = null) },
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
ListItem(
headlineContent = { Text("关于") },
leadingContent = { Icon(Icons.Filled.Info, contentDescription = null) },
supportingContent = { Text("Cyrene v0.1.0") },
modifier = Modifier.clickable { onNavigateToAbout() },
)
ListItem(
headlineContent = { Text("使用帮助") },
leadingContent = { Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null) },
)
Spacer(modifier = Modifier.height(16.dp))
// Logout
if (profile.isLoggedIn) {
ListItem(
headlineContent = {
Text(
text = "退出登录",
color = MaterialTheme.colorScheme.error,
)
},
leadingContent = {
Icon(
Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
},
modifier = Modifier.clickable { showLogoutDialog = true },
)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
private data class ProfileInfoItem(
val icon: ImageVector,
val label: String,
val value: String,
)
@Composable
private fun ProfileInfoCard(items: List<ProfileInfoItem>) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
) {
Column {
items.forEachIndexed { index, item ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
item.icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = item.value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (index < items.size - 1) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
}
}
}
}
@@ -0,0 +1,377 @@
package top.yeij.cyrene.ui.screens.settings
import android.app.Activity
import android.content.Intent
import android.os.Build
import android.provider.Settings
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
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.layout.width
import androidx.compose.foundation.rememberScrollState
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.BatterySaver
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import top.yeij.cyrene.service.WebSocketKeepAliveService
import top.yeij.cyrene.util.KeepAliveManager
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KeepAlivePage(
onBack: () -> Unit,
) {
val context = LocalContext.current
val keepAliveManager = KeepAliveManager(context)
val fgRunning = WebSocketKeepAliveService.isRunning
val batteryExempt = keepAliveManager.isBatteryOptimizationExempt()
val canOverlay = keepAliveManager.canDrawOverlays()
val manufacturerName = keepAliveManager.getManufacturerName()
val batteryLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) {
// Re-check battery optimization after returning from settings
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("后台保活") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
// Header explanation
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
Icons.Filled.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Android 系统会在应用进入后台后限制网络连接或终止进程,导致无法接收服务端主动推送的消息。请按照以下方法加强后台保活能力。",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "保活方式",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
// 1. Foreground Service
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.Notifications,
contentDescription = null,
tint = if (fgRunning) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "前台服务通知",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = if (fgRunning) "已开启,通知栏显示「昔涟 — 已连接」" else "切后台时显示持久通知保活"
)
}
Switch(
checked = fgRunning,
onCheckedChange = {
if (it) {
WebSocketKeepAliveService.start(context)
} else {
WebSocketKeepAliveService.stop(context)
}
},
)
}
}
// 2. Battery Optimization
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = null,
tint = if (batteryExempt) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "忽略电池优化",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = if (batteryExempt) "已免除,Doze 模式不会限制网络"
else "未免除,后台待久会被系统限制网络(Doze 休眠)"
)
}
if (!batteryExempt) {
TextButton(onClick = {
batteryLauncher.launch(
keepAliveManager.openBatteryOptimizationSettings()
)
}) {
Text("去设置")
}
}
}
}
// 3. Auto-start (OEM-specific)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.PlayArrow,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "自启动管理",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = when (manufacturerName) {
"xiaomi" -> "小米手机请在「安全中心 → 自启动管理」中允许昔涟自启动"
"huawei" -> "华为手机请在「手机管家 → 自启动管理」中允许昔涟自启动"
"oppo" -> "OPPO 手机请在「设置 → 应用自启动」中允许昔涟自启动"
"vivo" -> "vivo 手机请在「i管家 → 自启动」中允许昔涟自启动"
"oneplus" -> "一加手机请在「设置 → 自启动」中允许昔涟自启动"
"samsung" -> "三星手机请在「设置 → 电池 → 不受限制的应用」中添加昔涟"
else -> "请在系统设置中为昔涟开启「自启动/后台运行」权限"
}
)
}
TextButton(onClick = {
val intent = keepAliveManager.getAutoStartIntent()
if (intent != null) {
try {
context.startActivity(intent)
} catch (_: Exception) {
// Fallback to app info
try {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
} catch (_: Exception) { }
}
} else {
Toast.makeText(context, "未找到对应设置页面,请手动前往系统设置", Toast.LENGTH_LONG).show()
}
}) {
Text("去设置")
}
}
}
// 4. Lock task (recent apps lock)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.Security,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "锁定后台任务",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = "进入最近任务界面(多任务键),将昔涟卡片下拉锁定,防止系统清理后台时误杀"
)
}
}
}
// 5. Battery saver passthrough
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.BatterySaver,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "电池优化白名单",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = "手动确认系统电池优化白名单,确保昔涟不被限制"
)
}
TextButton(onClick = {
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
try {
context.startActivity(intent)
} catch (_: Exception) { }
}) {
Text("查看")
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "补充提示",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
Text(
text = """
不同的手机厂商对待后台应用的方式各不相同:
• 谷歌 Pixel / 原生 Android:开启电池优化豁免即可
• 小米 MIUI / HyperOS:需同时开启自启动 + 电池无限制
• 华为 HarmonyOS:需开启自启动 + 关闭省电模式限制
• OPPO ColorOS / vivo OriginOS:需开启自启动 + 后台运行
• 三星 OneUI:需添加到「不受限制的应用」列表
实际效果因系统版本和厂商策略而异。建议至少开启「前台服务通知」+「忽略电池优化」两项。
""".trimIndent(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@@ -0,0 +1,954 @@
package top.yeij.cyrene.ui.screens.settings
import android.os.Build
import android.widget.Toast
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.automirrored.filled.Send
import androidx.compose.material.icons.filled.BatterySaver
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.SettingsBrightness
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
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.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.graphics.Brush
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
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.ui.theme.PresetColorLabels
import top.yeij.cyrene.ui.theme.PresetThemeColors
import top.yeij.cyrene.util.KeepAliveManager
import top.yeij.cyrene.util.LogCategory
import top.yeij.cyrene.util.RootKeepAliveHelper
import top.yeij.cyrene.util.RuntimeLog
import top.yeij.cyrene.viewmodel.SettingsViewModel
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onNavigateToKeepAlive: () -> Unit = {},
viewModel: SettingsViewModel = koinInject(),
) {
val baseUrl by viewModel.baseUrl.collectAsState()
val themeMode by viewModel.themeMode.collectAsState()
val wakeWord by viewModel.wakeWord.collectAsState()
val dashScopeApiKey by viewModel.dashScopeApiKey.collectAsState()
val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState()
val dashScopeModel by viewModel.dashScopeModel.collectAsState()
val autoScreenContext by viewModel.autoScreenContext.collectAsState()
val typingIndicatorStyle by viewModel.typingIndicatorStyle.collectAsState()
val themeColor by viewModel.themeColor.collectAsState()
val enterToSend by viewModel.enterToSend.collectAsState()
val rootKeepAlive by viewModel.rootKeepAlive.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.updateBaseUrlInput(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)
},
)
var showColorDialog by remember { mutableStateOf(false) }
val currentColorLabel = PresetColorLabels[themeColor] ?: "昔涟粉"
ListItem(
headlineContent = { Text("主题色") },
supportingContent = { Text(currentColorLabel) },
leadingContent = {
Box(
modifier = Modifier
.size(24.dp)
.background(
color = androidx.compose.ui.graphics.Color(
(PresetThemeColors[themeColor]?.seed ?: 0xFFE91E8C).toInt()
),
shape = CircleShape,
),
)
},
modifier = Modifier.clickable { showColorDialog = true },
)
if (showColorDialog) {
AlertDialog(
onDismissRequest = { showColorDialog = false },
title = { Text("选择主题色") },
text = {
Column {
// Monet option (Android 12+)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
val isMonet = themeColor == "monet"
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.saveThemeColor("monet")
showColorDialog = false
}
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(36.dp)
.background(
brush = androidx.compose.ui.graphics.Brush.horizontalGradient(
listOf(
androidx.compose.ui.graphics.Color(0xFF4ECDC4),
androidx.compose.ui.graphics.Color(0xFFFF6B6B),
androidx.compose.ui.graphics.Color(0xFFFFE66D),
androidx.compose.ui.graphics.Color(0xFF45B7D1),
)
),
shape = CircleShape,
)
.then(
if (isMonet) Modifier.border(
3.dp,
MaterialTheme.colorScheme.primary,
CircleShape,
) else Modifier
),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "莫奈取色",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "跟随壁纸",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Preset color chips
FlowRow(
modifier = Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
PresetThemeColors.keys.forEach { key ->
val isSelected = themeColor == key
val seedColor = androidx.compose.ui.graphics.Color(
(PresetThemeColors[key]?.seed ?: 0xFFE91E8C).toInt()
)
val label = PresetColorLabels[key] ?: key
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clickable {
viewModel.saveThemeColor(key)
showColorDialog = false
}
.padding(4.dp),
) {
Box(
modifier = Modifier
.size(36.dp)
.background(seedColor, CircleShape)
.then(
if (isSelected) Modifier.border(
3.dp,
MaterialTheme.colorScheme.primary,
CircleShape,
) else Modifier.border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
CircleShape,
)
),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = { showColorDialog = false }) {
Text("关闭")
}
},
)
}
val indicatorStyleLabel = if (typingIndicatorStyle == "text") "文字" else "气泡"
ListItem(
headlineContent = { Text("正在输入指示器") },
supportingContent = { Text(indicatorStyleLabel) },
leadingContent = { Icon(Icons.Filled.Palette, contentDescription = null) },
modifier = Modifier.clickable {
val next = if (typingIndicatorStyle == "bubble") "text" else "bubble"
viewModel.saveTypingIndicatorStyle(next)
},
)
ListItem(
headlineContent = { Text("回车键发送") },
supportingContent = { Text(if (enterToSend) "回车直接发送消息" else "回车换行") },
leadingContent = { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) },
trailingContent = {
androidx.compose.material3.Switch(
checked = enterToSend,
onCheckedChange = { viewModel.saveEnterToSend(it) },
)
},
modifier = Modifier.clickable { viewModel.saveEnterToSend(!enterToSend) },
)
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Background keep-alive
val keepAliveManager = remember { KeepAliveManager(context) }
var isBatteryExempt by remember { mutableStateOf(keepAliveManager.isBatteryOptimizationExempt()) }
val lifecycleOwner = LocalLifecycleOwner.current
// Re-check battery exemption when returning from system settings
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.lifecycle.addObserver(object : androidx.lifecycle.DefaultLifecycleObserver {
override fun onResume(owner: androidx.lifecycle.LifecycleOwner) {
isBatteryExempt = keepAliveManager.isBatteryOptimizationExempt()
}
})
}
// Hidden root toggle: tap section title 5 times to reveal
var rootTapCount by remember { mutableIntStateOf(0) }
var rootRevealed by remember { mutableStateOf(false) }
Text(
text = "后台保活",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(16.dp)
.combinedClickable(
onClick = {
if (!rootRevealed) {
rootTapCount++
if (rootTapCount >= 5) {
rootRevealed = true
rootTapCount = 0
Toast.makeText(context, "已解锁 Root 保活选项", Toast.LENGTH_SHORT).show()
}
}
},
),
)
ListItem(
headlineContent = { Text("忽略电池优化") },
supportingContent = {
Text(
if (isBatteryExempt) "已允许,后台连接更稳定"
else "未允许,建议开启以确保消息推送及时送达"
)
},
leadingContent = {
Icon(
Icons.Filled.BatterySaver,
contentDescription = null,
tint = if (isBatteryExempt) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error,
)
},
modifier = Modifier.clickable {
if (!isBatteryExempt) {
try {
context.startActivity(keepAliveManager.openBatteryOptimizationSettings())
} catch (_: Exception) {
Toast.makeText(context, "无法打开电池优化设置", Toast.LENGTH_SHORT).show()
}
}
},
)
// Root-level keep-alive (hidden by default, revealed after 5 taps)
if (rootRevealed) {
val isRootAvailable = remember { RootKeepAliveHelper.isRootAvailable() }
ListItem(
headlineContent = { Text("Root 保活 (隐藏)") },
supportingContent = {
Text(
if (!isRootAvailable) "未检测到 Root 权限"
else if (rootKeepAlive) "已启用 — 系统级白名单、Doze豁免、强制后台运行"
else "使用 Root 权限将应用加入系统级白名单,对抗任何保活限制"
)
},
leadingContent = {
Icon(
Icons.Filled.Terminal,
contentDescription = null,
tint = if (rootKeepAlive && isRootAvailable) MaterialTheme.colorScheme.tertiary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailingContent = {
Switch(
checked = rootKeepAlive,
enabled = isRootAvailable,
onCheckedChange = { enabled ->
if (enabled) {
val success = RootKeepAliveHelper.applyRootKeepAlive(context.packageName)
if (success) {
viewModel.saveRootKeepAlive(true)
Toast.makeText(context, "Root 保活已启用", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Root 保活应用失败,请检查 Root 权限", Toast.LENGTH_LONG).show()
}
} else {
RootKeepAliveHelper.removeRootKeepAlive(context.packageName)
viewModel.saveRootKeepAlive(false)
Toast.makeText(context, "Root 保活已关闭", Toast.LENGTH_SHORT).show()
}
},
)
},
modifier = Modifier.clickable(enabled = isRootAvailable) {
val newState = !rootKeepAlive
if (newState) {
val success = RootKeepAliveHelper.applyRootKeepAlive(context.packageName)
if (success) {
viewModel.saveRootKeepAlive(true)
Toast.makeText(context, "Root 保活已启用", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Root 保活应用失败", Toast.LENGTH_LONG).show()
}
} else {
RootKeepAliveHelper.removeRootKeepAlive(context.packageName)
viewModel.saveRootKeepAlive(false)
Toast.makeText(context, "Root 保活已关闭", Toast.LENGTH_SHORT).show()
}
},
)
// System wakelock toggle (held only while app is alive, reapplied on boot)
var sysWakeLockHeld by remember { mutableStateOf(false) }
ListItem(
headlineContent = { Text("系统级 WakeLock (隐藏)") },
supportingContent = {
Text(
if (!isRootAvailable) "需要 Root 权限"
else if (sysWakeLockHeld) "已持有系统级内核锁,CPU永不休眠"
else "写入 /sys/power/wake_lock 阻止 CPU 休眠"
)
},
leadingContent = {
Icon(
Icons.Filled.Security,
contentDescription = null,
tint = if (sysWakeLockHeld) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailingContent = {
Switch(
checked = sysWakeLockHeld,
enabled = isRootAvailable,
onCheckedChange = { enable ->
if (enable) {
val ok = RootKeepAliveHelper.acquireSystemWakeLock("CyreneKA")
if (ok) {
sysWakeLockHeld = true
Toast.makeText(context, "系统 WakeLock 已持有 — 注意:将显著增加耗电", Toast.LENGTH_LONG).show()
} else {
Toast.makeText(context, "WakeLock 获取失败", Toast.LENGTH_SHORT).show()
}
} else {
val ok = RootKeepAliveHelper.releaseSystemWakeLock("CyreneKA")
if (ok) {
sysWakeLockHeld = false
Toast.makeText(context, "系统 WakeLock 已释放", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "WakeLock 释放失败", Toast.LENGTH_SHORT).show()
}
}
},
)
},
)
}
ListItem(
headlineContent = { Text("保活设置") },
supportingContent = { Text("前台服务、自启动管理、OEM厂商后台白名单") },
leadingContent = { Icon(Icons.Filled.Security, contentDescription = null) },
modifier = Modifier.clickable { onNavigateToKeepAlive() },
)
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,
)
Spacer(modifier = Modifier.height(8.dp))
ListItem(
headlineContent = { Text("自动读取屏幕内容") },
supportingContent = { Text("电源键唤起助手时自动截取当前屏幕内容并加入对话上下文") },
trailingContent = {
androidx.compose.material3.Switch(
checked = autoScreenContext,
onCheckedChange = { viewModel.saveAutoScreenContext(it) },
)
},
modifier = Modifier.clickable { viewModel.saveAutoScreenContext(!autoScreenContext) },
)
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// DashScope STT
Text(
text = "语音识别 (DashScope)",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp),
)
OutlinedTextField(
value = dashScopeApiKey,
onValueChange = { viewModel.updateDashScopeApiKeyInput(it) },
label = { Text("API Key") },
placeholder = { Text("sk-xxxxxxxxxxxxxxxx") },
singleLine = true,
shape = MaterialTheme.shapes.medium,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
scope.launch {
viewModel.saveDashScopeApiKey(dashScopeApiKey)
Toast.makeText(context, "API Key 已保存", Toast.LENGTH_SHORT).show()
}
},
),
trailingIcon = {
FilledTonalIconButton(onClick = {
scope.launch {
viewModel.saveDashScopeApiKey(dashScopeApiKey)
Toast.makeText(context, "API Key 已保存", Toast.LENGTH_SHORT).show()
}
}) {
Icon(Icons.Filled.Check, contentDescription = "保存")
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = dashScopeEndpoint,
onValueChange = { viewModel.updateDashScopeEndpointInput(it) },
label = { Text("WebSocket 端点") },
placeholder = { Text("wss://dashscope.aliyuncs.com/api-ws/v1/inference") },
singleLine = true,
shape = MaterialTheme.shapes.medium,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
scope.launch {
viewModel.saveDashScopeEndpoint(dashScopeEndpoint)
Toast.makeText(context, "端点已保存", Toast.LENGTH_SHORT).show()
}
},
),
trailingIcon = {
FilledTonalIconButton(onClick = {
scope.launch {
viewModel.saveDashScopeEndpoint(dashScopeEndpoint)
Toast.makeText(context, "端点已保存", Toast.LENGTH_SHORT).show()
}
}) {
Icon(Icons.Filled.Check, contentDescription = "保存")
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = dashScopeModel,
onValueChange = { viewModel.updateDashScopeModelInput(it) },
label = { Text("模型") },
placeholder = { Text("fun-asr-realtime") },
singleLine = true,
shape = MaterialTheme.shapes.medium,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
scope.launch {
viewModel.saveDashScopeModel(dashScopeModel)
Toast.makeText(context, "模型已保存", Toast.LENGTH_SHORT).show()
}
},
),
trailingIcon = {
FilledTonalIconButton(onClick = {
scope.launch {
viewModel.saveDashScopeModel(dashScopeModel)
Toast.makeText(context, "模型已保存", Toast.LENGTH_SHORT).show()
}
}) {
Icon(Icons.Filled.Check, contentDescription = "保存")
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "未配置 API Key 时,语音输入将自动使用后端服务处理。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Data management
Text(
text = "数据",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp),
)
var showClearDialog by remember { mutableStateOf(false) }
ListItem(
headlineContent = { Text("清空本地消息记录") },
supportingContent = { Text("仅清除本地数据库,服务器消息仍保留。下次加载将只获取清除时间之后的消息。") },
leadingContent = { Icon(Icons.Filled.DeleteForever, contentDescription = null, tint = MaterialTheme.colorScheme.error) },
modifier = Modifier.clickable { showClearDialog = true },
)
if (showClearDialog) {
AlertDialog(
onDismissRequest = { showClearDialog = false },
title = { Text("确认清空") },
text = { Text("将清空所有本地消息记录。服务器上的消息不会被删除,但下次加载历史时将只获取本次清除之后的消息。") },
confirmButton = {
TextButton(
onClick = {
showClearDialog = false
val activity = context as? FragmentActivity
if (activity == null) {
viewModel.clearLocalMessages()
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
return@TextButton
}
val biometricManager = BiometricManager.from(context)
val canAuth = when (biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)) {
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
}
if (!canAuth) {
viewModel.clearLocalMessages()
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
return@TextButton
}
val prompt = BiometricPrompt(
activity,
ContextCompat.getMainExecutor(context),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult,
) {
viewModel.clearLocalMessages()
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
}
override fun onAuthenticationFailed() {
Toast.makeText(context, "验证失败", Toast.LENGTH_SHORT).show()
}
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence,
) {
Toast.makeText(context, "验证取消", Toast.LENGTH_SHORT).show()
}
},
)
prompt.authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle("身份验证")
.setSubtitle("需要验证身份后才能清空消息记录")
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
.build(),
)
},
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text("清空")
}
},
dismissButton = {
TextButton(onClick = { showClearDialog = false }) {
Text("取消")
}
},
)
}
// Runtime logs
Text(
text = "运行日志",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp),
)
val logEntries by RuntimeLog.entries.collectAsState()
var selectedTab by remember { mutableStateOf(0) }
val tabs = listOf("全部") + LogCategory.entries.map { it.label }
val allCategories = LogCategory.entries.toList()
// Scroll to bottom
val scrollState = rememberScrollState()
androidx.compose.runtime.LaunchedEffect(logEntries.size, selectedTab) {
if (logEntries.isNotEmpty()) {
scrollState.animateScrollTo(scrollState.maxValue)
}
}
ScrollableTabRow(
selectedTabIndex = selectedTab,
edgePadding = 16.dp,
divider = {},
) {
tabs.forEachIndexed { index, label ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(label, maxLines = 1, style = MaterialTheme.typography.labelMedium) },
)
}
}
val currentCategory = if (selectedTab == 0) null else allCategories.getOrNull(selectedTab - 1)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = {
val file = RuntimeLog.exportToFile(context, currentCategory)
RuntimeLog.shareFile(context, file)
},
) {
Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text(if (currentCategory != null) "导出${currentCategory.label}" else "导出全部")
}
OutlinedButton(
onClick = {
val file = RuntimeLog.exportAllAsZip(context)
RuntimeLog.shareFile(context, file)
},
) {
Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("打包全部")
}
OutlinedButton(
onClick = { RuntimeLog.clear() },
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text("清空")
}
}
val displayLogs = if (selectedTab == 0) {
logEntries.takeLast(500)
} else {
val cat = allCategories.getOrNull(selectedTab - 1)
if (cat != null) logEntries.filter { it.category == cat }.takeLast(500) else emptyList()
}
if (displayLogs.isEmpty()) {
Text(
text = "暂无${tabs[selectedTab]}日志",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
} else {
// Log count header
Text(
text = "${displayLogs.size}${if (displayLogs.size >= 500) "+" else ""}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
// Fixed-height scrollable log area
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.height(280.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
RoundedCornerShape(8.dp),
)
.padding(8.dp),
) {
Column(
modifier = Modifier.verticalScroll(scrollState),
) {
displayLogs.forEach { entry ->
Text(
text = entry.formatted(),
style = MaterialTheme.typography.bodySmall,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 1.dp),
)
}
}
}
}
Spacer(modifier = Modifier.height(80.dp))
}
}
}
@@ -0,0 +1,201 @@
package top.yeij.cyrene.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
// Each preset provides a seed color and light/dark primary colors
data class ThemePreset(
val seed: Long,
val lightPrimary: Color,
val darkPrimary: Color,
)
val PresetThemeColors = mapOf(
"pink" to ThemePreset(
seed = 0xFFE91E8C,
lightPrimary = Color(0xFFC2185B),
darkPrimary = Color(0xFFFF80AB),
),
"sakura" to ThemePreset(
seed = 0xFFFFB4C8,
lightPrimary = Color(0xFFE91E63),
darkPrimary = Color(0xFFFFB4C8),
),
"lavender" to ThemePreset(
seed = 0xFF6D3BC0,
lightPrimary = Color(0xFF6D3BC0),
darkPrimary = Color(0xFFD3BBFF),
),
"ocean" to ThemePreset(
seed = 0xFF1565C0,
lightPrimary = Color(0xFF1565C0),
darkPrimary = Color(0xFF90CAF9),
),
"forest" to ThemePreset(
seed = 0xFF2E7D32,
lightPrimary = Color(0xFF2E7D32),
darkPrimary = Color(0xFFA5D6A7),
),
"sunset" to ThemePreset(
seed = 0xFFE65100,
lightPrimary = Color(0xFFE65100),
darkPrimary = Color(0xFFFFCC80),
),
"rose" to ThemePreset(
seed = 0xFFD81B60,
lightPrimary = Color(0xFFAD1457),
darkPrimary = Color(0xFFF48FB1),
),
"sky" to ThemePreset(
seed = 0xFF0277BD,
lightPrimary = Color(0xFF0277BD),
darkPrimary = Color(0xFF81D4FA),
),
"mint" to ThemePreset(
seed = 0xFF00695C,
lightPrimary = Color(0xFF00695C),
darkPrimary = Color(0xFF80CBC4),
),
)
val PresetColorLabels = mapOf(
"pink" to "昔涟粉",
"sakura" to "樱花粉",
"lavender" to "薰衣草紫",
"ocean" to "海洋蓝",
"forest" to "森林绿",
"sunset" to "日落橙",
"rose" to "玫瑰红",
"sky" to "天空蓝",
"mint" to "薄荷青",
)
fun getPreset(key: String): ThemePreset = PresetThemeColors[key] ?: PresetThemeColors["pink"]!!
// --- Color derivation via HSL — generates cohesive MD3-like schemes ---
private data class HSL(val h: Float, val s: Float, val l: Float)
private fun Color.toHSL(): HSL {
val r = red / 255f
val g = green / 255f
val b = blue / 255f
val maxV = max(max(r, g), b)
val minV = min(min(r, g), b)
val delta = maxV - minV
val l = (maxV + minV) / 2f
val s = if (delta == 0f) 0f else delta / (1f - abs(2f * l - 1f))
val h = when {
delta == 0f -> 0f
maxV == r -> 60f * (((g - b) / delta) % 6f)
maxV == g -> 60f * (((b - r) / delta) + 2f)
else -> 60f * (((r - g) / delta) + 4f)
}
return HSL(if (h < 0) h + 360f else h, s, l)
}
private fun hslToColor(h: Float, s: Float, l: Float): Color {
val c = (1f - abs(2f * l - 1f)) * s
val x = c * (1f - abs((h / 60f) % 2f - 1f))
val m = l - c / 2f
val (r, g, b) = when {
h < 60f -> Triple(c, x, 0f)
h < 120f -> Triple(x, c, 0f)
h < 180f -> Triple(0f, c, x)
h < 240f -> Triple(0f, x, c)
h < 300f -> Triple(x, 0f, c)
else -> Triple(c, 0f, x)
}
return Color((r + m).coerceIn(0f, 1f), (g + m).coerceIn(0f, 1f), (b + m).coerceIn(0f, 1f))
}
/**
* Build a full light ColorScheme derived from a primary color.
* All secondary/tertiary/container colors are computed from the primary
* so focus rings, ripples, badges, and containers all match the theme.
*/
fun buildLightScheme(primary: Color): ColorScheme {
val hsl = primary.toHSL()
val primaryContainer = hslToColor(hsl.h, 0.3f, 0.90f)
val onPrimaryContainer = hslToColor(hsl.h, 0.5f, 0.15f)
// Secondary: similar hue, less saturated
val secH = (hsl.h + 15f) % 360f
val secondary = hslToColor(secH, 0.35f, 0.42f)
val secondaryContainer = hslToColor(secH, 0.25f, 0.90f)
val onSecondaryContainer = hslToColor(secH, 0.3f, 0.15f)
// Tertiary: complementary hue shift
val terH = (hsl.h + 60f) % 360f
val tertiary = hslToColor(terH, 0.40f, 0.38f)
val tertiaryContainer = hslToColor(terH, 0.30f, 0.90f)
val onTertiaryContainer = hslToColor(terH, 0.35f, 0.15f)
val onPrimary = Color.White
val onSecondary = Color.White
val onTertiary = Color.White
val surfaceTint = primary
return lightColorScheme(
primary = primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = onPrimaryContainer,
secondary = secondary,
onSecondary = onSecondary,
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSecondaryContainer,
tertiary = tertiary,
onTertiary = onTertiary,
tertiaryContainer = tertiaryContainer,
onTertiaryContainer = onTertiaryContainer,
surfaceTint = surfaceTint,
)
}
/**
* Build a full dark ColorScheme derived from a primary color.
*/
fun buildDarkScheme(primary: Color): ColorScheme {
val hsl = primary.toHSL()
val primaryContainer = hslToColor(hsl.h, 0.40f, 0.22f)
val onPrimaryContainer = hslToColor(hsl.h, 0.30f, 0.88f)
val onPrimary = hslToColor(hsl.h, 0.5f, 0.10f)
val secH = (hsl.h + 15f) % 360f
val secondary = hslToColor(secH, 0.40f, 0.76f)
val secondaryContainer = hslToColor(secH, 0.30f, 0.22f)
val onSecondaryContainer = hslToColor(secH, 0.30f, 0.88f)
val onSecondary = hslToColor(secH, 0.3f, 0.12f)
val terH = (hsl.h + 60f) % 360f
val tertiary = hslToColor(terH, 0.40f, 0.80f)
val tertiaryContainer = hslToColor(terH, 0.30f, 0.22f)
val onTertiaryContainer = hslToColor(terH, 0.30f, 0.88f)
val onTertiary = hslToColor(terH, 0.3f, 0.12f)
val surfaceTint = primary
return darkColorScheme(
primary = primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = onPrimaryContainer,
secondary = secondary,
onSecondary = onSecondary,
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSecondaryContainer,
tertiary = tertiary,
onTertiary = onTertiary,
tertiaryContainer = tertiaryContainer,
onTertiaryContainer = onTertiaryContainer,
surfaceTint = surfaceTint,
)
}
@@ -0,0 +1,13 @@
package top.yeij.cyrene.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
val CyreneShapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(12.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(24.dp),
extraLarge = RoundedCornerShape(32.dp),
)
@@ -0,0 +1,58 @@
package top.yeij.cyrene.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
@Composable
fun CyreneTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
presetKey: String = "pink",
useDynamicColor: Boolean = false,
content: @Composable () -> Unit,
) {
val preset = getPreset(presetKey)
val colorScheme = when {
useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> buildDarkScheme(preset.darkPrimary)
else -> buildLightScheme(preset.lightPrimary)
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as? Activity)?.window
if (window != null) {
window.statusBarColor = colorScheme.background.toArgb()
window.navigationBarColor = colorScheme.background.toArgb()
window.decorView.setBackgroundColor(colorScheme.background.toArgb())
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme
}
} else {
view.rootView?.setBackgroundColor(android.graphics.Color.TRANSPARENT)
}
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = CyreneTypography,
shapes = CyreneShapes,
content = content,
)
}
@@ -0,0 +1,54 @@
package top.yeij.cyrene.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val CyreneTypography = Typography(
displayLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
),
headlineMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 28.sp,
lineHeight = 36.sp,
),
titleLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
),
titleMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
),
labelLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
),
labelMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
),
labelSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
),
)
@@ -0,0 +1,15 @@
package top.yeij.cyrene.util
object Constants {
const val DEFAULT_WAKE_WORD = "昔涟"
const val SILENCE_TIMEOUT_MS = 10_000L
const val WS_RECONNECT_MAX_ATTEMPTS = 5
const val WS_PING_INTERVAL_SECONDS = 30L
const val HOTWORD_INACTIVITY_TIMEOUT_MINUTES = 10L
const val OVERLAY_ANIM_DURATION_MS = 300
const val STT_SILENCE_THRESHOLD_MS = 1500L
// Intent extras
const val EXTRA_VOICE_ASSIST = "voice_assist"
const val EXTRA_START_LISTENING = "start_listening"
}
@@ -0,0 +1,149 @@
package top.yeij.cyrene.util
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import top.yeij.cyrene.service.WebSocketKeepAliveService
class KeepAliveManager(private val context: Context) {
// --- 前台服务 ---
val isForegroundServiceRunning: Boolean
get() = WebSocketKeepAliveService.isRunning
// --- 电池优化 ---
fun isBatteryOptimizationExempt(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
return pm.isIgnoringBatteryOptimizations(context.packageName)
}
fun openBatteryOptimizationSettings(): Intent {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}")
}
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${context.packageName}")
}
}
}
// --- 自启动 / 后台管理 (OEM-specific) ---
fun getAutoStartIntent(): Intent? {
val packageName = context.packageName
val manufacturers = listOf(
// Xiaomi
AutoStartIntent("xiaomi", Intent().apply {
component = ComponentName(
"com.miui.securitycenter",
"com.miui.permcenter.autostart.AutoStartManagementActivity"
)
}),
AutoStartIntent("xiaomi", Intent().apply {
component = ComponentName(
"com.miui.securitycenter",
"com.miui.appmanager.ApplicationsManagerActivity"
)
}),
// Huawei
AutoStartIntent("huawei", Intent().apply {
component = ComponentName(
"com.huawei.systemmanager",
"com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity"
)
}),
AutoStartIntent("huawei", Intent().apply {
component = ComponentName(
"com.huawei.systemmanager",
"com.huawei.systemmanager.optimize.process.ProtectActivity"
)
}),
// Oppo
AutoStartIntent("oppo", Intent().apply {
component = ComponentName(
"com.coloros.safecenter",
"com.coloros.safecenter.permission.startup.StartupAppListActivity"
)
}),
AutoStartIntent("oppo", Intent().apply {
component = ComponentName(
"com.coloros.safecenter",
"com.coloros.safecenter.permission.startup.FakeActivity"
)
}),
// Vivo
AutoStartIntent("vivo", Intent().apply {
component = ComponentName(
"com.vivo.permissionmanager",
"com.vivo.permissionmanager.activity.BgStartUpManagerActivity"
)
}),
AutoStartIntent("vivo", Intent().apply {
component = ComponentName(
"com.iqoo.secure",
"com.iqoo.secure.ui.phoneoptimize.AddWhiteListActivity"
)
}),
// Samsung
AutoStartIntent("samsung", Intent().apply {
component = ComponentName(
"com.samsung.android.lool",
"com.samsung.android.sm.ui.battery.BatteryActivity"
)
}),
// OnePlus
AutoStartIntent("oneplus", Intent().apply {
component = ComponentName(
"com.oneplus.security",
"com.oneplus.security.chainlaunch.view.ChainLaunchAppListActivity"
)
}),
// Generic fallback: app info
AutoStartIntent("generic", Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:$packageName")
)),
)
for (entry in manufacturers) {
val intent = entry.intent
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(context.packageManager) != null) {
return intent
}
}
return null
}
fun getManufacturerName(): String {
return Build.MANUFACTURER.lowercase()
}
private data class AutoStartIntent(val manufacturer: String, val intent: Intent)
// --- 悬浮窗权限 (optional, for overlay mode) ---
fun canDrawOverlays(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Settings.canDrawOverlays(context)
} else {
true
}
}
fun openOverlaySettings(): Intent {
return Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}")
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
}
}
@@ -0,0 +1,71 @@
package top.yeij.cyrene.util
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.R
import top.yeij.cyrene.domain.model.Message
class NotificationHelper(private val context: Context) {
private val notificationManager = context.getSystemService(NotificationManager::class.java)
init {
createChannel()
}
private fun createChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"消息通知",
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "昔涟的新消息通知"
enableVibration(true)
setShowBadge(true)
}
notificationManager.createNotificationChannel(channel)
}
fun showMessageNotification(message: Message) {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val preview = if (message.content.length > 50) {
message.content.take(50) + "..."
} else {
message.content
}
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("昔涟")
.setContentText(preview)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
val notifyId = message.id.hashCode()
notificationManager.notify(notifyId, notification)
RuntimeLog.notify("posted", "Notification posted: id=$notifyId preview='$preview'")
}
fun cancelAll() {
notificationManager.cancelAll()
}
companion object {
private const val CHANNEL_ID = "cyrene_messages"
}
}
@@ -0,0 +1,170 @@
package top.yeij.cyrene.util
import android.util.Log
import java.io.DataOutputStream
import java.io.File
/**
* Hidden root-based keep-alive operations. Only accessible via secret gesture in Settings.
* Performs system-level whitelisting that normal apps cannot do.
*/
object RootKeepAliveHelper {
private const val TAG = "CyreneRootKA"
private fun execRoot(vararg commands: String): Boolean {
return try {
val process = Runtime.getRuntime().exec("su")
val os = DataOutputStream(process.outputStream)
val shell = buildString {
append("export PATH=\$PATH:/system/bin:/system/xbin:/su/bin:/sbin:/vendor/bin\n")
for (cmd in commands) {
append(cmd).append(" 2>&1\n")
}
append("exit\n")
}
os.writeBytes(shell)
os.flush()
os.close()
process.waitFor()
val exitCode = process.exitValue()
if (exitCode != 0) {
Log.w(TAG, "Root command returned exit code $exitCode")
}
exitCode == 0
} catch (e: Exception) {
Log.e(TAG, "Root exec failed: ${e.message}")
false
}
}
fun isRootAvailable(): Boolean {
// Check common su binary locations
val suPaths = listOf(
"/system/bin/su",
"/system/xbin/su",
"/su/bin/su",
"/sbin/su",
"/system/sbin/su",
"/vendor/bin/su",
"/data/local/bin/su",
)
for (path in suPaths) {
if (File(path).exists()) return true
}
// Fallback: try running 'which su'
return try {
val p = Runtime.getRuntime().exec(arrayOf("which", "su"))
p.waitFor()
p.exitValue() == 0
} catch (_: Exception) {
false
}
}
/**
* Apply aggressive root-level keep-alive.
* @param packageName The app's package name.
*/
fun applyRootKeepAlive(packageName: String): Boolean {
if (!isRootAvailable()) {
Log.w(TAG, "Root not available, cannot apply root keep-alive")
return false
}
Log.i(TAG, "Applying root keep-alive for $packageName")
val commands = mutableListOf<String>()
// 1. Doze whitelist — prevent Doze from blocking network/wakelocks
commands.add("dumpsys deviceidle whitelist +$packageName")
// 2. Disable standby bucket — keep app in "active" bucket
commands.add("am set-standby-bucket $packageName active")
// 3. Grant WAKE_LOCK permission at system level (bypasses appops)
commands.add("appops set $packageName WAKE_LOCK allow")
// 4. Disable battery optimization via system settings
commands.add("settings put global app_restrictions_enabled false")
commands.add("cmd appops set $packageName RUN_IN_BACKGROUND allow")
commands.add("cmd appops set $packageName RUN_ANY_IN_BACKGROUND allow")
// 5. Make app not battery-restricted (hidden API)
commands.add("cmd deviceidle tempwhitelist $packageName")
// 6. Set app as START_FOREGROUND always allowed
commands.add("appops set $packageName START_FOREGROUND allow")
// 7. Persistent alarm allowance
commands.add("appops set $packageName SCHEDULE_EXACT_ALARM allow")
val success = execRoot(*commands.toTypedArray())
if (success) {
Log.i(TAG, "Root keep-alive applied successfully")
} else {
Log.e(TAG, "Failed to apply root keep-alive")
}
return success
}
/**
* Remove root-level keep-alive settings.
*/
fun removeRootKeepAlive(packageName: String): Boolean {
if (!isRootAvailable()) return false
Log.i(TAG, "Removing root keep-alive for $packageName")
val commands = listOf(
"dumpsys deviceidle whitelist -$packageName",
"am set-standby-bucket $packageName rarely",
"appops set $packageName WAKE_LOCK default",
"appops set $packageName RUN_IN_BACKGROUND default",
"appops set $packageName RUN_ANY_IN_BACKGROUND default",
"appops set $packageName START_FOREGROUND default",
"appops set $packageName SCHEDULE_EXACT_ALARM default",
)
val success = execRoot(*commands.toTypedArray())
if (success) {
Log.i(TAG, "Root keep-alive removed successfully")
}
return success
}
/**
* Hold a system-level wakelock. Use sparingly — drains battery.
* Released automatically when the process exits or can be released manually.
*/
private var wakeLockFile: File? = null
fun acquireSystemWakeLock(tag: String): Boolean {
if (!isRootAvailable()) return false
val lockPath = "/sys/power/wake_lock"
return try {
execRoot("echo '$tag' > $lockPath")
wakeLockFile = File(lockPath)
Log.i(TAG, "System wakelock acquired: $tag")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to acquire system wakelock: ${e.message}")
false
}
}
fun releaseSystemWakeLock(tag: String): Boolean {
if (!isRootAvailable()) return false
val lockPath = "/sys/power/wake_unlock"
return try {
execRoot("echo '$tag' > $lockPath")
wakeLockFile = null
Log.i(TAG, "System wakelock released: $tag")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to release system wakelock: ${e.message}")
false
}
}
}
@@ -0,0 +1,128 @@
package top.yeij.cyrene.util
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
enum class LogCategory(val label: String) {
CHAT("聊天"),
WS("WebSocket"),
STT("语音识别"),
GENERAL("通用"),
HTTP("网络"),
VOICE("语音"),
NOTIFY("通知"),
}
data class LogEntry(
val timestamp: Long,
val category: LogCategory,
val tag: String,
val message: String,
) {
fun formatted(): String {
val sdf = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
return "[${sdf.format(Date(timestamp))}] [${category.name}] [$tag] $message"
}
}
object RuntimeLog {
private const val MAX_ENTRIES = 2000
private val _entries = MutableStateFlow<List<LogEntry>>(emptyList())
val entries: StateFlow<List<LogEntry>> = _entries.asStateFlow()
private val buffer = ArrayDeque<LogEntry>(MAX_ENTRIES)
@Synchronized
fun log(category: LogCategory, tag: String, message: String) {
val entry = LogEntry(System.currentTimeMillis(), category, tag, message)
if (buffer.size >= MAX_ENTRIES) {
buffer.removeFirst()
}
buffer.addLast(entry)
_entries.value = buffer.toList()
}
fun chat(tag: String, message: String) = log(LogCategory.CHAT, tag, message)
fun ws(tag: String, message: String) = log(LogCategory.WS, tag, message)
fun stt(tag: String, message: String) = log(LogCategory.STT, tag, message)
fun general(tag: String, message: String) = log(LogCategory.GENERAL, tag, message)
fun http(tag: String, message: String) = log(LogCategory.HTTP, tag, message)
fun voice(tag: String, message: String) = log(LogCategory.VOICE, tag, message)
fun notify(tag: String, message: String) = log(LogCategory.NOTIFY, tag, message)
@Synchronized
fun getByCategory(category: LogCategory): List<LogEntry> {
return buffer.filter { it.category == category }
}
@Synchronized
fun exportAsText(category: LogCategory): String {
val entries = buffer.filter { it.category == category }
val header = "=== Cyrene ${category.label}日志 导出时间: ${
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
} ===\n\n"
return header + entries.joinToString("\n") { it.formatted() }
}
@Synchronized
fun exportAllAsText(): String {
val header = "=== Cyrene 全部日志 导出时间: ${
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
} ===\n\n"
val byCategory = buffer.groupBy { it.category }
return header + byCategory.entries.joinToString("\n\n") { (cat, entries) ->
"--- ${cat.label} (${cat.name}) ---\n" + entries.joinToString("\n") { it.formatted() }
}
}
fun exportToFile(context: Context, category: LogCategory? = null): File {
val text = if (category != null) exportAsText(category) else exportAllAsText()
val label = category?.name?.lowercase() ?: "all"
val dateStr = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val file = File(context.cacheDir, "cyrene_log_${label}_$dateStr.txt")
file.writeText(text)
return file
}
fun exportAllAsZip(context: Context): File {
val dateStr = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val zipFile = File(context.cacheDir, "cyrene_logs_$dateStr.zip")
ZipOutputStream(zipFile.outputStream().buffered()).use { zos ->
LogCategory.entries.forEach { cat ->
val text = exportAsText(cat)
zos.putNextEntry(ZipEntry("${cat.name.lowercase()}.txt"))
zos.write(text.toByteArray(Charsets.UTF_8))
zos.closeEntry()
}
}
return zipFile
}
fun shareFile(context: Context, file: File) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_SEND).apply {
type = if (file.extension == "zip") "application/zip" else "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "导出日志"))
}
@Synchronized
fun clear() {
buffer.clear()
_entries.value = emptyList()
}
}
@@ -0,0 +1,158 @@
package top.yeij.cyrene.util
import android.content.Context
import android.media.MediaRecorder
import android.os.Build
import android.util.Base64
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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 java.io.File
enum class RecordState {
IDLE,
RECORDING,
LOCKED, // hands-free mode
CANCELLING,
}
class VoiceRecorder(private val context: Context) {
private var recorder: MediaRecorder? = null
private var outputFile: File? = null
private var timerJob: Job? = null
private val _state = MutableStateFlow(RecordState.IDLE)
val state: StateFlow<RecordState> = _state.asStateFlow()
private val _durationMs = MutableStateFlow(0L)
val durationMs: StateFlow<Long> = _durationMs.asStateFlow()
fun start() {
if (_state.value != RecordState.IDLE) return
try {
outputFile = File(context.cacheDir, "voice_${System.currentTimeMillis()}.aac")
outputFile?.delete()
recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
recorder?.apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioSamplingRate(16000)
setAudioEncodingBitRate(32000)
setAudioChannels(1)
setOutputFile(outputFile?.absolutePath)
prepare()
start()
}
_state.value = RecordState.RECORDING
startTimer()
Log.i(TAG, "Recording started: ${outputFile?.absolutePath}")
} catch (e: Exception) {
Log.e(TAG, "Failed to start recording: ${e.message}", e)
cleanup()
}
}
fun lock() {
if (_state.value == RecordState.RECORDING) {
_state.value = RecordState.LOCKED
}
}
fun stop(): File? {
if (_state.value == RecordState.IDLE) return null
val file = outputFile
try {
recorder?.apply {
try { stop() } catch (_: Exception) { }
try { release() } catch (_: Exception) { }
}
} catch (e: Exception) {
Log.e(TAG, "Error stopping recorder: ${e.message}", e)
}
recorder = null
cancelTimer()
_state.value = RecordState.IDLE
_durationMs.value = 0L
Log.i(TAG, "Recording stopped: ${file?.absolutePath}, size=${file?.length()}")
return if (file != null && file.exists() && file.length() > 0) file else null
}
fun cancel() {
val file = outputFile
try {
recorder?.apply {
try { stop() } catch (_: Exception) { }
try { release() } catch (_: Exception) { }
}
} catch (e: Exception) { }
recorder = null
file?.delete()
cancelTimer()
_state.value = RecordState.IDLE
_durationMs.value = 0L
Log.i(TAG, "Recording cancelled")
}
fun cleanup() {
try { recorder?.release() } catch (_: Exception) { }
recorder = null
outputFile?.delete()
outputFile = null
cancelTimer()
_state.value = RecordState.IDLE
_durationMs.value = 0L
}
fun getBase64(): String? {
val file = outputFile ?: return null
if (!file.exists()) return null
return try {
val bytes = file.readBytes()
Base64.encodeToString(bytes, Base64.NO_WRAP)
} catch (e: Exception) {
Log.e(TAG, "Failed to encode audio: ${e.message}", e)
null
}
}
fun deleteFile() {
outputFile?.delete()
outputFile = null
}
private fun startTimer() {
cancelTimer()
val startTime = System.currentTimeMillis()
timerJob = CoroutineScope(Dispatchers.Main).launch {
while (true) {
delay(100)
_durationMs.value = System.currentTimeMillis() - startTime
}
}
}
private fun cancelTimer() {
timerJob?.cancel()
timerJob = null
}
companion object {
private const val TAG = "VoiceRecorder"
}
}
@@ -0,0 +1,382 @@
package top.yeij.cyrene.viewmodel
import android.app.Application
import android.net.Uri
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.data.remote.dto.WSAttachment
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.util.RecordState
import top.yeij.cyrene.util.RuntimeLog
import top.yeij.cyrene.util.VoiceRecorder
private fun List<Message>.deduplicate(): List<Message> {
if (isEmpty()) return this
val seen = mutableSetOf<String>()
return filter { seen.add(it.id) }
}
class ChatViewModel(
application: Application,
private val chatRepository: ChatRepository,
private val voiceRecorder: VoiceRecorder,
private val preferencesDataStore: PreferencesDataStore,
private val apiService: ApiService,
) : AndroidViewModel(application) {
companion object {
private var instanceCounter = 0
}
private val instanceId = ++instanceCounter
val isConnected: StateFlow<Boolean> = chatRepository.connectionState
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val connectionError: StateFlow<String?> = chatRepository.connectionError
val conversations: StateFlow<List<Conversation>> = chatRepository.getConversations()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private val _currentMessages = MutableStateFlow<List<Message>>(emptyList())
val currentMessages: StateFlow<List<Message>> = _currentMessages.asStateFlow()
private val _inputText = MutableStateFlow("")
val inputText: StateFlow<String> = _inputText.asStateFlow()
private val _isSending = MutableStateFlow(false)
val isStreaming: StateFlow<Boolean> = kotlinx.coroutines.flow.combine(
_isSending,
chatRepository.isAssistantStreaming,
) { sending, assistant -> sending || assistant }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
// Voice recording state
val voiceRecordState: StateFlow<RecordState> = voiceRecorder.state
val voiceRecordDurationMs: StateFlow<Long> = voiceRecorder.durationMs
// Animation ordering for message bubbles
private var animCounter = 0
private val _messageAnimIndex = MutableStateFlow<Map<String, Int>>(emptyMap())
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
private var currentSessionId: String? = null
private var dbObserverJob: Job? = null
private var sendTimeoutJob: Job? = null
init {
RuntimeLog.general("app", "ChatViewModel instance #$instanceId created")
viewModelScope.launch {
try {
RuntimeLog.general("app", "ChatViewModel #$instanceId — initializing session...")
val sessionId = chatRepository.initializeSession()
currentSessionId = sessionId
chatRepository.currentSessionId = sessionId
RuntimeLog.general("app", "Session initialized: $sessionId")
chatRepository.ensureConnected()
} catch (e: Exception) {
RuntimeLog.general("app", "initializeSession failed: ${e.message}")
}
// Always try to load from DB, even if initializeSession failed.
// After process death the persisted session ID gives us the history.
val sid = currentSessionId
?: preferencesDataStore.currentSessionId.firstOrNull()
if (sid != null) {
currentSessionId = sid
chatRepository.currentSessionId = sid
RuntimeLog.general("app", "Loading messages from DB for session=$sid")
loadMessagesFromDb(sid)
} else {
RuntimeLog.general("app", "No session ID available — cannot load messages")
}
}
// Observe incoming live messages — insert at correct descending position
viewModelScope.launch {
chatRepository.observeMessages().collect { message ->
try {
_currentMessages.update { list ->
val updated = list.toMutableList()
val existingIdx = updated.indexOfLast { it.id == message.id }
if (existingIdx >= 0) {
updated[existingIdx] = message
} else {
// Insert at correct position for descending timestamp (newest first)
val insertAt = updated.indexOfFirst { it.timestamp <= message.timestamp }
if (insertAt >= 0) updated.add(insertAt, message) else updated.add(message)
val idx = _messageAnimIndex.value.toMutableMap()
idx[message.id] = animCounter++
_messageAnimIndex.value = idx
}
updated.deduplicate()
}
// Any non-user response means the server acknowledged our message
if (_isSending.value && message.role != "user") {
_isSending.value = false
sendTimeoutJob?.cancel()
}
} catch (e: Exception) {
Log.e("ChatViewModel", "Error processing message: ${e.message}", e)
}
}
}
// Observe message clear events
viewModelScope.launch {
chatRepository.messageClearEvents.collect {
_currentMessages.value = emptyList()
_messageAnimIndex.value = emptyMap()
animCounter = 0
}
}
// Observe message removals (e.g. wrapping stream_end deduped by review items)
viewModelScope.launch {
chatRepository.messageRemovals.collect { msgId ->
_currentMessages.update { list -> list.filter { it.id != msgId } }
val idx = _messageAnimIndex.value.toMutableMap()
idx.remove(msgId)
_messageAnimIndex.value = idx
}
}
// Reset user-side sending state when server starts responding
viewModelScope.launch {
chatRepository.isAssistantStreaming.collect { streaming ->
if (streaming) {
_isSending.value = false
sendTimeoutJob?.cancel()
}
}
}
}
// --- Voice recording (WeChat-style gesture) ---
fun startRecord() {
voiceRecorder.start()
}
fun lockRecord() {
voiceRecorder.lock()
}
fun finishRecord() {
val file = voiceRecorder.stop() ?: return
val base64 = voiceRecorder.getBase64()
voiceRecorder.deleteFile()
if (base64.isNullOrBlank()) return
viewModelScope.launch {
chatRepository.sendVoiceInput(base64, "voice_msg")
RuntimeLog.chat("voice", "Voice message sent, duration=${file.length()}")
}
}
fun cancelRecord() {
voiceRecorder.cancel()
}
// --- Image attachments ---
private val _selectedImageUris = MutableStateFlow<List<Uri>>(emptyList())
val selectedImageUris: StateFlow<List<Uri>> = _selectedImageUris.asStateFlow()
fun addImages(uris: List<Uri>) {
_selectedImageUris.update { it + uris }
}
fun removeImage(index: Int) {
_selectedImageUris.update { list ->
list.filterIndexed { i, _ -> i != index }
}
}
fun clearImages() {
_selectedImageUris.value = emptyList()
}
private data class UploadResult(
val attachment: WSAttachment,
val thumbnailUrl: String,
)
private suspend fun uploadAndBuildAttachment(uri: Uri): UploadResult? {
return withContext(Dispatchers.IO) {
try {
val cr = getApplication<Application>().contentResolver
val mimeType = cr.getType(uri) ?: "image/jpeg"
val filename = uri.lastPathSegment ?: "image"
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: return@withContext null
if (bytes.isEmpty()) return@withContext null
// Upload to server, get file_id
val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", filename, requestBody)
val response = apiService.uploadFile(part)
if (!response.isSuccessful) {
Log.e("ChatViewModel", "Upload failed: ${response.code()} ${response.message()}")
return@withContext null
}
val fileId = response.body()?.id ?: return@withContext null
// Construct thumbnail URL
val baseUrl = preferencesDataStore.baseUrl.firstOrNull()
?.trimEnd('/') ?: "http://10.0.2.2:8080"
val thumbnailUrl = "$baseUrl/api/v1/files/$fileId/thumbnail"
val attachment = WSAttachment(
type = "image",
fileId = fileId,
thumbnailUrl = thumbnailUrl,
filename = filename,
size = bytes.size.toLong(),
)
UploadResult(attachment, thumbnailUrl)
} catch (e: Exception) {
Log.e("ChatViewModel", "Failed to upload image: ${e.message}", e)
null
}
}
}
// Override sendMessage to support image attachments
fun sendMessage() {
val text = _inputText.value.trim()
val uris = _selectedImageUris.value
if (text.isEmpty() && uris.isEmpty()) return
val sid = currentSessionId
if (sid == null) {
RuntimeLog.chat("send", "Cannot send — no current session")
return
}
_inputText.value = ""
_isSending.value = true
sendTimeoutJob?.cancel()
sendTimeoutJob = viewModelScope.launch {
delay(15_000L)
if (_isSending.value) {
Log.w("ChatViewModel", "Send timeout — no response in 15s, resetting")
_isSending.value = false
}
}
val localUriStrings = uris.map { it.toString() }
viewModelScope.launch {
val results = uris.mapNotNull { uploadAndBuildAttachment(it) }
clearImages()
val attachments = results.map { it.attachment }
val thumbnailUrls = results.map { it.thumbnailUrl }
try {
chatRepository.sendMessage(
text, sid,
attachments = attachments.ifEmpty { null },
localImageUris = thumbnailUrls.ifEmpty { localUriStrings },
)
} catch (e: Exception) {
Log.e("ChatViewModel", "sendMessage failed: ${e.message}", e)
_isSending.value = false
sendTimeoutJob?.cancel()
}
}
}
private fun loadMessagesFromDb(sessionId: String) {
dbObserverJob?.cancel()
dbObserverJob = viewModelScope.launch {
try {
chatRepository.getMessages(sessionId).collect { messages ->
_currentMessages.update { current ->
val live = current.associateBy { it.id }
val db = messages.associateBy { it.id }
(db + live).values
.sortedByDescending { it.timestamp }
.deduplicate()
}
val idx = _messageAnimIndex.value.toMutableMap()
messages.forEach { m ->
if (m.id !in idx) idx[m.id] = animCounter++
}
_messageAnimIndex.value = idx
}
} catch (e: Exception) {
Log.e("ChatViewModel", "Error loading messages: ${e.message}", e)
RuntimeLog.general("app", "loadMessagesFromDb failed: ${e.message}")
}
}
}
fun onInputChanged(text: String) {
_inputText.value = text
}
fun switchSession(sessionId: String) {
currentSessionId = sessionId
chatRepository.currentSessionId = sessionId
_currentMessages.value = emptyList()
_messageAnimIndex.value = emptyMap()
animCounter = 0
viewModelScope.launch {
chatRepository.connectWebSocket(sessionId)
chatRepository.loadMessagesFromServer(sessionId)
loadMessagesFromDb(sessionId)
}
}
fun refreshMessages() {
val sid = currentSessionId ?: return
viewModelScope.launch {
_isRefreshing.value = true
try {
// Clear all messages and let the DB observer rebuild from scratch.
// This avoids duplicates that occur when local-UUID messages survive
// the merge alongside server-ID versions loaded from HTTP.
_currentMessages.value = emptyList()
if (!isConnected.value) {
chatRepository.ensureConnected()
}
chatRepository.loadMessagesFromServer(sid)
} catch (_: Exception) { }
_isRefreshing.value = false
}
}
fun deleteConversation(id: String) {
viewModelScope.launch {
chatRepository.deleteConversation(id)
}
}
fun clearLocalMessages() {
viewModelScope.launch {
chatRepository.clearLocalMessages()
_currentMessages.value = emptyList()
_messageAnimIndex.value = emptyMap()
animCounter = 0
}
}
}
@@ -0,0 +1,59 @@
package top.yeij.cyrene.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import top.yeij.cyrene.domain.model.Device
import top.yeij.cyrene.domain.repository.IoTRepository
class IoTViewModel(
private val ioTRepository: IoTRepository,
) : ViewModel() {
val devices: StateFlow<List<Device>> = ioTRepository.getDevices()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
init {
refreshDevices()
}
fun refreshDevices() {
viewModelScope.launch {
_isLoading.value = true
ioTRepository.refreshDevices()
_isLoading.value = false
}
}
fun controlDevice(deviceId: String, action: String, value: Any? = null) {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
ioTRepository.controlDevice(deviceId, action, value).fold(
onSuccess = { },
onFailure = { _error.value = it.message },
)
_isLoading.value = false
}
}
fun togglePower(device: Device) {
val action = if (device.state.power == true) "off" else "on"
controlDevice(device.id, action)
}
fun clearError() {
_error.value = null
}
}
@@ -0,0 +1,275 @@
package top.yeij.cyrene.viewmodel
import android.util.Log
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.flow.update
import kotlinx.coroutines.launch
import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.domain.repository.ChatRepository
import top.yeij.cyrene.util.Constants
import top.yeij.cyrene.util.RecordState
import top.yeij.cyrene.util.VoiceRecorder
import top.yeij.cyrene.voice.tts.TextToSpeechEngine
private fun List<Message>.deduplicate(): List<Message> {
if (isEmpty()) return this
val seen = mutableSetOf<String>()
return filter { seen.add(it.id) }
}
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
if (size < 3) return this
val toRemove = mutableSetOf<String>()
for (msg in this) {
val containedCount = count { other ->
other.id != msg.id &&
other.content.isNotBlank() &&
other.content.length < msg.content.length &&
msg.content.contains(other.content) &&
kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
}
if (containedCount >= 2) {
toRemove.add(msg.id)
}
}
return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
}
enum class OverlayState {
IDLE,
LISTENING,
PROCESSING,
SPEAKING,
WAITING,
}
class OverlayViewModel(
private val chatRepository: ChatRepository,
private val voiceRecorder: VoiceRecorder,
private val ttsEngine: TextToSpeechEngine,
) : ViewModel() {
private val _state = MutableStateFlow(OverlayState.WAITING)
val state: StateFlow<OverlayState> = _state.asStateFlow()
private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages.asStateFlow()
private val _inputText = MutableStateFlow("")
val inputText: StateFlow<String> = _inputText.asStateFlow()
val voiceRecordState: StateFlow<RecordState> = voiceRecorder.state
val voiceRecordDurationMs: StateFlow<Long> = voiceRecorder.durationMs
// Animation ordering for message bubbles
private var animCounter = 0
private val _messageAnimIndex = MutableStateFlow<Map<String, Int>>(emptyMap())
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
private var silenceTimer: Job? = null
private var processingTimeoutJob: Job? = null
private var lastAssistantMessageId: String? = null
init {
viewModelScope.launch {
chatRepository.observeMessages().collect { message ->
_messages.update { list ->
val updated = list.toMutableList()
val existingIdx = updated.indexOfLast { it.id == message.id }
if (existingIdx >= 0) {
updated[existingIdx] = message
} else {
// Insert at correct position for ascending timestamp (oldest first for top-down layout)
val insertAt = updated.indexOfFirst { it.timestamp >= message.timestamp }
if (insertAt >= 0) updated.add(insertAt, message) else updated.add(message)
val animIdx = _messageAnimIndex.value.toMutableMap()
animIdx[message.id] = animCounter++
_messageAnimIndex.value = animIdx
}
updated.deduplicate()
}
// Any non-user response means the server acknowledged our message
if (_state.value == OverlayState.PROCESSING && message.role != "user") {
cancelProcessingTimeout()
setWaiting()
}
if (message.role == "assistant" && !message.isStreaming && message.msgType == "chat") {
if (message.id != lastAssistantMessageId && message.content.isNotBlank()) {
lastAssistantMessageId = message.id
speakResponse(message.content)
}
}
}
}
viewModelScope.launch {
chatRepository.isAssistantStreaming.collect { streaming ->
if (streaming) {
cancelProcessingTimeout()
} else if (_state.value == OverlayState.PROCESSING) {
delay(500)
if (_state.value == OverlayState.PROCESSING) {
setWaiting()
}
}
}
}
viewModelScope.launch {
ttsEngine.onDone.collect {
if (_state.value == OverlayState.SPEAKING) {
setWaiting()
}
}
}
viewModelScope.launch {
chatRepository.messageClearEvents.collect {
_messages.value = emptyList()
_messageAnimIndex.value = emptyMap()
animCounter = 0
}
}
viewModelScope.launch {
chatRepository.messageRemovals.collect { msgId ->
_messages.update { list -> list.filter { it.id != msgId } }
val idx = _messageAnimIndex.value.toMutableMap()
idx.remove(msgId)
_messageAnimIndex.value = idx
}
}
}
fun onInputChanged(text: String) {
_inputText.value = text
}
fun sendText() {
val text = _inputText.value.trim()
if (text.isEmpty()) return
_inputText.value = ""
_state.value = OverlayState.PROCESSING
cancelSilenceTimer()
startProcessingTimeout()
viewModelScope.launch {
try {
chatRepository.sendMessage(text, null)
} catch (e: Exception) {
Log.e("OverlayVM", "sendText failed: ${e.message}", e)
if (_state.value == OverlayState.PROCESSING) setWaiting()
}
}
}
// --- Voice recording (WeChat-style gesture) ---
fun startRecord() {
voiceRecorder.start()
_state.value = OverlayState.LISTENING
cancelSilenceTimer()
}
fun lockRecord() {
voiceRecorder.lock()
}
fun finishRecord() {
val file = voiceRecorder.stop() ?: return
val base64 = voiceRecorder.getBase64()
voiceRecorder.deleteFile()
if (base64.isNullOrBlank()) return
_state.value = OverlayState.PROCESSING
startProcessingTimeout()
viewModelScope.launch {
chatRepository.sendVoiceInput(base64, "voice_msg")
}
}
fun cancelRecord() {
voiceRecorder.cancel()
setWaiting()
}
fun cancelCurrentAction() {
if (voiceRecorder.state.value == RecordState.LOCKED) {
voiceRecorder.cancel()
setWaiting()
}
}
private fun speakResponse(text: String) {
if (text.isBlank()) return
_state.value = OverlayState.SPEAKING
ttsEngine.speak(text)
}
private fun setWaiting() {
_state.value = OverlayState.WAITING
startSilenceTimer()
}
fun stopSpeaking() {
ttsEngine.stop()
if (_state.value == OverlayState.SPEAKING) {
setWaiting()
}
}
fun sendScreenContext(content: String) {
if (content.isBlank()) return
viewModelScope.launch {
chatRepository.sendScreenContext(content)
}
}
fun finish() {
_state.value = OverlayState.IDLE
cancelSilenceTimer()
voiceRecorder.cancel()
ttsEngine.stop()
}
private fun startSilenceTimer() {
cancelSilenceTimer()
silenceTimer = viewModelScope.launch {
delay(Constants.SILENCE_TIMEOUT_MS)
if (_state.value == OverlayState.WAITING) {
_state.value = OverlayState.IDLE
}
}
}
private fun cancelSilenceTimer() {
silenceTimer?.cancel()
silenceTimer = null
}
private fun startProcessingTimeout() {
cancelProcessingTimeout()
processingTimeoutJob = viewModelScope.launch {
delay(15_000L)
if (_state.value == OverlayState.PROCESSING) {
Log.w("OverlayVM", "Processing timeout — no response in 15s, resetting to WAITING")
setWaiting()
}
}
}
private fun cancelProcessingTimeout() {
processingTimeoutJob?.cancel()
processingTimeoutJob = null
}
override fun onCleared() {
voiceRecorder.cancel()
ttsEngine.shutdown()
super.onCleared()
}
}
@@ -0,0 +1,120 @@
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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.domain.repository.AuthRepository
data class ProfileState(
val userId: String = "",
val username: String = "",
val nickname: String = "",
val isAdmin: Boolean = false,
val createdAt: String = "",
val isLoading: Boolean = false,
val isLoggedIn: Boolean = false,
)
class ProfileViewModel(
private val apiService: ApiService,
private val authRepository: AuthRepository,
private val prefs: PreferencesDataStore,
) : ViewModel() {
private val _profile = MutableStateFlow(ProfileState())
val profile: StateFlow<ProfileState> = _profile.asStateFlow()
private var loadedFromCache = false
init {
viewModelScope.launch {
_profile.value = _profile.value.copy(isLoggedIn = authRepository.isLoggedIn())
if (_profile.value.isLoggedIn) {
loadCachedProfile()
fetchFreshProfile()
}
}
}
/**
* Show cached profile immediately for instant UI.
*/
private suspend fun loadCachedProfile() {
val userId = prefs.profileUserId.firstOrNull() ?: return
val nickname = prefs.profileNickname.firstOrNull() ?: ""
val isAdmin = prefs.profileIsAdmin.firstOrNull()?.toBoolean() ?: false
val createdAt = prefs.profileCreatedAt.firstOrNull() ?: ""
val username = prefs.username.firstOrNull() ?: ""
_profile.value = ProfileState(
userId = userId,
username = username,
nickname = nickname,
isAdmin = isAdmin,
createdAt = createdAt,
isLoggedIn = true,
)
loadedFromCache = true
}
/**
* Fetch fresh profile from server. On success, update cache and UI.
* On failure, keep showing cached data — no error, no UI disruption.
*/
fun fetchFreshProfile() {
viewModelScope.launch {
if (!loadedFromCache) {
_profile.value = _profile.value.copy(isLoading = true)
}
try {
val response = apiService.getProfile()
if (response.isSuccessful) {
val body = response.body()
if (body != null) {
val dateStr = body.createdAt?.take(10) ?: ""
val nickname = body.nickname ?: body.username
_profile.value = ProfileState(
userId = body.userId,
username = body.username,
nickname = nickname,
isAdmin = body.isAdmin == true,
createdAt = dateStr,
isLoading = false,
isLoggedIn = true,
)
loadedFromCache = true
// Update local cache
prefs.saveProfileCache(body.userId, nickname, body.isAdmin == true, dateStr)
}
} else if (!loadedFromCache) {
_profile.value = _profile.value.copy(isLoading = false)
}
// On error with cache already shown: silently ignore
} catch (e: Exception) {
Log.w("ProfileVM", "Failed to fetch fresh profile: ${e.message}")
if (!loadedFromCache) {
_profile.value = _profile.value.copy(isLoading = false)
}
// If cached data is showing, keep it silently
}
}
}
fun logout() {
viewModelScope.launch {
authRepository.logout()
prefs.clearProfileCache()
_profile.value = ProfileState()
loadedFromCache = false
}
}
}
@@ -0,0 +1,232 @@
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
import top.yeij.cyrene.domain.repository.ChatRepository
import top.yeij.cyrene.voice.stt.SttManager
class SettingsViewModel(
private val authRepository: AuthRepository,
private val preferencesDataStore: PreferencesDataStore,
private val dynamicUrlInterceptor: DynamicUrlInterceptor,
private val chatRepository: ChatRepository,
private val sttManager: SttManager,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val _baseUrl = MutableStateFlow("")
val baseUrl: StateFlow<String> = _baseUrl.asStateFlow()
private val _themeMode = MutableStateFlow("auto")
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
private val _wakeWord = MutableStateFlow("昔涟")
val wakeWord: StateFlow<String> = _wakeWord.asStateFlow()
private val _username = MutableStateFlow("")
val username: StateFlow<String> = _username.asStateFlow()
private val _dashScopeApiKey = MutableStateFlow("")
val dashScopeApiKey: StateFlow<String> = _dashScopeApiKey.asStateFlow()
private val _dashScopeEndpoint = MutableStateFlow("wss://dashscope.aliyuncs.com/api-ws/v1/inference")
val dashScopeEndpoint: StateFlow<String> = _dashScopeEndpoint.asStateFlow()
private val _dashScopeModel = MutableStateFlow("fun-asr-realtime")
val dashScopeModel: StateFlow<String> = _dashScopeModel.asStateFlow()
private val _autoScreenContext = MutableStateFlow(false)
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
private val _themeColor = MutableStateFlow("pink")
val themeColor: StateFlow<String> = _themeColor.asStateFlow()
private val _enterToSend = MutableStateFlow(false)
val enterToSend: StateFlow<Boolean> = _enterToSend.asStateFlow()
private val _typingIndicatorStyle = MutableStateFlow("bubble")
val typingIndicatorStyle: StateFlow<String> = _typingIndicatorStyle.asStateFlow()
private val _rootKeepAlive = MutableStateFlow(false)
val rootKeepAlive: StateFlow<Boolean> = _rootKeepAlive.asStateFlow()
private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
init {
scope.launch {
_isLoggedIn.value = authRepository.isLoggedIn()
}
// Single collector for all DataStore preferences — avoids subscriber explosion
scope.launch {
preferencesDataStore.autoScreenContext.collect { value ->
_autoScreenContext.value = value
}
}
scope.launch {
preferencesDataStore.typingIndicatorStyle.collect { value ->
_typingIndicatorStyle.value = value
}
}
scope.launch {
preferencesDataStore.enterToSend.collect { value ->
_enterToSend.value = value
}
}
scope.launch {
preferencesDataStore.rootKeepAlive.collect { value ->
_rootKeepAlive.value = value
}
}
scope.launch {
preferencesDataStore.themeColor.collect { value ->
_themeColor.value = value
}
}
scope.launch {
combine(
preferencesDataStore.baseUrl,
preferencesDataStore.themeMode,
preferencesDataStore.wakeWord,
preferencesDataStore.username,
combine(
preferencesDataStore.dashScopeApiKey,
preferencesDataStore.dashScopeEndpoint,
preferencesDataStore.dashScopeModel,
) { apiKey, endpoint, model ->
Triple(apiKey, endpoint, model)
},
) { baseUrl, themeMode, wakeWord, username, dashScope ->
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 }
val (apiKey, endpoint, model) = dashScope
apiKey?.let { key ->
if (key.isNotBlank()) _dashScopeApiKey.value = key
sttManager.updateDashScopeApiKey(key)
}
endpoint?.let { ep ->
if (ep.isNotBlank()) _dashScopeEndpoint.value = ep
}
model?.let { m ->
if (m.isNotBlank()) _dashScopeModel.value = m
}
// Push full config to STT
sttManager.configureDashScope(
apiKey = _dashScopeApiKey.value,
endpoint = _dashScopeEndpoint.value,
model = _dashScopeModel.value,
)
}.collect { }
}
}
fun updateBaseUrlInput(url: String) {
_baseUrl.value = url
}
fun saveBaseUrl(url: String) {
_baseUrl.value = url
dynamicUrlInterceptor.baseUrl = url
scope.launch {
preferencesDataStore.saveBaseUrl(url)
chatRepository.reconnectWebSocket()
}
}
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 updateDashScopeApiKeyInput(key: String) {
_dashScopeApiKey.value = key
}
fun saveDashScopeApiKey(key: String) {
_dashScopeApiKey.value = key
sttManager.updateDashScopeApiKey(key)
scope.launch { preferencesDataStore.saveDashScopeApiKey(key) }
}
fun updateDashScopeEndpointInput(endpoint: String) {
_dashScopeEndpoint.value = endpoint
}
fun saveDashScopeEndpoint(endpoint: String) {
_dashScopeEndpoint.value = endpoint
sttManager.configureDashScope(_dashScopeApiKey.value, endpoint, _dashScopeModel.value)
scope.launch { preferencesDataStore.saveDashScopeEndpoint(endpoint) }
}
fun updateDashScopeModelInput(model: String) {
_dashScopeModel.value = model
}
fun saveDashScopeModel(model: String) {
_dashScopeModel.value = model
sttManager.configureDashScope(_dashScopeApiKey.value, _dashScopeEndpoint.value, model)
scope.launch { preferencesDataStore.saveDashScopeModel(model) }
}
fun saveAutoScreenContext(enabled: Boolean) {
_autoScreenContext.value = enabled
scope.launch { preferencesDataStore.saveAutoScreenContext(enabled) }
}
fun saveTypingIndicatorStyle(style: String) {
_typingIndicatorStyle.value = style
scope.launch { preferencesDataStore.saveTypingIndicatorStyle(style) }
}
fun saveEnterToSend(enabled: Boolean) {
_enterToSend.value = enabled
scope.launch { preferencesDataStore.saveEnterToSend(enabled) }
}
fun saveThemeColor(color: String) {
_themeColor.value = color
scope.launch { preferencesDataStore.saveThemeColor(color) }
}
fun saveRootKeepAlive(enabled: Boolean) {
_rootKeepAlive.value = enabled
scope.launch { preferencesDataStore.saveRootKeepAlive(enabled) }
}
fun clearLocalMessages() {
scope.launch {
chatRepository.clearLocalMessages()
}
}
fun logout() {
scope.launch {
authRepository.logout()
_isLoggedIn.value = false
}
}
}
@@ -0,0 +1,27 @@
package top.yeij.cyrene.voice.hotword
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class HotwordDetector {
private val _isListening = MutableStateFlow(false)
val isListening = _isListening.asStateFlow()
private val _onDetected = MutableStateFlow(false)
val onDetected = _onDetected.asStateFlow()
fun startListening(wakeWord: String) {
_isListening.value = true
// Integrate system AlwaysOnHotwordDetector or Porcupine SDK here
}
fun stopListening() {
_isListening.value = false
}
fun updateWakeWord(newWord: String) {
stopListening()
startListening(newWord)
}
}
@@ -0,0 +1,160 @@
package top.yeij.cyrene.voice.stt
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Base64
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
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.isActive
import kotlinx.coroutines.launch
import top.yeij.cyrene.domain.repository.ChatRepository
import java.io.ByteArrayOutputStream
class BackendSttProvider(
private val context: Context,
private val chatRepository: ChatRepository,
) : SttProvider {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var audioRecord: AudioRecord? = null
private var recordingJob: Job? = null
private val audioBuffer = ByteArrayOutputStream()
private val _partialResult = MutableStateFlow("")
override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 8)
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
override val onError: SharedFlow<String> = _onError.asSharedFlow()
private val _isListening = MutableStateFlow(false)
override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
private val sampleRate = 16000
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
private val bufferSize: Int = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
.coerceAtLeast(3200)
override fun start() {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
) {
_onError.tryEmit("缺少录音权限")
return
}
cancel()
_isListening.value = true
_partialResult.value = ""
audioBuffer.reset()
try {
audioRecord = AudioRecord(
MediaRecorder.AudioSource.VOICE_RECOGNITION,
sampleRate,
channelConfig,
audioFormat,
bufferSize,
).also {
if (it.state != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "AudioRecord init failed")
_onError.tryEmit("麦克风初始化失败")
_isListening.value = false
return
}
it.startRecording()
}
val readBuffer = ByteArray(bufferSize)
recordingJob = scope.launch {
while (isActive && _isListening.value) {
val bytesRead = audioRecord?.read(readBuffer, 0, readBuffer.size) ?: -1
if (bytesRead > 0) {
audioBuffer.write(readBuffer, 0, bytesRead)
} else if (bytesRead < 0) break
}
}
} catch (e: SecurityException) {
_onError.tryEmit("缺少录音权限")
_isListening.value = false
} catch (e: Exception) {
Log.e(TAG, "Recording error", e)
_onError.tryEmit("录音失败: ${e.message}")
_isListening.value = false
}
}
override fun stop() {
if (!_isListening.value) return
Log.d(TAG, "Stopping recording")
stopRecording()
val audioBytes = audioBuffer.toByteArray()
if (audioBytes.isEmpty()) {
Log.w(TAG, "No audio data recorded")
_isListening.value = false
_onError.tryEmit("未录制到语音")
return
}
_isListening.value = false
scope.launch {
try {
val base64 = Base64.encodeToString(audioBytes, Base64.NO_WRAP)
chatRepository.sendVoiceInput(base64, "voice_msg")
Log.d(TAG, "Sent ${audioBytes.size} bytes of audio to backend")
} catch (e: Exception) {
Log.e(TAG, "Failed to send voice input", e)
_onError.tryEmit("发送语音失败: ${e.message}")
}
}
}
override fun cancel() {
if (!_isListening.value) return
Log.d(TAG, "Cancelling recording")
stopRecording()
audioBuffer.reset()
_isListening.value = false
_partialResult.value = ""
}
private fun stopRecording() {
recordingJob?.cancel()
recordingJob = null
try {
audioRecord?.stop()
audioRecord?.release()
} catch (e: Exception) {
Log.w(TAG, "Error releasing AudioRecord: ${e.message}")
}
audioRecord = null
}
fun shutdown() {
cancel()
scope.cancel()
}
companion object {
private const val TAG = "BackendSTT"
}
}
@@ -0,0 +1,355 @@
package top.yeij.cyrene.voice.stt
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Log
import androidx.core.content.ContextCompat
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
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.isActive
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import java.util.UUID
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
class DashScopeSttService(
private val context: Context,
) : SttProvider {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val gson = Gson()
private val connectionId = AtomicInteger(0)
private var webSocket: WebSocket? = null
private var audioRecord: AudioRecord? = null
private var recordingJob: Job? = null
private var taskId: String = ""
private val _partialResult = MutableStateFlow("")
override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 16)
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
override val onError: SharedFlow<String> = _onError.asSharedFlow()
private val _isListening = MutableStateFlow(false)
override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
// Configurable via settings
var apiKey: String = ""
var endpoint: String = "wss://dashscope.aliyuncs.com/api-ws/v1/inference"
var model: String = "fun-asr-realtime"
var enableInterimResults: Boolean = true
private val sampleRate = 16000
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
private val bytesPerSample = 2
private val chunkMs = 100
private val bufferSize: Int = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
.coerceAtLeast(sampleRate * bytesPerSample * chunkMs / 1000)
override fun start() {
if (apiKey.isBlank()) {
Log.w(TAG, "DashScope API key not configured")
_onError.tryEmit("请先配置 DashScope API Key")
return
}
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
) {
_onError.tryEmit("缺少录音权限")
return
}
cancel()
taskId = UUID.randomUUID().toString()
_isListening.value = true
_partialResult.value = ""
connectWebSocket()
startRecording()
}
override fun stop() {
if (!_isListening.value) return
Log.d(TAG, "Stopping recognition, taskId=$taskId")
stopRecording()
sendFinishTask()
}
override fun cancel() {
if (!_isListening.value) return
Log.d(TAG, "Cancelling recognition, taskId=$taskId")
stopRecording()
closeWebSocket()
_isListening.value = false
_partialResult.value = ""
}
private fun connectWebSocket() {
val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.writeTimeout(0, TimeUnit.MILLISECONDS)
.callTimeout(0, TimeUnit.MILLISECONDS)
.build()
val request = Request.Builder()
.url(endpoint)
.header("Authorization", "Bearer $apiKey")
.build()
val connId = connectionId.incrementAndGet()
Log.i(TAG, "[#$connId] Connecting to DashScope: $endpoint")
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
if (connectionId.get() != connId) return
Log.i(TAG, "[#$connId] Connected, sending run-task")
sendRunTask()
}
override fun onMessage(webSocket: WebSocket, text: String) {
if (connectionId.get() != connId) return
handleServerMessage(text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
if (connectionId.get() != connId) return
Log.e(TAG, "[#$connId] WebSocket failure: ${t.message}", t)
_isListening.value = false
_onError.tryEmit("语音识别连接失败: ${t.message}")
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
if (connectionId.get() != connId) return
Log.d(TAG, "[#$connId] Server closing: $code $reason")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (connectionId.get() != connId) return
Log.d(TAG, "[#$connId] Closed: $code $reason")
_isListening.value = false
}
})
}
private fun sendRunTask() {
val msg = mapOf(
"header" to mapOf(
"action" to "run-task",
"task_id" to taskId,
"streaming" to "duplex",
),
"payload" to mapOf(
"model" to model,
"parameters" to mapOf(
"format" to "pcm",
"sample_rate" to sampleRate,
),
"input" to emptyMap<String, String>(),
),
)
val json = gson.toJson(msg)
Log.d(TAG, "Sending run-task: $json")
webSocket?.send(json)
}
private fun sendFinishTask() {
val msg = mapOf(
"header" to mapOf(
"action" to "finish-task",
"task_id" to taskId,
),
"payload" to emptyMap<String, String>(),
)
val json = gson.toJson(msg)
Log.d(TAG, "Sending finish-task: $json")
webSocket?.send(json)
}
private fun handleServerMessage(text: String) {
try {
val response = gson.fromJson(text, DashScopeResponse::class.java)
val header = response.header ?: return
val event = header.event ?: return
when (event) {
"result-generated" -> {
val sentence = response.payload?.output?.sentence ?: return
val sentenceText = sentence.text ?: return
_partialResult.value = sentenceText
val sttResult = SttResult(
text = sentenceText,
isFinal = sentence.sentenceEnd ?: true,
)
if (sttResult.isFinal) {
_finalResult.tryEmit(sttResult)
} else if (enableInterimResults) {
_finalResult.tryEmit(sttResult)
}
}
"task-finished" -> {
Log.i(TAG, "Task finished: $taskId")
// If no result-generated was received, emit what we have
val partial = _partialResult.value
if (partial.isNotBlank()) {
_finalResult.tryEmit(SttResult(text = partial, isFinal = true))
}
closeWebSocket()
_isListening.value = false
}
"task-failed" -> {
val error = header.errorMessage ?: "语音识别失败"
Log.e(TAG, "Task failed: $error")
_onError.tryEmit(error)
closeWebSocket()
_isListening.value = false
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to parse server message: ${e.message}")
}
}
private fun startRecording() {
try {
audioRecord = AudioRecord(
MediaRecorder.AudioSource.VOICE_RECOGNITION,
sampleRate,
channelConfig,
audioFormat,
bufferSize,
).also {
if (it.state != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "AudioRecord initialization failed")
_onError.tryEmit("麦克风初始化失败")
_isListening.value = false
return
}
it.startRecording()
}
Log.d(TAG, "Recording started, bufferSize=$bufferSize")
val readBuffer = ByteArray(bufferSize)
recordingJob = scope.launch {
while (isActive && _isListening.value) {
val bytesRead = audioRecord?.read(readBuffer, 0, readBuffer.size) ?: -1
if (bytesRead > 0) {
val data = readBuffer.copyOf(bytesRead)
webSocket?.let { ws ->
try {
ws.send(ByteString.of(*data))
} catch (e: Exception) {
Log.w(TAG, "Failed to send audio: ${e.message}")
}
}
} else if (bytesRead < 0) {
Log.w(TAG, "AudioRecord read error: $bytesRead")
break
}
}
}
} catch (e: SecurityException) {
Log.e(TAG, "Missing RECORD_AUDIO permission", e)
_onError.tryEmit("缺少录音权限")
_isListening.value = false
} catch (e: Exception) {
Log.e(TAG, "Failed to start recording", e)
_onError.tryEmit("录音启动失败: ${e.message}")
_isListening.value = false
}
}
private fun stopRecording() {
recordingJob?.cancel()
recordingJob = null
try {
audioRecord?.stop()
audioRecord?.release()
} catch (e: Exception) {
Log.w(TAG, "Error releasing AudioRecord: ${e.message}")
}
audioRecord = null
Log.d(TAG, "Recording stopped")
}
private fun closeWebSocket() {
try {
webSocket?.close(1000, "Done")
} catch (_: Exception) { }
webSocket = null
}
fun shutdown() {
cancel()
scope.cancel()
}
companion object {
private const val TAG = "DashScopeSTT"
}
}
// --- JSON DTOs for DashScope WebSocket protocol ---
data class DashScopeResponse(
@SerializedName("header") val header: DashScopeHeader?,
@SerializedName("payload") val payload: DashScopePayload?,
)
data class DashScopeHeader(
@SerializedName("task_id") val taskId: String?,
@SerializedName("event") val event: String?,
@SerializedName("action") val action: String?,
@SerializedName("error_message") val errorMessage: String?,
)
data class DashScopePayload(
@SerializedName("output") val output: DashScopeOutput?,
@SerializedName("usage") val usage: Map<String, Any>?,
)
data class DashScopeOutput(
@SerializedName("sentence") val sentence: DashScopeSentence?,
)
data class DashScopeSentence(
@SerializedName("text") val text: String?,
@SerializedName("sentence_end") val sentenceEnd: Boolean?,
@SerializedName("sentence_id") val sentenceId: Int?,
@SerializedName("begin_time") val beginTime: Long?,
@SerializedName("end_time") val endTime: Long?,
@SerializedName("words") val words: List<Map<String, Any>>?,
@SerializedName("emo_tag") val emoTag: String?,
)
@@ -0,0 +1,118 @@
package top.yeij.cyrene.voice.stt
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.util.Log
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
class SpeechRecognizer(private val context: Context) : SttProvider {
private var recognizer: android.speech.SpeechRecognizer? = null
private val _isListening = MutableStateFlow(false)
override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
private val _partialResult = MutableStateFlow("")
override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 8)
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
override val onError: SharedFlow<String> = _onError.asSharedFlow()
override fun start() {
if (!android.speech.SpeechRecognizer.isRecognitionAvailable(context)) {
Log.w(TAG, "Speech recognition not available on this device")
_onError.tryEmit("语音识别不可用")
return
}
cancel()
recognizer = android.speech.SpeechRecognizer.createSpeechRecognizer(context).apply {
setRecognitionListener(object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {
_isListening.value = true
_partialResult.value = ""
}
override fun onBeginningOfSpeech() {}
override fun onRmsChanged(rmsdB: Float) {}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {}
override fun onError(error: Int) {
_isListening.value = false
val msg = when (error) {
android.speech.SpeechRecognizer.ERROR_NETWORK -> "网络连接失败"
android.speech.SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "网络超时"
android.speech.SpeechRecognizer.ERROR_AUDIO -> "音频录制错误"
android.speech.SpeechRecognizer.ERROR_SERVER -> "服务器错误"
android.speech.SpeechRecognizer.ERROR_CLIENT -> "客户端错误"
android.speech.SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "未检测到语音"
android.speech.SpeechRecognizer.ERROR_NO_MATCH -> "未能识别"
android.speech.SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "语音引擎忙碌"
android.speech.SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "缺少录音权限"
else -> "未知错误 ($error)"
}
Log.w(TAG, "Recognition error: $msg")
_onError.tryEmit(msg)
}
override fun onResults(results: Bundle?) {
_isListening.value = false
val matches = results?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION)
if (!matches.isNullOrEmpty()) {
_finalResult.tryEmit(SttResult(text = matches[0], isFinal = true))
}
}
override fun onPartialResults(partialResults: Bundle?) {
val matches = partialResults?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION)
if (!matches.isNullOrEmpty()) {
_partialResult.value = matches[0]
}
}
override fun onEvent(eventType: Int, params: Bundle?) {}
})
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "zh-CN")
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
}
startListening(intent)
}
}
override fun stop() {
recognizer?.stopListening()
_isListening.value = false
}
override fun cancel() {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
_isListening.value = false
_partialResult.value = ""
}
companion object {
private const val TAG = "CyreneSTT"
}
}
@@ -0,0 +1,128 @@
package top.yeij.cyrene.voice.stt
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
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.launch
import top.yeij.cyrene.util.RuntimeLog
class SttManager(
private val dashScopeService: DashScopeSttService,
private val backendProvider: BackendSttProvider,
private val systemRecognizer: SpeechRecognizer,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val _isListening = MutableStateFlow(false)
val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
private val _partialResult = MutableStateFlow("")
val partialResult: StateFlow<String> = _partialResult.asStateFlow()
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 16)
val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
val onError: SharedFlow<String> = _onError.asSharedFlow()
private var activeProvider: SttProvider? = null
private var dashScopeFailed = false
fun start() {
cancel()
val provider = if (dashScopeService.apiKey.isNotBlank() && !dashScopeFailed) {
Log.d(TAG, "Using DashScope STT")
RuntimeLog.stt("start", "Using DashScope provider")
dashScopeService.also { dashScopeFailed = false }
} else {
Log.d(TAG, "Using Backend STT (fallback)")
RuntimeLog.stt("start", "Using Backend provider (fallback)")
backendProvider
}
activeProvider = provider
_isListening.value = true
_partialResult.value = ""
collectFrom(provider)
provider.start()
}
fun stop() {
RuntimeLog.stt("stop", "Stopping STT")
activeProvider?.stop()
_isListening.value = false
}
fun cancel() {
RuntimeLog.stt("cancel", "Cancelling STT")
activeProvider?.cancel()
activeProvider = null
_isListening.value = false
_partialResult.value = ""
}
fun configureDashScope(apiKey: String, endpoint: String, model: String) {
dashScopeService.apiKey = apiKey
dashScopeService.endpoint = endpoint
dashScopeService.model = model
dashScopeFailed = false
}
fun updateDashScopeApiKey(apiKey: String) {
dashScopeService.apiKey = apiKey
if (apiKey.isNotBlank()) dashScopeFailed = false
}
fun shutdown() {
cancel()
dashScopeService.shutdown()
backendProvider.shutdown()
scope.cancel()
}
private fun collectFrom(provider: SttProvider) {
scope.launch {
provider.partialResult.collect { text ->
if (provider == activeProvider && text.isNotEmpty()) {
_partialResult.value = text
}
}
}
scope.launch {
provider.finalResult.collect { result ->
if (provider == activeProvider) {
RuntimeLog.stt("result", "Final result: isFinal=${result.isFinal} text=${result.text.take(80)}")
_finalResult.tryEmit(result)
}
}
}
scope.launch {
provider.onError.collect { error ->
if (provider == activeProvider) {
Log.w(TAG, "STT error: $error")
RuntimeLog.stt("error", error)
_onError.tryEmit(error)
if (provider == dashScopeService) {
dashScopeFailed = true
RuntimeLog.stt("fallback", "DashScope failed, will use backend next time")
Log.i(TAG, "DashScope failed, will use backend next time")
}
}
}
}
}
companion object {
private const val TAG = "SttManager"
}
}
@@ -0,0 +1,23 @@
package top.yeij.cyrene.voice.stt
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
enum class SttProviderType { DASHSCOPE, BACKEND, SYSTEM }
data class SttResult(
val text: String,
val isFinal: Boolean,
val isError: Boolean = false,
)
interface SttProvider {
val partialResult: StateFlow<String>
val finalResult: SharedFlow<SttResult>
val onError: SharedFlow<String>
val isListening: StateFlow<Boolean>
fun start()
fun stop()
fun cancel()
}
@@ -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)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Cyrene</string>
<string name="voice_assistant_name">昔涟</string>
<string name="voice_assistant_description">昔涟 —— 你的智能语音助手</string>
<!-- Navigation -->
<string name="tab_chat">对话</string>
<string name="tab_iot">设备</string>
<string name="tab_profile">我的</string>
<!-- Voice -->
<string name="hotword_listening">正在聆听唤醒词…</string>
<string name="listening">我在听…</string>
<string name="thinking">思考中…</string>
<string name="speaking">正在说话…</string>
<string name="tap_to_speak">点击说话</string>
<string name="hold_to_speak">按住说话</string>
<!-- Actions -->
<string name="login">登录</string>
<string name="logout">退出登录</string>
<string name="settings">设置</string>
<string name="cancel">取消</string>
<string name="confirm">确认</string>
<string name="save">保存</string>
<string name="retry">重试</string>
<!-- Settings -->
<string name="set_default_assistant">设为默认语音助手</string>
<string name="set_default_assistant_desc">将昔涟替换为系统默认助手</string>
<string name="appearance">外观</string>
<string name="voice_settings">语音设置</string>
<string name="wake_word">唤醒词</string>
<string name="account">账号</string>
<string name="about">关于</string>
<string name="accessibility_service_description">昔涟使用无障碍服务读取屏幕内容,以便在唤醒时理解当前上下文并提供更精准的帮助。不会收集或上传个人隐私信息。</string>
<string name="server_address">服务器地址</string>
<string name="theme">主题</string>
<string name="theme_light">浅色</string>
<string name="theme_dark">深色</string>
<string name="theme_auto">跟随系统</string>
</resources>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Cyrene" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowBackground">@android:color/transparent</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:canRetrieveWindowContent="true"
android:description="@string/accessibility_service_description"
android:notificationTimeout="500" />
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="cache" path="/" />
</paths>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<voice-interaction-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:sessionService="top.yeij.cyrene.service.CyreneSessionService"
android:recognitionService=".service.CyreneRecognitionService"
android:supportsAssist="true"
android:supportsLaunchVoiceAssistFromKeyguard="true"
/>
+6
View File
@@ -0,0 +1,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
}
+158
View File
@@ -0,0 +1,158 @@
# 00 — 项目概述与架构
> 对应主项目 Phase 5v1.5 → v2.0Android 客户端
> 主项目文档:`../../docs/dev-plan/04-voice-system-plan.md`
---
## 1. 项目定位
Cyrene for Android 是昔涟的官方 Android 客户端,定位为 **可替换系统自带语音助手的智能体 APP**(替代 Google Assistant / Bixby)。
核心差异点:
- 不是传统 APP(以桌面图标为主要入口),而是**系统级语音助手**
- 除常规全屏 Activity 外,通过 `VoiceInteractionSession` 提供悬浮式覆盖层交互
- 支持息屏热词唤醒、长按 Home / 电源键呼出
## 2. 技术栈
| 层 | 技术 | 版本要求 |
|----|------|---------|
| 语言 | Kotlin | 2.0+ |
| UI 框架 | Jetpack Compose + Material Design 3 | BOM 2025+ |
| 构建 | Gradle (Kotlin DSL) | 8.7+ |
| 架构模式 | MVVM + Repository | — |
| 依赖注入 | Hilt / Koin (待定) | — |
| 网络 | Retrofit + OkHttp | — |
| 实时通信 | OkHttp WebSocket | — |
| 本地存储 | Room (SQLite) + DataStore | — |
| 推送 | FCM (Firebase Cloud Messaging) | — |
| 系统语音 | VoiceInteractionService | API 23+ |
| 热词唤醒 | Always-On Hotword Detection | API 23+ |
| 语音识别 | 服务端 (Whisper API) + 本地兜底 | — |
## 3. 最低系统要求
| 项目 | 要求 |
|------|------|
| minSdk | 26 (Android 8.0) |
| targetSdk | 35+ |
| compileSdk | 35+ |
| JDK | 17 |
| Gradle | 8.7+ |
注:`VoiceInteractionService` 基础 API 要求 23`AssistAction` 热词唤醒要求 23。minSdk 设为 26 以覆盖绝大多数活跃设备并简化兼容性。
## 4. 项目结构
```
android/
├── app/
│ ├── src/main/
│ │ ├── java/com/cyrene/app/
│ │ │ ├── CyreneApplication.kt # Application 初始化
│ │ │ ├── MainActivity.kt # 全屏主界面 (桌面图标入口)
│ │ │ ├── ui/
│ │ │ │ ├── theme/ # MD3 主题 (Color / Type / Shape)
│ │ │ │ ├── screens/ # 全屏页面 (Compose)
│ │ │ │ │ ├── chat/ # 对话页
│ │ │ │ │ ├── home/ # 首页 / IoT 面板
│ │ │ │ │ ├── settings/ # 设置页
│ │ │ │ │ └── login/ # 登录/注册
│ │ │ │ ├── overlay/ # 悬浮窗对话界面 (VoiceInteractionSession)
│ │ │ │ └── components/ # 共享组件库
│ │ │ ├── viewmodel/ # ViewModel 层
│ │ │ ├── domain/ # 领域层 (UseCase / Repository 接口)
│ │ │ ├── data/
│ │ │ │ ├── remote/ # API 接口定义 + DTO
│ │ │ │ ├── local/ # Room DAO + DataStore
│ │ │ │ └── repository/ # Repository 实现
│ │ │ ├── service/
│ │ │ │ ├── CyreneVoiceInteractionService.kt
│ │ │ │ ├── CyreneVoiceInteractionSession.kt
│ │ │ │ ├── CyreneAssistService.kt
│ │ │ │ └── WebSocketService.kt
│ │ │ ├── voice/
│ │ │ │ ├── hotword/ # 热词唤醒引擎
│ │ │ │ ├── stt/ # 语音识别客户端
│ │ │ │ └── tts/ # 语音合成客户端
│ │ │ ├── di/ # DI 模块定义
│ │ │ └── util/ # 工具类
│ │ ├── res/ # 资源文件
│ │ └── AndroidManifest.xml
│ └── build.gradle.kts
├── gradle/
│ ├── libs.versions.toml # 版本目录
│ └── wrapper/
├── build.gradle.kts # 根构建脚本
├── settings.gradle.kts
└── gradle.properties
```
## 5. 架构分层
```
┌──────────────────────────────────────────────┐
│ UI Layer (Compose) │
│ ├─ Screens (全屏 Activity) │
│ └─ Overlay (VoiceInteractionSession 悬浮窗) │
├──────────────────────────────────────────────┤
│ ViewModel Layer │
│ ├─ ChatViewModel │
│ ├─ HomeViewModel (IoT) │
│ ├─ SettingsViewModel │
│ └─ OverlayViewModel │
├──────────────────────────────────────────────┤
│ Domain Layer │
│ ├─ UseCase (SendMessage, ControlIoT, ...) │
│ └─ Repository Interfaces │
├──────────────────────────────────────────────┤
│ Data Layer │
│ ├─ Remote: Retrofit API + WebSocket │
│ ├─ Local: Room + DataStore │
│ └─ Repository Implementation │
├──────────────────────────────────────────────┤
│ Service Layer │
│ ├─ VoiceInteractionService (系统助手) │
│ ├─ WebSocketService (长连接) │
│ └─ FCMMessagingService (推送) │
└──────────────────────────────────────────────┘
```
UI 层和 Service 层通过 ViewModel 解耦——全屏 Activity 和悬浮窗 Overlay 复用同一组 ViewModel,只是 UI 布局不同。
## 6. 与后端的关系
```
Android Client
├─ HTTP REST ──────────► Gateway (:8080) # 登录、CRUD、配置
├─ WebSocket ──────────► Gateway (:8080) # 实时对话、IoT 状态推送
├─ STT Audio ──────────► Voice Service (:8093) # 语音识别
└─ TTS Stream ◄──────── Voice Service (:8093) # 语音合成
```
WebSocket 长连接是核心通信通道:对话消息、通知、IoT 状态广播均通过同一连接。HTTP 仅用于一次性操作(登录、文件上传/下载)。
## 7. 关键设计决策
| 决策 | 选择 | 理由 |
|------|------|------|
| UI 框架 | Jetpack Compose + MD3 | 声明式 UI,与悬浮窗的 ComposeView 集成简单 |
| 架构 | MVVM + Repository | Google 官方推荐,ViewModel 可在 Activity 和 Session 间复用 |
| 语音框架 | VoiceInteractionService(系统 API) | 原生支持替换系统助手,无需自定义悬浮窗权限 |
| 热词方案 | 系统 Always-On Hotword API | 息屏低功耗监听,不用自建音频采集 |
| 网络 | OkHttp WebSocket | 比 FCM 更实时,与主项目 Gateway 已有 WS Hub 对应 |
| 最低 API | 26 | 覆盖 95%+ 活跃设备,VoiceInteractionService 兼容 |
## 8. 排期参考
对应主项目路线图:
```
2026 Q4 ─ v1.3 多平台接入(前置依赖)
2027 Q1 ─ v1.8 语音模型训练完成(后端依赖)
2027 Q2 ─ v2.0 开始 Android 客户端开发
2027 Q3 ─ v2.3 语音助手 APP MVP 版本
2027 Q4 ─ v3.0 APP 上架(Google Play / 国内应用商店)
```
+256
View File
@@ -0,0 +1,256 @@
# 01 — 系统语音助手集成规范
> **目标**:让昔涟成为 Android 系统级默认语音助手,替换 Google Assistant / Bixby
> **核心 API**`VoiceInteractionService` + `VoiceInteractionSession`
---
## 1. 功能目标
- 用户可在 **系统设置 → 默认应用 → 数字助理** 中选择昔涟
- 长按 Home 键呼出昔涟(非全屏,悬浮覆盖层)
- 屏幕底部两角向内滑动触发昔涟
- 长按电源键可配置为呼出昔涟
- 息屏状态下热词唤醒昔涟
- 有线/蓝牙耳机按键呼出昔涟
## 2. AndroidManifest.xml 声明
```xml
<!-- VoiceInteractionService -->
<service
android:name=".service.CyreneVoiceInteractionService"
android:exported="true"
android:permission="android.permission.BIND_VOICE_INTERACTION">
<meta-data
android:name="android.voice_interaction"
android:resource="@xml/voice_interaction_config" />
<intent-filter>
<action android:name="android.service.voice.VoiceInteractionService" />
</intent-filter>
</service>
<!-- AssistService (Android 14+) -->
<service
android:name=".service.CyreneAssistService"
android:exported="true"
android:permission="android.permission.BIND_ASSIST">
<intent-filter>
<action android:name="android.service.voice.AssistService" />
</intent-filter>
</service>
```
## 3. 配置文件
### res/xml/voice_interaction_config.xml
```xml
<?xml version="1.0" encoding="utf-8"?>
<voice-interaction-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:sessionService=".service.CyreneVoiceInteractionSession"
android:recognitionService=".service.CyreneRecognitionService"
android:supportsAssist="true"
android:supportsLaunchVoiceAssistFromKeyguard="true"
android:supportsLocalRecognition="true"
android:serviceIcon="@drawable/ic_cyrene"
android:serviceLabel="@string/voice_assistant_name" />
```
## 4. VoiceInteractionService 实现
```kotlin
class CyreneVoiceInteractionService : VoiceInteractionService() {
override fun onReady() {
super.onReady()
// 服务就绪,可在此初始化 TTS 引擎等
}
override fun onCreateSession(args: Bundle?): VoiceInteractionSession {
return CyreneVoiceInteractionSession(this)
}
override fun onLaunchVoiceAssistFromKeyguard() {
// 锁屏启动 → 进入简化模式,仅显示对话,IoT 控制等需先解锁
}
// Android 14+: AssistAction 回调
override fun onHandleAssist(
request: AssistRequest?,
cancellationSignal: CancellationSignal?,
callback: OutcomeCallback<AssistResult?>?
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
request?.let {
val assistContent = it.assistContent
// 提取当前屏幕上下文(可选,用于后续上下文感知)
callback?.onResult(AssistResult(assistContent))
}
}
}
}
```
## 5. VoiceInteractionSession 实现(悬浮窗界面)
```kotlin
class CyreneVoiceInteractionSession(context: Context) :
VoiceInteractionSession(context) {
override fun onCreateContentView(): View {
// 返回 ComposeView 作为悬浮窗的内容
return ComposeView(context).apply {
setContent {
CyreneTheme {
OverlayScreen(
viewModel = overlayViewModel,
onDismiss = { finish() }
)
}
}
}
}
override fun onShow(args: Bundle?, showFlags: Int) {
super.onShow(args, showFlags)
// 设置窗口属性:透明背景 + 底部卡片式布局
window?.apply {
// 半透明遮罩
setBackgroundDrawable(ColorDrawable(0x80000000.toInt()))
// FLAG_DIM_BEHIND 可实现模糊效果
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
addSystemGestureExclusionRects(...)
}
}
}
override fun onComputeInsets(outInsets: Insets?) {
super.onComputeInsets(outInsets)
// 控制悬浮窗内容区域
}
override fun onHide() {
super.onHide()
// 悬浮窗隐藏时清理状态
}
}
```
### 关键窗口属性
| 属性 | 值 | 说明 |
|------|-----|------|
| 背景 | `ColorDrawable(0x80000000)` | 半透明黑色遮罩,透出底层 APP |
| 内容区域 | 自适应高度 | 底部弹出,类似 Google Assistant |
| 触摸外区域行为 | 关闭悬浮窗 | 用户点击遮罩区域关闭 |
| 键盘弹出 | 推高内容区域 | 文本输入时自动调整 |
## 6. 权限清单
```xml
<!-- 核心语音助手权限 -->
<uses-permission android:name="android.permission.BIND_VOICE_INTERACTION" />
<uses-permission android:name="android.permission.BIND_ASSIST" />
<!-- 音频相关 -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- 热词唤醒 -->
<uses-permission android:name="android.permission.CAPTURE_AUDIO_HOTWORD" />
<!-- 后台服务 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<!-- 网络 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 推送 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- 锁屏交互 -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
```
## 7. 引导用户设为默认助手
首次启动时检测并引导:
```kotlin
fun checkAndPromptDefaultAssistant(context: Context) {
val isDefault = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val componentName = ComponentName(context, CyreneVoiceInteractionService::class.java)
context.packageManager
.queryIntentServices(
Intent(VoiceInteractionService.SERVICE_INTERFACE),
PackageManager.MATCH_DEFAULT_ONLY
)
.any { it.serviceInfo.packageName == context.packageName }
} else {
false
}
if (!isDefault) {
// 显示引导 UI → 跳转到 Settings.ACTION_VOICE_INPUT_SETTINGS
val intent = Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)
context.startActivity(intent)
}
}
```
## 8. 热词唤醒检测
### 方案选型
| 方案 | 优点 | 缺点 | 适用场景 |
|------|------|------|---------|
| 系统 Always-On Hotword API | 低功耗、系统级支持 | 限 Android 8+,某些 ROM 不支持 | **首选** |
| Porcupine (Picovoice) | 跨平台、离线 | 商业许可,需额外集成 | 兜底 |
| 自建模型 (openWakeWord) | 完全可控、低成本 | 需要本地推理能力 | 长期方案 |
### 唤醒词配置
| 优先级 | 唤醒词 | 说明 |
|--------|--------|------|
| P0 | "昔涟" (Xī Lián) | 角色名,默认唤醒词 |
| P1 | "Hey 昔涟" | 与 "Hey Google" 习惯对齐 |
| P2 | 自定义 | 用户可在设置中自定义 |
### 息屏唤醒流程
```
用户说出唤醒词
→ HotwordDetector 识别成功(<800ms
→ 系统触发 VoiceInteractionService
→ CyreneVoiceInteractionSession.onCreateContentView()
→ Overlay 显示,播放连接提示音
→ 用户说话 → STT → AI-Core → TTS → 语音回复
→ 对话结束 → finish() → 息屏
```
## 9. Dismiss 时机
悬浮窗在以下情况关闭:
| 条件 | 行为 |
|------|------|
| 用户说"再见" / "退下" | 自然对话结束,收起悬浮窗 |
| 用户点击遮罩区域 | 立即关闭 |
| 对话静默 10 秒 | 自动收起 |
| 用户主动滑动关闭 | 手势关闭,同 Google Assistant |
| 收到系统电话等中断 | 暂停语音,进入后台等待 |
## 10. 降级策略
当系统不支持 `VoiceInteractionService` 或未设为默认助手时:
- **保底方案**:PWA(利用主项目已有的 PWA 支持)
- **WebView 封装**:内嵌 H5 对话界面作为过渡
- **通知栏常驻**:提供快速对话入口,但功能受限
+237
View File
@@ -0,0 +1,237 @@
# 02 — 交互流程与导航设计
> **核心原则**:间接启动(语音/手势)不进入全屏 APP,而是以悬浮覆盖层呈现
> **关联文档**[01-voice-assistant-system.md](01-voice-assistant-system.md)
---
## 1. 启动来源 → 界面模式 映射表
```
┌──────────────────────────────────────────────────────────┐
│ 启动来源 │
├───────────────────────┬──────────────────────────────────┤
│ 直接启动 (Explicit) │ 间接启动 (Implicit) │
├───────────────────────┼──────────────────────────────────┤
│ · 桌面图标 │ · 语音唤醒 ("昔涟") │
│ · 最近任务列表 │ · 长按 Home 键 │
│ · 通知栏点击 │ · 底部两角向内滑动 │
│ · Deep Link │ · 长按电源键 (配置后) │
│ │ · 耳机按键 (单击/长按) │
│ │ · 锁屏右滑助手 │
├───────────────────────┼──────────────────────────────────┤
│ ▼ │ ▼ │
│ 全屏 Activity │ VoiceInteractionSession │
│ (MainActivity) │ (全屏悬浮覆盖层) │
│ · 完整导航栏 │ · 无导航栏 │
│ · Tab 切换 │ · 仅对话卡片 │
│ · 设置/IoT 面板 │ · 半透明遮罩透出底层 │
│ · 压入返回栈 │ · 不压入返回栈 │
└───────────────────────┴──────────────────────────────────┘
```
## 2. 全屏 Activity 模式
### 2.1 导航结构
```
MainActivity
├── BottomNavigation
│ ├── Tab 1: 对话 (ChatScreen) ← 默认页
│ ├── Tab 2: IoT 面板 (IoTScreen)
│ └── Tab 3: 我的 (ProfileScreen)
├── TopAppBar
│ ├── 昔涟状态指示器 (在线/思考中/离线)
│ └── 快捷操作 (设置、通知)
└── 子页面 (通过 NavHost 导航)
├── SettingsScreen
├── MemoryScreen (记忆查看)
├── KnowledgeScreen (知识库)
├── AutomationScreen (自动化规则)
├── ReminderScreen (提醒列表)
└── LoginScreen
```
### 2.2 导航图 (NavGraph)
```
LoginScreen ──(登录成功)──► MainScreen (带 BottomNav)
┌────────────┼────────────┐
▼ ▼ ▼
ChatScreen IoTScreen ProfileScreen
│ │
▼ ▼
MemoryScreen SettingsScreen
KnowledgeScreen ├─ Account
AutomationScreen ├─ Appearance (主题)
ReminderScreen ├─ Voice (唤醒词/语音)
├─ IoT Config
└─ About
```
### 2.3 登录流程
```
APP 首次启动
→ 检查本地 Token
├─ 有效 → 直接进入 MainScreen
└─ 无效 → LoginScreen
├─ 输入 Gateway 地址 + 账号密码
├─ POST /api/v1/auth/login
├─ 保存 Token 到 DataStore
└─ 进入 MainScreen
```
## 3. 悬浮覆盖层模式 (VoiceInteractionSession)
### 3.1 视觉层级
```
┌──────────────────────────────────────┐
│ 底层 APP (半透明可见) │
│ ┌──────────────────────────────┐ │
│ │ 半透明黑色遮罩 (80% 不透明度) │ │
│ │ │ │
│ │ ┌──────────────────────┐ │ │
│ │ │ │ │ │
│ │ │ 对话卡片区域 │ │ │
│ │ │ (圆角顶部 28dp) │ │ │
│ │ │ │ │ │
│ │ │ · 昔涟状态条 │ │ │
│ │ │ · 对话消息流 │ │ │
│ │ │ · 文本输入框 │ │ │
│ │ │ · 语音输入按钮 │ │ │
│ │ │ │ │ │
│ │ └──────────────────────┘ │ │
│ │ │ │
│ └──────────────────────────────┘ │
└──────────────────────────────────────┘
```
### 3.2 覆盖层生命周期
```
Trigger (唤醒词/手势/按键)
VoiceInteractionSession.onCreateContentView()
onShow() → 设置窗口属性 → 播放出现动画(底部滑入)
OverlayScreen Compose 渲染
├→ 用户说话 → STT → 显示识别文本 → 发送到 AI-Core
│ │
│ ▼
│ SSE 流式响应
│ │
├← TTS 语音播放 ←── 流式合成 ←────────────┘
├→ 用户打字输入 → WebSocket 发送 → 显示回复气泡
对话结束
├→ 用户主动关闭 (说"再见"/点击遮罩/下滑)
└→ 超时自动关闭 (静默 10 秒)
onHide() → 播放消失动画(底部滑出)
finish() → 返回触发前界面
```
### 3.3 覆盖层状态机
```
┌──────────┐
│ IDLE │ (覆盖层不可见)
└────┬─────┘
│ 触发
┌──────────┐
│LISTENING │ (等待语音输入,波形动画)
└────┬─────┘
│ 检测到语音 / 用户开始打字
┌──────────┐
│PROCESSING│ (STT 识别中 / LLM 思考中)
└────┬─────┘
│ 收到回复
┌──────────┐
│SPEAKING │ (TTS 播放中)
└────┬─────┘
│ 播放完毕,等待下一轮
┌──────────┐
│ WAITING │ (等待用户继续或关闭)
└────┬─────┘
┌───────┼───────┐
│ │
用户继续说话 10s 静默
│ │
▼ ▼
LISTENING IDLE
(覆盖层关闭)
```
### 3.4 与全屏 Activity 的切换
```
悬浮窗中用户点击 "打开完整 APP"
→ finish() 关闭悬浮窗
→ startActivity(MainActivity)
→ 用户在全屏模式下继续操作
全屏 APP 中用户按 Home 返回桌面
→ onStop() → 进入后台
→ WebSocket 保持连接
→ 推送/FCM 通知到达时,点击通知 → 恢复 MainActivity
```
## 4. 锁屏交互
### 4.1 锁屏唤醒
```
设备锁屏 + 息屏
├→ 说出唤醒词 "昔涟"
│ └→ onLaunchVoiceAssistFromKeyguard()
│ └→ 简化版覆盖层 (仅对话,无 IoT / 敏感操作)
└→ 长按电源键
└→ 同理
```
### 4.2 锁屏安全策略
| 操作 | 锁屏状态 | 行为 |
|------|---------|------|
| 查询天气/时间 | 允许 | 直接回复 |
| 简单闲聊 | 允许 | 直接回复 |
| IoT 查询(状态) | 允许 | 回复设备状态 |
| IoT 控制(开关) | **禁止** | 提示"请先解锁设备" |
| 查看记忆 | **禁止** | 提示"请先解锁设备" |
| 修改设置 | **禁止** | 提示"请先解锁设备" |
| 宿主命令 | **禁止** | 提示"请先解锁设备" |
## 5. 多窗口与分屏
- **分屏模式**:悬浮窗模式下不支持(本身已是覆盖层);全屏 Activity 支持分屏
- **画中画**:语音通话场景支持画中画(PIP),显示昔涟头像 + 波形动画
## 6. 手势交互
| 手势 | 悬浮窗模式 | 全屏 Activity |
|------|-----------|---------------|
| 下滑覆盖层 | 关闭悬浮窗 | — |
| 点击遮罩区域 | 关闭悬浮窗 | — |
| 长按消息 | 复制/分享菜单 | 复制/分享/删除 |
| 左滑消息 | — | 查看消息详情/时间戳 |
| 双击昔涟头像 | 切换输入模式(语音↔文字) | 同左 |
+208
View File
@@ -0,0 +1,208 @@
# 03 — 设计系统规范 (Material Design 3)
> **设计语言**Material Design 3 (Material You)
> **组件库**`androidx.compose.material3`
> **最低 API**26(不支持 Monet 的设备回退为手动主题色)
---
## 1. 色彩系统
### 1.1 动态配色 (Dynamic Color)
```
首选:androidx.compose.material3.dynamicColor
→ 系统壁纸提取 Primary / Secondary / Tertiary
→ 支持 Android 12+ (API 31+)
→ API 26-30 回退为预设主题色
备选:用户在设置中手动选择 Seed Color
→ 通过 MaterialTheme.colorScheme 的 lightColorScheme/darkColorScheme 生成
```
### 1.2 预设主题色
| 主题名 | Seed Color | 氛围 |
|--------|-----------|------|
| 默认(昔涟紫) | `#9C6BFF` (Lavender) | 温柔、亲切 |
| 樱花粉 | `#FFB4C8` (Sakura) | 甜美 |
| 海洋蓝 | `#6BA4FF` (Ocean) | 清爽 |
| 森林绿 | `#6BCF7C` (Forest) | 自然 |
| 日落橙 | `#FF9E6B` (Sunset) | 温暖 |
### 1.3 暗黑模式
| 属性 | Light | Dark |
|------|-------|------|
| Surface | `#FFFBFF` | `#1C1B1F` |
| Background | `#FFFBFF` | `#1C1B1F` |
| Primary | Dynamic | Dynamic (暗黑自适应) |
| OnSurface | `#1C1B1F` | `#E6E1E5` |
| SurfaceVariant | `#E7E0EC` | `#49454F` |
| 遮罩颜色 | `rgba(0,0,0,0.5)` | `rgba(0,0,0,0.7)` |
### 1.4 悬浮窗专用色
```
覆盖层背景遮罩:
Light: rgba(0, 0, 0, 0.5) // 50% 不透明度
Dark: rgba(0, 0, 0, 0.7) // 70% 不透明度
对话卡片背景:
Light: MaterialTheme.colorScheme.surface
Dark: MaterialTheme.colorScheme.surface
卡片圆角:28dp (顶部) / 0dp (底部)
卡片阴影 (Light)8dp elevation
卡片阴影 (Dark):无阴影,用 1dp outline 代替
```
## 2. 字体系统 (Typography)
| 角色 | 字号 | 字重 | 行高 | 用途 |
|------|------|------|------|------|
| displayLarge | 57sp | 400 | 64sp | 欢迎页标题 |
| headlineMedium | 28sp | 400 | 36sp | 设置页标题 |
| titleLarge | 22sp | 400 | 28sp | 对话框标题 |
| titleMedium | 16sp | 500 | 24sp | 列表标题 |
| bodyLarge | 16sp | 400 | 24sp | 对话气泡文字 |
| bodyMedium | 14sp | 400 | 20sp | 辅助文字、时间戳 |
| labelLarge | 14sp | 500 | 20sp | 按钮文字 |
| labelMedium | 12sp | 500 | 16sp | Tab 标签 |
| labelSmall | 11sp | 500 | 16sp | 状态标签 |
字体家族:`system-ui`(默认),不支持自定义字体以保证加载速度和系统一致性。
## 3. 形状系统 (Shapes)
| 角色 | 圆角 | 用途 |
|------|------|------|
| extraSmall | 4dp | 小标签、芯片 |
| small | 8dp | 输入框、按钮 |
| medium | 12dp | 卡片、对话框 |
| large | 16dp | 大卡片 |
| extraLarge | 28dp | 底部弹出卡片、Sheet |
## 4. 组件规范
### 4.1 对话气泡
```
昔涟消息 (左侧)
┌─────────────────────────────┐
│ 🤖 昔涟 10:32 │
│ ┌─────────────────────────┐ │
│ │ 开拓者,今天心情怎么样? │ │ ← 圆角: 12dp (top-start 4dp)
│ └─────────────────────────┘ │ 背景: PrimaryContainer
│ │ 文字: OnPrimaryContainer
└─────────────────────────────┘
用户消息 (右侧)
┌─────────────────────────────┐
│ 你 10:33 │
│ ┌─────────────────────┐ │
│ │ 还不错!你呢? │ │ ← 圆角: 12dp (top-end 4dp)
│ └─────────────────────┘ │ 背景: Primary
│ │ 文字: OnPrimary
└─────────────────────────────┘
```
### 4.2 消息类型样式
| 类型 | 样式 | 示例 |
|------|------|------|
| `chat` | 普通气泡 | 对话内容 |
| `action` | 居中斜体、灰色 | *昔涟正在查看客厅灯光状态* |
| `thinking` | 折叠面板、虚线边框 | 可展开/折叠 |
| `system_info` | Toast 样式 | 服务状态告知 |
| `tool_progress` | 进度条 + 图标 | IoT 操作进行中 |
### 4.3 语音输入按钮 (悬浮窗核心组件)
```
┌─────────────────────────────────┐
│ │
│ 🎤 波形动画 │ (LISTENING 状态)
│ "我在听..." │
│ │
│ ┌───────────────────────────┐ │
│ │ 输入文字或直接说话... │ │ (IDLE 状态,点击切换语音)
│ └───────────────────────────┘ │
│ │
│ ┌────┐ ┌──┐ │
│ │ 🎤 │ (按住说话) │⌨️│ │ (WAITING 状态)
│ └────┘ └──┘ │
└─────────────────────────────────┘
```
### 4.4 昔涟状态指示器
```
在线 (绿色点 + "昔涟")
● 昔涟
思考中 (黄色脉冲 + "思考中...")
◉ 思考中...
离线 (灰色 + "离线")
○ 昔涟 · 离线
说话中 (蓝色波纹 + "正在说话...")
〰 正在说话...
```
### 4.5 IoT 设备卡片
```
┌──────────────────────────┐
│ 💡 客厅灯 ● ON │
│ ┌──────────────────────┐ │
│ │ 亮度 ████████░░ 80% │ │
│ │ 色温 ████░░░░░░ 4000K│ │
│ └──────────────────────┘ │
│ [💡 开关] │
└──────────────────────────┘
```
## 5. 动效规范
| 动效 | 时长 | 曲线 | 说明 |
|------|------|------|------|
| 覆盖层出现 | 300ms | `FastOutSlowInEasing` | 底部滑入 |
| 覆盖层消失 | 250ms | `FastOutLinearInEasing` | 底部滑落 |
| 气泡出现 | 200ms | `LinearOutSlowInEasing` | 淡入 + 微上移 |
| 涟漪效果 | 400ms | `LinearEasing` | 标准 MD3 ripple |
| 页面切换 | 300ms | `FastOutSlowInEasing` | 淡入淡出 |
| 波形动画 | 循环 | — | 录制时音频可视化 |
| 状态指示脉冲 | 2s 循环 | — | 思考中 / 说话中的呼吸灯效果 |
## 6. 图标系统
| 来源 | 用途 |
|------|------|
| `Icons.Filled` | 导航栏、主要操作按钮 |
| `Icons.Outlined` | 列表项、辅助操作 |
| `Icons.Rounded` | 芯片、标签 |
| 自定义 Lottie | 昔涟头像动画、情感表达 |
| 自定义 Vector | 品牌 LOGO、IoT 设备图标 |
## 7. 悬浮窗 vs 全屏 布局差异
| 元素 | 全屏 Activity | 悬浮窗 Overlay |
|------|-------------|---------------|
| TopAppBar | 显示(标题 + 操作) | 不显示 |
| BottomNav | 显示(三 Tab | 不显示 |
| 对话区域 | 全屏滚动 | 自适应高度,最大 70% 屏幕 |
| 背景 | Surface 纯色 | 半透明遮罩 + 卡片 |
| 圆角 | 无 | 顶部 28dp |
| 导航返回 | 系统返回键 | 关闭覆盖层 |
| IoT 面板 | 完整功能 | 仅限查询,无控制 |
| 设置入口 | 完整 | 无(需打开 APP) |
## 8. 无障碍规范
- 所有可交互元素提供 `contentDescription`
- 语音按钮提供大点击区域(最小 48dp × 48dp)
- 支持 TalkBack 导航
- 字体缩放:支持系统字体大小设置(最大 200%)
- 色彩对比度:满足 WCAG AA 标准(正文 ≥ 4.5:1,大文字 ≥ 3:1)
+272
View File
@@ -0,0 +1,272 @@
# 04 — 功能规格说明书
> **版本**MVP v0.1 → Stable v1.0
> **优先级定义**:P0 = 不可缺失 | P1 = 首个正式版必需 | P2 = 后续版本
---
## 1. 功能总览
### MVP (v0.1) — 核心语音助手
| # | 功能 | 优先级 | 说明 |
|---|------|--------|------|
| F01 | VoiceInteractionService 注册 | P0 | 系统可识别并设为默认助手 |
| F02 | 语音唤醒(热词"昔涟" | P0 | 息屏/亮屏唤醒 |
| F03 | 悬浮覆盖层对话 | P0 | VoiceInteractionSession 界面 |
| F04 | STT 语音识别 | P0 | 实时语音转文字 |
| F05 | TTS 语音合成 | P0 | 文字转语音回复 |
| F06 | 实时文字对话 | P0 | WebSocket 双向通信 |
| F07 | 用户认证与登录 | P0 | Token 持久化 |
### v0.5 — 功能完善
| # | 功能 | 优先级 | 说明 |
|---|------|--------|------|
| F08 | IoT 设备状态查询 | P1 | 只读查询设备状态 |
| F09 | IoT 设备控制 | P1 | 开关/调节设备 |
| F10 | 推送通知 (FCM) | P1 | 昔涟主动消息、提醒 |
| F11 | 多会话历史 | P1 | 查看历史对话记录 |
| F12 | 提醒管理 | P1 | 创建/查看/删除提醒 |
| F13 | 全屏 Activity 模式 | P1 | 桌面图标入口、完整功能 |
| F14 | 暗黑模式 | P1 | 跟随系统 / 手动切换 |
| F15 | 自定义唤醒词 | P2 | 用户可修改唤醒词 |
### v1.0 — 正式版
| # | 功能 | 优先级 | 说明 |
|---|------|--------|------|
| F16 | 记忆查看 | P1 | 浏览昔涟的记忆 |
| F17 | 自动化规则 | P2 | 查看/触发自动化场景 |
| F18 | 知识库查询 | P2 | 检索知识文档 |
| F19 | 文件上传 | P2 | 图片上传与分析 |
| F20 | 后台思考展示 | P2 | 查看昔涟的思考内容 |
| F21 | 多设备同步 | P2 | Web 端和 Android 端对话同步 |
| F22 | 画中画语音通话 | P2 | 持续语音对话的 PIP 模式 |
| F23 | 主题自定义 | P2 | 预设主题色切换 |
| F24 | 离线兜底 | P2 | 无网络时的本地回复 |
---
## 2. P0 功能详细规格
### F01 · VoiceInteractionService 注册
**用户故事**:作为用户,我可以在系统设置中将昔涟设为默认语音助手。
**验收标准**
- [ ] AndroidManifest.xml 正确声明 `VoiceInteractionService`
- [ ] 系统 **设置 → 默认应用 → 数字助理** 列表中可见 "昔涟"
- [ ] 选中后,长按 Home 键可触发昔涟
- [ ] 选中后,底部两角滑动可触发昔涟
- [ ] 未设为默认时,APP 内显示引导卡片并一键跳转设置页
**技术依赖**:无
---
### F02 · 语音唤醒
**用户故事**:作为用户,我可以在息屏或使用其他 APP 时说"昔涟"直接唤起助手。
**验收标准**
- [ ] 息屏状态下说出"昔涟"可唤醒(成功率 ≥ 95%,安静环境)
- [ ] 亮屏使用其他 APP 时说出"昔涟"可唤醒
- [ ] 唤醒后显示悬浮覆盖层,播放提示音
- [ ] 10 分钟无交互自动停止热词监听以省电
- [ ] 用户可在设置中开启/关闭息屏唤醒
- [ ] 误唤醒率 ≤ 5 次/天(正常使用环境)
**技术依赖**F01 (VoiceInteractionService)`CAPTURE_AUDIO_HOTWORD` 权限
---
### F03 · 悬浮覆盖层对话
**用户故事**:作为用户,语音唤醒昔涟后看到半透明覆盖层,不影响当前使用的 APP。
**验收标准**
- [ ] 底部滑入动画 300ms,覆盖层显示
- [ ] 半透明黑色遮罩透出底层 APP 内容
- [ ] 对话卡片顶部圆角 28dp,自适应高度(最大 70% 屏幕)
- [ ] 点击遮罩区域关闭覆盖层
- [ ] 下滑卡片关闭覆盖层
- [ ] 静默 10 秒自动收起
- [ ] 用户说"再见"/"退下"自然结束对话
- [ ] 关闭后回到触发前状态,不压入任何 Activity 栈
**技术依赖**F01 (VoiceInteractionService)
---
### F04 · STT 语音识别
**用户故事**:作为用户,我可以对昔涟说话,她会实时将我的语音转成文字。
**验收标准**
- [ ] 覆盖层显示时自动开始监听
- [ ] 实时显示识别中间结果(流式 STT)
- [ ] 语音结束(静默 1.5s)后自动提交识别结果
- [ ] 识别结果以用户气泡形式显示在对话中
- [ ] 安静环境识别准确率 ≥ 95%(中文普通话)
- [ ] 支持噪音环境降噪
**技术依赖**:后端 Whisper API (voice-service :8093)`RECORD_AUDIO` 权限
---
### F05 · TTS 语音合成
**用户故事**:作为用户,昔涟可以用自然的声音读出她的回复。
**验收标准**
- [ ] 流式 TTS:收到 LLM 第一个 token 即开始合成
- [ ] 语音自然流畅,无机械感(使用后端 Edge-TTS 或训练模型)
- [ ] 播放完毕自动进入下一轮监听
- [ ] 播放期间自动降低其他音频音量(Audio Focus
- [ ] 用户可在设置中调整语速、音量
**技术依赖**:后端 TTS API (voice-service :8093)
---
### F06 · 实时文字对话
**用户故事**:作为用户,我可以打字与昔涟交流,并实时看到她的回复。
**验收标准**
- [ ] WebSocket 连接建立后保持心跳 (30s ping)
- [ ] 文本消息发送后 200ms 内显示用户气泡
- [ ] 昔涟回复流式显示(逐字/逐 token 渲染)
- [ ] 正确处理 `chat``action``thinking` 三种消息类型
- [ ] 断线自动重连(指数退避,最多 5 次)
- [ ] 连接状态在界面实时指示
- [ ] 多条消息按时间顺序排列
- [ ] 支持消息滚动到顶部加载历史
**技术依赖**:后端 Gateway WebSocket (:8080)
---
### F07 · 用户认证与登录
**用户故事**:作为用户,我可以登录我的 Cyrene 账号以同步数据。
**验收标准**
- [ ] 首次启动显示登录页
- [ ] 支持输入 Gateway 地址 + 账号 + 密码
- [ ] 登录成功保存 JWT Token 到 EncryptedDataStore
- [ ] Token 过期自动刷新(refresh token
- [ ] 再次启动自动登录(skip login page
- [ ] 登录失败显示明确错误信息
- [ ] 支持退出登录并清除本地数据
**技术依赖**:后端 Gateway Auth API (:8080)
---
## 3. P1 功能详细规格
### F08 · IoT 设备状态查询
**验收标准**
- [ ] 全屏 Activity 中 IoT Tab 显示所有设备卡片
- [ ] 实时显示设备状态(开/关、亮度、温度等)
- [ ] 通过 WebSocket 接收状态变更推送
- [ ] 覆盖层模式下支持语音查询("灯开着吗?")
- [ ] 覆盖层模式下 IoT 查询结果以文字+卡片形式展示
- [ ] 锁屏状态下仅允许查询
### F09 · IoT 设备控制
**验收标准**
- [ ] 设备卡片上可直接开关/调节
- [ ] 支持语音控制("打开客厅灯")
- [ ] 控制结果实时反馈(成功/失败)
- [ ] 锁屏状态下禁止控制,提示解锁
- [ ] 支持设备白名单(每用户可控制的设备不同)
### F10 · 推送通知 (FCM)
**验收标准**
- [ ] 接收昔涟主动消息推送
- [ ] 接收提醒到期推送
- [ ] 接收 IoT 状态变更推送
- [ ] 点击通知打开对应界面
- [ ] 通知渠道分组(对话 / 提醒 / IoT / 系统)
- [ ] 用户可独立控制各渠道开关
### F11 · 多会话历史
**验收标准**
- [ ] 对话列表页展示所有历史会话
- [ ] 每条会话显示标题、最后一条消息预览、时间
- [ ] 点击进入对应会话详情
- [ ] 支持删除会话
- [ ] 与 Web 端历史同步
### F12 · 提醒管理
**验收标准**
- [ ] 可以语音创建提醒("提醒我下午三点开会")
- [ ] 列表展示所有活跃提醒
- [ ] 支持删除/标记完成
- [ ] 到期时 FCM 推送 + 覆盖层显示
### F13 · 全屏 Activity 模式
**验收标准**
- [ ] 桌面图标启动进入全屏界面
- [ ] BottomNav 三 Tab(对话 / IoT / 我的)
- [ ] 完整的设置页
- [ ] 与覆盖层共享同一 WebSocket 连接和 ViewModel
### F14 · 暗黑模式
**验收标准**
- [ ] 跟随系统暗黑模式自动切换
- [ ] 用户可在设置中手动选择 Light / Dark / Auto
- [ ] 覆盖层同步使用当前主题
- [ ] 对话气泡、卡片、输入框颜色正确适配
---
## 4. P2 功能概要
| # | 功能 | 关键验收标准 |
|---|------|------------|
| F15 | 自定义唤醒词 | 设置页可输入自定义词,验证唯一性,测试唤醒效果 |
| F16 | 记忆查看 | 时间线展示昔涟记忆,支持搜索过滤 |
| F17 | 自动化规则 | 查看规则列表,手动触发,查看执行日志 |
| F18 | 知识库查询 | 搜索文档,查看内容,语音问答 |
| F19 | 文件上传 | 图片选择/拍照,缩略图预览,AI 分析结果 |
| F20 | 后台思考 | 展示昔涟后台思考片段,可折叠面板 |
| F21 | 多设备同步 | Web 端和 Android 端对话实时同步 |
| F22 | PIP 语音通话 | 切换到 PIP 窗口进行持续语音对话 |
| F23 | 主题自定义 | 从预设色中选择,实时预览 |
| F24 | 离线兜底 | 无网络时显示离线提示,缓存本地回复模板 |
---
## 5. 按开发阶段分组
### Sprint 1 (MVP)F01 → F07 (P0 全部)
目标:可设为系统默认助手,能语音唤醒并对话
### Sprint 2F08 → F14 (P1 全部)
目标:IoT 控制、全屏界面、推送通知上线
### Sprint 3+F15 → F24 (P2 逐个)
目标:体验完善、高级功能
## 6. 非功能需求
| 类别 | 需求 | 指标 |
|------|------|------|
| 性能 | 覆盖层冷启动 | < 500ms |
| 性能 | 语音识别端到端延迟 | < 2s (STT + LLM + TTS) |
| 性能 | WebSocket 消息延迟 | < 100ms |
| 稳定性 | 崩溃率 | < 0.5% |
| 电量 | 热词监听功耗 | < 3% 电池/小时 (息屏) |
| 网络 | 支持弱网 | 切换到低码率 TTS |
| 兼容性 | 国内 ROM 适配 | MIUI / ColorOS / OriginOS / HarmonyOS |
+14
View File
@@ -0,0 +1,14 @@
# Gradle
org.gradle.parallel=true
org.gradle.daemon=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.internal.http.connectionTimeout=60000
org.gradle.internal.http.socketTimeout=60000
# Kotlin
kotlin.code.style=official
# Android
android.useAndroidX=true
android.nonTransitiveRClass=true
android.suppressUnsupportedCompileSdk=36
+70
View File
@@ -0,0 +1,70 @@
[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"
coil = "2.7.0"
[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" }
# Coil
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
# Biometric
biometric = { group = "androidx.biometric", name = "biometric", version = "1.1.0" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+25
View File
@@ -0,0 +1,25 @@
pluginManagement {
repositories {
maven { url = uri("https://maven.aliyun.com/repository/public") }
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
maven { url = uri("https://maven.aliyun.com/repository/public") }
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
google()
mavenCentral()
}
}
rootProject.name = "Cyrene"
include(":app")