13 Commits

Author SHA1 Message Date
AskaEth bc7630c43a fix: popBackStack guard, refresh dedup, action tag splitting, notification debug logging
- Navigation: guard popBackStack with currentDestination check to prevent
  double-pop during exit animation causing white screen (Settings→Main overlap)
- Chat refresh: clear all messages before reload to avoid local-UUID / server-ID
  duplication; split inline <action> tags from bulk-loaded HTTP/WS history messages
- Chat animation: restore AnimatedVisibility (fadeIn+slideInVertically) in
  AnimatedChatBubble composable
- Notification debug: add NOTIFY log category with strategic log points across
  the entire background/notification pipeline — WS lifecycle, foreground/background
  transitions, emitMessage decision reasons, keep-alive service events
- Settings UI: switch log tabs from TabRow to ScrollableTabRow, add fixed-height
  card-styled log viewer with entry count header
- Clean up obsolete launcher drawable/mipmap resources

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 08:08:53 +08:00
AskaEth 08d78c976a feat: image attachment thumbnails, send timeout recovery, action tag parsing
- Multi-image thumbnails in chat bubbles with tap-to-fullscreen preview
- 15s send timeout in ChatViewModel and OverlayViewModel to prevent
  stuck "thinking" state when server sends no response
- Strip <action> XML tags in ActionMessage rendering (new server format)
- Add file_id/thumbnail_url to WSAttachment DTO for upload-first flow
- Replace imageDataUri with imageDataUris list for multi-image support
- Remove "[图片]" placeholder text from user messages with images

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:36:13 +08:00
AskaEth 6394099e2e fix: WebSocket dead-connection recovery, notification delivery, theme system overhaul
- Detect silent WebSocket drops via 30s no-message timeout + 15s heartbeat
- Force reconnect in onAppBackground via foreground service context
- Reduce KeepAlive interval from 15min to 5min for faster background recovery
- Replace callback-based notification with direct NotificationHelper injection
- Suppress notifications during initial launch and when app is foregrounded
- 9 theme color presets (pink default) + Monet dynamic color (Android 12+)
- Full HSL-derived MD3 ColorScheme replacing stale purple-only scheme
- Inline markdown rendering for chat messages (bold, italic, code, links)
- Long-press copy on error/system messages
- Hidden root keep-alive toggle (5-tap) with system-level commands
- BootReceiver to reapply keep-alive and restart service on boot

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:56:21 +08:00
AskaEth e65a35a239 fix: remove AnimatedVisibility from chat bubbles to fix LazyColumn scroll limit
AnimatedVisibility with visible=false starts items at zero height. Inside a
reverseLayout LazyColumn, this causes the list to miscompute total content
height, preventing items beyond the visible viewport from being composed.
This was limiting the chat to ~9 visible messages that filled the screen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:43:57 +08:00
AskaEth 7fcf562648 feat: markdown/code message renderers, collapsible non-chat content, dark mode fixes, and data persistence
- Add MarkdownBubble (headings, bold/italic, code blocks, lists, quotes, links)
- Add CodeBubble with dark background + language header
- Add CollapsibleBubble wrapper for long non-chat content with fold/expand button
- Update WSReviewMessage DTO: add type and metadata fields for review messages
- Fix message dedup: apply removeWrappingDuplicates before DB insert instead of on return value
- Fix dark mode: explicit text colors on StatusIndicator, icon tints, dynamicColor=false
- Add enter-to-send toggle and typing indicator style (bubble/text) in settings
- Overlay: transparent window background, pill-shaped semi-transparent input field
- Remove PullToRefreshBox (conflicted with reverseLayout scroll), use refresh button
- Add auto-refresh when connection transitions offline→online
- Fix session ID fallback for DB message loading after APK update

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:36:06 +08:00
AskaEth 64c7018729 fix: stream_end dedup, typing indicator position, and indicator style toggle
- Fix stream_end not suppressed: populate recentParsedContents in response handler
  instead of clearing it, so the dedup check can correctly suppress wrapping text
