Compare commits
20
Commits
d0b8c5f2f7
..
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
+30
-8
@@ -1,14 +1,36 @@
|
||||
*.iml
|
||||
# ---> Android
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
.kotlin/
|
||||
# 本机开发凭据(主人授权落地的测试用 key 等)——只在本机,绝不进仓库
|
||||
dev-secrets.local.md
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# Log/OS Files
|
||||
*.log
|
||||
|
||||
# Android Studio generated files and folders
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
# 主人从手机丢进仓库根当素材用的图(例如换图标用的原图)——只用不存,绝不进仓库
|
||||
/IMG_*.png
|
||||
*.apk
|
||||
output.json
|
||||
|
||||
# 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,60 +1,121 @@
|
||||
<?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">
|
||||
|
||||
<!--
|
||||
launchMode="singleTop":App 已经开着时从别的 App 分享过来,系统不再新建一个实例,
|
||||
改成回调 onNewIntent(见 MainActivity)。不写它就会起第二个 Activity 实例、
|
||||
两个实例各持一套 ViewModel / socket。
|
||||
-->
|
||||
<!-- 全屏主界面 -->
|
||||
<activity
|
||||
android:name=".app.MainActivity"
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleTop"
|
||||
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>
|
||||
|
||||
<!--
|
||||
分享入口:只声明本端真能落地的两类 MIME(纯文本 / 图片)。
|
||||
刻意不声明 */*——对话路径上今天没有「文件」这个表示(协同文档 02/F1 的三缺),
|
||||
声明了就等于给出一个点进去必然失败的入口。标签沿用应用名(活动不设自己的
|
||||
label / icon 时,分享菜单取的就是 Cyrene 的名字与启动图标)。
|
||||
-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- 多图分享只收图片:多条纯文本的 EXTRA_TEXT(SEND_MULTIPLE + text/plain)本端不接。 -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
附件预览的「用其它应用打开 / 保存」出口:`cacheDir/documents/` 下的文件要交给
|
||||
系统里别的 App 读,就必须是 content:// 而不是 file://——API 24 起把 file:// 交出去
|
||||
会直接抛 FileUriExposedException(且 StrictMode 下同样致命)。
|
||||
路径范围见 res/xml/file_paths.xml:只暴露 cacheDir/documents/ 这一层,
|
||||
exported="false" + 按次授权(grantUriPermissions)保证这份缓存不对外常开。
|
||||
-->
|
||||
<!-- 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"
|
||||
|
||||
@@ -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,247 +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.EnumPreferences
|
||||
import top.yeij.cyrene.core.prefs.SessionPreferences
|
||||
import top.yeij.cyrene.core.session.GoneSessions
|
||||
import top.yeij.cyrene.data.api.AdminApi
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ChatApi
|
||||
import top.yeij.cyrene.data.api.DocumentsApi
|
||||
import top.yeij.cyrene.data.api.MetaApi
|
||||
import top.yeij.cyrene.data.api.PersonaApi
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import top.yeij.cyrene.data.api.UploadApi
|
||||
import top.yeij.cyrene.data.repository.AdminRepository
|
||||
import top.yeij.cyrene.data.repository.ChatRepository
|
||||
import top.yeij.cyrene.data.repository.DocumentRepository
|
||||
import top.yeij.cyrene.data.repository.MessageRepository
|
||||
import top.yeij.cyrene.data.repository.MetaRepository
|
||||
import top.yeij.cyrene.data.repository.PersonaRepository
|
||||
import top.yeij.cyrene.data.repository.SessionRepository
|
||||
import top.yeij.cyrene.data.repository.UploadRepository
|
||||
import top.yeij.cyrene.data.ws.ChatSocket
|
||||
|
||||
/** 手写 DI 容器(D:不引 Hilt)。 */
|
||||
class AppContainer(context: Context) {
|
||||
|
||||
/** 应用上下文:给需要在 ViewModel 里读本地资源(图片解码等)的组件用。 */
|
||||
val appContext: Context = context.applicationContext
|
||||
|
||||
val sessionStore: SessionStore = SessionStore(context.applicationContext)
|
||||
val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext)
|
||||
val enumPreferences: EnumPreferences = EnumPreferences(context.applicationContext)
|
||||
val appSettings: AppSettings = AppSettings(context.applicationContext)
|
||||
val authManager: AuthManager = AuthManager(sessionStore)
|
||||
|
||||
/**
|
||||
* 本端已知「服务端没有这个会话」的那几个 id(**进程级**,与 [chatSocket] 同一层)。
|
||||
*
|
||||
* 跨两个 VM 的唯一共享面:写它的是 `SessionsViewModel`(本端唯一的删除入口),读它的是
|
||||
* **每一个** `ChatViewModel`(它们各留一个常驻 VM,删会话不回收)。它拦下的是那一轮注定
|
||||
* `404 session_not_found` 的补拉 —— 理由与「为什么不持久化」见 [GoneSessions] 的类注释。
|
||||
*/
|
||||
val goneSessions: GoneSessions = GoneSessions()
|
||||
|
||||
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 = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedBaseUrl = baseUrl
|
||||
cachedSessionsApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val sessionRepository: SessionRepository get() = SessionRepository(sessionsApi)
|
||||
|
||||
val messageRepository: MessageRepository get() = MessageRepository(sessionsApi)
|
||||
|
||||
private var cachedChatApi: ChatApi? = null
|
||||
private var cachedChatBaseUrl: String? = null
|
||||
|
||||
/** 发送端点(`POST /api/v1/chat`);与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
|
||||
val chatApi: ChatApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedChatApi
|
||||
if (cached != null && cachedChatBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = ChatApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedChatBaseUrl = baseUrl
|
||||
cachedChatApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val chatRepository: ChatRepository get() = ChatRepository(chatApi)
|
||||
|
||||
private var cachedUploadApi: UploadApi? = null
|
||||
private var cachedUploadBaseUrl: String? = null
|
||||
|
||||
/**
|
||||
* 图片上传端点(`POST /api/v1/upload`);与 [chatApi] 同源(同一 baseUrl、同一 token/401 处理)——
|
||||
* 上传的产物 URL 就是要交给 `POST /api/v1/chat` 的 `image` 字段的,两者必须在同一个后端上。
|
||||
*/
|
||||
val uploadApi: UploadApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedUploadApi
|
||||
if (cached != null && cachedUploadBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = UploadApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedUploadBaseUrl = baseUrl
|
||||
cachedUploadApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val uploadRepository: UploadRepository get() = UploadRepository(uploadApi)
|
||||
|
||||
private var cachedDocumentsApi: DocumentsApi? = null
|
||||
private var cachedDocumentsBaseUrl: String? = null
|
||||
|
||||
/**
|
||||
* 文档存储端点(`POST /api/v1/documents` 上传、`GET /api/v1/documents/{name}` 取字节);
|
||||
* 与 [chatApi] **必须同源**(同一 baseUrl、同一 token/401 处理)——上传拿到的存储名就是要交给
|
||||
* 同一个后端的 `POST /api/v1/chat` 的 `documents[].name`,两条链路不在同一台机器上就必然 404。
|
||||
*
|
||||
* Bearer 与 401 续期不用在这里做额外事:[ApiClientFactory.create] 按 **OkHttpClient**
|
||||
* 挂 `AuthInterceptor`,文档读侧那点「与图片读侧刻意相反」的鉴权要求因此天然满足。
|
||||
*/
|
||||
val documentsApi: DocumentsApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedDocumentsApi
|
||||
if (cached != null && cachedDocumentsBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = DocumentsApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedDocumentsBaseUrl = baseUrl
|
||||
cachedDocumentsApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val documentRepository: DocumentRepository get() = DocumentRepository(documentsApi)
|
||||
|
||||
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 = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedAdminBaseUrl = baseUrl
|
||||
cachedAdminApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val adminRepository: AdminRepository get() = AdminRepository(adminApi)
|
||||
|
||||
private var cachedMetaApi: MetaApi? = null
|
||||
private var cachedMetaBaseUrl: String? = null
|
||||
|
||||
/** 枚举权威表客户端;同样与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
|
||||
val metaApi: MetaApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedMetaApi
|
||||
if (cached != null && cachedMetaBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = MetaApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedMetaBaseUrl = baseUrl
|
||||
cachedMetaApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val metaRepository: MetaRepository get() = MetaRepository(metaApi)
|
||||
|
||||
private var cachedPersonaApi: PersonaApi? = null
|
||||
private var cachedPersonaBaseUrl: String? = null
|
||||
|
||||
/** 用户侧角色目录(`GET /api/v1/personas`);与 [metaApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
|
||||
val personaApi: PersonaApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedPersonaApi
|
||||
if (cached != null && cachedPersonaBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = PersonaApi::class.java,
|
||||
tokenProvider = authManager::currentToken,
|
||||
unauthorizedPolicy = authManager.unauthorizedPolicy,
|
||||
)
|
||||
cachedPersonaBaseUrl = baseUrl
|
||||
cachedPersonaApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val personaRepository: PersonaRepository get() = PersonaRepository(personaApi)
|
||||
|
||||
/**
|
||||
* 进程内**唯一**的实时通道连接(`GET /ws/chat`):所有需要收下行帧的 ViewModel 共用这一条。
|
||||
*
|
||||
* ## 为什么可以共享(不是「省点资源」,是**逐帧等价**)
|
||||
* 服务端是**广播**:下行帧发给**每一条**已认证连接、**不按会话订阅**(见 [ChatSocket] 类注释
|
||||
* 「会话归属」)。于是改前「N 条连接,每条都收到全部帧 ⇒ 每个 VM 都收到全部帧 ⇒ 各 VM 靠
|
||||
* [top.yeij.cyrene.data.ws.WsEvent.isForSession] 收敛到自己的会话」与改后「一条连接收到全部帧
|
||||
* ⇒ **每个 VM 仍然收到全部帧**」是**同一个语义**:帧集合逐帧相同,过滤与事件分支一个字都不用改。
|
||||
* 这次变的只是「连接有几条」,不是「帧怎么走」。
|
||||
*
|
||||
* ## 为什么必须唯一
|
||||
* 每个 `ChatSocket` 都自带一个 `OkHttpClient`(各自的连接池 / ping / 重连循环)。改前
|
||||
* `ChatViewModel`(**每个会话一个 VM**)、[top.yeij.cyrene.ui.sessions.SessionsViewModel]、
|
||||
* [top.yeij.cyrene.ui.admin.AdminViewModel] 各自建了一条(`ChatViewModel.start()`、
|
||||
* 两个 VM 的 init)⇒ 访问 N 个会话 = N+2 条连接、N+2 个 `OkHttpClient`;而 VM 活在 Activity 级
|
||||
* `ViewModelStore` 里,**切走 / 删会话都不回收**。现在这里就是唯一的持有者(`by lazy`,只此一份),
|
||||
* 调用方只订阅 [ChatSocket.events] / [ChatSocket.state]。
|
||||
*
|
||||
* ## 开与关只有一个主人
|
||||
* [top.yeij.cyrene.ui.sessions.SessionsViewModel] 的登录态订阅:`LoggedIn → connect()`、
|
||||
* `LoggedOut → close()`(为什么是它,见那个类的 KDoc)。`connect()` 幂等,baseUrl / token 都是
|
||||
* 每次重连现读 ⇒ 登出重登、换账号**不需要**重建本实例。**任何 ViewModel 都不许关它**——
|
||||
* 细节与理由写在 [ChatSocket.close]。
|
||||
*/
|
||||
val chatSocket: ChatSocket by lazy {
|
||||
ChatSocket(
|
||||
baseUrlProvider = { authManager.currentBaseUrl },
|
||||
tokenProvider = authManager::currentToken,
|
||||
onAuthRecover = authManager::recoverSocketAuth,
|
||||
onAuthFailed = authManager::onSessionExpired,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +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 androidx.compose.runtime.rememberCoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
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()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
container.authManager.bootstrap()
|
||||
}
|
||||
|
||||
when (state) {
|
||||
SessionState.Unknown -> LoadingScreen()
|
||||
SessionState.LoggedOut -> LoginScreen(container)
|
||||
is SessionState.LoggedIn -> ChatHomeScreen(
|
||||
container = container,
|
||||
// 登出要先把服务端的 refresh 授权吊销掉,所以是挂起的;UI 不因为它在主线程等待。
|
||||
onLogout = { scope.launch { 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,72 +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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
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.yeij.cyrene.domain.enums.LocalEnumCatalog
|
||||
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
|
||||
|
||||
/**
|
||||
* 应用根:读 App 偏好 → 决定 Miuix 主题模式 → 通过 [LocalAppPrefs] / [LocalEnumCatalog] 下发给渲染层,
|
||||
* 同时把系统栏图标明暗跟着主题走(否则浅色模式下白字白底看不见)。
|
||||
*/
|
||||
@Composable
|
||||
fun CyreneRoot(container: AppContainer) {
|
||||
val prefs by container.appSettings.data.collectAsState(initial = AppPrefs())
|
||||
val enumCatalogViewModel: EnumCatalogViewModel = viewModel(
|
||||
factory = viewModelFactory { initializer { EnumCatalogViewModel(container) } },
|
||||
)
|
||||
val catalog by enumCatalogViewModel.catalog.collectAsState()
|
||||
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,
|
||||
LocalEnumCatalog provides catalog,
|
||||
) {
|
||||
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,53 +0,0 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
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.first
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.core.auth.SessionState
|
||||
import top.yeij.cyrene.domain.enums.EnumCatalog
|
||||
|
||||
/**
|
||||
* 枚举权威表(api-client §1.1)的应用级持有者:先用本地快照渲染,再拉一次权威表。
|
||||
*
|
||||
* - 登录 / 静默重登后拉取;**拉取失败保持现状**(快照或原样 id),绝不因此清空角标;
|
||||
* - 快照按 `baseUrl + username` 指纹认领:换服务器/账号即作废;
|
||||
* - 登出时落盘快照与内存标签一并清掉(此时界面只剩登录页,不会闪空角标)。
|
||||
*/
|
||||
class EnumCatalogViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
private val _catalog = MutableStateFlow(EnumCatalog.Empty)
|
||||
val catalog: StateFlow<EnumCatalog> = _catalog.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
container.authManager.state.collect { state ->
|
||||
if (state == SessionState.LoggedOut) {
|
||||
container.enumPreferences.clear()
|
||||
_catalog.value = EnumCatalog.Empty
|
||||
return@collect
|
||||
}
|
||||
val fingerprint = fingerprint()
|
||||
// 先用快照渲染(没有就原样显示 id),拉取不阻塞首帧
|
||||
val snapshot = container.enumPreferences.data.first()
|
||||
_catalog.value = EnumCatalog.of(snapshot.tableFor(fingerprint))
|
||||
if (state is SessionState.LoggedIn) refresh(fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh(fingerprint: String) {
|
||||
viewModelScope.launch {
|
||||
container.metaRepository.enums().onSuccess { table ->
|
||||
container.enumPreferences.save(table, fingerprint)
|
||||
_catalog.value = EnumCatalog.of(table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fingerprint(): String =
|
||||
"${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}"
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.content.Intent
|
||||
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)
|
||||
// 冷启动的两步:先清掉上一进程遗留的分享缓存(它已经没有主了),再接这次带来的分享。
|
||||
// 顺序不能反——反过来会把刚拷进来的图一起删掉。
|
||||
ShareIntake.reset(this)
|
||||
// 只在**真正的冷启动**接 intent:Activity 因旋转 / 深色切换而重建时,系统会把原来那个
|
||||
// intent 再送一遍,照单全收就会把同一条分享发第二次。重建带 savedInstanceState,
|
||||
// 冷启动不带——这就是判据。
|
||||
if (savedInstanceState == null) {
|
||||
intent?.let { ShareIntake.accept(this, it) }
|
||||
}
|
||||
val container = (application as CyreneApplication).container
|
||||
setContent {
|
||||
CyreneRoot(container)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 热启动(`launchMode="singleTop"`):App 已经开着时系统不再新建实例,只回调这里。
|
||||
* 不接这一路的话,「从浏览器分享 → 回到已经开着的 Cyrene」会一点反应都没有。
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// 让 getIntent() 与新到的 intent 一致(系统不会自己更新它),否则后续读到的还是旧的那个。
|
||||
setIntent(intent)
|
||||
ShareIntake.accept(this, intent)
|
||||
}
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
package top.yeij.cyrene.app
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import top.yeij.cyrene.domain.chat.DocumentAttachments
|
||||
import top.yeij.cyrene.domain.chat.DocumentDelivery
|
||||
import top.yeij.cyrene.domain.chat.DraftDocument
|
||||
import top.yeij.cyrene.domain.share.PendingShareHolder
|
||||
import top.yeij.cyrene.domain.share.ShareRules
|
||||
import top.yeij.cyrene.domain.share.SharedContent
|
||||
import top.yeij.cyrene.domain.share.SharedDocument
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* 「分享到 App」的接收入口(`ACTION_SEND` / `ACTION_SEND_MULTIPLE`)。
|
||||
*
|
||||
* ## 它为什么是一个进程级单例
|
||||
* 分享进来时可能还没登录、ChatScreen 甚至还没被组合出来,而登录之后消费它的又是**另一个**
|
||||
* Compose 实例。内容因此必须待在一个不属于任何 UI / ViewModel 的地方——见
|
||||
* [PendingShareHolder]。不塞进 `AppContainer` 是因为那个文件同时被别的并行改动占用,
|
||||
* 这里没有任何依赖需要它:本对象只需要一个 `Context`(读 URI、写 cache)。
|
||||
*
|
||||
* ## 图片为什么在收到的那一刻就落盘
|
||||
* `content://` 的读授权只给到**接收它的那个 Activity / 任务**,登录(可能十几秒、还可能切页)
|
||||
* 之后再去读,拿到的多半是 `SecurityException` / 打不开。所以字节在这里立刻拷进 `cacheDir`,
|
||||
* 之后一律用本端自己的 `file://` 路径——**外部 Uri 只在这个方法里出现一次**。文件(非图片)同理。
|
||||
*
|
||||
* ## 分流:图片还是文件
|
||||
* 逐条 URI 看 `ContentResolver.getType`(判定见 [top.yeij.cyrene.domain.share.classifySharedStream]):
|
||||
* MIME 以 `image/` 开头走既有图片路,**其余一律走文档路**。此前一律当图片拷、当图片解码,
|
||||
* 一份 `.md` 因此只有两种结局——「图片处理失败」或者被当成一张读不出来的图。
|
||||
*
|
||||
* ⚠️ **Manifest 刻意不声明全通配或文本通配 MIME**:后端**明令拒绝** `text/html` / SVG / XML /
|
||||
* 压缩包 / 脚本,放宽声明会造出一堆「点进去必然失败」的入口。所以这里能接到的**文件**只有
|
||||
* 系统按 `text/plain` 投过来那些(`.txt` / `.md` / `.log` 这类文件管理器确实会报 `text/plain`);
|
||||
* 报成 `text/markdown` 之类的分享**根本不会**出现在 Cyrene 的分享菜单里。
|
||||
*
|
||||
* ⚠️ **provider 不报 MIME(`getType` 回 null)时按文档路走**,这是刻意的:本端不猜类型,
|
||||
* 猜错等于替用户把一个合规文件变成一次失败;代价是「图片但 provider 没报 MIME」会落到文档路、
|
||||
* 被服务端按 `415` 拒(有如实文案)。画廊 / 相机 / MediaStore 都会报图片那一类,风险面很小。
|
||||
*
|
||||
* ## 清理
|
||||
* 缓存副本只对**产生它的那个进程**有意义(持有者在内存里,进程没了就没人认领),所以
|
||||
* 进程启动时整目录删一次([reset])。运行期间不做细粒度回收:那些文件在发送失败被回滚到
|
||||
* 输入框之后**仍然要用**(用户可能过一会儿才点发送),提前删掉就会让预览变空图。
|
||||
* 目录因此以「一次进程生命周期内的分享份数」为界(图片 4 + 文件 5,另加 [stageTextAsDocument]
|
||||
* 为超长正文落下的那一份 `.txt`),另有系统的 cache 回收兜底。
|
||||
*/
|
||||
object ShareIntake {
|
||||
|
||||
private const val DIR_NAME = "share-intake"
|
||||
|
||||
private val holder = PendingShareHolder<SharedContent>()
|
||||
|
||||
/** 待落地的分享内容;消费侧(ChatScreen)订阅它。 */
|
||||
val pending: StateFlow<List<SharedContent>> = holder.pending
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** 进程启动时调用一次:把上一进程遗留的分享缓存清干净。 */
|
||||
fun reset(context: Context) {
|
||||
if (cleanedThisProcess) return
|
||||
cleanedThisProcess = true
|
||||
runCatching { shareDir(context).deleteRecursively() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 「本进程已经清过一次」的标记。
|
||||
*
|
||||
* Activity 会因为旋转 / 分屏 / 深色切换而**重建**,重建就会再走一次 `onCreate`——
|
||||
* 那时清缓存会把用户刚分享进来、还摆在输入框里的那张图删掉(预览变空图、发送也没有字节)。
|
||||
* 缓存副本只对「产生它的进程」有意义,所以判据是**进程**而不是 Activity 实例。
|
||||
*/
|
||||
private var cleanedThisProcess = false
|
||||
|
||||
/**
|
||||
* 接收一个分享 Intent(冷启动在 `onCreate`、热启动在 `onNewIntent`)。
|
||||
*
|
||||
* 提取是纯函数([ShareRules.extract]),拷字节是重活,所以在 IO 线程做:`content://` 的读
|
||||
* 授权绑的是 Activity 而不是某一次调用栈,异步读一样有效。
|
||||
*/
|
||||
fun accept(context: Context, intent: Intent) {
|
||||
val appContext = context.applicationContext
|
||||
// EXTRA_STREAM 一次读原始值、按**形态**分派(规范形态 + 字符串兼容形态),见 [shareStreamExtrasOf]:
|
||||
// 挨个 getParcelableExtra / getParcelableArrayListExtra 去问的话,遇到字符串形态只会拿到两行
|
||||
// `ClassCastException` 加一个 `null`——那正是「分享进来什么都没发生」的来处。
|
||||
val rawStream = intent.streamExtraValue()
|
||||
val streamExtras = shareStreamExtrasOf(rawStream)
|
||||
val extracted = ShareRules.extract(
|
||||
action = intent.action,
|
||||
text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT),
|
||||
// 单值 / 列表两个字段都喂进去(本端不再按 action 猜该读哪个,见 [ShareRules.extract])。
|
||||
stream = streamExtras.uriForms.firstOrNull(),
|
||||
streams = streamExtras.uriForms,
|
||||
streamStrings = streamExtras.stringForms,
|
||||
// 「里面躺着值」这件事只有这里知道(纯函数看不到原始值):形态认不出的那种也算带着内容,
|
||||
// 否则「分享点下去什么都没发生」会从那条缝里回来。
|
||||
streamPresent = rawStream != null,
|
||||
clipUris = intent.clipUris(),
|
||||
// 分流就发生在这一处:读不到类型(null)也按文档路走,见类 KDoc。
|
||||
mimeOf = { uri ->
|
||||
runCatching { context.contentResolver.getType(Uri.parse(uri)) }.getOrNull()
|
||||
},
|
||||
)
|
||||
// 没什么值得往下走的就到此为止(判据是 [SharedContent.worthDelivering]:有可落地的内容,
|
||||
// 或者有丢件 / 读不进来要对用户说清的事)。整条分享里什么都没有时静默是对的;而
|
||||
// 「带着内容迹象却一条都没读进来」必须继续往下走——那条告知在下游 `shareNoticeFor` 里等它。
|
||||
if (!extracted.worthDelivering) return
|
||||
|
||||
scope.launch {
|
||||
val dir = shareDir(appContext).apply { mkdirs() }
|
||||
val copied = extracted.images.mapIndexedNotNull { index, uri ->
|
||||
copyToCache(appContext, dir, uri, index)
|
||||
}
|
||||
val documents = extracted.documents.mapIndexedNotNull { index, document ->
|
||||
copyDocumentToCache(appContext, dir, document, index)
|
||||
}
|
||||
holder.put(
|
||||
extracted.copy(
|
||||
images = copied,
|
||||
documents = documents,
|
||||
// 拷不动的那些如实计入「没发送」:宁可界面上说一句,也不要静默少一张。
|
||||
failedImages = extracted.failedImages + (extracted.images.size - copied.size),
|
||||
failedDocuments = extracted.failedDocuments + (extracted.documents.size - documents.size),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取走一条待落地内容(原子出队,只有取到它的那个调用方会去发);队列为空回 `null`。
|
||||
*
|
||||
* 出队是**唯一的认领方式**:消费它的地方会被重建(重组 / 切会话 / Activity 重建),
|
||||
* 所以「已经发过了」这件事不能记在消费方身上(见 [PendingShareHolder])。
|
||||
*/
|
||||
fun take(): SharedContent? = holder.take()
|
||||
|
||||
private fun shareDir(context: Context): File = File(context.cacheDir, DIR_NAME)
|
||||
|
||||
/**
|
||||
* 把一段**分享进来的超长正文**落成 cache 里的 `.txt`,返回可当附件用的待发文档;写不进去回 `null`。
|
||||
*
|
||||
* ## 为什么走本对象
|
||||
* 落成附件的那份字节必须有个**本端的本地地址**才能进既有发送链(芯片要显示它、上传要按 URI
|
||||
* 现读字节、发送失败回滚后用户还可能要点开看):`DraftDocument` 只带引用不带字节。而写进
|
||||
* 「分享缓存」这个目录、跟着同一条清理规则(进程启动整目录删一次)是最省事也最一致的做法——
|
||||
* 与外部分享进来的文件**同一处落地、同一条生命周期**,不另造一个没人回收的目录。
|
||||
* 本函数只负责「落盘 + 报一个可用的引用」,**不判断**要不要转(那是
|
||||
* [DocumentDelivery.planSharedText] 的事)。
|
||||
*
|
||||
* 文件名用 [ShareRules.documentCacheFileName] 生成(**只由本端 token 与序号拼路径**,
|
||||
* 外部字符串一个字符都进不了文件名),但对**服务端**报的名字是
|
||||
* [DocumentDelivery.TEXT_DOCUMENT_FILE_NAME] ——服务端只取扩展名(白名单里的 `.txt`),
|
||||
* 其余部分会被内容寻址丢弃。
|
||||
*
|
||||
* @return 待发文档(含 `file://` 地址、显示名与**真实字节数**);任何一步失败都回 `null`,
|
||||
* 由调用方按「内容一字不丢」的原则退回内联正文(见 `ChatViewModel.sendShared`)。
|
||||
*/
|
||||
suspend fun stageTextAsDocument(context: Context, text: String): DraftDocument? = withContext(Dispatchers.IO) {
|
||||
val staged = runCatching { DocumentDelivery.textAsFile(text) }.getOrNull() ?: return@withContext null
|
||||
val dir = shareDir(context.applicationContext).apply { mkdirs() }
|
||||
val target = File(
|
||||
dir,
|
||||
ShareRules.documentCacheFileName(UUID.randomUUID().toString(), 0, staged.fileName),
|
||||
)
|
||||
try {
|
||||
target.writeBytes(staged.bytes)
|
||||
val size = target.length()
|
||||
if (size > 0L) {
|
||||
DraftDocument(
|
||||
uri = Uri.fromFile(target).toString(),
|
||||
displayName = staged.fileName,
|
||||
sizeBytes = size,
|
||||
)
|
||||
} else {
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// 半截文件不能留下:它会被当成一份真附件去读,报出的错会指向错的地方。
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一份非图片分享文件的字节拷进 cache,返回**本端自己的** `file://` 地址 + 显示名 + 真实字节数;
|
||||
* 失败回 `null`。
|
||||
*
|
||||
* 显示名必须先取(拷完之后外部 URI 就只在这个调用栈里了),取不到就回落 URI 最后一段——
|
||||
* 它是**上传部件的名字**,服务端只从中取扩展名(判定白名单),空名字则由仓库层兜
|
||||
* `document.txt`。
|
||||
*/
|
||||
private fun copyDocumentToCache(
|
||||
context: Context,
|
||||
dir: File,
|
||||
document: SharedDocument,
|
||||
index: Int,
|
||||
): SharedDocument? {
|
||||
val parsed = runCatching { Uri.parse(document.uri) }.getOrNull() ?: return null
|
||||
if (parsed.scheme.isNullOrBlank()) return null
|
||||
|
||||
val name = displayNameOf(context, parsed)
|
||||
val target = File(dir, ShareRules.documentCacheFileName(UUID.randomUUID().toString(), index, name))
|
||||
return try {
|
||||
val source = context.contentResolver.openInputStream(parsed) ?: return null
|
||||
source.use { input -> target.outputStream().use { output -> input.copyTo(output) } }
|
||||
val size = target.length()
|
||||
if (size > 0L) {
|
||||
document.copy(uri = Uri.fromFile(target).toString(), displayName = name, sizeBytes = size)
|
||||
} else {
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// 半截文件不能留下:下一次发送会把它当一份真文件去读,报出的错会指向错的地方。
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** provider 报的显示名优先;拿不到就回落到 URI 最后一段(都不给就留空,不猜扩展名)。 */
|
||||
private fun displayNameOf(context: Context, uri: Uri): String {
|
||||
val fromProvider = runCatching {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (index < 0 || cursor.isNull(index)) null else cursor.getString(index)
|
||||
}
|
||||
}.getOrNull()
|
||||
return fromProvider?.takeIf { it.isNotBlank() }
|
||||
?: DocumentAttachments.fallbackDocumentName(uri.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一个外部 URI 的字节拷进 cache,返回本端自己的 `file://` URI;失败回 `null`。
|
||||
*
|
||||
* 必须是 `file://` 而不是裸路径:`ContentResolver.openInputStream` 认 `file` scheme,
|
||||
* 但裸路径没有 scheme,会走到「找 content provider」那条路并抛 `FileNotFoundException`。
|
||||
*/
|
||||
private fun copyToCache(context: Context, dir: File, uri: String, index: Int): String? {
|
||||
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return null
|
||||
if (parsed.scheme.isNullOrBlank()) return null
|
||||
|
||||
val target = File(
|
||||
dir,
|
||||
ShareRules.cacheFileName(
|
||||
token = UUID.randomUUID().toString(),
|
||||
index = index,
|
||||
mime = runCatching { context.contentResolver.getType(parsed) }.getOrNull(),
|
||||
),
|
||||
)
|
||||
return try {
|
||||
val source = context.contentResolver.openInputStream(parsed) ?: return null
|
||||
source.use { input -> target.outputStream().use { output -> input.copyTo(output) } }
|
||||
if (target.length() > 0L) {
|
||||
Uri.fromFile(target).toString()
|
||||
} else {
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// 半截文件不能留下:下一次发送会把它当一张真图去解码,报出的错会指向错的地方。
|
||||
target.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `EXTRA_STREAM` 的原始值(**故意读原始值**,理由见 [ShareStreamExtras])。
|
||||
*
|
||||
* `Bundle.get` 在 API 33 起被打上废弃:它绕过类型安全。这里要的正是「绕过类型安全」——要判的
|
||||
* 就是「这个值到底是什么形态」。逐类型的 `getXxxExtra` 遇到别的形态只会往 logcat 打一句
|
||||
* `ClassCastException` 再把值扔掉,那正是本次要修的那个静默。
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun Intent.streamExtraValue(): Any? = extras?.get(Intent.EXTRA_STREAM)
|
||||
|
||||
/**
|
||||
* `ClipData` 各条目的 URI(部分 App 只填它);纯文本条目的 URI 是 `null`。
|
||||
*
|
||||
* 这里刻意**保留**没有 URI 的条目(用 `null` 占位)而不是就地滤掉:条目本身就是「这次分享带着
|
||||
* 内容」的证据——只有纯文本条目时本端一个字节都读不到(本端不读 `ClipData` 里的文本),那种
|
||||
* 情形必须能说出口,见 [SharedContent.contentUnreadable]。
|
||||
*/
|
||||
private fun Intent.clipUris(): List<String?> {
|
||||
val clip = clipData ?: return emptyList()
|
||||
return (0 until clip.itemCount).map { clip.getItemAt(it).uri?.toString() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `EXTRA_STREAM` 一条**原始值** ⇒ 里面能读出来的地址,按形态分成两组。
|
||||
*
|
||||
* ## 为什么读原始值而不是挨个 `getXxxExtra` 去问
|
||||
* `EXTRA_STREAM` 在真实世界里有三种形态:规范推荐的 `Uri`(多选时 `ArrayList<Uri>`)、`String[]`、
|
||||
* `ArrayList<String>`。后两种(历史遗留 / 跨平台壳应用常见)此前**完全认不出**:`getParcelableExtra`
|
||||
* 对 `String[]` 只会往 logcat 打一句
|
||||
* `ClassCastException: Cannot cast [Ljava.lang.String; to android.net.Uri` 然后回 `null`,
|
||||
* `getParcelableArrayListExtra` 同理——于是整次分享「什么都没发生」,连一句告知都没有。读原始值再按
|
||||
* 类型分派是一次判完三种形态,顺带也不再往 logcat 里丢那两行噪声(`Bundle.get` 不做类型检查,
|
||||
* 正是这里需要的;`getXxxExtra` 那一套是查完再 cast,查不中就把值扔了)。
|
||||
*
|
||||
* ## 形态决定信任级别
|
||||
* - [uriForms]:`Uri`,以及数组 / 列表里的 `Uri`。对方是把 URI **对象**写进去的,这是规范路径;
|
||||
* 本端维持既有口径不动(不看 scheme)。
|
||||
* - [stringForms]:`String` / `CharSequence` 及其数组 / 列表。这是本端新开的一条读入口,所以开得窄
|
||||
* ——能不能用由 [ShareRules.stringStreamUri] 逐条判(只认 `content://`)。
|
||||
*
|
||||
* 认不出形态的原始值(别的 `Parcelable` / 数字 / 映射……)**不猜**:两边都不收。
|
||||
*/
|
||||
internal data class ShareStreamExtras(
|
||||
val uriForms: List<String>,
|
||||
val stringForms: List<String>,
|
||||
)
|
||||
|
||||
/** 见 [ShareStreamExtras]:把 `EXTRA_STREAM` 的原始值按形态摊平成两组地址(纯逻辑,可单测)。 */
|
||||
internal fun shareStreamExtrasOf(raw: Any?): ShareStreamExtras {
|
||||
val uriForms = mutableListOf<String>()
|
||||
val stringForms = mutableListOf<String>()
|
||||
|
||||
fun collect(value: Any?) {
|
||||
when (value) {
|
||||
null -> Unit
|
||||
is Uri -> uriForms += value.toString()
|
||||
// 含 String。`Spanned` 之类也走这一条:它照样要过 scheme 白名单,不是「因为它是
|
||||
// CharSequence 就信」。
|
||||
is CharSequence -> stringForms += value.toString()
|
||||
is Array<*> -> value.forEach { collect(it) }
|
||||
is Iterable<*> -> value.forEach { collect(it) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
collect(raw)
|
||||
|
||||
return ShareStreamExtras(uriForms = uriForms, stringForms = stringForms)
|
||||
}
|
||||
@@ -1,254 +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 top.yeij.cyrene.data.api.dto.LogoutRequest
|
||||
import top.yeij.cyrene.data.api.dto.RefreshRequest
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话生命周期:登录、静默恢复、续期、登出。
|
||||
*
|
||||
* B2 起密码**不再落盘**:长期凭据是 refresh token,访问令牌过期时用它续期,
|
||||
* 续期由 [TokenRefresher] 单飞协调(并发 401 只会真的刷一次——服务端轮换无宽限期)。
|
||||
*
|
||||
* @param apiFactory 认证接口的构造方式。默认走真实 Retrofit;单测注入假实现即可覆盖
|
||||
* 登录 / 迁移 / 登出路径,既不需要网络也不需要新增依赖。
|
||||
*/
|
||||
class AuthManager(
|
||||
private val store: SessionStorage,
|
||||
private val apiFactory: (String) -> CyreneApi = { ApiClientFactory.create(baseUrl = it) },
|
||||
) {
|
||||
|
||||
private val _state = MutableStateFlow<SessionState>(SessionState.Unknown)
|
||||
val state: StateFlow<SessionState> = _state.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
var currentBaseUrl: String = ""
|
||||
private set
|
||||
|
||||
private val refresher = TokenRefresher(store, ::refreshCall)
|
||||
|
||||
/** 不带令牌的认证客户端(登录 / 刷新 / 登出),按 baseUrl 缓存。可被 OkHttp 线程读到。 */
|
||||
@Volatile
|
||||
private var authApi: CyreneApi? = null
|
||||
|
||||
@Volatile
|
||||
private var authApiBaseUrl: String? = null
|
||||
|
||||
/**
|
||||
* 401 处置:能续期就用新令牌重试一次,明确被拒才判终态。
|
||||
* 暂时性失败(网络 / 5xx)**绝不清会话**——见 B2 §6。
|
||||
*/
|
||||
val unauthorizedPolicy: UnauthorizedPolicy = object : UnauthorizedPolicy {
|
||||
override fun renew(usedToken: String): AuthRecovery = refresher.renew(usedToken)
|
||||
|
||||
override fun onSessionEnded() = onSessionExpired()
|
||||
}
|
||||
|
||||
/** tokenProvider 与 WS 握手用:临近过期时阻塞地先换令牌。 */
|
||||
fun currentToken(): String? = refresher.ensureToken()
|
||||
|
||||
/**
|
||||
* WS 握手 401 的一次性恢复:换一次令牌让上层重连。
|
||||
* 与 401 拦截器共用同一条单飞路径,因此 REST 与 WS 同时失效也只会打一次刷新。
|
||||
*/
|
||||
fun recoverSocketAuth(): AuthRecovery = refresher.renew(store.load().token)
|
||||
|
||||
/**
|
||||
* 启动时恢复会话(D12 + B2 迁移)。顺序即优先级:
|
||||
*
|
||||
* 1. **迁移**:没有 refresh token 却还留着旧版的明文密码 → 用旧凭据静默登录一次换 refresh token,
|
||||
* 成功后立刻把它抹掉;失败(例如离线)保留原值、**不登出**,下次启动再试。
|
||||
* 2. 已经有 refresh token → 任何残留的明文密码立刻抹掉。
|
||||
* 3. 访问令牌过期 → 用 refresh token 续期。密码没了以后,这是唯一的静默恢复途径;
|
||||
* 没有了它,隔一天再打开就要重新输密码(比旧版还差)。
|
||||
* 4. 仍然拿不到有效令牌 → 回登录页(username 仍预填)。
|
||||
*/
|
||||
suspend fun bootstrap() = withContext(Dispatchers.IO) {
|
||||
var session = store.load()
|
||||
currentBaseUrl = session.baseUrl
|
||||
|
||||
val legacyUser = session.username?.takeIf(String::isNotBlank)
|
||||
val legacyPassword = session.password?.takeIf(String::isNotBlank)
|
||||
if (session.refreshToken.isNullOrBlank() && legacyUser != null && legacyPassword != null) {
|
||||
doLogin(session.baseUrl, legacyUser, legacyPassword)
|
||||
session = store.load()
|
||||
} else if (!session.password.isNullOrBlank()) {
|
||||
store.purgePassword()
|
||||
session = store.load()
|
||||
}
|
||||
|
||||
if (!session.hasValidToken() && !session.refreshToken.isNullOrBlank()) {
|
||||
refresher.renew(session.token)
|
||||
session = store.load()
|
||||
}
|
||||
|
||||
_state.value = if (session.hasValidToken()) {
|
||||
SessionState.LoggedIn(session.baseUrl, session.role.orEmpty())
|
||||
} else {
|
||||
SessionState.LoggedOut
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun login(rawBaseUrl: String, username: String, password: String): CyreneError? =
|
||||
withContext(Dispatchers.IO) {
|
||||
doLogin(ApiClientFactory.normalizeBaseUrl(rawBaseUrl), username, password)
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动登出:先尽力吊销服务端的 refresh 授权(否则本地「登出」之后那条授权还能活 30 天),
|
||||
* 再抹掉本地一切。吊销失败、超时、离线都不阻断本地清理,所以离线登出依然干净。
|
||||
*/
|
||||
suspend fun logout() = withContext(Dispatchers.IO) {
|
||||
val refreshToken = store.load().refreshToken?.takeIf(String::isNotBlank)
|
||||
if (refreshToken != null) {
|
||||
// 吊销请求不能拖住登出:给一个短上限,超时就当离线处理。
|
||||
withTimeoutOrNull(REVOKE_TIMEOUT_MS) {
|
||||
try {
|
||||
authApi(sessionBaseUrl()).logout(LogoutRequest(refreshToken))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// 尽力而为:离线 / 服务端报错也必须把本地清干净。
|
||||
}
|
||||
}
|
||||
}
|
||||
store.clearAll()
|
||||
authApi = null
|
||||
authApiBaseUrl = null
|
||||
_state.value = SessionState.LoggedOut
|
||||
}
|
||||
|
||||
/** 会话终结(refresh 不可能或明确被拒):清令牌回登录页;username 保留以便预填。 */
|
||||
fun onSessionExpired() {
|
||||
store.clearSession()
|
||||
_state.value = SessionState.LoggedOut
|
||||
}
|
||||
|
||||
private suspend fun doLogin(url: String, username: String, password: String): CyreneError? {
|
||||
return try {
|
||||
val response = authApi(url).login(LoginRequest(username, password))
|
||||
val data = response.body()?.data
|
||||
when {
|
||||
response.isSuccessful && data != null -> {
|
||||
store.saveLogin(url, data.token, data.expiresAt, data.role, username, data.refreshToken)
|
||||
// B2 红线:不再写明文密码。拿到长期凭据后,把历史遗留的那一份也一并抹掉。
|
||||
if (!data.refreshToken.isNullOrBlank()) store.purgePassword()
|
||||
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() == HTTP_UNAUTHORIZED) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [TokenRefresher] 注入的阻塞刷新调用。
|
||||
*
|
||||
* 它跑在 OkHttp 的调用线程上(拦截器 / tokenProvider),所以这里用 `runBlocking` 就地驱动
|
||||
* Retrofit 的挂起接口——那正是此刻需要的语义:同步等结果。
|
||||
*
|
||||
* 客户端**不带令牌**,镜像 [doLogin]:既不会让过期令牌搭车,也不会把刷新的 401 送回
|
||||
* 本类的过期处理路径(那两个客户端用的是 [NoUnauthorizedPolicy])。
|
||||
*/
|
||||
private fun refreshCall(refreshToken: String): RefreshCall {
|
||||
val baseUrl = sessionBaseUrl()
|
||||
if (baseUrl.isBlank()) return RefreshCall.Failed
|
||||
return try {
|
||||
val response = runBlocking { authApi(baseUrl).refresh(RefreshRequest(refreshToken)) }
|
||||
val data = response.body()?.data
|
||||
when {
|
||||
response.isSuccessful && data != null ->
|
||||
RefreshCall.Ok(data.token, data.expiresAt, data.refreshToken)
|
||||
|
||||
// 200 但解不动:不拿会话去赌,当暂时性失败。
|
||||
response.isSuccessful -> RefreshCall.Failed
|
||||
|
||||
// 该接口唯一的 401 就是 invalid_grant(未知 / 已吊销 / 已过期 / 已被用掉)。
|
||||
response.code() == HTTP_UNAUTHORIZED -> RefreshCall.Rejected
|
||||
|
||||
else -> RefreshCall.Failed
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
RefreshCall.Failed
|
||||
}
|
||||
}
|
||||
|
||||
private fun authApi(baseUrl: String): CyreneApi {
|
||||
val cached = authApi
|
||||
if (cached != null && authApiBaseUrl == baseUrl) return cached
|
||||
val created = apiFactory(baseUrl)
|
||||
authApi = created
|
||||
authApiBaseUrl = baseUrl
|
||||
return created
|
||||
}
|
||||
|
||||
private fun sessionBaseUrl(): String = currentBaseUrl.ifBlank { store.load().baseUrl }
|
||||
|
||||
private fun StoredSession.hasValidToken(): Boolean =
|
||||
!token.isNullOrBlank() && expiresAt * 1000 > System.currentTimeMillis()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HTTP_UNAUTHORIZED = 401
|
||||
|
||||
/** 吊销授权的最长等待:到点就当离线,本地照样清干净。 */
|
||||
const val REVOKE_TIMEOUT_MS = 3_000L
|
||||
}
|
||||
}
|
||||
@@ -1,137 +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?,
|
||||
/** 长期凭据(B2);B2 之前登录的安装没有这一项,因此可能为 null。 */
|
||||
val refreshToken: String?,
|
||||
/**
|
||||
* **遗留字段**:B2 之前的版本把登录密码落在这里。
|
||||
* 只读用于一次性迁移,绝不再写入;见 [SessionStorage.purgePassword]。
|
||||
*/
|
||||
val password: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* 会话存储契约。[SessionStore] 是唯一的生产实现(EncryptedSharedPreferences);
|
||||
* 单元测试用内存假实现顶替,避免把 Android 的加密存储拖进 JVM 测试。
|
||||
*/
|
||||
interface SessionStorage {
|
||||
|
||||
fun load(): StoredSession
|
||||
|
||||
/** 登录成功:整份会话落盘。**不写密码**(B2 红线)。 */
|
||||
fun saveLogin(
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
expiresAt: Long,
|
||||
role: String,
|
||||
username: String,
|
||||
refreshToken: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* 只替换令牌三元组,保留 baseUrl / username / role。
|
||||
* [refreshToken] 为 null 表示沿用原值——`AUTH_REFRESH_ROTATE_DISABLED=true` 时服务端不下发新的,
|
||||
* 此时旧凭据仍然有效,绝不能丢掉。
|
||||
*/
|
||||
fun updateTokens(token: String, expiresAt: Long, refreshToken: String?)
|
||||
|
||||
/**
|
||||
* 确定性抹掉历史遗留的明文密码。
|
||||
* 用 `commit()` 而不是 `apply()`:这是红线清除,必须在下一次读取之前就落到磁盘上。
|
||||
*/
|
||||
fun purgePassword()
|
||||
|
||||
/** 会话失效:清 token 与 refresh token,保留 username 以便登录页预填。 */
|
||||
fun clearSession()
|
||||
|
||||
/** 主动登出:整份清空。 */
|
||||
fun clearAll()
|
||||
}
|
||||
|
||||
/** JWT 与登录凭据的加密存储(红线:不落明文密码、不打日志)。 */
|
||||
class SessionStore(context: Context) : SessionStorage {
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
override 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),
|
||||
refreshToken = prefs.getString(KEY_REFRESH_TOKEN, null),
|
||||
// 只读:迁移路径靠它判断「这是升级前的安装」,不再写入。
|
||||
password = prefs.getString(KEY_PASSWORD, null),
|
||||
)
|
||||
|
||||
override fun saveLogin(
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
expiresAt: Long,
|
||||
role: String,
|
||||
username: String,
|
||||
refreshToken: String?,
|
||||
) {
|
||||
val editor = prefs.edit()
|
||||
.putString(KEY_BASE_URL, baseUrl)
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putLong(KEY_EXPIRES_AT, expiresAt)
|
||||
.putString(KEY_ROLE, role)
|
||||
.putString(KEY_USERNAME, username)
|
||||
if (!refreshToken.isNullOrBlank()) editor.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
override fun updateTokens(token: String, expiresAt: Long, refreshToken: String?) {
|
||||
val editor = prefs.edit()
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putLong(KEY_EXPIRES_AT, expiresAt)
|
||||
// 轮换关闭时服务端不下发新凭据,旧的那一份继续有效,必须保留。
|
||||
if (!refreshToken.isNullOrBlank()) editor.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
override fun purgePassword() {
|
||||
prefs.edit().remove(KEY_PASSWORD).commit()
|
||||
}
|
||||
|
||||
override fun clearSession() {
|
||||
prefs.edit()
|
||||
.remove(KEY_TOKEN)
|
||||
.remove(KEY_EXPIRES_AT)
|
||||
.remove(KEY_ROLE)
|
||||
.remove(KEY_REFRESH_TOKEN)
|
||||
.apply()
|
||||
}
|
||||
|
||||
override 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_REFRESH_TOKEN = "refresh_token"
|
||||
const val KEY_PASSWORD = "password"
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
/** 一次令牌续期的结果。 */
|
||||
sealed interface AuthRecovery {
|
||||
|
||||
/** 已拿到可用的访问令牌:可能是刚签发的,也可能是别的线程刚换好的。 */
|
||||
data class Renewed(val token: String) : AuthRecovery
|
||||
|
||||
/**
|
||||
* 无法续期:本地没有 refresh token,或被服务端以 `invalid_grant` 拒绝
|
||||
* (未知 / 已吊销 / 已过期 / 已被用掉,服务端把这四种情况折叠成一个码)。
|
||||
* 这是终态——必须重新登录。
|
||||
*/
|
||||
data object Unrecoverable : AuthRecovery
|
||||
|
||||
/** 暂时性失败:网络不通、5xx、响应解不动。会话可能仍然有效,稍后再试。 */
|
||||
data object Transient : AuthRecovery
|
||||
}
|
||||
|
||||
/**
|
||||
* [TokenRefresher] 注入的刷新调用结果。
|
||||
* 把 HTTP 细节挡在协调逻辑之外,单飞与代际守卫因此可以纯 JVM 单测。
|
||||
*/
|
||||
sealed interface RefreshCall {
|
||||
|
||||
/** 200:新的访问令牌;[refreshToken] 为 null 表示服务端关闭了轮换、旧凭据继续有效。 */
|
||||
data class Ok(val token: String, val expiresAt: Long, val refreshToken: String?) : RefreshCall
|
||||
|
||||
/** `401 invalid_grant`。 */
|
||||
data object Rejected : RefreshCall
|
||||
|
||||
/** 网络 / 5xx / 解析失败。 */
|
||||
data object Failed : RefreshCall
|
||||
}
|
||||
|
||||
/**
|
||||
* 单飞(single-flight)刷新协调器。
|
||||
*
|
||||
* ## 为什么必须单飞
|
||||
* 服务端 `RefreshStore.Rotate` 是**原子且无宽限期**的:同一个 refresh token 被第二次使用会立刻
|
||||
* 拿到 `invalid_grant`(防重放)。而本端并发 401 是常态——管理概览页两个请求并发、聊天页 REST 与
|
||||
* WS 同时起步。所以同一时刻只允许一个线程真正发刷新请求,其余线程在锁上等待后复用结果。
|
||||
*
|
||||
* ## 代际守卫
|
||||
* 拿到锁之后**重新读一次存储**:如果已存的访问令牌既不是自己手里那份([renew] 的 `staleToken`)、
|
||||
* 又还没到过期点,说明等在锁上的这段时间里别的线程已经换好了令牌——直接复用,绝不刷第二次。
|
||||
*
|
||||
* ## 线程模型
|
||||
* 全部逻辑跑在**调用方线程**上(OkHttp 的调用线程 / WS 重连协程),用 JVM 监视器锁串行化。
|
||||
* 因此这里没有、也不能用协程 `Mutex`——调用点根本没有 suspend 上下文。
|
||||
* 真实的刷新请求由调用方用**不带令牌**的客户端发出(见 `AuthManager.refreshCall`),
|
||||
* 不会递归回本类。
|
||||
*
|
||||
* @param store 会话读写;实现必须让写入立刻对下一次 [SessionStorage.load] 可见。
|
||||
* @param call 真正发刷新请求的阻塞调用。
|
||||
* @param now 当前毫秒时间戳,可注入以便单测。
|
||||
*/
|
||||
class TokenRefresher(
|
||||
private val store: SessionStorage,
|
||||
private val call: (refreshToken: String) -> RefreshCall,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
/**
|
||||
* 已经确定无效的那个 refresh token。`invalid_grant` 对该值而言是**永久**结论,
|
||||
* 记下来可以让同一次失效里的其它并发 401 直接得到终态,不必各打一次注定失败的请求。
|
||||
* 值本身随机且每次轮换都变,因此不需要额外失效逻辑。
|
||||
*/
|
||||
private var rejectedRefreshToken: String? = null
|
||||
|
||||
/** 距过期不足 [SKEW_MS] 就算「该提前换令牌了」。 */
|
||||
fun isNearExpiry(session: StoredSession): Boolean =
|
||||
session.expiresAt * 1000 - now() <= SKEW_MS
|
||||
|
||||
/**
|
||||
* 阻塞地给出一个当下可用的访问令牌;临近过期时先走一次单飞刷新。
|
||||
* 供 OkHttp 的 tokenProvider 与 WS 握手使用(每次重连都会重新读)。
|
||||
*
|
||||
* 刷新失败时**仍然返回手里那份令牌**——它可能还没真的过期,401 交给兜底路径处理;
|
||||
* 在这里返回 null 会让整条请求链断掉,比让服务端来裁决更糟。
|
||||
*/
|
||||
fun ensureToken(): String? {
|
||||
val session = store.load()
|
||||
val token = session.token?.takeIf(String::isNotBlank) ?: return null
|
||||
if (!isNearExpiry(session)) return token
|
||||
return when (val recovery = renew(token)) {
|
||||
is AuthRecovery.Renewed -> recovery.token
|
||||
AuthRecovery.Unrecoverable, AuthRecovery.Transient -> token
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单飞刷新:同一时刻只有一个线程真正发请求,其余线程在锁上等待并复用结果。
|
||||
*
|
||||
* @param staleToken 触发续期的那次请求实际携带的令牌;null 表示只知道「手上没有可用的令牌」。
|
||||
*/
|
||||
fun renew(staleToken: String?): AuthRecovery = synchronized(lock) {
|
||||
val session = store.load()
|
||||
|
||||
// 代际守卫:等在锁上的这段时间里,别的线程已经换好了令牌 → 复用,绝不用同一个 refresh token 打第二次。
|
||||
val current = session.token
|
||||
if (!current.isNullOrBlank() && current != staleToken && !isNearExpiry(session)) {
|
||||
return@synchronized AuthRecovery.Renewed(current)
|
||||
}
|
||||
|
||||
val refreshToken = session.refreshToken?.takeIf(String::isNotBlank)
|
||||
?: return@synchronized AuthRecovery.Unrecoverable
|
||||
if (refreshToken == rejectedRefreshToken) return@synchronized AuthRecovery.Unrecoverable
|
||||
|
||||
when (val result = call(refreshToken)) {
|
||||
is RefreshCall.Ok -> {
|
||||
rejectedRefreshToken = null
|
||||
store.updateTokens(result.token, result.expiresAt, result.refreshToken)
|
||||
AuthRecovery.Renewed(result.token)
|
||||
}
|
||||
|
||||
RefreshCall.Rejected -> {
|
||||
rejectedRefreshToken = refreshToken
|
||||
AuthRecovery.Unrecoverable
|
||||
}
|
||||
|
||||
RefreshCall.Failed -> AuthRecovery.Transient
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** 提前量:24h 的访问令牌留 60s 余量,避免踩着过期边界发请求。 */
|
||||
const val SKEW_MS = 60_000L
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
/**
|
||||
* 「带了令牌仍然 401」的处置策略。
|
||||
*
|
||||
* 由 `ApiClientFactory` 的拦截器在 **OkHttp 的调用线程上同步调用**,所以实现必须是
|
||||
* 阻塞且线程安全的——那里没有 suspend 上下文,协程 `Mutex` 帮不上忙。
|
||||
*/
|
||||
interface UnauthorizedPolicy {
|
||||
|
||||
/**
|
||||
* 尝试把被服务端拒绝的令牌换成可用的新令牌。
|
||||
*
|
||||
* @param usedToken 触发这次 401 的请求实际携带的访问令牌。
|
||||
* @return [AuthRecovery.Renewed] 时调用方会用新令牌重试一次;另外两种都不重试。
|
||||
*/
|
||||
fun renew(usedToken: String): AuthRecovery
|
||||
|
||||
/** 终态:续期不可能(没有 refresh token)或明确被拒(`invalid_grant`)——必须重新登录。 */
|
||||
fun onSessionEnded()
|
||||
}
|
||||
|
||||
/** 不带令牌的客户端(登录 / 刷新 / 登出)用:永不续期,也永不判终态。 */
|
||||
object NoUnauthorizedPolicy : UnauthorizedPolicy {
|
||||
|
||||
override fun renew(usedToken: String): AuthRecovery = AuthRecovery.Unrecoverable
|
||||
|
||||
override fun onSessionEnded() = Unit
|
||||
}
|
||||
@@ -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,134 +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 }
|
||||
|
||||
/**
|
||||
* 她忙时发消息的行为(协同项 A2,契约 v0.56 §3.1 的 `interrupt.scope`;忙时并行规范 §2.2 的
|
||||
* `chat.parallel`)。
|
||||
*
|
||||
* 类型名与落盘键(`interrupt_mode`)都是**历史名字**:第三档「并行」接上来之后它管的不再只是打断,
|
||||
* 但改类型名 / 键名 / 既有档位的 `name` 都会让已存偏好读不出来并**静默回落**默认档——所以三个名字
|
||||
* 一个都不动(见 [toInterruptMode] 与 `InterruptModeTest` 的「落盘名稳定」那几条)。
|
||||
*
|
||||
* 一档同时表达**两个上行声明**:[wireScope] 与 [wireParallel]。三档各占一种组合——打断占前者、
|
||||
* 并行占后者、追加队列两个都不占,**没有任何一档同时给两个**。
|
||||
*
|
||||
* @property wireScope 上行 `interrupt` 帧的 `scope` 取值;`null` = **不发打断帧**。注意它不是
|
||||
* 「发一条省略 scope 的帧」——那在服务端是 `"all"`(连待处理队列一起清),是完全不同的语义。
|
||||
* @property wireParallel 这条 `chat` 要不要声明 `parallel:true`;`false` = **一个字节都不带**。
|
||||
*/
|
||||
enum class InterruptMode(val wireScope: String?, val wireParallel: Boolean) {
|
||||
|
||||
/**
|
||||
* 追加队列(默认):忙时只把消息排进队列,不打断也不并行。
|
||||
*
|
||||
* 该模式**两个声明都不发**(`interruptScopeForSend` 恒为「不发」、`parallelForSend` 恒为
|
||||
* `false`):默认档的线上请求与本偏好存在之前**逐字节相同**。
|
||||
*/
|
||||
AppendQueue(wireScope = null, wireParallel = false),
|
||||
|
||||
/**
|
||||
* 打断:只取消在跑回合、**保留**待处理队列(`scope:"current"`)。
|
||||
* 队列里的消息按编排器正常续跑规则继续执行,一条都不丢。
|
||||
*/
|
||||
Interrupt(wireScope = "current", wireParallel = false),
|
||||
|
||||
/**
|
||||
* 并行:忙时**不打断**在跑的那一路,本条**另起一路**同时处理(`chat.parallel=true`);
|
||||
* 服务端并发额度满时**不报错**,这一条落回排队。
|
||||
*
|
||||
* [wireScope] 为 `null` 不是因为「这一档没什么要发的东西」(它当然要发东西,发的是
|
||||
* `chat.parallel`),而是因为**并行与打断互斥**:这一档**绝不允许**产生 `interrupt` 帧——
|
||||
* 一产生就等于先把要并行的那一路砍掉。
|
||||
*
|
||||
* 代价:两路各算一遍 ⇒ 峰值算力 / token 近翻倍(主人 2026-10-05 裁决 4:不分摊、优先性能),
|
||||
* 所以设置页这一栏的 summary 把这句代价明说出来。
|
||||
*/
|
||||
Parallel(wireScope = null, wireParallel = true),
|
||||
}
|
||||
|
||||
/**
|
||||
* 落盘字符串 → 档位(`AppSettings` 的 `interrupt_mode` 键读取口;抽成顶层函数是为了能单测——
|
||||
* `AppSettings` 本体要 Android + DataStore,单测里起不来)。
|
||||
*
|
||||
* 缺省 / 旧版本遗留的未知值一律解成默认档(追加队列),绝不悄悄换成更激进的档位。
|
||||
*/
|
||||
fun String?.toInterruptMode(): InterruptMode = when (this) {
|
||||
InterruptMode.Interrupt.name -> InterruptMode.Interrupt
|
||||
InterruptMode.Parallel.name -> InterruptMode.Parallel
|
||||
else -> InterruptMode.AppendQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* App 级偏好(非机密)。
|
||||
*
|
||||
* @param themeMode 主题深浅
|
||||
* @param showNarration 是否显示 `<action>` / `<narration>` 旁白
|
||||
* @param thinkingExpanded 思维链卡片默认是否展开
|
||||
* @param interruptMode 她正忙时按发送的行为(默认追加队列)
|
||||
*/
|
||||
data class AppPrefs(
|
||||
val themeMode: ThemeMode = ThemeMode.System,
|
||||
val showNarration: Boolean = true,
|
||||
val thinkingExpanded: Boolean = true,
|
||||
val interruptMode: InterruptMode = InterruptMode.AppendQueue,
|
||||
)
|
||||
|
||||
/** 全局注入 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,
|
||||
interruptMode = prefs[KEY_INTERRUPT_MODE].toInterruptMode(),
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
suspend fun setInterruptMode(mode: InterruptMode) {
|
||||
store.edit { it[KEY_INTERRUPT_MODE] = mode.name }
|
||||
}
|
||||
|
||||
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")
|
||||
val KEY_INTERRUPT_MODE = stringPreferencesKey("interrupt_mode")
|
||||
}
|
||||
}
|
||||
@@ -1,63 +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.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.dto.EnumTableDto
|
||||
|
||||
private val Context.enumDataStore: DataStore<Preferences> by preferencesDataStore("cyrene_enums")
|
||||
|
||||
/**
|
||||
* 枚举权威表的本地回退快照(§1.1)。
|
||||
*
|
||||
* @param table 上次成功拉取的权威表;拉取失败时先用它渲染(绝不因此清空角标)
|
||||
* @param fingerprint `baseUrl + username`:换服务器/账号后快照作废
|
||||
*/
|
||||
data class EnumPrefs(
|
||||
val table: EnumTableDto? = null,
|
||||
val fingerprint: String = "",
|
||||
) {
|
||||
|
||||
/** 指纹匹配才认这份快照——绝不把别的服务器/账号的标签拿来用。 */
|
||||
fun tableFor(fingerprint: String): EnumTableDto? = table?.takeIf { this.fingerprint == fingerprint }
|
||||
}
|
||||
|
||||
class EnumPreferences(context: Context) {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
private val store: DataStore<Preferences> = context.applicationContext.enumDataStore
|
||||
|
||||
val data: Flow<EnumPrefs> = store.data.map { prefs ->
|
||||
EnumPrefs(
|
||||
table = prefs[KEY_TABLE].decodeTable(),
|
||||
fingerprint = prefs[KEY_FINGERPRINT].orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun save(table: EnumTableDto, fingerprint: String) {
|
||||
store.edit { prefs ->
|
||||
prefs[KEY_TABLE] = json.encodeToString(EnumTableDto.serializer(), table)
|
||||
prefs[KEY_FINGERPRINT] = fingerprint
|
||||
}
|
||||
}
|
||||
|
||||
/** 登出 / 切换服务器时清空。 */
|
||||
suspend fun clear() {
|
||||
store.edit { it.clear() }
|
||||
}
|
||||
|
||||
private fun String?.decodeTable(): EnumTableDto? = this
|
||||
?.let { runCatching { json.decodeFromString(EnumTableDto.serializer(), it) }.getOrNull() }
|
||||
|
||||
private companion object {
|
||||
val KEY_TABLE = stringPreferencesKey("enum_table")
|
||||
val KEY_FINGERPRINT = stringPreferencesKey("enum_fingerprint")
|
||||
}
|
||||
}
|
||||
@@ -1,109 +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 top.yeij.cyrene.data.api.ApiClientFactory
|
||||
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 [accountFingerprint]:换服务器/账号后快照作废
|
||||
* @param activeSessionId 上次停留的会话
|
||||
*/
|
||||
data class SessionPrefs(
|
||||
val snapshot: List<SessionSummaryDto> = emptyList(),
|
||||
val snapshotAt: Long = 0L,
|
||||
val fingerprint: String = "",
|
||||
val activeSessionId: String = SessionSummaryDto.MAIN_SESSION_ID,
|
||||
) {
|
||||
|
||||
/** 指纹匹配才认这份快照——绝不把别的服务器/账号的会话列表拿来用。 */
|
||||
fun snapshotFor(fingerprint: String): List<SessionSummaryDto> =
|
||||
snapshot.takeIf { this.fingerprint == fingerprint }.orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号指纹(纯函数):`baseUrl + username`。回答的是「**这份本地状态属于哪个环境**」。
|
||||
*
|
||||
* 抽成一个函数的理由是**全仓只能有一份口径**:这类判据一旦各拼一套字符串,差一个分隔符
|
||||
* 就会在某个闸门上漏过去——而漏过去的后果是把别的服务器/账号的内容摆到屏幕上。
|
||||
* 现在的两个消费者是 [SessionPrefs.snapshotFor](会话快照作不作废)与聊天页的 VM key
|
||||
* (`chatViewModelKey`:那个 VM 是不是本环境的)。改口径就是改**两者**,不要就地另拼。
|
||||
*
|
||||
* 取用户名而不是角色:角色是同一个人在服务端被授的权限,账号身份由**服务器地址 + 用户名**
|
||||
* 共同确定;要让指纹对不上,只能是换了服务器或换了账号,那两件事都该让本地状态作废。
|
||||
*/
|
||||
internal fun accountFingerprint(baseUrl: String, username: String?): String =
|
||||
"$baseUrl|${username.orEmpty()}"
|
||||
|
||||
class SessionPreferences(context: Context) {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
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(),
|
||||
activeSessionId = prefs[KEY_ACTIVE] ?: SessionSummaryDto.MAIN_SESSION_ID,
|
||||
)
|
||||
}
|
||||
|
||||
/** 拉取成功:整体替换快照(不做 merge,避免已删除的会话变成幽灵)。 */
|
||||
suspend fun saveSnapshot(sessions: List<SessionSummaryDto>, fingerprint: String) {
|
||||
store.edit { prefs ->
|
||||
prefs[KEY_SNAPSHOT] = json.encodeToString(SessionsSerializer, sessions)
|
||||
prefs[KEY_SNAPSHOT_AT] = System.currentTimeMillis()
|
||||
prefs[KEY_FINGERPRINT] = fingerprint
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setActive(sessionId: String) {
|
||||
store.edit { prefs -> prefs[KEY_ACTIVE] = sessionId }
|
||||
}
|
||||
|
||||
/** 删除会话:必要时把 active 退回主会话,并同步从快照里剔除。 */
|
||||
suspend fun removeSession(sessionId: String) {
|
||||
store.edit { prefs ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/** 登出时清空:快照、指纹、active 一起走,下次登录不会先闪一份旧列表。 */
|
||||
suspend fun clear() {
|
||||
store.edit { it.clear() }
|
||||
}
|
||||
|
||||
private fun String?.decodeSessions(): List<SessionSummaryDto> = this
|
||||
?.let { runCatching { json.decodeFromString(SessionsSerializer, 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_ACTIVE = stringPreferencesKey("active_session_id")
|
||||
|
||||
val SessionsSerializer = ListSerializer(SessionSummaryDto.serializer())
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package top.yeij.cyrene.core.session
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* 本端**已经知道服务端没有这个会话**的那几个会话 id(进程级、只在内存里、只增不减)。
|
||||
*
|
||||
* ## 它回答的是哪一个问题
|
||||
* 「这个 id 还值不值得我发一次请求」。`ChatViewModel` 每个访问过的会话各留一个常驻 VM
|
||||
* (切走 / 删会话都不回收),而它们都订阅同一条共享连接 ⇒ **每次重连各自会去补拉一轮**。会话
|
||||
* 在本端被删掉之后,那个 VM 并不知道(它没有别的渠道知道),于是**每一个**重连都发一次注定
|
||||
* `404 session_not_found` 的补拉,404 被静默吞掉(S7 登记的那一条)。
|
||||
*
|
||||
* 补拉**事前**就能判掉它,靠的就是这里:删除是**本端自己做的**,那一刻就已经知道结果了,
|
||||
* 没有任何理由等下一条请求回来才「发现」。于是这一位把「已知不存在」从「一次注定失败的往返」
|
||||
* 提前成「一次本地查询」——**不是**「重连时少拉一点」的省流量优化,而是不让一个注定失败的请求
|
||||
* 上路(它会占一条连接、写一行日志、并把 `sessionGone` 变成一个只能靠 404 才置得起来的位)。
|
||||
*
|
||||
* ## 为什么是进程级、为什么**不**持久化
|
||||
* - 进程级(挂在 `AppContainer` 上,与那条共享连接同一层):写它的是 `SessionsViewModel`
|
||||
* (本端唯一的删除入口),读它的是**别的** VM —— 两个 VM 之间没有别的共享面。
|
||||
* - **不持久化**:它只是「这一趟进程里已经知道的事」。重启之后本端的会话列表会重新从服务端拉
|
||||
* ([top.yeij.cyrene.core.prefs.SessionPreferences] 那份快照本来就会整体替换),「这个会话还在不在」
|
||||
* 以那一次服务端答复为准;把 id 落盘只会让一份陈旧记忆挡掉重启后的第一次拉取。
|
||||
* - **换账号要清**([clear]):id 是服务端铸的,理论上不会跨账号撞车,但这一位说的是「本端
|
||||
* 已经知道」,而换账号之后本端**什么都不知道** —— 留着上个环境的结论,是那种「差一个分隔符
|
||||
* 就漏过去」的同一类错误(先例:`accountFingerprint` 的注释)。
|
||||
*/
|
||||
class GoneSessions {
|
||||
|
||||
private val ids = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** 记下「服务端已经没有这个会话了」。空串当无效值丢掉(那不是任何会话的 id)。 */
|
||||
fun mark(sessionId: String) {
|
||||
if (sessionId.isBlank()) return
|
||||
ids.update { it + sessionId }
|
||||
}
|
||||
|
||||
/** 本端是否**已经知道**它没了。只回答「知道不知道」,不回答「在不在」——不知道的一律 `false`。 */
|
||||
fun contains(sessionId: String): Boolean = sessionId in ids.value
|
||||
|
||||
/** 换账号 / 登出:本端重新回到「什么都不知道」。 */
|
||||
fun clear() {
|
||||
ids.value = emptySet()
|
||||
}
|
||||
}
|
||||
@@ -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,54 +0,0 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
|
||||
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>>>
|
||||
|
||||
/**
|
||||
* 后端日志 tail(api-client §2「可观测性契约」,v0.35):`{"data":[{time,level,source,msg}]}`,
|
||||
* **最新在前**;`limit` 缺省 200、上限 1000(≤0 或非法回落缺省)。
|
||||
*
|
||||
* 不暴露契约里的 `?level=`:它是**最低等级**过滤,只在服务端裁剪一次结果——而界面上那排
|
||||
* 等级档要能在同一份数据上来回切换(契约 §3.12 也明确推送帧的过滤只能本地做),
|
||||
* 所以这里一律取全量、由客户端本地过滤(`ui/admin/AdminOverviewMerge.kt`)。
|
||||
*
|
||||
* RBAC 是 v0.40 的 `viewerAuth`:**viewer** 也读得到(owner / admin 本来就可以),
|
||||
* 与 `trace` / `metrics` 同档。
|
||||
*/
|
||||
@GET("api/v1/admin/observability/logs")
|
||||
suspend fun logs(@Query("limit") limit: Int? = null): Response<ApiEnvelope<List<AdminLogEntryDto>>>
|
||||
|
||||
/** 人格列表(含 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,163 +0,0 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.BuildConfig
|
||||
import top.yeij.cyrene.core.auth.AuthRecovery
|
||||
import top.yeij.cyrene.core.auth.NoUnauthorizedPolicy
|
||||
import top.yeij.cyrene.core.auth.UnauthorizedPolicy
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* **连接超时**(毫秒):解析 DNS + 建立 TCP 连接(以及 TLS 握手)这一段的预算,**全端共用这一个值**。
|
||||
*
|
||||
* ## 为什么从 10 秒缩到 2.5 秒(W45)
|
||||
* 它就是「**完全无网时快速进队列**」这条性质的唯一依据。W41 把这条性质寄托在一个 2.5 秒的探针上
|
||||
* (W41 那个探针 client 的 `callTimeout`),而那个探针**从不复用连接池**、每次都要现做一次冷握手
|
||||
* ——于是真机上出现了「探了,但请求一个字节都没写出去」,而它与「压根没探」在外部完全同形,推断
|
||||
* 不出是哪一档。W45 删掉探测、改成**直接试 REST** 之后,这条性质改由**连接超时**承担:连不上就在 2.5 秒
|
||||
* 内失败,消息进队列(内容一个字节都不丢,重发仍是同一个 `clientMsgId`)。
|
||||
*
|
||||
* 取值 2.5 秒:正常网络下一次 TCP 握手是毫秒到几十毫秒量级(局域网、ZeroTier / Tailscale 也远达
|
||||
* 不到秒级),2.5 秒是「人开始觉得卡」之前的量级,也与 W41 那 2.5 秒的探针预算**对齐**——换成直接
|
||||
* 试 REST 之后,「无网时用户最多等多久」因此没有变长。**绝不许**再回到 10 秒:那会让每一条离线
|
||||
* 消息的用户都白等十倍的时间,换来的只是「极端慢网下多一次成功的机会」。
|
||||
*
|
||||
* ## 它不管什么
|
||||
* 「连上了但服务端一直不回话」(半死的代理、接住连接却不回应)不是它的事——那是读 / 写超时
|
||||
* ([READ_TIMEOUT_SECONDS])与回落那一次投递自己的整通上限
|
||||
* ([top.yeij.cyrene.ui.chat.REST_FALLBACK_TIMEOUT_MILLIS])的事。两个闸各管一段,合起来才是
|
||||
* 「没网时用户最多等多久」。
|
||||
*/
|
||||
const val CONNECT_TIMEOUT_MILLIS = 2_500L
|
||||
|
||||
/** 读 / 写超时(秒):真请求(长文本、内联图片、文档字节)的耐心,与 [CONNECT_TIMEOUT_MILLIS] 分开。 */
|
||||
private const val READ_TIMEOUT_SECONDS = 60L
|
||||
|
||||
object ApiClientFactory {
|
||||
|
||||
val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
/** 登录 / 刷新 / 登出接口(不注入 token、不参与续期)。 */
|
||||
fun create(
|
||||
baseUrl: String,
|
||||
tokenProvider: () -> String? = { null },
|
||||
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
|
||||
): CyreneApi = create(baseUrl, CyreneApi::class.java, tokenProvider, unauthorizedPolicy)
|
||||
|
||||
/**
|
||||
* 通用入口:按需构建任意 Retrofit service。
|
||||
*
|
||||
* 超时**只有两个值**,都由文件顶上的常量解释:连接 [CONNECT_TIMEOUT_MILLIS](短,保证「完全
|
||||
* 无网时快速失败」)、读 / 写 [READ_TIMEOUT_SECONDS](长,真请求的耐心)。**刻意不设
|
||||
* `callTimeout`**:那会把文档字节下载与图片上传一并框住,而它们本来就是长请求;哪一次投递需要
|
||||
* 整通上限,就写在哪一次投递的调用点上(例:回落那一次的
|
||||
* [top.yeij.cyrene.ui.chat.REST_FALLBACK_TIMEOUT_MILLIS])。
|
||||
*
|
||||
* DEBUG 下挂 BASIC 日志(`logcat -s OkHttp`):`-->` 打在 `chain.proceed()` **之前**,所以
|
||||
* 「这条请求有没有发出去」与「死在连接还是死在回执」当场可分——回落那条路下一轮要靠它取证。
|
||||
* 不动 `Authorization`(BASIC 根本不打印任何头),级别**绝不许**再往上调:`/auth/refresh` 与
|
||||
* `/auth/logout` 把 refresh token 放在请求体里。
|
||||
*/
|
||||
fun <T : Any> create(
|
||||
baseUrl: String,
|
||||
service: Class<T>,
|
||||
tokenProvider: () -> String? = { null },
|
||||
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
|
||||
): T {
|
||||
val client = OkHttpClient.Builder()
|
||||
// 连接这一段刻意短(理由见 [CONNECT_TIMEOUT_MILLIS]):它是「无网时消息快速进队列」的
|
||||
// 唯一依据,所以它**不含**任何整通上限的意味。
|
||||
.connectTimeout(CONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.writeTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.addInterceptor(AuthInterceptor(tokenProvider, unauthorizedPolicy))
|
||||
.apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(
|
||||
HttpLoggingInterceptor().apply {
|
||||
// BASIC 是上限:`/auth/refresh` 与 `/auth/logout` 把 refresh token 放在**请求体**里,
|
||||
// 再高一级就会把凭据打进 logcat。
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入 `X-API-Version` 与 `Authorization`,并兜住「**带了令牌仍然** 401」:
|
||||
* 先向策略要一次新令牌,拿到就用它**重试一次**;明确被拒才判终态。
|
||||
*
|
||||
* 为什么是应用层拦截器而不是 OkHttp `Authenticator`:
|
||||
* `Authenticator` 对**任何** 401 都会被调用(包括不带令牌的登录请求),而且它由
|
||||
* RetryAndFollowUp 在内部循环里驱动,重试次数(上限 20 次)不受本类控制——一个持续 401 的
|
||||
* 令牌会打出一串刷新风暴。应用层拦截器只在带令牌时介入,且重试严格一次
|
||||
* (OkHttp 允许应用层拦截器多次 `proceed()`,`calls != 1` 的检查只对网络拦截器生效)。
|
||||
*/
|
||||
private class AuthInterceptor(
|
||||
private val tokenProvider: () -> String?,
|
||||
private val unauthorizedPolicy: UnauthorizedPolicy,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val token = tokenProvider()
|
||||
var response = chain.proceed(chain.request().withAuth(token))
|
||||
// 只有「带了 token 仍 401」才算会话被拒;登录/刷新本身不带 token,不受影响。
|
||||
if (response.code != HTTP_UNAUTHORIZED || token == null) return response
|
||||
|
||||
when (val recovery = unauthorizedPolicy.renew(token)) {
|
||||
is AuthRecovery.Renewed -> {
|
||||
response.close()
|
||||
response = chain.proceed(chain.request().withAuth(recovery.token))
|
||||
// 换上刚签发的令牌仍然 401:服务端根本不接受这个账号,再刷也没有意义,判终态。
|
||||
if (response.code == HTTP_UNAUTHORIZED) unauthorizedPolicy.onSessionEnded()
|
||||
}
|
||||
|
||||
AuthRecovery.Unrecoverable -> unauthorizedPolicy.onSessionEnded()
|
||||
|
||||
// 网络 / 5xx:会话可能还好着,原样把 401 交回去,绝不清会话。
|
||||
AuthRecovery.Transient -> Unit
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private fun Request.withAuth(token: String?): Request = newBuilder()
|
||||
.header("X-API-Version", "1")
|
||||
.apply { if (token != null) header("Authorization", "Bearer $token") }
|
||||
.build()
|
||||
}
|
||||
|
||||
private const val HTTP_UNAUTHORIZED = 401
|
||||
}
|
||||
Loaded 100 of 346 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user