Compare commits
13 Commits
Alpha_v0.1.0
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| bc7630c43a | |||
| 08d78c976a | |||
| 6394099e2e | |||
| e65a35a239 | |||
| 7fcf562648 | |||
| 64c7018729 | |||
| 86d196b857 | |||
| 91231834dc | |||
| 5dad0cd39b | |||
| ce73f68bc8 | |||
| 3c90adae6a | |||
| 014437760d | |||
| eb94142404 |
@@ -114,4 +114,7 @@ dependencies {
|
|||||||
|
|
||||||
// Biometric
|
// Biometric
|
||||||
implementation(libs.biometric)
|
implementation(libs.biometric)
|
||||||
|
|
||||||
|
// Coil — image loading
|
||||||
|
implementation(libs.coil.compose)
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+90
-13
@@ -1,27 +1,104 @@
|
|||||||
# Cyrene ProGuard Rules
|
# Cyrene ProGuard Rules
|
||||||
|
|
||||||
# Retrofit
|
# --- Keep Android components declared in manifest ---
|
||||||
-keepattributes Signature
|
# 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 *Annotation*
|
||||||
-keep class top.yeij.cyrene.data.remote.dto.** { *; }
|
-keepattributes Signature
|
||||||
-dontwarn retrofit2.**
|
-keepattributes InnerClasses
|
||||||
-keep class retrofit2.** { *; }
|
|
||||||
|
|
||||||
# Gson
|
|
||||||
-keep class com.google.gson.** { *; }
|
|
||||||
-keepattributes EnclosingMethod
|
-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.** { *; }
|
||||||
|
|
||||||
# OkHttp
|
# --- 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 okhttp3.**
|
||||||
-dontwarn okio.**
|
-dontwarn okio.**
|
||||||
|
|
||||||
# Room
|
# --- Room ---
|
||||||
-keep class * extends androidx.room.RoomDatabase
|
-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.**
|
-dontwarn androidx.room.paging.**
|
||||||
|
|
||||||
# Koin
|
# --- Koin ---
|
||||||
-keep class org.koin.** { *; }
|
-keep class org.koin.** { *; }
|
||||||
|
-keep class top.yeij.cyrene.di.** { *; }
|
||||||
|
-keepclassmembers class top.yeij.cyrene.di.** { *; }
|
||||||
|
|
||||||
# Coroutines
|
# --- Compose ---
|
||||||
|
-dontwarn androidx.compose.**
|
||||||
|
-keep class androidx.compose.** { *; }
|
||||||
|
|
||||||
|
# --- Coroutines ---
|
||||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
-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.**
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<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_MICROPHONE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<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.POST_NOTIFICATIONS" />
|
||||||
@@ -21,6 +22,12 @@
|
|||||||
<!-- 锁屏交互 -->
|
<!-- 锁屏交互 -->
|
||||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
<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>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
@@ -42,6 +49,7 @@
|
|||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
|
android:windowSoftInputMode="adjustNothing"
|
||||||
android:theme="@style/Theme.Cyrene">
|
android:theme="@style/Theme.Cyrene">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@@ -83,6 +91,30 @@
|
|||||||
android:resource="@xml/accessibility_config" />
|
android:resource="@xml/accessibility_config" />
|
||||||
</service>
|
</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:日志分享 -->
|
<!-- FileProvider:日志分享 -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package top.yeij.cyrene
|
package top.yeij.cyrene
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -17,7 +23,8 @@ import top.yeij.cyrene.data.remote.AuthInterceptor
|
|||||||
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||||
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||||
import top.yeij.cyrene.di.appModule
|
import top.yeij.cyrene.di.appModule
|
||||||
import top.yeij.cyrene.util.NotificationHelper
|
import top.yeij.cyrene.service.KeepAliveReceiver
|
||||||
|
import top.yeij.cyrene.util.RootKeepAliveHelper
|
||||||
import top.yeij.cyrene.util.RuntimeLog
|
import top.yeij.cyrene.util.RuntimeLog
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
@@ -25,8 +32,6 @@ class CyreneApplication : Application() {
|
|||||||
|
|
||||||
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val activityCount = AtomicInteger(0)
|
private val activityCount = AtomicInteger(0)
|
||||||
@Volatile
|
|
||||||
private var notificationHelper: NotificationHelper? = null
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
@@ -39,15 +44,15 @@ class CyreneApplication : Application() {
|
|||||||
|
|
||||||
// Track foreground/background state
|
// Track foreground/background state
|
||||||
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
||||||
override fun onActivityStarted(activity: Activity) {
|
override fun onActivityResumed(activity: Activity) {
|
||||||
if (activityCount.incrementAndGet() == 1) {
|
if (activityCount.incrementAndGet() == 1) {
|
||||||
RuntimeLog.general("app", "App in foreground")
|
RuntimeLog.general("app", "App in foreground")
|
||||||
notificationHelper?.cancelAll()
|
getRepo()?.cancelNotifications()
|
||||||
getRepo()?.onAppForeground()
|
getRepo()?.onAppForeground()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityStopped(activity: Activity) {
|
override fun onActivityPaused(activity: Activity) {
|
||||||
if (activityCount.decrementAndGet() == 0) {
|
if (activityCount.decrementAndGet() == 0) {
|
||||||
RuntimeLog.general("app", "App in background")
|
RuntimeLog.general("app", "App in background")
|
||||||
getRepo()?.onAppBackground()
|
getRepo()?.onAppBackground()
|
||||||
@@ -55,22 +60,12 @@ class CyreneApplication : Application() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||||
override fun onActivityResumed(activity: Activity) {}
|
override fun onActivityStarted(activity: Activity) {}
|
||||||
override fun onActivityPaused(activity: Activity) {}
|
override fun onActivityStopped(activity: Activity) {}
|
||||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||||
override fun onActivityDestroyed(activity: Activity) {}
|
override fun onActivityDestroyed(activity: Activity) {}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set up background notification callback once Koin is ready
|
|
||||||
initScope.launch {
|
|
||||||
val helper = NotificationHelper(this@CyreneApplication)
|
|
||||||
notificationHelper = helper
|
|
||||||
val repo = getRepo()
|
|
||||||
repo?.setNotificationCallback { message ->
|
|
||||||
helper.showMessageNotification(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initScope.launch {
|
initScope.launch {
|
||||||
val koin = GlobalContext.get()
|
val koin = GlobalContext.get()
|
||||||
val prefs: PreferencesDataStore = koin.get()
|
val prefs: PreferencesDataStore = koin.get()
|
||||||
@@ -84,12 +79,55 @@ class CyreneApplication : Application() {
|
|||||||
authInterceptor.token = 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? {
|
private fun getRepo(): ChatRepositoryImpl? {
|
||||||
return try {
|
return try {
|
||||||
GlobalContext.get().get()
|
GlobalContext.get().get()
|
||||||
} catch (_: Exception) {
|
} catch (_: Throwable) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
package top.yeij.cyrene
|
package top.yeij.cyrene
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||||
@@ -24,22 +29,33 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private val isDefaultAssistant = mutableStateOf(false)
|
private val isDefaultAssistant = mutableStateOf(false)
|
||||||
|
|
||||||
|
private val notificationPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { /* granted or denied — either way we continue */ }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
requestNotificationPermission()
|
||||||
isDefaultAssistant.value = checkIsDefaultAssistant()
|
isDefaultAssistant.value = checkIsDefaultAssistant()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val prefs: PreferencesDataStore = koinInject()
|
val prefs: PreferencesDataStore = koinInject()
|
||||||
val themeMode by prefs.themeMode.collectAsState(initial = null)
|
val themeMode by prefs.themeMode.collectAsState(initial = null)
|
||||||
|
val themeColor by prefs.themeColor.collectAsState(initial = "pink")
|
||||||
val darkTheme = when (themeMode) {
|
val darkTheme = when (themeMode) {
|
||||||
"light" -> false
|
"light" -> false
|
||||||
"dark" -> true
|
"dark" -> true
|
||||||
else -> isSystemInDarkTheme()
|
else -> isSystemInDarkTheme()
|
||||||
}
|
}
|
||||||
|
val useDynamic = themeColor == "monet" && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
|
||||||
|
|
||||||
CyreneTheme(darkTheme = darkTheme) {
|
CyreneTheme(
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
presetKey = themeColor,
|
||||||
|
useDynamicColor = useDynamic,
|
||||||
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
CyreneNavGraph(
|
CyreneNavGraph(
|
||||||
@@ -75,4 +91,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
private fun openAssistantSettings() {
|
private fun openAssistantSettings() {
|
||||||
startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"cyrene.db",
|
"cyrene.db",
|
||||||
)
|
)
|
||||||
.fallbackToDestructiveMigration()
|
|
||||||
.build()
|
.build()
|
||||||
.also { INSTANCE = it }
|
.also { INSTANCE = it }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,34 @@ class PreferencesDataStore(private val context: Context) {
|
|||||||
private val KEY_PROFILE_IS_ADMIN = stringPreferencesKey("profile_is_admin")
|
private val KEY_PROFILE_IS_ADMIN = stringPreferencesKey("profile_is_admin")
|
||||||
private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at")
|
private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at")
|
||||||
private val KEY_AUTO_SCREEN_CONTEXT = booleanPreferencesKey("auto_screen_context")
|
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 token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ interface MessageDao {
|
|||||||
@Query("UPDATE messages SET conversationId = :newId WHERE conversationId = :oldId")
|
@Query("UPDATE messages SET conversationId = :newId WHERE conversationId = :oldId")
|
||||||
suspend fun migrateConversationId(oldId: String, newId: String)
|
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")
|
@Query("DELETE FROM messages WHERE id = :id")
|
||||||
suspend fun deleteById(id: String)
|
suspend fun deleteById(id: String)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package top.yeij.cyrene.data.remote
|
package top.yeij.cyrene.data.remote
|
||||||
|
|
||||||
|
import okhttp3.MultipartBody
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.DELETE
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Multipart
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Part
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
import top.yeij.cyrene.data.remote.dto.AuthRequest
|
import top.yeij.cyrene.data.remote.dto.AuthRequest
|
||||||
@@ -12,6 +15,7 @@ import top.yeij.cyrene.data.remote.dto.AuthResponse
|
|||||||
import top.yeij.cyrene.data.remote.dto.ProfileResponse
|
import top.yeij.cyrene.data.remote.dto.ProfileResponse
|
||||||
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
|
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
|
||||||
import top.yeij.cyrene.data.remote.dto.DeviceDto
|
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.IoTControlRequest
|
||||||
import top.yeij.cyrene.data.remote.dto.MessagesListResponse
|
import top.yeij.cyrene.data.remote.dto.MessagesListResponse
|
||||||
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
|
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
|
||||||
@@ -46,10 +50,17 @@ interface ApiService {
|
|||||||
@GET("api/v1/sessions/{id}/messages")
|
@GET("api/v1/sessions/{id}/messages")
|
||||||
suspend fun getSessionMessages(
|
suspend fun getSessionMessages(
|
||||||
@Path("id") sessionId: String,
|
@Path("id") sessionId: String,
|
||||||
@Query("limit") limit: Int = 50,
|
@Query("limit") limit: Int = 500,
|
||||||
@Query("offset") offset: Int = 0,
|
@Query("offset") offset: Int = 0,
|
||||||
): Response<MessagesListResponse>
|
): Response<MessagesListResponse>
|
||||||
|
|
||||||
|
// Files
|
||||||
|
@Multipart
|
||||||
|
@POST("api/v1/files/upload")
|
||||||
|
suspend fun uploadFile(
|
||||||
|
@Part file: MultipartBody.Part,
|
||||||
|
): Response<FileUploadResponse>
|
||||||
|
|
||||||
// IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/*
|
// IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/*
|
||||||
@GET("api/v1/iot/devices")
|
@GET("api/v1/iot/devices")
|
||||||
suspend fun getDevices(): Response<List<DeviceDto>>
|
suspend fun getDevices(): Response<List<DeviceDto>>
|
||||||
|
|||||||
@@ -36,3 +36,12 @@ data class SessionMessageDto(
|
|||||||
@SerializedName("content") val content: String,
|
@SerializedName("content") val content: String,
|
||||||
@SerializedName("created_at") val createdAt: Long,
|
@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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ data class WSClientMessage(
|
|||||||
data class WSAttachment(
|
data class WSAttachment(
|
||||||
@SerializedName("type") val type: String,
|
@SerializedName("type") val type: String,
|
||||||
@SerializedName("url") val url: String? = null,
|
@SerializedName("url") val url: String? = null,
|
||||||
|
@SerializedName("file_id") val fileId: String? = null,
|
||||||
@SerializedName("thumbnail_url") val thumbnailUrl: String? = null,
|
@SerializedName("thumbnail_url") val thumbnailUrl: String? = null,
|
||||||
@SerializedName("filename") val filename: String? = null,
|
@SerializedName("filename") val filename: String? = null,
|
||||||
@SerializedName("width") val width: Int? = null,
|
@SerializedName("width") val width: Int? = null,
|
||||||
@@ -55,11 +56,18 @@ data class WSServerMessage(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class WSReviewMessage(
|
data class WSReviewMessage(
|
||||||
@SerializedName("role") val role: String?,
|
@SerializedName("type") val type: String? = null,
|
||||||
@SerializedName("text") val text: String?,
|
@SerializedName("role") val role: String? = null,
|
||||||
@SerializedName("content") val content: String?,
|
@SerializedName("text") val text: String? = null,
|
||||||
@SerializedName("msg_type") val msgType: String?,
|
@SerializedName("content") val content: String? = null,
|
||||||
|
@SerializedName("msg_type") val msgType: String? = null,
|
||||||
@SerializedName("delay_ms") val delayMs: Long? = 0,
|
@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(
|
data class WSHistoryMessage(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package top.yeij.cyrene.data.repository
|
package top.yeij.cyrene.data.repository
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
@@ -22,20 +24,26 @@ import top.yeij.cyrene.data.local.entity.ConversationEntity
|
|||||||
import top.yeij.cyrene.data.local.entity.MessageEntity
|
import top.yeij.cyrene.data.local.entity.MessageEntity
|
||||||
import top.yeij.cyrene.data.remote.ApiService
|
import top.yeij.cyrene.data.remote.ApiService
|
||||||
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
|
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.data.remote.dto.WSServerMessage
|
||||||
import top.yeij.cyrene.domain.model.Conversation
|
import top.yeij.cyrene.domain.model.Conversation
|
||||||
import top.yeij.cyrene.domain.model.Message
|
import top.yeij.cyrene.domain.model.Message
|
||||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
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.service.WebSocketService
|
||||||
|
import top.yeij.cyrene.util.NotificationHelper
|
||||||
import top.yeij.cyrene.util.RuntimeLog
|
import top.yeij.cyrene.util.RuntimeLog
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class ChatRepositoryImpl(
|
class ChatRepositoryImpl(
|
||||||
|
private val app: Application,
|
||||||
private val conversationDao: ConversationDao,
|
private val conversationDao: ConversationDao,
|
||||||
private val messageDao: MessageDao,
|
private val messageDao: MessageDao,
|
||||||
private val webSocketService: WebSocketService,
|
private val webSocketService: WebSocketService,
|
||||||
private val apiService: ApiService,
|
private val apiService: ApiService,
|
||||||
private val preferencesDataStore: PreferencesDataStore,
|
private val preferencesDataStore: PreferencesDataStore,
|
||||||
|
private val notificationHelper: NotificationHelper,
|
||||||
) : ChatRepository {
|
) : ChatRepository {
|
||||||
|
|
||||||
private val exceptionHandler = CoroutineExceptionHandler { _, e ->
|
private val exceptionHandler = CoroutineExceptionHandler { _, e ->
|
||||||
@@ -54,15 +62,19 @@ class ChatRepositoryImpl(
|
|||||||
private val _messageClearEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
|
private val _messageClearEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
|
||||||
override val messageClearEvents: Flow<Unit> = _messageClearEvents
|
override val messageClearEvents: Flow<Unit> = _messageClearEvents
|
||||||
|
|
||||||
|
private val _messageRemovals = MutableSharedFlow<String>(extraBufferCapacity = 16)
|
||||||
|
override val messageRemovals: Flow<String> = _messageRemovals
|
||||||
|
|
||||||
private val _isAssistantStreaming = MutableStateFlow(false)
|
private val _isAssistantStreaming = MutableStateFlow(false)
|
||||||
override val isAssistantStreaming: StateFlow<Boolean> = _isAssistantStreaming.asStateFlow()
|
override val isAssistantStreaming: StateFlow<Boolean> = _isAssistantStreaming.asStateFlow()
|
||||||
|
|
||||||
private var streamingContent = ""
|
private var streamingContent = ""
|
||||||
private var streamingMessageId: String? = null
|
private var streamingMessageId: String? = null
|
||||||
|
private var streamTimeoutJob: kotlinx.coroutines.Job? = null
|
||||||
override var currentSessionId: String? = null
|
override var currentSessionId: String? = null
|
||||||
|
|
||||||
private var isAppInForeground = false
|
private var isAppInForeground = false
|
||||||
private var onBackgroundNotification: ((Message) -> Unit)? = null
|
private var hasEverBeenForeground = false
|
||||||
private var historyRequested = false
|
private var historyRequested = false
|
||||||
private val notifiedMessageIds = mutableSetOf<String>()
|
private val notifiedMessageIds = mutableSetOf<String>()
|
||||||
|
|
||||||
@@ -74,17 +86,45 @@ class ChatRepositoryImpl(
|
|||||||
private var lastResponseContent: String? = null
|
private var lastResponseContent: String? = null
|
||||||
private var lastResponseTime = 0L
|
private var lastResponseTime = 0L
|
||||||
|
|
||||||
fun setNotificationCallback(callback: ((Message) -> Unit)?) {
|
fun cancelNotifications() {
|
||||||
onBackgroundNotification = callback
|
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() {
|
override fun onAppForeground() {
|
||||||
|
RuntimeLog.notify("state", "onAppForeground: wasForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground")
|
||||||
isAppInForeground = true
|
isAppInForeground = true
|
||||||
|
hasEverBeenForeground = true
|
||||||
notifiedMessageIds.clear()
|
notifiedMessageIds.clear()
|
||||||
if (!_connectionState.value) {
|
notificationHelper.cancelAll()
|
||||||
webSocketService.forceReconnect()
|
KeepAliveReceiver.cancel(app)
|
||||||
}
|
WebSocketKeepAliveService.stop(app)
|
||||||
// Always request history on foreground to catch cross-device messages
|
RuntimeLog.notify("state", "onAppForeground: notifications cleared, keep-alive stopped")
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val sid = currentSessionId ?: return@launch
|
val sid = currentSessionId ?: return@launch
|
||||||
RuntimeLog.general("app", "Foreground — requesting history for session=$sid")
|
RuntimeLog.general("app", "Foreground — requesting history for session=$sid")
|
||||||
@@ -94,6 +134,21 @@ class ChatRepositoryImpl(
|
|||||||
|
|
||||||
override fun onAppBackground() {
|
override fun onAppBackground() {
|
||||||
isAppInForeground = false
|
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 {
|
init {
|
||||||
@@ -148,17 +203,6 @@ class ChatRepositoryImpl(
|
|||||||
messageDao.deleteAll()
|
messageDao.deleteAll()
|
||||||
preferencesDataStore.saveLastClearedTimestamp(now)
|
preferencesDataStore.saveLastClearedTimestamp(now)
|
||||||
|
|
||||||
// Also clear server-side messages for all known sessions
|
|
||||||
try {
|
|
||||||
val sessions = conversationDao.getAllSnapshot()
|
|
||||||
sessions.forEach { session ->
|
|
||||||
try {
|
|
||||||
apiService.clearSessionMessages(session.id)
|
|
||||||
RuntimeLog.chat("clear", "Server messages cleared for session=${session.id}")
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
|
|
||||||
_messageClearEvents.tryEmit(Unit)
|
_messageClearEvents.tryEmit(Unit)
|
||||||
|
|
||||||
RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now")
|
RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now")
|
||||||
@@ -186,11 +230,11 @@ class ChatRepositoryImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun ensureConnected() {
|
override suspend fun ensureConnected() {
|
||||||
if (_connectionState.value) return
|
// Always force reconnect — connectionState may be stuck at true on a silently dead socket
|
||||||
webSocketService.forceReconnect()
|
webSocketService.forceReconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendMessage(content: String, sessionId: String?) {
|
override suspend fun sendMessage(content: String, sessionId: String?, attachments: List<WSAttachment>?, localImageUris: List<String>) {
|
||||||
val messageId = UUID.randomUUID().toString()
|
val messageId = UUID.randomUUID().toString()
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val sid = sessionId ?: currentSessionId ?: "default"
|
val sid = sessionId ?: currentSessionId ?: "default"
|
||||||
@@ -199,13 +243,21 @@ class ChatRepositoryImpl(
|
|||||||
scope.launch { preferencesDataStore.saveCurrentSessionId(sid) }
|
scope.launch { preferencesDataStore.saveCurrentSessionId(sid) }
|
||||||
}
|
}
|
||||||
|
|
||||||
RuntimeLog.chat("send", "session=$sid msgId=$messageId content=${content.take(80)}")
|
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(
|
conversationDao.upsert(
|
||||||
ConversationEntity(
|
ConversationEntity(
|
||||||
id = sid,
|
id = sid,
|
||||||
title = "对话",
|
title = "对话",
|
||||||
lastMessage = content,
|
lastMessage = lastMsg,
|
||||||
lastMessageType = "chat",
|
lastMessageType = "chat",
|
||||||
updatedAt = now,
|
updatedAt = now,
|
||||||
createdAt = now,
|
createdAt = now,
|
||||||
@@ -217,7 +269,7 @@ class ChatRepositoryImpl(
|
|||||||
id = messageId,
|
id = messageId,
|
||||||
conversationId = sid,
|
conversationId = sid,
|
||||||
role = "user",
|
role = "user",
|
||||||
content = content,
|
content = displayContent,
|
||||||
msgType = "chat",
|
msgType = "chat",
|
||||||
timestamp = now,
|
timestamp = now,
|
||||||
)
|
)
|
||||||
@@ -227,13 +279,14 @@ class ChatRepositoryImpl(
|
|||||||
id = messageId,
|
id = messageId,
|
||||||
sessionId = sid,
|
sessionId = sid,
|
||||||
role = "user",
|
role = "user",
|
||||||
content = content,
|
content = displayContent,
|
||||||
msgType = "chat",
|
msgType = "chat",
|
||||||
timestamp = now,
|
timestamp = now,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
|
imageDataUris = localImageUris,
|
||||||
)
|
)
|
||||||
|
|
||||||
webSocketService.sendMessage(content, sid)
|
webSocketService.sendMessage(content, sid, attachments = attachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun loadConversationsFromServer() {
|
override suspend fun loadConversationsFromServer() {
|
||||||
@@ -310,29 +363,30 @@ class ChatRepositoryImpl(
|
|||||||
?.toLongOrNull() ?: 0L
|
?.toLongOrNull() ?: 0L
|
||||||
val filteredDtos = messageDtos.filter { it.createdAt > lastCleared }
|
val filteredDtos = messageDtos.filter { it.createdAt > lastCleared }
|
||||||
ensureConversation(sessionId)
|
ensureConversation(sessionId)
|
||||||
filteredDtos.forEach { dto ->
|
val messages = filteredDtos.map { dto ->
|
||||||
messageDao.upsert(
|
Message(
|
||||||
MessageEntity(
|
id = "${dto.id}",
|
||||||
id = "db_${dto.id}",
|
|
||||||
conversationId = sessionId,
|
conversationId = sessionId,
|
||||||
role = dto.role,
|
role = dto.role,
|
||||||
content = dto.content,
|
content = dto.content,
|
||||||
msgType = dto.msgType ?: "chat",
|
msgType = dto.msgType ?: "chat",
|
||||||
timestamp = dto.createdAt,
|
timestamp = dto.createdAt,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
RuntimeLog.http("loadMessages", "HTTP loaded ${filteredDtos.size} messages for session=$sessionId")
|
val deduped = messages.removeWrappingDuplicates().splitInlineActions()
|
||||||
filteredDtos.map { dto ->
|
messageDao.deleteUserMessagesByConversation(sessionId)
|
||||||
Message(
|
messageDao.upsertAll(deduped.map { msg ->
|
||||||
id = "db_${dto.id}",
|
MessageEntity(
|
||||||
conversationId = sessionId,
|
id = msg.id,
|
||||||
role = dto.role,
|
conversationId = msg.conversationId,
|
||||||
content = dto.content,
|
role = msg.role,
|
||||||
msgType = dto.msgType ?: "chat",
|
content = msg.content,
|
||||||
timestamp = dto.createdAt,
|
msgType = msg.msgType,
|
||||||
|
timestamp = msg.timestamp,
|
||||||
)
|
)
|
||||||
}.removeWrappingDuplicates()
|
})
|
||||||
|
RuntimeLog.http("loadMessages", "HTTP loaded ${deduped.size} messages (${messages.size} before dedup) for session=$sessionId")
|
||||||
|
deduped
|
||||||
} else {
|
} else {
|
||||||
RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS")
|
RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS")
|
||||||
requestHistoryViaWs(sessionId)
|
requestHistoryViaWs(sessionId)
|
||||||
@@ -346,11 +400,16 @@ class ChatRepositoryImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun requestHistoryViaWs(sessionId: String) {
|
private suspend fun requestHistoryViaWs(sessionId: String) {
|
||||||
// Wait up to 5s for WS to connect
|
|
||||||
if (!webSocketService.isConnected.value) {
|
if (!webSocketService.isConnected.value) {
|
||||||
withTimeoutOrNull(5000) {
|
val connected = withTimeoutOrNull(5000) {
|
||||||
webSocketService.isConnected.first { it }
|
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)
|
webSocketService.requestHistory(sessionId)
|
||||||
}
|
}
|
||||||
@@ -378,23 +437,27 @@ class ChatRepositoryImpl(
|
|||||||
streamingContent = ""
|
streamingContent = ""
|
||||||
streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
|
streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
|
||||||
_isAssistantStreaming.value = true
|
_isAssistantStreaming.value = true
|
||||||
|
recentParsedContents.clear()
|
||||||
|
resetStreamTimeout()
|
||||||
RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
|
RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
|
||||||
}
|
}
|
||||||
|
|
||||||
"stream_chunk" -> {
|
"stream_chunk" -> {
|
||||||
val delta = wsMsg.content ?: wsMsg.text ?: return
|
val delta = wsMsg.content ?: wsMsg.text ?: return
|
||||||
streamingContent += delta
|
streamingContent += delta
|
||||||
|
resetStreamTimeout()
|
||||||
emitMessage(
|
emitMessage(
|
||||||
id = streamingMessageId ?: "s_${System.currentTimeMillis()}",
|
id = streamingMessageId ?: "s_${System.currentTimeMillis()}",
|
||||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||||
role = "assistant",
|
role = "assistant",
|
||||||
content = streamingContent,
|
content = streamingContent,
|
||||||
msgType = "chat",
|
msgType = wsMsg.msgType ?: "chat",
|
||||||
isStreaming = true,
|
isStreaming = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
"stream_end" -> {
|
"stream_end" -> {
|
||||||
|
cancelStreamTimeout()
|
||||||
val msgId = wsMsg.messageId ?: streamingMessageId ?: "s_${System.currentTimeMillis()}"
|
val msgId = wsMsg.messageId ?: streamingMessageId ?: "s_${System.currentTimeMillis()}"
|
||||||
val content = streamingContent.ifEmpty { wsMsg.content ?: wsMsg.text ?: "" }
|
val content = streamingContent.ifEmpty { wsMsg.content ?: wsMsg.text ?: "" }
|
||||||
streamingContent = ""
|
streamingContent = ""
|
||||||
@@ -405,6 +468,19 @@ class ChatRepositoryImpl(
|
|||||||
}
|
}
|
||||||
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
|
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()) {
|
if (content.isNotBlank()) {
|
||||||
ensureConversation(sid, content)
|
ensureConversation(sid, content)
|
||||||
messageDao.upsert(
|
messageDao.upsert(
|
||||||
@@ -413,15 +489,19 @@ class ChatRepositoryImpl(
|
|||||||
conversationId = sid,
|
conversationId = sid,
|
||||||
role = "assistant",
|
role = "assistant",
|
||||||
content = content,
|
content = content,
|
||||||
msgType = "chat",
|
msgType = wsMsg.msgType ?: "chat",
|
||||||
timestamp = ts,
|
timestamp = ts,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = "chat", timestamp = ts, isStreaming = false, shouldNotify = true)
|
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
|
_isAssistantStreaming.value = false
|
||||||
RuntimeLog.chat("stream", "Stream end msgId=$msgId content=${content.take(80)}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
"response" -> {
|
"response" -> {
|
||||||
@@ -431,18 +511,6 @@ class ChatRepositoryImpl(
|
|||||||
val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}"
|
val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}"
|
||||||
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
|
||||||
|
|
||||||
// Suppress response if it wraps recently emitted review/multi_message items
|
|
||||||
val timeSinceParsed = System.currentTimeMillis() - lastParsedTime
|
|
||||||
if (timeSinceParsed < 3000 && recentParsedContents.isNotEmpty()) {
|
|
||||||
val allContained = recentParsedContents.all { text.contains(it) }
|
|
||||||
if (allContained) {
|
|
||||||
RuntimeLog.chat("dedup", "Suppressed wrapping response, ${recentParsedContents.size} items already shown")
|
|
||||||
recentParsedContents.clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
recentParsedContents.clear()
|
|
||||||
|
|
||||||
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
|
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
|
||||||
changeSessionId(sid)
|
changeSessionId(sid)
|
||||||
}
|
}
|
||||||
@@ -464,19 +532,28 @@ class ChatRepositoryImpl(
|
|||||||
lastResponseContent = text
|
lastResponseContent = text
|
||||||
lastResponseTime = System.currentTimeMillis()
|
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)
|
emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false, shouldNotify = true)
|
||||||
RuntimeLog.chat("receive", "Response msgId=$msgId role=$role msgType=$replyMsgType content=${text.take(80)}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
"review" -> {
|
"review" -> {
|
||||||
recentParsedContents.clear()
|
recentParsedContents.clear()
|
||||||
wsMsg.reviewMessages?.forEach { review ->
|
wsMsg.reviewMessages?.forEachIndexed { index, review ->
|
||||||
val text = review.content ?: review.text ?: return@forEach
|
if (index > 0) delay(1000L)
|
||||||
val role = review.role ?: "action"
|
val rawText = review.content ?: review.text ?: return@forEachIndexed
|
||||||
val rvMsgType = review.msgType ?: review.role ?: "action"
|
val role = review.role ?: "assistant"
|
||||||
|
val rvMsgType = review.type ?: review.msgType ?: "action"
|
||||||
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
|
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
|
||||||
recentParsedContents.add(text)
|
// Encode code language metadata into content for the renderer
|
||||||
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = text, msgType = rvMsgType, isStreaming = false)
|
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()
|
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
|
||||||
// Clean up wrapping response that arrived before this review
|
// Clean up wrapping response that arrived before this review
|
||||||
@@ -491,7 +568,7 @@ class ChatRepositoryImpl(
|
|||||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||||
role = "assistant",
|
role = "assistant",
|
||||||
content = text,
|
content = text,
|
||||||
msgType = "thinking",
|
msgType = wsMsg.msgType ?: "thinking",
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -507,19 +584,34 @@ class ChatRepositoryImpl(
|
|||||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||||
role = "system",
|
role = "system",
|
||||||
content = detail,
|
content = detail,
|
||||||
msgType = "tool_progress",
|
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,
|
isStreaming = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
"error" -> {
|
"error" -> {
|
||||||
|
cancelStreamTimeout()
|
||||||
|
streamingContent = ""
|
||||||
|
streamingMessageId = null
|
||||||
|
_isAssistantStreaming.value = false
|
||||||
RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}")
|
RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}")
|
||||||
emitMessage(
|
emitMessage(
|
||||||
id = "err_${System.currentTimeMillis()}",
|
id = "err_${System.currentTimeMillis()}",
|
||||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||||
role = "system",
|
role = "system",
|
||||||
content = wsMsg.error ?: "未知错误",
|
content = wsMsg.error ?: "未知错误",
|
||||||
msgType = "system_info",
|
msgType = wsMsg.msgType ?: "system_info",
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -536,11 +628,11 @@ class ChatRepositoryImpl(
|
|||||||
conversationId = sid,
|
conversationId = sid,
|
||||||
role = "user",
|
role = "user",
|
||||||
content = text,
|
content = text,
|
||||||
msgType = "chat",
|
msgType = wsMsg.msgType ?: "chat",
|
||||||
timestamp = ts,
|
timestamp = ts,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
emitMessage(id = msgId, sessionId = sid, role = "user", content = text, msgType = "chat", timestamp = ts, isStreaming = false)
|
emitMessage(id = msgId, sessionId = sid, role = "user", content = text, msgType = wsMsg.msgType ?: "chat", timestamp = ts, isStreaming = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
"history_response" -> {
|
"history_response" -> {
|
||||||
@@ -561,9 +653,8 @@ class ChatRepositoryImpl(
|
|||||||
timestamp = hist.timestamp ?: System.currentTimeMillis(),
|
timestamp = hist.timestamp ?: System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val deduped = messageList.removeWrappingDuplicates()
|
val deduped = messageList.removeWrappingDuplicates().splitInlineActions()
|
||||||
deduped.forEach { msg ->
|
messageDao.upsertAll(deduped.map { msg ->
|
||||||
messageDao.upsert(
|
|
||||||
MessageEntity(
|
MessageEntity(
|
||||||
id = msg.id,
|
id = msg.id,
|
||||||
conversationId = msg.conversationId,
|
conversationId = msg.conversationId,
|
||||||
@@ -572,7 +663,8 @@ class ChatRepositoryImpl(
|
|||||||
msgType = msg.msgType,
|
msgType = msg.msgType,
|
||||||
timestamp = msg.timestamp,
|
timestamp = msg.timestamp,
|
||||||
)
|
)
|
||||||
)
|
})
|
||||||
|
deduped.forEach { msg ->
|
||||||
emitMessage(
|
emitMessage(
|
||||||
id = msg.id,
|
id = msg.id,
|
||||||
sessionId = msg.conversationId,
|
sessionId = msg.conversationId,
|
||||||
@@ -589,7 +681,9 @@ class ChatRepositoryImpl(
|
|||||||
|
|
||||||
"multi_message" -> {
|
"multi_message" -> {
|
||||||
recentParsedContents.clear()
|
recentParsedContents.clear()
|
||||||
wsMsg.multiMessages?.forEach { item ->
|
var isFirst = true
|
||||||
|
wsMsg.multiMessages?.forEachIndexed { index, item ->
|
||||||
|
if (index > 0) delay(1000L)
|
||||||
val content = item.content ?: ""
|
val content = item.content ?: ""
|
||||||
recentParsedContents.add(content)
|
recentParsedContents.add(content)
|
||||||
emitMessage(
|
emitMessage(
|
||||||
@@ -600,7 +694,9 @@ class ChatRepositoryImpl(
|
|||||||
msgType = item.msgType ?: "chat",
|
msgType = item.msgType ?: "chat",
|
||||||
timestamp = wsMsg.timestamp ?: System.currentTimeMillis(),
|
timestamp = wsMsg.timestamp ?: System.currentTimeMillis(),
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
|
shouldNotify = isFirst,
|
||||||
)
|
)
|
||||||
|
isFirst = false
|
||||||
}
|
}
|
||||||
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
|
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
|
||||||
cleanupWrappingResponse()
|
cleanupWrappingResponse()
|
||||||
@@ -616,7 +712,8 @@ class ChatRepositoryImpl(
|
|||||||
val allContained = recentParsedContents.all { respContent.contains(it) }
|
val allContained = recentParsedContents.all { respContent.contains(it) }
|
||||||
if (allContained) {
|
if (allContained) {
|
||||||
messageDao.deleteById(respId)
|
messageDao.deleteById(respId)
|
||||||
RuntimeLog.chat("dedup", "Cleaned up wrapping response from DB id=$respId")
|
_messageRemovals.tryEmit(respId)
|
||||||
|
RuntimeLog.chat("dedup", "Cleaned up wrapping response from DB and live state id=$respId")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,8 +726,45 @@ class ChatRepositoryImpl(
|
|||||||
isStreaming: Boolean = false,
|
isStreaming: Boolean = false,
|
||||||
timestamp: Long = System.currentTimeMillis(),
|
timestamp: Long = System.currentTimeMillis(),
|
||||||
shouldNotify: Boolean = false,
|
shouldNotify: Boolean = false,
|
||||||
|
imageDataUris: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
if (content.isBlank() && msgType == "chat") return
|
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(
|
val message = Message(
|
||||||
id = id,
|
id = id,
|
||||||
conversationId = sessionId,
|
conversationId = sessionId,
|
||||||
@@ -639,14 +773,26 @@ class ChatRepositoryImpl(
|
|||||||
msgType = msgType,
|
msgType = msgType,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isStreaming = isStreaming,
|
isStreaming = isStreaming,
|
||||||
|
imageDataUris = imageDataUris,
|
||||||
)
|
)
|
||||||
_incomingMessages.tryEmit(message)
|
_incomingMessages.tryEmit(message)
|
||||||
|
|
||||||
if (shouldNotify && !isAppInForeground && role == "assistant" && !isStreaming) {
|
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)) {
|
if (notifiedMessageIds.add(id)) {
|
||||||
onBackgroundNotification?.invoke(message)
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -681,6 +827,35 @@ class ChatRepositoryImpl(
|
|||||||
createdAt = createdAt,
|
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(
|
private fun MessageEntity.toDomain() = Message(
|
||||||
id = id,
|
id = id,
|
||||||
conversationId = conversationId,
|
conversationId = conversationId,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import top.yeij.cyrene.viewmodel.IoTViewModel
|
|||||||
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||||
import top.yeij.cyrene.viewmodel.ProfileViewModel
|
import top.yeij.cyrene.viewmodel.ProfileViewModel
|
||||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||||
|
import top.yeij.cyrene.util.NotificationHelper
|
||||||
import top.yeij.cyrene.util.VoiceRecorder
|
import top.yeij.cyrene.util.VoiceRecorder
|
||||||
import top.yeij.cyrene.voice.stt.BackendSttProvider
|
import top.yeij.cyrene.voice.stt.BackendSttProvider
|
||||||
import top.yeij.cyrene.voice.stt.DashScopeSttService
|
import top.yeij.cyrene.voice.stt.DashScopeSttService
|
||||||
@@ -34,6 +35,9 @@ import top.yeij.cyrene.voice.tts.TextToSpeechEngine
|
|||||||
|
|
||||||
val appModule = module {
|
val appModule = module {
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
single { NotificationHelper(androidContext()) }
|
||||||
|
|
||||||
// DataStore
|
// DataStore
|
||||||
single { PreferencesDataStore(androidContext()) }
|
single { PreferencesDataStore(androidContext()) }
|
||||||
|
|
||||||
@@ -63,7 +67,7 @@ val appModule = module {
|
|||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) }
|
single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) }
|
||||||
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get(), get(), get()) }
|
single<ChatRepository> { ChatRepositoryImpl(androidContext() as android.app.Application, get(), get(), get(), get(), get(), get()) }
|
||||||
single<IoTRepository> { IoTRepositoryImpl(get(), get()) }
|
single<IoTRepository> { IoTRepositoryImpl(get(), get()) }
|
||||||
|
|
||||||
// UseCases
|
// UseCases
|
||||||
@@ -72,7 +76,7 @@ val appModule = module {
|
|||||||
factory { GetConversationsUseCase(get()) }
|
factory { GetConversationsUseCase(get()) }
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
viewModel { ChatViewModel(get(), get()) }
|
viewModel { ChatViewModel(androidContext() as android.app.Application, get(), get(), get(), get()) }
|
||||||
viewModel { IoTViewModel(get()) }
|
viewModel { IoTViewModel(get()) }
|
||||||
viewModel { OverlayViewModel(get(), get(), get()) }
|
viewModel { OverlayViewModel(get(), get(), get()) }
|
||||||
viewModel { ProfileViewModel(get(), get(), get()) }
|
viewModel { ProfileViewModel(get(), get(), get()) }
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ data class Message(
|
|||||||
val msgType: String,
|
val msgType: String,
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val isStreaming: Boolean = false,
|
val isStreaming: Boolean = false,
|
||||||
|
val imageDataUris: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface ChatRepository {
|
|||||||
val connectionError: StateFlow<String?>
|
val connectionError: StateFlow<String?>
|
||||||
val isAssistantStreaming: StateFlow<Boolean>
|
val isAssistantStreaming: StateFlow<Boolean>
|
||||||
val messageClearEvents: Flow<Unit>
|
val messageClearEvents: Flow<Unit>
|
||||||
|
val messageRemovals: Flow<String>
|
||||||
var currentSessionId: String?
|
var currentSessionId: String?
|
||||||
|
|
||||||
fun getConversations(): Flow<List<Conversation>>
|
fun getConversations(): Flow<List<Conversation>>
|
||||||
@@ -21,7 +22,7 @@ interface ChatRepository {
|
|||||||
|
|
||||||
suspend fun connectWebSocket(sessionId: String?)
|
suspend fun connectWebSocket(sessionId: String?)
|
||||||
|
|
||||||
suspend fun sendMessage(content: String, 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>
|
fun observeMessages(): Flow<Message>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import androidx.savedstate.SavedStateRegistry
|
|||||||
import androidx.savedstate.SavedStateRegistryController
|
import androidx.savedstate.SavedStateRegistryController
|
||||||
import androidx.savedstate.SavedStateRegistryOwner
|
import androidx.savedstate.SavedStateRegistryOwner
|
||||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||||
|
import android.content.res.Configuration
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.koin.core.context.GlobalContext
|
import org.koin.core.context.GlobalContext
|
||||||
@@ -48,7 +49,7 @@ class CyreneVoiceInteractionSession(context: Context) :
|
|||||||
private fun resolveViewModel(): OverlayViewModel? {
|
private fun resolveViewModel(): OverlayViewModel? {
|
||||||
return try {
|
return try {
|
||||||
GlobalContext.get().get<OverlayViewModel>()
|
GlobalContext.get().get<OverlayViewModel>()
|
||||||
} catch (e: Exception) {
|
} catch (e: Throwable) {
|
||||||
Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e)
|
Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e)
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -65,11 +66,40 @@ class CyreneVoiceInteractionSession(context: Context) :
|
|||||||
|
|
||||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||||
val vm = overlayViewModel
|
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 {
|
return ComposeView(context).apply {
|
||||||
setViewTreeLifecycleOwner(this@CyreneVoiceInteractionSession)
|
// Configure window as soon as view is attached — before system overrides flags
|
||||||
setViewTreeSavedStateRegistryOwner(this@CyreneVoiceInteractionSession)
|
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
|
||||||
|
override fun onViewAttachedToWindow(v: View) {
|
||||||
|
session.configureWindow()
|
||||||
|
}
|
||||||
|
override fun onViewDetachedFromWindow(v: View) {}
|
||||||
|
})
|
||||||
|
setViewTreeLifecycleOwner(session)
|
||||||
|
setViewTreeSavedStateRegistryOwner(session)
|
||||||
setContent {
|
setContent {
|
||||||
CyreneTheme {
|
CyreneTheme(
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
presetKey = themeColorKey,
|
||||||
|
useDynamicColor = themeColorKey == "monet",
|
||||||
|
) {
|
||||||
if (vm != null) {
|
if (vm != null) {
|
||||||
OverlayContent(
|
OverlayContent(
|
||||||
onDismiss = { finish() },
|
onDismiss = { finish() },
|
||||||
@@ -92,14 +122,22 @@ class CyreneVoiceInteractionSession(context: Context) :
|
|||||||
RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}")
|
RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}")
|
||||||
lifecycleRegistry.currentState = Lifecycle.State.STARTED
|
lifecycleRegistry.currentState = Lifecycle.State.STARTED
|
||||||
|
|
||||||
// Configure window: extend behind status bar, don't resize for IME
|
// 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()
|
configureWindow()
|
||||||
|
}
|
||||||
|
|
||||||
// Only read screen content if user enabled it in settings (default off)
|
// Only read screen content if user enabled it in settings (default off)
|
||||||
val autoScreenContext = try {
|
val autoScreenContext = try {
|
||||||
val prefs: PreferencesDataStore = GlobalContext.get().get()
|
val prefs: PreferencesDataStore = GlobalContext.get().get()
|
||||||
runBlocking { prefs.autoScreenContext.firstOrNull() } ?: false
|
runBlocking { prefs.autoScreenContext.firstOrNull() } ?: false
|
||||||
} catch (_: Exception) {
|
} catch (_: Throwable) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
if (autoScreenContext) {
|
if (autoScreenContext) {
|
||||||
@@ -111,16 +149,18 @@ class CyreneVoiceInteractionSession(context: Context) :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun configureWindow() {
|
fun configureWindow() {
|
||||||
try {
|
try {
|
||||||
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
|
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
|
||||||
method.isAccessible = true
|
method.isAccessible = true
|
||||||
val w = method.invoke(this) as? android.view.Window ?: return
|
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_STATUS)
|
||||||
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
|
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
|
||||||
w.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
w.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||||
Log.d(TAG, "Window configured: translucent status/nav, adjust nothing for IME")
|
Log.d(TAG, "Window configured: transparent bg, translucent status/nav")
|
||||||
} catch (e: Exception) {
|
} catch (e: Throwable) {
|
||||||
Log.w(TAG, "Failed to configure window: ${e.message}")
|
Log.w(TAG, "Failed to configure window: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,9 +21,12 @@ import okhttp3.Request
|
|||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.WebSocket
|
import okhttp3.WebSocket
|
||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
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.WSClientMessage
|
||||||
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||||
|
import top.yeij.cyrene.util.RuntimeLog
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
@@ -47,6 +50,8 @@ class WebSocketService(
|
|||||||
private var shouldReconnect = true
|
private var shouldReconnect = true
|
||||||
private var currentSessionId: String? = null
|
private var currentSessionId: String? = null
|
||||||
private val connectionId = AtomicInteger(0)
|
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 clientId: String = ""
|
||||||
private var deviceName: String = ""
|
private var deviceName: String = ""
|
||||||
@@ -132,12 +137,16 @@ class WebSocketService(
|
|||||||
_isConnected.value = true
|
_isConnected.value = true
|
||||||
_connectionError.value = null
|
_connectionError.value = null
|
||||||
startHeartbeat()
|
startHeartbeat()
|
||||||
|
RuntimeLog.ws("lifecycle", "WS connected #$connId")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
if (connectionId.get() != connId) return
|
if (connectionId.get() != connId) return
|
||||||
|
lastMessageReceived = System.currentTimeMillis()
|
||||||
try {
|
try {
|
||||||
val msg = gson.fromJson(text, WSServerMessage::class.java)
|
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)
|
_incomingMessages.tryEmit(msg)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "[#$connId] Failed to parse message: ${e.message}")
|
Log.w(TAG, "[#$connId] Failed to parse message: ${e.message}")
|
||||||
@@ -149,6 +158,7 @@ class WebSocketService(
|
|||||||
Log.i(TAG, "[#$connId] Server closing: code=$code reason=$reason")
|
Log.i(TAG, "[#$connId] Server closing: code=$code reason=$reason")
|
||||||
_isConnected.value = false
|
_isConnected.value = false
|
||||||
cancelHeartbeat()
|
cancelHeartbeat()
|
||||||
|
RuntimeLog.ws("lifecycle", "WS closing #$connId code=$code reason='$reason'")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
@@ -157,6 +167,7 @@ class WebSocketService(
|
|||||||
_isConnected.value = false
|
_isConnected.value = false
|
||||||
cancelHeartbeat()
|
cancelHeartbeat()
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
|
RuntimeLog.ws("lifecycle", "WS closed #$connId code=$code")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
@@ -165,6 +176,7 @@ class WebSocketService(
|
|||||||
Log.e(TAG, "[#$connId] Failure: ${t.message} (http=$httpCode)", t)
|
Log.e(TAG, "[#$connId] Failure: ${t.message} (http=$httpCode)", t)
|
||||||
_isConnected.value = false
|
_isConnected.value = false
|
||||||
cancelHeartbeat()
|
cancelHeartbeat()
|
||||||
|
RuntimeLog.ws("lifecycle", "WS failure #$connId http=$httpCode error='${t.message}'")
|
||||||
|
|
||||||
val errorMsg = when (httpCode) {
|
val errorMsg = when (httpCode) {
|
||||||
403 -> {
|
403 -> {
|
||||||
@@ -177,7 +189,8 @@ class WebSocketService(
|
|||||||
if (errorMsg != null) {
|
if (errorMsg != null) {
|
||||||
_connectionError.value = errorMsg
|
_connectionError.value = errorMsg
|
||||||
}
|
}
|
||||||
// onClosed will always follow, which triggers scheduleReconnect
|
// onClosed may or may not follow — schedule reconnect directly
|
||||||
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -189,19 +202,21 @@ class WebSocketService(
|
|||||||
sessionId: String? = null,
|
sessionId: String? = null,
|
||||||
mode: String? = null,
|
mode: String? = null,
|
||||||
content: String? = null,
|
content: String? = null,
|
||||||
|
attachments: List<WSAttachment>? = null,
|
||||||
): WSClientMessage = WSClientMessage(
|
): WSClientMessage = WSClientMessage(
|
||||||
type = type,
|
type = type,
|
||||||
sessionId = sessionId ?: currentSessionId,
|
sessionId = sessionId ?: currentSessionId,
|
||||||
mode = mode,
|
mode = mode,
|
||||||
content = content,
|
content = content,
|
||||||
|
attachments = attachments,
|
||||||
timestamp = System.currentTimeMillis(),
|
timestamp = System.currentTimeMillis(),
|
||||||
clientId = clientId.ifBlank { null },
|
clientId = clientId.ifBlank { null },
|
||||||
deviceName = deviceName.ifBlank { null },
|
deviceName = deviceName.ifBlank { null },
|
||||||
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
|
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
|
||||||
)
|
)
|
||||||
|
|
||||||
fun sendMessage(content: String, sessionId: String? = null, mode: String = "text") {
|
fun sendMessage(content: String, sessionId: String? = null, mode: String = "text", attachments: List<WSAttachment>? = null) {
|
||||||
val msg = buildMessage("message", sessionId, mode, content)
|
val msg = buildMessage("message", sessionId, mode, content, attachments = attachments)
|
||||||
webSocket?.send(gson.toJson(msg))
|
webSocket?.send(gson.toJson(msg))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,19 +255,24 @@ class WebSocketService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun forceReconnect() {
|
fun forceReconnect() {
|
||||||
|
RuntimeLog.ws("lifecycle", "forceReconnect called")
|
||||||
shouldReconnect = true
|
shouldReconnect = true
|
||||||
reconnectJob?.cancel()
|
reconnectJob?.cancel()
|
||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (!_isConnected.value) {
|
|
||||||
try {
|
try {
|
||||||
|
// Close existing socket directly without resetting shouldReconnect
|
||||||
|
cancelHeartbeat()
|
||||||
|
webSocket?.close(1000, "Reconnecting")
|
||||||
|
webSocket = null
|
||||||
|
_isConnected.value = false
|
||||||
connect(currentSessionId)
|
connect(currentSessionId)
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
|
RuntimeLog.ws("lifecycle", "WS disconnect — user requested")
|
||||||
shouldReconnect = false
|
shouldReconnect = false
|
||||||
reconnectJob?.cancel()
|
reconnectJob?.cancel()
|
||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
@@ -266,9 +286,15 @@ class WebSocketService(
|
|||||||
cancelHeartbeat()
|
cancelHeartbeat()
|
||||||
heartbeatJob = scope.launch {
|
heartbeatJob = scope.launch {
|
||||||
while (_isConnected.value) {
|
while (_isConnected.value) {
|
||||||
delay(30_000)
|
delay(15_000)
|
||||||
if (_isConnected.value) {
|
if (!_isConnected.value) break
|
||||||
sendPing()
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,36 @@
|
|||||||
package top.yeij.cyrene.ui.components
|
package top.yeij.cyrene.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
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.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -25,15 +38,444 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
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.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.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.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
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
|
@Composable
|
||||||
fun ChatBubble(
|
fun ChatBubble(
|
||||||
content: String,
|
content: String,
|
||||||
@@ -41,20 +483,83 @@ fun ChatBubble(
|
|||||||
msgType: String,
|
msgType: String,
|
||||||
timestamp: Long,
|
timestamp: Long,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
imageDataUris: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val isUser = role == "user"
|
val isUser = role == "user"
|
||||||
val formattedTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
|
val formattedTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
|
||||||
|
|
||||||
when (msgType) {
|
when (msgType) {
|
||||||
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier)
|
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier, imageDataUris)
|
||||||
"action" -> ActionMessage(content, modifier)
|
"action" -> ActionMessage(content, modifier)
|
||||||
"thinking" -> ThinkingBubble(content, modifier)
|
"markdown" -> CollapsibleBubble(content, modifier) { text, mod ->
|
||||||
"tool_progress" -> ToolProgressBubble(content, modifier)
|
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)
|
"system_info" -> SystemInfoBubble(content, modifier)
|
||||||
else -> ChatMessageBubble(content, isUser, formattedTime, 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)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatMessageBubble(
|
private fun ChatMessageBubble(
|
||||||
@@ -62,9 +567,13 @@ private fun ChatMessageBubble(
|
|||||||
isUser: Boolean,
|
isUser: Boolean,
|
||||||
time: String,
|
time: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
imageDataUris: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
var previewImageUri by remember { mutableStateOf<String?>(null) }
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val hasImages = imageDataUris.isNotEmpty()
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -90,15 +599,41 @@ private fun ChatMessageBubble(
|
|||||||
onLongClick = { showMenu = true },
|
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(
|
||||||
text = content,
|
text = renderInlineMarkdown(content),
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier.padding(
|
||||||
|
start = 12.dp, end = 12.dp,
|
||||||
|
top = if (hasImages) 6.dp else 12.dp,
|
||||||
|
bottom = 12.dp,
|
||||||
|
),
|
||||||
color = if (isUser)
|
color = if (isUser)
|
||||||
MaterialTheme.colorScheme.onPrimary
|
MaterialTheme.colorScheme.onPrimary
|
||||||
else
|
else
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = showMenu,
|
expanded = showMenu,
|
||||||
onDismissRequest = { showMenu = false },
|
onDismissRequest = { showMenu = false },
|
||||||
@@ -123,10 +658,25 @@ private fun ChatMessageBubble(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full-screen image preview
|
||||||
|
if (previewImageUri != null) {
|
||||||
|
ImagePreviewDialog(
|
||||||
|
imageUri = previewImageUri!!,
|
||||||
|
onDismiss = { previewImageUri = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Action message ---
|
||||||
|
|
||||||
|
private val actionTagRegex = Regex("""</?action>""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
||||||
|
val displayText = remember(content) {
|
||||||
|
content.replace(actionTagRegex, "").trim()
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -134,9 +684,9 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
|||||||
horizontalArrangement = Arrangement.Start,
|
horizontalArrangement = Arrangement.Start,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = content,
|
text = displayText,
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
|
fontStyle = FontStyle.Italic,
|
||||||
),
|
),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Start,
|
textAlign = TextAlign.Start,
|
||||||
@@ -144,6 +694,8 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Thinking bubble ---
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
|
private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
|
||||||
Box(
|
Box(
|
||||||
@@ -164,6 +716,8 @@ private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Tool progress bubble ---
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
|
private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
|
||||||
Row(
|
Row(
|
||||||
@@ -186,19 +740,46 @@ private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- System info bubble ---
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) {
|
private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) {
|
||||||
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
|
Box {
|
||||||
Text(
|
Text(
|
||||||
text = content,
|
text = content,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
textAlign = TextAlign.Center,
|
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))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,15 +49,15 @@ fun StatusIndicator(
|
|||||||
modifier = Modifier.size(8.dp),
|
modifier = Modifier.size(8.dp),
|
||||||
tint = Color(0xFF4CAF50),
|
tint = Color(0xFF4CAF50),
|
||||||
)
|
)
|
||||||
Text("昔涟", style = MaterialTheme.typography.labelLarge)
|
Text("昔涟", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
CyreneStatus.THINKING -> {
|
CyreneStatus.THINKING -> {
|
||||||
PulsingDot(Color(0xFFFFA726))
|
PulsingDot(Color(0xFFFFA726))
|
||||||
Text("思考中…", style = MaterialTheme.typography.labelLarge)
|
Text("思考中…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
CyreneStatus.SPEAKING -> {
|
CyreneStatus.SPEAKING -> {
|
||||||
PulsingDot(Color(0xFF42A5F5))
|
PulsingDot(Color(0xFF42A5F5))
|
||||||
Text("正在说话…", style = MaterialTheme.typography.labelLarge)
|
Text("正在说话…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
CyreneStatus.OFFLINE -> {
|
CyreneStatus.OFFLINE -> {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -66,7 +66,7 @@ fun StatusIndicator(
|
|||||||
modifier = Modifier.size(8.dp),
|
modifier = Modifier.size(8.dp),
|
||||||
tint = Color(0xFF9E9E9E),
|
tint = Color(0xFF9E9E9E),
|
||||||
)
|
)
|
||||||
Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge)
|
Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||||
@@ -16,11 +17,14 @@ import androidx.compose.material3.NavigationRail
|
|||||||
import androidx.compose.material3.NavigationRailItem
|
import androidx.compose.material3.NavigationRailItem
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
@@ -29,7 +33,9 @@ import top.yeij.cyrene.ui.screens.iot.IoTScreen
|
|||||||
import top.yeij.cyrene.ui.screens.login.LoginScreen
|
import top.yeij.cyrene.ui.screens.login.LoginScreen
|
||||||
import top.yeij.cyrene.ui.screens.about.AboutScreen
|
import top.yeij.cyrene.ui.screens.about.AboutScreen
|
||||||
import top.yeij.cyrene.ui.screens.profile.ProfileScreen
|
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.ui.screens.settings.SettingsScreen
|
||||||
|
import top.yeij.cyrene.util.RuntimeLog
|
||||||
|
|
||||||
object Routes {
|
object Routes {
|
||||||
const val LOGIN = "login"
|
const val LOGIN = "login"
|
||||||
@@ -38,6 +44,7 @@ object Routes {
|
|||||||
const val IOT = "iot"
|
const val IOT = "iot"
|
||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
const val ABOUT = "about"
|
const val ABOUT = "about"
|
||||||
|
const val KEEP_ALIVE = "keep_alive"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -47,6 +54,19 @@ fun CyreneNavGraph(
|
|||||||
isDefaultAssistant: Boolean,
|
isDefaultAssistant: Boolean,
|
||||||
onOpenAssistantSettings: () -> Unit,
|
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(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = startDestination,
|
startDestination = startDestination,
|
||||||
@@ -71,13 +91,32 @@ fun CyreneNavGraph(
|
|||||||
|
|
||||||
composable(Routes.SETTINGS) {
|
composable(Routes.SETTINGS) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
onBack = { navController.popBackStack() },
|
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) {
|
composable(Routes.ABOUT) {
|
||||||
AboutScreen(
|
AboutScreen(
|
||||||
onBack = { navController.popBackStack() },
|
onBack = {
|
||||||
|
if (navController.currentDestination?.route == Routes.ABOUT) {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,7 +163,10 @@ fun MainScreen(
|
|||||||
items.forEachIndexed { index, item ->
|
items.forEachIndexed { index, item ->
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
selected = selectedTab == index,
|
selected = selectedTab == index,
|
||||||
onClick = { selectedTab = index },
|
onClick = {
|
||||||
|
selectedTab = index
|
||||||
|
RuntimeLog.general("nav", "Tab switched to ${item.label} (index=$index)")
|
||||||
|
},
|
||||||
icon = item.icon,
|
icon = item.icon,
|
||||||
label = { Text(item.label) },
|
label = { Text(item.label) },
|
||||||
)
|
)
|
||||||
@@ -135,12 +177,31 @@ fun MainScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
|
.clipToBounds()
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.background),
|
||||||
) {
|
) {
|
||||||
when (selectedTab) {
|
// Keep all tabs alive by offsetting hidden ones off-screen.
|
||||||
0 -> ChatScreen()
|
// clipToBounds ensures they don't intercept touches outside the visible area.
|
||||||
1 -> IoTScreen()
|
Box(
|
||||||
2 -> ProfileScreen(
|
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) },
|
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
|
||||||
onNavigateToAbout = { navController.navigate(Routes.ABOUT) },
|
onNavigateToAbout = { navController.navigate(Routes.ABOUT) },
|
||||||
onLogout = {
|
onLogout = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package top.yeij.cyrene.ui.overlay
|
package top.yeij.cyrene.ui.overlay
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
@@ -17,11 +18,12 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
@@ -41,9 +43,11 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -54,6 +58,8 @@ import androidx.compose.ui.input.pointer.pointerInput
|
|||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -65,6 +71,7 @@ import top.yeij.cyrene.ui.components.TypingIndicator
|
|||||||
import top.yeij.cyrene.util.RecordState
|
import top.yeij.cyrene.util.RecordState
|
||||||
import top.yeij.cyrene.viewmodel.OverlayState
|
import top.yeij.cyrene.viewmodel.OverlayState
|
||||||
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||||
|
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -90,6 +97,7 @@ private fun AnimatedChatBubble(
|
|||||||
role = message.role,
|
role = message.role,
|
||||||
msgType = message.msgType,
|
msgType = message.msgType,
|
||||||
timestamp = message.timestamp,
|
timestamp = message.timestamp,
|
||||||
|
imageDataUris = message.imageDataUris,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,6 +107,7 @@ fun OverlayContent(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onNavigateToMain: () -> Unit,
|
onNavigateToMain: () -> Unit,
|
||||||
viewModel: OverlayViewModel = koinInject(),
|
viewModel: OverlayViewModel = koinInject(),
|
||||||
|
settingsViewModel: SettingsViewModel = koinInject(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val messages by viewModel.messages.collectAsState()
|
val messages by viewModel.messages.collectAsState()
|
||||||
@@ -106,6 +115,8 @@ fun OverlayContent(
|
|||||||
val recordState by viewModel.voiceRecordState.collectAsState()
|
val recordState by viewModel.voiceRecordState.collectAsState()
|
||||||
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
||||||
val animIndex by viewModel.messageAnimIndex.collectAsState()
|
val animIndex by viewModel.messageAnimIndex.collectAsState()
|
||||||
|
val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
|
||||||
|
val enterToSend by settingsViewModel.enterToSend.collectAsState()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val isProcessing = state == OverlayState.PROCESSING
|
val isProcessing = state == OverlayState.PROCESSING
|
||||||
val recordSec = recordDurationMs / 1000f
|
val recordSec = recordDurationMs / 1000f
|
||||||
@@ -184,10 +195,13 @@ fun OverlayContent(
|
|||||||
isRecording = isRecording,
|
isRecording = isRecording,
|
||||||
isLocked = isLocked,
|
isLocked = isLocked,
|
||||||
typingDots = typingDots.value,
|
typingDots = typingDots.value,
|
||||||
|
typingIndicatorStyle = typingIndicatorStyle,
|
||||||
|
enterToSend = enterToSend,
|
||||||
animIndex = animIndex,
|
animIndex = animIndex,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
onNavigateToMain = onNavigateToMain,
|
onNavigateToMain = onNavigateToMain,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
|
navBarHeightPx = navBarHeight,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
PortraitContent(
|
PortraitContent(
|
||||||
@@ -200,10 +214,13 @@ fun OverlayContent(
|
|||||||
isRecording = isRecording,
|
isRecording = isRecording,
|
||||||
isLocked = isLocked,
|
isLocked = isLocked,
|
||||||
typingDots = typingDots.value,
|
typingDots = typingDots.value,
|
||||||
|
typingIndicatorStyle = typingIndicatorStyle,
|
||||||
|
enterToSend = enterToSend,
|
||||||
animIndex = animIndex,
|
animIndex = animIndex,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
onNavigateToMain = onNavigateToMain,
|
onNavigateToMain = onNavigateToMain,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
|
navBarHeightPx = navBarHeight,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,10 +239,13 @@ private fun PortraitContent(
|
|||||||
isRecording: Boolean,
|
isRecording: Boolean,
|
||||||
isLocked: Boolean,
|
isLocked: Boolean,
|
||||||
typingDots: String,
|
typingDots: String,
|
||||||
|
typingIndicatorStyle: String,
|
||||||
|
enterToSend: Boolean,
|
||||||
animIndex: Map<String, Int>,
|
animIndex: Map<String, Int>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onNavigateToMain: () -> Unit,
|
onNavigateToMain: () -> Unit,
|
||||||
viewModel: OverlayViewModel,
|
viewModel: OverlayViewModel,
|
||||||
|
navBarHeightPx: Int,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -253,7 +273,7 @@ private fun PortraitContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isProcessing) {
|
if (isProcessing && typingIndicatorStyle != "text") {
|
||||||
item(key = "typing_indicator") {
|
item(key = "typing_indicator") {
|
||||||
TypingIndicator()
|
TypingIndicator()
|
||||||
}
|
}
|
||||||
@@ -261,19 +281,20 @@ private fun PortraitContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input area at bottom, imePadding pushes it above full-screen IME
|
// Input area at bottom; system adjust=pan handles IME offset
|
||||||
InputArea(
|
InputArea(
|
||||||
state = state,
|
state = state,
|
||||||
inputText = inputText,
|
inputText = inputText,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth(),
|
||||||
.imePadding(),
|
|
||||||
recordSec = recordSec,
|
recordSec = recordSec,
|
||||||
isRecording = isRecording,
|
isRecording = isRecording,
|
||||||
isLocked = isLocked,
|
isLocked = isLocked,
|
||||||
typingDots = typingDots,
|
typingDots = typingDots,
|
||||||
|
typingIndicatorStyle = typingIndicatorStyle,
|
||||||
|
enterToSend = enterToSend,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,10 +311,13 @@ private fun LandscapeContent(
|
|||||||
isRecording: Boolean,
|
isRecording: Boolean,
|
||||||
isLocked: Boolean,
|
isLocked: Boolean,
|
||||||
typingDots: String,
|
typingDots: String,
|
||||||
|
typingIndicatorStyle: String,
|
||||||
|
enterToSend: Boolean,
|
||||||
animIndex: Map<String, Int>,
|
animIndex: Map<String, Int>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onNavigateToMain: () -> Unit,
|
onNavigateToMain: () -> Unit,
|
||||||
viewModel: OverlayViewModel,
|
viewModel: OverlayViewModel,
|
||||||
|
navBarHeightPx: Int,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -320,7 +344,7 @@ private fun LandscapeContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isProcessing) {
|
if (isProcessing && typingIndicatorStyle != "text") {
|
||||||
item(key = "typing_indicator") {
|
item(key = "typing_indicator") {
|
||||||
TypingIndicator()
|
TypingIndicator()
|
||||||
}
|
}
|
||||||
@@ -346,12 +370,13 @@ private fun LandscapeContent(
|
|||||||
inputText = inputText,
|
inputText = inputText,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth(),
|
||||||
.imePadding(),
|
|
||||||
recordSec = recordSec,
|
recordSec = recordSec,
|
||||||
isRecording = isRecording,
|
isRecording = isRecording,
|
||||||
isLocked = isLocked,
|
isLocked = isLocked,
|
||||||
typingDots = typingDots,
|
typingDots = typingDots,
|
||||||
|
typingIndicatorStyle = typingIndicatorStyle,
|
||||||
|
enterToSend = enterToSend,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,6 +418,8 @@ private fun InputArea(
|
|||||||
isRecording: Boolean = false,
|
isRecording: Boolean = false,
|
||||||
isLocked: Boolean = false,
|
isLocked: Boolean = false,
|
||||||
typingDots: String = "",
|
typingDots: String = "",
|
||||||
|
typingIndicatorStyle: String = "bubble",
|
||||||
|
enterToSend: Boolean = false,
|
||||||
) {
|
) {
|
||||||
// Gesture tracking state — local to InputArea
|
// Gesture tracking state — local to InputArea
|
||||||
var isDragging by remember { mutableStateOf(false) }
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
@@ -402,19 +429,13 @@ private fun InputArea(
|
|||||||
val inLockZone = isDragging && dragOffsetX > 60f
|
val inLockZone = isDragging && dragOffsetX > 60f
|
||||||
val isProcessing = state == OverlayState.PROCESSING
|
val isProcessing = state == OverlayState.PROCESSING
|
||||||
|
|
||||||
Surface(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
|
||||||
shadowElevation = 8.dp,
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
) {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(12.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
// "昔涟正在输入..." indicator
|
// "昔涟正在输入..." indicator (text mode only)
|
||||||
if (isProcessing && typingDots.isNotEmpty()) {
|
if (isProcessing && typingDots.isNotEmpty() && typingIndicatorStyle == "text") {
|
||||||
Text(
|
Text(
|
||||||
text = "昔涟正在输入$typingDots",
|
text = "昔涟正在输入$typingDots",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -509,7 +530,23 @@ private fun InputArea(
|
|||||||
placeholder = { Text("输入消息...") },
|
placeholder = { Text("输入消息...") },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
maxLines = 3,
|
maxLines = 3,
|
||||||
shape = MaterialTheme.shapes.medium,
|
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))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
@@ -596,5 +633,4 @@ private fun InputArea(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,91 @@
|
|||||||
package top.yeij.cyrene.ui.screens.chat
|
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.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
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.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
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.KeyboardVoice
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
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.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
import org.koin.compose.koinInject
|
||||||
import top.yeij.cyrene.domain.model.Message
|
import top.yeij.cyrene.domain.model.Message
|
||||||
import top.yeij.cyrene.ui.components.ChatBubble
|
import top.yeij.cyrene.ui.components.ChatBubble
|
||||||
import top.yeij.cyrene.ui.components.CyreneStatus
|
import top.yeij.cyrene.ui.components.CyreneStatus
|
||||||
import top.yeij.cyrene.ui.components.StatusIndicator
|
import top.yeij.cyrene.ui.components.StatusIndicator
|
||||||
import top.yeij.cyrene.ui.components.TypingIndicator
|
import top.yeij.cyrene.ui.components.TypingIndicator
|
||||||
import top.yeij.cyrene.util.RecordState
|
import top.yeij.cyrene.util.RecordState
|
||||||
|
import top.yeij.cyrene.util.RuntimeLog
|
||||||
import top.yeij.cyrene.viewmodel.ChatViewModel
|
import top.yeij.cyrene.viewmodel.ChatViewModel
|
||||||
|
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AnimatedChatBubble(
|
private fun AnimatedChatBubble(
|
||||||
message: Message,
|
message: Message,
|
||||||
@@ -74,9 +98,9 @@ private fun AnimatedChatBubble(
|
|||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) +
|
enter = fadeIn(animationSpec = tween(300)) +
|
||||||
slideInVertically(
|
slideInVertically(
|
||||||
animationSpec = androidx.compose.animation.core.tween(300),
|
animationSpec = tween(300),
|
||||||
initialOffsetY = { it / 4 },
|
initialOffsetY = { it / 4 },
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
@@ -85,15 +109,20 @@ private fun AnimatedChatBubble(
|
|||||||
role = message.role,
|
role = message.role,
|
||||||
msgType = message.msgType,
|
msgType = message.msgType,
|
||||||
timestamp = message.timestamp,
|
timestamp = message.timestamp,
|
||||||
|
imageDataUris = message.imageDataUris,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
viewModel: ChatViewModel = koinViewModel(),
|
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 messages by viewModel.currentMessages.collectAsState()
|
||||||
val inputText by viewModel.inputText.collectAsState()
|
val inputText by viewModel.inputText.collectAsState()
|
||||||
val isStreaming by viewModel.isStreaming.collectAsState()
|
val isStreaming by viewModel.isStreaming.collectAsState()
|
||||||
@@ -102,8 +131,21 @@ fun ChatScreen(
|
|||||||
val recordState by viewModel.voiceRecordState.collectAsState()
|
val recordState by viewModel.voiceRecordState.collectAsState()
|
||||||
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
||||||
val animIndex by viewModel.messageAnimIndex.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()
|
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
|
// Gesture tracking state
|
||||||
var isDragging by remember { mutableStateOf(false) }
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
var dragOffsetX by remember { mutableStateOf(0f) }
|
var dragOffsetX by remember { mutableStateOf(0f) }
|
||||||
@@ -116,10 +158,24 @@ fun ChatScreen(
|
|||||||
val inCancelZone = isDragging && dragOffsetY < -120f
|
val inCancelZone = isDragging && dragOffsetY < -120f
|
||||||
val inLockZone = isDragging && dragOffsetX > 60f
|
val inLockZone = isDragging && dragOffsetX > 60f
|
||||||
|
|
||||||
LaunchedEffect(messages.size, isStreaming) {
|
// Image picker
|
||||||
if (messages.isNotEmpty()) {
|
val selectedImages by viewModel.selectedImageUris.collectAsState()
|
||||||
val targetIndex = if (isStreaming) messages.size else messages.size - 1
|
val imagePickerLauncher = rememberLauncherForActivityResult(
|
||||||
listState.animateScrollToItem(targetIndex)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,8 +201,14 @@ fun ChatScreen(
|
|||||||
else -> CyreneStatus.OFFLINE
|
else -> CyreneStatus.OFFLINE
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
// Single column layout: everything flows together and IME shrinks the whole view
|
||||||
topBar = {
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.imePadding(),
|
||||||
|
) {
|
||||||
|
// Top status bar with refresh button
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -154,16 +216,117 @@ fun ChatScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
StatusIndicator(status = status)
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
bottomBar = {
|
}
|
||||||
|
|
||||||
|
// 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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.navigationBarsPadding(),
|
.navigationBarsPadding(),
|
||||||
) {
|
) {
|
||||||
// "昔涟正在输入..." indicator
|
// "昔涟正在输入..." indicator (text mode only)
|
||||||
if (isStreaming) {
|
if (isStreaming && typingIndicatorStyle == "text") {
|
||||||
Text(
|
Text(
|
||||||
text = "昔涟正在输入${typingDots.value}",
|
text = "昔涟正在输入${typingDots.value}",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -182,7 +345,6 @@ fun ChatScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (isRecording && isDragging) {
|
if (isRecording && isDragging) {
|
||||||
// Recording state with drag — show recording indicator
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -205,7 +367,6 @@ fun ChatScreen(
|
|||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Record button (drag anchor)
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(start = 8.dp)
|
.padding(start = 8.dp)
|
||||||
@@ -222,7 +383,6 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (isLocked) {
|
} else if (isLocked) {
|
||||||
// Locked (hands-free) mode
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -254,7 +414,15 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Normal input mode
|
IconButton(
|
||||||
|
onClick = { imagePickerLauncher.launch("image/*") },
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.AddPhotoAlternate,
|
||||||
|
contentDescription = "添加图片",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = inputText,
|
value = inputText,
|
||||||
onValueChange = { viewModel.onInputChanged(it) },
|
onValueChange = { viewModel.onInputChanged(it) },
|
||||||
@@ -262,8 +430,19 @@ fun ChatScreen(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
shape = MaterialTheme.shapes.medium,
|
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
|
||||||
|
},
|
||||||
)
|
)
|
||||||
// Voice record button with long-press gesture
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(start = 4.dp)
|
.padding(start = 4.dp)
|
||||||
@@ -308,7 +487,6 @@ fun ChatScreen(
|
|||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Send button (only when text present)
|
|
||||||
if (inputText.isNotBlank()) {
|
if (inputText.isNotBlank()) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { viewModel.sendMessage() },
|
onClick = { viewModel.sendMessage() },
|
||||||
@@ -320,47 +498,12 @@ fun ChatScreen(
|
|||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
|
Icon(
|
||||||
}
|
Icons.AutoMirrored.Filled.Send,
|
||||||
}
|
contentDescription = "发送",
|
||||||
}
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { padding ->
|
|
||||||
PullToRefreshBox(
|
|
||||||
isRefreshing = isRefreshing,
|
|
||||||
onRefresh = { viewModel.refreshMessages() },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding),
|
|
||||||
) {
|
|
||||||
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,
|
|
||||||
) {
|
|
||||||
items(messages, key = { it.id }) { message ->
|
|
||||||
AnimatedChatBubble(
|
|
||||||
message = message,
|
|
||||||
animIndex = animIndex[message.id] ?: 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isStreaming) {
|
|
||||||
item(key = "typing_indicator") {
|
|
||||||
TypingIndicator()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
package top.yeij.cyrene.ui.screens.settings
|
package top.yeij.cyrene.ui.screens.settings
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.biometric.BiometricManager
|
import androidx.biometric.BiometricManager
|
||||||
import androidx.biometric.BiometricPrompt
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -15,18 +22,24 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.Check
|
||||||
import androidx.compose.material.icons.filled.DarkMode
|
import androidx.compose.material.icons.filled.DarkMode
|
||||||
import androidx.compose.material.icons.filled.DeleteForever
|
import androidx.compose.material.icons.filled.DeleteForever
|
||||||
import androidx.compose.material.icons.filled.LightMode
|
import androidx.compose.material.icons.filled.LightMode
|
||||||
import androidx.compose.material.icons.filled.Palette
|
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.SettingsBrightness
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material.icons.filled.Terminal
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
@@ -40,18 +53,24 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.TabRow
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -60,14 +79,19 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
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.LogCategory
|
||||||
|
import top.yeij.cyrene.util.RootKeepAliveHelper
|
||||||
import top.yeij.cyrene.util.RuntimeLog
|
import top.yeij.cyrene.util.RuntimeLog
|
||||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onNavigateToKeepAlive: () -> Unit = {},
|
||||||
viewModel: SettingsViewModel = koinInject(),
|
viewModel: SettingsViewModel = koinInject(),
|
||||||
) {
|
) {
|
||||||
val baseUrl by viewModel.baseUrl.collectAsState()
|
val baseUrl by viewModel.baseUrl.collectAsState()
|
||||||
@@ -77,6 +101,10 @@ fun SettingsScreen(
|
|||||||
val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState()
|
val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState()
|
||||||
val dashScopeModel by viewModel.dashScopeModel.collectAsState()
|
val dashScopeModel by viewModel.dashScopeModel.collectAsState()
|
||||||
val autoScreenContext by viewModel.autoScreenContext.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 context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -201,10 +229,345 @@ fun SettingsScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var showColorDialog by remember { mutableStateOf(false) }
|
||||||
|
val currentColorLabel = PresetColorLabels[themeColor] ?: "昔涟粉"
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("主题色") },
|
headlineContent = { Text("主题色") },
|
||||||
supportingContent = { 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) },
|
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))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -464,10 +827,6 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
HorizontalDivider()
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Runtime logs
|
// Runtime logs
|
||||||
Text(
|
Text(
|
||||||
text = "运行日志",
|
text = "运行日志",
|
||||||
@@ -489,23 +848,20 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TabRow(selectedTabIndex = selectedTab) {
|
ScrollableTabRow(
|
||||||
|
selectedTabIndex = selectedTab,
|
||||||
|
edgePadding = 16.dp,
|
||||||
|
divider = {},
|
||||||
|
) {
|
||||||
tabs.forEachIndexed { index, label ->
|
tabs.forEachIndexed { index, label ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = selectedTab == index,
|
selected = selectedTab == index,
|
||||||
onClick = { selectedTab = index },
|
onClick = { selectedTab = index },
|
||||||
text = { Text(label, maxLines = 1) },
|
text = { Text(label, maxLines = 1, style = MaterialTheme.typography.labelMedium) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val filteredLogs = if (selectedTab == 0) {
|
|
||||||
logEntries
|
|
||||||
} else {
|
|
||||||
val cat = allCategories.getOrNull(selectedTab - 1)
|
|
||||||
if (cat != null) logEntries.filter { it.category == cat } else emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentCategory = if (selectedTab == 0) null else allCategories.getOrNull(selectedTab - 1)
|
val currentCategory = if (selectedTab == 0) null else allCategories.getOrNull(selectedTab - 1)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
@@ -542,7 +898,14 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredLogs.isEmpty()) {
|
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(
|
||||||
text = "暂无${tabs[selectedTab]}日志",
|
text = "暂无${tabs[selectedTab]}日志",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -550,16 +913,29 @@ fun SettingsScreen(
|
|||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Column(
|
// 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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 12.dp)
|
||||||
.weight(1f, fill = false),
|
.height(280.dp)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||||
|
RoundedCornerShape(8.dp),
|
||||||
|
)
|
||||||
|
.padding(8.dp),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.verticalScroll(scrollState),
|
modifier = Modifier.verticalScroll(scrollState),
|
||||||
) {
|
) {
|
||||||
filteredLogs.takeLast(500).forEach { entry ->
|
displayLogs.forEach { entry ->
|
||||||
Text(
|
Text(
|
||||||
text = entry.formatted(),
|
text = entry.formatted(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
|||||||
@@ -1,58 +1,201 @@
|
|||||||
package top.yeij.cyrene.ui.theme
|
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 androidx.compose.ui.graphics.Color
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
// Light theme
|
// Each preset provides a seed color and light/dark primary colors
|
||||||
val LightPrimary = Color(0xFF6D3BC0)
|
data class ThemePreset(
|
||||||
val LightOnPrimary = Color(0xFFFFFFFF)
|
val seed: Long,
|
||||||
val LightPrimaryContainer = Color(0xFFEEDCFF)
|
val lightPrimary: Color,
|
||||||
val LightOnPrimaryContainer = Color(0xFF250058)
|
val darkPrimary: Color,
|
||||||
val LightSecondary = Color(0xFF625B71)
|
|
||||||
val LightOnSecondary = Color(0xFFFFFFFF)
|
|
||||||
val LightSecondaryContainer = Color(0xFFE8DEF8)
|
|
||||||
val LightOnSecondaryContainer = Color(0xFF1E192B)
|
|
||||||
val LightTertiary = Color(0xFF7E5260)
|
|
||||||
val LightOnTertiary = Color(0xFFFFFFFF)
|
|
||||||
val LightTertiaryContainer = Color(0xFFFFD9E3)
|
|
||||||
val LightOnTertiaryContainer = Color(0xFF31101D)
|
|
||||||
val LightBackground = Color(0xFFFFFBFF)
|
|
||||||
val LightOnBackground = Color(0xFF1C1B1F)
|
|
||||||
val LightSurface = Color(0xFFFFFBFF)
|
|
||||||
val LightOnSurface = Color(0xFF1C1B1F)
|
|
||||||
val LightSurfaceVariant = Color(0xFFE7E0EC)
|
|
||||||
val LightOnSurfaceVariant = Color(0xFF49454F)
|
|
||||||
val LightError = Color(0xFFBA1A1A)
|
|
||||||
val LightOutline = Color(0xFF79747E)
|
|
||||||
val LightOutlineVariant = Color(0xFFCAC4D0)
|
|
||||||
|
|
||||||
// Dark theme
|
|
||||||
val DarkPrimary = Color(0xFFD3BBFF)
|
|
||||||
val DarkOnPrimary = Color(0xFF3D0089)
|
|
||||||
val DarkPrimaryContainer = Color(0xFF541BA6)
|
|
||||||
val DarkOnPrimaryContainer = Color(0xFFEEDCFF)
|
|
||||||
val DarkSecondary = Color(0xFFCBC2DC)
|
|
||||||
val DarkOnSecondary = Color(0xFF332D41)
|
|
||||||
val DarkSecondaryContainer = Color(0xFF4A4458)
|
|
||||||
val DarkOnSecondaryContainer = Color(0xFFE8DEF8)
|
|
||||||
val DarkTertiary = Color(0xFFEFB8C8)
|
|
||||||
val DarkOnTertiary = Color(0xFF4A2532)
|
|
||||||
val DarkTertiaryContainer = Color(0xFF633B48)
|
|
||||||
val DarkOnTertiaryContainer = Color(0xFFFFD9E3)
|
|
||||||
val DarkBackground = Color(0xFF1C1B1F)
|
|
||||||
val DarkOnBackground = Color(0xFFE6E1E5)
|
|
||||||
val DarkSurface = Color(0xFF1C1B1F)
|
|
||||||
val DarkOnSurface = Color(0xFFE6E1E5)
|
|
||||||
val DarkSurfaceVariant = Color(0xFF49454F)
|
|
||||||
val DarkOnSurfaceVariant = Color(0xFFCAC4D0)
|
|
||||||
val DarkError = Color(0xFFFFB4AB)
|
|
||||||
val DarkOutline = Color(0xFF938F99)
|
|
||||||
val DarkOutlineVariant = Color(0xFF49454F)
|
|
||||||
|
|
||||||
// Preset seed colors for manual theme selection
|
|
||||||
val SeedColors = mapOf(
|
|
||||||
"default" to 0xFF6D3BC0, // Lavender
|
|
||||||
"sakura" to 0xFFFFB4C8, // Pink
|
|
||||||
"ocean" to 0xFF6BA4FF, // Blue
|
|
||||||
"forest" to 0xFF6BCF7C, // Green
|
|
||||||
"sunset" to 0xFFFF9E6B, // Orange
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import android.app.Activity
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
@@ -15,73 +13,29 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
|
||||||
primary = LightPrimary,
|
|
||||||
onPrimary = LightOnPrimary,
|
|
||||||
primaryContainer = LightPrimaryContainer,
|
|
||||||
onPrimaryContainer = LightOnPrimaryContainer,
|
|
||||||
secondary = LightSecondary,
|
|
||||||
onSecondary = LightOnSecondary,
|
|
||||||
secondaryContainer = LightSecondaryContainer,
|
|
||||||
onSecondaryContainer = LightOnSecondaryContainer,
|
|
||||||
tertiary = LightTertiary,
|
|
||||||
onTertiary = LightOnTertiary,
|
|
||||||
tertiaryContainer = LightTertiaryContainer,
|
|
||||||
onTertiaryContainer = LightOnTertiaryContainer,
|
|
||||||
background = LightBackground,
|
|
||||||
onBackground = LightOnBackground,
|
|
||||||
surface = LightSurface,
|
|
||||||
onSurface = LightOnSurface,
|
|
||||||
surfaceVariant = LightSurfaceVariant,
|
|
||||||
onSurfaceVariant = LightOnSurfaceVariant,
|
|
||||||
error = LightError,
|
|
||||||
outline = LightOutline,
|
|
||||||
outlineVariant = LightOutlineVariant,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
|
||||||
primary = DarkPrimary,
|
|
||||||
onPrimary = DarkOnPrimary,
|
|
||||||
primaryContainer = DarkPrimaryContainer,
|
|
||||||
onPrimaryContainer = DarkOnPrimaryContainer,
|
|
||||||
secondary = DarkSecondary,
|
|
||||||
onSecondary = DarkOnSecondary,
|
|
||||||
secondaryContainer = DarkSecondaryContainer,
|
|
||||||
onSecondaryContainer = DarkOnSecondaryContainer,
|
|
||||||
tertiary = DarkTertiary,
|
|
||||||
onTertiary = DarkOnTertiary,
|
|
||||||
tertiaryContainer = DarkTertiaryContainer,
|
|
||||||
onTertiaryContainer = DarkOnTertiaryContainer,
|
|
||||||
background = DarkBackground,
|
|
||||||
onBackground = DarkOnBackground,
|
|
||||||
surface = DarkSurface,
|
|
||||||
onSurface = DarkOnSurface,
|
|
||||||
surfaceVariant = DarkSurfaceVariant,
|
|
||||||
onSurfaceVariant = DarkOnSurfaceVariant,
|
|
||||||
error = DarkError,
|
|
||||||
outline = DarkOutline,
|
|
||||||
outlineVariant = DarkOutlineVariant,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CyreneTheme(
|
fun CyreneTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
dynamicColor: Boolean = true,
|
presetKey: String = "pink",
|
||||||
|
useDynamicColor: Boolean = false,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val preset = getPreset(presetKey)
|
||||||
|
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
}
|
}
|
||||||
darkTheme -> DarkColorScheme
|
darkTheme -> buildDarkScheme(preset.darkPrimary)
|
||||||
else -> LightColorScheme
|
else -> buildLightScheme(preset.lightPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
SideEffect {
|
||||||
val window = (view.context as Activity).window
|
val window = (view.context as? Activity)?.window
|
||||||
|
if (window != null) {
|
||||||
window.statusBarColor = colorScheme.background.toArgb()
|
window.statusBarColor = colorScheme.background.toArgb()
|
||||||
window.navigationBarColor = colorScheme.background.toArgb()
|
window.navigationBarColor = colorScheme.background.toArgb()
|
||||||
window.decorView.setBackgroundColor(colorScheme.background.toArgb())
|
window.decorView.setBackgroundColor(colorScheme.background.toArgb())
|
||||||
@@ -89,6 +43,9 @@ fun CyreneTheme(
|
|||||||
isAppearanceLightStatusBars = !darkTheme
|
isAppearanceLightStatusBars = !darkTheme
|
||||||
isAppearanceLightNavigationBars = !darkTheme
|
isAppearanceLightNavigationBars = !darkTheme
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
view.rootView?.setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import android.content.Intent
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import top.yeij.cyrene.MainActivity
|
import top.yeij.cyrene.MainActivity
|
||||||
|
import top.yeij.cyrene.R
|
||||||
import top.yeij.cyrene.domain.model.Message
|
import top.yeij.cyrene.domain.model.Message
|
||||||
|
|
||||||
class NotificationHelper(private val context: Context) {
|
class NotificationHelper(private val context: Context) {
|
||||||
@@ -47,7 +48,7 @@ class NotificationHelper(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setContentTitle("昔涟")
|
.setContentTitle("昔涟")
|
||||||
.setContentText(preview)
|
.setContentText(preview)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
@@ -55,7 +56,9 @@ class NotificationHelper(private val context: Context) {
|
|||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
notificationManager.notify(message.id.hashCode(), notification)
|
val notifyId = message.id.hashCode()
|
||||||
|
notificationManager.notify(notifyId, notification)
|
||||||
|
RuntimeLog.notify("posted", "Notification posted: id=$notifyId preview='$preview'")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelAll() {
|
fun cancelAll() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ enum class LogCategory(val label: String) {
|
|||||||
GENERAL("通用"),
|
GENERAL("通用"),
|
||||||
HTTP("网络"),
|
HTTP("网络"),
|
||||||
VOICE("语音"),
|
VOICE("语音"),
|
||||||
|
NOTIFY("通知"),
|
||||||
}
|
}
|
||||||
|
|
||||||
data class LogEntry(
|
data class LogEntry(
|
||||||
@@ -59,6 +60,7 @@ object RuntimeLog {
|
|||||||
fun general(tag: String, message: String) = log(LogCategory.GENERAL, 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 http(tag: String, message: String) = log(LogCategory.HTTP, tag, message)
|
||||||
fun voice(tag: String, message: String) = log(LogCategory.VOICE, 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
|
@Synchronized
|
||||||
fun getByCategory(category: LogCategory): List<LogEntry> {
|
fun getByCategory(category: LogCategory): List<LogEntry> {
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
package top.yeij.cyrene.viewmodel
|
package top.yeij.cyrene.viewmodel
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
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.Conversation
|
||||||
import top.yeij.cyrene.domain.model.Message
|
import top.yeij.cyrene.domain.model.Message
|
||||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||||
@@ -20,41 +32,22 @@ import top.yeij.cyrene.util.VoiceRecorder
|
|||||||
|
|
||||||
private fun List<Message>.deduplicate(): List<Message> {
|
private fun List<Message>.deduplicate(): List<Message> {
|
||||||
if (isEmpty()) return this
|
if (isEmpty()) return this
|
||||||
val result = mutableListOf(this[0])
|
val seen = mutableSetOf<String>()
|
||||||
for (i in 1 until size) {
|
return filter { seen.add(it.id) }
|
||||||
val prev = result.last()
|
|
||||||
val curr = this[i]
|
|
||||||
val isDuplicate = curr.id == prev.id ||
|
|
||||||
(curr.role == prev.role && curr.content == prev.content && curr.msgType == prev.msgType)
|
|
||||||
if (!isDuplicate) {
|
|
||||||
result.add(curr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChatViewModel(
|
class ChatViewModel(
|
||||||
|
application: Application,
|
||||||
private val chatRepository: ChatRepository,
|
private val chatRepository: ChatRepository,
|
||||||
private val voiceRecorder: VoiceRecorder,
|
private val voiceRecorder: VoiceRecorder,
|
||||||
) : ViewModel() {
|
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
|
val isConnected: StateFlow<Boolean> = chatRepository.connectionState
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
@@ -91,31 +84,36 @@ class ChatViewModel(
|
|||||||
|
|
||||||
private var currentSessionId: String? = null
|
private var currentSessionId: String? = null
|
||||||
private var dbObserverJob: Job? = null
|
private var dbObserverJob: Job? = null
|
||||||
|
private var sendTimeoutJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Phase 1: find/create main session, reconnect WS, load server history
|
RuntimeLog.general("app", "ChatViewModel instance #$instanceId created")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
RuntimeLog.general("app", "ChatViewModel #$instanceId — initializing session...")
|
||||||
val sessionId = chatRepository.initializeSession()
|
val sessionId = chatRepository.initializeSession()
|
||||||
currentSessionId = sessionId
|
currentSessionId = sessionId
|
||||||
chatRepository.currentSessionId = sessionId
|
chatRepository.currentSessionId = sessionId
|
||||||
|
RuntimeLog.general("app", "Session initialized: $sessionId")
|
||||||
chatRepository.ensureConnected()
|
chatRepository.ensureConnected()
|
||||||
loadMessagesFromDb(sessionId)
|
} catch (e: Exception) {
|
||||||
val serverMessages = chatRepository.loadMessagesFromServer(sessionId)
|
RuntimeLog.general("app", "initializeSession failed: ${e.message}")
|
||||||
if (serverMessages.isNotEmpty()) {
|
|
||||||
val serverIds = serverMessages.map { it.id }.toSet()
|
|
||||||
_currentMessages.update { current ->
|
|
||||||
val localOnly = current.filter { it.id !in serverIds }
|
|
||||||
(serverMessages + localOnly)
|
|
||||||
.sortedBy { it.timestamp }
|
|
||||||
.deduplicate()
|
|
||||||
.removeWrappingDuplicates()
|
|
||||||
}
|
}
|
||||||
|
// 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")
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe incoming live messages with atomic dedup
|
// Observe incoming live messages — insert at correct descending position
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
chatRepository.observeMessages().collect { message ->
|
chatRepository.observeMessages().collect { message ->
|
||||||
try {
|
try {
|
||||||
@@ -125,18 +123,20 @@ class ChatViewModel(
|
|||||||
if (existingIdx >= 0) {
|
if (existingIdx >= 0) {
|
||||||
updated[existingIdx] = message
|
updated[existingIdx] = message
|
||||||
} else {
|
} else {
|
||||||
val isDup = updated.any {
|
// Insert at correct position for descending timestamp (newest first)
|
||||||
it.role == message.role && it.content == message.content && it.msgType == message.msgType
|
val insertAt = updated.indexOfFirst { it.timestamp <= message.timestamp }
|
||||||
}
|
if (insertAt >= 0) updated.add(insertAt, message) else updated.add(message)
|
||||||
if (!isDup) {
|
|
||||||
updated.add(message)
|
|
||||||
val idx = _messageAnimIndex.value.toMutableMap()
|
val idx = _messageAnimIndex.value.toMutableMap()
|
||||||
idx[message.id] = animCounter++
|
idx[message.id] = animCounter++
|
||||||
_messageAnimIndex.value = idx
|
_messageAnimIndex.value = idx
|
||||||
}
|
}
|
||||||
}
|
|
||||||
updated.deduplicate()
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.e("ChatViewModel", "Error processing message: ${e.message}", e)
|
Log.e("ChatViewModel", "Error processing message: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -151,14 +151,27 @@ class ChatViewModel(
|
|||||||
animCounter = 0
|
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
|
// Reset user-side sending state when server starts responding
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
chatRepository.isAssistantStreaming.collect { streaming ->
|
chatRepository.isAssistantStreaming.collect { streaming ->
|
||||||
if (streaming) _isSending.value = false
|
if (streaming) {
|
||||||
|
_isSending.value = false
|
||||||
|
sendTimeoutJob?.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// --- Voice recording (WeChat-style gesture) ---
|
// --- Voice recording (WeChat-style gesture) ---
|
||||||
|
|
||||||
fun startRecord() {
|
fun startRecord() {
|
||||||
@@ -185,6 +198,113 @@ class ChatViewModel(
|
|||||||
voiceRecorder.cancel()
|
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) {
|
private fun loadMessagesFromDb(sessionId: String) {
|
||||||
dbObserverJob?.cancel()
|
dbObserverJob?.cancel()
|
||||||
dbObserverJob = viewModelScope.launch {
|
dbObserverJob = viewModelScope.launch {
|
||||||
@@ -194,9 +314,8 @@ class ChatViewModel(
|
|||||||
val live = current.associateBy { it.id }
|
val live = current.associateBy { it.id }
|
||||||
val db = messages.associateBy { it.id }
|
val db = messages.associateBy { it.id }
|
||||||
(db + live).values
|
(db + live).values
|
||||||
.sortedBy { it.timestamp }
|
.sortedByDescending { it.timestamp }
|
||||||
.deduplicate()
|
.deduplicate()
|
||||||
.removeWrappingDuplicates()
|
|
||||||
}
|
}
|
||||||
val idx = _messageAnimIndex.value.toMutableMap()
|
val idx = _messageAnimIndex.value.toMutableMap()
|
||||||
messages.forEach { m ->
|
messages.forEach { m ->
|
||||||
@@ -206,6 +325,7 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("ChatViewModel", "Error loading messages: ${e.message}", e)
|
Log.e("ChatViewModel", "Error loading messages: ${e.message}", e)
|
||||||
|
RuntimeLog.general("app", "loadMessagesFromDb failed: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,19 +334,6 @@ class ChatViewModel(
|
|||||||
_inputText.value = text
|
_inputText.value = text
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage() {
|
|
||||||
val text = _inputText.value.trim()
|
|
||||||
if (text.isEmpty()) return
|
|
||||||
|
|
||||||
_inputText.value = ""
|
|
||||||
_isSending.value = true
|
|
||||||
val sid = currentSessionId
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
chatRepository.sendMessage(text, sid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun switchSession(sessionId: String) {
|
fun switchSession(sessionId: String) {
|
||||||
currentSessionId = sessionId
|
currentSessionId = sessionId
|
||||||
chatRepository.currentSessionId = sessionId
|
chatRepository.currentSessionId = sessionId
|
||||||
@@ -236,29 +343,23 @@ class ChatViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
chatRepository.connectWebSocket(sessionId)
|
chatRepository.connectWebSocket(sessionId)
|
||||||
chatRepository.loadMessagesFromServer(sessionId)
|
chatRepository.loadMessagesFromServer(sessionId)
|
||||||
}
|
|
||||||
loadMessagesFromDb(sessionId)
|
loadMessagesFromDb(sessionId)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun refreshMessages() {
|
fun refreshMessages() {
|
||||||
val sid = currentSessionId ?: return
|
val sid = currentSessionId ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isRefreshing.value = true
|
_isRefreshing.value = true
|
||||||
try {
|
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) {
|
if (!isConnected.value) {
|
||||||
chatRepository.ensureConnected()
|
chatRepository.ensureConnected()
|
||||||
}
|
}
|
||||||
val serverMessages = chatRepository.loadMessagesFromServer(sid)
|
chatRepository.loadMessagesFromServer(sid)
|
||||||
if (serverMessages.isNotEmpty()) {
|
|
||||||
val serverIds = serverMessages.map { it.id }.toSet()
|
|
||||||
_currentMessages.update { current ->
|
|
||||||
val localOnly = current.filter { it.id !in serverIds }
|
|
||||||
(serverMessages + localOnly)
|
|
||||||
.sortedBy { it.timestamp }
|
|
||||||
.deduplicate()
|
|
||||||
.removeWrappingDuplicates()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
_isRefreshing.value = false
|
_isRefreshing.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,17 +19,8 @@ import top.yeij.cyrene.voice.tts.TextToSpeechEngine
|
|||||||
|
|
||||||
private fun List<Message>.deduplicate(): List<Message> {
|
private fun List<Message>.deduplicate(): List<Message> {
|
||||||
if (isEmpty()) return this
|
if (isEmpty()) return this
|
||||||
val result = mutableListOf(this[0])
|
val seen = mutableSetOf<String>()
|
||||||
for (i in 1 until size) {
|
return filter { seen.add(it.id) }
|
||||||
val prev = result.last()
|
|
||||||
val curr = this[i]
|
|
||||||
val isDuplicate = curr.id == prev.id ||
|
|
||||||
(curr.role == prev.role && curr.content == prev.content && curr.msgType == prev.msgType)
|
|
||||||
if (!isDuplicate) {
|
|
||||||
result.add(curr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
|
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
|
||||||
@@ -82,6 +73,7 @@ class OverlayViewModel(
|
|||||||
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
|
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
|
||||||
|
|
||||||
private var silenceTimer: Job? = null
|
private var silenceTimer: Job? = null
|
||||||
|
private var processingTimeoutJob: Job? = null
|
||||||
private var lastAssistantMessageId: String? = null
|
private var lastAssistantMessageId: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -89,23 +81,26 @@ class OverlayViewModel(
|
|||||||
chatRepository.observeMessages().collect { message ->
|
chatRepository.observeMessages().collect { message ->
|
||||||
_messages.update { list ->
|
_messages.update { list ->
|
||||||
val updated = list.toMutableList()
|
val updated = list.toMutableList()
|
||||||
val idx = updated.indexOfLast { it.id == message.id }
|
val existingIdx = updated.indexOfLast { it.id == message.id }
|
||||||
if (idx >= 0) {
|
if (existingIdx >= 0) {
|
||||||
updated[idx] = message
|
updated[existingIdx] = message
|
||||||
} else {
|
} else {
|
||||||
val isDup = updated.any {
|
// Insert at correct position for ascending timestamp (oldest first for top-down layout)
|
||||||
it.role == message.role && it.content == message.content && it.msgType == message.msgType
|
val insertAt = updated.indexOfFirst { it.timestamp >= message.timestamp }
|
||||||
}
|
if (insertAt >= 0) updated.add(insertAt, message) else updated.add(message)
|
||||||
if (!isDup) {
|
|
||||||
updated.add(message)
|
|
||||||
val animIdx = _messageAnimIndex.value.toMutableMap()
|
val animIdx = _messageAnimIndex.value.toMutableMap()
|
||||||
animIdx[message.id] = animCounter++
|
animIdx[message.id] = animCounter++
|
||||||
_messageAnimIndex.value = animIdx
|
_messageAnimIndex.value = animIdx
|
||||||
}
|
}
|
||||||
}
|
|
||||||
updated.deduplicate()
|
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.role == "assistant" && !message.isStreaming && message.msgType == "chat") {
|
||||||
if (message.id != lastAssistantMessageId && message.content.isNotBlank()) {
|
if (message.id != lastAssistantMessageId && message.content.isNotBlank()) {
|
||||||
lastAssistantMessageId = message.id
|
lastAssistantMessageId = message.id
|
||||||
@@ -114,6 +109,18 @@ class OverlayViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 {
|
viewModelScope.launch {
|
||||||
ttsEngine.onDone.collect {
|
ttsEngine.onDone.collect {
|
||||||
if (_state.value == OverlayState.SPEAKING) {
|
if (_state.value == OverlayState.SPEAKING) {
|
||||||
@@ -128,6 +135,14 @@ class OverlayViewModel(
|
|||||||
animCounter = 0
|
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) {
|
fun onInputChanged(text: String) {
|
||||||
@@ -141,8 +156,14 @@ class OverlayViewModel(
|
|||||||
|
|
||||||
_state.value = OverlayState.PROCESSING
|
_state.value = OverlayState.PROCESSING
|
||||||
cancelSilenceTimer()
|
cancelSilenceTimer()
|
||||||
|
startProcessingTimeout()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
chatRepository.sendMessage(text, null)
|
chatRepository.sendMessage(text, null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("OverlayVM", "sendText failed: ${e.message}", e)
|
||||||
|
if (_state.value == OverlayState.PROCESSING) setWaiting()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +186,7 @@ class OverlayViewModel(
|
|||||||
if (base64.isNullOrBlank()) return
|
if (base64.isNullOrBlank()) return
|
||||||
|
|
||||||
_state.value = OverlayState.PROCESSING
|
_state.value = OverlayState.PROCESSING
|
||||||
|
startProcessingTimeout()
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
chatRepository.sendVoiceInput(base64, "voice_msg")
|
chatRepository.sendVoiceInput(base64, "voice_msg")
|
||||||
}
|
}
|
||||||
@@ -229,6 +251,22 @@ class OverlayViewModel(
|
|||||||
silenceTimer = null
|
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() {
|
override fun onCleared() {
|
||||||
voiceRecorder.cancel()
|
voiceRecorder.cancel()
|
||||||
ttsEngine.shutdown()
|
ttsEngine.shutdown()
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ class SettingsViewModel(
|
|||||||
private val _autoScreenContext = MutableStateFlow(false)
|
private val _autoScreenContext = MutableStateFlow(false)
|
||||||
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
|
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)
|
private val _isLoggedIn = MutableStateFlow(false)
|
||||||
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
||||||
|
|
||||||
@@ -61,6 +73,26 @@ class SettingsViewModel(
|
|||||||
_autoScreenContext.value = 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 {
|
scope.launch {
|
||||||
combine(
|
combine(
|
||||||
preferencesDataStore.baseUrl,
|
preferencesDataStore.baseUrl,
|
||||||
@@ -165,6 +197,26 @@ class SettingsViewModel(
|
|||||||
scope.launch { preferencesDataStore.saveAutoScreenContext(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() {
|
fun clearLocalMessages() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
chatRepository.clearLocalMessages()
|
chatRepository.clearLocalMessages()
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#6D3BC0"
|
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<!-- 昔涟首字母 C,圆形背景 -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M54,30 C67.255,30 78,40.745 78,54 C78,67.255 67.255,78 54,78 C40.745,78 30,67.255 30,54 C30,40.745 40.745,30 54,30 Z M54,36 C44.059,36 36,44.059 36,54 C36,63.941 44.059,72 54,72 C58.935,72 63.437,70.1 66.878,66.878 C68.523,65.301 69.761,63.394 70.505,61.289 C70.963,59.947 71.213,58.524 71.233,57.067 C71.239,55.712 71.041,54.369 70.646,53.084 L66.757,58.243 L58.243,49.729 L53.084,53.619 L44.57,45.106 L45.398,44.278 C47.881,41.795 51.235,40.233 54,40.233 Z" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -13,6 +13,7 @@ koin = "4.0.0"
|
|||||||
datastore = "1.1.1"
|
datastore = "1.1.1"
|
||||||
coroutines = "1.9.0"
|
coroutines = "1.9.0"
|
||||||
material3 = "1.3.1"
|
material3 = "1.3.1"
|
||||||
|
coil = "2.7.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# Compose BOM
|
# Compose BOM
|
||||||
@@ -56,6 +57,9 @@ coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-andro
|
|||||||
# Core
|
# Core
|
||||||
core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" }
|
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
|
||||||
biometric = { group = "androidx.biometric", name = "biometric", version = "1.1.0" }
|
biometric = { group = "androidx.biometric", name = "biometric", version = "1.1.0" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user