- Fix typing indicator appearing at top (oldest) in reverseLayout: place item
  before itemsIndexed so it gets index 0 (visual bottom)
- Add typing indicator style toggle in settings: bubble (default) vs text mode,
  persisted via PreferencesDataStore, applied in ChatScreen and OverlayContent

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 19:02:47 +08:00
AskaEth 86d196b857 fix: align OverlayViewModel message dedup with ChatViewModel
Removed the legacy isDup check (role+content+msgType) that could suppress
multi_message items already seen as review items. Now deduplicates by
message ID only and inserts at correct timestamp position, matching
the fixed ChatViewModel behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:40:57 +08:00
AskaEth 91231834dc fix: prevent IME from hiding latest messages in chat
Changed chat layout from Box overlay to Column flow so imePadding()
applies to the whole container instead of just the input bar. Messages
area now shrinks with the keyboard, keeping latest messages visible.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:01:43 +08:00
AskaEth 5dad0cd39b fix: use backend msg_type instead of hardcoding in server message handlers
Backend now guarantees msg_type is always populated. Changed all server
message handlers (stream_chunk, stream_end, thinking, tool_progress, error,
voice_transcript, review) to use wsMsg.msgType with safe fallback defaults
instead of hardcoded values.

Also added missing ProGuard keep rules for UI screens/components/overlay to
prevent R8 from stripping composables called via navigation lambdas.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:50:49 +08:00
AskaEth ce73f68bc8 fix: add ProGuard keep rules for UI screens and components
Navigation compose lambdas in NavGraph may not be traced by R8's call
graph, causing screen composables to be stripped in release builds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:45:48 +08:00
AskaEth 3c90adae6a fix: use offset+clipToBounds instead of graphicsLayer alpha for tab keep-alive
Hidden tabs with graphicsLayer{alpha=0f} still intercepted touch events.
Replaced with offset(x=2000.dp) + parent clipToBounds() so hidden composables
are off-screen and cannot capture touches meant for the visible tab.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:04:33 +08:00
AskaEth 014437760d fix: IME layout, message dedup, animation order, and overlay input positioning
- ChatScreen: restructure to Box overlay layout so only input area
  rises with IME, messages stay fixed. Add opaque background to input
  area. Use reverseLayout with newest-first animation order.
- OverlayContent: remove all manual IME detection — system forces
  adjust=pan on VoiceInteractionSession windows, so manual padding
  caused double offset. Let system handle IME naturally.
- ChatRepositoryImpl: add messageRemovals flow to clean up wrapping
  stream_end/response when review/multi_message items arrive later.
  Track lastResponseId in both stream_end and response handlers.
- ChatViewModel/OverlayViewModel: fix dedup to check by message ID
  only. Sort descending (newest first). Observe messageRemovals.
- NavGraph: keep all tabs composed with graphicsLayer alpha toggle —
  prevents ChatScreen destruction and re-render on tab switch.
- CyreneVoiceInteractionSession: defer configureWindow via post()
  to override system softInputMode flags.
- AndroidManifest: set windowSoftInputMode=adjustNothing on main
  activity.
- Add WebSocketKeepAliveService for background connection persistence.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 21:41:46 +08:00
AskaEth eb94142404 fix: add missing ProGuard keep rules to prevent release crash
R8 was stripping Android components (services, application),
Room entities, Koin modules, and ViewModels — causing crash
when VoiceInteractionService was invoked via power button.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 21:15:23 +08:00
41 changed files with 3633 additions and 670 deletions
+3
View File
@@ -114,4 +114,7 @@ dependencies {
// Biometric
implementation(libs.biometric)
// Coil — image loading
implementation(libs.coil.compose)
}
+90 -13
View File
@@ -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.**
+32
View File
@@ -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))
@@ -596,5 +633,4 @@ 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

+4
View File
@@ -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" }