Compare commits
20
Commits
a7b3452e6d
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc7630c43a | ||
|
|
08d78c976a | ||
|
|
6394099e2e | ||
|
|
e65a35a239 | ||
|
|
7fcf562648 | ||
|
|
64c7018729 | ||
|
|
86d196b857 | ||
|
|
91231834dc | ||
|
|
5dad0cd39b | ||
|
|
ce73f68bc8 | ||
|
|
3c90adae6a | ||
|
|
014437760d | ||
|
|
eb94142404 | ||
|
|
1c96588d79 | ||
|
|
9295fe8021 | ||
|
|
5247eef0fc | ||
|
|
2725fdd1d5 | ||
|
|
367ef7f2d6 | ||
|
|
a57692353c | ||
|
|
9b8c8ab37d |
No files matched your search
+29
-5
@@ -1,12 +1,36 @@
|
||||
*.iml
|
||||
# ---> Android
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# Log/OS Files
|
||||
*.log
|
||||
|
||||
# Android Studio generated files and folders
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
*.apk
|
||||
output.json
|
||||
|
||||
# 本地设计/协作文档,不进仓库
|
||||
devdocs/
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/
|
||||
misc.xml
|
||||
deploymentTargetDropDown.xml
|
||||
render.experimental.xml
|
||||
|
||||
# Keystore files
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
google-services.json
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
keystore.properties
|
||||
@@ -0,0 +1,73 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
||||
|
||||
Copyright 2026 AskaEth
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,182 @@
|
||||
# Cyrene for Android
|
||||
|
||||
昔涟在安卓设备上的载体 —— 随时随地与昔涟对话、操控 IoT 设备、接收实时通知。
|
||||
|
||||
## 概述
|
||||
|
||||
Cyrene for Android 是 [Cyrene(昔涟)](https://github.com/Cyrene/Cyrene) 项目的官方 Android 客户端。Cyrene 是一个开源的基于 LLM 平台的智能体,提供多人格对话、IoT 设备操控、记忆管理、自动化规则、知识库、语音交互等功能。
|
||||
|
||||
Android 客户端的目标是成为用户的**默认语音助手**,完全替代系统自带的助手(Google Assistant / Bixby 等),同时提供:
|
||||
|
||||
- **系统级语音助手** — 注册为 `VoiceInteractionService`,可通过长按 Home / 侧滑呼出
|
||||
- **语音唤醒** — 息屏热词唤醒、免提交互(类似 "Hey Google",使用 "昔涟" 等唤醒词)
|
||||
- **随时对话** — 通过文字或语音与昔涟交流
|
||||
- **IoT 控制** — 远程操控家中的智能设备
|
||||
- **实时通知** — 接收昔涟的主动消息、提醒、IoT 状态变更
|
||||
- **后台连接** — 即使 APP 在后台,仍保持通知推送与语音服务就绪
|
||||
|
||||
## 开发状态
|
||||
|
||||
> **当前阶段**:项目初始化,尚未开始正式开发。
|
||||
|
||||
根据主项目的[开发路线图](../docs/dev-plan/00-development-roadmap.md),Android 客户端计划在 **Phase 5(v1.5 → v2.0)** 开始开发,预计时间窗口为 **2027 Q2-Q3**。
|
||||
|
||||
## 技术栈 (规划)
|
||||
|
||||
| 层 | 技术 |
|
||||
|----|------|
|
||||
| 语言 | Kotlin |
|
||||
| UI 框架 | Jetpack Compose + Material Design 3 |
|
||||
| 架构 | MVVM + Repository |
|
||||
| 网络 | OkHttp / Retrofit + WebSocket |
|
||||
| 本地存储 | Room (SQLite) + DataStore |
|
||||
| 推送 | FCM (Firebase Cloud Messaging) |
|
||||
| 语音 | VoiceInteractionService + 热词唤醒 + STT + TTS |
|
||||
| 构建 | Gradle (Kotlin DSL) |
|
||||
|
||||
## 功能路线图
|
||||
|
||||
- [ ] 用户认证与登录
|
||||
- [ ] 实时文字对话 (WebSocket)
|
||||
- [ ] VoiceInteractionService 注册(替换系统语音助手)
|
||||
- [ ] 间接启动悬浮窗(VoiceInteractionSession 全屏覆盖层)
|
||||
- [ ] 热词唤醒(息屏 / 亮屏)+ 唤醒词自定义
|
||||
- [ ] 语音识别 (STT)
|
||||
- [ ] 语音合成 (TTS)
|
||||
- [ ] IoT 设备控制面板
|
||||
- [ ] 推送通知 (FCM)
|
||||
- [ ] 后台连接与通知
|
||||
- [ ] 锁屏 / 息屏语音交互
|
||||
- [ ] PWA 保底方案 (短期替代)
|
||||
|
||||
## 交互模式
|
||||
|
||||
APP 有两种界面呈现方式,根据启动来源自动切换:
|
||||
|
||||
| 启动方式 | 界面模式 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 桌面图标 / 最近任务 | **全屏 Activity** | 常规 APP 模式,完整功能入口 |
|
||||
| 语音唤醒 / 长按 Home / 侧滑 / 长按电源键 / 耳机按键 | **悬浮窗 (VoiceInteractionSession)** | 全屏覆盖层,半透明背景透出底层 APP,不影响当前任务栈 |
|
||||
|
||||
悬浮窗模式的要点:
|
||||
- 借助 `VoiceInteractionSession` 系统窗口,不压入 Activity 返回栈
|
||||
- 对话结束后窗口收起,用户回到触发前的界面
|
||||
- 底层 APP 内容半透明可见(模糊遮罩),让用户保持上下文感知
|
||||
- 窗口高度自适应对话内容,类似 Google Assistant 的卡片式覆盖
|
||||
|
||||
## 设计规范
|
||||
|
||||
- **设计语言**:Material Design 3 (Material You)
|
||||
- **主题**:动态配色(Dynamic Color),跟随系统壁纸或手动选择主题色
|
||||
- **暗黑模式**:支持 Light / Dark 双主题,跟随系统或手动切换
|
||||
- **组件**:全面使用 `androidx.compose.material3` 组件库
|
||||
- **图标**:Material Icons + 自定义图标
|
||||
- **动效**:遵循 MD3 动效规范(过渡动画、涟漪效果、Shared Elements)
|
||||
- **字体**:系统默认字体(Roboto / Google Sans),支持动态字体缩放
|
||||
|
||||
## 项目结构 (规划)
|
||||
|
||||
```
|
||||
android/
|
||||
├── app/
|
||||
│ ├── src/main/
|
||||
│ │ ├── java/com/cyrene/app/
|
||||
│ │ │ ├── ui/ # Compose UI 层
|
||||
│ │ │ ├── viewmodel/ # ViewModel 层
|
||||
│ │ │ ├── repository/ # 数据仓库层
|
||||
│ │ │ ├── data/ # 数据模型 & API 接口
|
||||
│ │ │ ├── service/ # VoiceInteractionService & WebSocket & FCM
|
||||
│ │ │ ├── voice/ # 热词唤醒 (Hotword) & STT & TTS
|
||||
│ │ │ └── di/ # 依赖注入
|
||||
│ │ └── res/ # 资源文件
|
||||
│ └── build.gradle.kts
|
||||
├── build.gradle.kts
|
||||
├── settings.gradle.kts
|
||||
└── gradle.properties
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Android Studio Hedgehog (2023.1.1) 或更高版本
|
||||
- Kotlin 2.0+
|
||||
- JDK 17+
|
||||
- Android SDK (target: 34+, min: 26+)
|
||||
|
||||
## 网络配置(中国大陆用户)
|
||||
|
||||
Gradle、Google Maven、Maven Central 等服务器在国内访问缓慢或不可达,首次同步前需配置镜像。
|
||||
|
||||
### Gradle Wrapper
|
||||
|
||||
`gradle/wrapper/gradle-wrapper.properties`:
|
||||
|
||||
```properties
|
||||
# 将 services.gradle.org 替换为腾讯/阿里镜像
|
||||
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.7-bin.zip
|
||||
# 或阿里: https\://mirrors.aliyun.com/macports/distfiles/gradle/gradle-8.7-bin.zip
|
||||
```
|
||||
|
||||
### 仓库镜像
|
||||
|
||||
`settings.gradle.kts` 顶部添加:
|
||||
|
||||
```kotlin
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven { url = uri("https://mirrors.cloud.tencent.com/gradle/plugins") }
|
||||
maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public") }
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
maven { url = uri("https://mirrors.tencent.com/nexus/repository/maven-public") }
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Gradle 属性
|
||||
|
||||
`gradle.properties`:
|
||||
|
||||
```properties
|
||||
# HTTP 代理(如使用 Clash/V2Ray,通常不需要)
|
||||
# systemProp.http.proxyHost=127.0.0.1
|
||||
# systemProp.http.proxyPort=7890
|
||||
# systemProp.https.proxyHost=127.0.0.1
|
||||
# systemProp.https.proxyPort=7890
|
||||
```
|
||||
|
||||
### Android SDK Proxy
|
||||
|
||||
若 Android SDK Manager 也下载缓慢,可在 Android Studio 中设置:
|
||||
**Settings → Appearance & Behavior → System Settings → HTTP Proxy**
|
||||
|
||||
配置为 `mirrors.cloud.tencent.com` 或 `mirrors.neusoft.edu.cn`,端口 `80`。
|
||||
|
||||
## 快速开始
|
||||
|
||||
> 项目尚未包含可构建的源码,以下为后续开发的参考步骤。
|
||||
|
||||
1. 用 Android Studio 打开 `android/` 目录
|
||||
2. 按上述"网络配置"替换国内镜像
|
||||
3. 等待 Gradle 同步完成
|
||||
4. 启动主项目后端服务(参考[主项目 README](../README.md))
|
||||
5. 在 `local.properties` 中配置 `baseUrl` 指向 Gateway 地址
|
||||
6. 选择模拟器或设备,点击 Run
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [Cyrene 主项目](../) — 后端服务、前端、部署文档
|
||||
- [开发路线图](../docs/dev-plan/00-development-roadmap.md)
|
||||
- [多平台接入方案](../docs/dev-plan/03-multi-platform-integration.md)
|
||||
- [语音系统计划](../docs/dev-plan/04-voice-system-plan.md)
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
+79
-37
@@ -1,30 +1,58 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
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 = 37
|
||||
buildToolsVersion = "37.0.0"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "top.yeij.cyrene"
|
||||
minSdk = 33
|
||||
targetSdk = 37
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -32,47 +60,61 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
// 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)
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
// Activity & Navigation
|
||||
implementation(libs.compose.activity)
|
||||
implementation(libs.compose.navigation)
|
||||
|
||||
implementation(libs.miuix.ui)
|
||||
implementation(libs.miuix.icons)
|
||||
implementation(libs.miuix.preference)
|
||||
// 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.kotlinx.serialization)
|
||||
implementation(libs.retrofit.gson)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
// 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)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
Vendored
+102
-8
@@ -1,10 +1,104 @@
|
||||
# kotlinx-serialization:保留 @Serializable 类的序列化器(M2 起启用)
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.**
|
||||
# Cyrene ProGuard Rules
|
||||
|
||||
-keepclassmembers class **$$serializer {
|
||||
*** descriptor;
|
||||
}
|
||||
-keepclasseswithmembers class ** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
# --- 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.**
|
||||
@@ -1,26 +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=".app.CyreneApplication"
|
||||
android:allowBackup="false"
|
||||
android:name=".CyreneApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Cyrene">
|
||||
android:theme="@style/Theme.Cyrene"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<!-- 全屏主界面 -->
|
||||
<activity
|
||||
android:name=".app.MainActivity"
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.content.Context
|
||||
import top.yeij.cyrene.core.auth.AuthManager
|
||||
import top.yeij.cyrene.core.auth.SessionStore
|
||||
import top.yeij.cyrene.core.prefs.AppSettings
|
||||
import top.yeij.cyrene.core.prefs.SessionPreferences
|
||||
import top.yeij.cyrene.data.api.AdminApi
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import top.yeij.cyrene.data.repository.AdminRepository
|
||||
import top.yeij.cyrene.data.repository.MessageRepository
|
||||
import top.yeij.cyrene.data.repository.SessionRepository
|
||||
import top.yeij.cyrene.data.ws.ChatSocket
|
||||
|
||||
/** 手写 DI 容器(D:不引 Hilt)。 */
|
||||
class AppContainer(context: Context) {
|
||||
|
||||
val sessionStore: SessionStore = SessionStore(context.applicationContext)
|
||||
val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext)
|
||||
val appSettings: AppSettings = AppSettings(context.applicationContext)
|
||||
val authManager: AuthManager = AuthManager(sessionStore)
|
||||
|
||||
private var cachedBaseUrl: String? = null
|
||||
private var cachedSessionsApi: SessionsApi? = null
|
||||
|
||||
/** 会话/消息 REST 客户端;baseUrl 变化时自动重建。 */
|
||||
val sessionsApi: SessionsApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedSessionsApi
|
||||
if (cached != null && cachedBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = SessionsApi::class.java,
|
||||
tokenProvider = { sessionStore.load().token },
|
||||
onUnauthorized = { authManager.onSessionExpired() },
|
||||
)
|
||||
cachedBaseUrl = baseUrl
|
||||
cachedSessionsApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val sessionRepository: SessionRepository get() = SessionRepository(sessionsApi)
|
||||
|
||||
val messageRepository: MessageRepository get() = MessageRepository(sessionsApi)
|
||||
|
||||
private var cachedAdminApi: AdminApi? = null
|
||||
private var cachedAdminBaseUrl: String? = null
|
||||
|
||||
/** 管理只读监控客户端;与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
|
||||
val adminApi: AdminApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedAdminApi
|
||||
if (cached != null && cachedAdminBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = AdminApi::class.java,
|
||||
tokenProvider = { sessionStore.load().token },
|
||||
onUnauthorized = { authManager.onSessionExpired() },
|
||||
)
|
||||
cachedAdminBaseUrl = baseUrl
|
||||
cachedAdminApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val adminRepository: AdminRepository get() = AdminRepository(adminApi)
|
||||
|
||||
fun createChatSocket(): ChatSocket = ChatSocket(
|
||||
baseUrlProvider = { authManager.currentBaseUrl },
|
||||
tokenProvider = { sessionStore.load().token },
|
||||
onAuthFailed = { authManager.onSessionExpired() },
|
||||
)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import top.yeij.cyrene.core.auth.SessionState
|
||||
import top.yeij.cyrene.ui.chat.ChatHomeScreen
|
||||
import top.yeij.cyrene.ui.common.LoadingScreen
|
||||
import top.yeij.cyrene.ui.login.LoginScreen
|
||||
|
||||
@Composable
|
||||
fun CyreneApp(container: AppContainer) {
|
||||
val state by container.authManager.state.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
container.authManager.bootstrap()
|
||||
}
|
||||
|
||||
when (state) {
|
||||
SessionState.Unknown -> LoadingScreen()
|
||||
SessionState.LoggedOut -> LoginScreen(container)
|
||||
is SessionState.LoggedIn -> ChatHomeScreen(
|
||||
container = container,
|
||||
onLogout = { container.authManager.logout() },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class CyreneApplication : Application() {
|
||||
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = AppContainer(this)
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import top.yeij.cyrene.core.prefs.AppPrefs
|
||||
import top.yeij.cyrene.core.prefs.LocalAppPrefs
|
||||
import top.yeij.cyrene.core.prefs.ThemeMode
|
||||
import top.yeij.cyrene.core.theme.CyreneTheme
|
||||
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
||||
|
||||
/**
|
||||
* 应用根:读 App 偏好 → 决定 Miuix 主题模式 → 通过 [LocalAppPrefs] 下发给渲染层,
|
||||
* 同时把系统栏图标明暗跟着主题走(否则浅色模式下白字白底看不见)。
|
||||
*/
|
||||
@Composable
|
||||
fun CyreneRoot(container: AppContainer) {
|
||||
val prefs by container.appSettings.data.collectAsState(initial = AppPrefs())
|
||||
val darkTheme = when (prefs.themeMode) {
|
||||
ThemeMode.System -> isSystemInDarkTheme()
|
||||
ThemeMode.Light -> false
|
||||
ThemeMode.Dark -> true
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = view.context.findActivity()?.window ?: return@SideEffect
|
||||
WindowCompat.getInsetsController(window, view).apply {
|
||||
isAppearanceLightStatusBars = !darkTheme
|
||||
isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalAppPrefs provides prefs) {
|
||||
CyreneTheme(mode = prefs.themeMode.toColorSchemeMode()) {
|
||||
CyreneApp(container)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ThemeMode.toColorSchemeMode(): ColorSchemeMode = when (this) {
|
||||
ThemeMode.System -> ColorSchemeMode.System
|
||||
ThemeMode.Light -> ColorSchemeMode.Light
|
||||
ThemeMode.Dark -> ColorSchemeMode.Dark
|
||||
}
|
||||
|
||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
val container = (application as CyreneApplication).container
|
||||
setContent {
|
||||
CyreneRoot(container)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ApiErrorEnvelope
|
||||
import top.yeij.cyrene.data.api.CyreneApi
|
||||
import top.yeij.cyrene.data.api.dto.LoginRequest
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
sealed interface SessionState {
|
||||
data object Unknown : SessionState
|
||||
data object LoggedOut : SessionState
|
||||
data class LoggedIn(val baseUrl: String, val role: String) : SessionState
|
||||
}
|
||||
|
||||
class AuthManager(private val store: SessionStore) {
|
||||
|
||||
private val _state = MutableStateFlow<SessionState>(SessionState.Unknown)
|
||||
val state: StateFlow<SessionState> = _state.asStateFlow()
|
||||
|
||||
private var api: CyreneApi? = null
|
||||
var currentBaseUrl: String = ""
|
||||
private set
|
||||
|
||||
/** 启动时:有效 token 直接进入;过期则用已存凭据静默重登(D12);否则回登录页。 */
|
||||
suspend fun bootstrap() = withContext(Dispatchers.IO) {
|
||||
val stored = store.load()
|
||||
currentBaseUrl = stored.baseUrl
|
||||
when {
|
||||
!stored.token.isNullOrBlank() && stored.expiresAt * 1000 > System.currentTimeMillis() -> {
|
||||
_state.value = SessionState.LoggedIn(stored.baseUrl, stored.role.orEmpty())
|
||||
}
|
||||
|
||||
!stored.username.isNullOrBlank() && !stored.password.isNullOrBlank() -> {
|
||||
val error = doLogin(stored.baseUrl, stored.username, stored.password)
|
||||
if (error != null) _state.value = SessionState.LoggedOut
|
||||
}
|
||||
|
||||
else -> _state.value = SessionState.LoggedOut
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun login(rawBaseUrl: String, username: String, password: String): CyreneError? =
|
||||
withContext(Dispatchers.IO) {
|
||||
doLogin(ApiClientFactory.normalizeBaseUrl(rawBaseUrl), username, password)
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
store.clearAll()
|
||||
api = null
|
||||
_state.value = SessionState.LoggedOut
|
||||
}
|
||||
|
||||
/** 会话过期(401):清 token、回登录页;凭据保留以便下次静默重登。 */
|
||||
fun onSessionExpired() {
|
||||
store.clearSession()
|
||||
api = null
|
||||
_state.value = SessionState.LoggedOut
|
||||
}
|
||||
|
||||
private suspend fun doLogin(url: String, username: String, password: String): CyreneError? {
|
||||
val client = ApiClientFactory.create(baseUrl = url)
|
||||
return try {
|
||||
val response = client.login(LoginRequest(username, password))
|
||||
val data = response.body()?.data
|
||||
when {
|
||||
response.isSuccessful && data != null -> {
|
||||
store.saveLogin(url, data.token, data.expiresAt, data.role, username, password)
|
||||
api = client
|
||||
currentBaseUrl = url
|
||||
_state.value = SessionState.LoggedIn(url, data.role)
|
||||
null
|
||||
}
|
||||
|
||||
response.isSuccessful -> CyreneError.Local(CyreneError.LocalKind.Parse)
|
||||
|
||||
else -> {
|
||||
val parsed = parseError(response.errorBody()?.string())
|
||||
if (response.code() == 401) {
|
||||
CyreneError.Unauthorized(parsed?.code.orEmpty(), parsed?.message.orEmpty())
|
||||
} else {
|
||||
CyreneError.Server(
|
||||
code = parsed?.code.orEmpty(),
|
||||
message = parsed?.message.orEmpty(),
|
||||
httpStatus = response.code(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
mapException(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseError(body: String?) = body
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let {
|
||||
runCatching {
|
||||
ApiClientFactory.json.decodeFromString(ApiErrorEnvelope.serializer(), it).error
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun mapException(e: Throwable): CyreneError = when (e) {
|
||||
is UnknownHostException -> CyreneError.Local(CyreneError.LocalKind.Dns)
|
||||
is SSLException -> CyreneError.Local(CyreneError.LocalKind.Tls)
|
||||
is SocketTimeoutException -> CyreneError.Local(CyreneError.LocalKind.Timeout)
|
||||
is ConnectException -> CyreneError.Local(CyreneError.LocalKind.ConnectFailed)
|
||||
is java.net.UnknownServiceException -> CyreneError.Local(CyreneError.LocalKind.CleartextBlocked)
|
||||
is IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
|
||||
else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
data class StoredSession(
|
||||
val baseUrl: String,
|
||||
val token: String?,
|
||||
val expiresAt: Long,
|
||||
val role: String?,
|
||||
val username: String?,
|
||||
val password: String?,
|
||||
)
|
||||
|
||||
/** JWT 与登录凭据的加密存储(红线:不落明文、不打日志)。 */
|
||||
class SessionStore(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
FILE_NAME,
|
||||
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
fun load(): StoredSession = StoredSession(
|
||||
baseUrl = prefs.getString(KEY_BASE_URL, "").orEmpty(),
|
||||
token = prefs.getString(KEY_TOKEN, null),
|
||||
expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L),
|
||||
role = prefs.getString(KEY_ROLE, null),
|
||||
username = prefs.getString(KEY_USERNAME, null),
|
||||
password = prefs.getString(KEY_PASSWORD, null),
|
||||
)
|
||||
|
||||
fun saveLogin(
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
expiresAt: Long,
|
||||
role: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) {
|
||||
prefs.edit()
|
||||
.putString(KEY_BASE_URL, baseUrl)
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putLong(KEY_EXPIRES_AT, expiresAt)
|
||||
.putString(KEY_ROLE, role)
|
||||
.putString(KEY_USERNAME, username)
|
||||
.putString(KEY_PASSWORD, password)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** 会话失效(401/过期):清 token,但保留凭据以便静默重登(D12)。 */
|
||||
fun clearSession() {
|
||||
prefs.edit()
|
||||
.remove(KEY_TOKEN)
|
||||
.remove(KEY_EXPIRES_AT)
|
||||
.remove(KEY_ROLE)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** 主动登出:凭据一并清掉,避免下次启动被静默登回去。 */
|
||||
fun clearAll() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FILE_NAME = "cyrene_session"
|
||||
const val KEY_BASE_URL = "base_url"
|
||||
const val KEY_TOKEN = "token"
|
||||
const val KEY_EXPIRES_AT = "expires_at"
|
||||
const val KEY_ROLE = "role"
|
||||
const val KEY_USERNAME = "username"
|
||||
const val KEY_PASSWORD = "password"
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package top.yeij.cyrene.core.error
|
||||
|
||||
/**
|
||||
* 统一错误模型:区分「服务端有响应」与「本地/网络层失败」。
|
||||
* 约定(S1.1):已建立连接时用服务端 `error.message`,本地错误由客户端负责文案。
|
||||
*/
|
||||
sealed interface CyreneError {
|
||||
val userMessage: String
|
||||
|
||||
data class Server(
|
||||
val code: String,
|
||||
val message: String,
|
||||
val httpStatus: Int,
|
||||
) : CyreneError {
|
||||
override val userMessage: String
|
||||
get() = message.ifBlank { "服务端错误(HTTP $httpStatus)" }
|
||||
}
|
||||
|
||||
data class Unauthorized(
|
||||
val code: String,
|
||||
val message: String,
|
||||
) : CyreneError {
|
||||
override val userMessage: String
|
||||
get() = message.ifBlank { "用户名或密码不正确" }
|
||||
}
|
||||
|
||||
data class Local(val kind: LocalKind) : CyreneError {
|
||||
override val userMessage: String
|
||||
get() = when (kind) {
|
||||
LocalKind.InvalidUrl -> "服务器地址格式不对,示例:http://192.168.1.10:8080"
|
||||
LocalKind.NoNetwork -> "网络不可用,请检查连接"
|
||||
LocalKind.Dns -> "找不到服务器,请检查服务器地址"
|
||||
LocalKind.Tls -> "证书校验失败(自签证书暂不支持)"
|
||||
LocalKind.Timeout -> "连接超时,服务器没有响应"
|
||||
LocalKind.ConnectFailed -> "无法连接到服务器"
|
||||
LocalKind.CleartextBlocked -> "系统禁止明文 HTTP 访问"
|
||||
LocalKind.Parse -> "服务器返回的数据无法识别"
|
||||
LocalKind.Unknown -> "发生未知错误"
|
||||
}
|
||||
}
|
||||
|
||||
enum class LocalKind {
|
||||
InvalidUrl,
|
||||
NoNetwork,
|
||||
Dns,
|
||||
Tls,
|
||||
Timeout,
|
||||
ConnectFailed,
|
||||
CleartextBlocked,
|
||||
Parse,
|
||||
Unknown,
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package top.yeij.cyrene.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
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.appDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_app")
|
||||
|
||||
enum class ThemeMode { System, Light, Dark }
|
||||
|
||||
/**
|
||||
* App 级偏好(非机密)。
|
||||
*
|
||||
* @param themeMode 主题深浅
|
||||
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白
|
||||
* @param thinkingExpanded 思维链卡片默认是否展开
|
||||
*/
|
||||
data class AppPrefs(
|
||||
val themeMode: ThemeMode = ThemeMode.System,
|
||||
val showNarration: Boolean = true,
|
||||
val thinkingExpanded: Boolean = true,
|
||||
)
|
||||
|
||||
/** 全局注入 App 偏好,供聊天渲染等深层组件读取。 */
|
||||
val LocalAppPrefs = staticCompositionLocalOf { AppPrefs() }
|
||||
|
||||
class AppSettings(context: Context) {
|
||||
|
||||
private val store: DataStore<Preferences> = context.applicationContext.appDataStore
|
||||
|
||||
val data: Flow<AppPrefs> = store.data.map { prefs ->
|
||||
AppPrefs(
|
||||
themeMode = prefs[KEY_THEME_MODE].toThemeMode(),
|
||||
showNarration = prefs[KEY_SHOW_NARRATION] ?: true,
|
||||
thinkingExpanded = prefs[KEY_THINKING_EXPANDED] ?: true,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setThemeMode(mode: ThemeMode) {
|
||||
store.edit { it[KEY_THEME_MODE] = mode.name }
|
||||
}
|
||||
|
||||
suspend fun setShowNarration(enabled: Boolean) {
|
||||
store.edit { it[KEY_SHOW_NARRATION] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setThinkingExpanded(enabled: Boolean) {
|
||||
store.edit { it[KEY_THINKING_EXPANDED] = enabled }
|
||||
}
|
||||
|
||||
private fun String?.toThemeMode(): ThemeMode = when (this) {
|
||||
ThemeMode.Light.name -> ThemeMode.Light
|
||||
ThemeMode.Dark.name -> ThemeMode.Dark
|
||||
else -> ThemeMode.System
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEY_THEME_MODE = stringPreferencesKey("theme_mode")
|
||||
val KEY_SHOW_NARRATION = booleanPreferencesKey("show_narration")
|
||||
val KEY_THINKING_EXPANDED = booleanPreferencesKey("thinking_expanded")
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package top.yeij.cyrene.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
|
||||
private val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_sessions")
|
||||
|
||||
/**
|
||||
* 会话列表的本地回退快照与显示态(§3.1)。
|
||||
*
|
||||
* @param snapshot 上次成功拉取的会话列表;服务端不可用时用它先渲染
|
||||
* @param snapshotAt 快照时间(用于「同步于 N 分钟前」)
|
||||
* @param fingerprint `baseUrl + username`;换服务器/账号后快照作废
|
||||
* @param order `sessionId -> 显示序号`(后端 title 恒空,序号本地生成)
|
||||
* @param activeSessionId 上次停留的会话
|
||||
*/
|
||||
data class SessionPrefs(
|
||||
val snapshot: List<SessionSummaryDto> = emptyList(),
|
||||
val snapshotAt: Long = 0L,
|
||||
val fingerprint: String = "",
|
||||
val order: Map<String, Int> = emptyMap(),
|
||||
val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
|
||||
)
|
||||
|
||||
class SessionPreferences(context: Context) {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
private val store: DataStore<Preferences> = context.applicationContext.sessionDataStore
|
||||
|
||||
val data: Flow<SessionPrefs> = store.data.map { prefs ->
|
||||
SessionPrefs(
|
||||
snapshot = prefs[KEY_SNAPSHOT].decodeSessions(),
|
||||
snapshotAt = prefs[KEY_SNAPSHOT_AT] ?: 0L,
|
||||
fingerprint = prefs[KEY_FINGERPRINT].orEmpty(),
|
||||
order = prefs[KEY_ORDER].decodeOrder(),
|
||||
activeSessionId = prefs[KEY_ACTIVE] ?: SessionSummaryDto.MAIN_SESSION_ID,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取成功:整体替换快照(不做 merge,避免已删除的会话变成幽灵),
|
||||
* 同时为新出现的次级会话分配显示序号、清理已消失会话的序号。
|
||||
*/
|
||||
suspend fun saveSnapshot(sessions: List<SessionSummaryDto>, fingerprint: String) {
|
||||
store.edit { prefs ->
|
||||
val sameAccount = prefs[KEY_FINGERPRINT].orEmpty() == fingerprint
|
||||
val order = if (sameAccount) prefs[KEY_ORDER].decodeOrder().toMutableMap() else mutableMapOf()
|
||||
var next = order.values.maxOrNull() ?: 0
|
||||
sessions.filterNot { it.isMain }.forEach { dto ->
|
||||
if (order[dto.id] == null) order[dto.id] = ++next
|
||||
}
|
||||
order.keys.retainAll(sessions.map { it.id }.toSet())
|
||||
|
||||
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, sessions)
|
||||
prefs[KEY_SNAPSHOT_AT] = System.currentTimeMillis()
|
||||
prefs[KEY_FINGERPRINT] = fingerprint
|
||||
prefs[KEY_ORDER] = json.encodeToString(OrderSerializer, order)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setActive(sessionId: String) {
|
||||
store.edit { prefs -> prefs[KEY_ACTIVE] = sessionId }
|
||||
}
|
||||
|
||||
/** 分配显示序号(当前最大值 + 1);已分配过则原样返回。 */
|
||||
suspend fun ensureOrder(sessionId: String): Int {
|
||||
var assigned = 0
|
||||
store.edit { prefs ->
|
||||
val order = prefs[KEY_ORDER].decodeOrder().toMutableMap()
|
||||
val existing = order[sessionId]
|
||||
if (existing != null) {
|
||||
assigned = existing
|
||||
} else {
|
||||
assigned = (order.values.maxOrNull() ?: 0) + 1
|
||||
order[sessionId] = assigned
|
||||
prefs[KEY_ORDER] = json.encodeToString(OrderSerializer, order)
|
||||
}
|
||||
}
|
||||
return assigned
|
||||
}
|
||||
|
||||
/** 删除会话:清序号、必要时把 active 退回主会话、同步从快照里剔除。 */
|
||||
suspend fun removeSession(sessionId: String) {
|
||||
store.edit { prefs ->
|
||||
val order = prefs[KEY_ORDER].decodeOrder().toMutableMap()
|
||||
order.remove(sessionId)
|
||||
prefs[KEY_ORDER] = json.encodeToString(OrderSerializer, order)
|
||||
|
||||
if (prefs[KEY_ACTIVE] == sessionId) {
|
||||
prefs[KEY_ACTIVE] = SessionSummaryDto.MAIN_SESSION_ID
|
||||
}
|
||||
|
||||
val remaining = prefs[KEY_SNAPSHOT].decodeSessions().filterNot { it.id == sessionId }
|
||||
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, remaining)
|
||||
}
|
||||
}
|
||||
|
||||
/** 登出 / 切换服务器时清空。 */
|
||||
suspend fun clear() {
|
||||
store.edit { it.clear() }
|
||||
}
|
||||
|
||||
private fun String?.decodeSessions(): List<SessionSummaryDto> = this
|
||||
?.let { runCatching { json.decodeFromString(SessionsSerializer, it) }.getOrNull() }
|
||||
.orEmpty()
|
||||
|
||||
private fun String?.decodeOrder(): Map<String, Int> = this
|
||||
?.let { runCatching { json.decodeFromString(OrderSerializer, it) }.getOrNull() }
|
||||
.orEmpty()
|
||||
|
||||
private companion object {
|
||||
val KEY_SNAPSHOT = stringPreferencesKey("sessions_snapshot")
|
||||
val KEY_SNAPSHOT_AT = longPreferencesKey("sessions_snapshot_at")
|
||||
val KEY_FINGERPRINT = stringPreferencesKey("sessions_fingerprint")
|
||||
val KEY_ORDER = stringPreferencesKey("sessions_order")
|
||||
val KEY_ACTIVE = stringPreferencesKey("active_session_id")
|
||||
|
||||
val SessionsSerializer = ListSerializer(SessionSummaryDto.serializer())
|
||||
val OrderSerializer = MapSerializer(String.serializer(), Int.serializer())
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package top.yeij.cyrene.core.theme
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import top.yukonga.miuix.kmp.theme.ThemeController
|
||||
|
||||
@Composable
|
||||
fun CyreneTheme(
|
||||
mode: ColorSchemeMode = ColorSchemeMode.System,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val controller = remember(mode) { ThemeController(mode) }
|
||||
MiuixTheme(controller = controller, content = content)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
|
||||
import top.yeij.cyrene.data.api.dto.MemorySummaryDto
|
||||
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* 管理只读监控端点(api-client §2,全部 `GET`,无写操作)。
|
||||
*
|
||||
* RBAC 由服务端兜底(`viewer` 对 ②③ 档一律 403),客户端按 §2.0 权限矩阵**自行降级**,
|
||||
* 见 [top.yeij.cyrene.domain.admin.AdminAccess];这里只负责取数,不做权限判断。
|
||||
*/
|
||||
interface AdminApi {
|
||||
|
||||
/** 网关请求指标;`qps` 为近 1 秒窗口计数。 */
|
||||
@GET("api/v1/admin/observability/metrics")
|
||||
suspend fun metrics(): Response<ApiEnvelope<AdminMetricsDto>>
|
||||
|
||||
/** 最近 trace span,服务端按时间倒序(最新在前);`limit` 缺省 50、上限 500。 */
|
||||
@GET("api/v1/admin/observability/trace")
|
||||
suspend fun trace(@Query("limit") limit: Int? = null): Response<ApiEnvelope<List<AdminTraceSpanDto>>>
|
||||
|
||||
/** 人格列表(含 admin 专属 `thinking`)。 */
|
||||
@GET("api/v1/admin/personas")
|
||||
suspend fun personas(): Response<ApiEnvelope<List<PersonaSummaryDto>>>
|
||||
|
||||
/** 私人记忆正文;`viewer` 会拿到 403。 */
|
||||
@GET("api/v1/admin/memories")
|
||||
suspend fun memories(): Response<ApiEnvelope<List<MemorySummaryDto>>>
|
||||
|
||||
/** 后台思考记录;`viewer` 会拿到 403。 */
|
||||
@GET("api/v1/admin/backthink")
|
||||
suspend fun backthink(): Response<ApiEnvelope<List<AdminBackthinkItemDto>>>
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.BuildConfig
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object ApiClientFactory {
|
||||
|
||||
val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
/** 登录接口(不注入 token)。 */
|
||||
fun create(
|
||||
baseUrl: String,
|
||||
tokenProvider: () -> String? = { null },
|
||||
onUnauthorized: () -> Unit = {},
|
||||
): CyreneApi = create(baseUrl, CyreneApi::class.java, tokenProvider, onUnauthorized)
|
||||
|
||||
/** 通用入口:按需构建任意 Retrofit service。 */
|
||||
fun <T : Any> create(
|
||||
baseUrl: String,
|
||||
service: Class<T>,
|
||||
tokenProvider: () -> String? = { null },
|
||||
onUnauthorized: () -> Unit = {},
|
||||
): T {
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.addInterceptor(HeaderInterceptor(tokenProvider, onUnauthorized))
|
||||
.apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
redactHeader("Authorization")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(normalizeBaseUrl(baseUrl))
|
||||
.client(client)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
.create(service)
|
||||
}
|
||||
|
||||
/** Retrofit 要求 baseUrl 以 `/` 结尾;用户没写 scheme 时补 `http://`。 */
|
||||
fun normalizeBaseUrl(raw: String): String {
|
||||
var url = raw.trim()
|
||||
if (url.isEmpty()) return url
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
url = "http://$url"
|
||||
}
|
||||
if (!url.endsWith("/")) url += "/"
|
||||
return url
|
||||
}
|
||||
|
||||
private class HeaderInterceptor(
|
||||
private val tokenProvider: () -> String?,
|
||||
private val onUnauthorized: () -> Unit,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val token = tokenProvider()
|
||||
val request = chain.request().newBuilder()
|
||||
.header("X-API-Version", "1")
|
||||
.apply { if (token != null) header("Authorization", "Bearer $token") }
|
||||
.build()
|
||||
val response = chain.proceed(request)
|
||||
// 只有「带了 token 仍 401」才算会话过期;登录请求不带 token,不受影响。
|
||||
if (response.code == 401 && token != null) {
|
||||
onUnauthorized()
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** 后端统一信封:成功 `{data}`,失败 `{error:{code,message}}`。 */
|
||||
@Serializable
|
||||
data class ApiEnvelope<T>(
|
||||
val data: T? = null,
|
||||
val error: ApiError? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiErrorEnvelope(
|
||||
val error: ApiError? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApiError(
|
||||
val code: String = "",
|
||||
val message: String = "",
|
||||
)
|
||||
@@ -1,76 +0,0 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.SerializationException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/**
|
||||
* 统一错误映射(S1.1):已建连接用服务端 `error`,本地异常按类型分类。
|
||||
* 供所有 REST 仓库复用;不要在各个仓库里各写一份。
|
||||
*/
|
||||
object ApiErrorMapper {
|
||||
|
||||
/** 有 HTTP 响应:401 → [CyreneError.Unauthorized],其余 → [CyreneError.Server]。 */
|
||||
fun fromHttp(httpStatus: Int, errorBody: String?): CyreneError {
|
||||
val error = parseErrorEnvelope(errorBody)
|
||||
val code = error?.code.orEmpty()
|
||||
val message = error?.message.orEmpty()
|
||||
return if (httpStatus == 401) {
|
||||
CyreneError.Unauthorized(code = code, message = message)
|
||||
} else {
|
||||
CyreneError.Server(code = code, message = message, httpStatus = httpStatus)
|
||||
}
|
||||
}
|
||||
|
||||
/** 本地/网络异常 → [CyreneError.Local]。 */
|
||||
fun fromThrowable(throwable: Throwable): CyreneError = when (throwable) {
|
||||
is UnknownHostException -> CyreneError.Local(CyreneError.LocalKind.Dns)
|
||||
is SSLException -> CyreneError.Local(CyreneError.LocalKind.Tls)
|
||||
is SocketTimeoutException -> CyreneError.Local(CyreneError.LocalKind.Timeout)
|
||||
is ConnectException -> CyreneError.Local(CyreneError.LocalKind.ConnectFailed)
|
||||
is SerializationException -> CyreneError.Local(CyreneError.LocalKind.Parse)
|
||||
is IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
|
||||
else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
|
||||
}
|
||||
|
||||
/** 解析 `{"error":{code,message}}`;非信封 / 空白 / 解析失败均返回 null。 */
|
||||
fun parseErrorEnvelope(errorBody: String?): ApiError? = errorBody
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let {
|
||||
runCatching {
|
||||
ApiClientFactory.json.decodeFromString(ApiErrorEnvelope.serializer(), it).error
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 [CyreneError] 塞进 Kotlin `Result` 的载体,便于 `Result<T>` 统一传递失败。 */
|
||||
class CyreneException(val error: CyreneError) : Exception(error.userMessage)
|
||||
|
||||
/**
|
||||
* 执行一次信封调用并把结果收敛成 `Result<T>`:
|
||||
* 成功且 `data != null` → success;成功但 `data == null` → Parse;否则走 [ApiErrorMapper]。
|
||||
*/
|
||||
suspend fun <T : Any> apiCall(block: suspend () -> Response<ApiEnvelope<T>>): Result<T> =
|
||||
try {
|
||||
val response = block()
|
||||
val data = response.body()?.data
|
||||
when {
|
||||
response.isSuccessful && data != null -> Result.success(data)
|
||||
response.isSuccessful -> Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
|
||||
else -> Result.failure(
|
||||
CyreneException(
|
||||
ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string()),
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.LoginRequest
|
||||
import top.yeij.cyrene.data.api.dto.LoginResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface CyreneApi {
|
||||
|
||||
@POST("api/v1/auth/login")
|
||||
suspend fun login(@Body body: LoginRequest): Response<ApiEnvelope<LoginResponse>>
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.CreateSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.DeleteSessionResponse
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* 会话与消息相关端点(api-reference §3.2/§3.4/§3.5/§3.6)。
|
||||
* 与 M1 的 `CyreneApi` 分开,避免互相污染。
|
||||
*/
|
||||
interface SessionsApi {
|
||||
|
||||
@GET("api/v1/sessions")
|
||||
suspend fun listSessions(): Response<ApiEnvelope<List<SessionSummaryDto>>>
|
||||
|
||||
/** 无 body;服务端返回 `201 {data:{sessionId}}`。 */
|
||||
@POST("api/v1/sessions")
|
||||
suspend fun createSession(): Response<ApiEnvelope<CreateSessionResponse>>
|
||||
|
||||
/** 主会话 `web` 删除会返回 `403 forbidden`。 */
|
||||
@DELETE("api/v1/sessions/{id}")
|
||||
suspend fun deleteSession(@Path("id") sessionId: String): Response<ApiEnvelope<DeleteSessionResponse>>
|
||||
|
||||
/**
|
||||
* 消息热分页,服务端按 `createdAt` 倒序(newest first)返回。
|
||||
* @param limit 默认 50、上限 200(由 [top.yeij.cyrene.data.repository.MessageRepository] 钳制)
|
||||
* @param before 上一页最旧消息的 `createdAt`(Unix 毫秒);null = 最新一页
|
||||
* @param platform 来源平台过滤;null = 全量
|
||||
*/
|
||||
@GET("api/v1/sessions/{id}/messages")
|
||||
suspend fun listMessages(
|
||||
@Path("id") sessionId: String,
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("before") before: Long? = null,
|
||||
@Query("platform") platform: String? = null,
|
||||
): Response<ApiEnvelope<List<HistoryMessageDto>>>
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* `GET /api/v1/admin/observability/metrics`(api-client §2「可观测性契约」)。
|
||||
* 后端不统计 token / 成本,因此这里也没有对应字段——不伪造指标。
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminMetricsDto(
|
||||
val totalRequests: Long = 0,
|
||||
val qps: Double = 0.0,
|
||||
val avgLatencyMs: Double = 0.0,
|
||||
val maxLatencyMs: Long = 0,
|
||||
val perMethod: Map<String, Long> = emptyMap(),
|
||||
val perPath: Map<String, Long> = emptyMap(),
|
||||
val statusCounts: Map<String, Long> = emptyMap(),
|
||||
val llmCalls: Long = 0,
|
||||
val subagentEvents: Long = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /api/v1/admin/observability/trace?limit=N` 单个 span。
|
||||
* `kind` / `status` 是开放集合,保留原始字符串(不建枚举)。
|
||||
* `startedAt` / `endedAt` 为 RFC3339;`props` / `events` 为任意结构,原样透传。
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminTraceSpanDto(
|
||||
val id: String,
|
||||
val parentId: String? = null,
|
||||
val name: String = "",
|
||||
val kind: String = "",
|
||||
val status: String = "",
|
||||
val startedAt: String = "",
|
||||
val endedAt: String? = null,
|
||||
val durationNs: Long = 0,
|
||||
val props: Map<String, JsonElement>? = null,
|
||||
val events: List<AdminTraceEventDto>? = null,
|
||||
)
|
||||
|
||||
/** `trace.events[]` 一项;`time` 为 RFC3339(纳秒精度)。 */
|
||||
@Serializable
|
||||
data class AdminTraceEventDto(
|
||||
val time: String = "",
|
||||
val type: String = "",
|
||||
val props: Map<String, JsonElement>? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 人格摘要(用户侧 `GET /api/v1/personas` 与 admin 侧 `GET /api/v1/admin/personas` 同形)。
|
||||
* admin 侧多一个 [thinking];未配置该人格时**该键完全不出现**,故为 nullable。
|
||||
*/
|
||||
@Serializable
|
||||
data class PersonaSummaryDto(
|
||||
val id: String,
|
||||
val name: String = "",
|
||||
val character: String = "",
|
||||
val isDefault: Boolean = false,
|
||||
val mode: String = "",
|
||||
val memoryNamespace: String = "",
|
||||
val avatar: String? = null,
|
||||
val chatBackground: ChatBackgroundDto? = null,
|
||||
val mood: String? = null,
|
||||
val thinking: PersonaThinkingDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatBackgroundDto(
|
||||
val landscape: String? = null,
|
||||
val portrait: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 人格 `thinking` 读侧形状(v0.43)。
|
||||
* [showToUser] 是**三态**:`null` = 未配置(保持现行为),true/false = 显式选择;
|
||||
* 不得把 null 拍平成 false。
|
||||
*/
|
||||
@Serializable
|
||||
data class PersonaThinkingDto(
|
||||
val budgetTokens: Int = 0,
|
||||
val effort: String = "",
|
||||
val showToUser: Boolean? = null,
|
||||
val mode: String = "",
|
||||
)
|
||||
|
||||
/** `GET /api/v1/admin/memories` 一条记忆;`scope` 为开放集合(`user|persona|project`)。 */
|
||||
@Serializable
|
||||
data class MemorySummaryDto(
|
||||
val id: String,
|
||||
val scope: String = "",
|
||||
val text: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /api/v1/admin/backthink` 一条后台思考记录。
|
||||
* [at] 是 **Unix 毫秒**(与 trace 的 RFC3339 不同源,切勿共用解析)。
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminBackthinkItemDto(
|
||||
val id: String,
|
||||
val trigger: String = "",
|
||||
val conclusion: String? = null,
|
||||
val shouldNotify: Boolean = false,
|
||||
val pushed: Boolean = false,
|
||||
val at: Long = 0,
|
||||
)
|
||||
@@ -1,16 +0,0 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginResponse(
|
||||
val token: String,
|
||||
val expiresAt: Long,
|
||||
val role: String,
|
||||
)
|
||||
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user