Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc7630c43a | |||
| 08d78c976a | |||
| 6394099e2e | |||
| e65a35a239 | |||
| 7fcf562648 | |||
| 64c7018729 | |||
| 86d196b857 | |||
| 91231834dc | |||
| 5dad0cd39b | |||
| ce73f68bc8 | |||
| 3c90adae6a | |||
| 014437760d | |||
| eb94142404 | |||
| 1c96588d79 | |||
| 9295fe8021 | |||
| 5247eef0fc | |||
| 2725fdd1d5 | |||
| 367ef7f2d6 | |||
| a57692353c |
@@ -33,3 +33,4 @@ google-services.json
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
keystore.properties
|
||||
|
||||
@@ -1,3 +1,182 @@
|
||||
# Cyrene-For-Android
|
||||
# Cyrene for Android
|
||||
|
||||
昔涟在安卓设备上的载体。
|
||||
昔涟在安卓设备上的载体 —— 随时随地与昔涟对话、操控 IoT 设备、接收实时通知。
|
||||
|
||||
## 概述
|
||||
|
||||
Cyrene for Android 是 [Cyrene(昔涟)](https://github.com/Cyrene/Cyrene) 项目的官方 Android 客户端。Cyrene 是一个开源的基于 LLM 平台的智能体,提供多人格对话、IoT 设备操控、记忆管理、自动化规则、知识库、语音交互等功能。
|
||||
|
||||
Android 客户端的目标是成为用户的**默认语音助手**,完全替代系统自带的助手(Google Assistant / Bixby 等),同时提供:
|
||||
|
||||
- **系统级语音助手** — 注册为 `VoiceInteractionService`,可通过长按 Home / 侧滑呼出
|
||||
- **语音唤醒** — 息屏热词唤醒、免提交互(类似 "Hey Google",使用 "昔涟" 等唤醒词)
|
||||
- **随时对话** — 通过文字或语音与昔涟交流
|
||||
- **IoT 控制** — 远程操控家中的智能设备
|
||||
- **实时通知** — 接收昔涟的主动消息、提醒、IoT 状态变更
|
||||
- **后台连接** — 即使 APP 在后台,仍保持通知推送与语音服务就绪
|
||||
|
||||
## 开发状态
|
||||
|
||||
> **当前阶段**:项目初始化,尚未开始正式开发。
|
||||
|
||||
根据主项目的[开发路线图](../docs/dev-plan/00-development-roadmap.md),Android 客户端计划在 **Phase 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
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||
}
|
||||
|
||||
android {
|
||||
|
||||
signingConfigs {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace = "top.yeij.cyrene"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "top.yeij.cyrene"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Compose BOM
|
||||
val composeBom = platform(libs.compose.bom)
|
||||
implementation(composeBom)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.graphics)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.material.icons)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
// Activity & Navigation
|
||||
implementation(libs.compose.activity)
|
||||
implementation(libs.compose.navigation)
|
||||
|
||||
// Lifecycle
|
||||
implementation(libs.lifecycle.runtime)
|
||||
implementation(libs.lifecycle.viewmodel)
|
||||
|
||||
// Room
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
ksp(libs.room.compiler)
|
||||
|
||||
// Network
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.gson)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.logging)
|
||||
|
||||
// Koin DI
|
||||
implementation(libs.koin.android)
|
||||
implementation(libs.koin.compose)
|
||||
|
||||
// DataStore
|
||||
implementation(libs.datastore)
|
||||
|
||||
// Coroutines
|
||||
implementation(libs.coroutines)
|
||||
|
||||
// Core
|
||||
implementation(libs.core.ktx)
|
||||
|
||||
// Biometric
|
||||
implementation(libs.biometric)
|
||||
|
||||
// Coil — image loading
|
||||
implementation(libs.coil.compose)
|
||||
}
|
||||
Vendored
+104
@@ -0,0 +1,104 @@
|
||||
# Cyrene ProGuard Rules
|
||||
|
||||
# --- Keep Android components declared in manifest ---
|
||||
# These are instantiated by the Android system via reflection
|
||||
-keep class top.yeij.cyrene.CyreneApplication { *; }
|
||||
-keep class top.yeij.cyrene.MainActivity { *; }
|
||||
-keep class top.yeij.cyrene.service.** { *; }
|
||||
|
||||
# --- Kotlin ---
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes Signature
|
||||
-keepattributes InnerClasses
|
||||
-keepattributes EnclosingMethod
|
||||
-keepattributes RuntimeVisibleAnnotations
|
||||
-keepattributes RuntimeVisibleParameterAnnotations
|
||||
-keepattributes AnnotationDefault
|
||||
-keepattributes KotlinMetadata
|
||||
-dontwarn kotlin.**
|
||||
-keep class kotlin.Metadata { *; }
|
||||
-keep class kotlin.coroutines.Continuation
|
||||
-keep class kotlinx.coroutines.** { *; }
|
||||
|
||||
# --- Retrofit ---
|
||||
-keep class retrofit2.** { *; }
|
||||
-dontwarn retrofit2.**
|
||||
|
||||
# --- Gson ---
|
||||
-keep class com.google.gson.** { *; }
|
||||
-keepclassmembers,allowobfuscation class * {
|
||||
@com.google.gson.annotations.SerializedName <fields>;
|
||||
}
|
||||
# Keep all DTO classes and their members for Gson serialization
|
||||
-keep class top.yeij.cyrene.data.remote.dto.** { *; }
|
||||
-keepclassmembers class top.yeij.cyrene.data.remote.dto.** { *; }
|
||||
|
||||
# --- OkHttp ---
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
|
||||
# --- Room ---
|
||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||
-keep class top.yeij.cyrene.data.local.entity.** { *; }
|
||||
-keepclassmembers class top.yeij.cyrene.data.local.entity.** { *; }
|
||||
-dontwarn androidx.room.paging.**
|
||||
|
||||
# --- Koin ---
|
||||
-keep class org.koin.** { *; }
|
||||
-keep class top.yeij.cyrene.di.** { *; }
|
||||
-keepclassmembers class top.yeij.cyrene.di.** { *; }
|
||||
|
||||
# --- Compose ---
|
||||
-dontwarn androidx.compose.**
|
||||
-keep class androidx.compose.** { *; }
|
||||
|
||||
# --- Coroutines ---
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
|
||||
# --- Keep domain models (used in StateFlow, SharedFlow, etc.) ---
|
||||
-keep class top.yeij.cyrene.domain.model.** { *; }
|
||||
|
||||
# --- Keep ViewModels (Koin instantiates via reflection) ---
|
||||
-keep class top.yeij.cyrene.viewmodel.** { *; }
|
||||
-keepclassmembers class top.yeij.cyrene.viewmodel.** { *; }
|
||||
|
||||
# --- Keep repository implementations (Koin binds by interface) ---
|
||||
-keep class top.yeij.cyrene.data.repository.** { *; }
|
||||
-keep class top.yeij.cyrene.domain.repository.** { *; }
|
||||
|
||||
# --- Keep PreferencesDataStore (Koin injects) ---
|
||||
-keep class top.yeij.cyrene.data.local.PreferencesDataStore { *; }
|
||||
|
||||
# --- Keep utility classes (VoiceRecorder, RuntimeLog, etc. — injected by Koin) ---
|
||||
-keep class top.yeij.cyrene.util.** { *; }
|
||||
-keepclassmembers class top.yeij.cyrene.util.** { *; }
|
||||
|
||||
# --- Keep voice/TTS/STT classes (injected by Koin into OverlayViewModel) ---
|
||||
-keep class top.yeij.cyrene.voice.** { *; }
|
||||
-keepclassmembers class top.yeij.cyrene.voice.** { *; }
|
||||
|
||||
# --- Keep domain use cases (injected by Koin into ViewModels) ---
|
||||
-keep class top.yeij.cyrene.domain.usecase.** { *; }
|
||||
-keepclassmembers class top.yeij.cyrene.domain.usecase.** { *; }
|
||||
|
||||
# --- Keep network interceptors and ApiService (Koin singletons) ---
|
||||
-keep class top.yeij.cyrene.data.remote.RetrofitClient { *; }
|
||||
-keep class top.yeij.cyrene.data.remote.ApiService { *; }
|
||||
-keep class top.yeij.cyrene.data.remote.AuthInterceptor { *; }
|
||||
-keep class top.yeij.cyrene.data.remote.DynamicUrlInterceptor { *; }
|
||||
-keep class top.yeij.cyrene.data.remote.TokenAuthenticator { *; }
|
||||
|
||||
# --- Keep WebSocketService (injected into ChatRepositoryImpl) ---
|
||||
-keep class top.yeij.cyrene.service.WebSocketService { *; }
|
||||
|
||||
# --- UI screens & components (called via Navigation compose lambda — R8 may not trace) ---
|
||||
-keep class top.yeij.cyrene.ui.screens.** { *; }
|
||||
-keep class top.yeij.cyrene.ui.components.** { *; }
|
||||
-keep class top.yeij.cyrene.ui.overlay.** { *; }
|
||||
-keep class top.yeij.cyrene.ui.navigation.** { *; }
|
||||
-keep class top.yeij.cyrene.ui.theme.** { *; }
|
||||
|
||||
# --- General AndroidX ---
|
||||
-keep class androidx.lifecycle.** { *; }
|
||||
-dontwarn androidx.lifecycle.**
|
||||
@@ -0,0 +1,131 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- 网络 -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- 语音助手核心 -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.CAPTURE_AUDIO_HOTWORD" />
|
||||
|
||||
<!-- 后台服务 -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<!-- 推送 -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- 锁屏交互 -->
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
|
||||
<!-- 激进保活 -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!-- 查询其他应用(检查默认助手设置) -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".CyreneApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Cyrene"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<!-- 全屏主界面 -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustNothing"
|
||||
android:theme="@style/Theme.Cyrene">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- VoiceInteractionService:系统语音助手 -->
|
||||
<service
|
||||
android:name=".service.CyreneVoiceInteractionService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_VOICE_INTERACTION">
|
||||
|
||||
<meta-data
|
||||
android:name="android.voice_interaction"
|
||||
android:resource="@xml/voice_interaction_config" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- VoiceInteractionSessionService -->
|
||||
<service
|
||||
android:name=".service.CyreneSessionService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_VOICE_INTERACTION" />
|
||||
|
||||
<!-- AccessibilityService:读取屏幕内容 -->
|
||||
<service
|
||||
android:name=".service.CyreneAccessibilityService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_config" />
|
||||
</service>
|
||||
|
||||
<!-- WebSocket 后台保活 -->
|
||||
<service
|
||||
android:name=".service.WebSocketKeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="websocket_keepalive_for_push_message_delivery" />
|
||||
</service>
|
||||
|
||||
<!-- 开机自启 -->
|
||||
<receiver
|
||||
android:name=".service.BootReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- 定时保活唤醒 -->
|
||||
<receiver
|
||||
android:name=".service.KeepAliveReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- FileProvider:日志分享 -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,138 @@
|
||||
package top.yeij.cyrene
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.GlobalContext
|
||||
import org.koin.core.context.startKoin
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.AuthInterceptor
|
||||
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||
import top.yeij.cyrene.di.appModule
|
||||
import top.yeij.cyrene.service.KeepAliveReceiver
|
||||
import top.yeij.cyrene.util.RootKeepAliveHelper
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class CyreneApplication : Application() {
|
||||
|
||||
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val activityCount = AtomicInteger(0)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
RuntimeLog.general("app", "Application onCreate")
|
||||
|
||||
startKoin {
|
||||
androidContext(this@CyreneApplication)
|
||||
modules(appModule)
|
||||
}
|
||||
|
||||
// Track foreground/background state
|
||||
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
if (activityCount.incrementAndGet() == 1) {
|
||||
RuntimeLog.general("app", "App in foreground")
|
||||
getRepo()?.cancelNotifications()
|
||||
getRepo()?.onAppForeground()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityPaused(activity: Activity) {
|
||||
if (activityCount.decrementAndGet() == 0) {
|
||||
RuntimeLog.general("app", "App in background")
|
||||
getRepo()?.onAppBackground()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||
override fun onActivityStarted(activity: Activity) {}
|
||||
override fun onActivityStopped(activity: Activity) {}
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
override fun onActivityDestroyed(activity: Activity) {}
|
||||
})
|
||||
|
||||
initScope.launch {
|
||||
val koin = GlobalContext.get()
|
||||
val prefs: PreferencesDataStore = koin.get()
|
||||
val urlInterceptor: DynamicUrlInterceptor = koin.get()
|
||||
val authInterceptor: AuthInterceptor = koin.get()
|
||||
|
||||
prefs.baseUrl.firstOrNull()?.let { url ->
|
||||
if (url.isNotBlank()) urlInterceptor.baseUrl = url
|
||||
}
|
||||
prefs.token.firstOrNull()?.let { token ->
|
||||
authInterceptor.token = token
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule periodic keep-alive on first launch
|
||||
scheduleInitialKeepAlive()
|
||||
|
||||
// Check battery optimization
|
||||
checkBatteryOptimization()
|
||||
|
||||
// Apply root keep-alive if enabled
|
||||
initScope.launch {
|
||||
val koin = GlobalContext.get()
|
||||
val prefs: PreferencesDataStore = koin.get()
|
||||
val enabled = prefs.rootKeepAlive.firstOrNull() ?: false
|
||||
if (enabled) {
|
||||
val ok = RootKeepAliveHelper.applyRootKeepAlive(packageName)
|
||||
if (ok) {
|
||||
RuntimeLog.general("app", "Root keep-alive re-applied on boot")
|
||||
} else if (RootKeepAliveHelper.isRootAvailable()) {
|
||||
RuntimeLog.general("app", "Root keep-alive re-apply failed despite root being available")
|
||||
}
|
||||
// Only attempt system wakelock if root keep-alive was enabled
|
||||
// We don't persist the wakelock across reboots since it's per-session
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleInitialKeepAlive() {
|
||||
try {
|
||||
KeepAliveReceiver.schedule(this)
|
||||
RuntimeLog.general("app", "Initial keep-alive alarm scheduled")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to schedule initial keep-alive: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkBatteryOptimization() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val pm = getSystemService(PowerManager::class.java)
|
||||
if (pm?.isIgnoringBatteryOptimizations(packageName) == false) {
|
||||
Log.i(TAG, "App is NOT exempt from battery optimization")
|
||||
// Note: we can't request exemption from Application context directly.
|
||||
// SettingsScreen should offer a button to open the exemption dialog.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRepo(): ChatRepositoryImpl? {
|
||||
return try {
|
||||
GlobalContext.get().get()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneApp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package top.yeij.cyrene
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.service.CyreneVoiceInteractionService
|
||||
import top.yeij.cyrene.ui.navigation.CyreneNavGraph
|
||||
import top.yeij.cyrene.ui.navigation.Routes
|
||||
import top.yeij.cyrene.ui.theme.CyreneTheme
|
||||
import top.yeij.cyrene.util.Constants
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val isDefaultAssistant = mutableStateOf(false)
|
||||
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { /* granted or denied — either way we continue */ }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
requestNotificationPermission()
|
||||
isDefaultAssistant.value = checkIsDefaultAssistant()
|
||||
|
||||
setContent {
|
||||
val prefs: PreferencesDataStore = koinInject()
|
||||
val themeMode by prefs.themeMode.collectAsState(initial = null)
|
||||
val themeColor by prefs.themeColor.collectAsState(initial = "pink")
|
||||
val darkTheme = when (themeMode) {
|
||||
"light" -> false
|
||||
"dark" -> true
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
val useDynamic = themeColor == "monet" && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
|
||||
|
||||
CyreneTheme(
|
||||
darkTheme = darkTheme,
|
||||
presetKey = themeColor,
|
||||
useDynamicColor = useDynamic,
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
CyreneNavGraph(
|
||||
navController = navController,
|
||||
startDestination = Routes.MAIN,
|
||||
isDefaultAssistant = isDefaultAssistant.value,
|
||||
onOpenAssistantSettings = { openAssistantSettings() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
isDefaultAssistant.value = checkIsDefaultAssistant()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
}
|
||||
|
||||
private fun checkIsDefaultAssistant(): Boolean {
|
||||
// Standard Android check
|
||||
val flat = ComponentName(this, CyreneVoiceInteractionService::class.java).flattenToString()
|
||||
val current = Settings.Secure.getString(contentResolver, "voice_interaction_service")
|
||||
if (current == flat) return true
|
||||
// Fallback for COS and other custom OS: check persisted flag from service
|
||||
if (CyreneVoiceInteractionService.wasEverActive(this)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private fun openAssistantSettings() {
|
||||
startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS))
|
||||
}
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package top.yeij.cyrene.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import top.yeij.cyrene.data.local.dao.ConversationDao
|
||||
import top.yeij.cyrene.data.local.dao.MessageDao
|
||||
import top.yeij.cyrene.data.local.entity.ConversationEntity
|
||||
import top.yeij.cyrene.data.local.entity.MessageEntity
|
||||
|
||||
@Database(
|
||||
entities = [ConversationEntity::class, MessageEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun conversationDao(): ConversationDao
|
||||
abstract fun messageDao(): MessageDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"cyrene.db",
|
||||
)
|
||||
.build()
|
||||
.also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package top.yeij.cyrene.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "cyrene_prefs")
|
||||
|
||||
class PreferencesDataStore(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private val KEY_TOKEN = stringPreferencesKey("jwt_token")
|
||||
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
|
||||
private val KEY_BASE_URL = stringPreferencesKey("base_url")
|
||||
private val KEY_THEME = stringPreferencesKey("theme_mode")
|
||||
private val KEY_WAKE_WORD = stringPreferencesKey("wake_word")
|
||||
private val KEY_USERNAME = stringPreferencesKey("username")
|
||||
private val KEY_CLIENT_ID = stringPreferencesKey("client_id")
|
||||
private val KEY_DEVICE_NAME = stringPreferencesKey("device_name")
|
||||
private val KEY_CURRENT_SESSION_ID = stringPreferencesKey("current_session_id")
|
||||
private val KEY_DASHSCOPE_API_KEY = stringPreferencesKey("dashscope_api_key")
|
||||
private val KEY_DASHSCOPE_ENDPOINT = stringPreferencesKey("dashscope_endpoint")
|
||||
private val KEY_DASHSCOPE_MODEL = stringPreferencesKey("dashscope_model")
|
||||
private val KEY_LAST_CLEARED_TIMESTAMP = stringPreferencesKey("last_cleared_timestamp")
|
||||
private val KEY_PROFILE_USER_ID = stringPreferencesKey("profile_user_id")
|
||||
private val KEY_PROFILE_NICKNAME = stringPreferencesKey("profile_nickname")
|
||||
private val KEY_PROFILE_IS_ADMIN = stringPreferencesKey("profile_is_admin")
|
||||
private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at")
|
||||
private val KEY_AUTO_SCREEN_CONTEXT = booleanPreferencesKey("auto_screen_context")
|
||||
private val KEY_TYPING_INDICATOR_STYLE = stringPreferencesKey("typing_indicator_style")
|
||||
private val KEY_ENTER_TO_SEND = booleanPreferencesKey("enter_to_send")
|
||||
private val KEY_ROOT_KEEPALIVE = booleanPreferencesKey("root_keepalive")
|
||||
private val KEY_THEME_COLOR = stringPreferencesKey("theme_color")
|
||||
}
|
||||
|
||||
val typingIndicatorStyle: Flow<String> = context.dataStore.data.map { it[KEY_TYPING_INDICATOR_STYLE] ?: "bubble" }
|
||||
|
||||
suspend fun saveTypingIndicatorStyle(style: String) {
|
||||
context.dataStore.edit { it[KEY_TYPING_INDICATOR_STYLE] = style }
|
||||
}
|
||||
|
||||
val enterToSend: Flow<Boolean> = context.dataStore.data.map { it[KEY_ENTER_TO_SEND] ?: false }
|
||||
|
||||
suspend fun saveEnterToSend(enabled: Boolean) {
|
||||
context.dataStore.edit { it[KEY_ENTER_TO_SEND] = enabled }
|
||||
}
|
||||
|
||||
val rootKeepAlive: Flow<Boolean> = context.dataStore.data.map { it[KEY_ROOT_KEEPALIVE] ?: false }
|
||||
|
||||
suspend fun saveRootKeepAlive(enabled: Boolean) {
|
||||
context.dataStore.edit { it[KEY_ROOT_KEEPALIVE] = enabled }
|
||||
}
|
||||
|
||||
val themeColor: Flow<String> = context.dataStore.data.map { it[KEY_THEME_COLOR] ?: "pink" }
|
||||
|
||||
suspend fun saveThemeColor(color: String) {
|
||||
context.dataStore.edit { it[KEY_THEME_COLOR] = color }
|
||||
}
|
||||
|
||||
val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
|
||||
val refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
|
||||
val baseUrl: Flow<String?> = context.dataStore.data.map { it[KEY_BASE_URL] }
|
||||
val themeMode: Flow<String?> = context.dataStore.data.map { it[KEY_THEME] }
|
||||
val wakeWord: Flow<String?> = context.dataStore.data.map { it[KEY_WAKE_WORD] }
|
||||
val username: Flow<String?> = context.dataStore.data.map { it[KEY_USERNAME] }
|
||||
val clientId: Flow<String?> = context.dataStore.data.map { it[KEY_CLIENT_ID] }
|
||||
val deviceName: Flow<String?> = context.dataStore.data.map { it[KEY_DEVICE_NAME] }
|
||||
val currentSessionId: Flow<String?> = context.dataStore.data.map { it[KEY_CURRENT_SESSION_ID] }
|
||||
val dashScopeApiKey: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_API_KEY] }
|
||||
val dashScopeEndpoint: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_ENDPOINT] }
|
||||
val dashScopeModel: Flow<String?> = context.dataStore.data.map { it[KEY_DASHSCOPE_MODEL] }
|
||||
|
||||
suspend fun saveToken(token: String) {
|
||||
context.dataStore.edit { it[KEY_TOKEN] = token }
|
||||
}
|
||||
|
||||
suspend fun saveRefreshToken(token: String) {
|
||||
context.dataStore.edit { it[KEY_REFRESH_TOKEN] = token }
|
||||
}
|
||||
|
||||
suspend fun saveBaseUrl(url: String) {
|
||||
context.dataStore.edit { it[KEY_BASE_URL] = url }
|
||||
}
|
||||
|
||||
suspend fun saveThemeMode(mode: String) {
|
||||
context.dataStore.edit { it[KEY_THEME] = mode }
|
||||
}
|
||||
|
||||
suspend fun saveWakeWord(word: String) {
|
||||
context.dataStore.edit { it[KEY_WAKE_WORD] = word }
|
||||
}
|
||||
|
||||
suspend fun saveUsername(username: String) {
|
||||
context.dataStore.edit { it[KEY_USERNAME] = username }
|
||||
}
|
||||
|
||||
suspend fun saveDashScopeApiKey(key: String) {
|
||||
context.dataStore.edit { it[KEY_DASHSCOPE_API_KEY] = key }
|
||||
}
|
||||
|
||||
suspend fun saveDashScopeEndpoint(endpoint: String) {
|
||||
context.dataStore.edit { it[KEY_DASHSCOPE_ENDPOINT] = endpoint }
|
||||
}
|
||||
|
||||
suspend fun saveDashScopeModel(model: String) {
|
||||
context.dataStore.edit { it[KEY_DASHSCOPE_MODEL] = model }
|
||||
}
|
||||
|
||||
suspend fun saveClientId(id: String) {
|
||||
context.dataStore.edit { it[KEY_CLIENT_ID] = id }
|
||||
}
|
||||
|
||||
suspend fun saveDeviceName(name: String) {
|
||||
context.dataStore.edit { it[KEY_DEVICE_NAME] = name }
|
||||
}
|
||||
|
||||
suspend fun saveCurrentSessionId(id: String) {
|
||||
context.dataStore.edit { it[KEY_CURRENT_SESSION_ID] = id }
|
||||
}
|
||||
|
||||
val lastClearedTimestamp: Flow<String?> = context.dataStore.data.map { it[KEY_LAST_CLEARED_TIMESTAMP] }
|
||||
|
||||
suspend fun saveLastClearedTimestamp(timestamp: Long) {
|
||||
context.dataStore.edit { it[KEY_LAST_CLEARED_TIMESTAMP] = timestamp.toString() }
|
||||
}
|
||||
|
||||
// Cached profile for offline / local-first rendering
|
||||
val profileUserId: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_USER_ID] }
|
||||
val profileNickname: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_NICKNAME] }
|
||||
val profileIsAdmin: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_IS_ADMIN] }
|
||||
val profileCreatedAt: Flow<String?> = context.dataStore.data.map { it[KEY_PROFILE_CREATED_AT] }
|
||||
|
||||
suspend fun saveProfileCache(userId: String, nickname: String, isAdmin: Boolean, createdAt: String) {
|
||||
context.dataStore.edit {
|
||||
it[KEY_PROFILE_USER_ID] = userId
|
||||
it[KEY_PROFILE_NICKNAME] = nickname
|
||||
it[KEY_PROFILE_IS_ADMIN] = isAdmin.toString()
|
||||
it[KEY_PROFILE_CREATED_AT] = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
val autoScreenContext: Flow<Boolean> = context.dataStore.data.map { it[KEY_AUTO_SCREEN_CONTEXT] ?: false }
|
||||
|
||||
suspend fun saveAutoScreenContext(enabled: Boolean) {
|
||||
context.dataStore.edit { it[KEY_AUTO_SCREEN_CONTEXT] = enabled }
|
||||
}
|
||||
|
||||
suspend fun clearProfileCache() {
|
||||
context.dataStore.edit {
|
||||
it.remove(KEY_PROFILE_USER_ID)
|
||||
it.remove(KEY_PROFILE_NICKNAME)
|
||||
it.remove(KEY_PROFILE_IS_ADMIN)
|
||||
it.remove(KEY_PROFILE_CREATED_AT)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
context.dataStore.edit { it.clear() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package top.yeij.cyrene.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import top.yeij.cyrene.data.local.entity.ConversationEntity
|
||||
|
||||
@Dao
|
||||
interface ConversationDao {
|
||||
|
||||
@Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
|
||||
fun getAll(): Flow<List<ConversationEntity>>
|
||||
|
||||
@Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
|
||||
suspend fun getAllSnapshot(): List<ConversationEntity>
|
||||
|
||||
@Query("SELECT * FROM conversations WHERE id = :id")
|
||||
suspend fun getById(id: String): ConversationEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(conversation: ConversationEntity)
|
||||
|
||||
@Query("DELETE FROM conversations WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
|
||||
@Query("DELETE FROM conversations")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package top.yeij.cyrene.data.local.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import top.yeij.cyrene.data.local.entity.MessageEntity
|
||||
|
||||
@Dao
|
||||
interface MessageDao {
|
||||
|
||||
@Query("SELECT * FROM messages WHERE conversationId = :conversationId ORDER BY timestamp ASC")
|
||||
fun getByConversation(conversationId: String): Flow<List<MessageEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(message: MessageEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(messages: List<MessageEntity>)
|
||||
|
||||
@Query("DELETE FROM messages WHERE conversationId = :conversationId")
|
||||
suspend fun deleteByConversation(conversationId: String)
|
||||
|
||||
@Query("UPDATE messages SET conversationId = :newId WHERE conversationId = :oldId")
|
||||
suspend fun migrateConversationId(oldId: String, newId: String)
|
||||
|
||||
@Query("DELETE FROM messages WHERE conversationId = :conversationId AND role = 'user'")
|
||||
suspend fun deleteUserMessagesByConversation(conversationId: String)
|
||||
|
||||
@Query("DELETE FROM messages WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
|
||||
@Query("DELETE FROM messages")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package top.yeij.cyrene.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "conversations")
|
||||
data class ConversationEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val title: String,
|
||||
val lastMessage: String?,
|
||||
val lastMessageType: String?,
|
||||
val updatedAt: Long,
|
||||
val createdAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package top.yeij.cyrene.data.local.entity
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "messages",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = ConversationEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["conversationId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("conversationId")],
|
||||
)
|
||||
data class MessageEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val conversationId: String,
|
||||
val role: String,
|
||||
val content: String,
|
||||
val msgType: String,
|
||||
val timestamp: Long,
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
package top.yeij.cyrene.data.remote
|
||||
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import top.yeij.cyrene.data.remote.dto.AuthRequest
|
||||
import top.yeij.cyrene.data.remote.dto.AuthResponse
|
||||
import top.yeij.cyrene.data.remote.dto.ProfileResponse
|
||||
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
|
||||
import top.yeij.cyrene.data.remote.dto.DeviceDto
|
||||
import top.yeij.cyrene.data.remote.dto.FileUploadResponse
|
||||
import top.yeij.cyrene.data.remote.dto.IoTControlRequest
|
||||
import top.yeij.cyrene.data.remote.dto.MessagesListResponse
|
||||
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
|
||||
import top.yeij.cyrene.data.remote.dto.SessionDto
|
||||
import top.yeij.cyrene.data.remote.dto.SessionsListResponse
|
||||
|
||||
interface ApiService {
|
||||
|
||||
// Auth
|
||||
@POST("api/v1/auth/login")
|
||||
suspend fun login(@Body request: AuthRequest): Response<AuthResponse>
|
||||
|
||||
@POST("api/v1/auth/refresh")
|
||||
suspend fun refreshToken(@Body request: RefreshTokenRequest): Response<AuthResponse>
|
||||
|
||||
@GET("api/v1/profile")
|
||||
suspend fun getProfile(): Response<ProfileResponse>
|
||||
|
||||
// Sessions
|
||||
@GET("api/v1/sessions")
|
||||
suspend fun getSessions(): Response<SessionsListResponse>
|
||||
|
||||
@POST("api/v1/sessions")
|
||||
suspend fun createSession(@Body request: CreateSessionRequest): Response<SessionDto>
|
||||
|
||||
@DELETE("api/v1/sessions/{id}")
|
||||
suspend fun deleteSession(@Path("id") id: String): Response<Unit>
|
||||
|
||||
@DELETE("api/v1/sessions/{id}/messages")
|
||||
suspend fun clearSessionMessages(@Path("id") sessionId: String): Response<Unit>
|
||||
|
||||
@GET("api/v1/sessions/{id}/messages")
|
||||
suspend fun getSessionMessages(
|
||||
@Path("id") sessionId: String,
|
||||
@Query("limit") limit: Int = 500,
|
||||
@Query("offset") offset: Int = 0,
|
||||
): Response<MessagesListResponse>
|
||||
|
||||
// Files
|
||||
@Multipart
|
||||
@POST("api/v1/files/upload")
|
||||
suspend fun uploadFile(
|
||||
@Part file: MultipartBody.Part,
|
||||
): Response<FileUploadResponse>
|
||||
|
||||
// IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/*
|
||||
@GET("api/v1/iot/devices")
|
||||
suspend fun getDevices(): Response<List<DeviceDto>>
|
||||
|
||||
@POST("api/v1/iot/devices/{id}/control")
|
||||
suspend fun controlDevice(
|
||||
@Path("id") deviceId: String,
|
||||
@Body request: IoTControlRequest,
|
||||
): Response<DeviceDto>
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package top.yeij.cyrene.data.remote
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class AuthInterceptor : Interceptor {
|
||||
|
||||
@Volatile
|
||||
var token: String? = null
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = if (!token.isNullOrEmpty()) {
|
||||
chain.request().newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
} else {
|
||||
chain.request()
|
||||
}
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package top.yeij.cyrene.data.remote
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class DynamicUrlInterceptor : Interceptor {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DynamicUrlInterceptor"
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var baseUrl: String = "http://10.0.2.2:8080/"
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
val originalUrl = originalRequest.url
|
||||
|
||||
val targetBase = baseUrl.trimEnd('/')
|
||||
val parsed = targetBase.toHttpUrlOrNull()
|
||||
if (parsed == null) {
|
||||
Log.e(TAG, "Invalid baseUrl: '$baseUrl'")
|
||||
return chain.proceed(chain.request())
|
||||
}
|
||||
|
||||
val newUrl = originalUrl.newBuilder()
|
||||
.scheme(parsed.scheme)
|
||||
.host(parsed.host)
|
||||
.port(parsed.port)
|
||||
.build()
|
||||
|
||||
Log.d(TAG, "Rewriting ${originalUrl} → $newUrl (base: $targetBase)")
|
||||
return chain.proceed(originalRequest.newBuilder().url(newUrl).build())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package top.yeij.cyrene.data.remote
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object RetrofitClient {
|
||||
|
||||
fun provideOkHttpClient(
|
||||
authInterceptor: AuthInterceptor,
|
||||
dynamicUrlInterceptor: DynamicUrlInterceptor,
|
||||
tokenAuthenticator: TokenAuthenticator,
|
||||
): OkHttpClient {
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
.addInterceptor(dynamicUrlInterceptor)
|
||||
.addInterceptor(authInterceptor)
|
||||
.authenticator(tokenAuthenticator)
|
||||
.addInterceptor(logging)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl("http://localhost/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package top.yeij.cyrene.data.remote
|
||||
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Route
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.dto.AuthResponse
|
||||
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
|
||||
import android.util.Log
|
||||
|
||||
class TokenAuthenticator(
|
||||
private val authInterceptor: AuthInterceptor,
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
private val dynamicUrlInterceptor: DynamicUrlInterceptor,
|
||||
) : Authenticator {
|
||||
|
||||
private val gson = Gson()
|
||||
private val client = OkHttpClient()
|
||||
|
||||
override fun authenticate(route: Route?, response: okhttp3.Response): Request? {
|
||||
if (response.code != 401) return null
|
||||
|
||||
synchronized(this) {
|
||||
// Double-check: if token changed while we were waiting for lock, use it
|
||||
val currentToken = authInterceptor.token
|
||||
val requestAuthHeader = response.request.header("Authorization")
|
||||
if (requestAuthHeader != null && currentToken != null &&
|
||||
requestAuthHeader != "Bearer $currentToken"
|
||||
) {
|
||||
return response.request.newBuilder()
|
||||
.header("Authorization", "Bearer $currentToken")
|
||||
.build()
|
||||
}
|
||||
|
||||
// Try refresh
|
||||
return try {
|
||||
val refreshToken = runBlocking { preferencesDataStore.refreshToken.firstOrNull() }
|
||||
?: return null
|
||||
|
||||
val baseUrl = dynamicUrlInterceptor.baseUrl.trimEnd('/')
|
||||
val refreshUrl = "$baseUrl/api/v1/auth/refresh"
|
||||
|
||||
val jsonBody = gson.toJson(RefreshTokenRequest(refreshToken))
|
||||
val body = jsonBody.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val refreshRequest = Request.Builder()
|
||||
.url(refreshUrl)
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val refreshResponse = client.newCall(refreshRequest).execute()
|
||||
if (refreshResponse.isSuccessful) {
|
||||
val authResponse = gson.fromJson(
|
||||
refreshResponse.body?.string(),
|
||||
AuthResponse::class.java
|
||||
)
|
||||
authInterceptor.token = authResponse.token
|
||||
runBlocking {
|
||||
preferencesDataStore.saveToken(authResponse.token)
|
||||
authResponse.refreshToken?.let {
|
||||
preferencesDataStore.saveRefreshToken(it)
|
||||
}
|
||||
}
|
||||
Log.i("TokenAuthenticator", "Token refreshed successfully")
|
||||
response.request.newBuilder()
|
||||
.header("Authorization", "Bearer ${authResponse.token}")
|
||||
.build()
|
||||
} else {
|
||||
Log.w("TokenAuthenticator", "Token refresh failed: ${refreshResponse.code}")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("TokenAuthenticator", "Token refresh error: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package top.yeij.cyrene.data.remote.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class AuthRequest(
|
||||
@SerializedName("username") val username: String,
|
||||
@SerializedName("password") val password: String,
|
||||
)
|
||||
|
||||
data class AuthResponse(
|
||||
@SerializedName("token") val token: String,
|
||||
@SerializedName("refresh_token") val refreshToken: String?,
|
||||
@SerializedName("username") val username: String?,
|
||||
@SerializedName("nickname") val nickname: String?,
|
||||
@SerializedName("user_id") val userId: String?,
|
||||
@SerializedName("expires") val expires: Long? = null,
|
||||
)
|
||||
|
||||
data class RefreshTokenRequest(
|
||||
@SerializedName("refresh_token") val refreshToken: String,
|
||||
)
|
||||
|
||||
data class ProfileResponse(
|
||||
@SerializedName("user_id") val userId: String,
|
||||
@SerializedName("username") val username: String,
|
||||
@SerializedName("nickname") val nickname: String?,
|
||||
@SerializedName("is_admin") val isAdmin: Boolean? = false,
|
||||
@SerializedName("created_at") val createdAt: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package top.yeij.cyrene.data.remote.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class DeviceDto(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("name") val name: String,
|
||||
@SerializedName("type") val type: String,
|
||||
@SerializedName("state") val state: DeviceState,
|
||||
@SerializedName("room") val room: String?,
|
||||
)
|
||||
|
||||
data class DeviceState(
|
||||
@SerializedName("power") val power: Boolean?,
|
||||
@SerializedName("brightness") val brightness: Int?,
|
||||
@SerializedName("temperature") val temperature: Float?,
|
||||
@SerializedName("humidity") val humidity: Float?,
|
||||
@SerializedName("colorTemp") val colorTemp: Int?,
|
||||
@SerializedName("locked") val locked: Boolean?,
|
||||
@SerializedName("open") val open: Boolean?,
|
||||
)
|
||||
|
||||
data class IoTControlRequest(
|
||||
@SerializedName("action") val action: String,
|
||||
@SerializedName("value") val value: Any? = null,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
package top.yeij.cyrene.data.remote.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
// POST /api/v1/sessions — request body
|
||||
data class CreateSessionRequest(
|
||||
@SerializedName("session_id") val sessionId: String? = null,
|
||||
@SerializedName("title") val title: String = "新的对话",
|
||||
@SerializedName("is_main") val isMain: Boolean = false,
|
||||
)
|
||||
|
||||
// GET /api/v1/sessions — response wrapper
|
||||
data class SessionsListResponse(
|
||||
@SerializedName("sessions") val sessions: List<SessionDto>,
|
||||
)
|
||||
|
||||
data class SessionDto(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("user_id") val userId: String?,
|
||||
@SerializedName("title") val title: String,
|
||||
@SerializedName("is_main") val isMain: Boolean?,
|
||||
@SerializedName("created_at") val createdAt: Long,
|
||||
@SerializedName("updated_at") val updatedAt: Long,
|
||||
)
|
||||
|
||||
// GET /api/v1/sessions/{id}/messages — response wrapper
|
||||
data class MessagesListResponse(
|
||||
@SerializedName("messages") val messages: List<SessionMessageDto>,
|
||||
)
|
||||
|
||||
data class SessionMessageDto(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("session_id") val sessionId: String,
|
||||
@SerializedName("role") val role: String,
|
||||
@SerializedName("msg_type") val msgType: String?,
|
||||
@SerializedName("content") val content: String,
|
||||
@SerializedName("created_at") val createdAt: Long,
|
||||
)
|
||||
|
||||
// POST /api/v1/files/upload — response
|
||||
data class FileUploadResponse(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("filename") val filename: String? = null,
|
||||
@SerializedName("mime_type") val mimeType: String? = null,
|
||||
@SerializedName("size") val size: Long? = null,
|
||||
@SerializedName("url") val url: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
package top.yeij.cyrene.data.remote.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
// --- Client → Server ---
|
||||
|
||||
data class WSClientMessage(
|
||||
@SerializedName("type") val type: String,
|
||||
@SerializedName("session_id") val sessionId: String? = null,
|
||||
@SerializedName("mode") val mode: String? = null,
|
||||
@SerializedName("content") val content: String? = null,
|
||||
@SerializedName("audio_data") val audioData: String? = null,
|
||||
@SerializedName("attachments") val attachments: List<WSAttachment>? = null,
|
||||
@SerializedName("timestamp") val timestamp: Long? = null,
|
||||
@SerializedName("client_id") val clientId: String? = null,
|
||||
@SerializedName("device_name") val deviceName: String? = null,
|
||||
@SerializedName("user_agent") val userAgent: String? = null,
|
||||
)
|
||||
|
||||
data class WSAttachment(
|
||||
@SerializedName("type") val type: String,
|
||||
@SerializedName("url") val url: String? = null,
|
||||
@SerializedName("file_id") val fileId: String? = null,
|
||||
@SerializedName("thumbnail_url") val thumbnailUrl: String? = null,
|
||||
@SerializedName("filename") val filename: String? = null,
|
||||
@SerializedName("width") val width: Int? = null,
|
||||
@SerializedName("height") val height: Int? = null,
|
||||
@SerializedName("size") val size: Long? = null,
|
||||
@SerializedName("description") val description: String? = null,
|
||||
)
|
||||
|
||||
// --- Server → Client ---
|
||||
|
||||
data class WSClientInfo(
|
||||
@SerializedName("client_id") val clientId: String? = null,
|
||||
@SerializedName("device_name") val deviceName: String? = null,
|
||||
)
|
||||
|
||||
data class WSServerMessage(
|
||||
@SerializedName("type") val type: String?,
|
||||
@SerializedName("message_id") val messageId: String? = null,
|
||||
@SerializedName("text") val text: String? = null,
|
||||
@SerializedName("content") val content: String? = null,
|
||||
@SerializedName("role") val role: String? = null,
|
||||
@SerializedName("msg_type") val msgType: String? = null,
|
||||
@SerializedName("session_id") val sessionId: String? = null,
|
||||
@SerializedName("error") val error: String? = null,
|
||||
@SerializedName("review_messages") val reviewMessages: List<WSReviewMessage>? = null,
|
||||
@SerializedName("messages") val messages: List<WSHistoryMessage>? = null,
|
||||
@SerializedName("multi_message") val multiMessages: List<WSMultiItem>? = null,
|
||||
@SerializedName("tool_progress") val toolProgress: WSToolProgress? = null,
|
||||
@SerializedName("thinking_status") val thinkingStatus: String? = null,
|
||||
@SerializedName("thinking_content") val thinkingContent: String? = null,
|
||||
@SerializedName("timestamp") val timestamp: Long? = null,
|
||||
@SerializedName("client_info") val clientInfo: WSClientInfo? = null,
|
||||
)
|
||||
|
||||
data class WSReviewMessage(
|
||||
@SerializedName("type") val type: String? = null,
|
||||
@SerializedName("role") val role: String? = null,
|
||||
@SerializedName("text") val text: String? = null,
|
||||
@SerializedName("content") val content: String? = null,
|
||||
@SerializedName("msg_type") val msgType: String? = null,
|
||||
@SerializedName("delay_ms") val delayMs: Long? = 0,
|
||||
@SerializedName("metadata") val metadata: WSReviewMetadata? = null,
|
||||
)
|
||||
|
||||
data class WSReviewMetadata(
|
||||
@SerializedName("language") val language: String? = null,
|
||||
@SerializedName("url") val url: String? = null,
|
||||
)
|
||||
|
||||
data class WSHistoryMessage(
|
||||
@SerializedName("id") val id: String?,
|
||||
@SerializedName("role") val role: String?,
|
||||
@SerializedName("content") val content: String?,
|
||||
@SerializedName("msg_type") val msgType: String?,
|
||||
@SerializedName("timestamp") val timestamp: Long?,
|
||||
@SerializedName("name") val name: String?,
|
||||
)
|
||||
|
||||
data class WSMultiItem(
|
||||
@SerializedName("role") val role: String?,
|
||||
@SerializedName("content") val content: String?,
|
||||
@SerializedName("msg_type") val msgType: String?,
|
||||
)
|
||||
|
||||
data class WSToolProgress(
|
||||
@SerializedName("tool_name") val toolName: String?,
|
||||
@SerializedName("status") val status: String?,
|
||||
@SerializedName("detail") val detail: String?,
|
||||
@SerializedName("message") val message: String?,
|
||||
@SerializedName("progress") val progress: Int? = null,
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.ApiService
|
||||
import top.yeij.cyrene.data.remote.AuthInterceptor
|
||||
import top.yeij.cyrene.data.remote.dto.AuthRequest
|
||||
import top.yeij.cyrene.domain.model.AuthResult
|
||||
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
||||
class AuthRepositoryImpl(
|
||||
private val apiService: ApiService,
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
private val authInterceptor: AuthInterceptor,
|
||||
) : AuthRepository {
|
||||
|
||||
override suspend fun login(username: String, password: String): Result<AuthResult> {
|
||||
return try {
|
||||
val response = apiService.login(AuthRequest(username, password))
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()!!
|
||||
authInterceptor.token = body.token
|
||||
preferencesDataStore.saveToken(body.token)
|
||||
body.refreshToken?.let { preferencesDataStore.saveRefreshToken(it) }
|
||||
val displayName = body.nickname ?: body.username ?: body.userId ?: "开拓者"
|
||||
preferencesDataStore.saveUsername(displayName)
|
||||
Result.success(
|
||||
AuthResult(
|
||||
token = body.token,
|
||||
refreshToken = body.refreshToken,
|
||||
username = displayName,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Result.failure(Exception("登录失败: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun logout() {
|
||||
authInterceptor.token = null
|
||||
preferencesDataStore.clearAll()
|
||||
}
|
||||
|
||||
override suspend fun isLoggedIn(): Boolean {
|
||||
return preferencesDataStore.token.firstOrNull() != null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,867 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.local.dao.ConversationDao
|
||||
import top.yeij.cyrene.data.local.dao.MessageDao
|
||||
import top.yeij.cyrene.data.local.entity.ConversationEntity
|
||||
import top.yeij.cyrene.data.local.entity.MessageEntity
|
||||
import top.yeij.cyrene.data.remote.ApiService
|
||||
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
|
||||
import top.yeij.cyrene.data.remote.dto.WSAttachment
|
||||
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||
import top.yeij.cyrene.domain.model.Conversation
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import top.yeij.cyrene.service.KeepAliveReceiver
|
||||
import top.yeij.cyrene.service.WebSocketKeepAliveService
|
||||
import top.yeij.cyrene.service.WebSocketService
|
||||
import top.yeij.cyrene.util.NotificationHelper
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import java.util.UUID
|
||||
|
||||
class ChatRepositoryImpl(
|
||||
private val app: Application,
|
||||
private val conversationDao: ConversationDao,
|
||||
private val messageDao: MessageDao,
|
||||
private val webSocketService: WebSocketService,
|
||||
private val apiService: ApiService,
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
private val notificationHelper: NotificationHelper,
|
||||
) : ChatRepository {
|
||||
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, e ->
|
||||
Log.e("ChatRepository", "Unhandled exception", e)
|
||||
}
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)
|
||||
|
||||
private val _connectionState = MutableStateFlow(false)
|
||||
override val connectionState: StateFlow<Boolean> = _connectionState.asStateFlow()
|
||||
|
||||
override val connectionError: StateFlow<String?> = webSocketService.connectionError
|
||||
|
||||
private val _incomingMessages = MutableSharedFlow<Message>(extraBufferCapacity = 64)
|
||||
override fun observeMessages(): Flow<Message> = _incomingMessages
|
||||
|
||||
private val _messageClearEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
|
||||
override val messageClearEvents: Flow<Unit> = _messageClearEvents
|
||||
|
||||
private val _messageRemovals = MutableSharedFlow<String>(extraBufferCapacity = 16)
|
||||
override val messageRemovals: Flow<String> = _messageRemovals
|
||||
|
||||
private val _isAssistantStreaming = MutableStateFlow(false)
|
||||
override val isAssistantStreaming: StateFlow<Boolean> = _isAssistantStreaming.asStateFlow()
|
||||
|
||||
private var streamingContent = ""
|
||||
private var streamingMessageId: String? = null
|
||||
private var streamTimeoutJob: kotlinx.coroutines.Job? = null
|
||||
override var currentSessionId: String? = null
|
||||
|
||||
private var isAppInForeground = false
|
||||
private var hasEverBeenForeground = false
|
||||
private var historyRequested = false
|
||||
private val notifiedMessageIds = mutableSetOf<String>()
|
||||
|
||||
// Duplicate suppression: track items from review/multi_message to skip wrapping response
|
||||
private val recentParsedContents = mutableListOf<String>()
|
||||
private var lastParsedTime = 0L
|
||||
// Track last response to clean up if review/multi_message arrives after
|
||||
private var lastResponseId: String? = null
|
||||
private var lastResponseContent: String? = null
|
||||
private var lastResponseTime = 0L
|
||||
|
||||
fun cancelNotifications() {
|
||||
notificationHelper.cancelAll()
|
||||
}
|
||||
|
||||
private fun resetStreamTimeout() {
|
||||
cancelStreamTimeout()
|
||||
streamTimeoutJob = scope.launch {
|
||||
kotlinx.coroutines.delay(120_000L) // 2 min timeout
|
||||
if (_isAssistantStreaming.value) {
|
||||
RuntimeLog.chat("stream", "Stream timeout — no chunk or end for 120s, resetting")
|
||||
streamingContent = ""
|
||||
streamingMessageId = null
|
||||
_isAssistantStreaming.value = false
|
||||
emitMessage(
|
||||
id = "timeout_${System.currentTimeMillis()}",
|
||||
sessionId = currentSessionId ?: "default",
|
||||
role = "system",
|
||||
content = "AI 响应超时,请重试",
|
||||
msgType = "system_info",
|
||||
isStreaming = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelStreamTimeout() {
|
||||
streamTimeoutJob?.cancel()
|
||||
streamTimeoutJob = null
|
||||
}
|
||||
|
||||
override fun onAppForeground() {
|
||||
RuntimeLog.notify("state", "onAppForeground: wasForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground")
|
||||
isAppInForeground = true
|
||||
hasEverBeenForeground = true
|
||||
notifiedMessageIds.clear()
|
||||
notificationHelper.cancelAll()
|
||||
KeepAliveReceiver.cancel(app)
|
||||
WebSocketKeepAliveService.stop(app)
|
||||
RuntimeLog.notify("state", "onAppForeground: notifications cleared, keep-alive stopped")
|
||||
scope.launch {
|
||||
val sid = currentSessionId ?: return@launch
|
||||
RuntimeLog.general("app", "Foreground — requesting history for session=$sid")
|
||||
requestHistoryViaWs(sid)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAppBackground() {
|
||||
isAppInForeground = false
|
||||
WebSocketKeepAliveService.start(app)
|
||||
KeepAliveReceiver.schedule(app)
|
||||
val currentlyConnected = _connectionState.value
|
||||
RuntimeLog.notify("state", "onAppBackground: connected=$currentlyConnected hasEverBeen=$hasEverBeenForeground keepAliveStarted=true")
|
||||
// Only reconnect if the WS is already dead. Tearing down a healthy
|
||||
// connection creates a message loss window with no benefit.
|
||||
if (!currentlyConnected) {
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(1500) // let the service start first
|
||||
webSocketService.forceReconnect()
|
||||
RuntimeLog.general("app", "Background reconnect done, connected=${_connectionState.value}")
|
||||
}
|
||||
} else {
|
||||
RuntimeLog.general("app", "WS healthy — skipping background reconnect to avoid message loss")
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Restore persisted session ID, then connect and load history
|
||||
scope.launch {
|
||||
val persistedSid = preferencesDataStore.currentSessionId.firstOrNull()
|
||||
if (!persistedSid.isNullOrBlank()) {
|
||||
currentSessionId = persistedSid
|
||||
}
|
||||
RuntimeLog.ws("init", "Connecting WebSocket session=$currentSessionId")
|
||||
webSocketService.connect(currentSessionId)
|
||||
loadConversationsFromServer()
|
||||
}
|
||||
scope.launch {
|
||||
webSocketService.isConnected.collect { connected ->
|
||||
_connectionState.value = connected
|
||||
RuntimeLog.ws("connection", "Connected=$connected")
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
webSocketService.incomingMessages.collect { wsMsg ->
|
||||
try {
|
||||
RuntimeLog.ws("receive", "type=${wsMsg.type} msgId=${wsMsg.messageId ?: "-"}")
|
||||
handleServerMessage(wsMsg)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatRepository", "Error handling ${wsMsg.type}: ${e.message}", e)
|
||||
RuntimeLog.ws("error", "Handle error type=${wsMsg.type}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getConversations(): Flow<List<Conversation>> {
|
||||
return conversationDao.getAll().map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMessages(conversationId: String): Flow<List<Message>> {
|
||||
return messageDao.getByConversation(conversationId).map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteConversation(id: String) {
|
||||
conversationDao.deleteById(id)
|
||||
try { apiService.deleteSession(id) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override suspend fun clearLocalMessages() {
|
||||
val now = System.currentTimeMillis()
|
||||
messageDao.deleteAll()
|
||||
preferencesDataStore.saveLastClearedTimestamp(now)
|
||||
|
||||
_messageClearEvents.tryEmit(Unit)
|
||||
|
||||
RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now")
|
||||
Log.i("ChatRepository", "Local messages cleared, timestamp: $now")
|
||||
}
|
||||
|
||||
private suspend fun changeSessionId(newId: String) {
|
||||
val oldId = currentSessionId
|
||||
if (oldId != null && oldId != newId) {
|
||||
messageDao.migrateConversationId(oldId, newId)
|
||||
conversationDao.deleteById(oldId)
|
||||
}
|
||||
currentSessionId = newId
|
||||
preferencesDataStore.saveCurrentSessionId(newId)
|
||||
}
|
||||
|
||||
override suspend fun connectWebSocket(sessionId: String?) {
|
||||
currentSessionId = sessionId
|
||||
webSocketService.connect(sessionId)
|
||||
}
|
||||
|
||||
override suspend fun reconnectWebSocket() {
|
||||
webSocketService.disconnect()
|
||||
webSocketService.connect(currentSessionId)
|
||||
}
|
||||
|
||||
override suspend fun ensureConnected() {
|
||||
// Always force reconnect — connectionState may be stuck at true on a silently dead socket
|
||||
webSocketService.forceReconnect()
|
||||
}
|
||||
|
||||
override suspend fun sendMessage(content: String, sessionId: String?, attachments: List<WSAttachment>?, localImageUris: List<String>) {
|
||||
val messageId = UUID.randomUUID().toString()
|
||||
val now = System.currentTimeMillis()
|
||||
val sid = sessionId ?: currentSessionId ?: "default"
|
||||
if (currentSessionId == null) {
|
||||
currentSessionId = sid
|
||||
scope.launch { preferencesDataStore.saveCurrentSessionId(sid) }
|
||||
}
|
||||
|
||||
val hasImages = localImageUris.isNotEmpty()
|
||||
val displayContent = content.ifBlank { "" }
|
||||
val lastMsg = when {
|
||||
hasImages && content.isBlank() -> "[图片]"
|
||||
hasImages -> content
|
||||
else -> content
|
||||
}
|
||||
|
||||
RuntimeLog.chat("send", "session=$sid msgId=$messageId content=${content.take(80)} attachments=${attachments?.size ?: 0}")
|
||||
|
||||
conversationDao.upsert(
|
||||
ConversationEntity(
|
||||
id = sid,
|
||||
title = "对话",
|
||||
lastMessage = lastMsg,
|
||||
lastMessageType = "chat",
|
||||
updatedAt = now,
|
||||
createdAt = now,
|
||||
)
|
||||
)
|
||||
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = messageId,
|
||||
conversationId = sid,
|
||||
role = "user",
|
||||
content = displayContent,
|
||||
msgType = "chat",
|
||||
timestamp = now,
|
||||
)
|
||||
)
|
||||
|
||||
emitMessage(
|
||||
id = messageId,
|
||||
sessionId = sid,
|
||||
role = "user",
|
||||
content = displayContent,
|
||||
msgType = "chat",
|
||||
timestamp = now,
|
||||
isStreaming = false,
|
||||
imageDataUris = localImageUris,
|
||||
)
|
||||
|
||||
webSocketService.sendMessage(content, sid, attachments = attachments)
|
||||
}
|
||||
|
||||
override suspend fun loadConversationsFromServer() {
|
||||
try {
|
||||
val response = apiService.getSessions()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.sessions?.forEach { dto ->
|
||||
conversationDao.upsert(
|
||||
ConversationEntity(
|
||||
id = dto.id,
|
||||
title = dto.title,
|
||||
lastMessage = "",
|
||||
lastMessageType = "chat",
|
||||
updatedAt = dto.updatedAt,
|
||||
createdAt = dto.createdAt,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override suspend fun sendScreenContext(content: String) {
|
||||
webSocketService.sendScreenContext(content, currentSessionId)
|
||||
}
|
||||
|
||||
override suspend fun sendVoiceInput(audioBase64: String, mode: String) {
|
||||
webSocketService.sendVoiceInput(audioBase64, currentSessionId, mode)
|
||||
}
|
||||
|
||||
override suspend fun initializeSession(): String {
|
||||
// Try to find an existing main session on the server
|
||||
try {
|
||||
val response = apiService.getSessions()
|
||||
if (response.isSuccessful) {
|
||||
val sessions = response.body()?.sessions ?: emptyList()
|
||||
val mainSession = sessions.find { it.isMain == true }
|
||||
if (mainSession != null) {
|
||||
currentSessionId = mainSession.id
|
||||
preferencesDataStore.saveCurrentSessionId(mainSession.id)
|
||||
Log.i("ChatRepository", "Found main session: ${mainSession.id}")
|
||||
return mainSession.id
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// No main session found on server, create one with deterministic ID
|
||||
val sessionId = "session_admin_main"
|
||||
try {
|
||||
apiService.createSession(
|
||||
CreateSessionRequest(
|
||||
sessionId = sessionId,
|
||||
title = "主对话",
|
||||
isMain = true,
|
||||
)
|
||||
)
|
||||
Log.i("ChatRepository", "Created main session: $sessionId")
|
||||
} catch (e: Exception) {
|
||||
Log.w("ChatRepository", "Failed to create main session: ${e.message}")
|
||||
}
|
||||
|
||||
currentSessionId = sessionId
|
||||
preferencesDataStore.saveCurrentSessionId(sessionId)
|
||||
return sessionId
|
||||
}
|
||||
|
||||
override suspend fun loadMessagesFromServer(sessionId: String): List<Message> {
|
||||
currentSessionId = sessionId
|
||||
return try {
|
||||
val response = apiService.getSessionMessages(sessionId)
|
||||
if (response.isSuccessful) {
|
||||
val messageDtos = response.body()?.messages ?: emptyList()
|
||||
val lastCleared = preferencesDataStore.lastClearedTimestamp.firstOrNull()
|
||||
?.toLongOrNull() ?: 0L
|
||||
val filteredDtos = messageDtos.filter { it.createdAt > lastCleared }
|
||||
ensureConversation(sessionId)
|
||||
val messages = filteredDtos.map { dto ->
|
||||
Message(
|
||||
id = "${dto.id}",
|
||||
conversationId = sessionId,
|
||||
role = dto.role,
|
||||
content = dto.content,
|
||||
msgType = dto.msgType ?: "chat",
|
||||
timestamp = dto.createdAt,
|
||||
)
|
||||
}
|
||||
val deduped = messages.removeWrappingDuplicates().splitInlineActions()
|
||||
messageDao.deleteUserMessagesByConversation(sessionId)
|
||||
messageDao.upsertAll(deduped.map { msg ->
|
||||
MessageEntity(
|
||||
id = msg.id,
|
||||
conversationId = msg.conversationId,
|
||||
role = msg.role,
|
||||
content = msg.content,
|
||||
msgType = msg.msgType,
|
||||
timestamp = msg.timestamp,
|
||||
)
|
||||
})
|
||||
RuntimeLog.http("loadMessages", "HTTP loaded ${deduped.size} messages (${messages.size} before dedup) for session=$sessionId")
|
||||
deduped
|
||||
} else {
|
||||
RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS")
|
||||
requestHistoryViaWs(sessionId)
|
||||
emptyList()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
RuntimeLog.http("loadMessages", "HTTP error: ${e.message}, trying WS")
|
||||
requestHistoryViaWs(sessionId)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun requestHistoryViaWs(sessionId: String) {
|
||||
if (!webSocketService.isConnected.value) {
|
||||
val connected = withTimeoutOrNull(5000) {
|
||||
webSocketService.isConnected.first { it }
|
||||
}
|
||||
if (connected != true) {
|
||||
// WS couldn't connect, fall back to REST API
|
||||
RuntimeLog.chat("history", "WS not connected after 5s, falling back to REST")
|
||||
loadMessagesFromServer(sessionId)
|
||||
return
|
||||
}
|
||||
}
|
||||
webSocketService.requestHistory(sessionId)
|
||||
}
|
||||
|
||||
private suspend fun ensureConversation(sessionId: String, lastMessage: String = "") {
|
||||
val existing = conversationDao.getById(sessionId)
|
||||
if (existing == null) {
|
||||
val now = System.currentTimeMillis()
|
||||
conversationDao.upsert(
|
||||
ConversationEntity(
|
||||
id = sessionId,
|
||||
title = "对话",
|
||||
lastMessage = lastMessage,
|
||||
lastMessageType = "chat",
|
||||
updatedAt = now,
|
||||
createdAt = now,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleServerMessage(wsMsg: WSServerMessage) {
|
||||
when (wsMsg.type) {
|
||||
"stream_start" -> {
|
||||
streamingContent = ""
|
||||
streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
|
||||
_isAssistantStreaming.value = true
|
||||
recentParsedContents.clear()
|
||||
resetStreamTimeout()
|
||||
RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
|
||||
}
|
||||
|
||||
"stream_chunk" -> {
|
||||
val delta = wsMsg.content ?: wsMsg.text ?: return
|
||||
streamingContent += delta
|
||||
resetStreamTimeout()
|
||||
emitMessage(
|
||||
id = streamingMessageId ?: "s_${System.currentTimeMillis()}",
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
role = "assistant",
|
||||
content = streamingContent,
|
||||
msgType = wsMsg.msgType ?: "chat",
|
||||
isStreaming = true,
|
||||
)
|
||||
}
|
||||
|
||||
"stream_end" -> {
|
||||
cancelStreamTimeout()
|
||||
val msgId = wsMsg.messageId ?: streamingMessageId ?: "s_${System.currentTimeMillis()}"
|
||||
val content = streamingContent.ifEmpty { wsMsg.content ?: wsMsg.text ?: "" }
|
||||
streamingContent = ""
|
||||
streamingMessageId = null
|
||||
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
|
||||
changeSessionId(sid)
|
||||
}
|
||||
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
|
||||
|
||||
// Dedup: suppress if streaming content wraps already-shown multi_message/review items
|
||||
val timeSinceParsed = System.currentTimeMillis() - lastParsedTime
|
||||
if (timeSinceParsed < 3000 && recentParsedContents.isNotEmpty()) {
|
||||
val allContained = recentParsedContents.all { content.contains(it) }
|
||||
if (allContained) {
|
||||
RuntimeLog.chat("dedup", "Suppressed stream_end wrapping, ${recentParsedContents.size} items already shown")
|
||||
recentParsedContents.clear()
|
||||
_isAssistantStreaming.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
recentParsedContents.clear()
|
||||
|
||||
if (content.isNotBlank()) {
|
||||
ensureConversation(sid, content)
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = msgId,
|
||||
conversationId = sid,
|
||||
role = "assistant",
|
||||
content = content,
|
||||
msgType = wsMsg.msgType ?: "chat",
|
||||
timestamp = ts,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
lastResponseId = msgId
|
||||
lastResponseContent = content
|
||||
lastResponseTime = System.currentTimeMillis()
|
||||
|
||||
RuntimeLog.notify("trigger", "stream_end: id=$msgId isForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground content='${content.take(50)}'")
|
||||
emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = wsMsg.msgType ?: "chat", timestamp = ts, isStreaming = false, shouldNotify = true)
|
||||
_isAssistantStreaming.value = false
|
||||
}
|
||||
|
||||
"response" -> {
|
||||
val text = wsMsg.content ?: wsMsg.text ?: return
|
||||
val role = wsMsg.role ?: "assistant"
|
||||
val replyMsgType = wsMsg.msgType ?: "chat"
|
||||
val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}"
|
||||
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||
|
||||
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
|
||||
changeSessionId(sid)
|
||||
}
|
||||
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
|
||||
|
||||
ensureConversation(sid, text)
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = msgId,
|
||||
conversationId = sid,
|
||||
role = role,
|
||||
content = text,
|
||||
msgType = replyMsgType,
|
||||
timestamp = ts,
|
||||
)
|
||||
)
|
||||
|
||||
lastResponseId = msgId
|
||||
lastResponseContent = text
|
||||
lastResponseTime = System.currentTimeMillis()
|
||||
|
||||
// Track parsed content so stream_end can suppress the wrapping full text
|
||||
recentParsedContents.add(text)
|
||||
lastParsedTime = System.currentTimeMillis()
|
||||
|
||||
RuntimeLog.notify("trigger", "reply: id=$msgId role=$role isForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground content='${text.take(50)}'")
|
||||
emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false, shouldNotify = true)
|
||||
}
|
||||
|
||||
"review" -> {
|
||||
recentParsedContents.clear()
|
||||
wsMsg.reviewMessages?.forEachIndexed { index, review ->
|
||||
if (index > 0) delay(1000L)
|
||||
val rawText = review.content ?: review.text ?: return@forEachIndexed
|
||||
val role = review.role ?: "assistant"
|
||||
val rvMsgType = review.type ?: review.msgType ?: "action"
|
||||
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
|
||||
// Encode code language metadata into content for the renderer
|
||||
val content = if (rvMsgType == "code" && review.metadata?.language != null) {
|
||||
"[lang:${review.metadata.language}]\n$rawText"
|
||||
} else rawText
|
||||
recentParsedContents.add(rawText)
|
||||
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = content, msgType = rvMsgType, isStreaming = false)
|
||||
}
|
||||
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
|
||||
// Clean up wrapping response that arrived before this review
|
||||
cleanupWrappingResponse()
|
||||
}
|
||||
|
||||
"thinking" -> {
|
||||
val text = wsMsg.thinkingContent ?: wsMsg.content ?: wsMsg.text
|
||||
if (text != null) {
|
||||
emitMessage(
|
||||
id = wsMsg.messageId ?: "think_${System.currentTimeMillis()}",
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
role = "assistant",
|
||||
content = text,
|
||||
msgType = wsMsg.msgType ?: "thinking",
|
||||
isStreaming = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"tool_progress" -> {
|
||||
val detail = wsMsg.toolProgress?.message
|
||||
?: wsMsg.toolProgress?.detail
|
||||
?: wsMsg.toolProgress?.toolName
|
||||
?: "正在执行操作..."
|
||||
emitMessage(
|
||||
id = wsMsg.messageId ?: "tool_${System.currentTimeMillis()}",
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
role = "system",
|
||||
content = detail,
|
||||
msgType = wsMsg.msgType ?: "tool_progress",
|
||||
isStreaming = false,
|
||||
)
|
||||
}
|
||||
|
||||
"queued" -> {
|
||||
emitMessage(
|
||||
id = "queued_${System.currentTimeMillis()}",
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
role = "system",
|
||||
content = "消息已加入处理队列",
|
||||
msgType = "system_info",
|
||||
isStreaming = false,
|
||||
)
|
||||
}
|
||||
|
||||
"error" -> {
|
||||
cancelStreamTimeout()
|
||||
streamingContent = ""
|
||||
streamingMessageId = null
|
||||
_isAssistantStreaming.value = false
|
||||
RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}")
|
||||
emitMessage(
|
||||
id = "err_${System.currentTimeMillis()}",
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
role = "system",
|
||||
content = wsMsg.error ?: "未知错误",
|
||||
msgType = wsMsg.msgType ?: "system_info",
|
||||
isStreaming = false,
|
||||
)
|
||||
}
|
||||
|
||||
"voice_transcript" -> {
|
||||
val text = wsMsg.text ?: wsMsg.content ?: return
|
||||
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
|
||||
val msgId = wsMsg.messageId ?: "vt_${System.currentTimeMillis()}"
|
||||
ensureConversation(sid)
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = msgId,
|
||||
conversationId = sid,
|
||||
role = "user",
|
||||
content = text,
|
||||
msgType = wsMsg.msgType ?: "chat",
|
||||
timestamp = ts,
|
||||
)
|
||||
)
|
||||
emitMessage(id = msgId, sessionId = sid, role = "user", content = text, msgType = wsMsg.msgType ?: "chat", timestamp = ts, isStreaming = false)
|
||||
}
|
||||
|
||||
"history_response" -> {
|
||||
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
|
||||
changeSessionId(sid)
|
||||
}
|
||||
ensureConversation(sid)
|
||||
val messages = wsMsg.messages ?: return
|
||||
val messageList = messages.map { hist ->
|
||||
val msgId = hist.id ?: "hist_${System.currentTimeMillis()}_${hist.hashCode()}"
|
||||
Message(
|
||||
id = msgId,
|
||||
conversationId = sid,
|
||||
role = hist.role ?: "system",
|
||||
content = hist.content ?: "",
|
||||
msgType = hist.msgType ?: "chat",
|
||||
timestamp = hist.timestamp ?: System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
val deduped = messageList.removeWrappingDuplicates().splitInlineActions()
|
||||
messageDao.upsertAll(deduped.map { msg ->
|
||||
MessageEntity(
|
||||
id = msg.id,
|
||||
conversationId = msg.conversationId,
|
||||
role = msg.role,
|
||||
content = msg.content,
|
||||
msgType = msg.msgType,
|
||||
timestamp = msg.timestamp,
|
||||
)
|
||||
})
|
||||
deduped.forEach { msg ->
|
||||
emitMessage(
|
||||
id = msg.id,
|
||||
sessionId = msg.conversationId,
|
||||
role = msg.role,
|
||||
content = msg.content,
|
||||
msgType = msg.msgType,
|
||||
timestamp = msg.timestamp,
|
||||
isStreaming = false,
|
||||
shouldNotify = false,
|
||||
)
|
||||
}
|
||||
RuntimeLog.chat("history", "Loaded ${deduped.size} messages from server history")
|
||||
}
|
||||
|
||||
"multi_message" -> {
|
||||
recentParsedContents.clear()
|
||||
var isFirst = true
|
||||
wsMsg.multiMessages?.forEachIndexed { index, item ->
|
||||
if (index > 0) delay(1000L)
|
||||
val content = item.content ?: ""
|
||||
recentParsedContents.add(content)
|
||||
emitMessage(
|
||||
id = "mm_${System.currentTimeMillis()}_${item.hashCode()}",
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
role = item.role ?: "assistant",
|
||||
content = content,
|
||||
msgType = item.msgType ?: "chat",
|
||||
timestamp = wsMsg.timestamp ?: System.currentTimeMillis(),
|
||||
isStreaming = false,
|
||||
shouldNotify = isFirst,
|
||||
)
|
||||
isFirst = false
|
||||
}
|
||||
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
|
||||
cleanupWrappingResponse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cleanupWrappingResponse() {
|
||||
val respId = lastResponseId ?: return
|
||||
val respContent = lastResponseContent ?: return
|
||||
val timeSinceResponse = System.currentTimeMillis() - lastResponseTime
|
||||
if (timeSinceResponse > 5000 || recentParsedContents.size < 2) return
|
||||
val allContained = recentParsedContents.all { respContent.contains(it) }
|
||||
if (allContained) {
|
||||
messageDao.deleteById(respId)
|
||||
_messageRemovals.tryEmit(respId)
|
||||
RuntimeLog.chat("dedup", "Cleaned up wrapping response from DB and live state id=$respId")
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitMessage(
|
||||
id: String,
|
||||
sessionId: String,
|
||||
role: String,
|
||||
content: String,
|
||||
msgType: String,
|
||||
isStreaming: Boolean = false,
|
||||
timestamp: Long = System.currentTimeMillis(),
|
||||
shouldNotify: Boolean = false,
|
||||
imageDataUris: List<String> = emptyList(),
|
||||
) {
|
||||
if (content.isBlank() && msgType == "chat" && imageDataUris.isEmpty()) return
|
||||
|
||||
// Fallback: detect inline <action> tags missed by server parsing
|
||||
if (role == "assistant" && msgType == "chat") {
|
||||
val actionRegex = Regex("""<action>(.*?)</action>\s*""")
|
||||
val match = actionRegex.find(content)
|
||||
if (match != null) {
|
||||
val actionText = match.groupValues[1].trim()
|
||||
val remaining = actionRegex.replaceFirst(content, "").trim()
|
||||
RuntimeLog.chat("receive", "Split inline <action> from chat: action='${actionText.take(40)}' remaining='${remaining.take(40)}'")
|
||||
if (actionText.isNotEmpty()) {
|
||||
emitMessage(
|
||||
id = "${id}_action",
|
||||
sessionId = sessionId,
|
||||
role = "assistant",
|
||||
content = actionText,
|
||||
msgType = "action",
|
||||
timestamp = timestamp,
|
||||
shouldNotify = false,
|
||||
)
|
||||
}
|
||||
if (remaining.isNotEmpty()) {
|
||||
emitMessage(
|
||||
id = id,
|
||||
sessionId = sessionId,
|
||||
role = role,
|
||||
content = remaining,
|
||||
msgType = msgType,
|
||||
timestamp = timestamp + 1,
|
||||
isStreaming = isStreaming,
|
||||
shouldNotify = shouldNotify,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val message = Message(
|
||||
id = id,
|
||||
conversationId = sessionId,
|
||||
role = role,
|
||||
content = content,
|
||||
msgType = msgType,
|
||||
timestamp = timestamp,
|
||||
isStreaming = isStreaming,
|
||||
imageDataUris = imageDataUris,
|
||||
)
|
||||
_incomingMessages.tryEmit(message)
|
||||
|
||||
if (shouldNotify && role == "assistant" && !isStreaming) {
|
||||
if (!hasEverBeenForeground) {
|
||||
RuntimeLog.notify("skip", "Not showing notification for $id: app has never been foregrounded")
|
||||
} else if (isAppInForeground) {
|
||||
RuntimeLog.notify("skip", "Not showing notification for $id: app is in foreground")
|
||||
} else {
|
||||
if (notifiedMessageIds.add(id)) {
|
||||
notificationHelper.showMessageNotification(message)
|
||||
RuntimeLog.notify("show", "Notification sent: id=$id content='${content.take(40)}'")
|
||||
} else {
|
||||
RuntimeLog.notify("dup", "Notification already sent for $id, skipping")
|
||||
}
|
||||
}
|
||||
} else if (shouldNotify && role == "assistant" && isStreaming) {
|
||||
RuntimeLog.notify("skip", "Not showing notification for $id: still streaming")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove wrapper messages whose content contains the content of 2+ other messages.
|
||||
* This handles the case where the server sends both a combined "response" and
|
||||
* parsed "review"/"multi_message" items for the same turn.
|
||||
*/
|
||||
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
|
||||
if (size < 3) return this
|
||||
val toRemove = mutableSetOf<String>()
|
||||
for (msg in this) {
|
||||
val containedCount = count { other ->
|
||||
other.id != msg.id &&
|
||||
other.content.isNotBlank() &&
|
||||
other.content.length < msg.content.length &&
|
||||
msg.content.contains(other.content) &&
|
||||
kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
|
||||
}
|
||||
if (containedCount >= 2) {
|
||||
toRemove.add(msg.id)
|
||||
}
|
||||
}
|
||||
return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
|
||||
}
|
||||
|
||||
private fun ConversationEntity.toDomain() = Conversation(
|
||||
id = id,
|
||||
title = title,
|
||||
lastMessage = lastMessage,
|
||||
lastMessageType = lastMessageType,
|
||||
updatedAt = updatedAt,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
/**
|
||||
* Split inline `<action>` tags from assistant chat messages into separate messages.
|
||||
* Used for bulk-loaded messages (HTTP history, WS history_response) that bypass emitMessage.
|
||||
*/
|
||||
private fun List<Message>.splitInlineActions(): List<Message> {
|
||||
val actionRegex = Regex("""<action>(.*?)</action>\s*""")
|
||||
return flatMap { msg ->
|
||||
if (msg.role == "assistant" && msg.msgType == "chat") {
|
||||
val match = actionRegex.find(msg.content)
|
||||
if (match != null) {
|
||||
val actionText = match.groupValues[1].trim()
|
||||
val remaining = actionRegex.replaceFirst(msg.content, "").trim()
|
||||
val result = mutableListOf<Message>()
|
||||
if (actionText.isNotEmpty()) {
|
||||
result.add(msg.copy(id = "${msg.id}_action", content = actionText, msgType = "action"))
|
||||
}
|
||||
if (remaining.isNotEmpty()) {
|
||||
result.add(msg.copy(content = remaining))
|
||||
}
|
||||
if (result.isEmpty()) listOf(msg) else result
|
||||
} else {
|
||||
listOf(msg)
|
||||
}
|
||||
} else {
|
||||
listOf(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MessageEntity.toDomain() = Message(
|
||||
id = id,
|
||||
conversationId = conversationId,
|
||||
role = role,
|
||||
content = content,
|
||||
msgType = msgType,
|
||||
timestamp = timestamp,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import top.yeij.cyrene.data.remote.ApiService
|
||||
import top.yeij.cyrene.data.remote.dto.IoTControlRequest
|
||||
import top.yeij.cyrene.domain.model.Device
|
||||
import top.yeij.cyrene.domain.model.DeviceState
|
||||
import top.yeij.cyrene.domain.model.DeviceType
|
||||
import top.yeij.cyrene.domain.repository.IoTRepository
|
||||
|
||||
class IoTRepositoryImpl(
|
||||
private val apiService: ApiService,
|
||||
private val webSocketService: top.yeij.cyrene.service.WebSocketService,
|
||||
) : IoTRepository {
|
||||
|
||||
private val _devices = MutableStateFlow<List<Device>>(emptyList())
|
||||
override fun getDevices(): Flow<List<Device>> = _devices.asStateFlow()
|
||||
|
||||
override suspend fun controlDevice(
|
||||
deviceId: String,
|
||||
action: String,
|
||||
value: Any?,
|
||||
): Result<Device> {
|
||||
return try {
|
||||
val response = apiService.controlDevice(deviceId, IoTControlRequest(action, value))
|
||||
if (response.isSuccessful) {
|
||||
val updated = response.body()!!.toDomain()
|
||||
_devices.value = _devices.value.map {
|
||||
if (it.id == deviceId) updated else it
|
||||
}
|
||||
Result.success(updated)
|
||||
} else {
|
||||
Result.failure(Exception("设备控制失败: ${response.code()}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun refreshDevices() {
|
||||
try {
|
||||
val response = apiService.getDevices()
|
||||
if (response.isSuccessful) {
|
||||
_devices.value = response.body()!!.map { it.toDomain() }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
private fun top.yeij.cyrene.data.remote.dto.DeviceDto.toDomain() = Device(
|
||||
id = id,
|
||||
name = name,
|
||||
type = try {
|
||||
DeviceType.valueOf(type.uppercase())
|
||||
} catch (_: Exception) {
|
||||
DeviceType.UNKNOWN
|
||||
},
|
||||
state = DeviceState(
|
||||
power = state.power,
|
||||
brightness = state.brightness,
|
||||
temperature = state.temperature,
|
||||
humidity = state.humidity,
|
||||
colorTemp = state.colorTemp,
|
||||
locked = state.locked,
|
||||
open = state.open,
|
||||
),
|
||||
room = room,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package top.yeij.cyrene.di
|
||||
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import top.yeij.cyrene.data.local.AppDatabase
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.ApiService
|
||||
import top.yeij.cyrene.data.remote.AuthInterceptor
|
||||
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||
import top.yeij.cyrene.data.remote.RetrofitClient
|
||||
import top.yeij.cyrene.data.remote.TokenAuthenticator
|
||||
import top.yeij.cyrene.data.repository.AuthRepositoryImpl
|
||||
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||
import top.yeij.cyrene.data.repository.IoTRepositoryImpl
|
||||
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import top.yeij.cyrene.domain.repository.IoTRepository
|
||||
import top.yeij.cyrene.domain.usecase.GetConversationsUseCase
|
||||
import top.yeij.cyrene.domain.usecase.LoginUseCase
|
||||
import top.yeij.cyrene.domain.usecase.SendMessageUseCase
|
||||
import top.yeij.cyrene.service.WebSocketService
|
||||
import top.yeij.cyrene.viewmodel.ChatViewModel
|
||||
import top.yeij.cyrene.viewmodel.IoTViewModel
|
||||
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||
import top.yeij.cyrene.viewmodel.ProfileViewModel
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
import top.yeij.cyrene.util.NotificationHelper
|
||||
import top.yeij.cyrene.util.VoiceRecorder
|
||||
import top.yeij.cyrene.voice.stt.BackendSttProvider
|
||||
import top.yeij.cyrene.voice.stt.DashScopeSttService
|
||||
import top.yeij.cyrene.voice.stt.SpeechRecognizer
|
||||
import top.yeij.cyrene.voice.stt.SttManager
|
||||
import top.yeij.cyrene.voice.tts.TextToSpeechEngine
|
||||
|
||||
val appModule = module {
|
||||
|
||||
// Notifications
|
||||
single { NotificationHelper(androidContext()) }
|
||||
|
||||
// DataStore
|
||||
single { PreferencesDataStore(androidContext()) }
|
||||
|
||||
// Database
|
||||
single { AppDatabase.getInstance(androidContext()) }
|
||||
single { get<AppDatabase>().conversationDao() }
|
||||
single { get<AppDatabase>().messageDao() }
|
||||
|
||||
// Network interceptors
|
||||
single { AuthInterceptor() }
|
||||
single { DynamicUrlInterceptor() }
|
||||
single { TokenAuthenticator(get(), get(), get()) }
|
||||
single { RetrofitClient.provideOkHttpClient(get(), get(), get()) }
|
||||
single { RetrofitClient.provideRetrofit(get()) }
|
||||
single { get<retrofit2.Retrofit>().create(ApiService::class.java) }
|
||||
|
||||
// WebSocket
|
||||
single { WebSocketService(get()) }
|
||||
|
||||
// Voice
|
||||
single { VoiceRecorder(androidContext()) }
|
||||
single { SpeechRecognizer(androidContext()) }
|
||||
single { TextToSpeechEngine(androidContext()) }
|
||||
single { DashScopeSttService(androidContext()) }
|
||||
single { BackendSttProvider(androidContext(), get()) }
|
||||
single { SttManager(get(), get(), get()) }
|
||||
|
||||
// Repositories
|
||||
single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) }
|
||||
single<ChatRepository> { ChatRepositoryImpl(androidContext() as android.app.Application, get(), get(), get(), get(), get(), get()) }
|
||||
single<IoTRepository> { IoTRepositoryImpl(get(), get()) }
|
||||
|
||||
// UseCases
|
||||
factory { LoginUseCase(get()) }
|
||||
factory { SendMessageUseCase(get()) }
|
||||
factory { GetConversationsUseCase(get()) }
|
||||
|
||||
// ViewModels
|
||||
viewModel { ChatViewModel(androidContext() as android.app.Application, get(), get(), get(), get()) }
|
||||
viewModel { IoTViewModel(get()) }
|
||||
viewModel { OverlayViewModel(get(), get(), get()) }
|
||||
viewModel { ProfileViewModel(get(), get(), get()) }
|
||||
single { SettingsViewModel(get(), get(), get(), get(), get()) }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package top.yeij.cyrene.domain.model
|
||||
|
||||
data class AuthResult(
|
||||
val token: String,
|
||||
val refreshToken: String?,
|
||||
val username: String,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package top.yeij.cyrene.domain.model
|
||||
|
||||
data class Conversation(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val lastMessage: String?,
|
||||
val lastMessageType: String?,
|
||||
val updatedAt: Long,
|
||||
val createdAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package top.yeij.cyrene.domain.model
|
||||
|
||||
data class Device(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val type: DeviceType,
|
||||
val state: DeviceState,
|
||||
val room: String?,
|
||||
)
|
||||
|
||||
enum class DeviceType {
|
||||
LIGHT,
|
||||
AC,
|
||||
CURTAIN,
|
||||
SENSOR,
|
||||
DOOR_LOCK,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
data class DeviceState(
|
||||
val power: Boolean? = null,
|
||||
val brightness: Int? = null,
|
||||
val temperature: Float? = null,
|
||||
val humidity: Float? = null,
|
||||
val colorTemp: Int? = null,
|
||||
val locked: Boolean? = null,
|
||||
val open: Boolean? = null,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package top.yeij.cyrene.domain.model
|
||||
|
||||
data class Message(
|
||||
val id: String,
|
||||
val conversationId: String,
|
||||
val role: String,
|
||||
val content: String,
|
||||
val msgType: String,
|
||||
val timestamp: Long,
|
||||
val isStreaming: Boolean = false,
|
||||
val imageDataUris: List<String> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package top.yeij.cyrene.domain.repository
|
||||
|
||||
import top.yeij.cyrene.domain.model.AuthResult
|
||||
|
||||
interface AuthRepository {
|
||||
|
||||
suspend fun login(username: String, password: String): Result<AuthResult>
|
||||
|
||||
suspend fun logout()
|
||||
|
||||
suspend fun isLoggedIn(): Boolean
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package top.yeij.cyrene.domain.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import top.yeij.cyrene.domain.model.Conversation
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
|
||||
interface ChatRepository {
|
||||
|
||||
val connectionState: StateFlow<Boolean>
|
||||
val connectionError: StateFlow<String?>
|
||||
val isAssistantStreaming: StateFlow<Boolean>
|
||||
val messageClearEvents: Flow<Unit>
|
||||
val messageRemovals: Flow<String>
|
||||
var currentSessionId: String?
|
||||
|
||||
fun getConversations(): Flow<List<Conversation>>
|
||||
|
||||
suspend fun getMessages(conversationId: String): Flow<List<Message>>
|
||||
|
||||
suspend fun deleteConversation(id: String)
|
||||
|
||||
suspend fun connectWebSocket(sessionId: String?)
|
||||
|
||||
suspend fun sendMessage(content: String, sessionId: String?, attachments: List<top.yeij.cyrene.data.remote.dto.WSAttachment>? = null, localImageUris: List<String> = emptyList())
|
||||
|
||||
fun observeMessages(): Flow<Message>
|
||||
|
||||
suspend fun loadConversationsFromServer()
|
||||
|
||||
suspend fun loadMessagesFromServer(sessionId: String): List<Message>
|
||||
|
||||
suspend fun initializeSession(): String
|
||||
|
||||
suspend fun reconnectWebSocket()
|
||||
|
||||
suspend fun ensureConnected()
|
||||
|
||||
suspend fun sendScreenContext(content: String)
|
||||
|
||||
suspend fun sendVoiceInput(audioBase64: String, mode: String = "voice_msg")
|
||||
|
||||
suspend fun clearLocalMessages()
|
||||
|
||||
fun onAppForeground()
|
||||
fun onAppBackground()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package top.yeij.cyrene.domain.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import top.yeij.cyrene.domain.model.Device
|
||||
|
||||
interface IoTRepository {
|
||||
|
||||
fun getDevices(): Flow<List<Device>>
|
||||
|
||||
suspend fun controlDevice(deviceId: String, action: String, value: Any? = null): Result<Device>
|
||||
|
||||
suspend fun refreshDevices()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package top.yeij.cyrene.domain.usecase
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import top.yeij.cyrene.domain.model.Conversation
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
|
||||
class GetConversationsUseCase(
|
||||
private val chatRepository: ChatRepository,
|
||||
) {
|
||||
operator fun invoke(): Flow<List<Conversation>> = chatRepository.getConversations()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package top.yeij.cyrene.domain.usecase
|
||||
|
||||
import top.yeij.cyrene.domain.model.AuthResult
|
||||
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||
|
||||
class LoginUseCase(
|
||||
private val authRepository: AuthRepository,
|
||||
) {
|
||||
suspend operator fun invoke(username: String, password: String): Result<AuthResult> {
|
||||
if (username.isBlank() || password.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("用户名和密码不能为空"))
|
||||
}
|
||||
return authRepository.login(username, password)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package top.yeij.cyrene.domain.usecase
|
||||
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
|
||||
class SendMessageUseCase(
|
||||
private val chatRepository: ChatRepository,
|
||||
) {
|
||||
suspend operator fun invoke(content: String, sessionId: String? = null) {
|
||||
if (content.isNotBlank()) {
|
||||
chatRepository.sendMessage(content, sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.context.GlobalContext
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
Log.i(TAG, "Boot completed, restoring background connection")
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
// Wait a moment for Koin to initialize
|
||||
kotlinx.coroutines.delay(5000)
|
||||
|
||||
val prefs: PreferencesDataStore = GlobalContext.get().get()
|
||||
val token = prefs.token.firstOrNull()
|
||||
if (token.isNullOrBlank()) {
|
||||
Log.i(TAG, "No auth token, skipping auto-connect")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Start keep-alive service
|
||||
WebSocketKeepAliveService.start(context)
|
||||
|
||||
// Always reconnect — session may be stale
|
||||
val repo: ChatRepositoryImpl = GlobalContext.get().get()
|
||||
repo.ensureConnected()
|
||||
Log.i(TAG, "Boot connection restored, connected=${repo.connectionState.value}")
|
||||
|
||||
// Schedule periodic wake-up
|
||||
KeepAliveReceiver.schedule(context)
|
||||
|
||||
Log.i(TAG, "Background connection restored")
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to restore connection on boot: ${e.message}", e)
|
||||
// Fallback: still try to start the service
|
||||
try { WebSocketKeepAliveService.start(context) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneBoot"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
|
||||
class CyreneAccessibilityService : AccessibilityService() {
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: CyreneAccessibilityService? = null
|
||||
|
||||
fun getScreenContent(): String {
|
||||
return instance?.captureScreenContent() ?: ""
|
||||
}
|
||||
|
||||
fun isRunning(): Boolean = instance != null
|
||||
}
|
||||
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
instance = this
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {}
|
||||
|
||||
override fun onInterrupt() {}
|
||||
|
||||
override fun onDestroy() {
|
||||
instance = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun captureScreenContent(): String {
|
||||
val root = rootInActiveWindow ?: return ""
|
||||
return try {
|
||||
val sb = StringBuilder()
|
||||
collectNodeText(root, sb, depth = 0)
|
||||
sb.toString().trim()
|
||||
} finally {
|
||||
root.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectNodeText(node: AccessibilityNodeInfo, sb: StringBuilder, depth: Int) {
|
||||
// Skip the root decor view itself content, and limit depth
|
||||
if (depth > 16) return
|
||||
|
||||
val text = node.text?.toString()?.trim()
|
||||
val contentDesc = node.contentDescription?.toString()?.trim()
|
||||
|
||||
if (!text.isNullOrEmpty() && text != contentDesc) {
|
||||
if (sb.isNotEmpty()) sb.append("\n")
|
||||
sb.append(text)
|
||||
} else if (!contentDesc.isNullOrEmpty() && depth > 0) {
|
||||
// Only include content descriptions for non-root nodes
|
||||
if (sb.isNotEmpty()) sb.append("\n")
|
||||
sb.append(contentDesc)
|
||||
}
|
||||
|
||||
for (i in 0 until node.childCount) {
|
||||
val child = node.getChild(i) ?: continue
|
||||
collectNodeText(child, sb, depth + 1)
|
||||
child.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.service.voice.VoiceInteractionService
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Recognition service for always-on hotword detection.
|
||||
*
|
||||
* In API 36, AlwaysOnHotwordDetector was removed from the public SDK.
|
||||
* The system manages low-level hotword detection internally and invokes
|
||||
* the session service declared in voice_interaction_config.xml when triggered.
|
||||
* For custom hotword models (Porcupine / openWakeWord), integrate via
|
||||
* a foreground service with microphone capture instead.
|
||||
*/
|
||||
class CyreneRecognitionService : VoiceInteractionService() {
|
||||
|
||||
override fun onReady() {
|
||||
super.onReady()
|
||||
isActive = true
|
||||
Log.i(TAG, "Recognition service ready")
|
||||
}
|
||||
|
||||
override fun onShutdown() {
|
||||
isActive = false
|
||||
super.onShutdown()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneRecognition"
|
||||
var isActive: Boolean = false
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.service.voice.VoiceInteractionSessionService
|
||||
import android.util.Log
|
||||
|
||||
class CyreneSessionService : VoiceInteractionSessionService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(TAG, "Session service created")
|
||||
}
|
||||
|
||||
override fun onNewSession(args: Bundle?): CyreneVoiceInteractionSession {
|
||||
return CyreneVoiceInteractionSession(this)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Log.i(TAG, "Session service destroyed")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneSession"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.service.voice.VoiceInteractionService
|
||||
import android.util.Log
|
||||
import top.yeij.cyrene.MainActivity
|
||||
import top.yeij.cyrene.util.Constants
|
||||
|
||||
class CyreneVoiceInteractionService : VoiceInteractionService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(TAG, "Service created")
|
||||
}
|
||||
|
||||
override fun onReady() {
|
||||
super.onReady()
|
||||
isActive = true
|
||||
getSharedPreferences(PREF_NAME, MODE_PRIVATE)
|
||||
.edit().putBoolean(KEY_WAS_ACTIVE, true).apply()
|
||||
Log.i(TAG, "Service ready")
|
||||
}
|
||||
|
||||
override fun onPrepareToShowSession(args: Bundle, showFlags: Int) {
|
||||
Log.i(TAG, "onPrepareToShowSession")
|
||||
}
|
||||
|
||||
override fun onShowSessionFailed(args: Bundle) {
|
||||
Log.e(TAG, "onShowSessionFailed")
|
||||
}
|
||||
|
||||
override fun onLaunchVoiceAssistFromKeyguard() {
|
||||
Log.i(TAG, "onLaunchVoiceAssistFromKeyguard")
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
putExtra(Constants.EXTRA_VOICE_ASSIST, true)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onShutdown() {
|
||||
isActive = false
|
||||
super.onShutdown()
|
||||
Log.i(TAG, "Service shutdown")
|
||||
}
|
||||
|
||||
companion object {
|
||||
var isActive: Boolean = false
|
||||
private set
|
||||
private const val TAG = "CyreneVIS"
|
||||
private const val PREF_NAME = "cyrene_assistant"
|
||||
private const val KEY_WAS_ACTIVE = "was_assistant_active"
|
||||
|
||||
fun wasEverActive(context: android.content.Context): Boolean {
|
||||
return context.getSharedPreferences(PREF_NAME, android.content.Context.MODE_PRIVATE)
|
||||
.getBoolean(KEY_WAS_ACTIVE, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.service.voice.VoiceInteractionSession
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.savedstate.SavedStateRegistry
|
||||
import androidx.savedstate.SavedStateRegistryController
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import android.content.res.Configuration
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.core.context.GlobalContext
|
||||
import top.yeij.cyrene.MainActivity
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.ui.overlay.OverlayContent
|
||||
import top.yeij.cyrene.ui.theme.CyreneTheme
|
||||
import top.yeij.cyrene.util.Constants
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||
|
||||
class CyreneVoiceInteractionSession(context: Context) :
|
||||
VoiceInteractionSession(context), LifecycleOwner, SavedStateRegistryOwner {
|
||||
|
||||
private val lifecycleRegistry = LifecycleRegistry(this)
|
||||
override val lifecycle: Lifecycle get() = lifecycleRegistry
|
||||
|
||||
private val savedStateRegistryController = SavedStateRegistryController.create(this)
|
||||
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
|
||||
|
||||
// Resolved eagerly with fallback — lazy would silently crash composition on failure
|
||||
private var overlayViewModel: OverlayViewModel? = null
|
||||
private set
|
||||
|
||||
init {
|
||||
savedStateRegistryController.performAttach()
|
||||
savedStateRegistryController.performRestore(null)
|
||||
}
|
||||
|
||||
private fun resolveViewModel(): OverlayViewModel? {
|
||||
return try {
|
||||
GlobalContext.get().get<OverlayViewModel>()
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateContentView(): View {
|
||||
Log.d(TAG, "onCreateContentView called")
|
||||
RuntimeLog.general("overlay", "onCreateContentView")
|
||||
overlayViewModel = resolveViewModel()
|
||||
if (overlayViewModel == null) {
|
||||
Log.w(TAG, "ViewModel unavailable — overlay will be static")
|
||||
RuntimeLog.general("overlay", "ViewModel unavailable, overlay static")
|
||||
}
|
||||
|
||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||
val vm = overlayViewModel
|
||||
val session = this@CyreneVoiceInteractionSession
|
||||
|
||||
val (darkTheme, themeColorKey) = runBlocking {
|
||||
val prefs = GlobalContext.get().get<PreferencesDataStore>()
|
||||
val mode = prefs.themeMode.firstOrNull()
|
||||
val color = prefs.themeColor.firstOrNull() ?: "pink"
|
||||
val dark = when (mode) {
|
||||
"light" -> false
|
||||
"dark" -> true
|
||||
else -> {
|
||||
val nightMode = session.context.resources.configuration.uiMode and
|
||||
Configuration.UI_MODE_NIGHT_MASK
|
||||
nightMode == Configuration.UI_MODE_NIGHT_YES
|
||||
}
|
||||
}
|
||||
Pair(dark, color)
|
||||
}
|
||||
|
||||
return ComposeView(context).apply {
|
||||
// Configure window as soon as view is attached — before system overrides flags
|
||||
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
|
||||
override fun onViewAttachedToWindow(v: View) {
|
||||
session.configureWindow()
|
||||
}
|
||||
override fun onViewDetachedFromWindow(v: View) {}
|
||||
})
|
||||
setViewTreeLifecycleOwner(session)
|
||||
setViewTreeSavedStateRegistryOwner(session)
|
||||
setContent {
|
||||
CyreneTheme(
|
||||
darkTheme = darkTheme,
|
||||
presetKey = themeColorKey,
|
||||
useDynamicColor = themeColorKey == "monet",
|
||||
) {
|
||||
if (vm != null) {
|
||||
OverlayContent(
|
||||
onDismiss = { finish() },
|
||||
onNavigateToMain = {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
viewModel = vm,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShow(args: Bundle?, showFlags: Int) {
|
||||
super.onShow(args, showFlags)
|
||||
RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}")
|
||||
lifecycleRegistry.currentState = Lifecycle.State.STARTED
|
||||
|
||||
// Defer window config — system may override softInputMode after onShow returns
|
||||
try {
|
||||
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
|
||||
method.isAccessible = true
|
||||
val w = method.invoke(this) as? android.view.Window
|
||||
w?.decorView?.post { configureWindow() }
|
||||
} catch (e: Throwable) {
|
||||
// Fallback: configure immediately
|
||||
configureWindow()
|
||||
}
|
||||
|
||||
// Only read screen content if user enabled it in settings (default off)
|
||||
val autoScreenContext = try {
|
||||
val prefs: PreferencesDataStore = GlobalContext.get().get()
|
||||
runBlocking { prefs.autoScreenContext.firstOrNull() } ?: false
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
if (autoScreenContext) {
|
||||
val screenContent = CyreneAccessibilityService.getScreenContent()
|
||||
if (screenContent.isNotBlank()) {
|
||||
overlayViewModel?.sendScreenContext(screenContent)
|
||||
RuntimeLog.general("overlay", "Screen context sent, len=${screenContent.length}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun configureWindow() {
|
||||
try {
|
||||
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
|
||||
method.isAccessible = true
|
||||
val w = method.invoke(this) as? android.view.Window ?: return
|
||||
// Transparent window so the underlying screen is visible through the overlay
|
||||
w.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT))
|
||||
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
|
||||
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
|
||||
w.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
Log.d(TAG, "Window configured: transparent bg, translucent status/nav")
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to configure window: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onHide() {
|
||||
RuntimeLog.general("overlay", "onHide")
|
||||
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
|
||||
super.onHide()
|
||||
overlayViewModel?.finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneVIS-Session"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.context.GlobalContext
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
|
||||
class KeepAliveReceiver : BroadcastReceiver() {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "Keep-alive alarm fired")
|
||||
RuntimeLog.notify("keepalive", "Alarm fired in background")
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val prefs: PreferencesDataStore = GlobalContext.get().get()
|
||||
val token = prefs.token.firstOrNull()
|
||||
if (token.isNullOrBlank()) {
|
||||
Log.d(TAG, "No auth token, skipping wake-up")
|
||||
RuntimeLog.notify("keepalive", "Skipping: no auth token")
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (!WebSocketKeepAliveService.isRunning) {
|
||||
WebSocketKeepAliveService.start(context)
|
||||
RuntimeLog.notify("keepalive", "Foreground service restarted")
|
||||
}
|
||||
|
||||
val repo: ChatRepositoryImpl = GlobalContext.get().get()
|
||||
val wasConnected = repo.connectionState.value
|
||||
repo.ensureConnected()
|
||||
RuntimeLog.notify("keepalive", "WS reconnect triggered: wasConnected=$wasConnected nowConnected=${repo.connectionState.value}")
|
||||
|
||||
schedule(context)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Keep-alive check failed: ${e.message}", e)
|
||||
RuntimeLog.notify("keepalive", "Failed: ${e.message}")
|
||||
schedule(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneKeepAlive"
|
||||
|
||||
const val INTERVAL_MS = 5 * 60 * 1000L // 5 minutes
|
||||
|
||||
fun schedule(context: Context) {
|
||||
try {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = Intent(context, KeepAliveReceiver::class.java)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context, 0, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
// Cancel existing alarm first
|
||||
alarmManager.cancel(pendingIntent)
|
||||
|
||||
val triggerAt = System.currentTimeMillis() + INTERVAL_MS
|
||||
if (alarmManager.canScheduleExactAlarms()) {
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent
|
||||
)
|
||||
} else {
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to schedule keep-alive: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
try {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = Intent(context, KeepAliveReceiver::class.java)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context, 0, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
alarmManager.cancel(pendingIntent)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to cancel keep-alive: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import top.yeij.cyrene.MainActivity
|
||||
import top.yeij.cyrene.R
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
|
||||
class WebSocketKeepAliveService : Service() {
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
isRunning = true
|
||||
createChannel()
|
||||
acquireWakeLock()
|
||||
Log.i(TAG, "Service created, wakeLock held")
|
||||
RuntimeLog.notify("keepalive", "WS keep-alive service created, wakeLock acquired")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
isRunning = false
|
||||
releaseWakeLock()
|
||||
scheduleRestart()
|
||||
Log.i(TAG, "Service destroyed, restart scheduled")
|
||||
RuntimeLog.notify("keepalive", "WS keep-alive service destroyed, restart scheduled in ${RESTART_DELAY_MS}ms")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "onStartCommand flags=$flags startId=$startId")
|
||||
startForegroundNotification()
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
// App swiped away from recents — schedule restart and let it die
|
||||
Log.i(TAG, "Task removed, scheduling restart")
|
||||
scheduleRestart()
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
private fun startForegroundNotification() {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("昔涟")
|
||||
.setContentText("后台连接中,可接收消息推送")
|
||||
.setOngoing(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.build()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(NOTIFICATION_ID, notification, 0x40000001 /* dataSync | specialUse */)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
if (wakeLock?.isHeld == true) return
|
||||
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = pm.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"Cyrene:WebSocketKeepAlive"
|
||||
).apply {
|
||||
acquire(10 * 60 * 1000L) // 10 min timeout as safety net
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
try { wakeLock?.release() } catch (_: Exception) { }
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
private fun scheduleRestart() {
|
||||
try {
|
||||
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = Intent(this, KeepAliveReceiver::class.java)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
this, 0, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val triggerAt = System.currentTimeMillis() + RESTART_DELAY_MS
|
||||
if (alarmManager.canScheduleExactAlarms()) {
|
||||
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
|
||||
} else {
|
||||
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to schedule restart: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"连接状态",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "后台连接保活"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneKeepAlive"
|
||||
private const val CHANNEL_ID = "cyrene_keepalive"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
|
||||
@Volatile
|
||||
var isRunning: Boolean = false
|
||||
private set
|
||||
|
||||
fun start(context: Context) {
|
||||
if (isRunning) return
|
||||
context.startForegroundService(
|
||||
Intent(context, WebSocketKeepAliveService::class.java)
|
||||
)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(
|
||||
Intent(context, WebSocketKeepAliveService::class.java)
|
||||
)
|
||||
}
|
||||
|
||||
const val RESTART_DELAY_MS = 60_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.dto.WSAttachment
|
||||
import top.yeij.cyrene.data.remote.dto.WSClientMessage
|
||||
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class WebSocketService(
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.pingInterval(25, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val gson = Gson()
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private var heartbeatJob: Job? = null
|
||||
private var reconnectJob: Job? = null
|
||||
private var shouldReconnect = true
|
||||
private var currentSessionId: String? = null
|
||||
private val connectionId = AtomicInteger(0)
|
||||
@Volatile private var lastMessageReceived = System.currentTimeMillis()
|
||||
private val deadConnectionTimeoutMs = 30_000L // No message for 30s = treat as dead
|
||||
|
||||
private var clientId: String = ""
|
||||
private var deviceName: String = ""
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
private val _connectionError = MutableStateFlow<String?>(null)
|
||||
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
|
||||
|
||||
private val _incomingMessages = MutableSharedFlow<WSServerMessage>(extraBufferCapacity = 64)
|
||||
val incomingMessages: SharedFlow<WSServerMessage> = _incomingMessages.asSharedFlow()
|
||||
|
||||
private suspend fun initClientIdentity() {
|
||||
clientId = preferencesDataStore.clientId.firstOrNull() ?: run {
|
||||
val id = "cl_" + System.currentTimeMillis().toString(36) + "_" +
|
||||
(100000..999999).random().toString(36)
|
||||
preferencesDataStore.saveClientId(id)
|
||||
id
|
||||
}
|
||||
deviceName = preferencesDataStore.deviceName.firstOrNull() ?: run {
|
||||
val name = "Android " + (Build.MODEL ?: "Device")
|
||||
preferencesDataStore.saveDeviceName(name)
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
fun getClientId(): String = clientId
|
||||
fun getDeviceName(): String = deviceName
|
||||
|
||||
suspend fun connect(sessionId: String? = null) {
|
||||
currentSessionId = sessionId
|
||||
shouldReconnect = true
|
||||
|
||||
initClientIdentity()
|
||||
|
||||
val baseUrl = preferencesDataStore.baseUrl.firstOrNull()
|
||||
?: "http://10.0.2.2:8080"
|
||||
val token = preferencesDataStore.token.firstOrNull() ?: ""
|
||||
val wsBase = baseUrl
|
||||
.replace("https://", "wss://")
|
||||
.replace("http://", "ws://")
|
||||
.trimEnd('/')
|
||||
val urlBuilder = StringBuilder("$wsBase/ws/chat")
|
||||
val params = mutableListOf<String>()
|
||||
if (token.isNotBlank()) {
|
||||
params.add("token=" + encode(token))
|
||||
}
|
||||
sessionId?.let {
|
||||
if (it.isNotBlank()) params.add("session_id=" + encode(it))
|
||||
}
|
||||
if (clientId.isNotBlank()) {
|
||||
params.add("client_id=" + encode(clientId))
|
||||
}
|
||||
if (deviceName.isNotBlank()) {
|
||||
params.add("device_name=" + encode(deviceName))
|
||||
}
|
||||
if (params.isNotEmpty()) {
|
||||
urlBuilder.append("?")
|
||||
urlBuilder.append(params.joinToString("&"))
|
||||
}
|
||||
|
||||
val url = urlBuilder.toString()
|
||||
val connId = connectionId.incrementAndGet()
|
||||
Log.i(TAG, "[#$connId] Connecting to $url")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("User-Agent", "Cyrene-Android/${Build.MODEL ?: "Device"}")
|
||||
.build()
|
||||
|
||||
// Close previous socket silently
|
||||
try { webSocket?.close(1000, "Reconnecting") } catch (_: Exception) { }
|
||||
cancelHeartbeat()
|
||||
|
||||
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
if (connectionId.get() != connId) {
|
||||
Log.d(TAG, "[#$connId] onOpen ignored (stale)")
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "[#$connId] Connected")
|
||||
_isConnected.value = true
|
||||
_connectionError.value = null
|
||||
startHeartbeat()
|
||||
RuntimeLog.ws("lifecycle", "WS connected #$connId")
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (connectionId.get() != connId) return
|
||||
lastMessageReceived = System.currentTimeMillis()
|
||||
try {
|
||||
val msg = gson.fromJson(text, WSServerMessage::class.java)
|
||||
val preview = text.take(100).replace("\n", "\\n")
|
||||
RuntimeLog.ws("receive", "type=${msg.type} id=${msg.messageId ?: "-"} preview=$preview")
|
||||
_incomingMessages.tryEmit(msg)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "[#$connId] Failed to parse message: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (connectionId.get() != connId) return
|
||||
Log.i(TAG, "[#$connId] Server closing: code=$code reason=$reason")
|
||||
_isConnected.value = false
|
||||
cancelHeartbeat()
|
||||
RuntimeLog.ws("lifecycle", "WS closing #$connId code=$code reason='$reason'")
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (connectionId.get() != connId) return
|
||||
Log.i(TAG, "[#$connId] Closed: code=$code reason=$reason")
|
||||
_isConnected.value = false
|
||||
cancelHeartbeat()
|
||||
scheduleReconnect()
|
||||
RuntimeLog.ws("lifecycle", "WS closed #$connId code=$code")
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
if (connectionId.get() != connId) return
|
||||
val httpCode = response?.code
|
||||
Log.e(TAG, "[#$connId] Failure: ${t.message} (http=$httpCode)", t)
|
||||
_isConnected.value = false
|
||||
cancelHeartbeat()
|
||||
RuntimeLog.ws("lifecycle", "WS failure #$connId http=$httpCode error='${t.message}'")
|
||||
|
||||
val errorMsg = when (httpCode) {
|
||||
403 -> {
|
||||
Log.e(TAG, "[#$connId] WebSocket 403: 仅管理员用户可连接。请使用管理员账户登录。")
|
||||
"仅管理员用户可连接"
|
||||
}
|
||||
401 -> "认证失败,请重新登录"
|
||||
else -> null
|
||||
}
|
||||
if (errorMsg != null) {
|
||||
_connectionError.value = errorMsg
|
||||
}
|
||||
// onClosed may or may not follow — schedule reconnect directly
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
|
||||
|
||||
private fun buildMessage(
|
||||
type: String,
|
||||
sessionId: String? = null,
|
||||
mode: String? = null,
|
||||
content: String? = null,
|
||||
attachments: List<WSAttachment>? = null,
|
||||
): WSClientMessage = WSClientMessage(
|
||||
type = type,
|
||||
sessionId = sessionId ?: currentSessionId,
|
||||
mode = mode,
|
||||
content = content,
|
||||
attachments = attachments,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
clientId = clientId.ifBlank { null },
|
||||
deviceName = deviceName.ifBlank { null },
|
||||
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
|
||||
)
|
||||
|
||||
fun sendMessage(content: String, sessionId: String? = null, mode: String = "text", attachments: List<WSAttachment>? = null) {
|
||||
val msg = buildMessage("message", sessionId, mode, content, attachments = attachments)
|
||||
webSocket?.send(gson.toJson(msg))
|
||||
}
|
||||
|
||||
fun requestHistory(sessionId: String?) {
|
||||
val msg = buildMessage("history", sessionId)
|
||||
if (webSocket != null) {
|
||||
webSocket?.send(gson.toJson(msg))
|
||||
Log.i(TAG, "History requested for session=$sessionId")
|
||||
} else {
|
||||
Log.w(TAG, "Cannot request history: WebSocket is null")
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPing() {
|
||||
val msg = buildMessage("ping")
|
||||
webSocket?.send(gson.toJson(msg))
|
||||
}
|
||||
|
||||
fun sendScreenContext(content: String, sessionId: String? = null) {
|
||||
val msg = buildMessage("message", sessionId, mode = "text", content = content)
|
||||
webSocket?.send(gson.toJson(msg))
|
||||
}
|
||||
|
||||
fun sendVoiceInput(audioBase64: String, sessionId: String? = null, mode: String = "voice_msg") {
|
||||
val msg = WSClientMessage(
|
||||
type = "voice_input",
|
||||
sessionId = sessionId ?: currentSessionId,
|
||||
mode = mode,
|
||||
audioData = audioBase64,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
clientId = clientId.ifBlank { null },
|
||||
deviceName = deviceName.ifBlank { null },
|
||||
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
|
||||
)
|
||||
webSocket?.send(gson.toJson(msg))
|
||||
}
|
||||
|
||||
fun forceReconnect() {
|
||||
RuntimeLog.ws("lifecycle", "forceReconnect called")
|
||||
shouldReconnect = true
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
scope.launch {
|
||||
try {
|
||||
// Close existing socket directly without resetting shouldReconnect
|
||||
cancelHeartbeat()
|
||||
webSocket?.close(1000, "Reconnecting")
|
||||
webSocket = null
|
||||
_isConnected.value = false
|
||||
connect(currentSessionId)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
RuntimeLog.ws("lifecycle", "WS disconnect — user requested")
|
||||
shouldReconnect = false
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
cancelHeartbeat()
|
||||
try { webSocket?.close(1000, "User disconnected") } catch (_: Exception) { }
|
||||
webSocket = null
|
||||
_isConnected.value = false
|
||||
}
|
||||
|
||||
private fun startHeartbeat() {
|
||||
cancelHeartbeat()
|
||||
heartbeatJob = scope.launch {
|
||||
while (_isConnected.value) {
|
||||
delay(15_000)
|
||||
if (!_isConnected.value) break
|
||||
sendPing()
|
||||
// Check if connection is silently dead (no message received in 60s)
|
||||
val sinceLastMsg = System.currentTimeMillis() - lastMessageReceived
|
||||
if (sinceLastMsg > deadConnectionTimeoutMs) {
|
||||
Log.w(TAG, "No message received for ${sinceLastMsg}ms — connection may be dead, forcing reconnect")
|
||||
forceReconnect()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelHeartbeat() {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob = null
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (reconnectJob?.isActive == true || !shouldReconnect) return
|
||||
reconnectJob = scope.launch {
|
||||
var attempt = 0
|
||||
while (shouldReconnect && !_isConnected.value) {
|
||||
val delayMs = minOf(
|
||||
(Math.pow(2.0, attempt.toDouble()) * 1000).toLong(),
|
||||
30_000L
|
||||
)
|
||||
Log.i(TAG, "Reconnecting in ${delayMs}ms (attempt ${attempt + 1})")
|
||||
delay(delayMs)
|
||||
attempt++
|
||||
if (shouldReconnect && !_isConnected.value) {
|
||||
try {
|
||||
connect(currentSessionId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Reconnect attempt $attempt failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Reconnect loop ended (connected=${_isConnected.value}, shouldReconnect=$shouldReconnect)")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneWS"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
package top.yeij.cyrene.ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
// --- Markdown block model ---
|
||||
|
||||
private sealed class MdBlock {
|
||||
data class Heading(val level: Int, val text: String) : MdBlock()
|
||||
data class Paragraph(val text: String) : MdBlock()
|
||||
data class CodeBlock(val language: String?, val code: String) : MdBlock()
|
||||
data class ListItem(val ordered: Boolean, val index: Int, val text: String) : MdBlock()
|
||||
data class Quote(val text: String) : MdBlock()
|
||||
class ThematicBreak : MdBlock()
|
||||
}
|
||||
|
||||
private fun parseMarkdownBlocks(text: String): List<MdBlock> {
|
||||
val lines = text.lines()
|
||||
val blocks = mutableListOf<MdBlock>()
|
||||
var i = 0
|
||||
|
||||
while (i < lines.size) {
|
||||
val line = lines[i]
|
||||
val trimmed = line.trimStart()
|
||||
|
||||
when {
|
||||
// Fenced code block
|
||||
trimmed.startsWith("```") -> {
|
||||
val lang = trimmed.removePrefix("```").trim().ifBlank { null }
|
||||
val codeLines = mutableListOf<String>()
|
||||
i++
|
||||
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
|
||||
codeLines.add(lines[i])
|
||||
i++
|
||||
}
|
||||
if (i < lines.size) i++ // skip closing ```
|
||||
blocks.add(MdBlock.CodeBlock(lang, codeLines.joinToString("\n")))
|
||||
}
|
||||
// Heading
|
||||
trimmed.startsWith("#") -> {
|
||||
val match = Regex("^(#{1,6})\\s+(.+)$").find(trimmed)
|
||||
if (match != null) {
|
||||
val level = match.groupValues[1].length
|
||||
blocks.add(MdBlock.Heading(level, match.groupValues[2]))
|
||||
}
|
||||
i++
|
||||
}
|
||||
// Thematic break
|
||||
trimmed.matches(Regex("^[-*_]{3,}$")) -> {
|
||||
blocks.add(MdBlock.ThematicBreak())
|
||||
i++
|
||||
}
|
||||
// Blockquote
|
||||
line.startsWith(">") || trimmed.startsWith(">") -> {
|
||||
val quoteLines = mutableListOf<String>()
|
||||
while (i < lines.size) {
|
||||
val cur = lines[i]
|
||||
if (cur.trimStart().startsWith(">")) {
|
||||
quoteLines.add(cur.trimStart().removePrefix(">").trimStart())
|
||||
i++
|
||||
} else if (cur.isBlank()) {
|
||||
i++
|
||||
break
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (quoteLines.isNotEmpty()) {
|
||||
blocks.add(MdBlock.Quote(quoteLines.joinToString("\n")))
|
||||
}
|
||||
}
|
||||
// Unordered list
|
||||
trimmed.matches(Regex("^[-*+]\\s+.*$")) -> {
|
||||
while (i < lines.size && lines[i].trimStart().matches(Regex("^[-*+]\\s+.*$"))) {
|
||||
val itemText = lines[i].trimStart().replaceFirst(Regex("^[-*+]\\s+"), "")
|
||||
blocks.add(MdBlock.ListItem(false, blocks.size + 1, itemText))
|
||||
i++
|
||||
}
|
||||
}
|
||||
// Ordered list
|
||||
trimmed.matches(Regex("^\\d+\\.\\s+.*$")) -> {
|
||||
var idx = 1
|
||||
while (i < lines.size && lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*$"))) {
|
||||
val itemText = lines[i].trimStart().replaceFirst(Regex("^\\d+\\.\\s+"), "")
|
||||
blocks.add(MdBlock.ListItem(true, idx, itemText))
|
||||
idx++
|
||||
i++
|
||||
}
|
||||
}
|
||||
// Blank line — skip
|
||||
line.isBlank() -> { i++ }
|
||||
// Paragraph
|
||||
else -> {
|
||||
val paraLines = mutableListOf<String>()
|
||||
while (i < lines.size &&
|
||||
lines[i].isNotBlank() &&
|
||||
!lines[i].trimStart().startsWith("```") &&
|
||||
!lines[i].trimStart().startsWith("#") &&
|
||||
!lines[i].trimStart().matches(Regex("^[-*_]{3,}$")) &&
|
||||
!lines[i].trimStart().startsWith(">") &&
|
||||
!lines[i].trimStart().matches(Regex("^[-*+]\\s+.*$")) &&
|
||||
!lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*$"))
|
||||
) {
|
||||
paraLines.add(lines[i])
|
||||
i++
|
||||
}
|
||||
if (paraLines.isNotEmpty()) {
|
||||
blocks.add(MdBlock.Paragraph(paraLines.joinToString(" ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun renderInlineMarkdown(text: String): AnnotatedString {
|
||||
return buildAnnotatedString {
|
||||
var remaining = text
|
||||
while (remaining.isNotEmpty()) {
|
||||
// Bold + Italic ***
|
||||
val boldItalic = Regex("""\*\*\*(.+?)\*\*\*""").find(remaining)
|
||||
// Bold **
|
||||
val bold = Regex("""\*\*(.+?)\*\*""").find(remaining)
|
||||
// Italic *
|
||||
val italic = Regex("""(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)""").find(remaining)
|
||||
// Inline code `
|
||||
val code = Regex("""`([^`]+)`""").find(remaining)
|
||||
// Link [text](url)
|
||||
val link = Regex("""\[([^\]]+)\]\(([^)]+)\)""").find(remaining)
|
||||
|
||||
val matches = listOfNotNull(
|
||||
boldItalic?.let { "bi" to it },
|
||||
bold?.let { "b" to it },
|
||||
italic?.let { "i" to it },
|
||||
code?.let { "c" to it },
|
||||
link?.let { "l" to it },
|
||||
).sortedBy { it.second.range.first }
|
||||
|
||||
if (matches.isEmpty()) {
|
||||
append(remaining)
|
||||
remaining = ""
|
||||
} else {
|
||||
val (kind, match) = matches.first()
|
||||
// Append text before the match
|
||||
if (match.range.first > 0) {
|
||||
append(remaining.substring(0, match.range.first))
|
||||
}
|
||||
when (kind) {
|
||||
"bi" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
|
||||
append(match.groupValues[1])
|
||||
}
|
||||
"b" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(match.groupValues[1])
|
||||
}
|
||||
"i" -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
append(match.groupValues[1])
|
||||
}
|
||||
"c" -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = Color.Gray.copy(alpha = 0.2f))) {
|
||||
append(match.groupValues[1])
|
||||
}
|
||||
"l" -> {
|
||||
val label = match.groupValues[1]
|
||||
val url = match.groupValues[2]
|
||||
pushStringAnnotation("url", url)
|
||||
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline)) {
|
||||
append(label)
|
||||
}
|
||||
pop()
|
||||
}
|
||||
}
|
||||
remaining = remaining.substring(match.range.last + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Markdown bubble ---
|
||||
|
||||
@Composable
|
||||
private fun MarkdownBubble(content: String, modifier: Modifier = Modifier) {
|
||||
val blocks = remember(content) { parseMarkdownBlocks(content) }
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f),
|
||||
shadowElevation = 1.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
blocks.forEach { block ->
|
||||
when (block) {
|
||||
is MdBlock.Heading -> {
|
||||
val fontSize = when (block.level) {
|
||||
1 -> 22.sp
|
||||
2 -> 19.sp
|
||||
3 -> 17.sp
|
||||
4 -> 15.sp
|
||||
5 -> 14.sp
|
||||
else -> 13.sp
|
||||
}
|
||||
Text(
|
||||
text = renderInlineMarkdown(block.text),
|
||||
fontSize = fontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = if (block.level <= 2) 6.dp else 2.dp),
|
||||
)
|
||||
}
|
||||
is MdBlock.Paragraph -> {
|
||||
if (block.text.isNotBlank()) {
|
||||
Text(
|
||||
text = renderInlineMarkdown(block.text),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
is MdBlock.CodeBlock -> {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = Color(0xFF1E1E1E),
|
||||
) {
|
||||
Column {
|
||||
if (block.language != null) {
|
||||
Text(
|
||||
text = block.language,
|
||||
modifier = Modifier
|
||||
.background(Color(0xFF333333))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
color = Color(0xFFCCCCCC),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = block.code,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
color = Color(0xFFD4D4D4),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is MdBlock.ListItem -> {
|
||||
val prefix = if (block.ordered) "${block.index}. " else "• "
|
||||
Row(modifier = Modifier.padding(start = 8.dp, top = 2.dp)) {
|
||||
Text(
|
||||
text = prefix,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = renderInlineMarkdown(block.text),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
is MdBlock.Quote -> {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
) {
|
||||
Row {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.5f))
|
||||
.weight(0.012f)
|
||||
) {}
|
||||
Text(
|
||||
text = renderInlineMarkdown(block.text),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is MdBlock.ThematicBreak -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
)
|
||||
.weight(1f)
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Code bubble (standalone code block message) ---
|
||||
|
||||
private val codeDarkBg = Color(0xFF1E1E1E)
|
||||
private val codeSurface = Color(0xFF333333)
|
||||
|
||||
@Composable
|
||||
private fun CodeBubble(content: String, modifier: Modifier = Modifier) {
|
||||
val (language, code) = remember(content) {
|
||||
if (content.startsWith("[lang:")) {
|
||||
val endBracket = content.indexOf("]\n")
|
||||
if (endBracket > 0) {
|
||||
content.substring(6, endBracket) to content.substring(endBracket + 2)
|
||||
} else "Code" to content
|
||||
} else "Code" to content
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = codeDarkBg,
|
||||
shadowElevation = 2.dp,
|
||||
) {
|
||||
Column {
|
||||
// Language header
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(codeSurface, MaterialTheme.shapes.medium)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = language,
|
||||
color = Color(0xFFAAAAAA),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Code content
|
||||
Text(
|
||||
text = code,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
color = Color(0xFFD4D4D4),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Collapsible wrapper for non-chat content ---
|
||||
|
||||
private const val COLLAPSE_THRESHOLD = 300
|
||||
|
||||
@Composable
|
||||
private fun CollapsibleBubble(
|
||||
content: String,
|
||||
modifier: Modifier = Modifier,
|
||||
bubble: @Composable (String, Modifier) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val lines = content.lines()
|
||||
val isLong = content.length > COLLAPSE_THRESHOLD || lines.size > 8
|
||||
|
||||
if (!isLong) {
|
||||
bubble(content, modifier)
|
||||
return
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
if (expanded) {
|
||||
bubble(content, Modifier)
|
||||
} else {
|
||||
val truncated = lines.take(5).joinToString("\n").let {
|
||||
if (it.length >= content.length) it else it + "\n…"
|
||||
}
|
||||
bubble(truncated, Modifier)
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = { expanded = !expanded },
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "折叠" else "展开",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main ChatBubble dispatcher ---
|
||||
|
||||
@Composable
|
||||
fun ChatBubble(
|
||||
content: String,
|
||||
role: String,
|
||||
msgType: String,
|
||||
timestamp: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
imageDataUris: List<String> = emptyList(),
|
||||
) {
|
||||
val isUser = role == "user"
|
||||
val formattedTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
|
||||
|
||||
when (msgType) {
|
||||
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier, imageDataUris)
|
||||
"action" -> ActionMessage(content, modifier)
|
||||
"markdown" -> CollapsibleBubble(content, modifier) { text, mod ->
|
||||
MarkdownBubble(text, mod)
|
||||
}
|
||||
"code" -> CollapsibleBubble(content, modifier) { text, mod ->
|
||||
CodeBubble(text, mod)
|
||||
}
|
||||
"thinking" -> CollapsibleBubble(content, modifier) { text, mod ->
|
||||
ThinkingBubble(text, mod)
|
||||
}
|
||||
"tool_progress" -> CollapsibleBubble(content, modifier) { text, mod ->
|
||||
ToolProgressBubble(text, mod)
|
||||
}
|
||||
"system_info" -> SystemInfoBubble(content, modifier)
|
||||
else -> ChatMessageBubble(content, isUser, formattedTime, modifier, imageDataUris)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Full-screen image preview dialog ---
|
||||
|
||||
@Composable
|
||||
private fun ImagePreviewDialog(
|
||||
imageUri: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.95f))
|
||||
.clickable { onDismiss() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(imageUri)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "图片预览",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "关闭",
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Chat message bubble ---
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ChatMessageBubble(
|
||||
content: String,
|
||||
isUser: Boolean,
|
||||
time: String,
|
||||
modifier: Modifier = Modifier,
|
||||
imageDataUris: List<String> = emptyList(),
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var previewImageUri by remember { mutableStateOf<String?>(null) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
val hasImages = imageDataUris.isNotEmpty()
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = if (isUser) Alignment.End else Alignment.Start,
|
||||
) {
|
||||
Box {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (isUser)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant,
|
||||
shadowElevation = 2.dp,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = { showMenu = true },
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
if (hasImages) {
|
||||
imageDataUris.forEach { uri ->
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(uri)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "图片",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 240.dp)
|
||||
.padding(top = 6.dp, start = 6.dp, end = 6.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { previewImageUri = uri },
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (content.isNotBlank()) {
|
||||
Text(
|
||||
text = renderInlineMarkdown(content),
|
||||
modifier = Modifier.padding(
|
||||
start = 12.dp, end = 12.dp,
|
||||
top = if (hasImages) 6.dp else 12.dp,
|
||||
bottom = 12.dp,
|
||||
),
|
||||
color = if (isUser)
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("复制") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
showMenu = false
|
||||
clipboardManager.setText(AnnotatedString(content))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = time,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Full-screen image preview
|
||||
if (previewImageUri != null) {
|
||||
ImagePreviewDialog(
|
||||
imageUri = previewImageUri!!,
|
||||
onDismiss = { previewImageUri = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Action message ---
|
||||
|
||||
private val actionTagRegex = Regex("""</?action>""", RegexOption.IGNORE_CASE)
|
||||
|
||||
@Composable
|
||||
private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
||||
val displayText = remember(content) {
|
||||
content.replace(actionTagRegex, "").trim()
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
Text(
|
||||
text = displayText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Thinking bubble ---
|
||||
|
||||
@Composable
|
||||
private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = content,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tool progress bubble ---
|
||||
|
||||
@Composable
|
||||
private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f),
|
||||
) {
|
||||
Text(
|
||||
text = content,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- System info bubble ---
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Box {
|
||||
Text(
|
||||
text = content,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = { showMenu = true },
|
||||
),
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("复制") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
showMenu = false
|
||||
clipboardManager.setText(AnnotatedString(content))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package top.yeij.cyrene.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AcUnit
|
||||
import androidx.compose.material.icons.filled.Curtains
|
||||
import androidx.compose.material.icons.filled.Lightbulb
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Sensors
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.domain.model.Device
|
||||
import top.yeij.cyrene.domain.model.DeviceType
|
||||
|
||||
@Composable
|
||||
fun DeviceCard(
|
||||
device: Device,
|
||||
onTogglePower: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = device.type.toIcon(),
|
||||
contentDescription = device.type.name,
|
||||
tint = if (device.state.power == true)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = device.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
device.room?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
device.state.temperature?.let { temp ->
|
||||
Text(
|
||||
text = "${"%.1f".format(temp)}°C",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (device.state.power != null) {
|
||||
Switch(
|
||||
checked = device.state.power,
|
||||
onCheckedChange = { onTogglePower() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DeviceType.toIcon() = when (this) {
|
||||
DeviceType.LIGHT -> Icons.Filled.Lightbulb
|
||||
DeviceType.AC -> Icons.Filled.AcUnit
|
||||
DeviceType.CURTAIN -> Icons.Filled.Curtains
|
||||
DeviceType.SENSOR -> Icons.Filled.Sensors
|
||||
DeviceType.DOOR_LOCK -> Icons.Filled.Lock
|
||||
DeviceType.UNKNOWN -> Icons.Filled.Sensors
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package top.yeij.cyrene.ui.components
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
enum class CyreneStatus {
|
||||
ONLINE,
|
||||
THINKING,
|
||||
SPEAKING,
|
||||
OFFLINE,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatusIndicator(
|
||||
status: CyreneStatus,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
when (status) {
|
||||
CyreneStatus.ONLINE -> {
|
||||
Icon(
|
||||
Icons.Filled.Circle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(8.dp),
|
||||
tint = Color(0xFF4CAF50),
|
||||
)
|
||||
Text("昔涟", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
CyreneStatus.THINKING -> {
|
||||
PulsingDot(Color(0xFFFFA726))
|
||||
Text("思考中…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
CyreneStatus.SPEAKING -> {
|
||||
PulsingDot(Color(0xFF42A5F5))
|
||||
Text("正在说话…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
CyreneStatus.OFFLINE -> {
|
||||
Icon(
|
||||
Icons.Filled.Circle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(8.dp),
|
||||
tint = Color(0xFF9E9E9E),
|
||||
)
|
||||
Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PulsingDot(color: Color) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.3f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(600),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "pulse_alpha",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.alpha(alpha)
|
||||
.background(color, CircleShape),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package top.yeij.cyrene.ui.components
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun TypingIndicator(modifier: Modifier = Modifier) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "typing")
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shadowElevation = 1.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "昔涟正在输入",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
repeat(3) { index ->
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.2f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(
|
||||
durationMillis = 400,
|
||||
delayMillis = index * 200,
|
||||
),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "dot_$index",
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(5.dp)
|
||||
.alpha(alpha)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
CircleShape,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package top.yeij.cyrene.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.filled.DevicesOther
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import top.yeij.cyrene.ui.screens.chat.ChatScreen
|
||||
import top.yeij.cyrene.ui.screens.iot.IoTScreen
|
||||
import top.yeij.cyrene.ui.screens.login.LoginScreen
|
||||
import top.yeij.cyrene.ui.screens.about.AboutScreen
|
||||
import top.yeij.cyrene.ui.screens.profile.ProfileScreen
|
||||
import top.yeij.cyrene.ui.screens.settings.KeepAlivePage
|
||||
import top.yeij.cyrene.ui.screens.settings.SettingsScreen
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
|
||||
object Routes {
|
||||
const val LOGIN = "login"
|
||||
const val MAIN = "main"
|
||||
const val CHAT = "chat"
|
||||
const val IOT = "iot"
|
||||
const val SETTINGS = "settings"
|
||||
const val ABOUT = "about"
|
||||
const val KEEP_ALIVE = "keep_alive"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CyreneNavGraph(
|
||||
navController: NavHostController,
|
||||
startDestination: String,
|
||||
isDefaultAssistant: Boolean,
|
||||
onOpenAssistantSettings: () -> Unit,
|
||||
) {
|
||||
// After process death, the NavController may restore a stale back stack
|
||||
// (e.g. showing SETTINGS instead of MAIN). Reset to the intended start.
|
||||
LaunchedEffect(Unit) {
|
||||
val entries = navController.currentBackStack.value
|
||||
val currentRoute = navController.currentDestination?.route
|
||||
RuntimeLog.general("nav", "NavGraph start — currentRoute=$currentRoute backStackSize=${entries.size}")
|
||||
if (entries.size > 1 && entries.first().destination.route != startDestination) {
|
||||
RuntimeLog.general("nav", "Resetting stale back stack to $startDestination")
|
||||
navController.popBackStack(startDestination, inclusive = true)
|
||||
navController.navigate(startDestination)
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
) {
|
||||
composable(Routes.LOGIN) {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
navController.navigate(Routes.MAIN) {
|
||||
popUpTo(Routes.LOGIN) { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.MAIN) {
|
||||
MainScreen(
|
||||
navController = navController,
|
||||
isDefaultAssistant = isDefaultAssistant,
|
||||
onOpenAssistantSettings = onOpenAssistantSettings,
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.SETTINGS) {
|
||||
SettingsScreen(
|
||||
onBack = {
|
||||
if (navController.currentDestination?.route == Routes.SETTINGS) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
},
|
||||
onNavigateToKeepAlive = { navController.navigate(Routes.KEEP_ALIVE) },
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.KEEP_ALIVE) {
|
||||
KeepAlivePage(
|
||||
onBack = {
|
||||
if (navController.currentDestination?.route == Routes.KEEP_ALIVE) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.ABOUT) {
|
||||
AboutScreen(
|
||||
onBack = {
|
||||
if (navController.currentDestination?.route == Routes.ABOUT) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class BottomNavItem(
|
||||
val label: String,
|
||||
val icon: @Composable () -> Unit,
|
||||
val route: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
navController: NavHostController,
|
||||
isDefaultAssistant: Boolean,
|
||||
onOpenAssistantSettings: () -> Unit,
|
||||
) {
|
||||
val items = listOf(
|
||||
BottomNavItem(
|
||||
label = "对话",
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = "对话") },
|
||||
route = Routes.CHAT,
|
||||
),
|
||||
BottomNavItem(
|
||||
label = "设备",
|
||||
icon = { Icon(Icons.Filled.DevicesOther, contentDescription = "设备") },
|
||||
route = Routes.IOT,
|
||||
),
|
||||
BottomNavItem(
|
||||
label = "我的",
|
||||
icon = { Icon(Icons.Filled.Person, contentDescription = "我的") },
|
||||
route = "profile",
|
||||
),
|
||||
)
|
||||
|
||||
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding(),
|
||||
) {
|
||||
NavigationRail {
|
||||
items.forEachIndexed { index, item ->
|
||||
NavigationRailItem(
|
||||
selected = selectedTab == index,
|
||||
onClick = {
|
||||
selectedTab = index
|
||||
RuntimeLog.general("nav", "Tab switched to ${item.label} (index=$index)")
|
||||
},
|
||||
icon = item.icon,
|
||||
label = { Text(item.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clipToBounds()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
// Keep all tabs alive by offsetting hidden ones off-screen.
|
||||
// clipToBounds ensures they don't intercept touches outside the visible area.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.offset(x = if (selectedTab == 0) 0.dp else 2000.dp),
|
||||
) {
|
||||
ChatScreen()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.offset(x = if (selectedTab == 1) 0.dp else 2000.dp),
|
||||
) {
|
||||
IoTScreen()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.offset(x = if (selectedTab == 2) 0.dp else 2000.dp),
|
||||
) {
|
||||
ProfileScreen(
|
||||
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
|
||||
onNavigateToAbout = { navController.navigate(Routes.ABOUT) },
|
||||
onLogout = {
|
||||
navController.navigate(Routes.LOGIN) {
|
||||
popUpTo(Routes.MAIN) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateToLogin = {
|
||||
navController.navigate(Routes.LOGIN)
|
||||
},
|
||||
isDefaultAssistant = isDefaultAssistant,
|
||||
onOpenAssistantSettings = onOpenAssistantSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
package top.yeij.cyrene.ui.overlay
|
||||
|
||||
import android.content.res.Configuration
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardVoice
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.ui.components.ChatBubble
|
||||
import top.yeij.cyrene.ui.components.TypingIndicator
|
||||
import top.yeij.cyrene.util.RecordState
|
||||
import top.yeij.cyrene.viewmodel.OverlayState
|
||||
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
private fun AnimatedChatBubble(
|
||||
message: Message,
|
||||
animIndex: Int,
|
||||
) {
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(min(animIndex, 10) * 60L)
|
||||
visible = true
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) +
|
||||
slideInVertically(
|
||||
animationSpec = androidx.compose.animation.core.tween(300),
|
||||
initialOffsetY = { it / 4 },
|
||||
),
|
||||
) {
|
||||
ChatBubble(
|
||||
content = message.content,
|
||||
role = message.role,
|
||||
msgType = message.msgType,
|
||||
timestamp = message.timestamp,
|
||||
imageDataUris = message.imageDataUris,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OverlayContent(
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToMain: () -> Unit,
|
||||
viewModel: OverlayViewModel = koinInject(),
|
||||
settingsViewModel: SettingsViewModel = koinInject(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val messages by viewModel.messages.collectAsState()
|
||||
val inputText by viewModel.inputText.collectAsState()
|
||||
val recordState by viewModel.voiceRecordState.collectAsState()
|
||||
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
||||
val animIndex by viewModel.messageAnimIndex.collectAsState()
|
||||
val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
|
||||
val enterToSend by settingsViewModel.enterToSend.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
val isProcessing = state == OverlayState.PROCESSING
|
||||
val recordSec = recordDurationMs / 1000f
|
||||
val isRecording = recordState == RecordState.RECORDING
|
||||
val isLocked = recordState == RecordState.LOCKED
|
||||
|
||||
// Animated "昔涟正在输入..." dots
|
||||
val typingDots = remember { mutableStateOf("") }
|
||||
LaunchedEffect(isProcessing) {
|
||||
if (isProcessing) {
|
||||
val dots = arrayOf("", ".", "..", "...")
|
||||
var i = 0
|
||||
while (true) {
|
||||
typingDots.value = dots[i % 4]
|
||||
i++
|
||||
delay(400)
|
||||
}
|
||||
} else {
|
||||
typingDots.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
val configuration = LocalConfiguration.current
|
||||
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
// Manual status bar height — Compose WindowInsets may not work in VoiceInteractionSession
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val statusBarHeight = remember {
|
||||
val resourceId = context.resources.getIdentifier("status_bar_height", "dimen", "android")
|
||||
if (resourceId > 0) context.resources.getDimensionPixelSize(resourceId) else 0
|
||||
}
|
||||
val statusBarPaddingDp = with(androidx.compose.ui.platform.LocalDensity.current) {
|
||||
statusBarHeight.toDp()
|
||||
}
|
||||
|
||||
// Manual nav bar height
|
||||
val navBarHeight = remember {
|
||||
val resourceId = context.resources.getIdentifier("navigation_bar_height", "dimen", "android")
|
||||
if (resourceId > 0) context.resources.getDimensionPixelSize(resourceId) else 0
|
||||
}
|
||||
val navBarPaddingDp = with(androidx.compose.ui.platform.LocalDensity.current) {
|
||||
navBarHeight.toDp()
|
||||
}
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(messages.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
if (state == OverlayState.IDLE) {
|
||||
viewModel.finish()
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state != OverlayState.IDLE,
|
||||
enter = fadeIn() + slideInVertically { it },
|
||||
exit = fadeOut() + slideOutVertically { it },
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = statusBarPaddingDp, bottom = navBarPaddingDp),
|
||||
) {
|
||||
if (isLandscape) {
|
||||
LandscapeContent(
|
||||
state = state,
|
||||
messages = messages,
|
||||
inputText = inputText,
|
||||
isProcessing = isProcessing,
|
||||
listState = listState,
|
||||
recordSec = recordSec,
|
||||
isRecording = isRecording,
|
||||
isLocked = isLocked,
|
||||
typingDots = typingDots.value,
|
||||
typingIndicatorStyle = typingIndicatorStyle,
|
||||
enterToSend = enterToSend,
|
||||
animIndex = animIndex,
|
||||
onDismiss = onDismiss,
|
||||
onNavigateToMain = onNavigateToMain,
|
||||
viewModel = viewModel,
|
||||
navBarHeightPx = navBarHeight,
|
||||
)
|
||||
} else {
|
||||
PortraitContent(
|
||||
state = state,
|
||||
messages = messages,
|
||||
inputText = inputText,
|
||||
isProcessing = isProcessing,
|
||||
listState = listState,
|
||||
recordSec = recordSec,
|
||||
isRecording = isRecording,
|
||||
isLocked = isLocked,
|
||||
typingDots = typingDots.value,
|
||||
typingIndicatorStyle = typingIndicatorStyle,
|
||||
enterToSend = enterToSend,
|
||||
animIndex = animIndex,
|
||||
onDismiss = onDismiss,
|
||||
onNavigateToMain = onNavigateToMain,
|
||||
viewModel = viewModel,
|
||||
navBarHeightPx = navBarHeight,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun PortraitContent(
|
||||
state: OverlayState,
|
||||
messages: List<Message>,
|
||||
inputText: String,
|
||||
isProcessing: Boolean,
|
||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||
recordSec: Float,
|
||||
isRecording: Boolean,
|
||||
isLocked: Boolean,
|
||||
typingDots: String,
|
||||
typingIndicatorStyle: String,
|
||||
enterToSend: Boolean,
|
||||
animIndex: Map<String, Int>,
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToMain: () -> Unit,
|
||||
viewModel: OverlayViewModel,
|
||||
navBarHeightPx: Int,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) { /* consume clicks */ },
|
||||
) {
|
||||
// Messages + top bar stay fixed at top
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
state = listState,
|
||||
) {
|
||||
if (messages.isNotEmpty()) {
|
||||
items(messages, key = { it.id }) { message ->
|
||||
AnimatedChatBubble(
|
||||
message = message,
|
||||
animIndex = animIndex[message.id] ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isProcessing && typingIndicatorStyle != "text") {
|
||||
item(key = "typing_indicator") {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Input area at bottom; system adjust=pan handles IME offset
|
||||
InputArea(
|
||||
state = state,
|
||||
inputText = inputText,
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth(),
|
||||
recordSec = recordSec,
|
||||
isRecording = isRecording,
|
||||
isLocked = isLocked,
|
||||
typingDots = typingDots,
|
||||
typingIndicatorStyle = typingIndicatorStyle,
|
||||
enterToSend = enterToSend,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun LandscapeContent(
|
||||
state: OverlayState,
|
||||
messages: List<Message>,
|
||||
inputText: String,
|
||||
isProcessing: Boolean,
|
||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||
recordSec: Float,
|
||||
isRecording: Boolean,
|
||||
isLocked: Boolean,
|
||||
typingDots: String,
|
||||
typingIndicatorStyle: String,
|
||||
enterToSend: Boolean,
|
||||
animIndex: Map<String, Int>,
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToMain: () -> Unit,
|
||||
viewModel: OverlayViewModel,
|
||||
navBarHeightPx: Int,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) { /* consume clicks */ },
|
||||
) {
|
||||
// Messages + top bar on the left, stay fixed
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain)
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
state = listState,
|
||||
) {
|
||||
if (messages.isNotEmpty()) {
|
||||
items(messages, key = { it.id }) { message ->
|
||||
AnimatedChatBubble(
|
||||
message = message,
|
||||
animIndex = animIndex[message.id] ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isProcessing && typingIndicatorStyle != "text") {
|
||||
item(key = "typing_indicator") {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
InputArea(
|
||||
state = state,
|
||||
inputText = inputText,
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
recordSec = recordSec,
|
||||
isRecording = isRecording,
|
||||
isLocked = isLocked,
|
||||
typingDots = typingDots,
|
||||
typingIndicatorStyle = typingIndicatorStyle,
|
||||
enterToSend = enterToSend,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageTopBar(
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToMain: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.3f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "关闭")
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = onNavigateToMain) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = "进入主界面",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputArea(
|
||||
state: OverlayState,
|
||||
inputText: String,
|
||||
viewModel: OverlayViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
recordSec: Float = 0f,
|
||||
isRecording: Boolean = false,
|
||||
isLocked: Boolean = false,
|
||||
typingDots: String = "",
|
||||
typingIndicatorStyle: String = "bubble",
|
||||
enterToSend: Boolean = false,
|
||||
) {
|
||||
// Gesture tracking state — local to InputArea
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
var dragOffsetX by remember { mutableStateOf(0f) }
|
||||
var dragOffsetY by remember { mutableStateOf(0f) }
|
||||
val inCancelZone = isDragging && dragOffsetY < -120f
|
||||
val inLockZone = isDragging && dragOffsetX > 60f
|
||||
val isProcessing = state == OverlayState.PROCESSING
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
// "昔涟正在输入..." indicator (text mode only)
|
||||
if (isProcessing && typingDots.isNotEmpty() && typingIndicatorStyle == "text") {
|
||||
Text(
|
||||
text = "昔涟正在输入$typingDots",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 4.dp),
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isRecording && isDragging) {
|
||||
// Recording with drag — show recording indicator
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
if (inCancelZone) MaterialTheme.colorScheme.errorContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
inCancelZone -> "松手取消"
|
||||
inLockZone -> "松手录音"
|
||||
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (inCancelZone) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Mic,
|
||||
contentDescription = "录音中",
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
} else if (isLocked) {
|
||||
// Locked (hands-free) mode
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { viewModel.finishRecord() }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "发送",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Normal input mode
|
||||
OutlinedTextField(
|
||||
value = inputText,
|
||||
onValueChange = { viewModel.onInputChanged(it) },
|
||||
placeholder = { Text("输入消息...") },
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 3,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.35f),
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f),
|
||||
),
|
||||
keyboardOptions = if (enterToSend) {
|
||||
KeyboardOptions(imeAction = ImeAction.Done)
|
||||
} else {
|
||||
KeyboardOptions.Default
|
||||
},
|
||||
keyboardActions = if (enterToSend) {
|
||||
KeyboardActions(
|
||||
onDone = { if (inputText.isNotBlank()) viewModel.sendText() },
|
||||
)
|
||||
} else {
|
||||
KeyboardActions.Default
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
if (inputText.isNotBlank()) {
|
||||
IconButton(
|
||||
onClick = { viewModel.sendText() },
|
||||
enabled = !isProcessing,
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "发送",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { _ ->
|
||||
isDragging = true
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
viewModel.startRecord()
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffsetX += dragAmount.x
|
||||
dragOffsetY += dragAmount.y
|
||||
},
|
||||
onDragEnd = {
|
||||
isDragging = false
|
||||
when {
|
||||
dragOffsetY < -120f -> viewModel.cancelRecord()
|
||||
dragOffsetX > 60f -> viewModel.lockRecord()
|
||||
else -> viewModel.finishRecord()
|
||||
}
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
isDragging = false
|
||||
viewModel.cancelRecord()
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardVoice,
|
||||
contentDescription = "按住录音",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status hint
|
||||
val hint = when {
|
||||
isLocked -> ""
|
||||
isRecording && isDragging -> ""
|
||||
else -> when (state) {
|
||||
OverlayState.LISTENING -> "我在听..."
|
||||
OverlayState.PROCESSING -> "思考中..."
|
||||
OverlayState.SPEAKING -> "正在说话..."
|
||||
OverlayState.WAITING -> "长按麦克风开始说话"
|
||||
OverlayState.IDLE -> ""
|
||||
}
|
||||
}
|
||||
if (hint.isNotEmpty()) {
|
||||
Text(
|
||||
text = hint,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package top.yeij.cyrene.ui.screens.about
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AboutScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("关于") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
text = "Cyrene",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp),
|
||||
)
|
||||
Text(
|
||||
text = "昔涟",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
text = "v0.1.0",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "信息",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("简介") },
|
||||
leadingContent = {
|
||||
Icon(Icons.Filled.Info, contentDescription = null)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = "Cyrene 是一款基于 Android 的智能语音助手,支持实时对话、IoT 设备管理和语音交互。",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("开发者") },
|
||||
leadingContent = {
|
||||
Icon(Icons.Filled.Person, contentDescription = null)
|
||||
},
|
||||
supportingContent = { Text("AskaEth") },
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("源代码") },
|
||||
leadingContent = {
|
||||
Icon(Icons.Filled.Code, contentDescription = null)
|
||||
},
|
||||
supportingContent = { Text("git.yeij.top/AskaEth/Cyrene-For-Android") },
|
||||
trailingContent = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse("https://git.yeij.top/AskaEth/Cyrene-For-Android")
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "技术栈",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
val techStack = listOf(
|
||||
"Kotlin" to "主语言",
|
||||
"Jetpack Compose" to "UI 框架",
|
||||
"Material Design 3" to "设计系统",
|
||||
"Room" to "本地数据库",
|
||||
"OkHttp + Retrofit" to "网络请求",
|
||||
"WebSocket" to "实时通信",
|
||||
"Koin" to "依赖注入",
|
||||
)
|
||||
techStack.forEach { (name, desc) ->
|
||||
ListItem(
|
||||
headlineContent = { Text(name) },
|
||||
supportingContent = { Text(desc) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "© 2026 AskaEth. All rights reserved.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
package top.yeij.cyrene.ui.screens.chat
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AddPhotoAlternate
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardVoice
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import kotlinx.coroutines.delay
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.ui.components.ChatBubble
|
||||
import top.yeij.cyrene.ui.components.CyreneStatus
|
||||
import top.yeij.cyrene.ui.components.StatusIndicator
|
||||
import top.yeij.cyrene.ui.components.TypingIndicator
|
||||
import top.yeij.cyrene.util.RecordState
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import top.yeij.cyrene.viewmodel.ChatViewModel
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
@Composable
|
||||
private fun AnimatedChatBubble(
|
||||
message: Message,
|
||||
animIndex: Int,
|
||||
) {
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(min(animIndex, 10) * 60L)
|
||||
visible = true
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(animationSpec = tween(300)) +
|
||||
slideInVertically(
|
||||
animationSpec = tween(300),
|
||||
initialOffsetY = { it / 4 },
|
||||
),
|
||||
) {
|
||||
ChatBubble(
|
||||
content = message.content,
|
||||
role = message.role,
|
||||
msgType = message.msgType,
|
||||
timestamp = message.timestamp,
|
||||
imageDataUris = message.imageDataUris,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
viewModel: ChatViewModel = koinViewModel(),
|
||||
settingsViewModel: SettingsViewModel = koinInject(),
|
||||
) {
|
||||
// Track composition to diagnose navigation-related issues
|
||||
LaunchedEffect(Unit) {
|
||||
RuntimeLog.general("chat", "ChatScreen composed, ChatViewModel instance resolved")
|
||||
}
|
||||
val messages by viewModel.currentMessages.collectAsState()
|
||||
val inputText by viewModel.inputText.collectAsState()
|
||||
val isStreaming by viewModel.isStreaming.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val isRefreshing by viewModel.isRefreshing.collectAsState()
|
||||
val recordState by viewModel.voiceRecordState.collectAsState()
|
||||
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
||||
val animIndex by viewModel.messageAnimIndex.collectAsState()
|
||||
val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
|
||||
val enterToSend by settingsViewModel.enterToSend.collectAsState()
|
||||
|
||||
// reverseLayout: index 0 = newest (visual bottom), index N-1 = oldest (visual top)
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Track whether user is near the latest messages (visual bottom = index 0)
|
||||
val isNearBottom by remember {
|
||||
derivedStateOf {
|
||||
val info = listState.layoutInfo
|
||||
if (info.totalItemsCount == 0) return@derivedStateOf true
|
||||
(info.visibleItemsInfo.firstOrNull()?.index ?: 0) <= 2
|
||||
}
|
||||
}
|
||||
|
||||
// Gesture tracking state
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
var dragOffsetX by remember { mutableStateOf(0f) }
|
||||
var dragOffsetY by remember { mutableStateOf(0f) }
|
||||
var recordButtonY by remember { mutableStateOf(0f) }
|
||||
|
||||
val recordSec = recordDurationMs / 1000f
|
||||
val isRecording = recordState == RecordState.RECORDING
|
||||
val isLocked = recordState == RecordState.LOCKED
|
||||
val inCancelZone = isDragging && dragOffsetY < -120f
|
||||
val inLockZone = isDragging && dragOffsetX > 60f
|
||||
|
||||
// Image picker
|
||||
val selectedImages by viewModel.selectedImageUris.collectAsState()
|
||||
val imagePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetMultipleContents()
|
||||
) { uris: List<Uri> ->
|
||||
if (uris.isNotEmpty()) {
|
||||
viewModel.addImages(uris)
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
|
||||
// Stay at bottom for new messages unless user scrolled up
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { messages.size to isNearBottom }
|
||||
.collect { (_, nearBottom) ->
|
||||
if (nearBottom && listState.firstVisibleItemIndex != 0) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animated "昔涟正在输入..." dots
|
||||
val typingDots = remember { mutableStateOf("") }
|
||||
LaunchedEffect(isStreaming) {
|
||||
if (isStreaming) {
|
||||
val dots = arrayOf("", ".", "..", "...")
|
||||
var i = 0
|
||||
while (true) {
|
||||
typingDots.value = dots[i % 4]
|
||||
i++
|
||||
delay(400)
|
||||
}
|
||||
} else {
|
||||
typingDots.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
val status = when {
|
||||
isStreaming -> CyreneStatus.THINKING
|
||||
isConnected -> CyreneStatus.ONLINE
|
||||
else -> CyreneStatus.OFFLINE
|
||||
}
|
||||
|
||||
// Single column layout: everything flows together and IME shrinks the whole view
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.imePadding(),
|
||||
) {
|
||||
// Top status bar with refresh button
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
StatusIndicator(status = status)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
IconButton(
|
||||
onClick = { viewModel.refreshMessages() },
|
||||
enabled = !isRefreshing,
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Filled.Refresh,
|
||||
contentDescription = "刷新",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Selected images preview
|
||||
if (selectedImages.isNotEmpty()) {
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
itemsIndexed(selectedImages, key = { i, _ -> i }) { index, uri ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(uri)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "已选图片",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
IconButton(
|
||||
onClick = { viewModel.removeImage(index) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.size(20.dp)
|
||||
.padding(0.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "移除",
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f),
|
||||
CircleShape,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Messages area (fills remaining space, shrinks with IME)
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
if (messages.isEmpty() && !isStreaming) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "开始和昔涟对话吧",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
) {
|
||||
if (isStreaming && typingIndicatorStyle != "text") {
|
||||
item(key = "typing_indicator") {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
itemsIndexed(messages, key = { _, msg -> msg.id }) { index, message ->
|
||||
AnimatedChatBubble(
|
||||
message = message,
|
||||
animIndex = index.coerceAtMost(20),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Input area at bottom, in flow (not overlaid)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
// "昔涟正在输入..." indicator (text mode only)
|
||||
if (isStreaming && typingIndicatorStyle == "text") {
|
||||
Text(
|
||||
text = "昔涟正在输入${typingDots.value}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 2.dp),
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isRecording && isDragging) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
if (inCancelZone) MaterialTheme.colorScheme.errorContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = when {
|
||||
inCancelZone -> "松手取消"
|
||||
inLockZone -> "松手录音"
|
||||
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (inCancelZone) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Mic,
|
||||
contentDescription = "录音中",
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
} else if (isLocked) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { viewModel.finishRecord() }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "发送",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = { imagePickerLauncher.launch("image/*") },
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AddPhotoAlternate,
|
||||
contentDescription = "添加图片",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = inputText,
|
||||
onValueChange = { viewModel.onInputChanged(it) },
|
||||
placeholder = { Text("输入消息...") },
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 4,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
keyboardOptions = if (enterToSend) {
|
||||
KeyboardOptions(imeAction = ImeAction.Done)
|
||||
} else {
|
||||
KeyboardOptions.Default
|
||||
},
|
||||
keyboardActions = if (enterToSend) {
|
||||
KeyboardActions(
|
||||
onDone = { if (inputText.isNotBlank()) viewModel.sendMessage() },
|
||||
)
|
||||
} else {
|
||||
KeyboardActions.Default
|
||||
},
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 4.dp)
|
||||
.size(48.dp)
|
||||
.onGloballyPositioned { recordButtonY = it.positionInRoot().y }
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
isDragging = true
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
viewModel.startRecord()
|
||||
},
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffsetX += dragAmount.x
|
||||
dragOffsetY += dragAmount.y
|
||||
},
|
||||
onDragEnd = {
|
||||
isDragging = false
|
||||
when {
|
||||
dragOffsetY < -120f -> viewModel.cancelRecord()
|
||||
dragOffsetX > 60f -> viewModel.lockRecord()
|
||||
else -> viewModel.finishRecord()
|
||||
}
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
isDragging = false
|
||||
viewModel.cancelRecord()
|
||||
dragOffsetX = 0f
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardVoice,
|
||||
contentDescription = "按住录音",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (inputText.isNotBlank()) {
|
||||
IconButton(
|
||||
onClick = { viewModel.sendMessage() },
|
||||
enabled = !isStreaming,
|
||||
) {
|
||||
if (isStreaming) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "发送",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package top.yeij.cyrene.ui.screens.iot
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import top.yeij.cyrene.ui.components.DeviceCard
|
||||
import top.yeij.cyrene.viewmodel.IoTViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun IoTScreen(
|
||||
viewModel: IoTViewModel = koinViewModel(),
|
||||
) {
|
||||
val devices by viewModel.devices.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState()
|
||||
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isLoading,
|
||||
onRefresh = { viewModel.refreshDevices() },
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
if (devices.isEmpty() && !isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "暂无设备数据\n请确认已连接到服务器",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(devices, key = { it.id }) { device ->
|
||||
DeviceCard(
|
||||
device = device,
|
||||
onTogglePower = { viewModel.togglePower(device) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package top.yeij.cyrene.ui.screens.login
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.domain.usecase.LoginUseCase
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onLoginSuccess: () -> Unit,
|
||||
loginUseCase: LoginUseCase = koinInject(),
|
||||
settingsViewModel: SettingsViewModel = koinInject(),
|
||||
) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(80.dp))
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Cyrene",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = "昔涟",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("用户名") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("密码") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
visualTransformation = if (showPassword)
|
||||
VisualTransformation.None
|
||||
else
|
||||
PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
singleLine = true,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showPassword = !showPassword }) {
|
||||
Icon(
|
||||
if (showPassword) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||
contentDescription = if (showPassword) "隐藏密码" else "显示密码",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
isLoading = true
|
||||
scope.launch {
|
||||
loginUseCase(username, password).fold(
|
||||
onSuccess = {
|
||||
isLoading = false
|
||||
onLoginSuccess()
|
||||
},
|
||||
onFailure = { error ->
|
||||
isLoading = false
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
error.message ?: "登录失败",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||
enabled = username.isNotBlank() && password.isNotBlank() && !isLoading,
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
} else {
|
||||
Text("登录")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package top.yeij.cyrene.ui.screens.profile
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||
import androidx.compose.material.icons.automirrored.filled.Help
|
||||
import androidx.compose.material.icons.filled.AdminPanelSettings
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Circle
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Tag
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.viewmodel.ProfileViewModel
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
onNavigateToSettings: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onNavigateToLogin: () -> Unit,
|
||||
onNavigateToAbout: () -> Unit = {},
|
||||
isDefaultAssistant: Boolean = false,
|
||||
onOpenAssistantSettings: () -> Unit = {},
|
||||
profileViewModel: ProfileViewModel = koinInject(),
|
||||
) {
|
||||
val profile by profileViewModel.profile.collectAsState()
|
||||
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
profileViewModel.fetchFreshProfile()
|
||||
}
|
||||
|
||||
if (showLogoutDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showLogoutDialog = false },
|
||||
title = { Text("退出登录") },
|
||||
text = { Text("确定要退出登录吗?") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showLogoutDialog = false
|
||||
profileViewModel.logout()
|
||||
onLogout()
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text("退出")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showLogoutDialog = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
// Profile header
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Avatar
|
||||
val initials = if (profile.nickname.isNotBlank()) {
|
||||
profile.nickname.take(1)
|
||||
} else if (profile.username.isNotBlank()) {
|
||||
profile.username.take(1)
|
||||
} else {
|
||||
"?"
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = initials,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (profile.isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
} else if (!profile.isLoggedIn) {
|
||||
Text(
|
||||
text = "未登录",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable { onNavigateToLogin() },
|
||||
)
|
||||
Text(
|
||||
text = "点击登录以查看个人信息",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
// Nickname
|
||||
Text(
|
||||
text = profile.nickname.ifEmpty { profile.username },
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
// Username
|
||||
if (profile.nickname.isNotBlank() && profile.nickname != profile.username) {
|
||||
Text(
|
||||
text = "@${profile.username}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Admin badge
|
||||
if (profile.isAdmin) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.AdminPanelSettings,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "管理员",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
if (profile.isLoggedIn) {
|
||||
// User info card
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "账号信息",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
ProfileInfoCard(
|
||||
items = listOf(
|
||||
ProfileInfoItem(Icons.Filled.Tag, "用户 ID", profile.userId),
|
||||
ProfileInfoItem(Icons.Filled.CalendarMonth, "注册时间", profile.createdAt.ifEmpty { "未知" }),
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Assistant status
|
||||
Text(
|
||||
text = "助手",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (isDefaultAssistant)
|
||||
Color(0xFF4CAF50).copy(alpha = 0.1f)
|
||||
else
|
||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Circle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(10.dp),
|
||||
tint = if (isDefaultAssistant) Color(0xFF4CAF50) else Color(0xFFFF5722),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (isDefaultAssistant) "已设为默认助手" else "未设为默认助手",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (!isDefaultAssistant) {
|
||||
Text(
|
||||
text = "设为默认助手后,长按电源键或Home键即可呼出昔涟",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!isDefaultAssistant) {
|
||||
TextButton(onClick = onOpenAssistantSettings) {
|
||||
Text("去设置")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Menu items
|
||||
Text(
|
||||
text = "其他",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("设置") },
|
||||
leadingContent = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
|
||||
modifier = Modifier.clickable { onNavigateToSettings() },
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("提醒") },
|
||||
leadingContent = { Icon(Icons.Filled.Notifications, contentDescription = null) },
|
||||
trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("关于") },
|
||||
leadingContent = { Icon(Icons.Filled.Info, contentDescription = null) },
|
||||
supportingContent = { Text("Cyrene v0.1.0") },
|
||||
modifier = Modifier.clickable { onNavigateToAbout() },
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("使用帮助") },
|
||||
leadingContent = { Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Logout
|
||||
if (profile.isLoggedIn) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = "退出登录",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ExitToApp,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { showLogoutDialog = true },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private data class ProfileInfoItem(
|
||||
val icon: ImageVector,
|
||||
val label: String,
|
||||
val value: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun ProfileInfoCard(items: List<ProfileInfoItem>) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
) {
|
||||
Column {
|
||||
items.forEachIndexed { index, item ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
item.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = item.value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (index < items.size - 1) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package top.yeij.cyrene.ui.screens.settings
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.BatterySaver
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import top.yeij.cyrene.service.WebSocketKeepAliveService
|
||||
import top.yeij.cyrene.util.KeepAliveManager
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun KeepAlivePage(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val keepAliveManager = KeepAliveManager(context)
|
||||
|
||||
val fgRunning = WebSocketKeepAliveService.isRunning
|
||||
val batteryExempt = keepAliveManager.isBatteryOptimizationExempt()
|
||||
val canOverlay = keepAliveManager.canDrawOverlays()
|
||||
val manufacturerName = keepAliveManager.getManufacturerName()
|
||||
|
||||
val batteryLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) {
|
||||
// Re-check battery optimization after returning from settings
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("后台保活") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
// Header explanation
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Android 系统会在应用进入后台后限制网络连接或终止进程,导致无法接收服务端主动推送的消息。请按照以下方法加强后台保活能力。",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "保活方式",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
// 1. Foreground Service
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Notifications,
|
||||
contentDescription = null,
|
||||
tint = if (fgRunning) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "前台服务通知",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = if (fgRunning) "已开启,通知栏显示「昔涟 — 已连接」" else "切后台时显示持久通知保活"
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = fgRunning,
|
||||
onCheckedChange = {
|
||||
if (it) {
|
||||
WebSocketKeepAliveService.start(context)
|
||||
} else {
|
||||
WebSocketKeepAliveService.stop(context)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Battery Optimization
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = if (batteryExempt) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "忽略电池优化",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = if (batteryExempt) "已免除,Doze 模式不会限制网络"
|
||||
else "未免除,后台待久会被系统限制网络(Doze 休眠)"
|
||||
)
|
||||
}
|
||||
if (!batteryExempt) {
|
||||
TextButton(onClick = {
|
||||
batteryLauncher.launch(
|
||||
keepAliveManager.openBatteryOptimizationSettings()
|
||||
)
|
||||
}) {
|
||||
Text("去设置")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Auto-start (OEM-specific)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "自启动管理",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = when (manufacturerName) {
|
||||
"xiaomi" -> "小米手机请在「安全中心 → 自启动管理」中允许昔涟自启动"
|
||||
"huawei" -> "华为手机请在「手机管家 → 自启动管理」中允许昔涟自启动"
|
||||
"oppo" -> "OPPO 手机请在「设置 → 应用自启动」中允许昔涟自启动"
|
||||
"vivo" -> "vivo 手机请在「i管家 → 自启动」中允许昔涟自启动"
|
||||
"oneplus" -> "一加手机请在「设置 → 自启动」中允许昔涟自启动"
|
||||
"samsung" -> "三星手机请在「设置 → 电池 → 不受限制的应用」中添加昔涟"
|
||||
else -> "请在系统设置中为昔涟开启「自启动/后台运行」权限"
|
||||
}
|
||||
)
|
||||
}
|
||||
TextButton(onClick = {
|
||||
val intent = keepAliveManager.getAutoStartIntent()
|
||||
if (intent != null) {
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
// Fallback to app info
|
||||
try {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = android.net.Uri.parse("package:${context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(context, "未找到对应设置页面,请手动前往系统设置", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}) {
|
||||
Text("去设置")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Lock task (recent apps lock)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Security,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "锁定后台任务",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = "进入最近任务界面(多任务键),将昔涟卡片下拉锁定,防止系统清理后台时误杀"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Battery saver passthrough
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.BatterySaver,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "电池优化白名单",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = "手动确认系统电池优化白名单,确保昔涟不被限制"
|
||||
)
|
||||
}
|
||||
TextButton(onClick = {
|
||||
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) { }
|
||||
}) {
|
||||
Text("查看")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "补充提示",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
Text(
|
||||
text = """
|
||||
不同的手机厂商对待后台应用的方式各不相同:
|
||||
|
||||
• 谷歌 Pixel / 原生 Android:开启电池优化豁免即可
|
||||
• 小米 MIUI / HyperOS:需同时开启自启动 + 电池无限制
|
||||
• 华为 HarmonyOS:需开启自启动 + 关闭省电模式限制
|
||||
• OPPO ColorOS / vivo OriginOS:需开启自启动 + 后台运行
|
||||
• 三星 OneUI:需添加到「不受限制的应用」列表
|
||||
|
||||
实际效果因系统版本和厂商策略而异。建议至少开启「前台服务通知」+「忽略电池优化」两项。
|
||||
""".trimIndent(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,954 @@
|
||||
package top.yeij.cyrene.ui.screens.settings
|
||||
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.BatterySaver
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.DarkMode
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.filled.LightMode
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.SettingsBrightness
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.ui.theme.PresetColorLabels
|
||||
import top.yeij.cyrene.ui.theme.PresetThemeColors
|
||||
import top.yeij.cyrene.util.KeepAliveManager
|
||||
import top.yeij.cyrene.util.LogCategory
|
||||
import top.yeij.cyrene.util.RootKeepAliveHelper
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onNavigateToKeepAlive: () -> Unit = {},
|
||||
viewModel: SettingsViewModel = koinInject(),
|
||||
) {
|
||||
val baseUrl by viewModel.baseUrl.collectAsState()
|
||||
val themeMode by viewModel.themeMode.collectAsState()
|
||||
val wakeWord by viewModel.wakeWord.collectAsState()
|
||||
val dashScopeApiKey by viewModel.dashScopeApiKey.collectAsState()
|
||||
val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState()
|
||||
val dashScopeModel by viewModel.dashScopeModel.collectAsState()
|
||||
val autoScreenContext by viewModel.autoScreenContext.collectAsState()
|
||||
val typingIndicatorStyle by viewModel.typingIndicatorStyle.collectAsState()
|
||||
val themeColor by viewModel.themeColor.collectAsState()
|
||||
val enterToSend by viewModel.enterToSend.collectAsState()
|
||||
val rootKeepAlive by viewModel.rootKeepAlive.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun sanitizeUrl(raw: String): String? {
|
||||
var url = raw.trim()
|
||||
if (url.isEmpty()) return null
|
||||
val hasScheme = url.contains("://")
|
||||
if (!hasScheme) url = "http://$url"
|
||||
return try {
|
||||
val parsed = java.net.URL(url)
|
||||
val host = parsed.host ?: return null
|
||||
if (host.isEmpty()) return null
|
||||
val scheme = parsed.protocol
|
||||
val port = if (parsed.port > 0) ":${parsed.port}" else ""
|
||||
val path = parsed.path?.trimEnd('/') ?: ""
|
||||
val query = parsed.query?.let { "?$it" } ?: ""
|
||||
"$scheme://$host$port$path$query"
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
title = { Text("设置") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
// Server
|
||||
Text(
|
||||
text = "服务器",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = baseUrl,
|
||||
onValueChange = { viewModel.updateBaseUrlInput(it) },
|
||||
label = { Text("服务器地址") },
|
||||
placeholder = { Text("http://192.168.1.x:8080") },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
scope.launch {
|
||||
val sanitized = sanitizeUrl(baseUrl)
|
||||
if (sanitized != null) {
|
||||
viewModel.saveBaseUrl(sanitized)
|
||||
Toast.makeText(context, "地址已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
trailingIcon = {
|
||||
FilledTonalIconButton(onClick = {
|
||||
val sanitized = sanitizeUrl(baseUrl)
|
||||
if (sanitized != null) {
|
||||
viewModel.saveBaseUrl(sanitized)
|
||||
Toast.makeText(context, "地址已保存", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, "地址格式无效", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "确认")
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Appearance
|
||||
Text(
|
||||
text = "外观",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
val themeLabel = when (themeMode) {
|
||||
"light" -> "浅色模式"
|
||||
"dark" -> "深色模式"
|
||||
else -> "跟随系统"
|
||||
}
|
||||
val themeIcon = when (themeMode) {
|
||||
"light" -> Icons.Filled.LightMode
|
||||
"dark" -> Icons.Filled.DarkMode
|
||||
else -> Icons.Filled.SettingsBrightness
|
||||
}
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("主题") },
|
||||
supportingContent = { Text(themeLabel) },
|
||||
leadingContent = { Icon(themeIcon, contentDescription = null) },
|
||||
modifier = Modifier.clickable {
|
||||
val next = when (themeMode) {
|
||||
"light" -> "dark"
|
||||
"dark" -> "auto"
|
||||
else -> "light"
|
||||
}
|
||||
viewModel.saveThemeMode(next)
|
||||
},
|
||||
)
|
||||
|
||||
var showColorDialog by remember { mutableStateOf(false) }
|
||||
val currentColorLabel = PresetColorLabels[themeColor] ?: "昔涟粉"
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("主题色") },
|
||||
supportingContent = { Text(currentColorLabel) },
|
||||
leadingContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.background(
|
||||
color = androidx.compose.ui.graphics.Color(
|
||||
(PresetThemeColors[themeColor]?.seed ?: 0xFFE91E8C).toInt()
|
||||
),
|
||||
shape = CircleShape,
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { showColorDialog = true },
|
||||
)
|
||||
|
||||
if (showColorDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showColorDialog = false },
|
||||
title = { Text("选择主题色") },
|
||||
text = {
|
||||
Column {
|
||||
// Monet option (Android 12+)
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
||||
val isMonet = themeColor == "monet"
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
viewModel.saveThemeColor("monet")
|
||||
showColorDialog = false
|
||||
}
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.background(
|
||||
brush = androidx.compose.ui.graphics.Brush.horizontalGradient(
|
||||
listOf(
|
||||
androidx.compose.ui.graphics.Color(0xFF4ECDC4),
|
||||
androidx.compose.ui.graphics.Color(0xFFFF6B6B),
|
||||
androidx.compose.ui.graphics.Color(0xFFFFE66D),
|
||||
androidx.compose.ui.graphics.Color(0xFF45B7D1),
|
||||
)
|
||||
),
|
||||
shape = CircleShape,
|
||||
)
|
||||
.then(
|
||||
if (isMonet) Modifier.border(
|
||||
3.dp,
|
||||
MaterialTheme.colorScheme.primary,
|
||||
CircleShape,
|
||||
) else Modifier
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "莫奈取色",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = "跟随壁纸",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Preset color chips
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
PresetThemeColors.keys.forEach { key ->
|
||||
val isSelected = themeColor == key
|
||||
val seedColor = androidx.compose.ui.graphics.Color(
|
||||
(PresetThemeColors[key]?.seed ?: 0xFFE91E8C).toInt()
|
||||
)
|
||||
val label = PresetColorLabels[key] ?: key
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
viewModel.saveThemeColor(key)
|
||||
showColorDialog = false
|
||||
}
|
||||
.padding(4.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.background(seedColor, CircleShape)
|
||||
.then(
|
||||
if (isSelected) Modifier.border(
|
||||
3.dp,
|
||||
MaterialTheme.colorScheme.primary,
|
||||
CircleShape,
|
||||
) else Modifier.border(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant,
|
||||
CircleShape,
|
||||
)
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showColorDialog = false }) {
|
||||
Text("关闭")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val indicatorStyleLabel = if (typingIndicatorStyle == "text") "文字" else "气泡"
|
||||
ListItem(
|
||||
headlineContent = { Text("正在输入指示器") },
|
||||
supportingContent = { Text(indicatorStyleLabel) },
|
||||
leadingContent = { Icon(Icons.Filled.Palette, contentDescription = null) },
|
||||
modifier = Modifier.clickable {
|
||||
val next = if (typingIndicatorStyle == "bubble") "text" else "bubble"
|
||||
viewModel.saveTypingIndicatorStyle(next)
|
||||
},
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("回车键发送") },
|
||||
supportingContent = { Text(if (enterToSend) "回车直接发送消息" else "回车换行") },
|
||||
leadingContent = { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) },
|
||||
trailingContent = {
|
||||
androidx.compose.material3.Switch(
|
||||
checked = enterToSend,
|
||||
onCheckedChange = { viewModel.saveEnterToSend(it) },
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { viewModel.saveEnterToSend(!enterToSend) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Background keep-alive
|
||||
val keepAliveManager = remember { KeepAliveManager(context) }
|
||||
var isBatteryExempt by remember { mutableStateOf(keepAliveManager.isBatteryOptimizationExempt()) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
// Re-check battery exemption when returning from system settings
|
||||
LaunchedEffect(lifecycleOwner) {
|
||||
lifecycleOwner.lifecycle.addObserver(object : androidx.lifecycle.DefaultLifecycleObserver {
|
||||
override fun onResume(owner: androidx.lifecycle.LifecycleOwner) {
|
||||
isBatteryExempt = keepAliveManager.isBatteryOptimizationExempt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Hidden root toggle: tap section title 5 times to reveal
|
||||
var rootTapCount by remember { mutableIntStateOf(0) }
|
||||
var rootRevealed by remember { mutableStateOf(false) }
|
||||
|
||||
Text(
|
||||
text = "后台保活",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (!rootRevealed) {
|
||||
rootTapCount++
|
||||
if (rootTapCount >= 5) {
|
||||
rootRevealed = true
|
||||
rootTapCount = 0
|
||||
Toast.makeText(context, "已解锁 Root 保活选项", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("忽略电池优化") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (isBatteryExempt) "已允许,后台连接更稳定"
|
||||
else "未允许,建议开启以确保消息推送及时送达"
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Filled.BatterySaver,
|
||||
contentDescription = null,
|
||||
tint = if (isBatteryExempt) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
if (!isBatteryExempt) {
|
||||
try {
|
||||
context.startActivity(keepAliveManager.openBatteryOptimizationSettings())
|
||||
} catch (_: Exception) {
|
||||
Toast.makeText(context, "无法打开电池优化设置", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Root-level keep-alive (hidden by default, revealed after 5 taps)
|
||||
if (rootRevealed) {
|
||||
val isRootAvailable = remember { RootKeepAliveHelper.isRootAvailable() }
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("Root 保活 (隐藏)") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (!isRootAvailable) "未检测到 Root 权限"
|
||||
else if (rootKeepAlive) "已启用 — 系统级白名单、Doze豁免、强制后台运行"
|
||||
else "使用 Root 权限将应用加入系统级白名单,对抗任何保活限制"
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Filled.Terminal,
|
||||
contentDescription = null,
|
||||
tint = if (rootKeepAlive && isRootAvailable) MaterialTheme.colorScheme.tertiary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = rootKeepAlive,
|
||||
enabled = isRootAvailable,
|
||||
onCheckedChange = { enabled ->
|
||||
if (enabled) {
|
||||
val success = RootKeepAliveHelper.applyRootKeepAlive(context.packageName)
|
||||
if (success) {
|
||||
viewModel.saveRootKeepAlive(true)
|
||||
Toast.makeText(context, "Root 保活已启用", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, "Root 保活应用失败,请检查 Root 权限", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
} else {
|
||||
RootKeepAliveHelper.removeRootKeepAlive(context.packageName)
|
||||
viewModel.saveRootKeepAlive(false)
|
||||
Toast.makeText(context, "Root 保活已关闭", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable(enabled = isRootAvailable) {
|
||||
val newState = !rootKeepAlive
|
||||
if (newState) {
|
||||
val success = RootKeepAliveHelper.applyRootKeepAlive(context.packageName)
|
||||
if (success) {
|
||||
viewModel.saveRootKeepAlive(true)
|
||||
Toast.makeText(context, "Root 保活已启用", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, "Root 保活应用失败", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
} else {
|
||||
RootKeepAliveHelper.removeRootKeepAlive(context.packageName)
|
||||
viewModel.saveRootKeepAlive(false)
|
||||
Toast.makeText(context, "Root 保活已关闭", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// System wakelock toggle (held only while app is alive, reapplied on boot)
|
||||
var sysWakeLockHeld by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text("系统级 WakeLock (隐藏)") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (!isRootAvailable) "需要 Root 权限"
|
||||
else if (sysWakeLockHeld) "已持有系统级内核锁,CPU永不休眠"
|
||||
else "写入 /sys/power/wake_lock 阻止 CPU 休眠"
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Filled.Security,
|
||||
contentDescription = null,
|
||||
tint = if (sysWakeLockHeld) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = sysWakeLockHeld,
|
||||
enabled = isRootAvailable,
|
||||
onCheckedChange = { enable ->
|
||||
if (enable) {
|
||||
val ok = RootKeepAliveHelper.acquireSystemWakeLock("CyreneKA")
|
||||
if (ok) {
|
||||
sysWakeLockHeld = true
|
||||
Toast.makeText(context, "系统 WakeLock 已持有 — 注意:将显著增加耗电", Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
Toast.makeText(context, "WakeLock 获取失败", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
val ok = RootKeepAliveHelper.releaseSystemWakeLock("CyreneKA")
|
||||
if (ok) {
|
||||
sysWakeLockHeld = false
|
||||
Toast.makeText(context, "系统 WakeLock 已释放", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, "WakeLock 释放失败", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("保活设置") },
|
||||
supportingContent = { Text("前台服务、自启动管理、OEM厂商后台白名单") },
|
||||
leadingContent = { Icon(Icons.Filled.Security, contentDescription = null) },
|
||||
modifier = Modifier.clickable { onNavigateToKeepAlive() },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Voice
|
||||
Text(
|
||||
text = "语音",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = wakeWord,
|
||||
onValueChange = { viewModel.saveWakeWord(it) },
|
||||
label = { Text("唤醒词") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("自动读取屏幕内容") },
|
||||
supportingContent = { Text("电源键唤起助手时自动截取当前屏幕内容并加入对话上下文") },
|
||||
trailingContent = {
|
||||
androidx.compose.material3.Switch(
|
||||
checked = autoScreenContext,
|
||||
onCheckedChange = { viewModel.saveAutoScreenContext(it) },
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { viewModel.saveAutoScreenContext(!autoScreenContext) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// DashScope STT
|
||||
Text(
|
||||
text = "语音识别 (DashScope)",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = dashScopeApiKey,
|
||||
onValueChange = { viewModel.updateDashScopeApiKeyInput(it) },
|
||||
label = { Text("API Key") },
|
||||
placeholder = { Text("sk-xxxxxxxxxxxxxxxx") },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeApiKey(dashScopeApiKey)
|
||||
Toast.makeText(context, "API Key 已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
),
|
||||
trailingIcon = {
|
||||
FilledTonalIconButton(onClick = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeApiKey(dashScopeApiKey)
|
||||
Toast.makeText(context, "API Key 已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "保存")
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = dashScopeEndpoint,
|
||||
onValueChange = { viewModel.updateDashScopeEndpointInput(it) },
|
||||
label = { Text("WebSocket 端点") },
|
||||
placeholder = { Text("wss://dashscope.aliyuncs.com/api-ws/v1/inference") },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeEndpoint(dashScopeEndpoint)
|
||||
Toast.makeText(context, "端点已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
),
|
||||
trailingIcon = {
|
||||
FilledTonalIconButton(onClick = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeEndpoint(dashScopeEndpoint)
|
||||
Toast.makeText(context, "端点已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "保存")
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = dashScopeModel,
|
||||
onValueChange = { viewModel.updateDashScopeModelInput(it) },
|
||||
label = { Text("模型") },
|
||||
placeholder = { Text("fun-asr-realtime") },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeModel(dashScopeModel)
|
||||
Toast.makeText(context, "模型已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
),
|
||||
trailingIcon = {
|
||||
FilledTonalIconButton(onClick = {
|
||||
scope.launch {
|
||||
viewModel.saveDashScopeModel(dashScopeModel)
|
||||
Toast.makeText(context, "模型已保存", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "保存")
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "未配置 API Key 时,语音输入将自动使用后端服务处理。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Data management
|
||||
Text(
|
||||
text = "数据",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
var showClearDialog by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text("清空本地消息记录") },
|
||||
supportingContent = { Text("仅清除本地数据库,服务器消息仍保留。下次加载将只获取清除时间之后的消息。") },
|
||||
leadingContent = { Icon(Icons.Filled.DeleteForever, contentDescription = null, tint = MaterialTheme.colorScheme.error) },
|
||||
modifier = Modifier.clickable { showClearDialog = true },
|
||||
)
|
||||
|
||||
if (showClearDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showClearDialog = false },
|
||||
title = { Text("确认清空") },
|
||||
text = { Text("将清空所有本地消息记录。服务器上的消息不会被删除,但下次加载历史时将只获取本次清除之后的消息。") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showClearDialog = false
|
||||
val activity = context as? FragmentActivity
|
||||
if (activity == null) {
|
||||
viewModel.clearLocalMessages()
|
||||
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
|
||||
return@TextButton
|
||||
}
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
val canAuth = when (biometricManager.canAuthenticate(
|
||||
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
)) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> true
|
||||
else -> false
|
||||
}
|
||||
if (!canAuth) {
|
||||
viewModel.clearLocalMessages()
|
||||
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
|
||||
return@TextButton
|
||||
}
|
||||
val prompt = BiometricPrompt(
|
||||
activity,
|
||||
ContextCompat.getMainExecutor(context),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(
|
||||
result: BiometricPrompt.AuthenticationResult,
|
||||
) {
|
||||
viewModel.clearLocalMessages()
|
||||
Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
Toast.makeText(context, "验证失败", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(
|
||||
errorCode: Int,
|
||||
errString: CharSequence,
|
||||
) {
|
||||
Toast.makeText(context, "验证取消", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
prompt.authenticate(
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("身份验证")
|
||||
.setSubtitle("需要验证身份后才能清空消息记录")
|
||||
.setAllowedAuthenticators(
|
||||
BiometricManager.Authenticators.BIOMETRIC_STRONG or
|
||||
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Text("清空")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showClearDialog = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Runtime logs
|
||||
Text(
|
||||
text = "运行日志",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
|
||||
val logEntries by RuntimeLog.entries.collectAsState()
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
val tabs = listOf("全部") + LogCategory.entries.map { it.label }
|
||||
val allCategories = LogCategory.entries.toList()
|
||||
|
||||
// Scroll to bottom
|
||||
val scrollState = rememberScrollState()
|
||||
androidx.compose.runtime.LaunchedEffect(logEntries.size, selectedTab) {
|
||||
if (logEntries.isNotEmpty()) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
}
|
||||
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
edgePadding = 16.dp,
|
||||
divider = {},
|
||||
) {
|
||||
tabs.forEachIndexed { index, label ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(label, maxLines = 1, style = MaterialTheme.typography.labelMedium) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val currentCategory = if (selectedTab == 0) null else allCategories.getOrNull(selectedTab - 1)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val file = RuntimeLog.exportToFile(context, currentCategory)
|
||||
RuntimeLog.shareFile(context, file)
|
||||
},
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(if (currentCategory != null) "导出${currentCategory.label}" else "导出全部")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val file = RuntimeLog.exportAllAsZip(context)
|
||||
RuntimeLog.shareFile(context, file)
|
||||
},
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("打包全部")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { RuntimeLog.clear() },
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Text("清空")
|
||||
}
|
||||
}
|
||||
|
||||
val displayLogs = if (selectedTab == 0) {
|
||||
logEntries.takeLast(500)
|
||||
} else {
|
||||
val cat = allCategories.getOrNull(selectedTab - 1)
|
||||
if (cat != null) logEntries.filter { it.category == cat }.takeLast(500) else emptyList()
|
||||
}
|
||||
|
||||
if (displayLogs.isEmpty()) {
|
||||
Text(
|
||||
text = "暂无${tabs[selectedTab]}日志",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
} else {
|
||||
// Log count header
|
||||
Text(
|
||||
text = "共 ${displayLogs.size} 条${if (displayLogs.size >= 500) "+" else ""}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
// Fixed-height scrollable log area
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.height(280.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
RoundedCornerShape(8.dp),
|
||||
)
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(scrollState),
|
||||
) {
|
||||
displayLogs.forEach { entry ->
|
||||
Text(
|
||||
text = entry.formatted(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package top.yeij.cyrene.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
// Each preset provides a seed color and light/dark primary colors
|
||||
data class ThemePreset(
|
||||
val seed: Long,
|
||||
val lightPrimary: Color,
|
||||
val darkPrimary: Color,
|
||||
)
|
||||
|
||||
val PresetThemeColors = mapOf(
|
||||
"pink" to ThemePreset(
|
||||
seed = 0xFFE91E8C,
|
||||
lightPrimary = Color(0xFFC2185B),
|
||||
darkPrimary = Color(0xFFFF80AB),
|
||||
),
|
||||
"sakura" to ThemePreset(
|
||||
seed = 0xFFFFB4C8,
|
||||
lightPrimary = Color(0xFFE91E63),
|
||||
darkPrimary = Color(0xFFFFB4C8),
|
||||
),
|
||||
"lavender" to ThemePreset(
|
||||
seed = 0xFF6D3BC0,
|
||||
lightPrimary = Color(0xFF6D3BC0),
|
||||
darkPrimary = Color(0xFFD3BBFF),
|
||||
),
|
||||
"ocean" to ThemePreset(
|
||||
seed = 0xFF1565C0,
|
||||
lightPrimary = Color(0xFF1565C0),
|
||||
darkPrimary = Color(0xFF90CAF9),
|
||||
),
|
||||
"forest" to ThemePreset(
|
||||
seed = 0xFF2E7D32,
|
||||
lightPrimary = Color(0xFF2E7D32),
|
||||
darkPrimary = Color(0xFFA5D6A7),
|
||||
),
|
||||
"sunset" to ThemePreset(
|
||||
seed = 0xFFE65100,
|
||||
lightPrimary = Color(0xFFE65100),
|
||||
darkPrimary = Color(0xFFFFCC80),
|
||||
),
|
||||
"rose" to ThemePreset(
|
||||
seed = 0xFFD81B60,
|
||||
lightPrimary = Color(0xFFAD1457),
|
||||
darkPrimary = Color(0xFFF48FB1),
|
||||
),
|
||||
"sky" to ThemePreset(
|
||||
seed = 0xFF0277BD,
|
||||
lightPrimary = Color(0xFF0277BD),
|
||||
darkPrimary = Color(0xFF81D4FA),
|
||||
),
|
||||
"mint" to ThemePreset(
|
||||
seed = 0xFF00695C,
|
||||
lightPrimary = Color(0xFF00695C),
|
||||
darkPrimary = Color(0xFF80CBC4),
|
||||
),
|
||||
)
|
||||
|
||||
val PresetColorLabels = mapOf(
|
||||
"pink" to "昔涟粉",
|
||||
"sakura" to "樱花粉",
|
||||
"lavender" to "薰衣草紫",
|
||||
"ocean" to "海洋蓝",
|
||||
"forest" to "森林绿",
|
||||
"sunset" to "日落橙",
|
||||
"rose" to "玫瑰红",
|
||||
"sky" to "天空蓝",
|
||||
"mint" to "薄荷青",
|
||||
)
|
||||
|
||||
fun getPreset(key: String): ThemePreset = PresetThemeColors[key] ?: PresetThemeColors["pink"]!!
|
||||
|
||||
// --- Color derivation via HSL — generates cohesive MD3-like schemes ---
|
||||
|
||||
private data class HSL(val h: Float, val s: Float, val l: Float)
|
||||
|
||||
private fun Color.toHSL(): HSL {
|
||||
val r = red / 255f
|
||||
val g = green / 255f
|
||||
val b = blue / 255f
|
||||
val maxV = max(max(r, g), b)
|
||||
val minV = min(min(r, g), b)
|
||||
val delta = maxV - minV
|
||||
val l = (maxV + minV) / 2f
|
||||
val s = if (delta == 0f) 0f else delta / (1f - abs(2f * l - 1f))
|
||||
val h = when {
|
||||
delta == 0f -> 0f
|
||||
maxV == r -> 60f * (((g - b) / delta) % 6f)
|
||||
maxV == g -> 60f * (((b - r) / delta) + 2f)
|
||||
else -> 60f * (((r - g) / delta) + 4f)
|
||||
}
|
||||
return HSL(if (h < 0) h + 360f else h, s, l)
|
||||
}
|
||||
|
||||
private fun hslToColor(h: Float, s: Float, l: Float): Color {
|
||||
val c = (1f - abs(2f * l - 1f)) * s
|
||||
val x = c * (1f - abs((h / 60f) % 2f - 1f))
|
||||
val m = l - c / 2f
|
||||
val (r, g, b) = when {
|
||||
h < 60f -> Triple(c, x, 0f)
|
||||
h < 120f -> Triple(x, c, 0f)
|
||||
h < 180f -> Triple(0f, c, x)
|
||||
h < 240f -> Triple(0f, x, c)
|
||||
h < 300f -> Triple(x, 0f, c)
|
||||
else -> Triple(c, 0f, x)
|
||||
}
|
||||
return Color((r + m).coerceIn(0f, 1f), (g + m).coerceIn(0f, 1f), (b + m).coerceIn(0f, 1f))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full light ColorScheme derived from a primary color.
|
||||
* All secondary/tertiary/container colors are computed from the primary
|
||||
* so focus rings, ripples, badges, and containers all match the theme.
|
||||
*/
|
||||
fun buildLightScheme(primary: Color): ColorScheme {
|
||||
val hsl = primary.toHSL()
|
||||
|
||||
val primaryContainer = hslToColor(hsl.h, 0.3f, 0.90f)
|
||||
val onPrimaryContainer = hslToColor(hsl.h, 0.5f, 0.15f)
|
||||
|
||||
// Secondary: similar hue, less saturated
|
||||
val secH = (hsl.h + 15f) % 360f
|
||||
val secondary = hslToColor(secH, 0.35f, 0.42f)
|
||||
val secondaryContainer = hslToColor(secH, 0.25f, 0.90f)
|
||||
val onSecondaryContainer = hslToColor(secH, 0.3f, 0.15f)
|
||||
|
||||
// Tertiary: complementary hue shift
|
||||
val terH = (hsl.h + 60f) % 360f
|
||||
val tertiary = hslToColor(terH, 0.40f, 0.38f)
|
||||
val tertiaryContainer = hslToColor(terH, 0.30f, 0.90f)
|
||||
val onTertiaryContainer = hslToColor(terH, 0.35f, 0.15f)
|
||||
|
||||
val onPrimary = Color.White
|
||||
val onSecondary = Color.White
|
||||
val onTertiary = Color.White
|
||||
val surfaceTint = primary
|
||||
|
||||
return lightColorScheme(
|
||||
primary = primary,
|
||||
onPrimary = onPrimary,
|
||||
primaryContainer = primaryContainer,
|
||||
onPrimaryContainer = onPrimaryContainer,
|
||||
secondary = secondary,
|
||||
onSecondary = onSecondary,
|
||||
secondaryContainer = secondaryContainer,
|
||||
onSecondaryContainer = onSecondaryContainer,
|
||||
tertiary = tertiary,
|
||||
onTertiary = onTertiary,
|
||||
tertiaryContainer = tertiaryContainer,
|
||||
onTertiaryContainer = onTertiaryContainer,
|
||||
surfaceTint = surfaceTint,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full dark ColorScheme derived from a primary color.
|
||||
*/
|
||||
fun buildDarkScheme(primary: Color): ColorScheme {
|
||||
val hsl = primary.toHSL()
|
||||
|
||||
val primaryContainer = hslToColor(hsl.h, 0.40f, 0.22f)
|
||||
val onPrimaryContainer = hslToColor(hsl.h, 0.30f, 0.88f)
|
||||
val onPrimary = hslToColor(hsl.h, 0.5f, 0.10f)
|
||||
|
||||
val secH = (hsl.h + 15f) % 360f
|
||||
val secondary = hslToColor(secH, 0.40f, 0.76f)
|
||||
val secondaryContainer = hslToColor(secH, 0.30f, 0.22f)
|
||||
val onSecondaryContainer = hslToColor(secH, 0.30f, 0.88f)
|
||||
val onSecondary = hslToColor(secH, 0.3f, 0.12f)
|
||||
|
||||
val terH = (hsl.h + 60f) % 360f
|
||||
val tertiary = hslToColor(terH, 0.40f, 0.80f)
|
||||
val tertiaryContainer = hslToColor(terH, 0.30f, 0.22f)
|
||||
val onTertiaryContainer = hslToColor(terH, 0.30f, 0.88f)
|
||||
val onTertiary = hslToColor(terH, 0.3f, 0.12f)
|
||||
|
||||
val surfaceTint = primary
|
||||
|
||||
return darkColorScheme(
|
||||
primary = primary,
|
||||
onPrimary = onPrimary,
|
||||
primaryContainer = primaryContainer,
|
||||
onPrimaryContainer = onPrimaryContainer,
|
||||
secondary = secondary,
|
||||
onSecondary = onSecondary,
|
||||
secondaryContainer = secondaryContainer,
|
||||
onSecondaryContainer = onSecondaryContainer,
|
||||
tertiary = tertiary,
|
||||
onTertiary = onTertiary,
|
||||
tertiaryContainer = tertiaryContainer,
|
||||
onTertiaryContainer = onTertiaryContainer,
|
||||
surfaceTint = surfaceTint,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package top.yeij.cyrene.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
val CyreneShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(8.dp),
|
||||
small = RoundedCornerShape(12.dp),
|
||||
medium = RoundedCornerShape(16.dp),
|
||||
large = RoundedCornerShape(24.dp),
|
||||
extraLarge = RoundedCornerShape(32.dp),
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
package top.yeij.cyrene.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
@Composable
|
||||
fun CyreneTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
presetKey: String = "pink",
|
||||
useDynamicColor: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val preset = getPreset(presetKey)
|
||||
|
||||
val colorScheme = when {
|
||||
useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> buildDarkScheme(preset.darkPrimary)
|
||||
else -> buildLightScheme(preset.lightPrimary)
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as? Activity)?.window
|
||||
if (window != null) {
|
||||
window.statusBarColor = colorScheme.background.toArgb()
|
||||
window.navigationBarColor = colorScheme.background.toArgb()
|
||||
window.decorView.setBackgroundColor(colorScheme.background.toArgb())
|
||||
WindowCompat.getInsetsController(window, view).apply {
|
||||
isAppearanceLightStatusBars = !darkTheme
|
||||
isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
} else {
|
||||
view.rootView?.setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = CyreneTypography,
|
||||
shapes = CyreneShapes,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package top.yeij.cyrene.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val CyreneTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package top.yeij.cyrene.util
|
||||
|
||||
object Constants {
|
||||
const val DEFAULT_WAKE_WORD = "昔涟"
|
||||
const val SILENCE_TIMEOUT_MS = 10_000L
|
||||
const val WS_RECONNECT_MAX_ATTEMPTS = 5
|
||||
const val WS_PING_INTERVAL_SECONDS = 30L
|
||||
const val HOTWORD_INACTIVITY_TIMEOUT_MINUTES = 10L
|
||||
const val OVERLAY_ANIM_DURATION_MS = 300
|
||||
const val STT_SILENCE_THRESHOLD_MS = 1500L
|
||||
|
||||
// Intent extras
|
||||
const val EXTRA_VOICE_ASSIST = "voice_assist"
|
||||
const val EXTRA_START_LISTENING = "start_listening"
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package top.yeij.cyrene.util
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import top.yeij.cyrene.service.WebSocketKeepAliveService
|
||||
|
||||
class KeepAliveManager(private val context: Context) {
|
||||
|
||||
// --- 前台服务 ---
|
||||
|
||||
val isForegroundServiceRunning: Boolean
|
||||
get() = WebSocketKeepAliveService.isRunning
|
||||
|
||||
// --- 电池优化 ---
|
||||
|
||||
fun isBatteryOptimizationExempt(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
return pm.isIgnoringBatteryOptimizations(context.packageName)
|
||||
}
|
||||
|
||||
fun openBatteryOptimizationSettings(): Intent {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
}
|
||||
} else {
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 自启动 / 后台管理 (OEM-specific) ---
|
||||
|
||||
fun getAutoStartIntent(): Intent? {
|
||||
val packageName = context.packageName
|
||||
val manufacturers = listOf(
|
||||
// Xiaomi
|
||||
AutoStartIntent("xiaomi", Intent().apply {
|
||||
component = ComponentName(
|
||||
"com.miui.securitycenter",
|
||||
"com.miui.permcenter.autostart.AutoStartManagementActivity"
|
||||
)
|
||||
}),
|
||||
AutoStartIntent("xiaomi", Intent().apply {
|
||||
component = ComponentName(
|
||||
"com.miui.securitycenter",
|
||||
"com.miui.appmanager.ApplicationsManagerActivity"
|
||||
)
|
||||
}),
|
||||
// Huawei
|
||||
AutoStartIntent("huawei", Intent().apply {
|
||||
component = ComponentName(
|
||||
"com.huawei.systemmanager",
|
||||
"com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity"
|
||||
)
|
||||
}),
|
||||
AutoStartIntent("huawei", Intent().apply {
|
||||
component = ComponentName(
|
||||
"com.huawei.systemmanager",
|
||||
"com.huawei.systemmanager.optimize.process.ProtectActivity"
|
||||
)
|
||||
}),
|
||||
// Oppo
|
||||
AutoStartIntent("oppo", Intent().apply {
|
||||
component = ComponentName(
|
||||
"com.coloros.safecenter",
|
||||
"com.coloros.safecenter.permission.startup.StartupAppListActivity"
|
||||
)
|
||||
}),
|
||||
AutoStartIntent("oppo", Intent().apply {
|
||||
component = ComponentName(
|
||||
"com.coloros.safecenter",
|
||||
"com.coloros.safecenter.permission.startup.FakeActivity"
|
||||
)
|
||||
}),
|
||||
// Vivo
|
||||
AutoStartIntent("vivo", Intent().apply {
|
||||
component = ComponentName(
|
||||
"com.vivo.permissionmanager",
|
||||
"com.vivo.permissionmanager.activity.BgStartUpManagerActivity"
|
||||
)
|
||||
}),
|
||||
AutoStartIntent("vivo", Intent().apply {
|
||||
component = ComponentName(
|
||||
"com.iqoo.secure",
|
||||
"com.iqoo.secure.ui.phoneoptimize.AddWhiteListActivity"
|
||||
)
|
||||
}),
|
||||
// Samsung
|
||||
AutoStartIntent("samsung", Intent().apply {
|
||||
component = ComponentName(
|
||||
"com.samsung.android.lool",
|
||||
"com.samsung.android.sm.ui.battery.BatteryActivity"
|
||||
)
|
||||
}),
|
||||
// OnePlus
|
||||
AutoStartIntent("oneplus", Intent().apply {
|
||||
component = ComponentName(
|
||||
"com.oneplus.security",
|
||||
"com.oneplus.security.chainlaunch.view.ChainLaunchAppListActivity"
|
||||
)
|
||||
}),
|
||||
// Generic fallback: app info
|
||||
AutoStartIntent("generic", Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.parse("package:$packageName")
|
||||
)),
|
||||
)
|
||||
|
||||
for (entry in manufacturers) {
|
||||
val intent = entry.intent
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
return intent
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getManufacturerName(): String {
|
||||
return Build.MANUFACTURER.lowercase()
|
||||
}
|
||||
|
||||
private data class AutoStartIntent(val manufacturer: String, val intent: Intent)
|
||||
|
||||
// --- 悬浮窗权限 (optional, for overlay mode) ---
|
||||
|
||||
fun canDrawOverlays(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Settings.canDrawOverlays(context)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun openOverlaySettings(): Intent {
|
||||
return Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
Uri.parse("package:${context.packageName}")
|
||||
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package top.yeij.cyrene.util
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import top.yeij.cyrene.MainActivity
|
||||
import top.yeij.cyrene.R
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
|
||||
class NotificationHelper(private val context: Context) {
|
||||
|
||||
private val notificationManager = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
init {
|
||||
createChannel()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"消息通知",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = "昔涟的新消息通知"
|
||||
enableVibration(true)
|
||||
setShowBadge(true)
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun showMessageNotification(message: Message) {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, 0, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val preview = if (message.content.length > 50) {
|
||||
message.content.take(50) + "..."
|
||||
} else {
|
||||
message.content
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("昔涟")
|
||||
.setContentText(preview)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.build()
|
||||
|
||||
val notifyId = message.id.hashCode()
|
||||
notificationManager.notify(notifyId, notification)
|
||||
RuntimeLog.notify("posted", "Notification posted: id=$notifyId preview='$preview'")
|
||||
}
|
||||
|
||||
fun cancelAll() {
|
||||
notificationManager.cancelAll()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "cyrene_messages"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package top.yeij.cyrene.util
|
||||
|
||||
import android.util.Log
|
||||
import java.io.DataOutputStream
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Hidden root-based keep-alive operations. Only accessible via secret gesture in Settings.
|
||||
* Performs system-level whitelisting that normal apps cannot do.
|
||||
*/
|
||||
object RootKeepAliveHelper {
|
||||
|
||||
private const val TAG = "CyreneRootKA"
|
||||
|
||||
private fun execRoot(vararg commands: String): Boolean {
|
||||
return try {
|
||||
val process = Runtime.getRuntime().exec("su")
|
||||
val os = DataOutputStream(process.outputStream)
|
||||
val shell = buildString {
|
||||
append("export PATH=\$PATH:/system/bin:/system/xbin:/su/bin:/sbin:/vendor/bin\n")
|
||||
for (cmd in commands) {
|
||||
append(cmd).append(" 2>&1\n")
|
||||
}
|
||||
append("exit\n")
|
||||
}
|
||||
os.writeBytes(shell)
|
||||
os.flush()
|
||||
os.close()
|
||||
process.waitFor()
|
||||
val exitCode = process.exitValue()
|
||||
if (exitCode != 0) {
|
||||
Log.w(TAG, "Root command returned exit code $exitCode")
|
||||
}
|
||||
exitCode == 0
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Root exec failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun isRootAvailable(): Boolean {
|
||||
// Check common su binary locations
|
||||
val suPaths = listOf(
|
||||
"/system/bin/su",
|
||||
"/system/xbin/su",
|
||||
"/su/bin/su",
|
||||
"/sbin/su",
|
||||
"/system/sbin/su",
|
||||
"/vendor/bin/su",
|
||||
"/data/local/bin/su",
|
||||
)
|
||||
for (path in suPaths) {
|
||||
if (File(path).exists()) return true
|
||||
}
|
||||
|
||||
// Fallback: try running 'which su'
|
||||
return try {
|
||||
val p = Runtime.getRuntime().exec(arrayOf("which", "su"))
|
||||
p.waitFor()
|
||||
p.exitValue() == 0
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply aggressive root-level keep-alive.
|
||||
* @param packageName The app's package name.
|
||||
*/
|
||||
fun applyRootKeepAlive(packageName: String): Boolean {
|
||||
if (!isRootAvailable()) {
|
||||
Log.w(TAG, "Root not available, cannot apply root keep-alive")
|
||||
return false
|
||||
}
|
||||
|
||||
Log.i(TAG, "Applying root keep-alive for $packageName")
|
||||
|
||||
val commands = mutableListOf<String>()
|
||||
|
||||
// 1. Doze whitelist — prevent Doze from blocking network/wakelocks
|
||||
commands.add("dumpsys deviceidle whitelist +$packageName")
|
||||
|
||||
// 2. Disable standby bucket — keep app in "active" bucket
|
||||
commands.add("am set-standby-bucket $packageName active")
|
||||
|
||||
// 3. Grant WAKE_LOCK permission at system level (bypasses appops)
|
||||
commands.add("appops set $packageName WAKE_LOCK allow")
|
||||
|
||||
// 4. Disable battery optimization via system settings
|
||||
commands.add("settings put global app_restrictions_enabled false")
|
||||
commands.add("cmd appops set $packageName RUN_IN_BACKGROUND allow")
|
||||
commands.add("cmd appops set $packageName RUN_ANY_IN_BACKGROUND allow")
|
||||
|
||||
// 5. Make app not battery-restricted (hidden API)
|
||||
commands.add("cmd deviceidle tempwhitelist $packageName")
|
||||
|
||||
// 6. Set app as START_FOREGROUND always allowed
|
||||
commands.add("appops set $packageName START_FOREGROUND allow")
|
||||
|
||||
// 7. Persistent alarm allowance
|
||||
commands.add("appops set $packageName SCHEDULE_EXACT_ALARM allow")
|
||||
|
||||
val success = execRoot(*commands.toTypedArray())
|
||||
if (success) {
|
||||
Log.i(TAG, "Root keep-alive applied successfully")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to apply root keep-alive")
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove root-level keep-alive settings.
|
||||
*/
|
||||
fun removeRootKeepAlive(packageName: String): Boolean {
|
||||
if (!isRootAvailable()) return false
|
||||
|
||||
Log.i(TAG, "Removing root keep-alive for $packageName")
|
||||
|
||||
val commands = listOf(
|
||||
"dumpsys deviceidle whitelist -$packageName",
|
||||
"am set-standby-bucket $packageName rarely",
|
||||
"appops set $packageName WAKE_LOCK default",
|
||||
"appops set $packageName RUN_IN_BACKGROUND default",
|
||||
"appops set $packageName RUN_ANY_IN_BACKGROUND default",
|
||||
"appops set $packageName START_FOREGROUND default",
|
||||
"appops set $packageName SCHEDULE_EXACT_ALARM default",
|
||||
)
|
||||
|
||||
val success = execRoot(*commands.toTypedArray())
|
||||
if (success) {
|
||||
Log.i(TAG, "Root keep-alive removed successfully")
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold a system-level wakelock. Use sparingly — drains battery.
|
||||
* Released automatically when the process exits or can be released manually.
|
||||
*/
|
||||
private var wakeLockFile: File? = null
|
||||
|
||||
fun acquireSystemWakeLock(tag: String): Boolean {
|
||||
if (!isRootAvailable()) return false
|
||||
val lockPath = "/sys/power/wake_lock"
|
||||
return try {
|
||||
execRoot("echo '$tag' > $lockPath")
|
||||
wakeLockFile = File(lockPath)
|
||||
Log.i(TAG, "System wakelock acquired: $tag")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to acquire system wakelock: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseSystemWakeLock(tag: String): Boolean {
|
||||
if (!isRootAvailable()) return false
|
||||
val lockPath = "/sys/power/wake_unlock"
|
||||
return try {
|
||||
execRoot("echo '$tag' > $lockPath")
|
||||
wakeLockFile = null
|
||||
Log.i(TAG, "System wakelock released: $tag")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to release system wakelock: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package top.yeij.cyrene.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
enum class LogCategory(val label: String) {
|
||||
CHAT("聊天"),
|
||||
WS("WebSocket"),
|
||||
STT("语音识别"),
|
||||
GENERAL("通用"),
|
||||
HTTP("网络"),
|
||||
VOICE("语音"),
|
||||
NOTIFY("通知"),
|
||||
}
|
||||
|
||||
data class LogEntry(
|
||||
val timestamp: Long,
|
||||
val category: LogCategory,
|
||||
val tag: String,
|
||||
val message: String,
|
||||
) {
|
||||
fun formatted(): String {
|
||||
val sdf = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||
return "[${sdf.format(Date(timestamp))}] [${category.name}] [$tag] $message"
|
||||
}
|
||||
}
|
||||
|
||||
object RuntimeLog {
|
||||
|
||||
private const val MAX_ENTRIES = 2000
|
||||
|
||||
private val _entries = MutableStateFlow<List<LogEntry>>(emptyList())
|
||||
val entries: StateFlow<List<LogEntry>> = _entries.asStateFlow()
|
||||
|
||||
private val buffer = ArrayDeque<LogEntry>(MAX_ENTRIES)
|
||||
|
||||
@Synchronized
|
||||
fun log(category: LogCategory, tag: String, message: String) {
|
||||
val entry = LogEntry(System.currentTimeMillis(), category, tag, message)
|
||||
if (buffer.size >= MAX_ENTRIES) {
|
||||
buffer.removeFirst()
|
||||
}
|
||||
buffer.addLast(entry)
|
||||
_entries.value = buffer.toList()
|
||||
}
|
||||
|
||||
fun chat(tag: String, message: String) = log(LogCategory.CHAT, tag, message)
|
||||
fun ws(tag: String, message: String) = log(LogCategory.WS, tag, message)
|
||||
fun stt(tag: String, message: String) = log(LogCategory.STT, tag, message)
|
||||
fun general(tag: String, message: String) = log(LogCategory.GENERAL, tag, message)
|
||||
fun http(tag: String, message: String) = log(LogCategory.HTTP, tag, message)
|
||||
fun voice(tag: String, message: String) = log(LogCategory.VOICE, tag, message)
|
||||
fun notify(tag: String, message: String) = log(LogCategory.NOTIFY, tag, message)
|
||||
|
||||
@Synchronized
|
||||
fun getByCategory(category: LogCategory): List<LogEntry> {
|
||||
return buffer.filter { it.category == category }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun exportAsText(category: LogCategory): String {
|
||||
val entries = buffer.filter { it.category == category }
|
||||
val header = "=== Cyrene ${category.label}日志 导出时间: ${
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
|
||||
} ===\n\n"
|
||||
return header + entries.joinToString("\n") { it.formatted() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun exportAllAsText(): String {
|
||||
val header = "=== Cyrene 全部日志 导出时间: ${
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
|
||||
} ===\n\n"
|
||||
val byCategory = buffer.groupBy { it.category }
|
||||
return header + byCategory.entries.joinToString("\n\n") { (cat, entries) ->
|
||||
"--- ${cat.label} (${cat.name}) ---\n" + entries.joinToString("\n") { it.formatted() }
|
||||
}
|
||||
}
|
||||
|
||||
fun exportToFile(context: Context, category: LogCategory? = null): File {
|
||||
val text = if (category != null) exportAsText(category) else exportAllAsText()
|
||||
val label = category?.name?.lowercase() ?: "all"
|
||||
val dateStr = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||
val file = File(context.cacheDir, "cyrene_log_${label}_$dateStr.txt")
|
||||
file.writeText(text)
|
||||
return file
|
||||
}
|
||||
|
||||
fun exportAllAsZip(context: Context): File {
|
||||
val dateStr = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||
val zipFile = File(context.cacheDir, "cyrene_logs_$dateStr.zip")
|
||||
ZipOutputStream(zipFile.outputStream().buffered()).use { zos ->
|
||||
LogCategory.entries.forEach { cat ->
|
||||
val text = exportAsText(cat)
|
||||
zos.putNextEntry(ZipEntry("${cat.name.lowercase()}.txt"))
|
||||
zos.write(text.toByteArray(Charsets.UTF_8))
|
||||
zos.closeEntry()
|
||||
}
|
||||
}
|
||||
return zipFile
|
||||
}
|
||||
|
||||
fun shareFile(context: Context, file: File) {
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = if (file.extension == "zip") "application/zip" else "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(intent, "导出日志"))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
buffer.clear()
|
||||
_entries.value = emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package top.yeij.cyrene.util
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
enum class RecordState {
|
||||
IDLE,
|
||||
RECORDING,
|
||||
LOCKED, // hands-free mode
|
||||
CANCELLING,
|
||||
}
|
||||
|
||||
class VoiceRecorder(private val context: Context) {
|
||||
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var outputFile: File? = null
|
||||
private var timerJob: Job? = null
|
||||
|
||||
private val _state = MutableStateFlow(RecordState.IDLE)
|
||||
val state: StateFlow<RecordState> = _state.asStateFlow()
|
||||
|
||||
private val _durationMs = MutableStateFlow(0L)
|
||||
val durationMs: StateFlow<Long> = _durationMs.asStateFlow()
|
||||
|
||||
fun start() {
|
||||
if (_state.value != RecordState.IDLE) return
|
||||
try {
|
||||
outputFile = File(context.cacheDir, "voice_${System.currentTimeMillis()}.aac")
|
||||
outputFile?.delete()
|
||||
|
||||
recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(context)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaRecorder()
|
||||
}
|
||||
|
||||
recorder?.apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
setAudioSamplingRate(16000)
|
||||
setAudioEncodingBitRate(32000)
|
||||
setAudioChannels(1)
|
||||
setOutputFile(outputFile?.absolutePath)
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
|
||||
_state.value = RecordState.RECORDING
|
||||
startTimer()
|
||||
Log.i(TAG, "Recording started: ${outputFile?.absolutePath}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start recording: ${e.message}", e)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
fun lock() {
|
||||
if (_state.value == RecordState.RECORDING) {
|
||||
_state.value = RecordState.LOCKED
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(): File? {
|
||||
if (_state.value == RecordState.IDLE) return null
|
||||
val file = outputFile
|
||||
try {
|
||||
recorder?.apply {
|
||||
try { stop() } catch (_: Exception) { }
|
||||
try { release() } catch (_: Exception) { }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error stopping recorder: ${e.message}", e)
|
||||
}
|
||||
recorder = null
|
||||
cancelTimer()
|
||||
_state.value = RecordState.IDLE
|
||||
_durationMs.value = 0L
|
||||
Log.i(TAG, "Recording stopped: ${file?.absolutePath}, size=${file?.length()}")
|
||||
return if (file != null && file.exists() && file.length() > 0) file else null
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
val file = outputFile
|
||||
try {
|
||||
recorder?.apply {
|
||||
try { stop() } catch (_: Exception) { }
|
||||
try { release() } catch (_: Exception) { }
|
||||
}
|
||||
} catch (e: Exception) { }
|
||||
recorder = null
|
||||
file?.delete()
|
||||
cancelTimer()
|
||||
_state.value = RecordState.IDLE
|
||||
_durationMs.value = 0L
|
||||
Log.i(TAG, "Recording cancelled")
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
try { recorder?.release() } catch (_: Exception) { }
|
||||
recorder = null
|
||||
outputFile?.delete()
|
||||
outputFile = null
|
||||
cancelTimer()
|
||||
_state.value = RecordState.IDLE
|
||||
_durationMs.value = 0L
|
||||
}
|
||||
|
||||
fun getBase64(): String? {
|
||||
val file = outputFile ?: return null
|
||||
if (!file.exists()) return null
|
||||
return try {
|
||||
val bytes = file.readBytes()
|
||||
Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to encode audio: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteFile() {
|
||||
outputFile?.delete()
|
||||
outputFile = null
|
||||
}
|
||||
|
||||
private fun startTimer() {
|
||||
cancelTimer()
|
||||
val startTime = System.currentTimeMillis()
|
||||
timerJob = CoroutineScope(Dispatchers.Main).launch {
|
||||
while (true) {
|
||||
delay(100)
|
||||
_durationMs.value = System.currentTimeMillis() - startTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelTimer() {
|
||||
timerJob?.cancel()
|
||||
timerJob = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "VoiceRecorder"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package top.yeij.cyrene.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.ApiService
|
||||
import top.yeij.cyrene.data.remote.dto.WSAttachment
|
||||
import top.yeij.cyrene.domain.model.Conversation
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import top.yeij.cyrene.util.RecordState
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import top.yeij.cyrene.util.VoiceRecorder
|
||||
|
||||
private fun List<Message>.deduplicate(): List<Message> {
|
||||
if (isEmpty()) return this
|
||||
val seen = mutableSetOf<String>()
|
||||
return filter { seen.add(it.id) }
|
||||
}
|
||||
|
||||
class ChatViewModel(
|
||||
application: Application,
|
||||
private val chatRepository: ChatRepository,
|
||||
private val voiceRecorder: VoiceRecorder,
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
private val apiService: ApiService,
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
companion object {
|
||||
private var instanceCounter = 0
|
||||
}
|
||||
private val instanceId = ++instanceCounter
|
||||
|
||||
val isConnected: StateFlow<Boolean> = chatRepository.connectionState
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
val connectionError: StateFlow<String?> = chatRepository.connectionError
|
||||
|
||||
val conversations: StateFlow<List<Conversation>> = chatRepository.getConversations()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
private val _currentMessages = MutableStateFlow<List<Message>>(emptyList())
|
||||
val currentMessages: StateFlow<List<Message>> = _currentMessages.asStateFlow()
|
||||
|
||||
private val _inputText = MutableStateFlow("")
|
||||
val inputText: StateFlow<String> = _inputText.asStateFlow()
|
||||
|
||||
private val _isSending = MutableStateFlow(false)
|
||||
val isStreaming: StateFlow<Boolean> = kotlinx.coroutines.flow.combine(
|
||||
_isSending,
|
||||
chatRepository.isAssistantStreaming,
|
||||
) { sending, assistant -> sending || assistant }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||
|
||||
// Voice recording state
|
||||
val voiceRecordState: StateFlow<RecordState> = voiceRecorder.state
|
||||
val voiceRecordDurationMs: StateFlow<Long> = voiceRecorder.durationMs
|
||||
|
||||
// Animation ordering for message bubbles
|
||||
private var animCounter = 0
|
||||
private val _messageAnimIndex = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
|
||||
|
||||
private var currentSessionId: String? = null
|
||||
private var dbObserverJob: Job? = null
|
||||
private var sendTimeoutJob: Job? = null
|
||||
|
||||
init {
|
||||
RuntimeLog.general("app", "ChatViewModel instance #$instanceId created")
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
RuntimeLog.general("app", "ChatViewModel #$instanceId — initializing session...")
|
||||
val sessionId = chatRepository.initializeSession()
|
||||
currentSessionId = sessionId
|
||||
chatRepository.currentSessionId = sessionId
|
||||
RuntimeLog.general("app", "Session initialized: $sessionId")
|
||||
chatRepository.ensureConnected()
|
||||
} catch (e: Exception) {
|
||||
RuntimeLog.general("app", "initializeSession failed: ${e.message}")
|
||||
}
|
||||
// Always try to load from DB, even if initializeSession failed.
|
||||
// After process death the persisted session ID gives us the history.
|
||||
val sid = currentSessionId
|
||||
?: preferencesDataStore.currentSessionId.firstOrNull()
|
||||
if (sid != null) {
|
||||
currentSessionId = sid
|
||||
chatRepository.currentSessionId = sid
|
||||
RuntimeLog.general("app", "Loading messages from DB for session=$sid")
|
||||
loadMessagesFromDb(sid)
|
||||
} else {
|
||||
RuntimeLog.general("app", "No session ID available — cannot load messages")
|
||||
}
|
||||
}
|
||||
|
||||
// Observe incoming live messages — insert at correct descending position
|
||||
viewModelScope.launch {
|
||||
chatRepository.observeMessages().collect { message ->
|
||||
try {
|
||||
_currentMessages.update { list ->
|
||||
val updated = list.toMutableList()
|
||||
val existingIdx = updated.indexOfLast { it.id == message.id }
|
||||
if (existingIdx >= 0) {
|
||||
updated[existingIdx] = message
|
||||
} else {
|
||||
// Insert at correct position for descending timestamp (newest first)
|
||||
val insertAt = updated.indexOfFirst { it.timestamp <= message.timestamp }
|
||||
if (insertAt >= 0) updated.add(insertAt, message) else updated.add(message)
|
||||
val idx = _messageAnimIndex.value.toMutableMap()
|
||||
idx[message.id] = animCounter++
|
||||
_messageAnimIndex.value = idx
|
||||
}
|
||||
updated.deduplicate()
|
||||
}
|
||||
// Any non-user response means the server acknowledged our message
|
||||
if (_isSending.value && message.role != "user") {
|
||||
_isSending.value = false
|
||||
sendTimeoutJob?.cancel()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatViewModel", "Error processing message: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Observe message clear events
|
||||
viewModelScope.launch {
|
||||
chatRepository.messageClearEvents.collect {
|
||||
_currentMessages.value = emptyList()
|
||||
_messageAnimIndex.value = emptyMap()
|
||||
animCounter = 0
|
||||
}
|
||||
}
|
||||
// Observe message removals (e.g. wrapping stream_end deduped by review items)
|
||||
viewModelScope.launch {
|
||||
chatRepository.messageRemovals.collect { msgId ->
|
||||
_currentMessages.update { list -> list.filter { it.id != msgId } }
|
||||
val idx = _messageAnimIndex.value.toMutableMap()
|
||||
idx.remove(msgId)
|
||||
_messageAnimIndex.value = idx
|
||||
}
|
||||
}
|
||||
// Reset user-side sending state when server starts responding
|
||||
viewModelScope.launch {
|
||||
chatRepository.isAssistantStreaming.collect { streaming ->
|
||||
if (streaming) {
|
||||
_isSending.value = false
|
||||
sendTimeoutJob?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// --- Voice recording (WeChat-style gesture) ---
|
||||
|
||||
fun startRecord() {
|
||||
voiceRecorder.start()
|
||||
}
|
||||
|
||||
fun lockRecord() {
|
||||
voiceRecorder.lock()
|
||||
}
|
||||
|
||||
fun finishRecord() {
|
||||
val file = voiceRecorder.stop() ?: return
|
||||
val base64 = voiceRecorder.getBase64()
|
||||
voiceRecorder.deleteFile()
|
||||
if (base64.isNullOrBlank()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
chatRepository.sendVoiceInput(base64, "voice_msg")
|
||||
RuntimeLog.chat("voice", "Voice message sent, duration=${file.length()}")
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelRecord() {
|
||||
voiceRecorder.cancel()
|
||||
}
|
||||
|
||||
// --- Image attachments ---
|
||||
|
||||
private val _selectedImageUris = MutableStateFlow<List<Uri>>(emptyList())
|
||||
val selectedImageUris: StateFlow<List<Uri>> = _selectedImageUris.asStateFlow()
|
||||
|
||||
fun addImages(uris: List<Uri>) {
|
||||
_selectedImageUris.update { it + uris }
|
||||
}
|
||||
|
||||
fun removeImage(index: Int) {
|
||||
_selectedImageUris.update { list ->
|
||||
list.filterIndexed { i, _ -> i != index }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearImages() {
|
||||
_selectedImageUris.value = emptyList()
|
||||
}
|
||||
|
||||
private data class UploadResult(
|
||||
val attachment: WSAttachment,
|
||||
val thumbnailUrl: String,
|
||||
)
|
||||
|
||||
private suspend fun uploadAndBuildAttachment(uri: Uri): UploadResult? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val cr = getApplication<Application>().contentResolver
|
||||
val mimeType = cr.getType(uri) ?: "image/jpeg"
|
||||
val filename = uri.lastPathSegment ?: "image"
|
||||
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: return@withContext null
|
||||
if (bytes.isEmpty()) return@withContext null
|
||||
|
||||
// Upload to server, get file_id
|
||||
val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
|
||||
val part = MultipartBody.Part.createFormData("file", filename, requestBody)
|
||||
val response = apiService.uploadFile(part)
|
||||
if (!response.isSuccessful) {
|
||||
Log.e("ChatViewModel", "Upload failed: ${response.code()} ${response.message()}")
|
||||
return@withContext null
|
||||
}
|
||||
val fileId = response.body()?.id ?: return@withContext null
|
||||
|
||||
// Construct thumbnail URL
|
||||
val baseUrl = preferencesDataStore.baseUrl.firstOrNull()
|
||||
?.trimEnd('/') ?: "http://10.0.2.2:8080"
|
||||
val thumbnailUrl = "$baseUrl/api/v1/files/$fileId/thumbnail"
|
||||
|
||||
val attachment = WSAttachment(
|
||||
type = "image",
|
||||
fileId = fileId,
|
||||
thumbnailUrl = thumbnailUrl,
|
||||
filename = filename,
|
||||
size = bytes.size.toLong(),
|
||||
)
|
||||
UploadResult(attachment, thumbnailUrl)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatViewModel", "Failed to upload image: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override sendMessage to support image attachments
|
||||
fun sendMessage() {
|
||||
val text = _inputText.value.trim()
|
||||
val uris = _selectedImageUris.value
|
||||
if (text.isEmpty() && uris.isEmpty()) return
|
||||
val sid = currentSessionId
|
||||
if (sid == null) {
|
||||
RuntimeLog.chat("send", "Cannot send — no current session")
|
||||
return
|
||||
}
|
||||
|
||||
_inputText.value = ""
|
||||
_isSending.value = true
|
||||
|
||||
sendTimeoutJob?.cancel()
|
||||
sendTimeoutJob = viewModelScope.launch {
|
||||
delay(15_000L)
|
||||
if (_isSending.value) {
|
||||
Log.w("ChatViewModel", "Send timeout — no response in 15s, resetting")
|
||||
_isSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
val localUriStrings = uris.map { it.toString() }
|
||||
|
||||
viewModelScope.launch {
|
||||
val results = uris.mapNotNull { uploadAndBuildAttachment(it) }
|
||||
clearImages()
|
||||
val attachments = results.map { it.attachment }
|
||||
val thumbnailUrls = results.map { it.thumbnailUrl }
|
||||
try {
|
||||
chatRepository.sendMessage(
|
||||
text, sid,
|
||||
attachments = attachments.ifEmpty { null },
|
||||
localImageUris = thumbnailUrls.ifEmpty { localUriStrings },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatViewModel", "sendMessage failed: ${e.message}", e)
|
||||
_isSending.value = false
|
||||
sendTimeoutJob?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMessagesFromDb(sessionId: String) {
|
||||
dbObserverJob?.cancel()
|
||||
dbObserverJob = viewModelScope.launch {
|
||||
try {
|
||||
chatRepository.getMessages(sessionId).collect { messages ->
|
||||
_currentMessages.update { current ->
|
||||
val live = current.associateBy { it.id }
|
||||
val db = messages.associateBy { it.id }
|
||||
(db + live).values
|
||||
.sortedByDescending { it.timestamp }
|
||||
.deduplicate()
|
||||
}
|
||||
val idx = _messageAnimIndex.value.toMutableMap()
|
||||
messages.forEach { m ->
|
||||
if (m.id !in idx) idx[m.id] = animCounter++
|
||||
}
|
||||
_messageAnimIndex.value = idx
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatViewModel", "Error loading messages: ${e.message}", e)
|
||||
RuntimeLog.general("app", "loadMessagesFromDb failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onInputChanged(text: String) {
|
||||
_inputText.value = text
|
||||
}
|
||||
|
||||
fun switchSession(sessionId: String) {
|
||||
currentSessionId = sessionId
|
||||
chatRepository.currentSessionId = sessionId
|
||||
_currentMessages.value = emptyList()
|
||||
_messageAnimIndex.value = emptyMap()
|
||||
animCounter = 0
|
||||
viewModelScope.launch {
|
||||
chatRepository.connectWebSocket(sessionId)
|
||||
chatRepository.loadMessagesFromServer(sessionId)
|
||||
loadMessagesFromDb(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshMessages() {
|
||||
val sid = currentSessionId ?: return
|
||||
viewModelScope.launch {
|
||||
_isRefreshing.value = true
|
||||
try {
|
||||
// Clear all messages and let the DB observer rebuild from scratch.
|
||||
// This avoids duplicates that occur when local-UUID messages survive
|
||||
// the merge alongside server-ID versions loaded from HTTP.
|
||||
_currentMessages.value = emptyList()
|
||||
if (!isConnected.value) {
|
||||
chatRepository.ensureConnected()
|
||||
}
|
||||
chatRepository.loadMessagesFromServer(sid)
|
||||
} catch (_: Exception) { }
|
||||
_isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteConversation(id: String) {
|
||||
viewModelScope.launch {
|
||||
chatRepository.deleteConversation(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearLocalMessages() {
|
||||
viewModelScope.launch {
|
||||
chatRepository.clearLocalMessages()
|
||||
_currentMessages.value = emptyList()
|
||||
_messageAnimIndex.value = emptyMap()
|
||||
animCounter = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package top.yeij.cyrene.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.domain.model.Device
|
||||
import top.yeij.cyrene.domain.repository.IoTRepository
|
||||
|
||||
class IoTViewModel(
|
||||
private val ioTRepository: IoTRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val devices: StateFlow<List<Device>> = ioTRepository.getDevices()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
init {
|
||||
refreshDevices()
|
||||
}
|
||||
|
||||
fun refreshDevices() {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
ioTRepository.refreshDevices()
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun controlDevice(deviceId: String, action: String, value: Any? = null) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
_error.value = null
|
||||
ioTRepository.controlDevice(deviceId, action, value).fold(
|
||||
onSuccess = { },
|
||||
onFailure = { _error.value = it.message },
|
||||
)
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePower(device: Device) {
|
||||
val action = if (device.state.power == true) "off" else "on"
|
||||
controlDevice(device.id, action)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_error.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package top.yeij.cyrene.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import top.yeij.cyrene.util.Constants
|
||||
import top.yeij.cyrene.util.RecordState
|
||||
import top.yeij.cyrene.util.VoiceRecorder
|
||||
import top.yeij.cyrene.voice.tts.TextToSpeechEngine
|
||||
|
||||
private fun List<Message>.deduplicate(): List<Message> {
|
||||
if (isEmpty()) return this
|
||||
val seen = mutableSetOf<String>()
|
||||
return filter { seen.add(it.id) }
|
||||
}
|
||||
|
||||
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
|
||||
if (size < 3) return this
|
||||
val toRemove = mutableSetOf<String>()
|
||||
for (msg in this) {
|
||||
val containedCount = count { other ->
|
||||
other.id != msg.id &&
|
||||
other.content.isNotBlank() &&
|
||||
other.content.length < msg.content.length &&
|
||||
msg.content.contains(other.content) &&
|
||||
kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
|
||||
}
|
||||
if (containedCount >= 2) {
|
||||
toRemove.add(msg.id)
|
||||
}
|
||||
}
|
||||
return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
|
||||
}
|
||||
|
||||
enum class OverlayState {
|
||||
IDLE,
|
||||
LISTENING,
|
||||
PROCESSING,
|
||||
SPEAKING,
|
||||
WAITING,
|
||||
}
|
||||
|
||||
class OverlayViewModel(
|
||||
private val chatRepository: ChatRepository,
|
||||
private val voiceRecorder: VoiceRecorder,
|
||||
private val ttsEngine: TextToSpeechEngine,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(OverlayState.WAITING)
|
||||
val state: StateFlow<OverlayState> = _state.asStateFlow()
|
||||
|
||||
private val _messages = MutableStateFlow<List<Message>>(emptyList())
|
||||
val messages: StateFlow<List<Message>> = _messages.asStateFlow()
|
||||
|
||||
private val _inputText = MutableStateFlow("")
|
||||
val inputText: StateFlow<String> = _inputText.asStateFlow()
|
||||
|
||||
val voiceRecordState: StateFlow<RecordState> = voiceRecorder.state
|
||||
val voiceRecordDurationMs: StateFlow<Long> = voiceRecorder.durationMs
|
||||
|
||||
// Animation ordering for message bubbles
|
||||
private var animCounter = 0
|
||||
private val _messageAnimIndex = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
|
||||
|
||||
private var silenceTimer: Job? = null
|
||||
private var processingTimeoutJob: Job? = null
|
||||
private var lastAssistantMessageId: String? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
chatRepository.observeMessages().collect { message ->
|
||||
_messages.update { list ->
|
||||
val updated = list.toMutableList()
|
||||
val existingIdx = updated.indexOfLast { it.id == message.id }
|
||||
if (existingIdx >= 0) {
|
||||
updated[existingIdx] = message
|
||||
} else {
|
||||
// Insert at correct position for ascending timestamp (oldest first for top-down layout)
|
||||
val insertAt = updated.indexOfFirst { it.timestamp >= message.timestamp }
|
||||
if (insertAt >= 0) updated.add(insertAt, message) else updated.add(message)
|
||||
val animIdx = _messageAnimIndex.value.toMutableMap()
|
||||
animIdx[message.id] = animCounter++
|
||||
_messageAnimIndex.value = animIdx
|
||||
}
|
||||
updated.deduplicate()
|
||||
}
|
||||
|
||||
// Any non-user response means the server acknowledged our message
|
||||
if (_state.value == OverlayState.PROCESSING && message.role != "user") {
|
||||
cancelProcessingTimeout()
|
||||
setWaiting()
|
||||
}
|
||||
|
||||
if (message.role == "assistant" && !message.isStreaming && message.msgType == "chat") {
|
||||
if (message.id != lastAssistantMessageId && message.content.isNotBlank()) {
|
||||
lastAssistantMessageId = message.id
|
||||
speakResponse(message.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
chatRepository.isAssistantStreaming.collect { streaming ->
|
||||
if (streaming) {
|
||||
cancelProcessingTimeout()
|
||||
} else if (_state.value == OverlayState.PROCESSING) {
|
||||
delay(500)
|
||||
if (_state.value == OverlayState.PROCESSING) {
|
||||
setWaiting()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
ttsEngine.onDone.collect {
|
||||
if (_state.value == OverlayState.SPEAKING) {
|
||||
setWaiting()
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
chatRepository.messageClearEvents.collect {
|
||||
_messages.value = emptyList()
|
||||
_messageAnimIndex.value = emptyMap()
|
||||
animCounter = 0
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
chatRepository.messageRemovals.collect { msgId ->
|
||||
_messages.update { list -> list.filter { it.id != msgId } }
|
||||
val idx = _messageAnimIndex.value.toMutableMap()
|
||||
idx.remove(msgId)
|
||||
_messageAnimIndex.value = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onInputChanged(text: String) {
|
||||
_inputText.value = text
|
||||
}
|
||||
|
||||
fun sendText() {
|
||||
val text = _inputText.value.trim()
|
||||
if (text.isEmpty()) return
|
||||
_inputText.value = ""
|
||||
|
||||
_state.value = OverlayState.PROCESSING
|
||||
cancelSilenceTimer()
|
||||
startProcessingTimeout()
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
chatRepository.sendMessage(text, null)
|
||||
} catch (e: Exception) {
|
||||
Log.e("OverlayVM", "sendText failed: ${e.message}", e)
|
||||
if (_state.value == OverlayState.PROCESSING) setWaiting()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Voice recording (WeChat-style gesture) ---
|
||||
|
||||
fun startRecord() {
|
||||
voiceRecorder.start()
|
||||
_state.value = OverlayState.LISTENING
|
||||
cancelSilenceTimer()
|
||||
}
|
||||
|
||||
fun lockRecord() {
|
||||
voiceRecorder.lock()
|
||||
}
|
||||
|
||||
fun finishRecord() {
|
||||
val file = voiceRecorder.stop() ?: return
|
||||
val base64 = voiceRecorder.getBase64()
|
||||
voiceRecorder.deleteFile()
|
||||
if (base64.isNullOrBlank()) return
|
||||
|
||||
_state.value = OverlayState.PROCESSING
|
||||
startProcessingTimeout()
|
||||
viewModelScope.launch {
|
||||
chatRepository.sendVoiceInput(base64, "voice_msg")
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelRecord() {
|
||||
voiceRecorder.cancel()
|
||||
setWaiting()
|
||||
}
|
||||
|
||||
fun cancelCurrentAction() {
|
||||
if (voiceRecorder.state.value == RecordState.LOCKED) {
|
||||
voiceRecorder.cancel()
|
||||
setWaiting()
|
||||
}
|
||||
}
|
||||
|
||||
private fun speakResponse(text: String) {
|
||||
if (text.isBlank()) return
|
||||
_state.value = OverlayState.SPEAKING
|
||||
ttsEngine.speak(text)
|
||||
}
|
||||
|
||||
private fun setWaiting() {
|
||||
_state.value = OverlayState.WAITING
|
||||
startSilenceTimer()
|
||||
}
|
||||
|
||||
fun stopSpeaking() {
|
||||
ttsEngine.stop()
|
||||
if (_state.value == OverlayState.SPEAKING) {
|
||||
setWaiting()
|
||||
}
|
||||
}
|
||||
|
||||
fun sendScreenContext(content: String) {
|
||||
if (content.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
chatRepository.sendScreenContext(content)
|
||||
}
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
_state.value = OverlayState.IDLE
|
||||
cancelSilenceTimer()
|
||||
voiceRecorder.cancel()
|
||||
ttsEngine.stop()
|
||||
}
|
||||
|
||||
private fun startSilenceTimer() {
|
||||
cancelSilenceTimer()
|
||||
silenceTimer = viewModelScope.launch {
|
||||
delay(Constants.SILENCE_TIMEOUT_MS)
|
||||
if (_state.value == OverlayState.WAITING) {
|
||||
_state.value = OverlayState.IDLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelSilenceTimer() {
|
||||
silenceTimer?.cancel()
|
||||
silenceTimer = null
|
||||
}
|
||||
|
||||
private fun startProcessingTimeout() {
|
||||
cancelProcessingTimeout()
|
||||
processingTimeoutJob = viewModelScope.launch {
|
||||
delay(15_000L)
|
||||
if (_state.value == OverlayState.PROCESSING) {
|
||||
Log.w("OverlayVM", "Processing timeout — no response in 15s, resetting to WAITING")
|
||||
setWaiting()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelProcessingTimeout() {
|
||||
processingTimeoutJob?.cancel()
|
||||
processingTimeoutJob = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
voiceRecorder.cancel()
|
||||
ttsEngine.shutdown()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package top.yeij.cyrene.viewmodel
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.ApiService
|
||||
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||
|
||||
data class ProfileState(
|
||||
val userId: String = "",
|
||||
val username: String = "",
|
||||
val nickname: String = "",
|
||||
val isAdmin: Boolean = false,
|
||||
val createdAt: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val isLoggedIn: Boolean = false,
|
||||
)
|
||||
|
||||
class ProfileViewModel(
|
||||
private val apiService: ApiService,
|
||||
private val authRepository: AuthRepository,
|
||||
private val prefs: PreferencesDataStore,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _profile = MutableStateFlow(ProfileState())
|
||||
val profile: StateFlow<ProfileState> = _profile.asStateFlow()
|
||||
|
||||
private var loadedFromCache = false
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
_profile.value = _profile.value.copy(isLoggedIn = authRepository.isLoggedIn())
|
||||
if (_profile.value.isLoggedIn) {
|
||||
loadCachedProfile()
|
||||
fetchFreshProfile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show cached profile immediately for instant UI.
|
||||
*/
|
||||
private suspend fun loadCachedProfile() {
|
||||
val userId = prefs.profileUserId.firstOrNull() ?: return
|
||||
val nickname = prefs.profileNickname.firstOrNull() ?: ""
|
||||
val isAdmin = prefs.profileIsAdmin.firstOrNull()?.toBoolean() ?: false
|
||||
val createdAt = prefs.profileCreatedAt.firstOrNull() ?: ""
|
||||
val username = prefs.username.firstOrNull() ?: ""
|
||||
|
||||
_profile.value = ProfileState(
|
||||
userId = userId,
|
||||
username = username,
|
||||
nickname = nickname,
|
||||
isAdmin = isAdmin,
|
||||
createdAt = createdAt,
|
||||
isLoggedIn = true,
|
||||
)
|
||||
loadedFromCache = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch fresh profile from server. On success, update cache and UI.
|
||||
* On failure, keep showing cached data — no error, no UI disruption.
|
||||
*/
|
||||
fun fetchFreshProfile() {
|
||||
viewModelScope.launch {
|
||||
if (!loadedFromCache) {
|
||||
_profile.value = _profile.value.copy(isLoading = true)
|
||||
}
|
||||
try {
|
||||
val response = apiService.getProfile()
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
if (body != null) {
|
||||
val dateStr = body.createdAt?.take(10) ?: ""
|
||||
val nickname = body.nickname ?: body.username
|
||||
|
||||
_profile.value = ProfileState(
|
||||
userId = body.userId,
|
||||
username = body.username,
|
||||
nickname = nickname,
|
||||
isAdmin = body.isAdmin == true,
|
||||
createdAt = dateStr,
|
||||
isLoading = false,
|
||||
isLoggedIn = true,
|
||||
)
|
||||
loadedFromCache = true
|
||||
|
||||
// Update local cache
|
||||
prefs.saveProfileCache(body.userId, nickname, body.isAdmin == true, dateStr)
|
||||
}
|
||||
} else if (!loadedFromCache) {
|
||||
_profile.value = _profile.value.copy(isLoading = false)
|
||||
}
|
||||
// On error with cache already shown: silently ignore
|
||||
} catch (e: Exception) {
|
||||
Log.w("ProfileVM", "Failed to fetch fresh profile: ${e.message}")
|
||||
if (!loadedFromCache) {
|
||||
_profile.value = _profile.value.copy(isLoading = false)
|
||||
}
|
||||
// If cached data is showing, keep it silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
authRepository.logout()
|
||||
prefs.clearProfileCache()
|
||||
_profile.value = ProfileState()
|
||||
loadedFromCache = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package top.yeij.cyrene.viewmodel
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||
import top.yeij.cyrene.domain.repository.AuthRepository
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import top.yeij.cyrene.voice.stt.SttManager
|
||||
|
||||
class SettingsViewModel(
|
||||
private val authRepository: AuthRepository,
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
private val dynamicUrlInterceptor: DynamicUrlInterceptor,
|
||||
private val chatRepository: ChatRepository,
|
||||
private val sttManager: SttManager,
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
private val _baseUrl = MutableStateFlow("")
|
||||
val baseUrl: StateFlow<String> = _baseUrl.asStateFlow()
|
||||
|
||||
private val _themeMode = MutableStateFlow("auto")
|
||||
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
|
||||
|
||||
private val _wakeWord = MutableStateFlow("昔涟")
|
||||
val wakeWord: StateFlow<String> = _wakeWord.asStateFlow()
|
||||
|
||||
private val _username = MutableStateFlow("")
|
||||
val username: StateFlow<String> = _username.asStateFlow()
|
||||
|
||||
private val _dashScopeApiKey = MutableStateFlow("")
|
||||
val dashScopeApiKey: StateFlow<String> = _dashScopeApiKey.asStateFlow()
|
||||
|
||||
private val _dashScopeEndpoint = MutableStateFlow("wss://dashscope.aliyuncs.com/api-ws/v1/inference")
|
||||
val dashScopeEndpoint: StateFlow<String> = _dashScopeEndpoint.asStateFlow()
|
||||
|
||||
private val _dashScopeModel = MutableStateFlow("fun-asr-realtime")
|
||||
val dashScopeModel: StateFlow<String> = _dashScopeModel.asStateFlow()
|
||||
|
||||
private val _autoScreenContext = MutableStateFlow(false)
|
||||
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
|
||||
|
||||
private val _themeColor = MutableStateFlow("pink")
|
||||
val themeColor: StateFlow<String> = _themeColor.asStateFlow()
|
||||
|
||||
private val _enterToSend = MutableStateFlow(false)
|
||||
val enterToSend: StateFlow<Boolean> = _enterToSend.asStateFlow()
|
||||
|
||||
private val _typingIndicatorStyle = MutableStateFlow("bubble")
|
||||
val typingIndicatorStyle: StateFlow<String> = _typingIndicatorStyle.asStateFlow()
|
||||
|
||||
private val _rootKeepAlive = MutableStateFlow(false)
|
||||
val rootKeepAlive: StateFlow<Boolean> = _rootKeepAlive.asStateFlow()
|
||||
|
||||
private val _isLoggedIn = MutableStateFlow(false)
|
||||
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
_isLoggedIn.value = authRepository.isLoggedIn()
|
||||
}
|
||||
// Single collector for all DataStore preferences — avoids subscriber explosion
|
||||
scope.launch {
|
||||
preferencesDataStore.autoScreenContext.collect { value ->
|
||||
_autoScreenContext.value = value
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
preferencesDataStore.typingIndicatorStyle.collect { value ->
|
||||
_typingIndicatorStyle.value = value
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
preferencesDataStore.enterToSend.collect { value ->
|
||||
_enterToSend.value = value
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
preferencesDataStore.rootKeepAlive.collect { value ->
|
||||
_rootKeepAlive.value = value
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
preferencesDataStore.themeColor.collect { value ->
|
||||
_themeColor.value = value
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
combine(
|
||||
preferencesDataStore.baseUrl,
|
||||
preferencesDataStore.themeMode,
|
||||
preferencesDataStore.wakeWord,
|
||||
preferencesDataStore.username,
|
||||
combine(
|
||||
preferencesDataStore.dashScopeApiKey,
|
||||
preferencesDataStore.dashScopeEndpoint,
|
||||
preferencesDataStore.dashScopeModel,
|
||||
) { apiKey, endpoint, model ->
|
||||
Triple(apiKey, endpoint, model)
|
||||
},
|
||||
) { baseUrl, themeMode, wakeWord, username, dashScope ->
|
||||
baseUrl?.let { url ->
|
||||
if (url.isNotBlank()) {
|
||||
_baseUrl.value = url
|
||||
dynamicUrlInterceptor.baseUrl = url
|
||||
}
|
||||
}
|
||||
themeMode?.let { _themeMode.value = it }
|
||||
wakeWord?.let { word ->
|
||||
if (word.isNotBlank()) _wakeWord.value = word
|
||||
}
|
||||
username?.let { _username.value = it }
|
||||
val (apiKey, endpoint, model) = dashScope
|
||||
apiKey?.let { key ->
|
||||
if (key.isNotBlank()) _dashScopeApiKey.value = key
|
||||
sttManager.updateDashScopeApiKey(key)
|
||||
}
|
||||
endpoint?.let { ep ->
|
||||
if (ep.isNotBlank()) _dashScopeEndpoint.value = ep
|
||||
}
|
||||
model?.let { m ->
|
||||
if (m.isNotBlank()) _dashScopeModel.value = m
|
||||
}
|
||||
// Push full config to STT
|
||||
sttManager.configureDashScope(
|
||||
apiKey = _dashScopeApiKey.value,
|
||||
endpoint = _dashScopeEndpoint.value,
|
||||
model = _dashScopeModel.value,
|
||||
)
|
||||
}.collect { }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateBaseUrlInput(url: String) {
|
||||
_baseUrl.value = url
|
||||
}
|
||||
|
||||
fun saveBaseUrl(url: String) {
|
||||
_baseUrl.value = url
|
||||
dynamicUrlInterceptor.baseUrl = url
|
||||
scope.launch {
|
||||
preferencesDataStore.saveBaseUrl(url)
|
||||
chatRepository.reconnectWebSocket()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveThemeMode(mode: String) {
|
||||
_themeMode.value = mode
|
||||
scope.launch { preferencesDataStore.saveThemeMode(mode) }
|
||||
}
|
||||
|
||||
fun saveWakeWord(word: String) {
|
||||
_wakeWord.value = word
|
||||
scope.launch { preferencesDataStore.saveWakeWord(word) }
|
||||
}
|
||||
|
||||
fun updateDashScopeApiKeyInput(key: String) {
|
||||
_dashScopeApiKey.value = key
|
||||
}
|
||||
|
||||
fun saveDashScopeApiKey(key: String) {
|
||||
_dashScopeApiKey.value = key
|
||||
sttManager.updateDashScopeApiKey(key)
|
||||
scope.launch { preferencesDataStore.saveDashScopeApiKey(key) }
|
||||
}
|
||||
|
||||
fun updateDashScopeEndpointInput(endpoint: String) {
|
||||
_dashScopeEndpoint.value = endpoint
|
||||
}
|
||||
|
||||
fun saveDashScopeEndpoint(endpoint: String) {
|
||||
_dashScopeEndpoint.value = endpoint
|
||||
sttManager.configureDashScope(_dashScopeApiKey.value, endpoint, _dashScopeModel.value)
|
||||
scope.launch { preferencesDataStore.saveDashScopeEndpoint(endpoint) }
|
||||
}
|
||||
|
||||
fun updateDashScopeModelInput(model: String) {
|
||||
_dashScopeModel.value = model
|
||||
}
|
||||
|
||||
fun saveDashScopeModel(model: String) {
|
||||
_dashScopeModel.value = model
|
||||
sttManager.configureDashScope(_dashScopeApiKey.value, _dashScopeEndpoint.value, model)
|
||||
scope.launch { preferencesDataStore.saveDashScopeModel(model) }
|
||||
}
|
||||
|
||||
fun saveAutoScreenContext(enabled: Boolean) {
|
||||
_autoScreenContext.value = enabled
|
||||
scope.launch { preferencesDataStore.saveAutoScreenContext(enabled) }
|
||||
}
|
||||
|
||||
fun saveTypingIndicatorStyle(style: String) {
|
||||
_typingIndicatorStyle.value = style
|
||||
scope.launch { preferencesDataStore.saveTypingIndicatorStyle(style) }
|
||||
}
|
||||
|
||||
fun saveEnterToSend(enabled: Boolean) {
|
||||
_enterToSend.value = enabled
|
||||
scope.launch { preferencesDataStore.saveEnterToSend(enabled) }
|
||||
}
|
||||
|
||||
fun saveThemeColor(color: String) {
|
||||
_themeColor.value = color
|
||||
scope.launch { preferencesDataStore.saveThemeColor(color) }
|
||||
}
|
||||
|
||||
fun saveRootKeepAlive(enabled: Boolean) {
|
||||
_rootKeepAlive.value = enabled
|
||||
scope.launch { preferencesDataStore.saveRootKeepAlive(enabled) }
|
||||
}
|
||||
|
||||
fun clearLocalMessages() {
|
||||
scope.launch {
|
||||
chatRepository.clearLocalMessages()
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
scope.launch {
|
||||
authRepository.logout()
|
||||
_isLoggedIn.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package top.yeij.cyrene.voice.hotword
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class HotwordDetector {
|
||||
|
||||
private val _isListening = MutableStateFlow(false)
|
||||
val isListening = _isListening.asStateFlow()
|
||||
|
||||
private val _onDetected = MutableStateFlow(false)
|
||||
val onDetected = _onDetected.asStateFlow()
|
||||
|
||||
fun startListening(wakeWord: String) {
|
||||
_isListening.value = true
|
||||
// Integrate system AlwaysOnHotwordDetector or Porcupine SDK here
|
||||
}
|
||||
|
||||
fun stopListening() {
|
||||
_isListening.value = false
|
||||
}
|
||||
|
||||
fun updateWakeWord(newWord: String) {
|
||||
stopListening()
|
||||
startListening(newWord)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package top.yeij.cyrene.voice.stt
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
class BackendSttProvider(
|
||||
private val context: Context,
|
||||
private val chatRepository: ChatRepository,
|
||||
) : SttProvider {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var recordingJob: Job? = null
|
||||
private val audioBuffer = ByteArrayOutputStream()
|
||||
|
||||
private val _partialResult = MutableStateFlow("")
|
||||
override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
|
||||
|
||||
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 8)
|
||||
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
|
||||
|
||||
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
||||
override val onError: SharedFlow<String> = _onError.asSharedFlow()
|
||||
|
||||
private val _isListening = MutableStateFlow(false)
|
||||
override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
|
||||
|
||||
private val sampleRate = 16000
|
||||
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT
|
||||
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
|
||||
private val bufferSize: Int = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
|
||||
.coerceAtLeast(3200)
|
||||
|
||||
override fun start() {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
_onError.tryEmit("缺少录音权限")
|
||||
return
|
||||
}
|
||||
|
||||
cancel()
|
||||
_isListening.value = true
|
||||
_partialResult.value = ""
|
||||
audioBuffer.reset()
|
||||
|
||||
try {
|
||||
audioRecord = AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_RECOGNITION,
|
||||
sampleRate,
|
||||
channelConfig,
|
||||
audioFormat,
|
||||
bufferSize,
|
||||
).also {
|
||||
if (it.state != AudioRecord.STATE_INITIALIZED) {
|
||||
Log.e(TAG, "AudioRecord init failed")
|
||||
_onError.tryEmit("麦克风初始化失败")
|
||||
_isListening.value = false
|
||||
return
|
||||
}
|
||||
it.startRecording()
|
||||
}
|
||||
|
||||
val readBuffer = ByteArray(bufferSize)
|
||||
recordingJob = scope.launch {
|
||||
while (isActive && _isListening.value) {
|
||||
val bytesRead = audioRecord?.read(readBuffer, 0, readBuffer.size) ?: -1
|
||||
if (bytesRead > 0) {
|
||||
audioBuffer.write(readBuffer, 0, bytesRead)
|
||||
} else if (bytesRead < 0) break
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
_onError.tryEmit("缺少录音权限")
|
||||
_isListening.value = false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Recording error", e)
|
||||
_onError.tryEmit("录音失败: ${e.message}")
|
||||
_isListening.value = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
if (!_isListening.value) return
|
||||
Log.d(TAG, "Stopping recording")
|
||||
stopRecording()
|
||||
|
||||
val audioBytes = audioBuffer.toByteArray()
|
||||
if (audioBytes.isEmpty()) {
|
||||
Log.w(TAG, "No audio data recorded")
|
||||
_isListening.value = false
|
||||
_onError.tryEmit("未录制到语音")
|
||||
return
|
||||
}
|
||||
|
||||
_isListening.value = false
|
||||
scope.launch {
|
||||
try {
|
||||
val base64 = Base64.encodeToString(audioBytes, Base64.NO_WRAP)
|
||||
chatRepository.sendVoiceInput(base64, "voice_msg")
|
||||
Log.d(TAG, "Sent ${audioBytes.size} bytes of audio to backend")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send voice input", e)
|
||||
_onError.tryEmit("发送语音失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
if (!_isListening.value) return
|
||||
Log.d(TAG, "Cancelling recording")
|
||||
stopRecording()
|
||||
audioBuffer.reset()
|
||||
_isListening.value = false
|
||||
_partialResult.value = ""
|
||||
}
|
||||
|
||||
private fun stopRecording() {
|
||||
recordingJob?.cancel()
|
||||
recordingJob = null
|
||||
try {
|
||||
audioRecord?.stop()
|
||||
audioRecord?.release()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error releasing AudioRecord: ${e.message}")
|
||||
}
|
||||
audioRecord = null
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
cancel()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BackendSTT"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package top.yeij.cyrene.voice.stt
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.ByteString
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class DashScopeSttService(
|
||||
private val context: Context,
|
||||
) : SttProvider {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val gson = Gson()
|
||||
private val connectionId = AtomicInteger(0)
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var recordingJob: Job? = null
|
||||
private var taskId: String = ""
|
||||
|
||||
private val _partialResult = MutableStateFlow("")
|
||||
override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
|
||||
|
||||
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 16)
|
||||
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
|
||||
|
||||
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
||||
override val onError: SharedFlow<String> = _onError.asSharedFlow()
|
||||
|
||||
private val _isListening = MutableStateFlow(false)
|
||||
override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
|
||||
|
||||
// Configurable via settings
|
||||
var apiKey: String = ""
|
||||
var endpoint: String = "wss://dashscope.aliyuncs.com/api-ws/v1/inference"
|
||||
var model: String = "fun-asr-realtime"
|
||||
var enableInterimResults: Boolean = true
|
||||
|
||||
private val sampleRate = 16000
|
||||
private val audioFormat = AudioFormat.ENCODING_PCM_16BIT
|
||||
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
|
||||
private val bytesPerSample = 2
|
||||
private val chunkMs = 100
|
||||
private val bufferSize: Int = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
|
||||
.coerceAtLeast(sampleRate * bytesPerSample * chunkMs / 1000)
|
||||
|
||||
override fun start() {
|
||||
if (apiKey.isBlank()) {
|
||||
Log.w(TAG, "DashScope API key not configured")
|
||||
_onError.tryEmit("请先配置 DashScope API Key")
|
||||
return
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
_onError.tryEmit("缺少录音权限")
|
||||
return
|
||||
}
|
||||
|
||||
cancel()
|
||||
taskId = UUID.randomUUID().toString()
|
||||
_isListening.value = true
|
||||
_partialResult.value = ""
|
||||
|
||||
connectWebSocket()
|
||||
startRecording()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
if (!_isListening.value) return
|
||||
Log.d(TAG, "Stopping recognition, taskId=$taskId")
|
||||
stopRecording()
|
||||
sendFinishTask()
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
if (!_isListening.value) return
|
||||
Log.d(TAG, "Cancelling recognition, taskId=$taskId")
|
||||
stopRecording()
|
||||
closeWebSocket()
|
||||
_isListening.value = false
|
||||
_partialResult.value = ""
|
||||
}
|
||||
|
||||
private fun connectWebSocket() {
|
||||
val client = OkHttpClient.Builder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(endpoint)
|
||||
.header("Authorization", "Bearer $apiKey")
|
||||
.build()
|
||||
|
||||
val connId = connectionId.incrementAndGet()
|
||||
Log.i(TAG, "[#$connId] Connecting to DashScope: $endpoint")
|
||||
|
||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
if (connectionId.get() != connId) return
|
||||
Log.i(TAG, "[#$connId] Connected, sending run-task")
|
||||
sendRunTask()
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (connectionId.get() != connId) return
|
||||
handleServerMessage(text)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
if (connectionId.get() != connId) return
|
||||
Log.e(TAG, "[#$connId] WebSocket failure: ${t.message}", t)
|
||||
_isListening.value = false
|
||||
_onError.tryEmit("语音识别连接失败: ${t.message}")
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (connectionId.get() != connId) return
|
||||
Log.d(TAG, "[#$connId] Server closing: $code $reason")
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (connectionId.get() != connId) return
|
||||
Log.d(TAG, "[#$connId] Closed: $code $reason")
|
||||
_isListening.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun sendRunTask() {
|
||||
val msg = mapOf(
|
||||
"header" to mapOf(
|
||||
"action" to "run-task",
|
||||
"task_id" to taskId,
|
||||
"streaming" to "duplex",
|
||||
),
|
||||
"payload" to mapOf(
|
||||
"model" to model,
|
||||
"parameters" to mapOf(
|
||||
"format" to "pcm",
|
||||
"sample_rate" to sampleRate,
|
||||
),
|
||||
"input" to emptyMap<String, String>(),
|
||||
),
|
||||
)
|
||||
val json = gson.toJson(msg)
|
||||
Log.d(TAG, "Sending run-task: $json")
|
||||
webSocket?.send(json)
|
||||
}
|
||||
|
||||
private fun sendFinishTask() {
|
||||
val msg = mapOf(
|
||||
"header" to mapOf(
|
||||
"action" to "finish-task",
|
||||
"task_id" to taskId,
|
||||
),
|
||||
"payload" to emptyMap<String, String>(),
|
||||
)
|
||||
val json = gson.toJson(msg)
|
||||
Log.d(TAG, "Sending finish-task: $json")
|
||||
webSocket?.send(json)
|
||||
}
|
||||
|
||||
private fun handleServerMessage(text: String) {
|
||||
try {
|
||||
val response = gson.fromJson(text, DashScopeResponse::class.java)
|
||||
val header = response.header ?: return
|
||||
val event = header.event ?: return
|
||||
|
||||
when (event) {
|
||||
"result-generated" -> {
|
||||
val sentence = response.payload?.output?.sentence ?: return
|
||||
val sentenceText = sentence.text ?: return
|
||||
_partialResult.value = sentenceText
|
||||
|
||||
val sttResult = SttResult(
|
||||
text = sentenceText,
|
||||
isFinal = sentence.sentenceEnd ?: true,
|
||||
)
|
||||
if (sttResult.isFinal) {
|
||||
_finalResult.tryEmit(sttResult)
|
||||
} else if (enableInterimResults) {
|
||||
_finalResult.tryEmit(sttResult)
|
||||
}
|
||||
}
|
||||
|
||||
"task-finished" -> {
|
||||
Log.i(TAG, "Task finished: $taskId")
|
||||
// If no result-generated was received, emit what we have
|
||||
val partial = _partialResult.value
|
||||
if (partial.isNotBlank()) {
|
||||
_finalResult.tryEmit(SttResult(text = partial, isFinal = true))
|
||||
}
|
||||
closeWebSocket()
|
||||
_isListening.value = false
|
||||
}
|
||||
|
||||
"task-failed" -> {
|
||||
val error = header.errorMessage ?: "语音识别失败"
|
||||
Log.e(TAG, "Task failed: $error")
|
||||
_onError.tryEmit(error)
|
||||
closeWebSocket()
|
||||
_isListening.value = false
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse server message: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRecording() {
|
||||
try {
|
||||
audioRecord = AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_RECOGNITION,
|
||||
sampleRate,
|
||||
channelConfig,
|
||||
audioFormat,
|
||||
bufferSize,
|
||||
).also {
|
||||
if (it.state != AudioRecord.STATE_INITIALIZED) {
|
||||
Log.e(TAG, "AudioRecord initialization failed")
|
||||
_onError.tryEmit("麦克风初始化失败")
|
||||
_isListening.value = false
|
||||
return
|
||||
}
|
||||
it.startRecording()
|
||||
}
|
||||
|
||||
Log.d(TAG, "Recording started, bufferSize=$bufferSize")
|
||||
val readBuffer = ByteArray(bufferSize)
|
||||
|
||||
recordingJob = scope.launch {
|
||||
while (isActive && _isListening.value) {
|
||||
val bytesRead = audioRecord?.read(readBuffer, 0, readBuffer.size) ?: -1
|
||||
if (bytesRead > 0) {
|
||||
val data = readBuffer.copyOf(bytesRead)
|
||||
webSocket?.let { ws ->
|
||||
try {
|
||||
ws.send(ByteString.of(*data))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to send audio: ${e.message}")
|
||||
}
|
||||
}
|
||||
} else if (bytesRead < 0) {
|
||||
Log.w(TAG, "AudioRecord read error: $bytesRead")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Missing RECORD_AUDIO permission", e)
|
||||
_onError.tryEmit("缺少录音权限")
|
||||
_isListening.value = false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start recording", e)
|
||||
_onError.tryEmit("录音启动失败: ${e.message}")
|
||||
_isListening.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopRecording() {
|
||||
recordingJob?.cancel()
|
||||
recordingJob = null
|
||||
try {
|
||||
audioRecord?.stop()
|
||||
audioRecord?.release()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error releasing AudioRecord: ${e.message}")
|
||||
}
|
||||
audioRecord = null
|
||||
Log.d(TAG, "Recording stopped")
|
||||
}
|
||||
|
||||
private fun closeWebSocket() {
|
||||
try {
|
||||
webSocket?.close(1000, "Done")
|
||||
} catch (_: Exception) { }
|
||||
webSocket = null
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
cancel()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DashScopeSTT"
|
||||
}
|
||||
}
|
||||
|
||||
// --- JSON DTOs for DashScope WebSocket protocol ---
|
||||
|
||||
data class DashScopeResponse(
|
||||
@SerializedName("header") val header: DashScopeHeader?,
|
||||
@SerializedName("payload") val payload: DashScopePayload?,
|
||||
)
|
||||
|
||||
data class DashScopeHeader(
|
||||
@SerializedName("task_id") val taskId: String?,
|
||||
@SerializedName("event") val event: String?,
|
||||
@SerializedName("action") val action: String?,
|
||||
@SerializedName("error_message") val errorMessage: String?,
|
||||
)
|
||||
|
||||
data class DashScopePayload(
|
||||
@SerializedName("output") val output: DashScopeOutput?,
|
||||
@SerializedName("usage") val usage: Map<String, Any>?,
|
||||
)
|
||||
|
||||
data class DashScopeOutput(
|
||||
@SerializedName("sentence") val sentence: DashScopeSentence?,
|
||||
)
|
||||
|
||||
data class DashScopeSentence(
|
||||
@SerializedName("text") val text: String?,
|
||||
@SerializedName("sentence_end") val sentenceEnd: Boolean?,
|
||||
@SerializedName("sentence_id") val sentenceId: Int?,
|
||||
@SerializedName("begin_time") val beginTime: Long?,
|
||||
@SerializedName("end_time") val endTime: Long?,
|
||||
@SerializedName("words") val words: List<Map<String, Any>>?,
|
||||
@SerializedName("emo_tag") val emoTag: String?,
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
package top.yeij.cyrene.voice.stt
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class SpeechRecognizer(private val context: Context) : SttProvider {
|
||||
|
||||
private var recognizer: android.speech.SpeechRecognizer? = null
|
||||
|
||||
private val _isListening = MutableStateFlow(false)
|
||||
override val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
|
||||
|
||||
private val _partialResult = MutableStateFlow("")
|
||||
override val partialResult: StateFlow<String> = _partialResult.asStateFlow()
|
||||
|
||||
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 8)
|
||||
override val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
|
||||
|
||||
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
||||
override val onError: SharedFlow<String> = _onError.asSharedFlow()
|
||||
|
||||
override fun start() {
|
||||
if (!android.speech.SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||
Log.w(TAG, "Speech recognition not available on this device")
|
||||
_onError.tryEmit("语音识别不可用")
|
||||
return
|
||||
}
|
||||
|
||||
cancel()
|
||||
recognizer = android.speech.SpeechRecognizer.createSpeechRecognizer(context).apply {
|
||||
setRecognitionListener(object : RecognitionListener {
|
||||
override fun onReadyForSpeech(params: Bundle?) {
|
||||
_isListening.value = true
|
||||
_partialResult.value = ""
|
||||
}
|
||||
|
||||
override fun onBeginningOfSpeech() {}
|
||||
|
||||
override fun onRmsChanged(rmsdB: Float) {}
|
||||
|
||||
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||
|
||||
override fun onEndOfSpeech() {}
|
||||
|
||||
override fun onError(error: Int) {
|
||||
_isListening.value = false
|
||||
val msg = when (error) {
|
||||
android.speech.SpeechRecognizer.ERROR_NETWORK -> "网络连接失败"
|
||||
android.speech.SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "网络超时"
|
||||
android.speech.SpeechRecognizer.ERROR_AUDIO -> "音频录制错误"
|
||||
android.speech.SpeechRecognizer.ERROR_SERVER -> "服务器错误"
|
||||
android.speech.SpeechRecognizer.ERROR_CLIENT -> "客户端错误"
|
||||
android.speech.SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "未检测到语音"
|
||||
android.speech.SpeechRecognizer.ERROR_NO_MATCH -> "未能识别"
|
||||
android.speech.SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "语音引擎忙碌"
|
||||
android.speech.SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "缺少录音权限"
|
||||
else -> "未知错误 ($error)"
|
||||
}
|
||||
Log.w(TAG, "Recognition error: $msg")
|
||||
_onError.tryEmit(msg)
|
||||
}
|
||||
|
||||
override fun onResults(results: Bundle?) {
|
||||
_isListening.value = false
|
||||
val matches = results?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
if (!matches.isNullOrEmpty()) {
|
||||
_finalResult.tryEmit(SttResult(text = matches[0], isFinal = true))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPartialResults(partialResults: Bundle?) {
|
||||
val matches = partialResults?.getStringArrayList(android.speech.SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
if (!matches.isNullOrEmpty()) {
|
||||
_partialResult.value = matches[0]
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEvent(eventType: Int, params: Bundle?) {}
|
||||
})
|
||||
|
||||
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "zh-CN")
|
||||
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
|
||||
}
|
||||
startListening(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
recognizer?.stopListening()
|
||||
_isListening.value = false
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
recognizer?.cancel()
|
||||
recognizer?.destroy()
|
||||
recognizer = null
|
||||
_isListening.value = false
|
||||
_partialResult.value = ""
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneSTT"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package top.yeij.cyrene.voice.stt
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
|
||||
class SttManager(
|
||||
private val dashScopeService: DashScopeSttService,
|
||||
private val backendProvider: BackendSttProvider,
|
||||
private val systemRecognizer: SpeechRecognizer,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
private val _isListening = MutableStateFlow(false)
|
||||
val isListening: StateFlow<Boolean> = _isListening.asStateFlow()
|
||||
|
||||
private val _partialResult = MutableStateFlow("")
|
||||
val partialResult: StateFlow<String> = _partialResult.asStateFlow()
|
||||
|
||||
private val _finalResult = MutableSharedFlow<SttResult>(extraBufferCapacity = 16)
|
||||
val finalResult: SharedFlow<SttResult> = _finalResult.asSharedFlow()
|
||||
|
||||
private val _onError = MutableSharedFlow<String>(extraBufferCapacity = 8)
|
||||
val onError: SharedFlow<String> = _onError.asSharedFlow()
|
||||
|
||||
private var activeProvider: SttProvider? = null
|
||||
private var dashScopeFailed = false
|
||||
|
||||
fun start() {
|
||||
cancel()
|
||||
|
||||
val provider = if (dashScopeService.apiKey.isNotBlank() && !dashScopeFailed) {
|
||||
Log.d(TAG, "Using DashScope STT")
|
||||
RuntimeLog.stt("start", "Using DashScope provider")
|
||||
dashScopeService.also { dashScopeFailed = false }
|
||||
} else {
|
||||
Log.d(TAG, "Using Backend STT (fallback)")
|
||||
RuntimeLog.stt("start", "Using Backend provider (fallback)")
|
||||
backendProvider
|
||||
}
|
||||
|
||||
activeProvider = provider
|
||||
_isListening.value = true
|
||||
_partialResult.value = ""
|
||||
collectFrom(provider)
|
||||
provider.start()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
RuntimeLog.stt("stop", "Stopping STT")
|
||||
activeProvider?.stop()
|
||||
_isListening.value = false
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
RuntimeLog.stt("cancel", "Cancelling STT")
|
||||
activeProvider?.cancel()
|
||||
activeProvider = null
|
||||
_isListening.value = false
|
||||
_partialResult.value = ""
|
||||
}
|
||||
|
||||
fun configureDashScope(apiKey: String, endpoint: String, model: String) {
|
||||
dashScopeService.apiKey = apiKey
|
||||
dashScopeService.endpoint = endpoint
|
||||
dashScopeService.model = model
|
||||
dashScopeFailed = false
|
||||
}
|
||||
|
||||
fun updateDashScopeApiKey(apiKey: String) {
|
||||
dashScopeService.apiKey = apiKey
|
||||
if (apiKey.isNotBlank()) dashScopeFailed = false
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
cancel()
|
||||
dashScopeService.shutdown()
|
||||
backendProvider.shutdown()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun collectFrom(provider: SttProvider) {
|
||||
scope.launch {
|
||||
provider.partialResult.collect { text ->
|
||||
if (provider == activeProvider && text.isNotEmpty()) {
|
||||
_partialResult.value = text
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
provider.finalResult.collect { result ->
|
||||
if (provider == activeProvider) {
|
||||
RuntimeLog.stt("result", "Final result: isFinal=${result.isFinal} text=${result.text.take(80)}")
|
||||
_finalResult.tryEmit(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
provider.onError.collect { error ->
|
||||
if (provider == activeProvider) {
|
||||
Log.w(TAG, "STT error: $error")
|
||||
RuntimeLog.stt("error", error)
|
||||
_onError.tryEmit(error)
|
||||
|
||||
if (provider == dashScopeService) {
|
||||
dashScopeFailed = true
|
||||
RuntimeLog.stt("fallback", "DashScope failed, will use backend next time")
|
||||
Log.i(TAG, "DashScope failed, will use backend next time")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SttManager"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package top.yeij.cyrene.voice.stt
|
||||
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
enum class SttProviderType { DASHSCOPE, BACKEND, SYSTEM }
|
||||
|
||||
data class SttResult(
|
||||
val text: String,
|
||||
val isFinal: Boolean,
|
||||
val isError: Boolean = false,
|
||||
)
|
||||
|
||||
interface SttProvider {
|
||||
val partialResult: StateFlow<String>
|
||||
val finalResult: SharedFlow<SttResult>
|
||||
val onError: SharedFlow<String>
|
||||
val isListening: StateFlow<Boolean>
|
||||
|
||||
fun start()
|
||||
fun stop()
|
||||
fun cancel()
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package top.yeij.cyrene.voice.tts
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.Locale
|
||||
|
||||
class TextToSpeechEngine(private val context: Context) {
|
||||
|
||||
private var tts: TextToSpeech? = null
|
||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
|
||||
private val _isSpeaking = MutableStateFlow(false)
|
||||
val isSpeaking = _isSpeaking.asStateFlow()
|
||||
|
||||
private val _onDone = MutableStateFlow(false)
|
||||
val onDone = _onDone.asStateFlow()
|
||||
|
||||
fun initialize(onReady: () -> Unit) {
|
||||
tts = TextToSpeech(context) { status ->
|
||||
if (status == TextToSpeech.SUCCESS) {
|
||||
tts?.language = Locale.CHINESE
|
||||
tts?.setSpeechRate(0.9f)
|
||||
tts?.setPitch(1.1f)
|
||||
|
||||
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(utteranceId: String?) {
|
||||
requestAudioFocus()
|
||||
_isSpeaking.value = true
|
||||
}
|
||||
|
||||
override fun onDone(utteranceId: String?) {
|
||||
_isSpeaking.value = false
|
||||
_onDone.value = true
|
||||
abandonAudioFocus()
|
||||
}
|
||||
|
||||
override fun onError(utteranceId: String?) {
|
||||
_isSpeaking.value = false
|
||||
abandonAudioFocus()
|
||||
}
|
||||
})
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun speak(text: String, utteranceId: String = System.currentTimeMillis().toString()) {
|
||||
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
tts?.stop()
|
||||
_isSpeaking.value = false
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
tts?.stop()
|
||||
tts?.shutdown()
|
||||
}
|
||||
|
||||
private fun requestAudioFocus() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ASSISTANT)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
audioManager.requestAudioFocus(focusRequest)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.requestAudioFocus(
|
||||
null,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun abandonAudioFocus() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// Focus released with the request
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.abandonAudioFocus(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Cyrene</string>
|
||||
<string name="voice_assistant_name">昔涟</string>
|
||||
<string name="voice_assistant_description">昔涟 —— 你的智能语音助手</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="tab_chat">对话</string>
|
||||
<string name="tab_iot">设备</string>
|
||||
<string name="tab_profile">我的</string>
|
||||
|
||||
<!-- Voice -->
|
||||
<string name="hotword_listening">正在聆听唤醒词…</string>
|
||||
<string name="listening">我在听…</string>
|
||||
<string name="thinking">思考中…</string>
|
||||
<string name="speaking">正在说话…</string>
|
||||
<string name="tap_to_speak">点击说话</string>
|
||||
<string name="hold_to_speak">按住说话</string>
|
||||
|
||||
<!-- Actions -->
|
||||
<string name="login">登录</string>
|
||||
<string name="logout">退出登录</string>
|
||||
<string name="settings">设置</string>
|
||||
<string name="cancel">取消</string>
|
||||
<string name="confirm">确认</string>
|
||||
<string name="save">保存</string>
|
||||
<string name="retry">重试</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="set_default_assistant">设为默认语音助手</string>
|
||||
<string name="set_default_assistant_desc">将昔涟替换为系统默认助手</string>
|
||||
<string name="appearance">外观</string>
|
||||
<string name="voice_settings">语音设置</string>
|
||||
<string name="wake_word">唤醒词</string>
|
||||
<string name="account">账号</string>
|
||||
<string name="about">关于</string>
|
||||
<string name="accessibility_service_description">昔涟使用无障碍服务读取屏幕内容,以便在唤醒时理解当前上下文并提供更精准的帮助。不会收集或上传个人隐私信息。</string>
|
||||
<string name="server_address">服务器地址</string>
|
||||
<string name="theme">主题</string>
|
||||
<string name="theme_light">浅色</string>
|
||||
<string name="theme_dark">深色</string>
|
||||
<string name="theme_auto">跟随系统</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Cyrene" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowBackground">@android:color/transparent</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:canRetrieveWindowContent="true"
|
||||
android:description="@string/accessibility_service_description"
|
||||
android:notificationTimeout="500" />
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="cache" path="/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<voice-interaction-service
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:sessionService="top.yeij.cyrene.service.CyreneSessionService"
|
||||
android:recognitionService=".service.CyreneRecognitionService"
|
||||
android:supportsAssist="true"
|
||||
android:supportsLaunchVoiceAssistFromKeyguard="true"
|
||||
/>
|
||||
@@ -0,0 +1,6 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
# 00 — 项目概述与架构
|
||||
|
||||
> 对应主项目 Phase 5(v1.5 → v2.0)Android 客户端
|
||||
> 主项目文档:`../../docs/dev-plan/04-voice-system-plan.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
Cyrene for Android 是昔涟的官方 Android 客户端,定位为 **可替换系统自带语音助手的智能体 APP**(替代 Google Assistant / Bixby)。
|
||||
|
||||
核心差异点:
|
||||
- 不是传统 APP(以桌面图标为主要入口),而是**系统级语音助手**
|
||||
- 除常规全屏 Activity 外,通过 `VoiceInteractionSession` 提供悬浮式覆盖层交互
|
||||
- 支持息屏热词唤醒、长按 Home / 电源键呼出
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
| 层 | 技术 | 版本要求 |
|
||||
|----|------|---------|
|
||||
| 语言 | Kotlin | 2.0+ |
|
||||
| UI 框架 | Jetpack Compose + Material Design 3 | BOM 2025+ |
|
||||
| 构建 | Gradle (Kotlin DSL) | 8.7+ |
|
||||
| 架构模式 | MVVM + Repository | — |
|
||||
| 依赖注入 | Hilt / Koin (待定) | — |
|
||||
| 网络 | Retrofit + OkHttp | — |
|
||||
| 实时通信 | OkHttp WebSocket | — |
|
||||
| 本地存储 | Room (SQLite) + DataStore | — |
|
||||
| 推送 | FCM (Firebase Cloud Messaging) | — |
|
||||
| 系统语音 | VoiceInteractionService | API 23+ |
|
||||
| 热词唤醒 | Always-On Hotword Detection | API 23+ |
|
||||
| 语音识别 | 服务端 (Whisper API) + 本地兜底 | — |
|
||||
|
||||
## 3. 最低系统要求
|
||||
|
||||
| 项目 | 要求 |
|
||||
|------|------|
|
||||
| minSdk | 26 (Android 8.0) |
|
||||
| targetSdk | 35+ |
|
||||
| compileSdk | 35+ |
|
||||
| JDK | 17 |
|
||||
| Gradle | 8.7+ |
|
||||
|
||||
注:`VoiceInteractionService` 基础 API 要求 23,`AssistAction` 热词唤醒要求 23。minSdk 设为 26 以覆盖绝大多数活跃设备并简化兼容性。
|
||||
|
||||
## 4. 项目结构
|
||||
|
||||
```
|
||||
android/
|
||||
├── app/
|
||||
│ ├── src/main/
|
||||
│ │ ├── java/com/cyrene/app/
|
||||
│ │ │ ├── CyreneApplication.kt # Application 初始化
|
||||
│ │ │ ├── MainActivity.kt # 全屏主界面 (桌面图标入口)
|
||||
│ │ │ ├── ui/
|
||||
│ │ │ │ ├── theme/ # MD3 主题 (Color / Type / Shape)
|
||||
│ │ │ │ ├── screens/ # 全屏页面 (Compose)
|
||||
│ │ │ │ │ ├── chat/ # 对话页
|
||||
│ │ │ │ │ ├── home/ # 首页 / IoT 面板
|
||||
│ │ │ │ │ ├── settings/ # 设置页
|
||||
│ │ │ │ │ └── login/ # 登录/注册
|
||||
│ │ │ │ ├── overlay/ # 悬浮窗对话界面 (VoiceInteractionSession)
|
||||
│ │ │ │ └── components/ # 共享组件库
|
||||
│ │ │ ├── viewmodel/ # ViewModel 层
|
||||
│ │ │ ├── domain/ # 领域层 (UseCase / Repository 接口)
|
||||
│ │ │ ├── data/
|
||||
│ │ │ │ ├── remote/ # API 接口定义 + DTO
|
||||
│ │ │ │ ├── local/ # Room DAO + DataStore
|
||||
│ │ │ │ └── repository/ # Repository 实现
|
||||
│ │ │ ├── service/
|
||||
│ │ │ │ ├── CyreneVoiceInteractionService.kt
|
||||
│ │ │ │ ├── CyreneVoiceInteractionSession.kt
|
||||
│ │ │ │ ├── CyreneAssistService.kt
|
||||
│ │ │ │ └── WebSocketService.kt
|
||||
│ │ │ ├── voice/
|
||||
│ │ │ │ ├── hotword/ # 热词唤醒引擎
|
||||
│ │ │ │ ├── stt/ # 语音识别客户端
|
||||
│ │ │ │ └── tts/ # 语音合成客户端
|
||||
│ │ │ ├── di/ # DI 模块定义
|
||||
│ │ │ └── util/ # 工具类
|
||||
│ │ ├── res/ # 资源文件
|
||||
│ │ └── AndroidManifest.xml
|
||||
│ └── build.gradle.kts
|
||||
├── gradle/
|
||||
│ ├── libs.versions.toml # 版本目录
|
||||
│ └── wrapper/
|
||||
├── build.gradle.kts # 根构建脚本
|
||||
├── settings.gradle.kts
|
||||
└── gradle.properties
|
||||
```
|
||||
|
||||
## 5. 架构分层
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ UI Layer (Compose) │
|
||||
│ ├─ Screens (全屏 Activity) │
|
||||
│ └─ Overlay (VoiceInteractionSession 悬浮窗) │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ ViewModel Layer │
|
||||
│ ├─ ChatViewModel │
|
||||
│ ├─ HomeViewModel (IoT) │
|
||||
│ ├─ SettingsViewModel │
|
||||
│ └─ OverlayViewModel │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Domain Layer │
|
||||
│ ├─ UseCase (SendMessage, ControlIoT, ...) │
|
||||
│ └─ Repository Interfaces │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Data Layer │
|
||||
│ ├─ Remote: Retrofit API + WebSocket │
|
||||
│ ├─ Local: Room + DataStore │
|
||||
│ └─ Repository Implementation │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Service Layer │
|
||||
│ ├─ VoiceInteractionService (系统助手) │
|
||||
│ ├─ WebSocketService (长连接) │
|
||||
│ └─ FCMMessagingService (推送) │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
UI 层和 Service 层通过 ViewModel 解耦——全屏 Activity 和悬浮窗 Overlay 复用同一组 ViewModel,只是 UI 布局不同。
|
||||
|
||||
## 6. 与后端的关系
|
||||
|
||||
```
|
||||
Android Client
|
||||
│
|
||||
├─ HTTP REST ──────────► Gateway (:8080) # 登录、CRUD、配置
|
||||
├─ WebSocket ──────────► Gateway (:8080) # 实时对话、IoT 状态推送
|
||||
├─ STT Audio ──────────► Voice Service (:8093) # 语音识别
|
||||
└─ TTS Stream ◄──────── Voice Service (:8093) # 语音合成
|
||||
```
|
||||
|
||||
WebSocket 长连接是核心通信通道:对话消息、通知、IoT 状态广播均通过同一连接。HTTP 仅用于一次性操作(登录、文件上传/下载)。
|
||||
|
||||
## 7. 关键设计决策
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|------|------|------|
|
||||
| UI 框架 | Jetpack Compose + MD3 | 声明式 UI,与悬浮窗的 ComposeView 集成简单 |
|
||||
| 架构 | MVVM + Repository | Google 官方推荐,ViewModel 可在 Activity 和 Session 间复用 |
|
||||
| 语音框架 | VoiceInteractionService(系统 API) | 原生支持替换系统助手,无需自定义悬浮窗权限 |
|
||||
| 热词方案 | 系统 Always-On Hotword API | 息屏低功耗监听,不用自建音频采集 |
|
||||
| 网络 | OkHttp WebSocket | 比 FCM 更实时,与主项目 Gateway 已有 WS Hub 对应 |
|
||||
| 最低 API | 26 | 覆盖 95%+ 活跃设备,VoiceInteractionService 兼容 |
|
||||
|
||||
## 8. 排期参考
|
||||
|
||||
对应主项目路线图:
|
||||
|
||||
```
|
||||
2026 Q4 ─ v1.3 多平台接入(前置依赖)
|
||||
2027 Q1 ─ v1.8 语音模型训练完成(后端依赖)
|
||||
2027 Q2 ─ v2.0 开始 Android 客户端开发
|
||||
2027 Q3 ─ v2.3 语音助手 APP MVP 版本
|
||||
2027 Q4 ─ v3.0 APP 上架(Google Play / 国内应用商店)
|
||||
```
|
||||
@@ -0,0 +1,256 @@
|
||||
# 01 — 系统语音助手集成规范
|
||||
|
||||
> **目标**:让昔涟成为 Android 系统级默认语音助手,替换 Google Assistant / Bixby
|
||||
> **核心 API**:`VoiceInteractionService` + `VoiceInteractionSession`
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能目标
|
||||
|
||||
- 用户可在 **系统设置 → 默认应用 → 数字助理** 中选择昔涟
|
||||
- 长按 Home 键呼出昔涟(非全屏,悬浮覆盖层)
|
||||
- 屏幕底部两角向内滑动触发昔涟
|
||||
- 长按电源键可配置为呼出昔涟
|
||||
- 息屏状态下热词唤醒昔涟
|
||||
- 有线/蓝牙耳机按键呼出昔涟
|
||||
|
||||
## 2. AndroidManifest.xml 声明
|
||||
|
||||
```xml
|
||||
<!-- VoiceInteractionService -->
|
||||
<service
|
||||
android:name=".service.CyreneVoiceInteractionService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_VOICE_INTERACTION">
|
||||
|
||||
<meta-data
|
||||
android:name="android.voice_interaction"
|
||||
android:resource="@xml/voice_interaction_config" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- AssistService (Android 14+) -->
|
||||
<service
|
||||
android:name=".service.CyreneAssistService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_ASSIST">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.service.voice.AssistService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
```
|
||||
|
||||
## 3. 配置文件
|
||||
|
||||
### res/xml/voice_interaction_config.xml
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<voice-interaction-service
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:sessionService=".service.CyreneVoiceInteractionSession"
|
||||
android:recognitionService=".service.CyreneRecognitionService"
|
||||
android:supportsAssist="true"
|
||||
android:supportsLaunchVoiceAssistFromKeyguard="true"
|
||||
android:supportsLocalRecognition="true"
|
||||
android:serviceIcon="@drawable/ic_cyrene"
|
||||
android:serviceLabel="@string/voice_assistant_name" />
|
||||
```
|
||||
|
||||
## 4. VoiceInteractionService 实现
|
||||
|
||||
```kotlin
|
||||
class CyreneVoiceInteractionService : VoiceInteractionService() {
|
||||
|
||||
override fun onReady() {
|
||||
super.onReady()
|
||||
// 服务就绪,可在此初始化 TTS 引擎等
|
||||
}
|
||||
|
||||
override fun onCreateSession(args: Bundle?): VoiceInteractionSession {
|
||||
return CyreneVoiceInteractionSession(this)
|
||||
}
|
||||
|
||||
override fun onLaunchVoiceAssistFromKeyguard() {
|
||||
// 锁屏启动 → 进入简化模式,仅显示对话,IoT 控制等需先解锁
|
||||
}
|
||||
|
||||
// Android 14+: AssistAction 回调
|
||||
override fun onHandleAssist(
|
||||
request: AssistRequest?,
|
||||
cancellationSignal: CancellationSignal?,
|
||||
callback: OutcomeCallback<AssistResult?>?
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
request?.let {
|
||||
val assistContent = it.assistContent
|
||||
// 提取当前屏幕上下文(可选,用于后续上下文感知)
|
||||
callback?.onResult(AssistResult(assistContent))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. VoiceInteractionSession 实现(悬浮窗界面)
|
||||
|
||||
```kotlin
|
||||
class CyreneVoiceInteractionSession(context: Context) :
|
||||
VoiceInteractionSession(context) {
|
||||
|
||||
override fun onCreateContentView(): View {
|
||||
// 返回 ComposeView 作为悬浮窗的内容
|
||||
return ComposeView(context).apply {
|
||||
setContent {
|
||||
CyreneTheme {
|
||||
OverlayScreen(
|
||||
viewModel = overlayViewModel,
|
||||
onDismiss = { finish() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShow(args: Bundle?, showFlags: Int) {
|
||||
super.onShow(args, showFlags)
|
||||
// 设置窗口属性:透明背景 + 底部卡片式布局
|
||||
window?.apply {
|
||||
// 半透明遮罩
|
||||
setBackgroundDrawable(ColorDrawable(0x80000000.toInt()))
|
||||
// FLAG_DIM_BEHIND 可实现模糊效果
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
addSystemGestureExclusionRects(...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onComputeInsets(outInsets: Insets?) {
|
||||
super.onComputeInsets(outInsets)
|
||||
// 控制悬浮窗内容区域
|
||||
}
|
||||
|
||||
override fun onHide() {
|
||||
super.onHide()
|
||||
// 悬浮窗隐藏时清理状态
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 关键窗口属性
|
||||
|
||||
| 属性 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| 背景 | `ColorDrawable(0x80000000)` | 半透明黑色遮罩,透出底层 APP |
|
||||
| 内容区域 | 自适应高度 | 底部弹出,类似 Google Assistant |
|
||||
| 触摸外区域行为 | 关闭悬浮窗 | 用户点击遮罩区域关闭 |
|
||||
| 键盘弹出 | 推高内容区域 | 文本输入时自动调整 |
|
||||
|
||||
## 6. 权限清单
|
||||
|
||||
```xml
|
||||
<!-- 核心语音助手权限 -->
|
||||
<uses-permission android:name="android.permission.BIND_VOICE_INTERACTION" />
|
||||
<uses-permission android:name="android.permission.BIND_ASSIST" />
|
||||
|
||||
<!-- 音频相关 -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<!-- 热词唤醒 -->
|
||||
<uses-permission android:name="android.permission.CAPTURE_AUDIO_HOTWORD" />
|
||||
|
||||
<!-- 后台服务 -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
|
||||
<!-- 网络 -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- 推送 -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- 锁屏交互 -->
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
```
|
||||
|
||||
## 7. 引导用户设为默认助手
|
||||
|
||||
首次启动时检测并引导:
|
||||
|
||||
```kotlin
|
||||
fun checkAndPromptDefaultAssistant(context: Context) {
|
||||
val isDefault = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val componentName = ComponentName(context, CyreneVoiceInteractionService::class.java)
|
||||
context.packageManager
|
||||
.queryIntentServices(
|
||||
Intent(VoiceInteractionService.SERVICE_INTERFACE),
|
||||
PackageManager.MATCH_DEFAULT_ONLY
|
||||
)
|
||||
.any { it.serviceInfo.packageName == context.packageName }
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
if (!isDefault) {
|
||||
// 显示引导 UI → 跳转到 Settings.ACTION_VOICE_INPUT_SETTINGS
|
||||
val intent = Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 热词唤醒检测
|
||||
|
||||
### 方案选型
|
||||
|
||||
| 方案 | 优点 | 缺点 | 适用场景 |
|
||||
|------|------|------|---------|
|
||||
| 系统 Always-On Hotword API | 低功耗、系统级支持 | 限 Android 8+,某些 ROM 不支持 | **首选** |
|
||||
| Porcupine (Picovoice) | 跨平台、离线 | 商业许可,需额外集成 | 兜底 |
|
||||
| 自建模型 (openWakeWord) | 完全可控、低成本 | 需要本地推理能力 | 长期方案 |
|
||||
|
||||
### 唤醒词配置
|
||||
|
||||
| 优先级 | 唤醒词 | 说明 |
|
||||
|--------|--------|------|
|
||||
| P0 | "昔涟" (Xī Lián) | 角色名,默认唤醒词 |
|
||||
| P1 | "Hey 昔涟" | 与 "Hey Google" 习惯对齐 |
|
||||
| P2 | 自定义 | 用户可在设置中自定义 |
|
||||
|
||||
### 息屏唤醒流程
|
||||
|
||||
```
|
||||
用户说出唤醒词
|
||||
→ HotwordDetector 识别成功(<800ms)
|
||||
→ 系统触发 VoiceInteractionService
|
||||
→ CyreneVoiceInteractionSession.onCreateContentView()
|
||||
→ Overlay 显示,播放连接提示音
|
||||
→ 用户说话 → STT → AI-Core → TTS → 语音回复
|
||||
→ 对话结束 → finish() → 息屏
|
||||
```
|
||||
|
||||
## 9. Dismiss 时机
|
||||
|
||||
悬浮窗在以下情况关闭:
|
||||
|
||||
| 条件 | 行为 |
|
||||
|------|------|
|
||||
| 用户说"再见" / "退下" | 自然对话结束,收起悬浮窗 |
|
||||
| 用户点击遮罩区域 | 立即关闭 |
|
||||
| 对话静默 10 秒 | 自动收起 |
|
||||
| 用户主动滑动关闭 | 手势关闭,同 Google Assistant |
|
||||
| 收到系统电话等中断 | 暂停语音,进入后台等待 |
|
||||
|
||||
## 10. 降级策略
|
||||
|
||||
当系统不支持 `VoiceInteractionService` 或未设为默认助手时:
|
||||
|
||||
- **保底方案**:PWA(利用主项目已有的 PWA 支持)
|
||||
- **WebView 封装**:内嵌 H5 对话界面作为过渡
|
||||
- **通知栏常驻**:提供快速对话入口,但功能受限
|
||||
@@ -0,0 +1,237 @@
|
||||
# 02 — 交互流程与导航设计
|
||||
|
||||
> **核心原则**:间接启动(语音/手势)不进入全屏 APP,而是以悬浮覆盖层呈现
|
||||
> **关联文档**:[01-voice-assistant-system.md](01-voice-assistant-system.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 启动来源 → 界面模式 映射表
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ 启动来源 │
|
||||
├───────────────────────┬──────────────────────────────────┤
|
||||
│ 直接启动 (Explicit) │ 间接启动 (Implicit) │
|
||||
├───────────────────────┼──────────────────────────────────┤
|
||||
│ · 桌面图标 │ · 语音唤醒 ("昔涟") │
|
||||
│ · 最近任务列表 │ · 长按 Home 键 │
|
||||
│ · 通知栏点击 │ · 底部两角向内滑动 │
|
||||
│ · Deep Link │ · 长按电源键 (配置后) │
|
||||
│ │ · 耳机按键 (单击/长按) │
|
||||
│ │ · 锁屏右滑助手 │
|
||||
├───────────────────────┼──────────────────────────────────┤
|
||||
│ ▼ │ ▼ │
|
||||
│ 全屏 Activity │ VoiceInteractionSession │
|
||||
│ (MainActivity) │ (全屏悬浮覆盖层) │
|
||||
│ · 完整导航栏 │ · 无导航栏 │
|
||||
│ · Tab 切换 │ · 仅对话卡片 │
|
||||
│ · 设置/IoT 面板 │ · 半透明遮罩透出底层 │
|
||||
│ · 压入返回栈 │ · 不压入返回栈 │
|
||||
└───────────────────────┴──────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 2. 全屏 Activity 模式
|
||||
|
||||
### 2.1 导航结构
|
||||
|
||||
```
|
||||
MainActivity
|
||||
├── BottomNavigation
|
||||
│ ├── Tab 1: 对话 (ChatScreen) ← 默认页
|
||||
│ ├── Tab 2: IoT 面板 (IoTScreen)
|
||||
│ └── Tab 3: 我的 (ProfileScreen)
|
||||
├── TopAppBar
|
||||
│ ├── 昔涟状态指示器 (在线/思考中/离线)
|
||||
│ └── 快捷操作 (设置、通知)
|
||||
└── 子页面 (通过 NavHost 导航)
|
||||
├── SettingsScreen
|
||||
├── MemoryScreen (记忆查看)
|
||||
├── KnowledgeScreen (知识库)
|
||||
├── AutomationScreen (自动化规则)
|
||||
├── ReminderScreen (提醒列表)
|
||||
└── LoginScreen
|
||||
```
|
||||
|
||||
### 2.2 导航图 (NavGraph)
|
||||
|
||||
```
|
||||
LoginScreen ──(登录成功)──► MainScreen (带 BottomNav)
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
▼ ▼ ▼
|
||||
ChatScreen IoTScreen ProfileScreen
|
||||
│ │
|
||||
▼ ▼
|
||||
MemoryScreen SettingsScreen
|
||||
KnowledgeScreen ├─ Account
|
||||
AutomationScreen ├─ Appearance (主题)
|
||||
ReminderScreen ├─ Voice (唤醒词/语音)
|
||||
├─ IoT Config
|
||||
└─ About
|
||||
```
|
||||
|
||||
### 2.3 登录流程
|
||||
|
||||
```
|
||||
APP 首次启动
|
||||
→ 检查本地 Token
|
||||
├─ 有效 → 直接进入 MainScreen
|
||||
└─ 无效 → LoginScreen
|
||||
├─ 输入 Gateway 地址 + 账号密码
|
||||
├─ POST /api/v1/auth/login
|
||||
├─ 保存 Token 到 DataStore
|
||||
└─ 进入 MainScreen
|
||||
```
|
||||
|
||||
## 3. 悬浮覆盖层模式 (VoiceInteractionSession)
|
||||
|
||||
### 3.1 视觉层级
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ 底层 APP (半透明可见) │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ 半透明黑色遮罩 (80% 不透明度) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────┐ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ 对话卡片区域 │ │ │
|
||||
│ │ │ (圆角顶部 28dp) │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ · 昔涟状态条 │ │ │
|
||||
│ │ │ · 对话消息流 │ │ │
|
||||
│ │ │ · 文本输入框 │ │ │
|
||||
│ │ │ · 语音输入按钮 │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ └──────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 覆盖层生命周期
|
||||
|
||||
```
|
||||
Trigger (唤醒词/手势/按键)
|
||||
│
|
||||
▼
|
||||
VoiceInteractionSession.onCreateContentView()
|
||||
│
|
||||
▼
|
||||
onShow() → 设置窗口属性 → 播放出现动画(底部滑入)
|
||||
│
|
||||
▼
|
||||
OverlayScreen Compose 渲染
|
||||
│
|
||||
├→ 用户说话 → STT → 显示识别文本 → 发送到 AI-Core
|
||||
│ │
|
||||
│ ▼
|
||||
│ SSE 流式响应
|
||||
│ │
|
||||
├← TTS 语音播放 ←── 流式合成 ←────────────┘
|
||||
│
|
||||
├→ 用户打字输入 → WebSocket 发送 → 显示回复气泡
|
||||
│
|
||||
▼
|
||||
对话结束
|
||||
├→ 用户主动关闭 (说"再见"/点击遮罩/下滑)
|
||||
└→ 超时自动关闭 (静默 10 秒)
|
||||
│
|
||||
▼
|
||||
onHide() → 播放消失动画(底部滑出)
|
||||
│
|
||||
▼
|
||||
finish() → 返回触发前界面
|
||||
```
|
||||
|
||||
### 3.3 覆盖层状态机
|
||||
|
||||
```
|
||||
┌──────────┐
|
||||
│ IDLE │ (覆盖层不可见)
|
||||
└────┬─────┘
|
||||
│ 触发
|
||||
▼
|
||||
┌──────────┐
|
||||
│LISTENING │ (等待语音输入,波形动画)
|
||||
└────┬─────┘
|
||||
│ 检测到语音 / 用户开始打字
|
||||
▼
|
||||
┌──────────┐
|
||||
│PROCESSING│ (STT 识别中 / LLM 思考中)
|
||||
└────┬─────┘
|
||||
│ 收到回复
|
||||
▼
|
||||
┌──────────┐
|
||||
│SPEAKING │ (TTS 播放中)
|
||||
└────┬─────┘
|
||||
│ 播放完毕,等待下一轮
|
||||
▼
|
||||
┌──────────┐
|
||||
│ WAITING │ (等待用户继续或关闭)
|
||||
└────┬─────┘
|
||||
│
|
||||
┌───────┼───────┐
|
||||
│ │
|
||||
用户继续说话 10s 静默
|
||||
│ │
|
||||
▼ ▼
|
||||
LISTENING IDLE
|
||||
(覆盖层关闭)
|
||||
```
|
||||
|
||||
### 3.4 与全屏 Activity 的切换
|
||||
|
||||
```
|
||||
悬浮窗中用户点击 "打开完整 APP"
|
||||
→ finish() 关闭悬浮窗
|
||||
→ startActivity(MainActivity)
|
||||
→ 用户在全屏模式下继续操作
|
||||
|
||||
全屏 APP 中用户按 Home 返回桌面
|
||||
→ onStop() → 进入后台
|
||||
→ WebSocket 保持连接
|
||||
→ 推送/FCM 通知到达时,点击通知 → 恢复 MainActivity
|
||||
```
|
||||
|
||||
## 4. 锁屏交互
|
||||
|
||||
### 4.1 锁屏唤醒
|
||||
|
||||
```
|
||||
设备锁屏 + 息屏
|
||||
│
|
||||
├→ 说出唤醒词 "昔涟"
|
||||
│ └→ onLaunchVoiceAssistFromKeyguard()
|
||||
│ └→ 简化版覆盖层 (仅对话,无 IoT / 敏感操作)
|
||||
│
|
||||
└→ 长按电源键
|
||||
└→ 同理
|
||||
```
|
||||
|
||||
### 4.2 锁屏安全策略
|
||||
|
||||
| 操作 | 锁屏状态 | 行为 |
|
||||
|------|---------|------|
|
||||
| 查询天气/时间 | 允许 | 直接回复 |
|
||||
| 简单闲聊 | 允许 | 直接回复 |
|
||||
| IoT 查询(状态) | 允许 | 回复设备状态 |
|
||||
| IoT 控制(开关) | **禁止** | 提示"请先解锁设备" |
|
||||
| 查看记忆 | **禁止** | 提示"请先解锁设备" |
|
||||
| 修改设置 | **禁止** | 提示"请先解锁设备" |
|
||||
| 宿主命令 | **禁止** | 提示"请先解锁设备" |
|
||||
|
||||
## 5. 多窗口与分屏
|
||||
|
||||
- **分屏模式**:悬浮窗模式下不支持(本身已是覆盖层);全屏 Activity 支持分屏
|
||||
- **画中画**:语音通话场景支持画中画(PIP),显示昔涟头像 + 波形动画
|
||||
|
||||
## 6. 手势交互
|
||||
|
||||
| 手势 | 悬浮窗模式 | 全屏 Activity |
|
||||
|------|-----------|---------------|
|
||||
| 下滑覆盖层 | 关闭悬浮窗 | — |
|
||||
| 点击遮罩区域 | 关闭悬浮窗 | — |
|
||||
| 长按消息 | 复制/分享菜单 | 复制/分享/删除 |
|
||||
| 左滑消息 | — | 查看消息详情/时间戳 |
|
||||
| 双击昔涟头像 | 切换输入模式(语音↔文字) | 同左 |
|
||||
@@ -0,0 +1,208 @@
|
||||
# 03 — 设计系统规范 (Material Design 3)
|
||||
|
||||
> **设计语言**:Material Design 3 (Material You)
|
||||
> **组件库**:`androidx.compose.material3`
|
||||
> **最低 API**:26(不支持 Monet 的设备回退为手动主题色)
|
||||
|
||||
---
|
||||
|
||||
## 1. 色彩系统
|
||||
|
||||
### 1.1 动态配色 (Dynamic Color)
|
||||
|
||||
```
|
||||
首选:androidx.compose.material3.dynamicColor
|
||||
→ 系统壁纸提取 Primary / Secondary / Tertiary
|
||||
→ 支持 Android 12+ (API 31+)
|
||||
→ API 26-30 回退为预设主题色
|
||||
|
||||
备选:用户在设置中手动选择 Seed Color
|
||||
→ 通过 MaterialTheme.colorScheme 的 lightColorScheme/darkColorScheme 生成
|
||||
```
|
||||
|
||||
### 1.2 预设主题色
|
||||
|
||||
| 主题名 | Seed Color | 氛围 |
|
||||
|--------|-----------|------|
|
||||
| 默认(昔涟紫) | `#9C6BFF` (Lavender) | 温柔、亲切 |
|
||||
| 樱花粉 | `#FFB4C8` (Sakura) | 甜美 |
|
||||
| 海洋蓝 | `#6BA4FF` (Ocean) | 清爽 |
|
||||
| 森林绿 | `#6BCF7C` (Forest) | 自然 |
|
||||
| 日落橙 | `#FF9E6B` (Sunset) | 温暖 |
|
||||
|
||||
### 1.3 暗黑模式
|
||||
|
||||
| 属性 | Light | Dark |
|
||||
|------|-------|------|
|
||||
| Surface | `#FFFBFF` | `#1C1B1F` |
|
||||
| Background | `#FFFBFF` | `#1C1B1F` |
|
||||
| Primary | Dynamic | Dynamic (暗黑自适应) |
|
||||
| OnSurface | `#1C1B1F` | `#E6E1E5` |
|
||||
| SurfaceVariant | `#E7E0EC` | `#49454F` |
|
||||
| 遮罩颜色 | `rgba(0,0,0,0.5)` | `rgba(0,0,0,0.7)` |
|
||||
|
||||
### 1.4 悬浮窗专用色
|
||||
|
||||
```
|
||||
覆盖层背景遮罩:
|
||||
Light: rgba(0, 0, 0, 0.5) // 50% 不透明度
|
||||
Dark: rgba(0, 0, 0, 0.7) // 70% 不透明度
|
||||
|
||||
对话卡片背景:
|
||||
Light: MaterialTheme.colorScheme.surface
|
||||
Dark: MaterialTheme.colorScheme.surface
|
||||
|
||||
卡片圆角:28dp (顶部) / 0dp (底部)
|
||||
卡片阴影 (Light):8dp elevation
|
||||
卡片阴影 (Dark):无阴影,用 1dp outline 代替
|
||||
```
|
||||
|
||||
## 2. 字体系统 (Typography)
|
||||
|
||||
| 角色 | 字号 | 字重 | 行高 | 用途 |
|
||||
|------|------|------|------|------|
|
||||
| displayLarge | 57sp | 400 | 64sp | 欢迎页标题 |
|
||||
| headlineMedium | 28sp | 400 | 36sp | 设置页标题 |
|
||||
| titleLarge | 22sp | 400 | 28sp | 对话框标题 |
|
||||
| titleMedium | 16sp | 500 | 24sp | 列表标题 |
|
||||
| bodyLarge | 16sp | 400 | 24sp | 对话气泡文字 |
|
||||
| bodyMedium | 14sp | 400 | 20sp | 辅助文字、时间戳 |
|
||||
| labelLarge | 14sp | 500 | 20sp | 按钮文字 |
|
||||
| labelMedium | 12sp | 500 | 16sp | Tab 标签 |
|
||||
| labelSmall | 11sp | 500 | 16sp | 状态标签 |
|
||||
|
||||
字体家族:`system-ui`(默认),不支持自定义字体以保证加载速度和系统一致性。
|
||||
|
||||
## 3. 形状系统 (Shapes)
|
||||
|
||||
| 角色 | 圆角 | 用途 |
|
||||
|------|------|------|
|
||||
| extraSmall | 4dp | 小标签、芯片 |
|
||||
| small | 8dp | 输入框、按钮 |
|
||||
| medium | 12dp | 卡片、对话框 |
|
||||
| large | 16dp | 大卡片 |
|
||||
| extraLarge | 28dp | 底部弹出卡片、Sheet |
|
||||
|
||||
## 4. 组件规范
|
||||
|
||||
### 4.1 对话气泡
|
||||
|
||||
```
|
||||
昔涟消息 (左侧):
|
||||
┌─────────────────────────────┐
|
||||
│ 🤖 昔涟 10:32 │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ 开拓者,今天心情怎么样? │ │ ← 圆角: 12dp (top-start 4dp)
|
||||
│ └─────────────────────────┘ │ 背景: PrimaryContainer
|
||||
│ │ 文字: OnPrimaryContainer
|
||||
└─────────────────────────────┘
|
||||
|
||||
用户消息 (右侧):
|
||||
┌─────────────────────────────┐
|
||||
│ 你 10:33 │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ 还不错!你呢? │ │ ← 圆角: 12dp (top-end 4dp)
|
||||
│ └─────────────────────┘ │ 背景: Primary
|
||||
│ │ 文字: OnPrimary
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 消息类型样式
|
||||
|
||||
| 类型 | 样式 | 示例 |
|
||||
|------|------|------|
|
||||
| `chat` | 普通气泡 | 对话内容 |
|
||||
| `action` | 居中斜体、灰色 | *昔涟正在查看客厅灯光状态* |
|
||||
| `thinking` | 折叠面板、虚线边框 | 可展开/折叠 |
|
||||
| `system_info` | Toast 样式 | 服务状态告知 |
|
||||
| `tool_progress` | 进度条 + 图标 | IoT 操作进行中 |
|
||||
|
||||
### 4.3 语音输入按钮 (悬浮窗核心组件)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ │
|
||||
│ 🎤 波形动画 │ (LISTENING 状态)
|
||||
│ "我在听..." │
|
||||
│ │
|
||||
│ ┌───────────────────────────┐ │
|
||||
│ │ 输入文字或直接说话... │ │ (IDLE 状态,点击切换语音)
|
||||
│ └───────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────┐ ┌──┐ │
|
||||
│ │ 🎤 │ (按住说话) │⌨️│ │ (WAITING 状态)
|
||||
│ └────┘ └──┘ │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.4 昔涟状态指示器
|
||||
|
||||
```
|
||||
在线 (绿色点 + "昔涟"):
|
||||
● 昔涟
|
||||
|
||||
思考中 (黄色脉冲 + "思考中..."):
|
||||
◉ 思考中...
|
||||
|
||||
离线 (灰色 + "离线"):
|
||||
○ 昔涟 · 离线
|
||||
|
||||
说话中 (蓝色波纹 + "正在说话..."):
|
||||
〰 正在说话...
|
||||
```
|
||||
|
||||
### 4.5 IoT 设备卡片
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ 💡 客厅灯 ● ON │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ 亮度 ████████░░ 80% │ │
|
||||
│ │ 色温 ████░░░░░░ 4000K│ │
|
||||
│ └──────────────────────┘ │
|
||||
│ [💡 开关] │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
## 5. 动效规范
|
||||
|
||||
| 动效 | 时长 | 曲线 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 覆盖层出现 | 300ms | `FastOutSlowInEasing` | 底部滑入 |
|
||||
| 覆盖层消失 | 250ms | `FastOutLinearInEasing` | 底部滑落 |
|
||||
| 气泡出现 | 200ms | `LinearOutSlowInEasing` | 淡入 + 微上移 |
|
||||
| 涟漪效果 | 400ms | `LinearEasing` | 标准 MD3 ripple |
|
||||
| 页面切换 | 300ms | `FastOutSlowInEasing` | 淡入淡出 |
|
||||
| 波形动画 | 循环 | — | 录制时音频可视化 |
|
||||
| 状态指示脉冲 | 2s 循环 | — | 思考中 / 说话中的呼吸灯效果 |
|
||||
|
||||
## 6. 图标系统
|
||||
|
||||
| 来源 | 用途 |
|
||||
|------|------|
|
||||
| `Icons.Filled` | 导航栏、主要操作按钮 |
|
||||
| `Icons.Outlined` | 列表项、辅助操作 |
|
||||
| `Icons.Rounded` | 芯片、标签 |
|
||||
| 自定义 Lottie | 昔涟头像动画、情感表达 |
|
||||
| 自定义 Vector | 品牌 LOGO、IoT 设备图标 |
|
||||
|
||||
## 7. 悬浮窗 vs 全屏 布局差异
|
||||
|
||||
| 元素 | 全屏 Activity | 悬浮窗 Overlay |
|
||||
|------|-------------|---------------|
|
||||
| TopAppBar | 显示(标题 + 操作) | 不显示 |
|
||||
| BottomNav | 显示(三 Tab) | 不显示 |
|
||||
| 对话区域 | 全屏滚动 | 自适应高度,最大 70% 屏幕 |
|
||||
| 背景 | Surface 纯色 | 半透明遮罩 + 卡片 |
|
||||
| 圆角 | 无 | 顶部 28dp |
|
||||
| 导航返回 | 系统返回键 | 关闭覆盖层 |
|
||||
| IoT 面板 | 完整功能 | 仅限查询,无控制 |
|
||||
| 设置入口 | 完整 | 无(需打开 APP) |
|
||||
|
||||
## 8. 无障碍规范
|
||||
|
||||
- 所有可交互元素提供 `contentDescription`
|
||||
- 语音按钮提供大点击区域(最小 48dp × 48dp)
|
||||
- 支持 TalkBack 导航
|
||||
- 字体缩放:支持系统字体大小设置(最大 200%)
|
||||
- 色彩对比度:满足 WCAG AA 标准(正文 ≥ 4.5:1,大文字 ≥ 3:1)
|
||||
@@ -0,0 +1,272 @@
|
||||
# 04 — 功能规格说明书
|
||||
|
||||
> **版本**:MVP v0.1 → Stable v1.0
|
||||
> **优先级定义**:P0 = 不可缺失 | P1 = 首个正式版必需 | P2 = 后续版本
|
||||
|
||||
---
|
||||
|
||||
## 1. 功能总览
|
||||
|
||||
### MVP (v0.1) — 核心语音助手
|
||||
|
||||
| # | 功能 | 优先级 | 说明 |
|
||||
|---|------|--------|------|
|
||||
| F01 | VoiceInteractionService 注册 | P0 | 系统可识别并设为默认助手 |
|
||||
| F02 | 语音唤醒(热词"昔涟") | P0 | 息屏/亮屏唤醒 |
|
||||
| F03 | 悬浮覆盖层对话 | P0 | VoiceInteractionSession 界面 |
|
||||
| F04 | STT 语音识别 | P0 | 实时语音转文字 |
|
||||
| F05 | TTS 语音合成 | P0 | 文字转语音回复 |
|
||||
| F06 | 实时文字对话 | P0 | WebSocket 双向通信 |
|
||||
| F07 | 用户认证与登录 | P0 | Token 持久化 |
|
||||
|
||||
### v0.5 — 功能完善
|
||||
|
||||
| # | 功能 | 优先级 | 说明 |
|
||||
|---|------|--------|------|
|
||||
| F08 | IoT 设备状态查询 | P1 | 只读查询设备状态 |
|
||||
| F09 | IoT 设备控制 | P1 | 开关/调节设备 |
|
||||
| F10 | 推送通知 (FCM) | P1 | 昔涟主动消息、提醒 |
|
||||
| F11 | 多会话历史 | P1 | 查看历史对话记录 |
|
||||
| F12 | 提醒管理 | P1 | 创建/查看/删除提醒 |
|
||||
| F13 | 全屏 Activity 模式 | P1 | 桌面图标入口、完整功能 |
|
||||
| F14 | 暗黑模式 | P1 | 跟随系统 / 手动切换 |
|
||||
| F15 | 自定义唤醒词 | P2 | 用户可修改唤醒词 |
|
||||
|
||||
### v1.0 — 正式版
|
||||
|
||||
| # | 功能 | 优先级 | 说明 |
|
||||
|---|------|--------|------|
|
||||
| F16 | 记忆查看 | P1 | 浏览昔涟的记忆 |
|
||||
| F17 | 自动化规则 | P2 | 查看/触发自动化场景 |
|
||||
| F18 | 知识库查询 | P2 | 检索知识文档 |
|
||||
| F19 | 文件上传 | P2 | 图片上传与分析 |
|
||||
| F20 | 后台思考展示 | P2 | 查看昔涟的思考内容 |
|
||||
| F21 | 多设备同步 | P2 | Web 端和 Android 端对话同步 |
|
||||
| F22 | 画中画语音通话 | P2 | 持续语音对话的 PIP 模式 |
|
||||
| F23 | 主题自定义 | P2 | 预设主题色切换 |
|
||||
| F24 | 离线兜底 | P2 | 无网络时的本地回复 |
|
||||
|
||||
---
|
||||
|
||||
## 2. P0 功能详细规格
|
||||
|
||||
### F01 · VoiceInteractionService 注册
|
||||
|
||||
**用户故事**:作为用户,我可以在系统设置中将昔涟设为默认语音助手。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] AndroidManifest.xml 正确声明 `VoiceInteractionService`
|
||||
- [ ] 系统 **设置 → 默认应用 → 数字助理** 列表中可见 "昔涟"
|
||||
- [ ] 选中后,长按 Home 键可触发昔涟
|
||||
- [ ] 选中后,底部两角滑动可触发昔涟
|
||||
- [ ] 未设为默认时,APP 内显示引导卡片并一键跳转设置页
|
||||
|
||||
**技术依赖**:无
|
||||
|
||||
---
|
||||
|
||||
### F02 · 语音唤醒
|
||||
|
||||
**用户故事**:作为用户,我可以在息屏或使用其他 APP 时说"昔涟"直接唤起助手。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 息屏状态下说出"昔涟"可唤醒(成功率 ≥ 95%,安静环境)
|
||||
- [ ] 亮屏使用其他 APP 时说出"昔涟"可唤醒
|
||||
- [ ] 唤醒后显示悬浮覆盖层,播放提示音
|
||||
- [ ] 10 分钟无交互自动停止热词监听以省电
|
||||
- [ ] 用户可在设置中开启/关闭息屏唤醒
|
||||
- [ ] 误唤醒率 ≤ 5 次/天(正常使用环境)
|
||||
|
||||
**技术依赖**:F01 (VoiceInteractionService),`CAPTURE_AUDIO_HOTWORD` 权限
|
||||
|
||||
---
|
||||
|
||||
### F03 · 悬浮覆盖层对话
|
||||
|
||||
**用户故事**:作为用户,语音唤醒昔涟后看到半透明覆盖层,不影响当前使用的 APP。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 底部滑入动画 300ms,覆盖层显示
|
||||
- [ ] 半透明黑色遮罩透出底层 APP 内容
|
||||
- [ ] 对话卡片顶部圆角 28dp,自适应高度(最大 70% 屏幕)
|
||||
- [ ] 点击遮罩区域关闭覆盖层
|
||||
- [ ] 下滑卡片关闭覆盖层
|
||||
- [ ] 静默 10 秒自动收起
|
||||
- [ ] 用户说"再见"/"退下"自然结束对话
|
||||
- [ ] 关闭后回到触发前状态,不压入任何 Activity 栈
|
||||
|
||||
**技术依赖**:F01 (VoiceInteractionService)
|
||||
|
||||
---
|
||||
|
||||
### F04 · STT 语音识别
|
||||
|
||||
**用户故事**:作为用户,我可以对昔涟说话,她会实时将我的语音转成文字。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 覆盖层显示时自动开始监听
|
||||
- [ ] 实时显示识别中间结果(流式 STT)
|
||||
- [ ] 语音结束(静默 1.5s)后自动提交识别结果
|
||||
- [ ] 识别结果以用户气泡形式显示在对话中
|
||||
- [ ] 安静环境识别准确率 ≥ 95%(中文普通话)
|
||||
- [ ] 支持噪音环境降噪
|
||||
|
||||
**技术依赖**:后端 Whisper API (voice-service :8093),`RECORD_AUDIO` 权限
|
||||
|
||||
---
|
||||
|
||||
### F05 · TTS 语音合成
|
||||
|
||||
**用户故事**:作为用户,昔涟可以用自然的声音读出她的回复。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 流式 TTS:收到 LLM 第一个 token 即开始合成
|
||||
- [ ] 语音自然流畅,无机械感(使用后端 Edge-TTS 或训练模型)
|
||||
- [ ] 播放完毕自动进入下一轮监听
|
||||
- [ ] 播放期间自动降低其他音频音量(Audio Focus)
|
||||
- [ ] 用户可在设置中调整语速、音量
|
||||
|
||||
**技术依赖**:后端 TTS API (voice-service :8093)
|
||||
|
||||
---
|
||||
|
||||
### F06 · 实时文字对话
|
||||
|
||||
**用户故事**:作为用户,我可以打字与昔涟交流,并实时看到她的回复。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] WebSocket 连接建立后保持心跳 (30s ping)
|
||||
- [ ] 文本消息发送后 200ms 内显示用户气泡
|
||||
- [ ] 昔涟回复流式显示(逐字/逐 token 渲染)
|
||||
- [ ] 正确处理 `chat`、`action`、`thinking` 三种消息类型
|
||||
- [ ] 断线自动重连(指数退避,最多 5 次)
|
||||
- [ ] 连接状态在界面实时指示
|
||||
- [ ] 多条消息按时间顺序排列
|
||||
- [ ] 支持消息滚动到顶部加载历史
|
||||
|
||||
**技术依赖**:后端 Gateway WebSocket (:8080)
|
||||
|
||||
---
|
||||
|
||||
### F07 · 用户认证与登录
|
||||
|
||||
**用户故事**:作为用户,我可以登录我的 Cyrene 账号以同步数据。
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 首次启动显示登录页
|
||||
- [ ] 支持输入 Gateway 地址 + 账号 + 密码
|
||||
- [ ] 登录成功保存 JWT Token 到 EncryptedDataStore
|
||||
- [ ] Token 过期自动刷新(refresh token)
|
||||
- [ ] 再次启动自动登录(skip login page)
|
||||
- [ ] 登录失败显示明确错误信息
|
||||
- [ ] 支持退出登录并清除本地数据
|
||||
|
||||
**技术依赖**:后端 Gateway Auth API (:8080)
|
||||
|
||||
---
|
||||
|
||||
## 3. P1 功能详细规格
|
||||
|
||||
### F08 · IoT 设备状态查询
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 全屏 Activity 中 IoT Tab 显示所有设备卡片
|
||||
- [ ] 实时显示设备状态(开/关、亮度、温度等)
|
||||
- [ ] 通过 WebSocket 接收状态变更推送
|
||||
- [ ] 覆盖层模式下支持语音查询("灯开着吗?")
|
||||
- [ ] 覆盖层模式下 IoT 查询结果以文字+卡片形式展示
|
||||
- [ ] 锁屏状态下仅允许查询
|
||||
|
||||
### F09 · IoT 设备控制
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 设备卡片上可直接开关/调节
|
||||
- [ ] 支持语音控制("打开客厅灯")
|
||||
- [ ] 控制结果实时反馈(成功/失败)
|
||||
- [ ] 锁屏状态下禁止控制,提示解锁
|
||||
- [ ] 支持设备白名单(每用户可控制的设备不同)
|
||||
|
||||
### F10 · 推送通知 (FCM)
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 接收昔涟主动消息推送
|
||||
- [ ] 接收提醒到期推送
|
||||
- [ ] 接收 IoT 状态变更推送
|
||||
- [ ] 点击通知打开对应界面
|
||||
- [ ] 通知渠道分组(对话 / 提醒 / IoT / 系统)
|
||||
- [ ] 用户可独立控制各渠道开关
|
||||
|
||||
### F11 · 多会话历史
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 对话列表页展示所有历史会话
|
||||
- [ ] 每条会话显示标题、最后一条消息预览、时间
|
||||
- [ ] 点击进入对应会话详情
|
||||
- [ ] 支持删除会话
|
||||
- [ ] 与 Web 端历史同步
|
||||
|
||||
### F12 · 提醒管理
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 可以语音创建提醒("提醒我下午三点开会")
|
||||
- [ ] 列表展示所有活跃提醒
|
||||
- [ ] 支持删除/标记完成
|
||||
- [ ] 到期时 FCM 推送 + 覆盖层显示
|
||||
|
||||
### F13 · 全屏 Activity 模式
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 桌面图标启动进入全屏界面
|
||||
- [ ] BottomNav 三 Tab(对话 / IoT / 我的)
|
||||
- [ ] 完整的设置页
|
||||
- [ ] 与覆盖层共享同一 WebSocket 连接和 ViewModel
|
||||
|
||||
### F14 · 暗黑模式
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 跟随系统暗黑模式自动切换
|
||||
- [ ] 用户可在设置中手动选择 Light / Dark / Auto
|
||||
- [ ] 覆盖层同步使用当前主题
|
||||
- [ ] 对话气泡、卡片、输入框颜色正确适配
|
||||
|
||||
---
|
||||
|
||||
## 4. P2 功能概要
|
||||
|
||||
| # | 功能 | 关键验收标准 |
|
||||
|---|------|------------|
|
||||
| F15 | 自定义唤醒词 | 设置页可输入自定义词,验证唯一性,测试唤醒效果 |
|
||||
| F16 | 记忆查看 | 时间线展示昔涟记忆,支持搜索过滤 |
|
||||
| F17 | 自动化规则 | 查看规则列表,手动触发,查看执行日志 |
|
||||
| F18 | 知识库查询 | 搜索文档,查看内容,语音问答 |
|
||||
| F19 | 文件上传 | 图片选择/拍照,缩略图预览,AI 分析结果 |
|
||||
| F20 | 后台思考 | 展示昔涟后台思考片段,可折叠面板 |
|
||||
| F21 | 多设备同步 | Web 端和 Android 端对话实时同步 |
|
||||
| F22 | PIP 语音通话 | 切换到 PIP 窗口进行持续语音对话 |
|
||||
| F23 | 主题自定义 | 从预设色中选择,实时预览 |
|
||||
| F24 | 离线兜底 | 无网络时显示离线提示,缓存本地回复模板 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 按开发阶段分组
|
||||
|
||||
### Sprint 1 (MVP):F01 → F07 (P0 全部)
|
||||
目标:可设为系统默认助手,能语音唤醒并对话
|
||||
|
||||
### Sprint 2:F08 → F14 (P1 全部)
|
||||
目标:IoT 控制、全屏界面、推送通知上线
|
||||
|
||||
### Sprint 3+:F15 → F24 (P2 逐个)
|
||||
目标:体验完善、高级功能
|
||||
|
||||
## 6. 非功能需求
|
||||
|
||||
| 类别 | 需求 | 指标 |
|
||||
|------|------|------|
|
||||
| 性能 | 覆盖层冷启动 | < 500ms |
|
||||
| 性能 | 语音识别端到端延迟 | < 2s (STT + LLM + TTS) |
|
||||
| 性能 | WebSocket 消息延迟 | < 100ms |
|
||||
| 稳定性 | 崩溃率 | < 0.5% |
|
||||
| 电量 | 热词监听功耗 | < 3% 电池/小时 (息屏) |
|
||||
| 网络 | 支持弱网 | 切换到低码率 TTS |
|
||||
| 兼容性 | 国内 ROM 适配 | MIUI / ColorOS / OriginOS / HarmonyOS |
|
||||
@@ -0,0 +1,14 @@
|
||||
# Gradle
|
||||
org.gradle.parallel=true
|
||||
org.gradle.daemon=true
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.internal.http.connectionTimeout=60000
|
||||
org.gradle.internal.http.socketTimeout=60000
|
||||
|
||||
# Kotlin
|
||||
kotlin.code.style=official
|
||||
|
||||
# Android
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
android.suppressUnsupportedCompileSdk=36
|
||||
@@ -0,0 +1,70 @@
|
||||
[versions]
|
||||
agp = "8.7.3"
|
||||
kotlin = "2.0.21"
|
||||
ksp = "2.0.21-1.0.27"
|
||||
composeBom = "2024.12.01"
|
||||
composeActivity = "1.9.3"
|
||||
composeNavigation = "2.8.5"
|
||||
lifecycle = "2.8.7"
|
||||
room = "2.6.1"
|
||||
retrofit = "2.11.0"
|
||||
okhttp = "4.12.0"
|
||||
koin = "4.0.0"
|
||||
datastore = "1.1.1"
|
||||
coroutines = "1.9.0"
|
||||
material3 = "1.3.1"
|
||||
coil = "2.7.0"
|
||||
|
||||
[libraries]
|
||||
# Compose BOM
|
||||
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
|
||||
# Activity & Navigation
|
||||
compose-activity = { group = "androidx.activity", name = "activity-compose", version.ref = "composeActivity" }
|
||||
compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "composeNavigation" }
|
||||
|
||||
# Lifecycle
|
||||
lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||
lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||
|
||||
# Room
|
||||
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
|
||||
# Network
|
||||
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||
|
||||
# Koin DI
|
||||
koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
|
||||
koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
|
||||
|
||||
# DataStore
|
||||
datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
|
||||
# Coroutines
|
||||
coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
|
||||
# Core
|
||||
core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" }
|
||||
|
||||
# Coil
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
|
||||
# Biometric
|
||||
biometric = { group = "androidx.biometric", name = "biometric", version = "1.1.0" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.11.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+94
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,25 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven { url = uri("https://maven.aliyun.com/repository/public") }
|
||||
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||
maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") }
|
||||
maven { url = uri("https://maven.aliyun.com/repository/central") }
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
maven { url = uri("https://maven.aliyun.com/repository/public") }
|
||||
maven { url = uri("https://maven.aliyun.com/repository/google") }
|
||||
maven { url = uri("https://maven.aliyun.com/repository/central") }
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "Cyrene"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user