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
|
||||
implementation(libs.biometric)
|
||||
|
||||
// Coil — image loading
|
||||
implementation(libs.coil.compose)
|
||||
}
|
||||
|
||||
Vendored
+90
-13
@@ -1,27 +1,104 @@
|
||||
# Cyrene ProGuard Rules
|
||||
|
||||
# Retrofit
|
||||
-keepattributes Signature
|
||||
# --- Keep Android components declared in manifest ---
|
||||
# These are instantiated by the Android system via reflection
|
||||
-keep class top.yeij.cyrene.CyreneApplication { *; }
|
||||
-keep class top.yeij.cyrene.MainActivity { *; }
|
||||
-keep class top.yeij.cyrene.service.** { *; }
|
||||
|
||||
# --- Kotlin ---
|
||||
-keepattributes *Annotation*
|
||||
-keep class top.yeij.cyrene.data.remote.dto.** { *; }
|
||||
-dontwarn retrofit2.**
|
||||
-keep class retrofit2.** { *; }
|
||||
|
||||
# Gson
|
||||
-keep class com.google.gson.** { *; }
|
||||
-keepattributes Signature
|
||||
-keepattributes InnerClasses
|
||||
-keepattributes EnclosingMethod
|
||||
-keepattributes RuntimeVisibleAnnotations
|
||||
-keepattributes RuntimeVisibleParameterAnnotations
|
||||
-keepattributes AnnotationDefault
|
||||
-keepattributes KotlinMetadata
|
||||
-dontwarn kotlin.**
|
||||
-keep class kotlin.Metadata { *; }
|
||||
-keep class kotlin.coroutines.Continuation
|
||||
-keep class kotlinx.coroutines.** { *; }
|
||||
|
||||
# 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 okio.**
|
||||
|
||||
# Room
|
||||
-keep class * extends androidx.room.RoomDatabase
|
||||
# --- Room ---
|
||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||
-keep class top.yeij.cyrene.data.local.entity.** { *; }
|
||||
-keepclassmembers class top.yeij.cyrene.data.local.entity.** { *; }
|
||||
-dontwarn androidx.room.paging.**
|
||||
|
||||
# Koin
|
||||
# --- 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.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_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<!-- 推送 -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
@@ -21,6 +22,12 @@
|
||||
<!-- 锁屏交互 -->
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
|
||||
<!-- 激进保活 -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!-- 查询其他应用(检查默认助手设置) -->
|
||||
<queries>
|
||||
<intent>
|
||||
@@ -42,6 +49,7 @@
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustNothing"
|
||||
android:theme="@style/Theme.Cyrene">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -83,6 +91,30 @@
|
||||
android:resource="@xml/accessibility_config" />
|
||||
</service>
|
||||
|
||||
<!-- WebSocket 后台保活 -->
|
||||
<service
|
||||
android:name=".service.WebSocketKeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="websocket_keepalive_for_push_message_delivery" />
|
||||
</service>
|
||||
|
||||
<!-- 开机自启 -->
|
||||
<receiver
|
||||
android:name=".service.BootReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- 定时保活唤醒 -->
|
||||
<receiver
|
||||
android:name=".service.KeepAliveReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- FileProvider:日志分享 -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package top.yeij.cyrene
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -17,7 +23,8 @@ import top.yeij.cyrene.data.remote.AuthInterceptor
|
||||
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||
import top.yeij.cyrene.di.appModule
|
||||
import top.yeij.cyrene.util.NotificationHelper
|
||||
import top.yeij.cyrene.service.KeepAliveReceiver
|
||||
import top.yeij.cyrene.util.RootKeepAliveHelper
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@@ -25,8 +32,6 @@ class CyreneApplication : Application() {
|
||||
|
||||
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val activityCount = AtomicInteger(0)
|
||||
@Volatile
|
||||
private var notificationHelper: NotificationHelper? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -39,15 +44,15 @@ class CyreneApplication : Application() {
|
||||
|
||||
// Track foreground/background state
|
||||
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
if (activityCount.incrementAndGet() == 1) {
|
||||
RuntimeLog.general("app", "App in foreground")
|
||||
notificationHelper?.cancelAll()
|
||||
getRepo()?.cancelNotifications()
|
||||
getRepo()?.onAppForeground()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityStopped(activity: Activity) {
|
||||
override fun onActivityPaused(activity: Activity) {
|
||||
if (activityCount.decrementAndGet() == 0) {
|
||||
RuntimeLog.general("app", "App in background")
|
||||
getRepo()?.onAppBackground()
|
||||
@@ -55,22 +60,12 @@ class CyreneApplication : Application() {
|
||||
}
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
|
||||
override fun onActivityResumed(activity: Activity) {}
|
||||
override fun onActivityPaused(activity: Activity) {}
|
||||
override fun onActivityStarted(activity: Activity) {}
|
||||
override fun onActivityStopped(activity: Activity) {}
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
|
||||
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 {
|
||||
val koin = GlobalContext.get()
|
||||
val prefs: PreferencesDataStore = koin.get()
|
||||
@@ -84,12 +79,55 @@ class CyreneApplication : Application() {
|
||||
authInterceptor.token = token
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule periodic keep-alive on first launch
|
||||
scheduleInitialKeepAlive()
|
||||
|
||||
// Check battery optimization
|
||||
checkBatteryOptimization()
|
||||
|
||||
// Apply root keep-alive if enabled
|
||||
initScope.launch {
|
||||
val koin = GlobalContext.get()
|
||||
val prefs: PreferencesDataStore = koin.get()
|
||||
val enabled = prefs.rootKeepAlive.firstOrNull() ?: false
|
||||
if (enabled) {
|
||||
val ok = RootKeepAliveHelper.applyRootKeepAlive(packageName)
|
||||
if (ok) {
|
||||
RuntimeLog.general("app", "Root keep-alive re-applied on boot")
|
||||
} else if (RootKeepAliveHelper.isRootAvailable()) {
|
||||
RuntimeLog.general("app", "Root keep-alive re-apply failed despite root being available")
|
||||
}
|
||||
// Only attempt system wakelock if root keep-alive was enabled
|
||||
// We don't persist the wakelock across reboots since it's per-session
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleInitialKeepAlive() {
|
||||
try {
|
||||
KeepAliveReceiver.schedule(this)
|
||||
RuntimeLog.general("app", "Initial keep-alive alarm scheduled")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to schedule initial keep-alive: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkBatteryOptimization() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val pm = getSystemService(PowerManager::class.java)
|
||||
if (pm?.isIgnoringBatteryOptimizations(packageName) == false) {
|
||||
Log.i(TAG, "App is NOT exempt from battery optimization")
|
||||
// Note: we can't request exemption from Application context directly.
|
||||
// SettingsScreen should offer a button to open the exemption dialog.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRepo(): ChatRepositoryImpl? {
|
||||
return try {
|
||||
GlobalContext.get().get()
|
||||
} catch (_: Exception) {
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
package top.yeij.cyrene
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
@@ -24,22 +29,33 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private val isDefaultAssistant = mutableStateOf(false)
|
||||
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { /* granted or denied — either way we continue */ }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
requestNotificationPermission()
|
||||
isDefaultAssistant.value = checkIsDefaultAssistant()
|
||||
|
||||
setContent {
|
||||
val prefs: PreferencesDataStore = koinInject()
|
||||
val themeMode by prefs.themeMode.collectAsState(initial = null)
|
||||
val themeColor by prefs.themeColor.collectAsState(initial = "pink")
|
||||
val darkTheme = when (themeMode) {
|
||||
"light" -> false
|
||||
"dark" -> true
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
val useDynamic = themeColor == "monet" && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
|
||||
|
||||
CyreneTheme(darkTheme = darkTheme) {
|
||||
CyreneTheme(
|
||||
darkTheme = darkTheme,
|
||||
presetKey = themeColor,
|
||||
useDynamicColor = useDynamic,
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
CyreneNavGraph(
|
||||
@@ -75,4 +91,14 @@ class MainActivity : ComponentActivity() {
|
||||
private fun openAssistantSettings() {
|
||||
startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS))
|
||||
}
|
||||
|
||||
private fun requestNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
AppDatabase::class.java,
|
||||
"cyrene.db",
|
||||
)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
.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_CREATED_AT = stringPreferencesKey("profile_created_at")
|
||||
private val KEY_AUTO_SCREEN_CONTEXT = booleanPreferencesKey("auto_screen_context")
|
||||
private val KEY_TYPING_INDICATOR_STYLE = stringPreferencesKey("typing_indicator_style")
|
||||
private val KEY_ENTER_TO_SEND = booleanPreferencesKey("enter_to_send")
|
||||
private val KEY_ROOT_KEEPALIVE = booleanPreferencesKey("root_keepalive")
|
||||
private val KEY_THEME_COLOR = stringPreferencesKey("theme_color")
|
||||
}
|
||||
|
||||
val typingIndicatorStyle: Flow<String> = context.dataStore.data.map { it[KEY_TYPING_INDICATOR_STYLE] ?: "bubble" }
|
||||
|
||||
suspend fun saveTypingIndicatorStyle(style: String) {
|
||||
context.dataStore.edit { it[KEY_TYPING_INDICATOR_STYLE] = style }
|
||||
}
|
||||
|
||||
val enterToSend: Flow<Boolean> = context.dataStore.data.map { it[KEY_ENTER_TO_SEND] ?: false }
|
||||
|
||||
suspend fun saveEnterToSend(enabled: Boolean) {
|
||||
context.dataStore.edit { it[KEY_ENTER_TO_SEND] = enabled }
|
||||
}
|
||||
|
||||
val rootKeepAlive: Flow<Boolean> = context.dataStore.data.map { it[KEY_ROOT_KEEPALIVE] ?: false }
|
||||
|
||||
suspend fun saveRootKeepAlive(enabled: Boolean) {
|
||||
context.dataStore.edit { it[KEY_ROOT_KEEPALIVE] = enabled }
|
||||
}
|
||||
|
||||
val themeColor: Flow<String> = context.dataStore.data.map { it[KEY_THEME_COLOR] ?: "pink" }
|
||||
|
||||
suspend fun saveThemeColor(color: String) {
|
||||
context.dataStore.edit { it[KEY_THEME_COLOR] = color }
|
||||
}
|
||||
|
||||
val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
|
||||
|
||||
@@ -25,6 +25,9 @@ interface MessageDao {
|
||||
@Query("UPDATE messages SET conversationId = :newId WHERE conversationId = :oldId")
|
||||
suspend fun migrateConversationId(oldId: String, newId: String)
|
||||
|
||||
@Query("DELETE FROM messages WHERE conversationId = :conversationId AND role = 'user'")
|
||||
suspend fun deleteUserMessagesByConversation(conversationId: String)
|
||||
|
||||
@Query("DELETE FROM messages WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package top.yeij.cyrene.data.remote
|
||||
|
||||
import okhttp3.MultipartBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import top.yeij.cyrene.data.remote.dto.AuthRequest
|
||||
@@ -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.CreateSessionRequest
|
||||
import top.yeij.cyrene.data.remote.dto.DeviceDto
|
||||
import top.yeij.cyrene.data.remote.dto.FileUploadResponse
|
||||
import top.yeij.cyrene.data.remote.dto.IoTControlRequest
|
||||
import top.yeij.cyrene.data.remote.dto.MessagesListResponse
|
||||
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
|
||||
@@ -46,10 +50,17 @@ interface ApiService {
|
||||
@GET("api/v1/sessions/{id}/messages")
|
||||
suspend fun getSessionMessages(
|
||||
@Path("id") sessionId: String,
|
||||
@Query("limit") limit: Int = 50,
|
||||
@Query("limit") limit: Int = 500,
|
||||
@Query("offset") offset: Int = 0,
|
||||
): Response<MessagesListResponse>
|
||||
|
||||
// Files
|
||||
@Multipart
|
||||
@POST("api/v1/files/upload")
|
||||
suspend fun uploadFile(
|
||||
@Part file: MultipartBody.Part,
|
||||
): Response<FileUploadResponse>
|
||||
|
||||
// IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/*
|
||||
@GET("api/v1/iot/devices")
|
||||
suspend fun getDevices(): Response<List<DeviceDto>>
|
||||
|
||||
@@ -36,3 +36,12 @@ data class SessionMessageDto(
|
||||
@SerializedName("content") val content: String,
|
||||
@SerializedName("created_at") val createdAt: Long,
|
||||
)
|
||||
|
||||
// POST /api/v1/files/upload — response
|
||||
data class FileUploadResponse(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("filename") val filename: String? = null,
|
||||
@SerializedName("mime_type") val mimeType: String? = null,
|
||||
@SerializedName("size") val size: Long? = null,
|
||||
@SerializedName("url") val url: String? = null,
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ data class WSClientMessage(
|
||||
data class WSAttachment(
|
||||
@SerializedName("type") val type: String,
|
||||
@SerializedName("url") val url: String? = null,
|
||||
@SerializedName("file_id") val fileId: String? = null,
|
||||
@SerializedName("thumbnail_url") val thumbnailUrl: String? = null,
|
||||
@SerializedName("filename") val filename: String? = null,
|
||||
@SerializedName("width") val width: Int? = null,
|
||||
@@ -55,11 +56,18 @@ data class WSServerMessage(
|
||||
)
|
||||
|
||||
data class WSReviewMessage(
|
||||
@SerializedName("role") val role: String?,
|
||||
@SerializedName("text") val text: String?,
|
||||
@SerializedName("content") val content: String?,
|
||||
@SerializedName("msg_type") val msgType: String?,
|
||||
@SerializedName("type") val type: String? = null,
|
||||
@SerializedName("role") val role: String? = null,
|
||||
@SerializedName("text") val text: String? = null,
|
||||
@SerializedName("content") val content: String? = null,
|
||||
@SerializedName("msg_type") val msgType: String? = null,
|
||||
@SerializedName("delay_ms") val delayMs: Long? = 0,
|
||||
@SerializedName("metadata") val metadata: WSReviewMetadata? = null,
|
||||
)
|
||||
|
||||
data class WSReviewMetadata(
|
||||
@SerializedName("language") val language: String? = null,
|
||||
@SerializedName("url") val url: String? = null,
|
||||
)
|
||||
|
||||
data class WSHistoryMessage(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
@@ -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.remote.ApiService
|
||||
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
|
||||
import top.yeij.cyrene.data.remote.dto.WSAttachment
|
||||
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||
import top.yeij.cyrene.domain.model.Conversation
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import top.yeij.cyrene.service.KeepAliveReceiver
|
||||
import top.yeij.cyrene.service.WebSocketKeepAliveService
|
||||
import top.yeij.cyrene.service.WebSocketService
|
||||
import top.yeij.cyrene.util.NotificationHelper
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import java.util.UUID
|
||||
|
||||
class ChatRepositoryImpl(
|
||||
private val app: Application,
|
||||
private val conversationDao: ConversationDao,
|
||||
private val messageDao: MessageDao,
|
||||
private val webSocketService: WebSocketService,
|
||||
private val apiService: ApiService,
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
private val notificationHelper: NotificationHelper,
|
||||
) : ChatRepository {
|
||||
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, e ->
|
||||
@@ -54,15 +62,19 @@ class ChatRepositoryImpl(
|
||||
private val _messageClearEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
|
||||
override val messageClearEvents: Flow<Unit> = _messageClearEvents
|
||||
|
||||
private val _messageRemovals = MutableSharedFlow<String>(extraBufferCapacity = 16)
|
||||
override val messageRemovals: Flow<String> = _messageRemovals
|
||||
|
||||
private val _isAssistantStreaming = MutableStateFlow(false)
|
||||
override val isAssistantStreaming: StateFlow<Boolean> = _isAssistantStreaming.asStateFlow()
|
||||
|
||||
private var streamingContent = ""
|
||||
private var streamingMessageId: String? = null
|
||||
private var streamTimeoutJob: kotlinx.coroutines.Job? = null
|
||||
override var currentSessionId: String? = null
|
||||
|
||||
private var isAppInForeground = false
|
||||
private var onBackgroundNotification: ((Message) -> Unit)? = null
|
||||
private var hasEverBeenForeground = false
|
||||
private var historyRequested = false
|
||||
private val notifiedMessageIds = mutableSetOf<String>()
|
||||
|
||||
@@ -74,17 +86,45 @@ class ChatRepositoryImpl(
|
||||
private var lastResponseContent: String? = null
|
||||
private var lastResponseTime = 0L
|
||||
|
||||
fun setNotificationCallback(callback: ((Message) -> Unit)?) {
|
||||
onBackgroundNotification = callback
|
||||
fun cancelNotifications() {
|
||||
notificationHelper.cancelAll()
|
||||
}
|
||||
|
||||
private fun resetStreamTimeout() {
|
||||
cancelStreamTimeout()
|
||||
streamTimeoutJob = scope.launch {
|
||||
kotlinx.coroutines.delay(120_000L) // 2 min timeout
|
||||
if (_isAssistantStreaming.value) {
|
||||
RuntimeLog.chat("stream", "Stream timeout — no chunk or end for 120s, resetting")
|
||||
streamingContent = ""
|
||||
streamingMessageId = null
|
||||
_isAssistantStreaming.value = false
|
||||
emitMessage(
|
||||
id = "timeout_${System.currentTimeMillis()}",
|
||||
sessionId = currentSessionId ?: "default",
|
||||
role = "system",
|
||||
content = "AI 响应超时,请重试",
|
||||
msgType = "system_info",
|
||||
isStreaming = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelStreamTimeout() {
|
||||
streamTimeoutJob?.cancel()
|
||||
streamTimeoutJob = null
|
||||
}
|
||||
|
||||
override fun onAppForeground() {
|
||||
RuntimeLog.notify("state", "onAppForeground: wasForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground")
|
||||
isAppInForeground = true
|
||||
hasEverBeenForeground = true
|
||||
notifiedMessageIds.clear()
|
||||
if (!_connectionState.value) {
|
||||
webSocketService.forceReconnect()
|
||||
}
|
||||
// Always request history on foreground to catch cross-device messages
|
||||
notificationHelper.cancelAll()
|
||||
KeepAliveReceiver.cancel(app)
|
||||
WebSocketKeepAliveService.stop(app)
|
||||
RuntimeLog.notify("state", "onAppForeground: notifications cleared, keep-alive stopped")
|
||||
scope.launch {
|
||||
val sid = currentSessionId ?: return@launch
|
||||
RuntimeLog.general("app", "Foreground — requesting history for session=$sid")
|
||||
@@ -94,6 +134,21 @@ class ChatRepositoryImpl(
|
||||
|
||||
override fun onAppBackground() {
|
||||
isAppInForeground = false
|
||||
WebSocketKeepAliveService.start(app)
|
||||
KeepAliveReceiver.schedule(app)
|
||||
val currentlyConnected = _connectionState.value
|
||||
RuntimeLog.notify("state", "onAppBackground: connected=$currentlyConnected hasEverBeen=$hasEverBeenForeground keepAliveStarted=true")
|
||||
// Only reconnect if the WS is already dead. Tearing down a healthy
|
||||
// connection creates a message loss window with no benefit.
|
||||
if (!currentlyConnected) {
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(1500) // let the service start first
|
||||
webSocketService.forceReconnect()
|
||||
RuntimeLog.general("app", "Background reconnect done, connected=${_connectionState.value}")
|
||||
}
|
||||
} else {
|
||||
RuntimeLog.general("app", "WS healthy — skipping background reconnect to avoid message loss")
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
@@ -148,17 +203,6 @@ class ChatRepositoryImpl(
|
||||
messageDao.deleteAll()
|
||||
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)
|
||||
|
||||
RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now")
|
||||
@@ -186,11 +230,11 @@ class ChatRepositoryImpl(
|
||||
}
|
||||
|
||||
override suspend fun ensureConnected() {
|
||||
if (_connectionState.value) return
|
||||
// Always force reconnect — connectionState may be stuck at true on a silently dead socket
|
||||
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 now = System.currentTimeMillis()
|
||||
val sid = sessionId ?: currentSessionId ?: "default"
|
||||
@@ -199,13 +243,21 @@ class ChatRepositoryImpl(
|
||||
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(
|
||||
ConversationEntity(
|
||||
id = sid,
|
||||
title = "对话",
|
||||
lastMessage = content,
|
||||
lastMessage = lastMsg,
|
||||
lastMessageType = "chat",
|
||||
updatedAt = now,
|
||||
createdAt = now,
|
||||
@@ -217,7 +269,7 @@ class ChatRepositoryImpl(
|
||||
id = messageId,
|
||||
conversationId = sid,
|
||||
role = "user",
|
||||
content = content,
|
||||
content = displayContent,
|
||||
msgType = "chat",
|
||||
timestamp = now,
|
||||
)
|
||||
@@ -227,13 +279,14 @@ class ChatRepositoryImpl(
|
||||
id = messageId,
|
||||
sessionId = sid,
|
||||
role = "user",
|
||||
content = content,
|
||||
content = displayContent,
|
||||
msgType = "chat",
|
||||
timestamp = now,
|
||||
isStreaming = false,
|
||||
imageDataUris = localImageUris,
|
||||
)
|
||||
|
||||
webSocketService.sendMessage(content, sid)
|
||||
webSocketService.sendMessage(content, sid, attachments = attachments)
|
||||
}
|
||||
|
||||
override suspend fun loadConversationsFromServer() {
|
||||
@@ -310,29 +363,30 @@ class ChatRepositoryImpl(
|
||||
?.toLongOrNull() ?: 0L
|
||||
val filteredDtos = messageDtos.filter { it.createdAt > lastCleared }
|
||||
ensureConversation(sessionId)
|
||||
filteredDtos.forEach { dto ->
|
||||
messageDao.upsert(
|
||||
MessageEntity(
|
||||
id = "db_${dto.id}",
|
||||
val messages = filteredDtos.map { dto ->
|
||||
Message(
|
||||
id = "${dto.id}",
|
||||
conversationId = sessionId,
|
||||
role = dto.role,
|
||||
content = dto.content,
|
||||
msgType = dto.msgType ?: "chat",
|
||||
timestamp = dto.createdAt,
|
||||
)
|
||||
)
|
||||
}
|
||||
RuntimeLog.http("loadMessages", "HTTP loaded ${filteredDtos.size} messages for session=$sessionId")
|
||||
filteredDtos.map { dto ->
|
||||
Message(
|
||||
id = "db_${dto.id}",
|
||||
conversationId = sessionId,
|
||||
role = dto.role,
|
||||
content = dto.content,
|
||||
msgType = dto.msgType ?: "chat",
|
||||
timestamp = dto.createdAt,
|
||||
val deduped = messages.removeWrappingDuplicates().splitInlineActions()
|
||||
messageDao.deleteUserMessagesByConversation(sessionId)
|
||||
messageDao.upsertAll(deduped.map { msg ->
|
||||
MessageEntity(
|
||||
id = msg.id,
|
||||
conversationId = msg.conversationId,
|
||||
role = msg.role,
|
||||
content = msg.content,
|
||||
msgType = msg.msgType,
|
||||
timestamp = msg.timestamp,
|
||||
)
|
||||
}.removeWrappingDuplicates()
|
||||
})
|
||||
RuntimeLog.http("loadMessages", "HTTP loaded ${deduped.size} messages (${messages.size} before dedup) for session=$sessionId")
|
||||
deduped
|
||||
} else {
|
||||
RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS")
|
||||
requestHistoryViaWs(sessionId)
|
||||
@@ -346,11 +400,16 @@ class ChatRepositoryImpl(
|
||||
}
|
||||
|
||||
private suspend fun requestHistoryViaWs(sessionId: String) {
|
||||
// Wait up to 5s for WS to connect
|
||||
if (!webSocketService.isConnected.value) {
|
||||
withTimeoutOrNull(5000) {
|
||||
val connected = withTimeoutOrNull(5000) {
|
||||
webSocketService.isConnected.first { it }
|
||||
}
|
||||
if (connected != true) {
|
||||
// WS couldn't connect, fall back to REST API
|
||||
RuntimeLog.chat("history", "WS not connected after 5s, falling back to REST")
|
||||
loadMessagesFromServer(sessionId)
|
||||
return
|
||||
}
|
||||
}
|
||||
webSocketService.requestHistory(sessionId)
|
||||
}
|
||||
@@ -378,23 +437,27 @@ class ChatRepositoryImpl(
|
||||
streamingContent = ""
|
||||
streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
|
||||
_isAssistantStreaming.value = true
|
||||
recentParsedContents.clear()
|
||||
resetStreamTimeout()
|
||||
RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
|
||||
}
|
||||
|
||||
"stream_chunk" -> {
|
||||
val delta = wsMsg.content ?: wsMsg.text ?: return
|
||||
streamingContent += delta
|
||||
resetStreamTimeout()
|
||||
emitMessage(
|
||||
id = streamingMessageId ?: "s_${System.currentTimeMillis()}",
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
role = "assistant",
|
||||
content = streamingContent,
|
||||
msgType = "chat",
|
||||
msgType = wsMsg.msgType ?: "chat",
|
||||
isStreaming = true,
|
||||
)
|
||||
}
|
||||
|
||||
"stream_end" -> {
|
||||
cancelStreamTimeout()
|
||||
val msgId = wsMsg.messageId ?: streamingMessageId ?: "s_${System.currentTimeMillis()}"
|
||||
val content = streamingContent.ifEmpty { wsMsg.content ?: wsMsg.text ?: "" }
|
||||
streamingContent = ""
|
||||
@@ -405,6 +468,19 @@ class ChatRepositoryImpl(
|
||||
}
|
||||
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
|
||||
|
||||
// Dedup: suppress if streaming content wraps already-shown multi_message/review items
|
||||
val timeSinceParsed = System.currentTimeMillis() - lastParsedTime
|
||||
if (timeSinceParsed < 3000 && recentParsedContents.isNotEmpty()) {
|
||||
val allContained = recentParsedContents.all { content.contains(it) }
|
||||
if (allContained) {
|
||||
RuntimeLog.chat("dedup", "Suppressed stream_end wrapping, ${recentParsedContents.size} items already shown")
|
||||
recentParsedContents.clear()
|
||||
_isAssistantStreaming.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
recentParsedContents.clear()
|
||||
|
||||
if (content.isNotBlank()) {
|
||||
ensureConversation(sid, content)
|
||||
messageDao.upsert(
|
||||
@@ -413,15 +489,19 @@ class ChatRepositoryImpl(
|
||||
conversationId = sid,
|
||||
role = "assistant",
|
||||
content = content,
|
||||
msgType = "chat",
|
||||
msgType = wsMsg.msgType ?: "chat",
|
||||
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
|
||||
RuntimeLog.chat("stream", "Stream end msgId=$msgId content=${content.take(80)}")
|
||||
}
|
||||
|
||||
"response" -> {
|
||||
@@ -431,18 +511,6 @@ class ChatRepositoryImpl(
|
||||
val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}"
|
||||
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)) {
|
||||
changeSessionId(sid)
|
||||
}
|
||||
@@ -464,19 +532,28 @@ class ChatRepositoryImpl(
|
||||
lastResponseContent = text
|
||||
lastResponseTime = System.currentTimeMillis()
|
||||
|
||||
// Track parsed content so stream_end can suppress the wrapping full text
|
||||
recentParsedContents.add(text)
|
||||
lastParsedTime = System.currentTimeMillis()
|
||||
|
||||
RuntimeLog.notify("trigger", "reply: id=$msgId role=$role isForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground content='${text.take(50)}'")
|
||||
emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false, shouldNotify = true)
|
||||
RuntimeLog.chat("receive", "Response msgId=$msgId role=$role msgType=$replyMsgType content=${text.take(80)}")
|
||||
}
|
||||
|
||||
"review" -> {
|
||||
recentParsedContents.clear()
|
||||
wsMsg.reviewMessages?.forEach { review ->
|
||||
val text = review.content ?: review.text ?: return@forEach
|
||||
val role = review.role ?: "action"
|
||||
val rvMsgType = review.msgType ?: review.role ?: "action"
|
||||
wsMsg.reviewMessages?.forEachIndexed { index, review ->
|
||||
if (index > 0) delay(1000L)
|
||||
val rawText = review.content ?: review.text ?: return@forEachIndexed
|
||||
val role = review.role ?: "assistant"
|
||||
val rvMsgType = review.type ?: review.msgType ?: "action"
|
||||
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
|
||||
recentParsedContents.add(text)
|
||||
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = text, msgType = rvMsgType, isStreaming = false)
|
||||
// Encode code language metadata into content for the renderer
|
||||
val content = if (rvMsgType == "code" && review.metadata?.language != null) {
|
||||
"[lang:${review.metadata.language}]\n$rawText"
|
||||
} else rawText
|
||||
recentParsedContents.add(rawText)
|
||||
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = content, msgType = rvMsgType, isStreaming = false)
|
||||
}
|
||||
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
|
||||
// Clean up wrapping response that arrived before this review
|
||||
@@ -491,7 +568,7 @@ class ChatRepositoryImpl(
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
role = "assistant",
|
||||
content = text,
|
||||
msgType = "thinking",
|
||||
msgType = wsMsg.msgType ?: "thinking",
|
||||
isStreaming = false,
|
||||
)
|
||||
}
|
||||
@@ -507,19 +584,34 @@ class ChatRepositoryImpl(
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
role = "system",
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
"error" -> {
|
||||
cancelStreamTimeout()
|
||||
streamingContent = ""
|
||||
streamingMessageId = null
|
||||
_isAssistantStreaming.value = false
|
||||
RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}")
|
||||
emitMessage(
|
||||
id = "err_${System.currentTimeMillis()}",
|
||||
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
|
||||
role = "system",
|
||||
content = wsMsg.error ?: "未知错误",
|
||||
msgType = "system_info",
|
||||
msgType = wsMsg.msgType ?: "system_info",
|
||||
isStreaming = false,
|
||||
)
|
||||
}
|
||||
@@ -536,11 +628,11 @@ class ChatRepositoryImpl(
|
||||
conversationId = sid,
|
||||
role = "user",
|
||||
content = text,
|
||||
msgType = "chat",
|
||||
msgType = wsMsg.msgType ?: "chat",
|
||||
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" -> {
|
||||
@@ -561,9 +653,8 @@ class ChatRepositoryImpl(
|
||||
timestamp = hist.timestamp ?: System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
val deduped = messageList.removeWrappingDuplicates()
|
||||
deduped.forEach { msg ->
|
||||
messageDao.upsert(
|
||||
val deduped = messageList.removeWrappingDuplicates().splitInlineActions()
|
||||
messageDao.upsertAll(deduped.map { msg ->
|
||||
MessageEntity(
|
||||
id = msg.id,
|
||||
conversationId = msg.conversationId,
|
||||
@@ -572,7 +663,8 @@ class ChatRepositoryImpl(
|
||||
msgType = msg.msgType,
|
||||
timestamp = msg.timestamp,
|
||||
)
|
||||
)
|
||||
})
|
||||
deduped.forEach { msg ->
|
||||
emitMessage(
|
||||
id = msg.id,
|
||||
sessionId = msg.conversationId,
|
||||
@@ -589,7 +681,9 @@ class ChatRepositoryImpl(
|
||||
|
||||
"multi_message" -> {
|
||||
recentParsedContents.clear()
|
||||
wsMsg.multiMessages?.forEach { item ->
|
||||
var isFirst = true
|
||||
wsMsg.multiMessages?.forEachIndexed { index, item ->
|
||||
if (index > 0) delay(1000L)
|
||||
val content = item.content ?: ""
|
||||
recentParsedContents.add(content)
|
||||
emitMessage(
|
||||
@@ -600,7 +694,9 @@ class ChatRepositoryImpl(
|
||||
msgType = item.msgType ?: "chat",
|
||||
timestamp = wsMsg.timestamp ?: System.currentTimeMillis(),
|
||||
isStreaming = false,
|
||||
shouldNotify = isFirst,
|
||||
)
|
||||
isFirst = false
|
||||
}
|
||||
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
|
||||
cleanupWrappingResponse()
|
||||
@@ -616,7 +712,8 @@ class ChatRepositoryImpl(
|
||||
val allContained = recentParsedContents.all { respContent.contains(it) }
|
||||
if (allContained) {
|
||||
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,
|
||||
timestamp: Long = System.currentTimeMillis(),
|
||||
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(
|
||||
id = id,
|
||||
conversationId = sessionId,
|
||||
@@ -639,14 +773,26 @@ class ChatRepositoryImpl(
|
||||
msgType = msgType,
|
||||
timestamp = timestamp,
|
||||
isStreaming = isStreaming,
|
||||
imageDataUris = imageDataUris,
|
||||
)
|
||||
_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)) {
|
||||
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,
|
||||
)
|
||||
|
||||
/**
|
||||
* Split inline `<action>` tags from assistant chat messages into separate messages.
|
||||
* Used for bulk-loaded messages (HTTP history, WS history_response) that bypass emitMessage.
|
||||
*/
|
||||
private fun List<Message>.splitInlineActions(): List<Message> {
|
||||
val actionRegex = Regex("""<action>(.*?)</action>\s*""")
|
||||
return flatMap { msg ->
|
||||
if (msg.role == "assistant" && msg.msgType == "chat") {
|
||||
val match = actionRegex.find(msg.content)
|
||||
if (match != null) {
|
||||
val actionText = match.groupValues[1].trim()
|
||||
val remaining = actionRegex.replaceFirst(msg.content, "").trim()
|
||||
val result = mutableListOf<Message>()
|
||||
if (actionText.isNotEmpty()) {
|
||||
result.add(msg.copy(id = "${msg.id}_action", content = actionText, msgType = "action"))
|
||||
}
|
||||
if (remaining.isNotEmpty()) {
|
||||
result.add(msg.copy(content = remaining))
|
||||
}
|
||||
if (result.isEmpty()) listOf(msg) else result
|
||||
} else {
|
||||
listOf(msg)
|
||||
}
|
||||
} else {
|
||||
listOf(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MessageEntity.toDomain() = Message(
|
||||
id = id,
|
||||
conversationId = conversationId,
|
||||
|
||||
@@ -25,6 +25,7 @@ import top.yeij.cyrene.viewmodel.IoTViewModel
|
||||
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||
import top.yeij.cyrene.viewmodel.ProfileViewModel
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
import top.yeij.cyrene.util.NotificationHelper
|
||||
import top.yeij.cyrene.util.VoiceRecorder
|
||||
import top.yeij.cyrene.voice.stt.BackendSttProvider
|
||||
import top.yeij.cyrene.voice.stt.DashScopeSttService
|
||||
@@ -34,6 +35,9 @@ import top.yeij.cyrene.voice.tts.TextToSpeechEngine
|
||||
|
||||
val appModule = module {
|
||||
|
||||
// Notifications
|
||||
single { NotificationHelper(androidContext()) }
|
||||
|
||||
// DataStore
|
||||
single { PreferencesDataStore(androidContext()) }
|
||||
|
||||
@@ -63,7 +67,7 @@ val appModule = module {
|
||||
|
||||
// Repositories
|
||||
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()) }
|
||||
|
||||
// UseCases
|
||||
@@ -72,7 +76,7 @@ val appModule = module {
|
||||
factory { GetConversationsUseCase(get()) }
|
||||
|
||||
// ViewModels
|
||||
viewModel { ChatViewModel(get(), get()) }
|
||||
viewModel { ChatViewModel(androidContext() as android.app.Application, get(), get(), get(), get()) }
|
||||
viewModel { IoTViewModel(get()) }
|
||||
viewModel { OverlayViewModel(get(), get(), get()) }
|
||||
viewModel { ProfileViewModel(get(), get(), get()) }
|
||||
|
||||
@@ -8,4 +8,5 @@ data class Message(
|
||||
val msgType: String,
|
||||
val timestamp: Long,
|
||||
val isStreaming: Boolean = false,
|
||||
val imageDataUris: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ChatRepository {
|
||||
val connectionError: StateFlow<String?>
|
||||
val isAssistantStreaming: StateFlow<Boolean>
|
||||
val messageClearEvents: Flow<Unit>
|
||||
val messageRemovals: Flow<String>
|
||||
var currentSessionId: String?
|
||||
|
||||
fun getConversations(): Flow<List<Conversation>>
|
||||
@@ -21,7 +22,7 @@ interface ChatRepository {
|
||||
|
||||
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>
|
||||
|
||||
|
||||
@@ -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.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import android.content.res.Configuration
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.core.context.GlobalContext
|
||||
@@ -48,7 +49,7 @@ class CyreneVoiceInteractionSession(context: Context) :
|
||||
private fun resolveViewModel(): OverlayViewModel? {
|
||||
return try {
|
||||
GlobalContext.get().get<OverlayViewModel>()
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e)
|
||||
null
|
||||
}
|
||||
@@ -65,11 +66,40 @@ class CyreneVoiceInteractionSession(context: Context) :
|
||||
|
||||
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||
val vm = overlayViewModel
|
||||
val session = this@CyreneVoiceInteractionSession
|
||||
|
||||
val (darkTheme, themeColorKey) = runBlocking {
|
||||
val prefs = GlobalContext.get().get<PreferencesDataStore>()
|
||||
val mode = prefs.themeMode.firstOrNull()
|
||||
val color = prefs.themeColor.firstOrNull() ?: "pink"
|
||||
val dark = when (mode) {
|
||||
"light" -> false
|
||||
"dark" -> true
|
||||
else -> {
|
||||
val nightMode = session.context.resources.configuration.uiMode and
|
||||
Configuration.UI_MODE_NIGHT_MASK
|
||||
nightMode == Configuration.UI_MODE_NIGHT_YES
|
||||
}
|
||||
}
|
||||
Pair(dark, color)
|
||||
}
|
||||
|
||||
return ComposeView(context).apply {
|
||||
setViewTreeLifecycleOwner(this@CyreneVoiceInteractionSession)
|
||||
setViewTreeSavedStateRegistryOwner(this@CyreneVoiceInteractionSession)
|
||||
// Configure window as soon as view is attached — before system overrides flags
|
||||
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
|
||||
override fun onViewAttachedToWindow(v: View) {
|
||||
session.configureWindow()
|
||||
}
|
||||
override fun onViewDetachedFromWindow(v: View) {}
|
||||
})
|
||||
setViewTreeLifecycleOwner(session)
|
||||
setViewTreeSavedStateRegistryOwner(session)
|
||||
setContent {
|
||||
CyreneTheme {
|
||||
CyreneTheme(
|
||||
darkTheme = darkTheme,
|
||||
presetKey = themeColorKey,
|
||||
useDynamicColor = themeColorKey == "monet",
|
||||
) {
|
||||
if (vm != null) {
|
||||
OverlayContent(
|
||||
onDismiss = { finish() },
|
||||
@@ -92,14 +122,22 @@ class CyreneVoiceInteractionSession(context: Context) :
|
||||
RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}")
|
||||
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()
|
||||
}
|
||||
|
||||
// Only read screen content if user enabled it in settings (default off)
|
||||
val autoScreenContext = try {
|
||||
val prefs: PreferencesDataStore = GlobalContext.get().get()
|
||||
runBlocking { prefs.autoScreenContext.firstOrNull() } ?: false
|
||||
} catch (_: Exception) {
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
if (autoScreenContext) {
|
||||
@@ -111,16 +149,18 @@ class CyreneVoiceInteractionSession(context: Context) :
|
||||
}
|
||||
}
|
||||
|
||||
private fun configureWindow() {
|
||||
fun configureWindow() {
|
||||
try {
|
||||
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
|
||||
method.isAccessible = true
|
||||
val w = method.invoke(this) as? android.view.Window ?: return
|
||||
// Transparent window so the underlying screen is visible through the overlay
|
||||
w.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT))
|
||||
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
|
||||
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
|
||||
w.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
Log.d(TAG, "Window configured: translucent status/nav, adjust nothing for IME")
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Window configured: transparent bg, translucent status/nav")
|
||||
} catch (e: Throwable) {
|
||||
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.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.dto.WSAttachment
|
||||
import top.yeij.cyrene.data.remote.dto.WSClientMessage
|
||||
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
@@ -47,6 +50,8 @@ class WebSocketService(
|
||||
private var shouldReconnect = true
|
||||
private var currentSessionId: String? = null
|
||||
private val connectionId = AtomicInteger(0)
|
||||
@Volatile private var lastMessageReceived = System.currentTimeMillis()
|
||||
private val deadConnectionTimeoutMs = 30_000L // No message for 30s = treat as dead
|
||||
|
||||
private var clientId: String = ""
|
||||
private var deviceName: String = ""
|
||||
@@ -132,12 +137,16 @@ class WebSocketService(
|
||||
_isConnected.value = true
|
||||
_connectionError.value = null
|
||||
startHeartbeat()
|
||||
RuntimeLog.ws("lifecycle", "WS connected #$connId")
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (connectionId.get() != connId) return
|
||||
lastMessageReceived = System.currentTimeMillis()
|
||||
try {
|
||||
val msg = gson.fromJson(text, WSServerMessage::class.java)
|
||||
val preview = text.take(100).replace("\n", "\\n")
|
||||
RuntimeLog.ws("receive", "type=${msg.type} id=${msg.messageId ?: "-"} preview=$preview")
|
||||
_incomingMessages.tryEmit(msg)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "[#$connId] Failed to parse message: ${e.message}")
|
||||
@@ -149,6 +158,7 @@ class WebSocketService(
|
||||
Log.i(TAG, "[#$connId] Server closing: code=$code reason=$reason")
|
||||
_isConnected.value = false
|
||||
cancelHeartbeat()
|
||||
RuntimeLog.ws("lifecycle", "WS closing #$connId code=$code reason='$reason'")
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
@@ -157,6 +167,7 @@ class WebSocketService(
|
||||
_isConnected.value = false
|
||||
cancelHeartbeat()
|
||||
scheduleReconnect()
|
||||
RuntimeLog.ws("lifecycle", "WS closed #$connId code=$code")
|
||||
}
|
||||
|
||||
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)
|
||||
_isConnected.value = false
|
||||
cancelHeartbeat()
|
||||
RuntimeLog.ws("lifecycle", "WS failure #$connId http=$httpCode error='${t.message}'")
|
||||
|
||||
val errorMsg = when (httpCode) {
|
||||
403 -> {
|
||||
@@ -177,7 +189,8 @@ class WebSocketService(
|
||||
if (errorMsg != null) {
|
||||
_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,
|
||||
mode: String? = null,
|
||||
content: String? = null,
|
||||
attachments: List<WSAttachment>? = null,
|
||||
): WSClientMessage = WSClientMessage(
|
||||
type = type,
|
||||
sessionId = sessionId ?: currentSessionId,
|
||||
mode = mode,
|
||||
content = content,
|
||||
attachments = attachments,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
clientId = clientId.ifBlank { null },
|
||||
deviceName = deviceName.ifBlank { null },
|
||||
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
|
||||
)
|
||||
|
||||
fun sendMessage(content: String, sessionId: String? = null, mode: String = "text") {
|
||||
val msg = buildMessage("message", sessionId, mode, content)
|
||||
fun sendMessage(content: String, sessionId: String? = null, mode: String = "text", attachments: List<WSAttachment>? = null) {
|
||||
val msg = buildMessage("message", sessionId, mode, content, attachments = attachments)
|
||||
webSocket?.send(gson.toJson(msg))
|
||||
}
|
||||
|
||||
@@ -240,19 +255,24 @@ class WebSocketService(
|
||||
}
|
||||
|
||||
fun forceReconnect() {
|
||||
RuntimeLog.ws("lifecycle", "forceReconnect called")
|
||||
shouldReconnect = true
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
scope.launch {
|
||||
if (!_isConnected.value) {
|
||||
try {
|
||||
// Close existing socket directly without resetting shouldReconnect
|
||||
cancelHeartbeat()
|
||||
webSocket?.close(1000, "Reconnecting")
|
||||
webSocket = null
|
||||
_isConnected.value = false
|
||||
connect(currentSessionId)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
RuntimeLog.ws("lifecycle", "WS disconnect — user requested")
|
||||
shouldReconnect = false
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
@@ -266,9 +286,15 @@ class WebSocketService(
|
||||
cancelHeartbeat()
|
||||
heartbeatJob = scope.launch {
|
||||
while (_isConnected.value) {
|
||||
delay(30_000)
|
||||
if (_isConnected.value) {
|
||||
delay(15_000)
|
||||
if (!_isConnected.value) break
|
||||
sendPing()
|
||||
// Check if connection is silently dead (no message received in 60s)
|
||||
val sinceLastMsg = System.currentTimeMillis() - lastMessageReceived
|
||||
if (sinceLastMsg > deadConnectionTimeoutMs) {
|
||||
Log.w(TAG, "No message received for ${sinceLastMsg}ms — connection may be dead, forcing reconnect")
|
||||
forceReconnect()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
package top.yeij.cyrene.ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -25,15 +38,444 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
// --- Markdown block model ---
|
||||
|
||||
private sealed class MdBlock {
|
||||
data class Heading(val level: Int, val text: String) : MdBlock()
|
||||
data class Paragraph(val text: String) : MdBlock()
|
||||
data class CodeBlock(val language: String?, val code: String) : MdBlock()
|
||||
data class ListItem(val ordered: Boolean, val index: Int, val text: String) : MdBlock()
|
||||
data class Quote(val text: String) : MdBlock()
|
||||
class ThematicBreak : MdBlock()
|
||||
}
|
||||
|
||||
private fun parseMarkdownBlocks(text: String): List<MdBlock> {
|
||||
val lines = text.lines()
|
||||
val blocks = mutableListOf<MdBlock>()
|
||||
var i = 0
|
||||
|
||||
while (i < lines.size) {
|
||||
val line = lines[i]
|
||||
val trimmed = line.trimStart()
|
||||
|
||||
when {
|
||||
// Fenced code block
|
||||
trimmed.startsWith("```") -> {
|
||||
val lang = trimmed.removePrefix("```").trim().ifBlank { null }
|
||||
val codeLines = mutableListOf<String>()
|
||||
i++
|
||||
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
|
||||
codeLines.add(lines[i])
|
||||
i++
|
||||
}
|
||||
if (i < lines.size) i++ // skip closing ```
|
||||
blocks.add(MdBlock.CodeBlock(lang, codeLines.joinToString("\n")))
|
||||
}
|
||||
// Heading
|
||||
trimmed.startsWith("#") -> {
|
||||
val match = Regex("^(#{1,6})\\s+(.+)$").find(trimmed)
|
||||
if (match != null) {
|
||||
val level = match.groupValues[1].length
|
||||
blocks.add(MdBlock.Heading(level, match.groupValues[2]))
|
||||
}
|
||||
i++
|
||||
}
|
||||
// Thematic break
|
||||
trimmed.matches(Regex("^[-*_]{3,}$")) -> {
|
||||
blocks.add(MdBlock.ThematicBreak())
|
||||
i++
|
||||
}
|
||||
// Blockquote
|
||||
line.startsWith(">") || trimmed.startsWith(">") -> {
|
||||
val quoteLines = mutableListOf<String>()
|
||||
while (i < lines.size) {
|
||||
val cur = lines[i]
|
||||
if (cur.trimStart().startsWith(">")) {
|
||||
quoteLines.add(cur.trimStart().removePrefix(">").trimStart())
|
||||
i++
|
||||
} else if (cur.isBlank()) {
|
||||
i++
|
||||
break
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (quoteLines.isNotEmpty()) {
|
||||
blocks.add(MdBlock.Quote(quoteLines.joinToString("\n")))
|
||||
}
|
||||
}
|
||||
// Unordered list
|
||||
trimmed.matches(Regex("^[-*+]\\s+.*$")) -> {
|
||||
while (i < lines.size && lines[i].trimStart().matches(Regex("^[-*+]\\s+.*$"))) {
|
||||
val itemText = lines[i].trimStart().replaceFirst(Regex("^[-*+]\\s+"), "")
|
||||
blocks.add(MdBlock.ListItem(false, blocks.size + 1, itemText))
|
||||
i++
|
||||
}
|
||||
}
|
||||
// Ordered list
|
||||
trimmed.matches(Regex("^\\d+\\.\\s+.*$")) -> {
|
||||
var idx = 1
|
||||
while (i < lines.size && lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*$"))) {
|
||||
val itemText = lines[i].trimStart().replaceFirst(Regex("^\\d+\\.\\s+"), "")
|
||||
blocks.add(MdBlock.ListItem(true, idx, itemText))
|
||||
idx++
|
||||
i++
|
||||
}
|
||||
}
|
||||
// Blank line — skip
|
||||
line.isBlank() -> { i++ }
|
||||
// Paragraph
|
||||
else -> {
|
||||
val paraLines = mutableListOf<String>()
|
||||
while (i < lines.size &&
|
||||
lines[i].isNotBlank() &&
|
||||
!lines[i].trimStart().startsWith("```") &&
|
||||
!lines[i].trimStart().startsWith("#") &&
|
||||
!lines[i].trimStart().matches(Regex("^[-*_]{3,}$")) &&
|
||||
!lines[i].trimStart().startsWith(">") &&
|
||||
!lines[i].trimStart().matches(Regex("^[-*+]\\s+.*$")) &&
|
||||
!lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*$"))
|
||||
) {
|
||||
paraLines.add(lines[i])
|
||||
i++
|
||||
}
|
||||
if (paraLines.isNotEmpty()) {
|
||||
blocks.add(MdBlock.Paragraph(paraLines.joinToString(" ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun renderInlineMarkdown(text: String): AnnotatedString {
|
||||
return buildAnnotatedString {
|
||||
var remaining = text
|
||||
while (remaining.isNotEmpty()) {
|
||||
// Bold + Italic ***
|
||||
val boldItalic = Regex("""\*\*\*(.+?)\*\*\*""").find(remaining)
|
||||
// Bold **
|
||||
val bold = Regex("""\*\*(.+?)\*\*""").find(remaining)
|
||||
// Italic *
|
||||
val italic = Regex("""(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)""").find(remaining)
|
||||
// Inline code `
|
||||
val code = Regex("""`([^`]+)`""").find(remaining)
|
||||
// Link [text](url)
|
||||
val link = Regex("""\[([^\]]+)\]\(([^)]+)\)""").find(remaining)
|
||||
|
||||
val matches = listOfNotNull(
|
||||
boldItalic?.let { "bi" to it },
|
||||
bold?.let { "b" to it },
|
||||
italic?.let { "i" to it },
|
||||
code?.let { "c" to it },
|
||||
link?.let { "l" to it },
|
||||
).sortedBy { it.second.range.first }
|
||||
|
||||
if (matches.isEmpty()) {
|
||||
append(remaining)
|
||||
remaining = ""
|
||||
} else {
|
||||
val (kind, match) = matches.first()
|
||||
// Append text before the match
|
||||
if (match.range.first > 0) {
|
||||
append(remaining.substring(0, match.range.first))
|
||||
}
|
||||
when (kind) {
|
||||
"bi" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
|
||||
append(match.groupValues[1])
|
||||
}
|
||||
"b" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(match.groupValues[1])
|
||||
}
|
||||
"i" -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
append(match.groupValues[1])
|
||||
}
|
||||
"c" -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = Color.Gray.copy(alpha = 0.2f))) {
|
||||
append(match.groupValues[1])
|
||||
}
|
||||
"l" -> {
|
||||
val label = match.groupValues[1]
|
||||
val url = match.groupValues[2]
|
||||
pushStringAnnotation("url", url)
|
||||
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline)) {
|
||||
append(label)
|
||||
}
|
||||
pop()
|
||||
}
|
||||
}
|
||||
remaining = remaining.substring(match.range.last + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Markdown bubble ---
|
||||
|
||||
@Composable
|
||||
private fun MarkdownBubble(content: String, modifier: Modifier = Modifier) {
|
||||
val blocks = remember(content) { parseMarkdownBlocks(content) }
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f),
|
||||
shadowElevation = 1.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
blocks.forEach { block ->
|
||||
when (block) {
|
||||
is MdBlock.Heading -> {
|
||||
val fontSize = when (block.level) {
|
||||
1 -> 22.sp
|
||||
2 -> 19.sp
|
||||
3 -> 17.sp
|
||||
4 -> 15.sp
|
||||
5 -> 14.sp
|
||||
else -> 13.sp
|
||||
}
|
||||
Text(
|
||||
text = renderInlineMarkdown(block.text),
|
||||
fontSize = fontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = if (block.level <= 2) 6.dp else 2.dp),
|
||||
)
|
||||
}
|
||||
is MdBlock.Paragraph -> {
|
||||
if (block.text.isNotBlank()) {
|
||||
Text(
|
||||
text = renderInlineMarkdown(block.text),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
is MdBlock.CodeBlock -> {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = Color(0xFF1E1E1E),
|
||||
) {
|
||||
Column {
|
||||
if (block.language != null) {
|
||||
Text(
|
||||
text = block.language,
|
||||
modifier = Modifier
|
||||
.background(Color(0xFF333333))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
color = Color(0xFFCCCCCC),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = block.code,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
color = Color(0xFFD4D4D4),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is MdBlock.ListItem -> {
|
||||
val prefix = if (block.ordered) "${block.index}. " else "• "
|
||||
Row(modifier = Modifier.padding(start = 8.dp, top = 2.dp)) {
|
||||
Text(
|
||||
text = prefix,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = renderInlineMarkdown(block.text),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
is MdBlock.Quote -> {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
) {
|
||||
Row {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.5f))
|
||||
.weight(0.012f)
|
||||
) {}
|
||||
Text(
|
||||
text = renderInlineMarkdown(block.text),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is MdBlock.ThematicBreak -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
)
|
||||
.weight(1f)
|
||||
) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Code bubble (standalone code block message) ---
|
||||
|
||||
private val codeDarkBg = Color(0xFF1E1E1E)
|
||||
private val codeSurface = Color(0xFF333333)
|
||||
|
||||
@Composable
|
||||
private fun CodeBubble(content: String, modifier: Modifier = Modifier) {
|
||||
val (language, code) = remember(content) {
|
||||
if (content.startsWith("[lang:")) {
|
||||
val endBracket = content.indexOf("]\n")
|
||||
if (endBracket > 0) {
|
||||
content.substring(6, endBracket) to content.substring(endBracket + 2)
|
||||
} else "Code" to content
|
||||
} else "Code" to content
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = codeDarkBg,
|
||||
shadowElevation = 2.dp,
|
||||
) {
|
||||
Column {
|
||||
// Language header
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(codeSurface, MaterialTheme.shapes.medium)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = language,
|
||||
color = Color(0xFFAAAAAA),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Code content
|
||||
Text(
|
||||
text = code,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
color = Color(0xFFD4D4D4),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Collapsible wrapper for non-chat content ---
|
||||
|
||||
private const val COLLAPSE_THRESHOLD = 300
|
||||
|
||||
@Composable
|
||||
private fun CollapsibleBubble(
|
||||
content: String,
|
||||
modifier: Modifier = Modifier,
|
||||
bubble: @Composable (String, Modifier) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val lines = content.lines()
|
||||
val isLong = content.length > COLLAPSE_THRESHOLD || lines.size > 8
|
||||
|
||||
if (!isLong) {
|
||||
bubble(content, modifier)
|
||||
return
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
if (expanded) {
|
||||
bubble(content, Modifier)
|
||||
} else {
|
||||
val truncated = lines.take(5).joinToString("\n").let {
|
||||
if (it.length >= content.length) it else it + "\n…"
|
||||
}
|
||||
bubble(truncated, Modifier)
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = { expanded = !expanded },
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "折叠" else "展开",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main ChatBubble dispatcher ---
|
||||
|
||||
@Composable
|
||||
fun ChatBubble(
|
||||
content: String,
|
||||
@@ -41,20 +483,83 @@ fun ChatBubble(
|
||||
msgType: String,
|
||||
timestamp: Long,
|
||||
modifier: Modifier = Modifier,
|
||||
imageDataUris: List<String> = emptyList(),
|
||||
) {
|
||||
val isUser = role == "user"
|
||||
val formattedTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
|
||||
|
||||
when (msgType) {
|
||||
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier)
|
||||
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier, imageDataUris)
|
||||
"action" -> ActionMessage(content, modifier)
|
||||
"thinking" -> ThinkingBubble(content, modifier)
|
||||
"tool_progress" -> ToolProgressBubble(content, modifier)
|
||||
"markdown" -> CollapsibleBubble(content, modifier) { text, mod ->
|
||||
MarkdownBubble(text, mod)
|
||||
}
|
||||
"code" -> CollapsibleBubble(content, modifier) { text, mod ->
|
||||
CodeBubble(text, mod)
|
||||
}
|
||||
"thinking" -> CollapsibleBubble(content, modifier) { text, mod ->
|
||||
ThinkingBubble(text, mod)
|
||||
}
|
||||
"tool_progress" -> CollapsibleBubble(content, modifier) { text, mod ->
|
||||
ToolProgressBubble(text, mod)
|
||||
}
|
||||
"system_info" -> SystemInfoBubble(content, modifier)
|
||||
else -> ChatMessageBubble(content, isUser, formattedTime, modifier)
|
||||
else -> ChatMessageBubble(content, isUser, formattedTime, modifier, imageDataUris)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Full-screen image preview dialog ---
|
||||
|
||||
@Composable
|
||||
private fun ImagePreviewDialog(
|
||||
imageUri: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.95f))
|
||||
.clickable { onDismiss() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(imageUri)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "图片预览",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "关闭",
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Chat message bubble ---
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ChatMessageBubble(
|
||||
@@ -62,9 +567,13 @@ private fun ChatMessageBubble(
|
||||
isUser: Boolean,
|
||||
time: String,
|
||||
modifier: Modifier = Modifier,
|
||||
imageDataUris: List<String> = emptyList(),
|
||||
) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var previewImageUri by remember { mutableStateOf<String?>(null) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
val hasImages = imageDataUris.isNotEmpty()
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
@@ -90,15 +599,41 @@ private fun ChatMessageBubble(
|
||||
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 = content,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
text = renderInlineMarkdown(content),
|
||||
modifier = Modifier.padding(
|
||||
start = 12.dp, end = 12.dp,
|
||||
top = if (hasImages) 6.dp else 12.dp,
|
||||
bottom = 12.dp,
|
||||
),
|
||||
color = if (isUser)
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
@@ -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
|
||||
private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
||||
val displayText = remember(content) {
|
||||
content.replace(actionTagRegex, "").trim()
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
@@ -134,9 +684,9 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
Text(
|
||||
text = content,
|
||||
text = displayText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
|
||||
fontStyle = FontStyle.Italic,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Start,
|
||||
@@ -144,6 +694,8 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Thinking bubble ---
|
||||
|
||||
@Composable
|
||||
private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
@@ -164,6 +716,8 @@ private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tool progress bubble ---
|
||||
|
||||
@Composable
|
||||
private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
@@ -186,19 +740,46 @@ private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- System info bubble ---
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Box {
|
||||
Text(
|
||||
text = content,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = { showMenu = true },
|
||||
),
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("复制") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
showMenu = false
|
||||
clipboardManager.setText(AnnotatedString(content))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,15 +49,15 @@ fun StatusIndicator(
|
||||
modifier = Modifier.size(8.dp),
|
||||
tint = Color(0xFF4CAF50),
|
||||
)
|
||||
Text("昔涟", style = MaterialTheme.typography.labelLarge)
|
||||
Text("昔涟", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
CyreneStatus.THINKING -> {
|
||||
PulsingDot(Color(0xFFFFA726))
|
||||
Text("思考中…", style = MaterialTheme.typography.labelLarge)
|
||||
Text("思考中…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
CyreneStatus.SPEAKING -> {
|
||||
PulsingDot(Color(0xFF42A5F5))
|
||||
Text("正在说话…", style = MaterialTheme.typography.labelLarge)
|
||||
Text("正在说话…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
CyreneStatus.OFFLINE -> {
|
||||
Icon(
|
||||
@@ -66,7 +66,7 @@ fun StatusIndicator(
|
||||
modifier = Modifier.size(8.dp),
|
||||
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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
@@ -16,11 +17,14 @@ import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
@@ -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.about.AboutScreen
|
||||
import top.yeij.cyrene.ui.screens.profile.ProfileScreen
|
||||
import top.yeij.cyrene.ui.screens.settings.KeepAlivePage
|
||||
import top.yeij.cyrene.ui.screens.settings.SettingsScreen
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
|
||||
object Routes {
|
||||
const val LOGIN = "login"
|
||||
@@ -38,6 +44,7 @@ object Routes {
|
||||
const val IOT = "iot"
|
||||
const val SETTINGS = "settings"
|
||||
const val ABOUT = "about"
|
||||
const val KEEP_ALIVE = "keep_alive"
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -47,6 +54,19 @@ fun CyreneNavGraph(
|
||||
isDefaultAssistant: Boolean,
|
||||
onOpenAssistantSettings: () -> Unit,
|
||||
) {
|
||||
// After process death, the NavController may restore a stale back stack
|
||||
// (e.g. showing SETTINGS instead of MAIN). Reset to the intended start.
|
||||
LaunchedEffect(Unit) {
|
||||
val entries = navController.currentBackStack.value
|
||||
val currentRoute = navController.currentDestination?.route
|
||||
RuntimeLog.general("nav", "NavGraph start — currentRoute=$currentRoute backStackSize=${entries.size}")
|
||||
if (entries.size > 1 && entries.first().destination.route != startDestination) {
|
||||
RuntimeLog.general("nav", "Resetting stale back stack to $startDestination")
|
||||
navController.popBackStack(startDestination, inclusive = true)
|
||||
navController.navigate(startDestination)
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
@@ -71,13 +91,32 @@ fun CyreneNavGraph(
|
||||
|
||||
composable(Routes.SETTINGS) {
|
||||
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) {
|
||||
AboutScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onBack = {
|
||||
if (navController.currentDestination?.route == Routes.ABOUT) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -124,7 +163,10 @@ fun MainScreen(
|
||||
items.forEachIndexed { index, item ->
|
||||
NavigationRailItem(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
onClick = {
|
||||
selectedTab = index
|
||||
RuntimeLog.general("nav", "Tab switched to ${item.label} (index=$index)")
|
||||
},
|
||||
icon = item.icon,
|
||||
label = { Text(item.label) },
|
||||
)
|
||||
@@ -135,12 +177,31 @@ fun MainScreen(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clipToBounds()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
when (selectedTab) {
|
||||
0 -> ChatScreen()
|
||||
1 -> IoTScreen()
|
||||
2 -> ProfileScreen(
|
||||
// Keep all tabs alive by offsetting hidden ones off-screen.
|
||||
// clipToBounds ensures they don't intercept touches outside the visible area.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.offset(x = if (selectedTab == 0) 0.dp else 2000.dp),
|
||||
) {
|
||||
ChatScreen()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.offset(x = if (selectedTab == 1) 0.dp else 2000.dp),
|
||||
) {
|
||||
IoTScreen()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.offset(x = if (selectedTab == 2) 0.dp else 2000.dp),
|
||||
) {
|
||||
ProfileScreen(
|
||||
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
|
||||
onNavigateToAbout = { navController.navigate(Routes.ABOUT) },
|
||||
onLogout = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package top.yeij.cyrene.ui.overlay
|
||||
|
||||
import android.content.res.Configuration
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
@@ -17,11 +18,12 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -41,9 +43,11 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -54,6 +58,8 @@ import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -65,6 +71,7 @@ import top.yeij.cyrene.ui.components.TypingIndicator
|
||||
import top.yeij.cyrene.util.RecordState
|
||||
import top.yeij.cyrene.viewmodel.OverlayState
|
||||
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
@@ -90,6 +97,7 @@ private fun AnimatedChatBubble(
|
||||
role = message.role,
|
||||
msgType = message.msgType,
|
||||
timestamp = message.timestamp,
|
||||
imageDataUris = message.imageDataUris,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -99,6 +107,7 @@ fun OverlayContent(
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToMain: () -> Unit,
|
||||
viewModel: OverlayViewModel = koinInject(),
|
||||
settingsViewModel: SettingsViewModel = koinInject(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val messages by viewModel.messages.collectAsState()
|
||||
@@ -106,6 +115,8 @@ fun OverlayContent(
|
||||
val recordState by viewModel.voiceRecordState.collectAsState()
|
||||
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
||||
val animIndex by viewModel.messageAnimIndex.collectAsState()
|
||||
val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
|
||||
val enterToSend by settingsViewModel.enterToSend.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
val isProcessing = state == OverlayState.PROCESSING
|
||||
val recordSec = recordDurationMs / 1000f
|
||||
@@ -184,10 +195,13 @@ fun OverlayContent(
|
||||
isRecording = isRecording,
|
||||
isLocked = isLocked,
|
||||
typingDots = typingDots.value,
|
||||
typingIndicatorStyle = typingIndicatorStyle,
|
||||
enterToSend = enterToSend,
|
||||
animIndex = animIndex,
|
||||
onDismiss = onDismiss,
|
||||
onNavigateToMain = onNavigateToMain,
|
||||
viewModel = viewModel,
|
||||
navBarHeightPx = navBarHeight,
|
||||
)
|
||||
} else {
|
||||
PortraitContent(
|
||||
@@ -200,10 +214,13 @@ fun OverlayContent(
|
||||
isRecording = isRecording,
|
||||
isLocked = isLocked,
|
||||
typingDots = typingDots.value,
|
||||
typingIndicatorStyle = typingIndicatorStyle,
|
||||
enterToSend = enterToSend,
|
||||
animIndex = animIndex,
|
||||
onDismiss = onDismiss,
|
||||
onNavigateToMain = onNavigateToMain,
|
||||
viewModel = viewModel,
|
||||
navBarHeightPx = navBarHeight,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -222,10 +239,13 @@ private fun PortraitContent(
|
||||
isRecording: Boolean,
|
||||
isLocked: Boolean,
|
||||
typingDots: String,
|
||||
typingIndicatorStyle: String,
|
||||
enterToSend: Boolean,
|
||||
animIndex: Map<String, Int>,
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToMain: () -> Unit,
|
||||
viewModel: OverlayViewModel,
|
||||
navBarHeightPx: Int,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -253,7 +273,7 @@ private fun PortraitContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isProcessing) {
|
||||
if (isProcessing && typingIndicatorStyle != "text") {
|
||||
item(key = "typing_indicator") {
|
||||
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(
|
||||
state = state,
|
||||
inputText = inputText,
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.imePadding(),
|
||||
.fillMaxWidth(),
|
||||
recordSec = recordSec,
|
||||
isRecording = isRecording,
|
||||
isLocked = isLocked,
|
||||
typingDots = typingDots,
|
||||
typingIndicatorStyle = typingIndicatorStyle,
|
||||
enterToSend = enterToSend,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -290,10 +311,13 @@ private fun LandscapeContent(
|
||||
isRecording: Boolean,
|
||||
isLocked: Boolean,
|
||||
typingDots: String,
|
||||
typingIndicatorStyle: String,
|
||||
enterToSend: Boolean,
|
||||
animIndex: Map<String, Int>,
|
||||
onDismiss: () -> Unit,
|
||||
onNavigateToMain: () -> Unit,
|
||||
viewModel: OverlayViewModel,
|
||||
navBarHeightPx: Int,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -320,7 +344,7 @@ private fun LandscapeContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isProcessing) {
|
||||
if (isProcessing && typingIndicatorStyle != "text") {
|
||||
item(key = "typing_indicator") {
|
||||
TypingIndicator()
|
||||
}
|
||||
@@ -346,12 +370,13 @@ private fun LandscapeContent(
|
||||
inputText = inputText,
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.imePadding(),
|
||||
.fillMaxWidth(),
|
||||
recordSec = recordSec,
|
||||
isRecording = isRecording,
|
||||
isLocked = isLocked,
|
||||
typingDots = typingDots,
|
||||
typingIndicatorStyle = typingIndicatorStyle,
|
||||
enterToSend = enterToSend,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -393,6 +418,8 @@ private fun InputArea(
|
||||
isRecording: Boolean = false,
|
||||
isLocked: Boolean = false,
|
||||
typingDots: String = "",
|
||||
typingIndicatorStyle: String = "bubble",
|
||||
enterToSend: Boolean = false,
|
||||
) {
|
||||
// Gesture tracking state — local to InputArea
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
@@ -402,19 +429,13 @@ private fun InputArea(
|
||||
val inLockZone = isDragging && dragOffsetX > 60f
|
||||
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(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
// "昔涟正在输入..." indicator
|
||||
if (isProcessing && typingDots.isNotEmpty()) {
|
||||
// "昔涟正在输入..." indicator (text mode only)
|
||||
if (isProcessing && typingDots.isNotEmpty() && typingIndicatorStyle == "text") {
|
||||
Text(
|
||||
text = "昔涟正在输入$typingDots",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -509,7 +530,23 @@ private fun InputArea(
|
||||
placeholder = { Text("输入消息...") },
|
||||
modifier = Modifier.weight(1f),
|
||||
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))
|
||||
@@ -597,4 +634,3 @@ private fun InputArea(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +1,91 @@
|
||||
package top.yeij.cyrene.ui.screens.chat
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AddPhotoAlternate
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardVoice
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import kotlinx.coroutines.delay
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.ui.components.ChatBubble
|
||||
import top.yeij.cyrene.ui.components.CyreneStatus
|
||||
import top.yeij.cyrene.ui.components.StatusIndicator
|
||||
import top.yeij.cyrene.ui.components.TypingIndicator
|
||||
import top.yeij.cyrene.util.RecordState
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import top.yeij.cyrene.viewmodel.ChatViewModel
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
import kotlin.math.min
|
||||
|
||||
|
||||
@Composable
|
||||
private fun AnimatedChatBubble(
|
||||
message: Message,
|
||||
@@ -74,9 +98,9 @@ private fun AnimatedChatBubble(
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) +
|
||||
enter = fadeIn(animationSpec = tween(300)) +
|
||||
slideInVertically(
|
||||
animationSpec = androidx.compose.animation.core.tween(300),
|
||||
animationSpec = tween(300),
|
||||
initialOffsetY = { it / 4 },
|
||||
),
|
||||
) {
|
||||
@@ -85,15 +109,20 @@ private fun AnimatedChatBubble(
|
||||
role = message.role,
|
||||
msgType = message.msgType,
|
||||
timestamp = message.timestamp,
|
||||
imageDataUris = message.imageDataUris,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
viewModel: ChatViewModel = koinViewModel(),
|
||||
settingsViewModel: SettingsViewModel = koinInject(),
|
||||
) {
|
||||
// Track composition to diagnose navigation-related issues
|
||||
LaunchedEffect(Unit) {
|
||||
RuntimeLog.general("chat", "ChatScreen composed, ChatViewModel instance resolved")
|
||||
}
|
||||
val messages by viewModel.currentMessages.collectAsState()
|
||||
val inputText by viewModel.inputText.collectAsState()
|
||||
val isStreaming by viewModel.isStreaming.collectAsState()
|
||||
@@ -102,8 +131,21 @@ fun ChatScreen(
|
||||
val recordState by viewModel.voiceRecordState.collectAsState()
|
||||
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
|
||||
val animIndex by viewModel.messageAnimIndex.collectAsState()
|
||||
val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
|
||||
val enterToSend by settingsViewModel.enterToSend.collectAsState()
|
||||
|
||||
// reverseLayout: index 0 = newest (visual bottom), index N-1 = oldest (visual top)
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Track whether user is near the latest messages (visual bottom = index 0)
|
||||
val isNearBottom by remember {
|
||||
derivedStateOf {
|
||||
val info = listState.layoutInfo
|
||||
if (info.totalItemsCount == 0) return@derivedStateOf true
|
||||
(info.visibleItemsInfo.firstOrNull()?.index ?: 0) <= 2
|
||||
}
|
||||
}
|
||||
|
||||
// Gesture tracking state
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
var dragOffsetX by remember { mutableStateOf(0f) }
|
||||
@@ -116,10 +158,24 @@ fun ChatScreen(
|
||||
val inCancelZone = isDragging && dragOffsetY < -120f
|
||||
val inLockZone = isDragging && dragOffsetX > 60f
|
||||
|
||||
LaunchedEffect(messages.size, isStreaming) {
|
||||
if (messages.isNotEmpty()) {
|
||||
val targetIndex = if (isStreaming) messages.size else messages.size - 1
|
||||
listState.animateScrollToItem(targetIndex)
|
||||
// Image picker
|
||||
val selectedImages by viewModel.selectedImageUris.collectAsState()
|
||||
val imagePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetMultipleContents()
|
||||
) { uris: List<Uri> ->
|
||||
if (uris.isNotEmpty()) {
|
||||
viewModel.addImages(uris)
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
|
||||
// Stay at bottom for new messages unless user scrolled up
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { messages.size to isNearBottom }
|
||||
.collect { (_, nearBottom) ->
|
||||
if (nearBottom && listState.firstVisibleItemIndex != 0) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,8 +201,14 @@ fun ChatScreen(
|
||||
else -> CyreneStatus.OFFLINE
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
// Single column layout: everything flows together and IME shrinks the whole view
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.imePadding(),
|
||||
) {
|
||||
// Top status bar with refresh button
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -154,16 +216,117 @@ fun ChatScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
StatusIndicator(status = status)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
IconButton(
|
||||
onClick = { viewModel.refreshMessages() },
|
||||
enabled = !isRefreshing,
|
||||
) {
|
||||
if (isRefreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Filled.Refresh,
|
||||
contentDescription = "刷新",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
// "昔涟正在输入..." indicator
|
||||
if (isStreaming) {
|
||||
// "昔涟正在输入..." indicator (text mode only)
|
||||
if (isStreaming && typingIndicatorStyle == "text") {
|
||||
Text(
|
||||
text = "昔涟正在输入${typingDots.value}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -182,7 +345,6 @@ fun ChatScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isRecording && isDragging) {
|
||||
// Recording state with drag — show recording indicator
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
@@ -205,7 +367,6 @@ fun ChatScreen(
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Record button (drag anchor)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
@@ -222,7 +383,6 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
} else if (isLocked) {
|
||||
// Locked (hands-free) mode
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
@@ -254,7 +414,15 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Normal input mode
|
||||
IconButton(
|
||||
onClick = { imagePickerLauncher.launch("image/*") },
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AddPhotoAlternate,
|
||||
contentDescription = "添加图片",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = inputText,
|
||||
onValueChange = { viewModel.onInputChanged(it) },
|
||||
@@ -262,8 +430,19 @@ fun ChatScreen(
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 4,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
keyboardOptions = if (enterToSend) {
|
||||
KeyboardOptions(imeAction = ImeAction.Done)
|
||||
} else {
|
||||
KeyboardOptions.Default
|
||||
},
|
||||
keyboardActions = if (enterToSend) {
|
||||
KeyboardActions(
|
||||
onDone = { if (inputText.isNotBlank()) viewModel.sendMessage() },
|
||||
)
|
||||
} else {
|
||||
KeyboardActions.Default
|
||||
},
|
||||
)
|
||||
// Voice record button with long-press gesture
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 4.dp)
|
||||
@@ -308,7 +487,6 @@ fun ChatScreen(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Send button (only when text present)
|
||||
if (inputText.isNotBlank()) {
|
||||
IconButton(
|
||||
onClick = { viewModel.sendMessage() },
|
||||
@@ -320,47 +498,12 @@ fun ChatScreen(
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { 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,
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "发送",
|
||||
tint = 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
|
||||
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -15,18 +22,24 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.BatterySaver
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.DarkMode
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.filled.LightMode
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.SettingsBrightness
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
@@ -40,18 +53,24 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -60,14 +79,19 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.ui.theme.PresetColorLabels
|
||||
import top.yeij.cyrene.ui.theme.PresetThemeColors
|
||||
import top.yeij.cyrene.util.KeepAliveManager
|
||||
import top.yeij.cyrene.util.LogCategory
|
||||
import top.yeij.cyrene.util.RootKeepAliveHelper
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
onNavigateToKeepAlive: () -> Unit = {},
|
||||
viewModel: SettingsViewModel = koinInject(),
|
||||
) {
|
||||
val baseUrl by viewModel.baseUrl.collectAsState()
|
||||
@@ -77,6 +101,10 @@ fun SettingsScreen(
|
||||
val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState()
|
||||
val dashScopeModel by viewModel.dashScopeModel.collectAsState()
|
||||
val autoScreenContext by viewModel.autoScreenContext.collectAsState()
|
||||
val typingIndicatorStyle by viewModel.typingIndicatorStyle.collectAsState()
|
||||
val themeColor by viewModel.themeColor.collectAsState()
|
||||
val enterToSend by viewModel.enterToSend.collectAsState()
|
||||
val rootKeepAlive by viewModel.rootKeepAlive.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -201,10 +229,345 @@ fun SettingsScreen(
|
||||
},
|
||||
)
|
||||
|
||||
var showColorDialog by remember { mutableStateOf(false) }
|
||||
val currentColorLabel = PresetColorLabels[themeColor] ?: "昔涟粉"
|
||||
|
||||
ListItem(
|
||||
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) },
|
||||
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))
|
||||
@@ -464,10 +827,6 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Runtime logs
|
||||
Text(
|
||||
text = "运行日志",
|
||||
@@ -489,23 +848,20 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
TabRow(selectedTabIndex = selectedTab) {
|
||||
ScrollableTabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
edgePadding = 16.dp,
|
||||
divider = {},
|
||||
) {
|
||||
tabs.forEachIndexed { index, label ->
|
||||
Tab(
|
||||
selected = 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)
|
||||
|
||||
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 = "暂无${tabs[selectedTab]}日志",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -550,16 +913,29 @@ fun SettingsScreen(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
} 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
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.weight(1f, fill = false),
|
||||
.padding(horizontal = 12.dp)
|
||||
.height(280.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
RoundedCornerShape(8.dp),
|
||||
)
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(scrollState),
|
||||
) {
|
||||
filteredLogs.takeLast(500).forEach { entry ->
|
||||
displayLogs.forEach { entry ->
|
||||
Text(
|
||||
text = entry.formatted(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
@@ -1,58 +1,201 @@
|
||||
package top.yeij.cyrene.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
// Light theme
|
||||
val LightPrimary = Color(0xFF6D3BC0)
|
||||
val LightOnPrimary = Color(0xFFFFFFFF)
|
||||
val LightPrimaryContainer = Color(0xFFEEDCFF)
|
||||
val LightOnPrimaryContainer = Color(0xFF250058)
|
||||
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
|
||||
// Each preset provides a seed color and light/dark primary colors
|
||||
data class ThemePreset(
|
||||
val seed: Long,
|
||||
val lightPrimary: Color,
|
||||
val darkPrimary: Color,
|
||||
)
|
||||
|
||||
val PresetThemeColors = mapOf(
|
||||
"pink" to ThemePreset(
|
||||
seed = 0xFFE91E8C,
|
||||
lightPrimary = Color(0xFFC2185B),
|
||||
darkPrimary = Color(0xFFFF80AB),
|
||||
),
|
||||
"sakura" to ThemePreset(
|
||||
seed = 0xFFFFB4C8,
|
||||
lightPrimary = Color(0xFFE91E63),
|
||||
darkPrimary = Color(0xFFFFB4C8),
|
||||
),
|
||||
"lavender" to ThemePreset(
|
||||
seed = 0xFF6D3BC0,
|
||||
lightPrimary = Color(0xFF6D3BC0),
|
||||
darkPrimary = Color(0xFFD3BBFF),
|
||||
),
|
||||
"ocean" to ThemePreset(
|
||||
seed = 0xFF1565C0,
|
||||
lightPrimary = Color(0xFF1565C0),
|
||||
darkPrimary = Color(0xFF90CAF9),
|
||||
),
|
||||
"forest" to ThemePreset(
|
||||
seed = 0xFF2E7D32,
|
||||
lightPrimary = Color(0xFF2E7D32),
|
||||
darkPrimary = Color(0xFFA5D6A7),
|
||||
),
|
||||
"sunset" to ThemePreset(
|
||||
seed = 0xFFE65100,
|
||||
lightPrimary = Color(0xFFE65100),
|
||||
darkPrimary = Color(0xFFFFCC80),
|
||||
),
|
||||
"rose" to ThemePreset(
|
||||
seed = 0xFFD81B60,
|
||||
lightPrimary = Color(0xFFAD1457),
|
||||
darkPrimary = Color(0xFFF48FB1),
|
||||
),
|
||||
"sky" to ThemePreset(
|
||||
seed = 0xFF0277BD,
|
||||
lightPrimary = Color(0xFF0277BD),
|
||||
darkPrimary = Color(0xFF81D4FA),
|
||||
),
|
||||
"mint" to ThemePreset(
|
||||
seed = 0xFF00695C,
|
||||
lightPrimary = Color(0xFF00695C),
|
||||
darkPrimary = Color(0xFF80CBC4),
|
||||
),
|
||||
)
|
||||
|
||||
val PresetColorLabels = mapOf(
|
||||
"pink" to "昔涟粉",
|
||||
"sakura" to "樱花粉",
|
||||
"lavender" to "薰衣草紫",
|
||||
"ocean" to "海洋蓝",
|
||||
"forest" to "森林绿",
|
||||
"sunset" to "日落橙",
|
||||
"rose" to "玫瑰红",
|
||||
"sky" to "天空蓝",
|
||||
"mint" to "薄荷青",
|
||||
)
|
||||
|
||||
fun getPreset(key: String): ThemePreset = PresetThemeColors[key] ?: PresetThemeColors["pink"]!!
|
||||
|
||||
// --- Color derivation via HSL — generates cohesive MD3-like schemes ---
|
||||
|
||||
private data class HSL(val h: Float, val s: Float, val l: Float)
|
||||
|
||||
private fun Color.toHSL(): HSL {
|
||||
val r = red / 255f
|
||||
val g = green / 255f
|
||||
val b = blue / 255f
|
||||
val maxV = max(max(r, g), b)
|
||||
val minV = min(min(r, g), b)
|
||||
val delta = maxV - minV
|
||||
val l = (maxV + minV) / 2f
|
||||
val s = if (delta == 0f) 0f else delta / (1f - abs(2f * l - 1f))
|
||||
val h = when {
|
||||
delta == 0f -> 0f
|
||||
maxV == r -> 60f * (((g - b) / delta) % 6f)
|
||||
maxV == g -> 60f * (((b - r) / delta) + 2f)
|
||||
else -> 60f * (((r - g) / delta) + 4f)
|
||||
}
|
||||
return HSL(if (h < 0) h + 360f else h, s, l)
|
||||
}
|
||||
|
||||
private fun hslToColor(h: Float, s: Float, l: Float): Color {
|
||||
val c = (1f - abs(2f * l - 1f)) * s
|
||||
val x = c * (1f - abs((h / 60f) % 2f - 1f))
|
||||
val m = l - c / 2f
|
||||
val (r, g, b) = when {
|
||||
h < 60f -> Triple(c, x, 0f)
|
||||
h < 120f -> Triple(x, c, 0f)
|
||||
h < 180f -> Triple(0f, c, x)
|
||||
h < 240f -> Triple(0f, x, c)
|
||||
h < 300f -> Triple(x, 0f, c)
|
||||
else -> Triple(c, 0f, x)
|
||||
}
|
||||
return Color((r + m).coerceIn(0f, 1f), (g + m).coerceIn(0f, 1f), (b + m).coerceIn(0f, 1f))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full light ColorScheme derived from a primary color.
|
||||
* All secondary/tertiary/container colors are computed from the primary
|
||||
* so focus rings, ripples, badges, and containers all match the theme.
|
||||
*/
|
||||
fun buildLightScheme(primary: Color): ColorScheme {
|
||||
val hsl = primary.toHSL()
|
||||
|
||||
val primaryContainer = hslToColor(hsl.h, 0.3f, 0.90f)
|
||||
val onPrimaryContainer = hslToColor(hsl.h, 0.5f, 0.15f)
|
||||
|
||||
// Secondary: similar hue, less saturated
|
||||
val secH = (hsl.h + 15f) % 360f
|
||||
val secondary = hslToColor(secH, 0.35f, 0.42f)
|
||||
val secondaryContainer = hslToColor(secH, 0.25f, 0.90f)
|
||||
val onSecondaryContainer = hslToColor(secH, 0.3f, 0.15f)
|
||||
|
||||
// Tertiary: complementary hue shift
|
||||
val terH = (hsl.h + 60f) % 360f
|
||||
val tertiary = hslToColor(terH, 0.40f, 0.38f)
|
||||
val tertiaryContainer = hslToColor(terH, 0.30f, 0.90f)
|
||||
val onTertiaryContainer = hslToColor(terH, 0.35f, 0.15f)
|
||||
|
||||
val onPrimary = Color.White
|
||||
val onSecondary = Color.White
|
||||
val onTertiary = Color.White
|
||||
val surfaceTint = primary
|
||||
|
||||
return lightColorScheme(
|
||||
primary = primary,
|
||||
onPrimary = onPrimary,
|
||||
primaryContainer = primaryContainer,
|
||||
onPrimaryContainer = onPrimaryContainer,
|
||||
secondary = secondary,
|
||||
onSecondary = onSecondary,
|
||||
secondaryContainer = secondaryContainer,
|
||||
onSecondaryContainer = onSecondaryContainer,
|
||||
tertiary = tertiary,
|
||||
onTertiary = onTertiary,
|
||||
tertiaryContainer = tertiaryContainer,
|
||||
onTertiaryContainer = onTertiaryContainer,
|
||||
surfaceTint = surfaceTint,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full dark ColorScheme derived from a primary color.
|
||||
*/
|
||||
fun buildDarkScheme(primary: Color): ColorScheme {
|
||||
val hsl = primary.toHSL()
|
||||
|
||||
val primaryContainer = hslToColor(hsl.h, 0.40f, 0.22f)
|
||||
val onPrimaryContainer = hslToColor(hsl.h, 0.30f, 0.88f)
|
||||
val onPrimary = hslToColor(hsl.h, 0.5f, 0.10f)
|
||||
|
||||
val secH = (hsl.h + 15f) % 360f
|
||||
val secondary = hslToColor(secH, 0.40f, 0.76f)
|
||||
val secondaryContainer = hslToColor(secH, 0.30f, 0.22f)
|
||||
val onSecondaryContainer = hslToColor(secH, 0.30f, 0.88f)
|
||||
val onSecondary = hslToColor(secH, 0.3f, 0.12f)
|
||||
|
||||
val terH = (hsl.h + 60f) % 360f
|
||||
val tertiary = hslToColor(terH, 0.40f, 0.80f)
|
||||
val tertiaryContainer = hslToColor(terH, 0.30f, 0.22f)
|
||||
val onTertiaryContainer = hslToColor(terH, 0.30f, 0.88f)
|
||||
val onTertiary = hslToColor(terH, 0.3f, 0.12f)
|
||||
|
||||
val surfaceTint = primary
|
||||
|
||||
return darkColorScheme(
|
||||
primary = primary,
|
||||
onPrimary = onPrimary,
|
||||
primaryContainer = primaryContainer,
|
||||
onPrimaryContainer = onPrimaryContainer,
|
||||
secondary = secondary,
|
||||
onSecondary = onSecondary,
|
||||
secondaryContainer = secondaryContainer,
|
||||
onSecondaryContainer = onSecondaryContainer,
|
||||
tertiary = tertiary,
|
||||
onTertiary = onTertiary,
|
||||
tertiaryContainer = tertiaryContainer,
|
||||
onTertiaryContainer = onTertiaryContainer,
|
||||
surfaceTint = surfaceTint,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,8 @@ import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
@@ -15,73 +13,29 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
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
|
||||
fun CyreneTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
presetKey: String = "pink",
|
||||
useDynamicColor: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val preset = getPreset(presetKey)
|
||||
|
||||
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
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
darkTheme -> buildDarkScheme(preset.darkPrimary)
|
||||
else -> buildLightScheme(preset.lightPrimary)
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
val window = (view.context as? Activity)?.window
|
||||
if (window != null) {
|
||||
window.statusBarColor = colorScheme.background.toArgb()
|
||||
window.navigationBarColor = colorScheme.background.toArgb()
|
||||
window.decorView.setBackgroundColor(colorScheme.background.toArgb())
|
||||
@@ -89,6 +43,9 @@ fun CyreneTheme(
|
||||
isAppearanceLightStatusBars = !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 androidx.core.app.NotificationCompat
|
||||
import top.yeij.cyrene.MainActivity
|
||||
import top.yeij.cyrene.R
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
|
||||
class NotificationHelper(private val context: Context) {
|
||||
@@ -47,7 +48,7 @@ class NotificationHelper(private val context: Context) {
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("昔涟")
|
||||
.setContentText(preview)
|
||||
.setAutoCancel(true)
|
||||
@@ -55,7 +56,9 @@ class NotificationHelper(private val context: Context) {
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.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() {
|
||||
|
||||
@@ -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("通用"),
|
||||
HTTP("网络"),
|
||||
VOICE("语音"),
|
||||
NOTIFY("通知"),
|
||||
}
|
||||
|
||||
data class LogEntry(
|
||||
@@ -59,6 +60,7 @@ object RuntimeLog {
|
||||
fun general(tag: String, message: String) = log(LogCategory.GENERAL, tag, message)
|
||||
fun http(tag: String, message: String) = log(LogCategory.HTTP, tag, message)
|
||||
fun voice(tag: String, message: String) = log(LogCategory.VOICE, tag, message)
|
||||
fun notify(tag: String, message: String) = log(LogCategory.NOTIFY, tag, message)
|
||||
|
||||
@Synchronized
|
||||
fun getByCategory(category: LogCategory): List<LogEntry> {
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
package top.yeij.cyrene.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.ApiService
|
||||
import top.yeij.cyrene.data.remote.dto.WSAttachment
|
||||
import top.yeij.cyrene.domain.model.Conversation
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
@@ -20,41 +32,22 @@ import top.yeij.cyrene.util.VoiceRecorder
|
||||
|
||||
private fun List<Message>.deduplicate(): List<Message> {
|
||||
if (isEmpty()) return this
|
||||
val result = mutableListOf(this[0])
|
||||
for (i in 1 until size) {
|
||||
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 }
|
||||
val seen = mutableSetOf<String>()
|
||||
return filter { seen.add(it.id) }
|
||||
}
|
||||
|
||||
class ChatViewModel(
|
||||
application: Application,
|
||||
private val chatRepository: ChatRepository,
|
||||
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
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
@@ -91,31 +84,36 @@ class ChatViewModel(
|
||||
|
||||
private var currentSessionId: String? = null
|
||||
private var dbObserverJob: Job? = null
|
||||
private var sendTimeoutJob: Job? = null
|
||||
|
||||
init {
|
||||
// Phase 1: find/create main session, reconnect WS, load server history
|
||||
RuntimeLog.general("app", "ChatViewModel instance #$instanceId created")
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
RuntimeLog.general("app", "ChatViewModel #$instanceId — initializing session...")
|
||||
val sessionId = chatRepository.initializeSession()
|
||||
currentSessionId = sessionId
|
||||
chatRepository.currentSessionId = sessionId
|
||||
RuntimeLog.general("app", "Session initialized: $sessionId")
|
||||
chatRepository.ensureConnected()
|
||||
loadMessagesFromDb(sessionId)
|
||||
val serverMessages = chatRepository.loadMessagesFromServer(sessionId)
|
||||
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 (e: Exception) {
|
||||
RuntimeLog.general("app", "initializeSession failed: ${e.message}")
|
||||
}
|
||||
// Always try to load from DB, even if initializeSession failed.
|
||||
// After process death the persisted session ID gives us the history.
|
||||
val sid = currentSessionId
|
||||
?: preferencesDataStore.currentSessionId.firstOrNull()
|
||||
if (sid != null) {
|
||||
currentSessionId = sid
|
||||
chatRepository.currentSessionId = sid
|
||||
RuntimeLog.general("app", "Loading messages from DB for session=$sid")
|
||||
loadMessagesFromDb(sid)
|
||||
} else {
|
||||
RuntimeLog.general("app", "No session ID available — cannot load messages")
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
// Observe incoming live messages with atomic dedup
|
||||
// Observe incoming live messages — insert at correct descending position
|
||||
viewModelScope.launch {
|
||||
chatRepository.observeMessages().collect { message ->
|
||||
try {
|
||||
@@ -125,18 +123,20 @@ class ChatViewModel(
|
||||
if (existingIdx >= 0) {
|
||||
updated[existingIdx] = message
|
||||
} else {
|
||||
val isDup = updated.any {
|
||||
it.role == message.role && it.content == message.content && it.msgType == message.msgType
|
||||
}
|
||||
if (!isDup) {
|
||||
updated.add(message)
|
||||
// Insert at correct position for descending timestamp (newest first)
|
||||
val insertAt = updated.indexOfFirst { it.timestamp <= message.timestamp }
|
||||
if (insertAt >= 0) updated.add(insertAt, message) else updated.add(message)
|
||||
val idx = _messageAnimIndex.value.toMutableMap()
|
||||
idx[message.id] = animCounter++
|
||||
_messageAnimIndex.value = idx
|
||||
}
|
||||
}
|
||||
updated.deduplicate()
|
||||
}
|
||||
// Any non-user response means the server acknowledged our message
|
||||
if (_isSending.value && message.role != "user") {
|
||||
_isSending.value = false
|
||||
sendTimeoutJob?.cancel()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatViewModel", "Error processing message: ${e.message}", e)
|
||||
}
|
||||
@@ -151,14 +151,27 @@ class ChatViewModel(
|
||||
animCounter = 0
|
||||
}
|
||||
}
|
||||
// Observe message removals (e.g. wrapping stream_end deduped by review items)
|
||||
viewModelScope.launch {
|
||||
chatRepository.messageRemovals.collect { msgId ->
|
||||
_currentMessages.update { list -> list.filter { it.id != msgId } }
|
||||
val idx = _messageAnimIndex.value.toMutableMap()
|
||||
idx.remove(msgId)
|
||||
_messageAnimIndex.value = idx
|
||||
}
|
||||
}
|
||||
// Reset user-side sending state when server starts responding
|
||||
viewModelScope.launch {
|
||||
chatRepository.isAssistantStreaming.collect { streaming ->
|
||||
if (streaming) _isSending.value = false
|
||||
if (streaming) {
|
||||
_isSending.value = false
|
||||
sendTimeoutJob?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// --- Voice recording (WeChat-style gesture) ---
|
||||
|
||||
fun startRecord() {
|
||||
@@ -185,6 +198,113 @@ class ChatViewModel(
|
||||
voiceRecorder.cancel()
|
||||
}
|
||||
|
||||
// --- Image attachments ---
|
||||
|
||||
private val _selectedImageUris = MutableStateFlow<List<Uri>>(emptyList())
|
||||
val selectedImageUris: StateFlow<List<Uri>> = _selectedImageUris.asStateFlow()
|
||||
|
||||
fun addImages(uris: List<Uri>) {
|
||||
_selectedImageUris.update { it + uris }
|
||||
}
|
||||
|
||||
fun removeImage(index: Int) {
|
||||
_selectedImageUris.update { list ->
|
||||
list.filterIndexed { i, _ -> i != index }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearImages() {
|
||||
_selectedImageUris.value = emptyList()
|
||||
}
|
||||
|
||||
private data class UploadResult(
|
||||
val attachment: WSAttachment,
|
||||
val thumbnailUrl: String,
|
||||
)
|
||||
|
||||
private suspend fun uploadAndBuildAttachment(uri: Uri): UploadResult? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val cr = getApplication<Application>().contentResolver
|
||||
val mimeType = cr.getType(uri) ?: "image/jpeg"
|
||||
val filename = uri.lastPathSegment ?: "image"
|
||||
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: return@withContext null
|
||||
if (bytes.isEmpty()) return@withContext null
|
||||
|
||||
// Upload to server, get file_id
|
||||
val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
|
||||
val part = MultipartBody.Part.createFormData("file", filename, requestBody)
|
||||
val response = apiService.uploadFile(part)
|
||||
if (!response.isSuccessful) {
|
||||
Log.e("ChatViewModel", "Upload failed: ${response.code()} ${response.message()}")
|
||||
return@withContext null
|
||||
}
|
||||
val fileId = response.body()?.id ?: return@withContext null
|
||||
|
||||
// Construct thumbnail URL
|
||||
val baseUrl = preferencesDataStore.baseUrl.firstOrNull()
|
||||
?.trimEnd('/') ?: "http://10.0.2.2:8080"
|
||||
val thumbnailUrl = "$baseUrl/api/v1/files/$fileId/thumbnail"
|
||||
|
||||
val attachment = WSAttachment(
|
||||
type = "image",
|
||||
fileId = fileId,
|
||||
thumbnailUrl = thumbnailUrl,
|
||||
filename = filename,
|
||||
size = bytes.size.toLong(),
|
||||
)
|
||||
UploadResult(attachment, thumbnailUrl)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatViewModel", "Failed to upload image: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override sendMessage to support image attachments
|
||||
fun sendMessage() {
|
||||
val text = _inputText.value.trim()
|
||||
val uris = _selectedImageUris.value
|
||||
if (text.isEmpty() && uris.isEmpty()) return
|
||||
val sid = currentSessionId
|
||||
if (sid == null) {
|
||||
RuntimeLog.chat("send", "Cannot send — no current session")
|
||||
return
|
||||
}
|
||||
|
||||
_inputText.value = ""
|
||||
_isSending.value = true
|
||||
|
||||
sendTimeoutJob?.cancel()
|
||||
sendTimeoutJob = viewModelScope.launch {
|
||||
delay(15_000L)
|
||||
if (_isSending.value) {
|
||||
Log.w("ChatViewModel", "Send timeout — no response in 15s, resetting")
|
||||
_isSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
val localUriStrings = uris.map { it.toString() }
|
||||
|
||||
viewModelScope.launch {
|
||||
val results = uris.mapNotNull { uploadAndBuildAttachment(it) }
|
||||
clearImages()
|
||||
val attachments = results.map { it.attachment }
|
||||
val thumbnailUrls = results.map { it.thumbnailUrl }
|
||||
try {
|
||||
chatRepository.sendMessage(
|
||||
text, sid,
|
||||
attachments = attachments.ifEmpty { null },
|
||||
localImageUris = thumbnailUrls.ifEmpty { localUriStrings },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatViewModel", "sendMessage failed: ${e.message}", e)
|
||||
_isSending.value = false
|
||||
sendTimeoutJob?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMessagesFromDb(sessionId: String) {
|
||||
dbObserverJob?.cancel()
|
||||
dbObserverJob = viewModelScope.launch {
|
||||
@@ -194,9 +314,8 @@ class ChatViewModel(
|
||||
val live = current.associateBy { it.id }
|
||||
val db = messages.associateBy { it.id }
|
||||
(db + live).values
|
||||
.sortedBy { it.timestamp }
|
||||
.sortedByDescending { it.timestamp }
|
||||
.deduplicate()
|
||||
.removeWrappingDuplicates()
|
||||
}
|
||||
val idx = _messageAnimIndex.value.toMutableMap()
|
||||
messages.forEach { m ->
|
||||
@@ -206,6 +325,7 @@ class ChatViewModel(
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
currentSessionId = sessionId
|
||||
chatRepository.currentSessionId = sessionId
|
||||
@@ -236,29 +343,23 @@ class ChatViewModel(
|
||||
viewModelScope.launch {
|
||||
chatRepository.connectWebSocket(sessionId)
|
||||
chatRepository.loadMessagesFromServer(sessionId)
|
||||
}
|
||||
loadMessagesFromDb(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshMessages() {
|
||||
val sid = currentSessionId ?: return
|
||||
viewModelScope.launch {
|
||||
_isRefreshing.value = true
|
||||
try {
|
||||
// Clear all messages and let the DB observer rebuild from scratch.
|
||||
// This avoids duplicates that occur when local-UUID messages survive
|
||||
// the merge alongside server-ID versions loaded from HTTP.
|
||||
_currentMessages.value = emptyList()
|
||||
if (!isConnected.value) {
|
||||
chatRepository.ensureConnected()
|
||||
}
|
||||
val serverMessages = 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()
|
||||
}
|
||||
}
|
||||
chatRepository.loadMessagesFromServer(sid)
|
||||
} catch (_: Exception) { }
|
||||
_isRefreshing.value = false
|
||||
}
|
||||
|
||||
@@ -19,17 +19,8 @@ import top.yeij.cyrene.voice.tts.TextToSpeechEngine
|
||||
|
||||
private fun List<Message>.deduplicate(): List<Message> {
|
||||
if (isEmpty()) return this
|
||||
val result = mutableListOf(this[0])
|
||||
for (i in 1 until size) {
|
||||
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
|
||||
val seen = mutableSetOf<String>()
|
||||
return filter { seen.add(it.id) }
|
||||
}
|
||||
|
||||
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
|
||||
@@ -82,6 +73,7 @@ class OverlayViewModel(
|
||||
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
|
||||
|
||||
private var silenceTimer: Job? = null
|
||||
private var processingTimeoutJob: Job? = null
|
||||
private var lastAssistantMessageId: String? = null
|
||||
|
||||
init {
|
||||
@@ -89,23 +81,26 @@ class OverlayViewModel(
|
||||
chatRepository.observeMessages().collect { message ->
|
||||
_messages.update { list ->
|
||||
val updated = list.toMutableList()
|
||||
val idx = updated.indexOfLast { it.id == message.id }
|
||||
if (idx >= 0) {
|
||||
updated[idx] = message
|
||||
val existingIdx = updated.indexOfLast { it.id == message.id }
|
||||
if (existingIdx >= 0) {
|
||||
updated[existingIdx] = message
|
||||
} else {
|
||||
val isDup = updated.any {
|
||||
it.role == message.role && it.content == message.content && it.msgType == message.msgType
|
||||
}
|
||||
if (!isDup) {
|
||||
updated.add(message)
|
||||
// Insert at correct position for ascending timestamp (oldest first for top-down layout)
|
||||
val insertAt = updated.indexOfFirst { it.timestamp >= message.timestamp }
|
||||
if (insertAt >= 0) updated.add(insertAt, message) else updated.add(message)
|
||||
val animIdx = _messageAnimIndex.value.toMutableMap()
|
||||
animIdx[message.id] = animCounter++
|
||||
_messageAnimIndex.value = animIdx
|
||||
}
|
||||
}
|
||||
updated.deduplicate()
|
||||
}
|
||||
|
||||
// Any non-user response means the server acknowledged our message
|
||||
if (_state.value == OverlayState.PROCESSING && message.role != "user") {
|
||||
cancelProcessingTimeout()
|
||||
setWaiting()
|
||||
}
|
||||
|
||||
if (message.role == "assistant" && !message.isStreaming && message.msgType == "chat") {
|
||||
if (message.id != lastAssistantMessageId && message.content.isNotBlank()) {
|
||||
lastAssistantMessageId = message.id
|
||||
@@ -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 {
|
||||
ttsEngine.onDone.collect {
|
||||
if (_state.value == OverlayState.SPEAKING) {
|
||||
@@ -128,6 +135,14 @@ class OverlayViewModel(
|
||||
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) {
|
||||
@@ -141,8 +156,14 @@ class OverlayViewModel(
|
||||
|
||||
_state.value = OverlayState.PROCESSING
|
||||
cancelSilenceTimer()
|
||||
startProcessingTimeout()
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
chatRepository.sendMessage(text, null)
|
||||
} catch (e: Exception) {
|
||||
Log.e("OverlayVM", "sendText failed: ${e.message}", e)
|
||||
if (_state.value == OverlayState.PROCESSING) setWaiting()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +186,7 @@ class OverlayViewModel(
|
||||
if (base64.isNullOrBlank()) return
|
||||
|
||||
_state.value = OverlayState.PROCESSING
|
||||
startProcessingTimeout()
|
||||
viewModelScope.launch {
|
||||
chatRepository.sendVoiceInput(base64, "voice_msg")
|
||||
}
|
||||
@@ -229,6 +251,22 @@ class OverlayViewModel(
|
||||
silenceTimer = null
|
||||
}
|
||||
|
||||
private fun startProcessingTimeout() {
|
||||
cancelProcessingTimeout()
|
||||
processingTimeoutJob = viewModelScope.launch {
|
||||
delay(15_000L)
|
||||
if (_state.value == OverlayState.PROCESSING) {
|
||||
Log.w("OverlayVM", "Processing timeout — no response in 15s, resetting to WAITING")
|
||||
setWaiting()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelProcessingTimeout() {
|
||||
processingTimeoutJob?.cancel()
|
||||
processingTimeoutJob = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
voiceRecorder.cancel()
|
||||
ttsEngine.shutdown()
|
||||
|
||||
@@ -48,6 +48,18 @@ class SettingsViewModel(
|
||||
private val _autoScreenContext = MutableStateFlow(false)
|
||||
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
|
||||
|
||||
private val _themeColor = MutableStateFlow("pink")
|
||||
val themeColor: StateFlow<String> = _themeColor.asStateFlow()
|
||||
|
||||
private val _enterToSend = MutableStateFlow(false)
|
||||
val enterToSend: StateFlow<Boolean> = _enterToSend.asStateFlow()
|
||||
|
||||
private val _typingIndicatorStyle = MutableStateFlow("bubble")
|
||||
val typingIndicatorStyle: StateFlow<String> = _typingIndicatorStyle.asStateFlow()
|
||||
|
||||
private val _rootKeepAlive = MutableStateFlow(false)
|
||||
val rootKeepAlive: StateFlow<Boolean> = _rootKeepAlive.asStateFlow()
|
||||
|
||||
private val _isLoggedIn = MutableStateFlow(false)
|
||||
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
||||
|
||||
@@ -61,6 +73,26 @@ class SettingsViewModel(
|
||||
_autoScreenContext.value = value
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
preferencesDataStore.typingIndicatorStyle.collect { value ->
|
||||
_typingIndicatorStyle.value = value
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
preferencesDataStore.enterToSend.collect { value ->
|
||||
_enterToSend.value = value
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
preferencesDataStore.rootKeepAlive.collect { value ->
|
||||
_rootKeepAlive.value = value
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
preferencesDataStore.themeColor.collect { value ->
|
||||
_themeColor.value = value
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
combine(
|
||||
preferencesDataStore.baseUrl,
|
||||
@@ -165,6 +197,26 @@ class SettingsViewModel(
|
||||
scope.launch { preferencesDataStore.saveAutoScreenContext(enabled) }
|
||||
}
|
||||
|
||||
fun saveTypingIndicatorStyle(style: String) {
|
||||
_typingIndicatorStyle.value = style
|
||||
scope.launch { preferencesDataStore.saveTypingIndicatorStyle(style) }
|
||||
}
|
||||
|
||||
fun saveEnterToSend(enabled: Boolean) {
|
||||
_enterToSend.value = enabled
|
||||
scope.launch { preferencesDataStore.saveEnterToSend(enabled) }
|
||||
}
|
||||
|
||||
fun saveThemeColor(color: String) {
|
||||
_themeColor.value = color
|
||||
scope.launch { preferencesDataStore.saveThemeColor(color) }
|
||||
}
|
||||
|
||||
fun saveRootKeepAlive(enabled: Boolean) {
|
||||
_rootKeepAlive.value = enabled
|
||||
scope.launch { preferencesDataStore.saveRootKeepAlive(enabled) }
|
||||
}
|
||||
|
||||
fun clearLocalMessages() {
|
||||
scope.launch {
|
||||
chatRepository.clearLocalMessages()
|
||||
|
||||
@@ -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"
|
||||
coroutines = "1.9.0"
|
||||
material3 = "1.3.1"
|
||||
coil = "2.7.0"
|
||||
|
||||
[libraries]
|
||||
# Compose BOM
|
||||
@@ -56,6 +57,9 @@ coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-andro
|
||||
# Core
|
||||
core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" }
|
||||
|
||||
# Coil
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
|
||||
# Biometric
|
||||
biometric = { group = "androidx.biometric", name = "biometric", version = "1.1.0" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user