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 // Biometric
implementation(libs.biometric) implementation(libs.biometric)
// Coil — image loading
implementation(libs.coil.compose)
} }
+90 -13
View File
@@ -1,27 +1,104 @@
# Cyrene ProGuard Rules # Cyrene ProGuard Rules
# Retrofit # --- Keep Android components declared in manifest ---
-keepattributes Signature # These are instantiated by the Android system via reflection
-keep class top.yeij.cyrene.CyreneApplication { *; }
-keep class top.yeij.cyrene.MainActivity { *; }
-keep class top.yeij.cyrene.service.** { *; }
# --- Kotlin ---
-keepattributes *Annotation* -keepattributes *Annotation*
-keep class top.yeij.cyrene.data.remote.dto.** { *; } -keepattributes Signature
-dontwarn retrofit2.** -keepattributes InnerClasses
-keep class retrofit2.** { *; }
# Gson
-keep class com.google.gson.** { *; }
-keepattributes EnclosingMethod -keepattributes EnclosingMethod
-keepattributes RuntimeVisibleAnnotations
-keepattributes RuntimeVisibleParameterAnnotations
-keepattributes AnnotationDefault
-keepattributes KotlinMetadata
-dontwarn kotlin.**
-keep class kotlin.Metadata { *; }
-keep class kotlin.coroutines.Continuation
-keep class kotlinx.coroutines.** { *; }
# OkHttp # --- Retrofit ---
-keep class retrofit2.** { *; }
-dontwarn retrofit2.**
# --- Gson ---
-keep class com.google.gson.** { *; }
-keepclassmembers,allowobfuscation class * {
@com.google.gson.annotations.SerializedName <fields>;
}
# Keep all DTO classes and their members for Gson serialization
-keep class top.yeij.cyrene.data.remote.dto.** { *; }
-keepclassmembers class top.yeij.cyrene.data.remote.dto.** { *; }
# --- OkHttp ---
-dontwarn okhttp3.** -dontwarn okhttp3.**
-dontwarn okio.** -dontwarn okio.**
# Room # --- Room ---
-keep class * extends androidx.room.RoomDatabase -keep class * extends androidx.room.RoomDatabase { *; }
-keep class top.yeij.cyrene.data.local.entity.** { *; }
-keepclassmembers class top.yeij.cyrene.data.local.entity.** { *; }
-dontwarn androidx.room.paging.** -dontwarn androidx.room.paging.**
# Koin # --- Koin ---
-keep class org.koin.** { *; } -keep class org.koin.** { *; }
-keep class top.yeij.cyrene.di.** { *; }
-keepclassmembers class top.yeij.cyrene.di.** { *; }
# Coroutines # --- Compose ---
-dontwarn androidx.compose.**
-keep class androidx.compose.** { *; }
# --- Coroutines ---
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
# --- Keep domain models (used in StateFlow, SharedFlow, etc.) ---
-keep class top.yeij.cyrene.domain.model.** { *; }
# --- Keep ViewModels (Koin instantiates via reflection) ---
-keep class top.yeij.cyrene.viewmodel.** { *; }
-keepclassmembers class top.yeij.cyrene.viewmodel.** { *; }
# --- Keep repository implementations (Koin binds by interface) ---
-keep class top.yeij.cyrene.data.repository.** { *; }
-keep class top.yeij.cyrene.domain.repository.** { *; }
# --- Keep PreferencesDataStore (Koin injects) ---
-keep class top.yeij.cyrene.data.local.PreferencesDataStore { *; }
# --- Keep utility classes (VoiceRecorder, RuntimeLog, etc. injected by Koin) ---
-keep class top.yeij.cyrene.util.** { *; }
-keepclassmembers class top.yeij.cyrene.util.** { *; }
# --- Keep voice/TTS/STT classes (injected by Koin into OverlayViewModel) ---
-keep class top.yeij.cyrene.voice.** { *; }
-keepclassmembers class top.yeij.cyrene.voice.** { *; }
# --- Keep domain use cases (injected by Koin into ViewModels) ---
-keep class top.yeij.cyrene.domain.usecase.** { *; }
-keepclassmembers class top.yeij.cyrene.domain.usecase.** { *; }
# --- Keep network interceptors and ApiService (Koin singletons) ---
-keep class top.yeij.cyrene.data.remote.RetrofitClient { *; }
-keep class top.yeij.cyrene.data.remote.ApiService { *; }
-keep class top.yeij.cyrene.data.remote.AuthInterceptor { *; }
-keep class top.yeij.cyrene.data.remote.DynamicUrlInterceptor { *; }
-keep class top.yeij.cyrene.data.remote.TokenAuthenticator { *; }
# --- Keep WebSocketService (injected into ChatRepositoryImpl) ---
-keep class top.yeij.cyrene.service.WebSocketService { *; }
# --- UI screens & components (called via Navigation compose lambda R8 may not trace) ---
-keep class top.yeij.cyrene.ui.screens.** { *; }
-keep class top.yeij.cyrene.ui.components.** { *; }
-keep class top.yeij.cyrene.ui.overlay.** { *; }
-keep class top.yeij.cyrene.ui.navigation.** { *; }
-keep class top.yeij.cyrene.ui.theme.** { *; }
# --- General AndroidX ---
-keep class androidx.lifecycle.** { *; }
-dontwarn androidx.lifecycle.**
+32
View File
@@ -14,6 +14,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- 推送 --> <!-- 推送 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -21,6 +22,12 @@
<!-- 锁屏交互 --> <!-- 锁屏交互 -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" /> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- 激进保活 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- 查询其他应用(检查默认助手设置) --> <!-- 查询其他应用(检查默认助手设置) -->
<queries> <queries>
<intent> <intent>
@@ -42,6 +49,7 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:windowSoftInputMode="adjustNothing"
android:theme="@style/Theme.Cyrene"> android:theme="@style/Theme.Cyrene">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -83,6 +91,30 @@
android:resource="@xml/accessibility_config" /> android:resource="@xml/accessibility_config" />
</service> </service>
<!-- WebSocket 后台保活 -->
<service
android:name=".service.WebSocketKeepAliveService"
android:exported="false"
android:foregroundServiceType="dataSync|specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="websocket_keepalive_for_push_message_delivery" />
</service>
<!-- 开机自启 -->
<receiver
android:name=".service.BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- 定时保活唤醒 -->
<receiver
android:name=".service.KeepAliveReceiver"
android:exported="false" />
<!-- FileProvider:日志分享 --> <!-- FileProvider:日志分享 -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@@ -1,9 +1,15 @@
package top.yeij.cyrene package top.yeij.cyrene
import android.Manifest
import android.app.Activity import android.app.Activity
import android.app.Application import android.app.Application
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -17,7 +23,8 @@ import top.yeij.cyrene.data.remote.AuthInterceptor
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
import top.yeij.cyrene.data.repository.ChatRepositoryImpl import top.yeij.cyrene.data.repository.ChatRepositoryImpl
import top.yeij.cyrene.di.appModule import top.yeij.cyrene.di.appModule
import top.yeij.cyrene.util.NotificationHelper import top.yeij.cyrene.service.KeepAliveReceiver
import top.yeij.cyrene.util.RootKeepAliveHelper
import top.yeij.cyrene.util.RuntimeLog import top.yeij.cyrene.util.RuntimeLog
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@@ -25,8 +32,6 @@ class CyreneApplication : Application() {
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val activityCount = AtomicInteger(0) private val activityCount = AtomicInteger(0)
@Volatile
private var notificationHelper: NotificationHelper? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -39,15 +44,15 @@ class CyreneApplication : Application() {
// Track foreground/background state // Track foreground/background state
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityStarted(activity: Activity) { override fun onActivityResumed(activity: Activity) {
if (activityCount.incrementAndGet() == 1) { if (activityCount.incrementAndGet() == 1) {
RuntimeLog.general("app", "App in foreground") RuntimeLog.general("app", "App in foreground")
notificationHelper?.cancelAll() getRepo()?.cancelNotifications()
getRepo()?.onAppForeground() getRepo()?.onAppForeground()
} }
} }
override fun onActivityStopped(activity: Activity) { override fun onActivityPaused(activity: Activity) {
if (activityCount.decrementAndGet() == 0) { if (activityCount.decrementAndGet() == 0) {
RuntimeLog.general("app", "App in background") RuntimeLog.general("app", "App in background")
getRepo()?.onAppBackground() getRepo()?.onAppBackground()
@@ -55,22 +60,12 @@ class CyreneApplication : Application() {
} }
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityResumed(activity: Activity) {} override fun onActivityStarted(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {} override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {} override fun onActivityDestroyed(activity: Activity) {}
}) })
// Set up background notification callback once Koin is ready
initScope.launch {
val helper = NotificationHelper(this@CyreneApplication)
notificationHelper = helper
val repo = getRepo()
repo?.setNotificationCallback { message ->
helper.showMessageNotification(message)
}
}
initScope.launch { initScope.launch {
val koin = GlobalContext.get() val koin = GlobalContext.get()
val prefs: PreferencesDataStore = koin.get() val prefs: PreferencesDataStore = koin.get()
@@ -84,12 +79,55 @@ class CyreneApplication : Application() {
authInterceptor.token = token authInterceptor.token = token
} }
} }
// Schedule periodic keep-alive on first launch
scheduleInitialKeepAlive()
// Check battery optimization
checkBatteryOptimization()
// Apply root keep-alive if enabled
initScope.launch {
val koin = GlobalContext.get()
val prefs: PreferencesDataStore = koin.get()
val enabled = prefs.rootKeepAlive.firstOrNull() ?: false
if (enabled) {
val ok = RootKeepAliveHelper.applyRootKeepAlive(packageName)
if (ok) {
RuntimeLog.general("app", "Root keep-alive re-applied on boot")
} else if (RootKeepAliveHelper.isRootAvailable()) {
RuntimeLog.general("app", "Root keep-alive re-apply failed despite root being available")
}
// Only attempt system wakelock if root keep-alive was enabled
// We don't persist the wakelock across reboots since it's per-session
}
}
}
private fun scheduleInitialKeepAlive() {
try {
KeepAliveReceiver.schedule(this)
RuntimeLog.general("app", "Initial keep-alive alarm scheduled")
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule initial keep-alive: ${e.message}")
}
}
private fun checkBatteryOptimization() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = getSystemService(PowerManager::class.java)
if (pm?.isIgnoringBatteryOptimizations(packageName) == false) {
Log.i(TAG, "App is NOT exempt from battery optimization")
// Note: we can't request exemption from Application context directly.
// SettingsScreen should offer a button to open the exemption dialog.
}
}
} }
private fun getRepo(): ChatRepositoryImpl? { private fun getRepo(): ChatRepositoryImpl? {
return try { return try {
GlobalContext.get().get() GlobalContext.get().get()
} catch (_: Exception) { } catch (_: Throwable) {
null null
} }
} }
@@ -1,16 +1,21 @@
package top.yeij.cyrene package top.yeij.cyrene
import android.Manifest
import android.content.ComponentName import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import org.koin.compose.koinInject import org.koin.compose.koinInject
import top.yeij.cyrene.data.local.PreferencesDataStore import top.yeij.cyrene.data.local.PreferencesDataStore
@@ -24,22 +29,33 @@ class MainActivity : ComponentActivity() {
private val isDefaultAssistant = mutableStateOf(false) private val isDefaultAssistant = mutableStateOf(false)
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { /* granted or denied — either way we continue */ }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
requestNotificationPermission()
isDefaultAssistant.value = checkIsDefaultAssistant() isDefaultAssistant.value = checkIsDefaultAssistant()
setContent { setContent {
val prefs: PreferencesDataStore = koinInject() val prefs: PreferencesDataStore = koinInject()
val themeMode by prefs.themeMode.collectAsState(initial = null) val themeMode by prefs.themeMode.collectAsState(initial = null)
val themeColor by prefs.themeColor.collectAsState(initial = "pink")
val darkTheme = when (themeMode) { val darkTheme = when (themeMode) {
"light" -> false "light" -> false
"dark" -> true "dark" -> true
else -> isSystemInDarkTheme() else -> isSystemInDarkTheme()
} }
val useDynamic = themeColor == "monet" && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
CyreneTheme(darkTheme = darkTheme) { CyreneTheme(
darkTheme = darkTheme,
presetKey = themeColor,
useDynamicColor = useDynamic,
) {
val navController = rememberNavController() val navController = rememberNavController()
CyreneNavGraph( CyreneNavGraph(
@@ -75,4 +91,14 @@ class MainActivity : ComponentActivity() {
private fun openAssistantSettings() { private fun openAssistantSettings() {
startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)) startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS))
} }
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
} }
@@ -30,7 +30,6 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
"cyrene.db", "cyrene.db",
) )
.fallbackToDestructiveMigration()
.build() .build()
.also { INSTANCE = it } .also { INSTANCE = it }
} }
@@ -33,6 +33,34 @@ class PreferencesDataStore(private val context: Context) {
private val KEY_PROFILE_IS_ADMIN = stringPreferencesKey("profile_is_admin") private val KEY_PROFILE_IS_ADMIN = stringPreferencesKey("profile_is_admin")
private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at") private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at")
private val KEY_AUTO_SCREEN_CONTEXT = booleanPreferencesKey("auto_screen_context") private val KEY_AUTO_SCREEN_CONTEXT = booleanPreferencesKey("auto_screen_context")
private val KEY_TYPING_INDICATOR_STYLE = stringPreferencesKey("typing_indicator_style")
private val KEY_ENTER_TO_SEND = booleanPreferencesKey("enter_to_send")
private val KEY_ROOT_KEEPALIVE = booleanPreferencesKey("root_keepalive")
private val KEY_THEME_COLOR = stringPreferencesKey("theme_color")
}
val typingIndicatorStyle: Flow<String> = context.dataStore.data.map { it[KEY_TYPING_INDICATOR_STYLE] ?: "bubble" }
suspend fun saveTypingIndicatorStyle(style: String) {
context.dataStore.edit { it[KEY_TYPING_INDICATOR_STYLE] = style }
}
val enterToSend: Flow<Boolean> = context.dataStore.data.map { it[KEY_ENTER_TO_SEND] ?: false }
suspend fun saveEnterToSend(enabled: Boolean) {
context.dataStore.edit { it[KEY_ENTER_TO_SEND] = enabled }
}
val rootKeepAlive: Flow<Boolean> = context.dataStore.data.map { it[KEY_ROOT_KEEPALIVE] ?: false }
suspend fun saveRootKeepAlive(enabled: Boolean) {
context.dataStore.edit { it[KEY_ROOT_KEEPALIVE] = enabled }
}
val themeColor: Flow<String> = context.dataStore.data.map { it[KEY_THEME_COLOR] ?: "pink" }
suspend fun saveThemeColor(color: String) {
context.dataStore.edit { it[KEY_THEME_COLOR] = color }
} }
val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] } val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
@@ -25,6 +25,9 @@ interface MessageDao {
@Query("UPDATE messages SET conversationId = :newId WHERE conversationId = :oldId") @Query("UPDATE messages SET conversationId = :newId WHERE conversationId = :oldId")
suspend fun migrateConversationId(oldId: String, newId: String) suspend fun migrateConversationId(oldId: String, newId: String)
@Query("DELETE FROM messages WHERE conversationId = :conversationId AND role = 'user'")
suspend fun deleteUserMessagesByConversation(conversationId: String)
@Query("DELETE FROM messages WHERE id = :id") @Query("DELETE FROM messages WHERE id = :id")
suspend fun deleteById(id: String) suspend fun deleteById(id: String)
@@ -1,10 +1,13 @@
package top.yeij.cyrene.data.remote package top.yeij.cyrene.data.remote
import okhttp3.MultipartBody
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
import top.yeij.cyrene.data.remote.dto.AuthRequest import top.yeij.cyrene.data.remote.dto.AuthRequest
@@ -12,6 +15,7 @@ import top.yeij.cyrene.data.remote.dto.AuthResponse
import top.yeij.cyrene.data.remote.dto.ProfileResponse import top.yeij.cyrene.data.remote.dto.ProfileResponse
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
import top.yeij.cyrene.data.remote.dto.DeviceDto import top.yeij.cyrene.data.remote.dto.DeviceDto
import top.yeij.cyrene.data.remote.dto.FileUploadResponse
import top.yeij.cyrene.data.remote.dto.IoTControlRequest import top.yeij.cyrene.data.remote.dto.IoTControlRequest
import top.yeij.cyrene.data.remote.dto.MessagesListResponse import top.yeij.cyrene.data.remote.dto.MessagesListResponse
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
@@ -46,10 +50,17 @@ interface ApiService {
@GET("api/v1/sessions/{id}/messages") @GET("api/v1/sessions/{id}/messages")
suspend fun getSessionMessages( suspend fun getSessionMessages(
@Path("id") sessionId: String, @Path("id") sessionId: String,
@Query("limit") limit: Int = 50, @Query("limit") limit: Int = 500,
@Query("offset") offset: Int = 0, @Query("offset") offset: Int = 0,
): Response<MessagesListResponse> ): Response<MessagesListResponse>
// Files
@Multipart
@POST("api/v1/files/upload")
suspend fun uploadFile(
@Part file: MultipartBody.Part,
): Response<FileUploadResponse>
// IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/* // IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/*
@GET("api/v1/iot/devices") @GET("api/v1/iot/devices")
suspend fun getDevices(): Response<List<DeviceDto>> suspend fun getDevices(): Response<List<DeviceDto>>
@@ -36,3 +36,12 @@ data class SessionMessageDto(
@SerializedName("content") val content: String, @SerializedName("content") val content: String,
@SerializedName("created_at") val createdAt: Long, @SerializedName("created_at") val createdAt: Long,
) )
// POST /api/v1/files/upload — response
data class FileUploadResponse(
@SerializedName("id") val id: String,
@SerializedName("filename") val filename: String? = null,
@SerializedName("mime_type") val mimeType: String? = null,
@SerializedName("size") val size: Long? = null,
@SerializedName("url") val url: String? = null,
)
@@ -20,6 +20,7 @@ data class WSClientMessage(
data class WSAttachment( data class WSAttachment(
@SerializedName("type") val type: String, @SerializedName("type") val type: String,
@SerializedName("url") val url: String? = null, @SerializedName("url") val url: String? = null,
@SerializedName("file_id") val fileId: String? = null,
@SerializedName("thumbnail_url") val thumbnailUrl: String? = null, @SerializedName("thumbnail_url") val thumbnailUrl: String? = null,
@SerializedName("filename") val filename: String? = null, @SerializedName("filename") val filename: String? = null,
@SerializedName("width") val width: Int? = null, @SerializedName("width") val width: Int? = null,
@@ -55,11 +56,18 @@ data class WSServerMessage(
) )
data class WSReviewMessage( data class WSReviewMessage(
@SerializedName("role") val role: String?, @SerializedName("type") val type: String? = null,
@SerializedName("text") val text: String?, @SerializedName("role") val role: String? = null,
@SerializedName("content") val content: String?, @SerializedName("text") val text: String? = null,
@SerializedName("msg_type") val msgType: String?, @SerializedName("content") val content: String? = null,
@SerializedName("msg_type") val msgType: String? = null,
@SerializedName("delay_ms") val delayMs: Long? = 0, @SerializedName("delay_ms") val delayMs: Long? = 0,
@SerializedName("metadata") val metadata: WSReviewMetadata? = null,
)
data class WSReviewMetadata(
@SerializedName("language") val language: String? = null,
@SerializedName("url") val url: String? = null,
) )
data class WSHistoryMessage( data class WSHistoryMessage(
@@ -1,5 +1,6 @@
package top.yeij.cyrene.data.repository package top.yeij.cyrene.data.repository
import android.app.Application
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -11,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
@@ -22,20 +24,26 @@ import top.yeij.cyrene.data.local.entity.ConversationEntity
import top.yeij.cyrene.data.local.entity.MessageEntity import top.yeij.cyrene.data.local.entity.MessageEntity
import top.yeij.cyrene.data.remote.ApiService import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
import top.yeij.cyrene.data.remote.dto.WSAttachment
import top.yeij.cyrene.data.remote.dto.WSServerMessage import top.yeij.cyrene.data.remote.dto.WSServerMessage
import top.yeij.cyrene.domain.model.Conversation import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.domain.repository.ChatRepository import top.yeij.cyrene.domain.repository.ChatRepository
import top.yeij.cyrene.service.KeepAliveReceiver
import top.yeij.cyrene.service.WebSocketKeepAliveService
import top.yeij.cyrene.service.WebSocketService import top.yeij.cyrene.service.WebSocketService
import top.yeij.cyrene.util.NotificationHelper
import top.yeij.cyrene.util.RuntimeLog import top.yeij.cyrene.util.RuntimeLog
import java.util.UUID import java.util.UUID
class ChatRepositoryImpl( class ChatRepositoryImpl(
private val app: Application,
private val conversationDao: ConversationDao, private val conversationDao: ConversationDao,
private val messageDao: MessageDao, private val messageDao: MessageDao,
private val webSocketService: WebSocketService, private val webSocketService: WebSocketService,
private val apiService: ApiService, private val apiService: ApiService,
private val preferencesDataStore: PreferencesDataStore, private val preferencesDataStore: PreferencesDataStore,
private val notificationHelper: NotificationHelper,
) : ChatRepository { ) : ChatRepository {
private val exceptionHandler = CoroutineExceptionHandler { _, e -> private val exceptionHandler = CoroutineExceptionHandler { _, e ->
@@ -54,15 +62,19 @@ class ChatRepositoryImpl(
private val _messageClearEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 4) private val _messageClearEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
override val messageClearEvents: Flow<Unit> = _messageClearEvents override val messageClearEvents: Flow<Unit> = _messageClearEvents
private val _messageRemovals = MutableSharedFlow<String>(extraBufferCapacity = 16)
override val messageRemovals: Flow<String> = _messageRemovals
private val _isAssistantStreaming = MutableStateFlow(false) private val _isAssistantStreaming = MutableStateFlow(false)
override val isAssistantStreaming: StateFlow<Boolean> = _isAssistantStreaming.asStateFlow() override val isAssistantStreaming: StateFlow<Boolean> = _isAssistantStreaming.asStateFlow()
private var streamingContent = "" private var streamingContent = ""
private var streamingMessageId: String? = null private var streamingMessageId: String? = null
private var streamTimeoutJob: kotlinx.coroutines.Job? = null
override var currentSessionId: String? = null override var currentSessionId: String? = null
private var isAppInForeground = false private var isAppInForeground = false
private var onBackgroundNotification: ((Message) -> Unit)? = null private var hasEverBeenForeground = false
private var historyRequested = false private var historyRequested = false
private val notifiedMessageIds = mutableSetOf<String>() private val notifiedMessageIds = mutableSetOf<String>()
@@ -74,17 +86,45 @@ class ChatRepositoryImpl(
private var lastResponseContent: String? = null private var lastResponseContent: String? = null
private var lastResponseTime = 0L private var lastResponseTime = 0L
fun setNotificationCallback(callback: ((Message) -> Unit)?) { fun cancelNotifications() {
onBackgroundNotification = callback notificationHelper.cancelAll()
}
private fun resetStreamTimeout() {
cancelStreamTimeout()
streamTimeoutJob = scope.launch {
kotlinx.coroutines.delay(120_000L) // 2 min timeout
if (_isAssistantStreaming.value) {
RuntimeLog.chat("stream", "Stream timeout — no chunk or end for 120s, resetting")
streamingContent = ""
streamingMessageId = null
_isAssistantStreaming.value = false
emitMessage(
id = "timeout_${System.currentTimeMillis()}",
sessionId = currentSessionId ?: "default",
role = "system",
content = "AI 响应超时,请重试",
msgType = "system_info",
isStreaming = false,
)
}
}
}
private fun cancelStreamTimeout() {
streamTimeoutJob?.cancel()
streamTimeoutJob = null
} }
override fun onAppForeground() { override fun onAppForeground() {
RuntimeLog.notify("state", "onAppForeground: wasForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground")
isAppInForeground = true isAppInForeground = true
hasEverBeenForeground = true
notifiedMessageIds.clear() notifiedMessageIds.clear()
if (!_connectionState.value) { notificationHelper.cancelAll()
webSocketService.forceReconnect() KeepAliveReceiver.cancel(app)
} WebSocketKeepAliveService.stop(app)
// Always request history on foreground to catch cross-device messages RuntimeLog.notify("state", "onAppForeground: notifications cleared, keep-alive stopped")
scope.launch { scope.launch {
val sid = currentSessionId ?: return@launch val sid = currentSessionId ?: return@launch
RuntimeLog.general("app", "Foreground — requesting history for session=$sid") RuntimeLog.general("app", "Foreground — requesting history for session=$sid")
@@ -94,6 +134,21 @@ class ChatRepositoryImpl(
override fun onAppBackground() { override fun onAppBackground() {
isAppInForeground = false isAppInForeground = false
WebSocketKeepAliveService.start(app)
KeepAliveReceiver.schedule(app)
val currentlyConnected = _connectionState.value
RuntimeLog.notify("state", "onAppBackground: connected=$currentlyConnected hasEverBeen=$hasEverBeenForeground keepAliveStarted=true")
// Only reconnect if the WS is already dead. Tearing down a healthy
// connection creates a message loss window with no benefit.
if (!currentlyConnected) {
scope.launch {
kotlinx.coroutines.delay(1500) // let the service start first
webSocketService.forceReconnect()
RuntimeLog.general("app", "Background reconnect done, connected=${_connectionState.value}")
}
} else {
RuntimeLog.general("app", "WS healthy — skipping background reconnect to avoid message loss")
}
} }
init { init {
@@ -148,17 +203,6 @@ class ChatRepositoryImpl(
messageDao.deleteAll() messageDao.deleteAll()
preferencesDataStore.saveLastClearedTimestamp(now) preferencesDataStore.saveLastClearedTimestamp(now)
// Also clear server-side messages for all known sessions
try {
val sessions = conversationDao.getAllSnapshot()
sessions.forEach { session ->
try {
apiService.clearSessionMessages(session.id)
RuntimeLog.chat("clear", "Server messages cleared for session=${session.id}")
} catch (_: Exception) { }
}
} catch (_: Exception) { }
_messageClearEvents.tryEmit(Unit) _messageClearEvents.tryEmit(Unit)
RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now") RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now")
@@ -186,11 +230,11 @@ class ChatRepositoryImpl(
} }
override suspend fun ensureConnected() { override suspend fun ensureConnected() {
if (_connectionState.value) return // Always force reconnect — connectionState may be stuck at true on a silently dead socket
webSocketService.forceReconnect() webSocketService.forceReconnect()
} }
override suspend fun sendMessage(content: String, sessionId: String?) { override suspend fun sendMessage(content: String, sessionId: String?, attachments: List<WSAttachment>?, localImageUris: List<String>) {
val messageId = UUID.randomUUID().toString() val messageId = UUID.randomUUID().toString()
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val sid = sessionId ?: currentSessionId ?: "default" val sid = sessionId ?: currentSessionId ?: "default"
@@ -199,13 +243,21 @@ class ChatRepositoryImpl(
scope.launch { preferencesDataStore.saveCurrentSessionId(sid) } scope.launch { preferencesDataStore.saveCurrentSessionId(sid) }
} }
RuntimeLog.chat("send", "session=$sid msgId=$messageId content=${content.take(80)}") val hasImages = localImageUris.isNotEmpty()
val displayContent = content.ifBlank { "" }
val lastMsg = when {
hasImages && content.isBlank() -> "[图片]"
hasImages -> content
else -> content
}
RuntimeLog.chat("send", "session=$sid msgId=$messageId content=${content.take(80)} attachments=${attachments?.size ?: 0}")
conversationDao.upsert( conversationDao.upsert(
ConversationEntity( ConversationEntity(
id = sid, id = sid,
title = "对话", title = "对话",
lastMessage = content, lastMessage = lastMsg,
lastMessageType = "chat", lastMessageType = "chat",
updatedAt = now, updatedAt = now,
createdAt = now, createdAt = now,
@@ -217,7 +269,7 @@ class ChatRepositoryImpl(
id = messageId, id = messageId,
conversationId = sid, conversationId = sid,
role = "user", role = "user",
content = content, content = displayContent,
msgType = "chat", msgType = "chat",
timestamp = now, timestamp = now,
) )
@@ -227,13 +279,14 @@ class ChatRepositoryImpl(
id = messageId, id = messageId,
sessionId = sid, sessionId = sid,
role = "user", role = "user",
content = content, content = displayContent,
msgType = "chat", msgType = "chat",
timestamp = now, timestamp = now,
isStreaming = false, isStreaming = false,
imageDataUris = localImageUris,
) )
webSocketService.sendMessage(content, sid) webSocketService.sendMessage(content, sid, attachments = attachments)
} }
override suspend fun loadConversationsFromServer() { override suspend fun loadConversationsFromServer() {
@@ -310,29 +363,30 @@ class ChatRepositoryImpl(
?.toLongOrNull() ?: 0L ?.toLongOrNull() ?: 0L
val filteredDtos = messageDtos.filter { it.createdAt > lastCleared } val filteredDtos = messageDtos.filter { it.createdAt > lastCleared }
ensureConversation(sessionId) ensureConversation(sessionId)
filteredDtos.forEach { dto -> val messages = filteredDtos.map { dto ->
messageDao.upsert(
MessageEntity(
id = "db_${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( Message(
id = "db_${dto.id}", id = "${dto.id}",
conversationId = sessionId, conversationId = sessionId,
role = dto.role, role = dto.role,
content = dto.content, content = dto.content,
msgType = dto.msgType ?: "chat", msgType = dto.msgType ?: "chat",
timestamp = dto.createdAt, timestamp = dto.createdAt,
) )
}.removeWrappingDuplicates() }
val deduped = messages.removeWrappingDuplicates().splitInlineActions()
messageDao.deleteUserMessagesByConversation(sessionId)
messageDao.upsertAll(deduped.map { msg ->
MessageEntity(
id = msg.id,
conversationId = msg.conversationId,
role = msg.role,
content = msg.content,
msgType = msg.msgType,
timestamp = msg.timestamp,
)
})
RuntimeLog.http("loadMessages", "HTTP loaded ${deduped.size} messages (${messages.size} before dedup) for session=$sessionId")
deduped
} else { } else {
RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS") RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS")
requestHistoryViaWs(sessionId) requestHistoryViaWs(sessionId)
@@ -346,11 +400,16 @@ class ChatRepositoryImpl(
} }
private suspend fun requestHistoryViaWs(sessionId: String) { private suspend fun requestHistoryViaWs(sessionId: String) {
// Wait up to 5s for WS to connect
if (!webSocketService.isConnected.value) { if (!webSocketService.isConnected.value) {
withTimeoutOrNull(5000) { val connected = withTimeoutOrNull(5000) {
webSocketService.isConnected.first { it } webSocketService.isConnected.first { it }
} }
if (connected != true) {
// WS couldn't connect, fall back to REST API
RuntimeLog.chat("history", "WS not connected after 5s, falling back to REST")
loadMessagesFromServer(sessionId)
return
}
} }
webSocketService.requestHistory(sessionId) webSocketService.requestHistory(sessionId)
} }
@@ -378,23 +437,27 @@ class ChatRepositoryImpl(
streamingContent = "" streamingContent = ""
streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}" streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
_isAssistantStreaming.value = true _isAssistantStreaming.value = true
recentParsedContents.clear()
resetStreamTimeout()
RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId") RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
} }
"stream_chunk" -> { "stream_chunk" -> {
val delta = wsMsg.content ?: wsMsg.text ?: return val delta = wsMsg.content ?: wsMsg.text ?: return
streamingContent += delta streamingContent += delta
resetStreamTimeout()
emitMessage( emitMessage(
id = streamingMessageId ?: "s_${System.currentTimeMillis()}", id = streamingMessageId ?: "s_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "assistant", role = "assistant",
content = streamingContent, content = streamingContent,
msgType = "chat", msgType = wsMsg.msgType ?: "chat",
isStreaming = true, isStreaming = true,
) )
} }
"stream_end" -> { "stream_end" -> {
cancelStreamTimeout()
val msgId = wsMsg.messageId ?: streamingMessageId ?: "s_${System.currentTimeMillis()}" val msgId = wsMsg.messageId ?: streamingMessageId ?: "s_${System.currentTimeMillis()}"
val content = streamingContent.ifEmpty { wsMsg.content ?: wsMsg.text ?: "" } val content = streamingContent.ifEmpty { wsMsg.content ?: wsMsg.text ?: "" }
streamingContent = "" streamingContent = ""
@@ -405,6 +468,19 @@ class ChatRepositoryImpl(
} }
val ts = wsMsg.timestamp ?: System.currentTimeMillis() val ts = wsMsg.timestamp ?: System.currentTimeMillis()
// Dedup: suppress if streaming content wraps already-shown multi_message/review items
val timeSinceParsed = System.currentTimeMillis() - lastParsedTime
if (timeSinceParsed < 3000 && recentParsedContents.isNotEmpty()) {
val allContained = recentParsedContents.all { content.contains(it) }
if (allContained) {
RuntimeLog.chat("dedup", "Suppressed stream_end wrapping, ${recentParsedContents.size} items already shown")
recentParsedContents.clear()
_isAssistantStreaming.value = false
return
}
}
recentParsedContents.clear()
if (content.isNotBlank()) { if (content.isNotBlank()) {
ensureConversation(sid, content) ensureConversation(sid, content)
messageDao.upsert( messageDao.upsert(
@@ -413,15 +489,19 @@ class ChatRepositoryImpl(
conversationId = sid, conversationId = sid,
role = "assistant", role = "assistant",
content = content, content = content,
msgType = "chat", msgType = wsMsg.msgType ?: "chat",
timestamp = ts, timestamp = ts,
) )
) )
} }
emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = "chat", timestamp = ts, isStreaming = false, shouldNotify = true) lastResponseId = msgId
lastResponseContent = content
lastResponseTime = System.currentTimeMillis()
RuntimeLog.notify("trigger", "stream_end: id=$msgId isForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground content='${content.take(50)}'")
emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = wsMsg.msgType ?: "chat", timestamp = ts, isStreaming = false, shouldNotify = true)
_isAssistantStreaming.value = false _isAssistantStreaming.value = false
RuntimeLog.chat("stream", "Stream end msgId=$msgId content=${content.take(80)}")
} }
"response" -> { "response" -> {
@@ -431,18 +511,6 @@ class ChatRepositoryImpl(
val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}" val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}"
val sid = wsMsg.sessionId ?: currentSessionId ?: "default" val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
// Suppress response if it wraps recently emitted review/multi_message items
val timeSinceParsed = System.currentTimeMillis() - lastParsedTime
if (timeSinceParsed < 3000 && recentParsedContents.isNotEmpty()) {
val allContained = recentParsedContents.all { text.contains(it) }
if (allContained) {
RuntimeLog.chat("dedup", "Suppressed wrapping response, ${recentParsedContents.size} items already shown")
recentParsedContents.clear()
return
}
}
recentParsedContents.clear()
if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) { if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
changeSessionId(sid) changeSessionId(sid)
} }
@@ -464,19 +532,28 @@ class ChatRepositoryImpl(
lastResponseContent = text lastResponseContent = text
lastResponseTime = System.currentTimeMillis() lastResponseTime = System.currentTimeMillis()
// Track parsed content so stream_end can suppress the wrapping full text
recentParsedContents.add(text)
lastParsedTime = System.currentTimeMillis()
RuntimeLog.notify("trigger", "reply: id=$msgId role=$role isForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground content='${text.take(50)}'")
emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false, shouldNotify = true) emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false, shouldNotify = true)
RuntimeLog.chat("receive", "Response msgId=$msgId role=$role msgType=$replyMsgType content=${text.take(80)}")
} }
"review" -> { "review" -> {
recentParsedContents.clear() recentParsedContents.clear()
wsMsg.reviewMessages?.forEach { review -> wsMsg.reviewMessages?.forEachIndexed { index, review ->
val text = review.content ?: review.text ?: return@forEach if (index > 0) delay(1000L)
val role = review.role ?: "action" val rawText = review.content ?: review.text ?: return@forEachIndexed
val rvMsgType = review.msgType ?: review.role ?: "action" val role = review.role ?: "assistant"
val rvMsgType = review.type ?: review.msgType ?: "action"
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}" val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
recentParsedContents.add(text) // Encode code language metadata into content for the renderer
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = text, msgType = rvMsgType, isStreaming = false) val content = if (rvMsgType == "code" && review.metadata?.language != null) {
"[lang:${review.metadata.language}]\n$rawText"
} else rawText
recentParsedContents.add(rawText)
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = content, msgType = rvMsgType, isStreaming = false)
} }
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis() if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
// Clean up wrapping response that arrived before this review // Clean up wrapping response that arrived before this review
@@ -491,7 +568,7 @@ class ChatRepositoryImpl(
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "assistant", role = "assistant",
content = text, content = text,
msgType = "thinking", msgType = wsMsg.msgType ?: "thinking",
isStreaming = false, isStreaming = false,
) )
} }
@@ -507,19 +584,34 @@ class ChatRepositoryImpl(
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "system", role = "system",
content = detail, content = detail,
msgType = "tool_progress", msgType = wsMsg.msgType ?: "tool_progress",
isStreaming = false,
)
}
"queued" -> {
emitMessage(
id = "queued_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "system",
content = "消息已加入处理队列",
msgType = "system_info",
isStreaming = false, isStreaming = false,
) )
} }
"error" -> { "error" -> {
cancelStreamTimeout()
streamingContent = ""
streamingMessageId = null
_isAssistantStreaming.value = false
RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}") RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}")
emitMessage( emitMessage(
id = "err_${System.currentTimeMillis()}", id = "err_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "system", role = "system",
content = wsMsg.error ?: "未知错误", content = wsMsg.error ?: "未知错误",
msgType = "system_info", msgType = wsMsg.msgType ?: "system_info",
isStreaming = false, isStreaming = false,
) )
} }
@@ -536,11 +628,11 @@ class ChatRepositoryImpl(
conversationId = sid, conversationId = sid,
role = "user", role = "user",
content = text, content = text,
msgType = "chat", msgType = wsMsg.msgType ?: "chat",
timestamp = ts, timestamp = ts,
) )
) )
emitMessage(id = msgId, sessionId = sid, role = "user", content = text, msgType = "chat", timestamp = ts, isStreaming = false) emitMessage(id = msgId, sessionId = sid, role = "user", content = text, msgType = wsMsg.msgType ?: "chat", timestamp = ts, isStreaming = false)
} }
"history_response" -> { "history_response" -> {
@@ -561,18 +653,18 @@ class ChatRepositoryImpl(
timestamp = hist.timestamp ?: System.currentTimeMillis(), timestamp = hist.timestamp ?: System.currentTimeMillis(),
) )
} }
val deduped = messageList.removeWrappingDuplicates() val deduped = messageList.removeWrappingDuplicates().splitInlineActions()
deduped.forEach { msg -> messageDao.upsertAll(deduped.map { msg ->
messageDao.upsert( MessageEntity(
MessageEntity( id = msg.id,
id = msg.id, conversationId = msg.conversationId,
conversationId = msg.conversationId, role = msg.role,
role = msg.role, content = msg.content,
content = msg.content, msgType = msg.msgType,
msgType = msg.msgType, timestamp = msg.timestamp,
timestamp = msg.timestamp,
)
) )
})
deduped.forEach { msg ->
emitMessage( emitMessage(
id = msg.id, id = msg.id,
sessionId = msg.conversationId, sessionId = msg.conversationId,
@@ -589,7 +681,9 @@ class ChatRepositoryImpl(
"multi_message" -> { "multi_message" -> {
recentParsedContents.clear() recentParsedContents.clear()
wsMsg.multiMessages?.forEach { item -> var isFirst = true
wsMsg.multiMessages?.forEachIndexed { index, item ->
if (index > 0) delay(1000L)
val content = item.content ?: "" val content = item.content ?: ""
recentParsedContents.add(content) recentParsedContents.add(content)
emitMessage( emitMessage(
@@ -600,7 +694,9 @@ class ChatRepositoryImpl(
msgType = item.msgType ?: "chat", msgType = item.msgType ?: "chat",
timestamp = wsMsg.timestamp ?: System.currentTimeMillis(), timestamp = wsMsg.timestamp ?: System.currentTimeMillis(),
isStreaming = false, isStreaming = false,
shouldNotify = isFirst,
) )
isFirst = false
} }
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis() if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
cleanupWrappingResponse() cleanupWrappingResponse()
@@ -616,7 +712,8 @@ class ChatRepositoryImpl(
val allContained = recentParsedContents.all { respContent.contains(it) } val allContained = recentParsedContents.all { respContent.contains(it) }
if (allContained) { if (allContained) {
messageDao.deleteById(respId) messageDao.deleteById(respId)
RuntimeLog.chat("dedup", "Cleaned up wrapping response from DB id=$respId") _messageRemovals.tryEmit(respId)
RuntimeLog.chat("dedup", "Cleaned up wrapping response from DB and live state id=$respId")
} }
} }
@@ -629,8 +726,45 @@ class ChatRepositoryImpl(
isStreaming: Boolean = false, isStreaming: Boolean = false,
timestamp: Long = System.currentTimeMillis(), timestamp: Long = System.currentTimeMillis(),
shouldNotify: Boolean = false, shouldNotify: Boolean = false,
imageDataUris: List<String> = emptyList(),
) { ) {
if (content.isBlank() && msgType == "chat") return if (content.isBlank() && msgType == "chat" && imageDataUris.isEmpty()) return
// Fallback: detect inline <action> tags missed by server parsing
if (role == "assistant" && msgType == "chat") {
val actionRegex = Regex("""<action>(.*?)</action>\s*""")
val match = actionRegex.find(content)
if (match != null) {
val actionText = match.groupValues[1].trim()
val remaining = actionRegex.replaceFirst(content, "").trim()
RuntimeLog.chat("receive", "Split inline <action> from chat: action='${actionText.take(40)}' remaining='${remaining.take(40)}'")
if (actionText.isNotEmpty()) {
emitMessage(
id = "${id}_action",
sessionId = sessionId,
role = "assistant",
content = actionText,
msgType = "action",
timestamp = timestamp,
shouldNotify = false,
)
}
if (remaining.isNotEmpty()) {
emitMessage(
id = id,
sessionId = sessionId,
role = role,
content = remaining,
msgType = msgType,
timestamp = timestamp + 1,
isStreaming = isStreaming,
shouldNotify = shouldNotify,
)
}
return
}
}
val message = Message( val message = Message(
id = id, id = id,
conversationId = sessionId, conversationId = sessionId,
@@ -639,13 +773,25 @@ class ChatRepositoryImpl(
msgType = msgType, msgType = msgType,
timestamp = timestamp, timestamp = timestamp,
isStreaming = isStreaming, isStreaming = isStreaming,
imageDataUris = imageDataUris,
) )
_incomingMessages.tryEmit(message) _incomingMessages.tryEmit(message)
if (shouldNotify && !isAppInForeground && role == "assistant" && !isStreaming) { if (shouldNotify && role == "assistant" && !isStreaming) {
if (notifiedMessageIds.add(id)) { if (!hasEverBeenForeground) {
onBackgroundNotification?.invoke(message) RuntimeLog.notify("skip", "Not showing notification for $id: app has never been foregrounded")
} else if (isAppInForeground) {
RuntimeLog.notify("skip", "Not showing notification for $id: app is in foreground")
} else {
if (notifiedMessageIds.add(id)) {
notificationHelper.showMessageNotification(message)
RuntimeLog.notify("show", "Notification sent: id=$id content='${content.take(40)}'")
} else {
RuntimeLog.notify("dup", "Notification already sent for $id, skipping")
}
} }
} else if (shouldNotify && role == "assistant" && isStreaming) {
RuntimeLog.notify("skip", "Not showing notification for $id: still streaming")
} }
} }
@@ -681,6 +827,35 @@ class ChatRepositoryImpl(
createdAt = createdAt, createdAt = createdAt,
) )
/**
* Split inline `<action>` tags from assistant chat messages into separate messages.
* Used for bulk-loaded messages (HTTP history, WS history_response) that bypass emitMessage.
*/
private fun List<Message>.splitInlineActions(): List<Message> {
val actionRegex = Regex("""<action>(.*?)</action>\s*""")
return flatMap { msg ->
if (msg.role == "assistant" && msg.msgType == "chat") {
val match = actionRegex.find(msg.content)
if (match != null) {
val actionText = match.groupValues[1].trim()
val remaining = actionRegex.replaceFirst(msg.content, "").trim()
val result = mutableListOf<Message>()
if (actionText.isNotEmpty()) {
result.add(msg.copy(id = "${msg.id}_action", content = actionText, msgType = "action"))
}
if (remaining.isNotEmpty()) {
result.add(msg.copy(content = remaining))
}
if (result.isEmpty()) listOf(msg) else result
} else {
listOf(msg)
}
} else {
listOf(msg)
}
}
}
private fun MessageEntity.toDomain() = Message( private fun MessageEntity.toDomain() = Message(
id = id, id = id,
conversationId = conversationId, conversationId = conversationId,
@@ -25,6 +25,7 @@ import top.yeij.cyrene.viewmodel.IoTViewModel
import top.yeij.cyrene.viewmodel.OverlayViewModel import top.yeij.cyrene.viewmodel.OverlayViewModel
import top.yeij.cyrene.viewmodel.ProfileViewModel import top.yeij.cyrene.viewmodel.ProfileViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel import top.yeij.cyrene.viewmodel.SettingsViewModel
import top.yeij.cyrene.util.NotificationHelper
import top.yeij.cyrene.util.VoiceRecorder import top.yeij.cyrene.util.VoiceRecorder
import top.yeij.cyrene.voice.stt.BackendSttProvider import top.yeij.cyrene.voice.stt.BackendSttProvider
import top.yeij.cyrene.voice.stt.DashScopeSttService import top.yeij.cyrene.voice.stt.DashScopeSttService
@@ -34,6 +35,9 @@ import top.yeij.cyrene.voice.tts.TextToSpeechEngine
val appModule = module { val appModule = module {
// Notifications
single { NotificationHelper(androidContext()) }
// DataStore // DataStore
single { PreferencesDataStore(androidContext()) } single { PreferencesDataStore(androidContext()) }
@@ -63,7 +67,7 @@ val appModule = module {
// Repositories // Repositories
single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) } single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) }
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get(), get(), get()) } single<ChatRepository> { ChatRepositoryImpl(androidContext() as android.app.Application, get(), get(), get(), get(), get(), get()) }
single<IoTRepository> { IoTRepositoryImpl(get(), get()) } single<IoTRepository> { IoTRepositoryImpl(get(), get()) }
// UseCases // UseCases
@@ -72,7 +76,7 @@ val appModule = module {
factory { GetConversationsUseCase(get()) } factory { GetConversationsUseCase(get()) }
// ViewModels // ViewModels
viewModel { ChatViewModel(get(), get()) } viewModel { ChatViewModel(androidContext() as android.app.Application, get(), get(), get(), get()) }
viewModel { IoTViewModel(get()) } viewModel { IoTViewModel(get()) }
viewModel { OverlayViewModel(get(), get(), get()) } viewModel { OverlayViewModel(get(), get(), get()) }
viewModel { ProfileViewModel(get(), get(), get()) } viewModel { ProfileViewModel(get(), get(), get()) }
@@ -8,4 +8,5 @@ data class Message(
val msgType: String, val msgType: String,
val timestamp: Long, val timestamp: Long,
val isStreaming: Boolean = false, val isStreaming: Boolean = false,
val imageDataUris: List<String> = emptyList(),
) )
@@ -11,6 +11,7 @@ interface ChatRepository {
val connectionError: StateFlow<String?> val connectionError: StateFlow<String?>
val isAssistantStreaming: StateFlow<Boolean> val isAssistantStreaming: StateFlow<Boolean>
val messageClearEvents: Flow<Unit> val messageClearEvents: Flow<Unit>
val messageRemovals: Flow<String>
var currentSessionId: String? var currentSessionId: String?
fun getConversations(): Flow<List<Conversation>> fun getConversations(): Flow<List<Conversation>>
@@ -21,7 +22,7 @@ interface ChatRepository {
suspend fun connectWebSocket(sessionId: String?) suspend fun connectWebSocket(sessionId: String?)
suspend fun sendMessage(content: String, sessionId: String?) suspend fun sendMessage(content: String, sessionId: String?, attachments: List<top.yeij.cyrene.data.remote.dto.WSAttachment>? = null, localImageUris: List<String> = emptyList())
fun observeMessages(): Flow<Message> fun observeMessages(): Flow<Message>
@@ -0,0 +1,59 @@
package top.yeij.cyrene.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.koin.core.context.GlobalContext
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
class BootReceiver : BroadcastReceiver() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
Log.i(TAG, "Boot completed, restoring background connection")
scope.launch {
try {
// Wait a moment for Koin to initialize
kotlinx.coroutines.delay(5000)
val prefs: PreferencesDataStore = GlobalContext.get().get()
val token = prefs.token.firstOrNull()
if (token.isNullOrBlank()) {
Log.i(TAG, "No auth token, skipping auto-connect")
return@launch
}
// Start keep-alive service
WebSocketKeepAliveService.start(context)
// Always reconnect — session may be stale
val repo: ChatRepositoryImpl = GlobalContext.get().get()
repo.ensureConnected()
Log.i(TAG, "Boot connection restored, connected=${repo.connectionState.value}")
// Schedule periodic wake-up
KeepAliveReceiver.schedule(context)
Log.i(TAG, "Background connection restored")
} catch (e: Throwable) {
Log.e(TAG, "Failed to restore connection on boot: ${e.message}", e)
// Fallback: still try to start the service
try { WebSocketKeepAliveService.start(context) } catch (_: Exception) { }
}
}
}
companion object {
private const val TAG = "CyreneBoot"
}
}
@@ -16,6 +16,7 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import android.content.res.Configuration
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
@@ -48,7 +49,7 @@ class CyreneVoiceInteractionSession(context: Context) :
private fun resolveViewModel(): OverlayViewModel? { private fun resolveViewModel(): OverlayViewModel? {
return try { return try {
GlobalContext.get().get<OverlayViewModel>() GlobalContext.get().get<OverlayViewModel>()
} catch (e: Exception) { } catch (e: Throwable) {
Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e) Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e)
null null
} }
@@ -65,11 +66,40 @@ class CyreneVoiceInteractionSession(context: Context) :
lifecycleRegistry.currentState = Lifecycle.State.CREATED lifecycleRegistry.currentState = Lifecycle.State.CREATED
val vm = overlayViewModel val vm = overlayViewModel
val session = this@CyreneVoiceInteractionSession
val (darkTheme, themeColorKey) = runBlocking {
val prefs = GlobalContext.get().get<PreferencesDataStore>()
val mode = prefs.themeMode.firstOrNull()
val color = prefs.themeColor.firstOrNull() ?: "pink"
val dark = when (mode) {
"light" -> false
"dark" -> true
else -> {
val nightMode = session.context.resources.configuration.uiMode and
Configuration.UI_MODE_NIGHT_MASK
nightMode == Configuration.UI_MODE_NIGHT_YES
}
}
Pair(dark, color)
}
return ComposeView(context).apply { return ComposeView(context).apply {
setViewTreeLifecycleOwner(this@CyreneVoiceInteractionSession) // Configure window as soon as view is attached — before system overrides flags
setViewTreeSavedStateRegistryOwner(this@CyreneVoiceInteractionSession) addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
session.configureWindow()
}
override fun onViewDetachedFromWindow(v: View) {}
})
setViewTreeLifecycleOwner(session)
setViewTreeSavedStateRegistryOwner(session)
setContent { setContent {
CyreneTheme { CyreneTheme(
darkTheme = darkTheme,
presetKey = themeColorKey,
useDynamicColor = themeColorKey == "monet",
) {
if (vm != null) { if (vm != null) {
OverlayContent( OverlayContent(
onDismiss = { finish() }, onDismiss = { finish() },
@@ -92,14 +122,22 @@ class CyreneVoiceInteractionSession(context: Context) :
RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}") RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}")
lifecycleRegistry.currentState = Lifecycle.State.STARTED lifecycleRegistry.currentState = Lifecycle.State.STARTED
// Configure window: extend behind status bar, don't resize for IME // Defer window config — system may override softInputMode after onShow returns
configureWindow() 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) // Only read screen content if user enabled it in settings (default off)
val autoScreenContext = try { val autoScreenContext = try {
val prefs: PreferencesDataStore = GlobalContext.get().get() val prefs: PreferencesDataStore = GlobalContext.get().get()
runBlocking { prefs.autoScreenContext.firstOrNull() } ?: false runBlocking { prefs.autoScreenContext.firstOrNull() } ?: false
} catch (_: Exception) { } catch (_: Throwable) {
false false
} }
if (autoScreenContext) { if (autoScreenContext) {
@@ -111,16 +149,18 @@ class CyreneVoiceInteractionSession(context: Context) :
} }
} }
private fun configureWindow() { fun configureWindow() {
try { try {
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow") val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
method.isAccessible = true method.isAccessible = true
val w = method.invoke(this) as? android.view.Window ?: return val w = method.invoke(this) as? android.view.Window ?: return
// Transparent window so the underlying screen is visible through the overlay
w.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT))
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
w.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) w.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
Log.d(TAG, "Window configured: translucent status/nav, adjust nothing for IME") Log.d(TAG, "Window configured: transparent bg, translucent status/nav")
} catch (e: Exception) { } catch (e: Throwable) {
Log.w(TAG, "Failed to configure window: ${e.message}") Log.w(TAG, "Failed to configure window: ${e.message}")
} }
} }
@@ -0,0 +1,102 @@
package top.yeij.cyrene.service
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.koin.core.context.GlobalContext
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
import top.yeij.cyrene.util.RuntimeLog
class KeepAliveReceiver : BroadcastReceiver() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "Keep-alive alarm fired")
RuntimeLog.notify("keepalive", "Alarm fired in background")
scope.launch {
try {
val prefs: PreferencesDataStore = GlobalContext.get().get()
val token = prefs.token.firstOrNull()
if (token.isNullOrBlank()) {
Log.d(TAG, "No auth token, skipping wake-up")
RuntimeLog.notify("keepalive", "Skipping: no auth token")
return@launch
}
if (!WebSocketKeepAliveService.isRunning) {
WebSocketKeepAliveService.start(context)
RuntimeLog.notify("keepalive", "Foreground service restarted")
}
val repo: ChatRepositoryImpl = GlobalContext.get().get()
val wasConnected = repo.connectionState.value
repo.ensureConnected()
RuntimeLog.notify("keepalive", "WS reconnect triggered: wasConnected=$wasConnected nowConnected=${repo.connectionState.value}")
schedule(context)
} catch (e: Throwable) {
Log.e(TAG, "Keep-alive check failed: ${e.message}", e)
RuntimeLog.notify("keepalive", "Failed: ${e.message}")
schedule(context)
}
}
}
companion object {
private const val TAG = "CyreneKeepAlive"
const val INTERVAL_MS = 5 * 60 * 1000L // 5 minutes
fun schedule(context: Context) {
try {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, KeepAliveReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
// Cancel existing alarm first
alarmManager.cancel(pendingIntent)
val triggerAt = System.currentTimeMillis() + INTERVAL_MS
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent
)
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent
)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule keep-alive: ${e.message}")
}
}
fun cancel(context: Context) {
try {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, KeepAliveReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
alarmManager.cancel(pendingIntent)
} catch (e: Exception) {
Log.e(TAG, "Failed to cancel keep-alive: ${e.message}")
}
}
}
}
@@ -0,0 +1,154 @@
package top.yeij.cyrene.service
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.R
import top.yeij.cyrene.util.RuntimeLog
class WebSocketKeepAliveService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
isRunning = true
createChannel()
acquireWakeLock()
Log.i(TAG, "Service created, wakeLock held")
RuntimeLog.notify("keepalive", "WS keep-alive service created, wakeLock acquired")
}
override fun onDestroy() {
isRunning = false
releaseWakeLock()
scheduleRestart()
Log.i(TAG, "Service destroyed, restart scheduled")
RuntimeLog.notify("keepalive", "WS keep-alive service destroyed, restart scheduled in ${RESTART_DELAY_MS}ms")
super.onDestroy()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "onStartCommand flags=$flags startId=$startId")
startForegroundNotification()
return START_REDELIVER_INTENT
}
override fun onTaskRemoved(rootIntent: Intent?) {
// App swiped away from recents — schedule restart and let it die
Log.i(TAG, "Task removed, scheduling restart")
scheduleRestart()
super.onTaskRemoved(rootIntent)
}
private fun startForegroundNotification() {
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("昔涟")
.setContentText("后台连接中,可接收消息推送")
.setOngoing(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIFICATION_ID, notification, 0x40000001 /* dataSync | specialUse */)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"Cyrene:WebSocketKeepAlive"
).apply {
acquire(10 * 60 * 1000L) // 10 min timeout as safety net
}
}
private fun releaseWakeLock() {
try { wakeLock?.release() } catch (_: Exception) { }
wakeLock = null
}
private fun scheduleRestart() {
try {
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(this, KeepAliveReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val triggerAt = System.currentTimeMillis() + RESTART_DELAY_MS
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
} else {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule restart: ${e.message}")
}
}
private fun createChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"连接状态",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "后台连接保活"
setShowBadge(false)
}
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel)
}
companion object {
private const val TAG = "CyreneKeepAlive"
private const val CHANNEL_ID = "cyrene_keepalive"
private const val NOTIFICATION_ID = 1
@Volatile
var isRunning: Boolean = false
private set
fun start(context: Context) {
if (isRunning) return
context.startForegroundService(
Intent(context, WebSocketKeepAliveService::class.java)
)
}
fun stop(context: Context) {
context.stopService(
Intent(context, WebSocketKeepAliveService::class.java)
)
}
const val RESTART_DELAY_MS = 60_000L
}
}
@@ -21,9 +21,12 @@ import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import java.util.concurrent.atomic.AtomicLong
import top.yeij.cyrene.data.local.PreferencesDataStore import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.dto.WSAttachment
import top.yeij.cyrene.data.remote.dto.WSClientMessage import top.yeij.cyrene.data.remote.dto.WSClientMessage
import top.yeij.cyrene.data.remote.dto.WSServerMessage import top.yeij.cyrene.data.remote.dto.WSServerMessage
import top.yeij.cyrene.util.RuntimeLog
import java.net.URLEncoder import java.net.URLEncoder
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@@ -47,6 +50,8 @@ class WebSocketService(
private var shouldReconnect = true private var shouldReconnect = true
private var currentSessionId: String? = null private var currentSessionId: String? = null
private val connectionId = AtomicInteger(0) private val connectionId = AtomicInteger(0)
@Volatile private var lastMessageReceived = System.currentTimeMillis()
private val deadConnectionTimeoutMs = 30_000L // No message for 30s = treat as dead
private var clientId: String = "" private var clientId: String = ""
private var deviceName: String = "" private var deviceName: String = ""
@@ -132,12 +137,16 @@ class WebSocketService(
_isConnected.value = true _isConnected.value = true
_connectionError.value = null _connectionError.value = null
startHeartbeat() startHeartbeat()
RuntimeLog.ws("lifecycle", "WS connected #$connId")
} }
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
if (connectionId.get() != connId) return if (connectionId.get() != connId) return
lastMessageReceived = System.currentTimeMillis()
try { try {
val msg = gson.fromJson(text, WSServerMessage::class.java) val msg = gson.fromJson(text, WSServerMessage::class.java)
val preview = text.take(100).replace("\n", "\\n")
RuntimeLog.ws("receive", "type=${msg.type} id=${msg.messageId ?: "-"} preview=$preview")
_incomingMessages.tryEmit(msg) _incomingMessages.tryEmit(msg)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "[#$connId] Failed to parse message: ${e.message}") Log.w(TAG, "[#$connId] Failed to parse message: ${e.message}")
@@ -149,6 +158,7 @@ class WebSocketService(
Log.i(TAG, "[#$connId] Server closing: code=$code reason=$reason") Log.i(TAG, "[#$connId] Server closing: code=$code reason=$reason")
_isConnected.value = false _isConnected.value = false
cancelHeartbeat() cancelHeartbeat()
RuntimeLog.ws("lifecycle", "WS closing #$connId code=$code reason='$reason'")
} }
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
@@ -157,6 +167,7 @@ class WebSocketService(
_isConnected.value = false _isConnected.value = false
cancelHeartbeat() cancelHeartbeat()
scheduleReconnect() scheduleReconnect()
RuntimeLog.ws("lifecycle", "WS closed #$connId code=$code")
} }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
@@ -165,6 +176,7 @@ class WebSocketService(
Log.e(TAG, "[#$connId] Failure: ${t.message} (http=$httpCode)", t) Log.e(TAG, "[#$connId] Failure: ${t.message} (http=$httpCode)", t)
_isConnected.value = false _isConnected.value = false
cancelHeartbeat() cancelHeartbeat()
RuntimeLog.ws("lifecycle", "WS failure #$connId http=$httpCode error='${t.message}'")
val errorMsg = when (httpCode) { val errorMsg = when (httpCode) {
403 -> { 403 -> {
@@ -177,7 +189,8 @@ class WebSocketService(
if (errorMsg != null) { if (errorMsg != null) {
_connectionError.value = errorMsg _connectionError.value = errorMsg
} }
// onClosed will always follow, which triggers scheduleReconnect // onClosed may or may not follow — schedule reconnect directly
scheduleReconnect()
} }
}) })
} }
@@ -189,19 +202,21 @@ class WebSocketService(
sessionId: String? = null, sessionId: String? = null,
mode: String? = null, mode: String? = null,
content: String? = null, content: String? = null,
attachments: List<WSAttachment>? = null,
): WSClientMessage = WSClientMessage( ): WSClientMessage = WSClientMessage(
type = type, type = type,
sessionId = sessionId ?: currentSessionId, sessionId = sessionId ?: currentSessionId,
mode = mode, mode = mode,
content = content, content = content,
attachments = attachments,
timestamp = System.currentTimeMillis(), timestamp = System.currentTimeMillis(),
clientId = clientId.ifBlank { null }, clientId = clientId.ifBlank { null },
deviceName = deviceName.ifBlank { null }, deviceName = deviceName.ifBlank { null },
userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}", userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
) )
fun sendMessage(content: String, sessionId: String? = null, mode: String = "text") { fun sendMessage(content: String, sessionId: String? = null, mode: String = "text", attachments: List<WSAttachment>? = null) {
val msg = buildMessage("message", sessionId, mode, content) val msg = buildMessage("message", sessionId, mode, content, attachments = attachments)
webSocket?.send(gson.toJson(msg)) webSocket?.send(gson.toJson(msg))
} }
@@ -240,19 +255,24 @@ class WebSocketService(
} }
fun forceReconnect() { fun forceReconnect() {
RuntimeLog.ws("lifecycle", "forceReconnect called")
shouldReconnect = true shouldReconnect = true
reconnectJob?.cancel() reconnectJob?.cancel()
reconnectJob = null reconnectJob = null
scope.launch { scope.launch {
if (!_isConnected.value) { try {
try { // Close existing socket directly without resetting shouldReconnect
connect(currentSessionId) cancelHeartbeat()
} catch (_: Exception) { } webSocket?.close(1000, "Reconnecting")
} webSocket = null
_isConnected.value = false
connect(currentSessionId)
} catch (_: Exception) { }
} }
} }
fun disconnect() { fun disconnect() {
RuntimeLog.ws("lifecycle", "WS disconnect — user requested")
shouldReconnect = false shouldReconnect = false
reconnectJob?.cancel() reconnectJob?.cancel()
reconnectJob = null reconnectJob = null
@@ -266,9 +286,15 @@ class WebSocketService(
cancelHeartbeat() cancelHeartbeat()
heartbeatJob = scope.launch { heartbeatJob = scope.launch {
while (_isConnected.value) { while (_isConnected.value) {
delay(30_000) delay(15_000)
if (_isConnected.value) { if (!_isConnected.value) break
sendPing() sendPing()
// Check if connection is silently dead (no message received in 60s)
val sinceLastMsg = System.currentTimeMillis() - lastMessageReceived
if (sinceLastMsg > deadConnectionTimeoutMs) {
Log.w(TAG, "No message received for ${sinceLastMsg}ms — connection may be dead, forcing reconnect")
forceReconnect()
break
} }
} }
} }
@@ -1,23 +1,36 @@
package top.yeij.cyrene.ui.components package top.yeij.cyrene.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -25,15 +38,444 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil.request.ImageRequest
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
// --- Markdown block model ---
private sealed class MdBlock {
data class Heading(val level: Int, val text: String) : MdBlock()
data class Paragraph(val text: String) : MdBlock()
data class CodeBlock(val language: String?, val code: String) : MdBlock()
data class ListItem(val ordered: Boolean, val index: Int, val text: String) : MdBlock()
data class Quote(val text: String) : MdBlock()
class ThematicBreak : MdBlock()
}
private fun parseMarkdownBlocks(text: String): List<MdBlock> {
val lines = text.lines()
val blocks = mutableListOf<MdBlock>()
var i = 0
while (i < lines.size) {
val line = lines[i]
val trimmed = line.trimStart()
when {
// Fenced code block
trimmed.startsWith("```") -> {
val lang = trimmed.removePrefix("```").trim().ifBlank { null }
val codeLines = mutableListOf<String>()
i++
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
codeLines.add(lines[i])
i++
}
if (i < lines.size) i++ // skip closing ```
blocks.add(MdBlock.CodeBlock(lang, codeLines.joinToString("\n")))
}
// Heading
trimmed.startsWith("#") -> {
val match = Regex("^(#{1,6})\\s+(.+)$").find(trimmed)
if (match != null) {
val level = match.groupValues[1].length
blocks.add(MdBlock.Heading(level, match.groupValues[2]))
}
i++
}
// Thematic break
trimmed.matches(Regex("^[-*_]{3,}$")) -> {
blocks.add(MdBlock.ThematicBreak())
i++
}
// Blockquote
line.startsWith(">") || trimmed.startsWith(">") -> {
val quoteLines = mutableListOf<String>()
while (i < lines.size) {
val cur = lines[i]
if (cur.trimStart().startsWith(">")) {
quoteLines.add(cur.trimStart().removePrefix(">").trimStart())
i++
} else if (cur.isBlank()) {
i++
break
} else {
break
}
}
if (quoteLines.isNotEmpty()) {
blocks.add(MdBlock.Quote(quoteLines.joinToString("\n")))
}
}
// Unordered list
trimmed.matches(Regex("^[-*+]\\s+.*$")) -> {
while (i < lines.size && lines[i].trimStart().matches(Regex("^[-*+]\\s+.*$"))) {
val itemText = lines[i].trimStart().replaceFirst(Regex("^[-*+]\\s+"), "")
blocks.add(MdBlock.ListItem(false, blocks.size + 1, itemText))
i++
}
}
// Ordered list
trimmed.matches(Regex("^\\d+\\.\\s+.*$")) -> {
var idx = 1
while (i < lines.size && lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*$"))) {
val itemText = lines[i].trimStart().replaceFirst(Regex("^\\d+\\.\\s+"), "")
blocks.add(MdBlock.ListItem(true, idx, itemText))
idx++
i++
}
}
// Blank line — skip
line.isBlank() -> { i++ }
// Paragraph
else -> {
val paraLines = mutableListOf<String>()
while (i < lines.size &&
lines[i].isNotBlank() &&
!lines[i].trimStart().startsWith("```") &&
!lines[i].trimStart().startsWith("#") &&
!lines[i].trimStart().matches(Regex("^[-*_]{3,}$")) &&
!lines[i].trimStart().startsWith(">") &&
!lines[i].trimStart().matches(Regex("^[-*+]\\s+.*$")) &&
!lines[i].trimStart().matches(Regex("^\\d+\\.\\s+.*$"))
) {
paraLines.add(lines[i])
i++
}
if (paraLines.isNotEmpty()) {
blocks.add(MdBlock.Paragraph(paraLines.joinToString(" ")))
}
}
}
}
return blocks
}
@Composable
private fun renderInlineMarkdown(text: String): AnnotatedString {
return buildAnnotatedString {
var remaining = text
while (remaining.isNotEmpty()) {
// Bold + Italic ***
val boldItalic = Regex("""\*\*\*(.+?)\*\*\*""").find(remaining)
// Bold **
val bold = Regex("""\*\*(.+?)\*\*""").find(remaining)
// Italic *
val italic = Regex("""(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)""").find(remaining)
// Inline code `
val code = Regex("""`([^`]+)`""").find(remaining)
// Link [text](url)
val link = Regex("""\[([^\]]+)\]\(([^)]+)\)""").find(remaining)
val matches = listOfNotNull(
boldItalic?.let { "bi" to it },
bold?.let { "b" to it },
italic?.let { "i" to it },
code?.let { "c" to it },
link?.let { "l" to it },
).sortedBy { it.second.range.first }
if (matches.isEmpty()) {
append(remaining)
remaining = ""
} else {
val (kind, match) = matches.first()
// Append text before the match
if (match.range.first > 0) {
append(remaining.substring(0, match.range.first))
}
when (kind) {
"bi" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
append(match.groupValues[1])
}
"b" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(match.groupValues[1])
}
"i" -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
append(match.groupValues[1])
}
"c" -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = Color.Gray.copy(alpha = 0.2f))) {
append(match.groupValues[1])
}
"l" -> {
val label = match.groupValues[1]
val url = match.groupValues[2]
pushStringAnnotation("url", url)
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline)) {
append(label)
}
pop()
}
}
remaining = remaining.substring(match.range.last + 1)
}
}
}
}
// --- Markdown bubble ---
@Composable
private fun MarkdownBubble(content: String, modifier: Modifier = Modifier) {
val blocks = remember(content) { parseMarkdownBlocks(content) }
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f),
shadowElevation = 1.dp,
) {
Column(modifier = Modifier.padding(12.dp)) {
blocks.forEach { block ->
when (block) {
is MdBlock.Heading -> {
val fontSize = when (block.level) {
1 -> 22.sp
2 -> 19.sp
3 -> 17.sp
4 -> 15.sp
5 -> 14.sp
else -> 13.sp
}
Text(
text = renderInlineMarkdown(block.text),
fontSize = fontSize,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = if (block.level <= 2) 6.dp else 2.dp),
)
}
is MdBlock.Paragraph -> {
if (block.text.isNotBlank()) {
Text(
text = renderInlineMarkdown(block.text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is MdBlock.CodeBlock -> {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
shape = MaterialTheme.shapes.small,
color = Color(0xFF1E1E1E),
) {
Column {
if (block.language != null) {
Text(
text = block.language,
modifier = Modifier
.background(Color(0xFF333333))
.padding(horizontal = 10.dp, vertical = 4.dp),
color = Color(0xFFCCCCCC),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
)
}
Text(
text = block.code,
modifier = Modifier.padding(10.dp),
color = Color(0xFFD4D4D4),
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
)
}
}
}
is MdBlock.ListItem -> {
val prefix = if (block.ordered) "${block.index}. " else ""
Row(modifier = Modifier.padding(start = 8.dp, top = 2.dp)) {
Text(
text = prefix,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = renderInlineMarkdown(block.text),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
is MdBlock.Quote -> {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = MaterialTheme.shapes.small,
) {
Row {
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.5f))
.weight(0.012f)
) {}
Text(
text = renderInlineMarkdown(block.text),
modifier = Modifier
.weight(1f)
.padding(8.dp),
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
is MdBlock.ThematicBreak -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.background(
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
shape = MaterialTheme.shapes.extraSmall,
)
.weight(1f)
) {}
}
}
}
}
}
}
// --- Code bubble (standalone code block message) ---
private val codeDarkBg = Color(0xFF1E1E1E)
private val codeSurface = Color(0xFF333333)
@Composable
private fun CodeBubble(content: String, modifier: Modifier = Modifier) {
val (language, code) = remember(content) {
if (content.startsWith("[lang:")) {
val endBracket = content.indexOf("]\n")
if (endBracket > 0) {
content.substring(6, endBracket) to content.substring(endBracket + 2)
} else "Code" to content
} else "Code" to content
}
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
) {
Surface(
shape = MaterialTheme.shapes.medium,
color = codeDarkBg,
shadowElevation = 2.dp,
) {
Column {
// Language header
Box(
modifier = Modifier
.background(codeSurface, MaterialTheme.shapes.medium)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = language,
color = Color(0xFFAAAAAA),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
)
}
}
// Code content
Text(
text = code,
modifier = Modifier.padding(12.dp),
color = Color(0xFFD4D4D4),
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
)
}
}
}
}
// --- Collapsible wrapper for non-chat content ---
private const val COLLAPSE_THRESHOLD = 300
@Composable
private fun CollapsibleBubble(
content: String,
modifier: Modifier = Modifier,
bubble: @Composable (String, Modifier) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val lines = content.lines()
val isLong = content.length > COLLAPSE_THRESHOLD || lines.size > 8
if (!isLong) {
bubble(content, modifier)
return
}
Column(modifier = modifier) {
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier.fillMaxWidth(),
) {
Box(modifier = Modifier.weight(1f)) {
if (expanded) {
bubble(content, Modifier)
} else {
val truncated = lines.take(5).joinToString("\n").let {
if (it.length >= content.length) it else it + "\n"
}
bubble(truncated, Modifier)
}
}
IconButton(
onClick = { expanded = !expanded },
modifier = Modifier
.padding(top = 4.dp)
.size(32.dp),
) {
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = if (expanded) "折叠" else "展开",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
}
// --- Main ChatBubble dispatcher ---
@Composable @Composable
fun ChatBubble( fun ChatBubble(
content: String, content: String,
@@ -41,20 +483,83 @@ fun ChatBubble(
msgType: String, msgType: String,
timestamp: Long, timestamp: Long,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
imageDataUris: List<String> = emptyList(),
) { ) {
val isUser = role == "user" val isUser = role == "user"
val formattedTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp)) val formattedTime = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
when (msgType) { when (msgType) {
"chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier) "chat" -> ChatMessageBubble(content, isUser, formattedTime, modifier, imageDataUris)
"action" -> ActionMessage(content, modifier) "action" -> ActionMessage(content, modifier)
"thinking" -> ThinkingBubble(content, modifier) "markdown" -> CollapsibleBubble(content, modifier) { text, mod ->
"tool_progress" -> ToolProgressBubble(content, modifier) MarkdownBubble(text, mod)
}
"code" -> CollapsibleBubble(content, modifier) { text, mod ->
CodeBubble(text, mod)
}
"thinking" -> CollapsibleBubble(content, modifier) { text, mod ->
ThinkingBubble(text, mod)
}
"tool_progress" -> CollapsibleBubble(content, modifier) { text, mod ->
ToolProgressBubble(text, mod)
}
"system_info" -> SystemInfoBubble(content, modifier) "system_info" -> SystemInfoBubble(content, modifier)
else -> ChatMessageBubble(content, isUser, formattedTime, modifier) else -> ChatMessageBubble(content, isUser, formattedTime, modifier, imageDataUris)
} }
} }
// --- Full-screen image preview dialog ---
@Composable
private fun ImagePreviewDialog(
imageUri: String,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.95f))
.clickable { onDismiss() },
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = Color.White.copy(alpha = 0.6f),
modifier = Modifier.size(48.dp),
)
AsyncImage(
model = ImageRequest.Builder(context)
.data(imageUri)
.crossfade(true)
.build(),
contentDescription = "图片预览",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentScale = ContentScale.Fit,
)
IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp),
) {
Icon(
Icons.Default.Close,
contentDescription = "关闭",
tint = Color.White,
)
}
}
}
}
// --- Chat message bubble ---
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun ChatMessageBubble( private fun ChatMessageBubble(
@@ -62,9 +567,13 @@ private fun ChatMessageBubble(
isUser: Boolean, isUser: Boolean,
time: String, time: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
imageDataUris: List<String> = emptyList(),
) { ) {
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
var previewImageUri by remember { mutableStateOf<String?>(null) }
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
val hasImages = imageDataUris.isNotEmpty()
Row( Row(
modifier = modifier modifier = modifier
@@ -90,14 +599,40 @@ private fun ChatMessageBubble(
onLongClick = { showMenu = true }, onLongClick = { showMenu = true },
), ),
) { ) {
Text( Column {
text = content, if (hasImages) {
modifier = Modifier.padding(12.dp), imageDataUris.forEach { uri ->
color = if (isUser) AsyncImage(
MaterialTheme.colorScheme.onPrimary model = ImageRequest.Builder(context)
else .data(uri)
MaterialTheme.colorScheme.onSurfaceVariant, .crossfade(true)
) .build(),
contentDescription = "图片",
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 240.dp)
.padding(top = 6.dp, start = 6.dp, end = 6.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { previewImageUri = uri },
contentScale = ContentScale.FillWidth,
)
}
}
if (content.isNotBlank()) {
Text(
text = renderInlineMarkdown(content),
modifier = Modifier.padding(
start = 12.dp, end = 12.dp,
top = if (hasImages) 6.dp else 12.dp,
bottom = 12.dp,
),
color = if (isUser)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} }
DropdownMenu( DropdownMenu(
expanded = showMenu, expanded = showMenu,
@@ -123,10 +658,25 @@ private fun ChatMessageBubble(
) )
} }
} }
// Full-screen image preview
if (previewImageUri != null) {
ImagePreviewDialog(
imageUri = previewImageUri!!,
onDismiss = { previewImageUri = null },
)
}
} }
// --- Action message ---
private val actionTagRegex = Regex("""</?action>""", RegexOption.IGNORE_CASE)
@Composable @Composable
private fun ActionMessage(content: String, modifier: Modifier = Modifier) { private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
val displayText = remember(content) {
content.replace(actionTagRegex, "").trim()
}
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -134,9 +684,9 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
horizontalArrangement = Arrangement.Start, horizontalArrangement = Arrangement.Start,
) { ) {
Text( Text(
text = content, text = displayText,
style = MaterialTheme.typography.bodyMedium.copy( style = MaterialTheme.typography.bodyMedium.copy(
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic, fontStyle = FontStyle.Italic,
), ),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
@@ -144,6 +694,8 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
} }
} }
// --- Thinking bubble ---
@Composable @Composable
private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) { private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
Box( Box(
@@ -164,6 +716,8 @@ private fun ThinkingBubble(content: String, modifier: Modifier = Modifier) {
} }
} }
// --- Tool progress bubble ---
@Composable @Composable
private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) { private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
Row( Row(
@@ -186,19 +740,46 @@ private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
} }
} }
// --- System info bubble ---
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) { private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) {
var showMenu by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp), .padding(horizontal = 12.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
) { ) {
Text( Box {
text = content, Text(
style = MaterialTheme.typography.bodySmall, text = content,
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center, 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), modifier = Modifier.size(8.dp),
tint = Color(0xFF4CAF50), tint = Color(0xFF4CAF50),
) )
Text("昔涟", style = MaterialTheme.typography.labelLarge) Text("昔涟", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
} }
CyreneStatus.THINKING -> { CyreneStatus.THINKING -> {
PulsingDot(Color(0xFFFFA726)) PulsingDot(Color(0xFFFFA726))
Text("思考中…", style = MaterialTheme.typography.labelLarge) Text("思考中…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
} }
CyreneStatus.SPEAKING -> { CyreneStatus.SPEAKING -> {
PulsingDot(Color(0xFF42A5F5)) PulsingDot(Color(0xFF42A5F5))
Text("正在说话…", style = MaterialTheme.typography.labelLarge) Text("正在说话…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
} }
CyreneStatus.OFFLINE -> { CyreneStatus.OFFLINE -> {
Icon( Icon(
@@ -66,7 +66,7 @@ fun StatusIndicator(
modifier = Modifier.size(8.dp), modifier = Modifier.size(8.dp),
tint = Color(0xFF9E9E9E), tint = Color(0xFF9E9E9E),
) )
Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge) Text("昔涟 · 离线", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
} }
} }
} }
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.Chat
@@ -16,11 +17,14 @@ import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -29,7 +33,9 @@ import top.yeij.cyrene.ui.screens.iot.IoTScreen
import top.yeij.cyrene.ui.screens.login.LoginScreen import top.yeij.cyrene.ui.screens.login.LoginScreen
import top.yeij.cyrene.ui.screens.about.AboutScreen import top.yeij.cyrene.ui.screens.about.AboutScreen
import top.yeij.cyrene.ui.screens.profile.ProfileScreen import top.yeij.cyrene.ui.screens.profile.ProfileScreen
import top.yeij.cyrene.ui.screens.settings.KeepAlivePage
import top.yeij.cyrene.ui.screens.settings.SettingsScreen import top.yeij.cyrene.ui.screens.settings.SettingsScreen
import top.yeij.cyrene.util.RuntimeLog
object Routes { object Routes {
const val LOGIN = "login" const val LOGIN = "login"
@@ -38,6 +44,7 @@ object Routes {
const val IOT = "iot" const val IOT = "iot"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val ABOUT = "about" const val ABOUT = "about"
const val KEEP_ALIVE = "keep_alive"
} }
@Composable @Composable
@@ -47,6 +54,19 @@ fun CyreneNavGraph(
isDefaultAssistant: Boolean, isDefaultAssistant: Boolean,
onOpenAssistantSettings: () -> Unit, onOpenAssistantSettings: () -> Unit,
) { ) {
// After process death, the NavController may restore a stale back stack
// (e.g. showing SETTINGS instead of MAIN). Reset to the intended start.
LaunchedEffect(Unit) {
val entries = navController.currentBackStack.value
val currentRoute = navController.currentDestination?.route
RuntimeLog.general("nav", "NavGraph start — currentRoute=$currentRoute backStackSize=${entries.size}")
if (entries.size > 1 && entries.first().destination.route != startDestination) {
RuntimeLog.general("nav", "Resetting stale back stack to $startDestination")
navController.popBackStack(startDestination, inclusive = true)
navController.navigate(startDestination)
}
}
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = startDestination,
@@ -71,13 +91,32 @@ fun CyreneNavGraph(
composable(Routes.SETTINGS) { composable(Routes.SETTINGS) {
SettingsScreen( SettingsScreen(
onBack = { navController.popBackStack() }, onBack = {
if (navController.currentDestination?.route == Routes.SETTINGS) {
navController.popBackStack()
}
},
onNavigateToKeepAlive = { navController.navigate(Routes.KEEP_ALIVE) },
)
}
composable(Routes.KEEP_ALIVE) {
KeepAlivePage(
onBack = {
if (navController.currentDestination?.route == Routes.KEEP_ALIVE) {
navController.popBackStack()
}
},
) )
} }
composable(Routes.ABOUT) { composable(Routes.ABOUT) {
AboutScreen( AboutScreen(
onBack = { navController.popBackStack() }, onBack = {
if (navController.currentDestination?.route == Routes.ABOUT) {
navController.popBackStack()
}
},
) )
} }
} }
@@ -124,7 +163,10 @@ fun MainScreen(
items.forEachIndexed { index, item -> items.forEachIndexed { index, item ->
NavigationRailItem( NavigationRailItem(
selected = selectedTab == index, selected = selectedTab == index,
onClick = { selectedTab = index }, onClick = {
selectedTab = index
RuntimeLog.general("nav", "Tab switched to ${item.label} (index=$index)")
},
icon = item.icon, icon = item.icon,
label = { Text(item.label) }, label = { Text(item.label) },
) )
@@ -135,12 +177,31 @@ fun MainScreen(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.clipToBounds()
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
) { ) {
when (selectedTab) { // Keep all tabs alive by offsetting hidden ones off-screen.
0 -> ChatScreen() // clipToBounds ensures they don't intercept touches outside the visible area.
1 -> IoTScreen() Box(
2 -> ProfileScreen( modifier = Modifier
.fillMaxSize()
.offset(x = if (selectedTab == 0) 0.dp else 2000.dp),
) {
ChatScreen()
}
Box(
modifier = Modifier
.fillMaxSize()
.offset(x = if (selectedTab == 1) 0.dp else 2000.dp),
) {
IoTScreen()
}
Box(
modifier = Modifier
.fillMaxSize()
.offset(x = if (selectedTab == 2) 0.dp else 2000.dp),
) {
ProfileScreen(
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) }, onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
onNavigateToAbout = { navController.navigate(Routes.ABOUT) }, onNavigateToAbout = { navController.navigate(Routes.ABOUT) },
onLogout = { onLogout = {
@@ -1,6 +1,7 @@
package top.yeij.cyrene.ui.overlay package top.yeij.cyrene.ui.overlay
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
@@ -17,11 +18,12 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -41,9 +43,11 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -54,6 +58,8 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -65,6 +71,7 @@ import top.yeij.cyrene.ui.components.TypingIndicator
import top.yeij.cyrene.util.RecordState import top.yeij.cyrene.util.RecordState
import top.yeij.cyrene.viewmodel.OverlayState import top.yeij.cyrene.viewmodel.OverlayState
import top.yeij.cyrene.viewmodel.OverlayViewModel import top.yeij.cyrene.viewmodel.OverlayViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel
import kotlin.math.min import kotlin.math.min
@Composable @Composable
@@ -90,6 +97,7 @@ private fun AnimatedChatBubble(
role = message.role, role = message.role,
msgType = message.msgType, msgType = message.msgType,
timestamp = message.timestamp, timestamp = message.timestamp,
imageDataUris = message.imageDataUris,
) )
} }
} }
@@ -99,6 +107,7 @@ fun OverlayContent(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel = koinInject(), viewModel: OverlayViewModel = koinInject(),
settingsViewModel: SettingsViewModel = koinInject(),
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val messages by viewModel.messages.collectAsState() val messages by viewModel.messages.collectAsState()
@@ -106,6 +115,8 @@ fun OverlayContent(
val recordState by viewModel.voiceRecordState.collectAsState() val recordState by viewModel.voiceRecordState.collectAsState()
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState() val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
val animIndex by viewModel.messageAnimIndex.collectAsState() val animIndex by viewModel.messageAnimIndex.collectAsState()
val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
val enterToSend by settingsViewModel.enterToSend.collectAsState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val isProcessing = state == OverlayState.PROCESSING val isProcessing = state == OverlayState.PROCESSING
val recordSec = recordDurationMs / 1000f val recordSec = recordDurationMs / 1000f
@@ -184,10 +195,13 @@ fun OverlayContent(
isRecording = isRecording, isRecording = isRecording,
isLocked = isLocked, isLocked = isLocked,
typingDots = typingDots.value, typingDots = typingDots.value,
typingIndicatorStyle = typingIndicatorStyle,
enterToSend = enterToSend,
animIndex = animIndex, animIndex = animIndex,
onDismiss = onDismiss, onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain, onNavigateToMain = onNavigateToMain,
viewModel = viewModel, viewModel = viewModel,
navBarHeightPx = navBarHeight,
) )
} else { } else {
PortraitContent( PortraitContent(
@@ -200,10 +214,13 @@ fun OverlayContent(
isRecording = isRecording, isRecording = isRecording,
isLocked = isLocked, isLocked = isLocked,
typingDots = typingDots.value, typingDots = typingDots.value,
typingIndicatorStyle = typingIndicatorStyle,
enterToSend = enterToSend,
animIndex = animIndex, animIndex = animIndex,
onDismiss = onDismiss, onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain, onNavigateToMain = onNavigateToMain,
viewModel = viewModel, viewModel = viewModel,
navBarHeightPx = navBarHeight,
) )
} }
} }
@@ -222,10 +239,13 @@ private fun PortraitContent(
isRecording: Boolean, isRecording: Boolean,
isLocked: Boolean, isLocked: Boolean,
typingDots: String, typingDots: String,
typingIndicatorStyle: String,
enterToSend: Boolean,
animIndex: Map<String, Int>, animIndex: Map<String, Int>,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel, viewModel: OverlayViewModel,
navBarHeightPx: Int,
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -253,7 +273,7 @@ private fun PortraitContent(
) )
} }
} }
if (isProcessing) { if (isProcessing && typingIndicatorStyle != "text") {
item(key = "typing_indicator") { item(key = "typing_indicator") {
TypingIndicator() TypingIndicator()
} }
@@ -261,19 +281,20 @@ private fun PortraitContent(
} }
} }
// Input area at bottom, imePadding pushes it above full-screen IME // Input area at bottom; system adjust=pan handles IME offset
InputArea( InputArea(
state = state, state = state,
inputText = inputText, inputText = inputText,
viewModel = viewModel, viewModel = viewModel,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth(),
.imePadding(),
recordSec = recordSec, recordSec = recordSec,
isRecording = isRecording, isRecording = isRecording,
isLocked = isLocked, isLocked = isLocked,
typingDots = typingDots, typingDots = typingDots,
typingIndicatorStyle = typingIndicatorStyle,
enterToSend = enterToSend,
) )
} }
} }
@@ -290,10 +311,13 @@ private fun LandscapeContent(
isRecording: Boolean, isRecording: Boolean,
isLocked: Boolean, isLocked: Boolean,
typingDots: String, typingDots: String,
typingIndicatorStyle: String,
enterToSend: Boolean,
animIndex: Map<String, Int>, animIndex: Map<String, Int>,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel, viewModel: OverlayViewModel,
navBarHeightPx: Int,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -320,7 +344,7 @@ private fun LandscapeContent(
) )
} }
} }
if (isProcessing) { if (isProcessing && typingIndicatorStyle != "text") {
item(key = "typing_indicator") { item(key = "typing_indicator") {
TypingIndicator() TypingIndicator()
} }
@@ -346,12 +370,13 @@ private fun LandscapeContent(
inputText = inputText, inputText = inputText,
viewModel = viewModel, viewModel = viewModel,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(),
.imePadding(),
recordSec = recordSec, recordSec = recordSec,
isRecording = isRecording, isRecording = isRecording,
isLocked = isLocked, isLocked = isLocked,
typingDots = typingDots, typingDots = typingDots,
typingIndicatorStyle = typingIndicatorStyle,
enterToSend = enterToSend,
) )
} }
} }
@@ -393,6 +418,8 @@ private fun InputArea(
isRecording: Boolean = false, isRecording: Boolean = false,
isLocked: Boolean = false, isLocked: Boolean = false,
typingDots: String = "", typingDots: String = "",
typingIndicatorStyle: String = "bubble",
enterToSend: Boolean = false,
) { ) {
// Gesture tracking state — local to InputArea // Gesture tracking state — local to InputArea
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
@@ -402,19 +429,13 @@ private fun InputArea(
val inLockZone = isDragging && dragOffsetX > 60f val inLockZone = isDragging && dragOffsetX > 60f
val isProcessing = state == OverlayState.PROCESSING val isProcessing = state == OverlayState.PROCESSING
Surface( Column(
modifier = modifier.fillMaxWidth(), modifier = modifier
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), .fillMaxWidth()
shadowElevation = 8.dp, .padding(horizontal = 12.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.surface,
) { ) {
Column( // "昔涟正在输入..." indicator (text mode only)
modifier = Modifier if (isProcessing && typingDots.isNotEmpty() && typingIndicatorStyle == "text") {
.fillMaxWidth()
.padding(12.dp),
) {
// "昔涟正在输入..." indicator
if (isProcessing && typingDots.isNotEmpty()) {
Text( Text(
text = "昔涟正在输入$typingDots", text = "昔涟正在输入$typingDots",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -509,7 +530,23 @@ private fun InputArea(
placeholder = { Text("输入消息...") }, placeholder = { Text("输入消息...") },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
maxLines = 3, maxLines = 3,
shape = MaterialTheme.shapes.medium, shape = RoundedCornerShape(24.dp),
colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.35f),
focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f),
),
keyboardOptions = if (enterToSend) {
KeyboardOptions(imeAction = ImeAction.Done)
} else {
KeyboardOptions.Default
},
keyboardActions = if (enterToSend) {
KeyboardActions(
onDone = { if (inputText.isNotBlank()) viewModel.sendText() },
)
} else {
KeyboardActions.Default
},
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
@@ -595,6 +632,5 @@ private fun InputArea(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
}
} }
} }
@@ -1,67 +1,91 @@
package top.yeij.cyrene.ui.screens.chat package top.yeij.cyrene.ui.screens.chat
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardVoice import androidx.compose.material.icons.filled.KeyboardVoice
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.ui.components.ChatBubble import top.yeij.cyrene.ui.components.ChatBubble
import top.yeij.cyrene.ui.components.CyreneStatus import top.yeij.cyrene.ui.components.CyreneStatus
import top.yeij.cyrene.ui.components.StatusIndicator import top.yeij.cyrene.ui.components.StatusIndicator
import top.yeij.cyrene.ui.components.TypingIndicator import top.yeij.cyrene.ui.components.TypingIndicator
import top.yeij.cyrene.util.RecordState import top.yeij.cyrene.util.RecordState
import top.yeij.cyrene.util.RuntimeLog
import top.yeij.cyrene.viewmodel.ChatViewModel import top.yeij.cyrene.viewmodel.ChatViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel
import kotlin.math.min import kotlin.math.min
@Composable @Composable
private fun AnimatedChatBubble( private fun AnimatedChatBubble(
message: Message, message: Message,
@@ -74,9 +98,9 @@ private fun AnimatedChatBubble(
} }
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) + enter = fadeIn(animationSpec = tween(300)) +
slideInVertically( slideInVertically(
animationSpec = androidx.compose.animation.core.tween(300), animationSpec = tween(300),
initialOffsetY = { it / 4 }, initialOffsetY = { it / 4 },
), ),
) { ) {
@@ -85,15 +109,20 @@ private fun AnimatedChatBubble(
role = message.role, role = message.role,
msgType = message.msgType, msgType = message.msgType,
timestamp = message.timestamp, timestamp = message.timestamp,
imageDataUris = message.imageDataUris,
) )
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChatScreen( fun ChatScreen(
viewModel: ChatViewModel = koinViewModel(), viewModel: ChatViewModel = koinViewModel(),
settingsViewModel: SettingsViewModel = koinInject(),
) { ) {
// Track composition to diagnose navigation-related issues
LaunchedEffect(Unit) {
RuntimeLog.general("chat", "ChatScreen composed, ChatViewModel instance resolved")
}
val messages by viewModel.currentMessages.collectAsState() val messages by viewModel.currentMessages.collectAsState()
val inputText by viewModel.inputText.collectAsState() val inputText by viewModel.inputText.collectAsState()
val isStreaming by viewModel.isStreaming.collectAsState() val isStreaming by viewModel.isStreaming.collectAsState()
@@ -102,8 +131,21 @@ fun ChatScreen(
val recordState by viewModel.voiceRecordState.collectAsState() val recordState by viewModel.voiceRecordState.collectAsState()
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState() val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
val animIndex by viewModel.messageAnimIndex.collectAsState() val animIndex by viewModel.messageAnimIndex.collectAsState()
val typingIndicatorStyle by settingsViewModel.typingIndicatorStyle.collectAsState()
val enterToSend by settingsViewModel.enterToSend.collectAsState()
// reverseLayout: index 0 = newest (visual bottom), index N-1 = oldest (visual top)
val listState = rememberLazyListState() val listState = rememberLazyListState()
// Track whether user is near the latest messages (visual bottom = index 0)
val isNearBottom by remember {
derivedStateOf {
val info = listState.layoutInfo
if (info.totalItemsCount == 0) return@derivedStateOf true
(info.visibleItemsInfo.firstOrNull()?.index ?: 0) <= 2
}
}
// Gesture tracking state // Gesture tracking state
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
var dragOffsetX by remember { mutableStateOf(0f) } var dragOffsetX by remember { mutableStateOf(0f) }
@@ -116,12 +158,26 @@ fun ChatScreen(
val inCancelZone = isDragging && dragOffsetY < -120f val inCancelZone = isDragging && dragOffsetY < -120f
val inLockZone = isDragging && dragOffsetX > 60f val inLockZone = isDragging && dragOffsetX > 60f
LaunchedEffect(messages.size, isStreaming) { // Image picker
if (messages.isNotEmpty()) { val selectedImages by viewModel.selectedImageUris.collectAsState()
val targetIndex = if (isStreaming) messages.size else messages.size - 1 val imagePickerLauncher = rememberLauncherForActivityResult(
listState.animateScrollToItem(targetIndex) contract = ActivityResultContracts.GetMultipleContents()
) { uris: List<Uri> ->
if (uris.isNotEmpty()) {
viewModel.addImages(uris)
} }
} }
val context = LocalContext.current
// Stay at bottom for new messages unless user scrolled up
LaunchedEffect(Unit) {
snapshotFlow { messages.size to isNearBottom }
.collect { (_, nearBottom) ->
if (nearBottom && listState.firstVisibleItemIndex != 0) {
listState.animateScrollToItem(0)
}
}
}
// Animated "昔涟正在输入..." dots // Animated "昔涟正在输入..." dots
val typingDots = remember { mutableStateOf("") } val typingDots = remember { mutableStateOf("") }
@@ -145,197 +201,91 @@ fun ChatScreen(
else -> CyreneStatus.OFFLINE else -> CyreneStatus.OFFLINE
} }
Scaffold( // Single column layout: everything flows together and IME shrinks the whole view
topBar = { Column(
Row( modifier = Modifier
modifier = Modifier .fillMaxSize()
.fillMaxWidth() .statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp), .imePadding(),
verticalAlignment = Alignment.CenterVertically, ) {
// Top status bar with refresh button
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
StatusIndicator(status = status)
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = { viewModel.refreshMessages() },
enabled = !isRefreshing,
) { ) {
StatusIndicator(status = status) if (isRefreshing) {
} CircularProgressIndicator(
}, modifier = Modifier.size(20.dp),
bottomBar = { strokeWidth = 2.dp,
Column( )
modifier = Modifier } else {
.fillMaxWidth() Icon(
.navigationBarsPadding(), Icons.Filled.Refresh,
) { contentDescription = "刷新",
// "昔涟正在输入..." indicator tint = MaterialTheme.colorScheme.onSurfaceVariant,
if (isStreaming) {
Text(
text = "昔涟正在输入${typingDots.value}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 2.dp),
textAlign = TextAlign.Start,
) )
} }
}
}
Row( // Selected images preview
modifier = Modifier if (selectedImages.isNotEmpty()) {
.fillMaxWidth() LazyRow(
.padding(horizontal = 12.dp, vertical = 8.dp), modifier = Modifier
verticalAlignment = Alignment.CenterVertically, .fillMaxWidth()
) { .padding(horizontal = 12.dp, vertical = 4.dp),
if (isRecording && isDragging) { horizontalArrangement = Arrangement.spacedBy(8.dp),
// Recording state with drag — show recording indicator ) {
Box( itemsIndexed(selectedImages, key = { i, _ -> i }) { index, uri ->
modifier = Modifier Box(
.weight(1f) modifier = Modifier
.clip(RoundedCornerShape(12.dp)) .size(72.dp)
.background( .clip(RoundedCornerShape(8.dp))
if (inCancelZone) MaterialTheme.colorScheme.errorContainer .border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)),
else MaterialTheme.colorScheme.surfaceVariant ) {
) AsyncImage(
.padding(horizontal = 16.dp, vertical = 14.dp), model = ImageRequest.Builder(context)
contentAlignment = Alignment.Center, .data(uri)
) { .crossfade(true)
Text( .build(),
text = when { contentDescription = "已选图片",
inCancelZone -> "松手取消" modifier = Modifier.fillMaxSize(),
inLockZone -> "松手录音" contentScale = ContentScale.Crop,
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
},
style = MaterialTheme.typography.bodyMedium,
color = if (inCancelZone) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Record button (drag anchor)
Box(
modifier = Modifier
.padding(start = 8.dp)
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Mic,
contentDescription = "录音中",
tint = MaterialTheme.colorScheme.onPrimary,
)
}
} else if (isLocked) {
// Locked (hands-free) mode
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = 16.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Lock,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
IconButton(onClick = { viewModel.finishRecord() }) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "发送",
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
// Normal input mode
OutlinedTextField(
value = inputText,
onValueChange = { viewModel.onInputChanged(it) },
placeholder = { Text("输入消息...") },
modifier = Modifier.weight(1f),
maxLines = 4,
shape = MaterialTheme.shapes.medium,
) )
// Voice record button with long-press gesture IconButton(
Box( onClick = { viewModel.removeImage(index) },
modifier = Modifier modifier = Modifier
.padding(start = 4.dp) .align(Alignment.TopEnd)
.size(48.dp) .size(20.dp)
.onGloballyPositioned { recordButtonY = it.positionInRoot().y } .padding(0.dp),
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
isDragging = true
dragOffsetX = 0f
dragOffsetY = 0f
viewModel.startRecord()
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetX += dragAmount.x
dragOffsetY += dragAmount.y
},
onDragEnd = {
isDragging = false
when {
dragOffsetY < -120f -> viewModel.cancelRecord()
dragOffsetX > 60f -> viewModel.lockRecord()
else -> viewModel.finishRecord()
}
dragOffsetX = 0f
dragOffsetY = 0f
},
onDragCancel = {
isDragging = false
viewModel.cancelRecord()
dragOffsetX = 0f
dragOffsetY = 0f
},
)
},
contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
Icons.Filled.KeyboardVoice, Icons.Default.Close,
contentDescription = "按住录音", contentDescription = "移除",
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier
.size(14.dp)
.background(
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f),
CircleShape,
),
) )
} }
// Send button (only when text present)
if (inputText.isNotBlank()) {
IconButton(
onClick = { viewModel.sendMessage() },
enabled = !isStreaming,
) {
if (isStreaming) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
} else {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
}
}
}
} }
} }
} }
}, }
) { padding ->
PullToRefreshBox( // Messages area (fills remaining space, shrinks with IME)
isRefreshing = isRefreshing, Box(modifier = Modifier.weight(1f)) {
onRefresh = { viewModel.refreshMessages() },
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
if (messages.isEmpty() && !isStreaming) { if (messages.isEmpty() && !isStreaming) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -351,18 +301,211 @@ fun ChatScreen(
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = listState, state = listState,
reverseLayout = true,
) { ) {
items(messages, key = { it.id }) { message -> if (isStreaming && typingIndicatorStyle != "text") {
AnimatedChatBubble(
message = message,
animIndex = animIndex[message.id] ?: 0,
)
}
if (isStreaming) {
item(key = "typing_indicator") { item(key = "typing_indicator") {
TypingIndicator() TypingIndicator()
} }
} }
itemsIndexed(messages, key = { _, msg -> msg.id }) { index, message ->
AnimatedChatBubble(
message = message,
animIndex = index.coerceAtMost(20),
)
}
}
}
}
// Input area at bottom, in flow (not overlaid)
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.navigationBarsPadding(),
) {
// "昔涟正在输入..." indicator (text mode only)
if (isStreaming && typingIndicatorStyle == "text") {
Text(
text = "昔涟正在输入${typingDots.value}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 2.dp),
textAlign = TextAlign.Start,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isRecording && isDragging) {
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(
if (inCancelZone) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.surfaceVariant
)
.padding(horizontal = 16.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = when {
inCancelZone -> "松手取消"
inLockZone -> "松手录音"
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
},
style = MaterialTheme.typography.bodyMedium,
color = if (inCancelZone) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box(
modifier = Modifier
.padding(start = 8.dp)
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Mic,
contentDescription = "录音中",
tint = MaterialTheme.colorScheme.onPrimary,
)
}
} else if (isLocked) {
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = 16.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Lock,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
IconButton(onClick = { viewModel.finishRecord() }) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "发送",
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
IconButton(
onClick = { imagePickerLauncher.launch("image/*") },
) {
Icon(
Icons.Default.AddPhotoAlternate,
contentDescription = "添加图片",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedTextField(
value = inputText,
onValueChange = { viewModel.onInputChanged(it) },
placeholder = { Text("输入消息...") },
modifier = Modifier.weight(1f),
maxLines = 4,
shape = MaterialTheme.shapes.medium,
keyboardOptions = if (enterToSend) {
KeyboardOptions(imeAction = ImeAction.Done)
} else {
KeyboardOptions.Default
},
keyboardActions = if (enterToSend) {
KeyboardActions(
onDone = { if (inputText.isNotBlank()) viewModel.sendMessage() },
)
} else {
KeyboardActions.Default
},
)
Box(
modifier = Modifier
.padding(start = 4.dp)
.size(48.dp)
.onGloballyPositioned { recordButtonY = it.positionInRoot().y }
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
isDragging = true
dragOffsetX = 0f
dragOffsetY = 0f
viewModel.startRecord()
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetX += dragAmount.x
dragOffsetY += dragAmount.y
},
onDragEnd = {
isDragging = false
when {
dragOffsetY < -120f -> viewModel.cancelRecord()
dragOffsetX > 60f -> viewModel.lockRecord()
else -> viewModel.finishRecord()
}
dragOffsetX = 0f
dragOffsetY = 0f
},
onDragCancel = {
isDragging = false
viewModel.cancelRecord()
dragOffsetX = 0f
dragOffsetY = 0f
},
)
},
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.KeyboardVoice,
contentDescription = "按住录音",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (inputText.isNotBlank()) {
IconButton(
onClick = { viewModel.sendMessage() },
enabled = !isStreaming,
) {
if (isStreaming) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
} else {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "发送",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
} }
} }
} }
@@ -0,0 +1,377 @@
package top.yeij.cyrene.ui.screens.settings
import android.app.Activity
import android.content.Intent
import android.os.Build
import android.provider.Settings
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.BatterySaver
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import top.yeij.cyrene.service.WebSocketKeepAliveService
import top.yeij.cyrene.util.KeepAliveManager
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KeepAlivePage(
onBack: () -> Unit,
) {
val context = LocalContext.current
val keepAliveManager = KeepAliveManager(context)
val fgRunning = WebSocketKeepAliveService.isRunning
val batteryExempt = keepAliveManager.isBatteryOptimizationExempt()
val canOverlay = keepAliveManager.canDrawOverlays()
val manufacturerName = keepAliveManager.getManufacturerName()
val batteryLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) {
// Re-check battery optimization after returning from settings
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("后台保活") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
// Header explanation
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
Icons.Filled.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Android 系统会在应用进入后台后限制网络连接或终止进程,导致无法接收服务端主动推送的消息。请按照以下方法加强后台保活能力。",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "保活方式",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
// 1. Foreground Service
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.Notifications,
contentDescription = null,
tint = if (fgRunning) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "前台服务通知",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = if (fgRunning) "已开启,通知栏显示「昔涟 — 已连接」" else "切后台时显示持久通知保活"
)
}
Switch(
checked = fgRunning,
onCheckedChange = {
if (it) {
WebSocketKeepAliveService.start(context)
} else {
WebSocketKeepAliveService.stop(context)
}
},
)
}
}
// 2. Battery Optimization
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = null,
tint = if (batteryExempt) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "忽略电池优化",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = if (batteryExempt) "已免除,Doze 模式不会限制网络"
else "未免除,后台待久会被系统限制网络(Doze 休眠)"
)
}
if (!batteryExempt) {
TextButton(onClick = {
batteryLauncher.launch(
keepAliveManager.openBatteryOptimizationSettings()
)
}) {
Text("去设置")
}
}
}
}
// 3. Auto-start (OEM-specific)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.PlayArrow,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "自启动管理",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = when (manufacturerName) {
"xiaomi" -> "小米手机请在「安全中心 → 自启动管理」中允许昔涟自启动"
"huawei" -> "华为手机请在「手机管家 → 自启动管理」中允许昔涟自启动"
"oppo" -> "OPPO 手机请在「设置 → 应用自启动」中允许昔涟自启动"
"vivo" -> "vivo 手机请在「i管家 → 自启动」中允许昔涟自启动"
"oneplus" -> "一加手机请在「设置 → 自启动」中允许昔涟自启动"
"samsung" -> "三星手机请在「设置 → 电池 → 不受限制的应用」中添加昔涟"
else -> "请在系统设置中为昔涟开启「自启动/后台运行」权限"
}
)
}
TextButton(onClick = {
val intent = keepAliveManager.getAutoStartIntent()
if (intent != null) {
try {
context.startActivity(intent)
} catch (_: Exception) {
// Fallback to app info
try {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
} catch (_: Exception) { }
}
} else {
Toast.makeText(context, "未找到对应设置页面,请手动前往系统设置", Toast.LENGTH_LONG).show()
}
}) {
Text("去设置")
}
}
}
// 4. Lock task (recent apps lock)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.Security,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "锁定后台任务",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = "进入最近任务界面(多任务键),将昔涟卡片下拉锁定,防止系统清理后台时误杀"
)
}
}
}
// 5. Battery saver passthrough
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.BatterySaver,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "电池优化白名单",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = "手动确认系统电池优化白名单,确保昔涟不被限制"
)
}
TextButton(onClick = {
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
try {
context.startActivity(intent)
} catch (_: Exception) { }
}) {
Text("查看")
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "补充提示",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
Text(
text = """
不同的手机厂商对待后台应用的方式各不相同:
• 谷歌 Pixel / 原生 Android:开启电池优化豁免即可
• 小米 MIUI / HyperOS:需同时开启自启动 + 电池无限制
• 华为 HarmonyOS:需开启自启动 + 关闭省电模式限制
• OPPO ColorOS / vivo OriginOS:需开启自启动 + 后台运行
• 三星 OneUI:需添加到「不受限制的应用」列表
实际效果因系统版本和厂商策略而异。建议至少开启「前台服务通知」+「忽略电池优化」两项。
""".trimIndent(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@@ -1,11 +1,18 @@
package top.yeij.cyrene.ui.screens.settings package top.yeij.cyrene.ui.screens.settings
import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -15,18 +22,24 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.BatterySaver
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.SettingsBrightness import androidx.compose.material.icons.filled.SettingsBrightness
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CenterAlignedTopAppBar
@@ -40,18 +53,24 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -60,14 +79,19 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import top.yeij.cyrene.ui.theme.PresetColorLabels
import top.yeij.cyrene.ui.theme.PresetThemeColors
import top.yeij.cyrene.util.KeepAliveManager
import top.yeij.cyrene.util.LogCategory import top.yeij.cyrene.util.LogCategory
import top.yeij.cyrene.util.RootKeepAliveHelper
import top.yeij.cyrene.util.RuntimeLog import top.yeij.cyrene.util.RuntimeLog
import top.yeij.cyrene.viewmodel.SettingsViewModel import top.yeij.cyrene.viewmodel.SettingsViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onNavigateToKeepAlive: () -> Unit = {},
viewModel: SettingsViewModel = koinInject(), viewModel: SettingsViewModel = koinInject(),
) { ) {
val baseUrl by viewModel.baseUrl.collectAsState() val baseUrl by viewModel.baseUrl.collectAsState()
@@ -77,6 +101,10 @@ fun SettingsScreen(
val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState() val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState()
val dashScopeModel by viewModel.dashScopeModel.collectAsState() val dashScopeModel by viewModel.dashScopeModel.collectAsState()
val autoScreenContext by viewModel.autoScreenContext.collectAsState() val autoScreenContext by viewModel.autoScreenContext.collectAsState()
val typingIndicatorStyle by viewModel.typingIndicatorStyle.collectAsState()
val themeColor by viewModel.themeColor.collectAsState()
val enterToSend by viewModel.enterToSend.collectAsState()
val rootKeepAlive by viewModel.rootKeepAlive.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -201,10 +229,345 @@ fun SettingsScreen(
}, },
) )
var showColorDialog by remember { mutableStateOf(false) }
val currentColorLabel = PresetColorLabels[themeColor] ?: "昔涟粉"
ListItem( ListItem(
headlineContent = { Text("主题色") }, headlineContent = { Text("主题色") },
supportingContent = { Text("昔涟紫") }, supportingContent = { Text(currentColorLabel) },
leadingContent = {
Box(
modifier = Modifier
.size(24.dp)
.background(
color = androidx.compose.ui.graphics.Color(
(PresetThemeColors[themeColor]?.seed ?: 0xFFE91E8C).toInt()
),
shape = CircleShape,
),
)
},
modifier = Modifier.clickable { showColorDialog = true },
)
if (showColorDialog) {
AlertDialog(
onDismissRequest = { showColorDialog = false },
title = { Text("选择主题色") },
text = {
Column {
// Monet option (Android 12+)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
val isMonet = themeColor == "monet"
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
viewModel.saveThemeColor("monet")
showColorDialog = false
}
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(36.dp)
.background(
brush = androidx.compose.ui.graphics.Brush.horizontalGradient(
listOf(
androidx.compose.ui.graphics.Color(0xFF4ECDC4),
androidx.compose.ui.graphics.Color(0xFFFF6B6B),
androidx.compose.ui.graphics.Color(0xFFFFE66D),
androidx.compose.ui.graphics.Color(0xFF45B7D1),
)
),
shape = CircleShape,
)
.then(
if (isMonet) Modifier.border(
3.dp,
MaterialTheme.colorScheme.primary,
CircleShape,
) else Modifier
),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "莫奈取色",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "跟随壁纸",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Preset color chips
FlowRow(
modifier = Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
PresetThemeColors.keys.forEach { key ->
val isSelected = themeColor == key
val seedColor = androidx.compose.ui.graphics.Color(
(PresetThemeColors[key]?.seed ?: 0xFFE91E8C).toInt()
)
val label = PresetColorLabels[key] ?: key
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clickable {
viewModel.saveThemeColor(key)
showColorDialog = false
}
.padding(4.dp),
) {
Box(
modifier = Modifier
.size(36.dp)
.background(seedColor, CircleShape)
.then(
if (isSelected) Modifier.border(
3.dp,
MaterialTheme.colorScheme.primary,
CircleShape,
) else Modifier.border(
1.dp,
MaterialTheme.colorScheme.outlineVariant,
CircleShape,
)
),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = if (isSelected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = { showColorDialog = false }) {
Text("关闭")
}
},
)
}
val indicatorStyleLabel = if (typingIndicatorStyle == "text") "文字" else "气泡"
ListItem(
headlineContent = { Text("正在输入指示器") },
supportingContent = { Text(indicatorStyleLabel) },
leadingContent = { Icon(Icons.Filled.Palette, contentDescription = null) }, leadingContent = { Icon(Icons.Filled.Palette, contentDescription = null) },
modifier = Modifier.clickable {
val next = if (typingIndicatorStyle == "bubble") "text" else "bubble"
viewModel.saveTypingIndicatorStyle(next)
},
)
ListItem(
headlineContent = { Text("回车键发送") },
supportingContent = { Text(if (enterToSend) "回车直接发送消息" else "回车换行") },
leadingContent = { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) },
trailingContent = {
androidx.compose.material3.Switch(
checked = enterToSend,
onCheckedChange = { viewModel.saveEnterToSend(it) },
)
},
modifier = Modifier.clickable { viewModel.saveEnterToSend(!enterToSend) },
)
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Background keep-alive
val keepAliveManager = remember { KeepAliveManager(context) }
var isBatteryExempt by remember { mutableStateOf(keepAliveManager.isBatteryOptimizationExempt()) }
val lifecycleOwner = LocalLifecycleOwner.current
// Re-check battery exemption when returning from system settings
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.lifecycle.addObserver(object : androidx.lifecycle.DefaultLifecycleObserver {
override fun onResume(owner: androidx.lifecycle.LifecycleOwner) {
isBatteryExempt = keepAliveManager.isBatteryOptimizationExempt()
}
})
}
// Hidden root toggle: tap section title 5 times to reveal
var rootTapCount by remember { mutableIntStateOf(0) }
var rootRevealed by remember { mutableStateOf(false) }
Text(
text = "后台保活",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(16.dp)
.combinedClickable(
onClick = {
if (!rootRevealed) {
rootTapCount++
if (rootTapCount >= 5) {
rootRevealed = true
rootTapCount = 0
Toast.makeText(context, "已解锁 Root 保活选项", Toast.LENGTH_SHORT).show()
}
}
},
),
)
ListItem(
headlineContent = { Text("忽略电池优化") },
supportingContent = {
Text(
if (isBatteryExempt) "已允许,后台连接更稳定"
else "未允许,建议开启以确保消息推送及时送达"
)
},
leadingContent = {
Icon(
Icons.Filled.BatterySaver,
contentDescription = null,
tint = if (isBatteryExempt) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error,
)
},
modifier = Modifier.clickable {
if (!isBatteryExempt) {
try {
context.startActivity(keepAliveManager.openBatteryOptimizationSettings())
} catch (_: Exception) {
Toast.makeText(context, "无法打开电池优化设置", Toast.LENGTH_SHORT).show()
}
}
},
)
// Root-level keep-alive (hidden by default, revealed after 5 taps)
if (rootRevealed) {
val isRootAvailable = remember { RootKeepAliveHelper.isRootAvailable() }
ListItem(
headlineContent = { Text("Root 保活 (隐藏)") },
supportingContent = {
Text(
if (!isRootAvailable) "未检测到 Root 权限"
else if (rootKeepAlive) "已启用 — 系统级白名单、Doze豁免、强制后台运行"
else "使用 Root 权限将应用加入系统级白名单,对抗任何保活限制"
)
},
leadingContent = {
Icon(
Icons.Filled.Terminal,
contentDescription = null,
tint = if (rootKeepAlive && isRootAvailable) MaterialTheme.colorScheme.tertiary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailingContent = {
Switch(
checked = rootKeepAlive,
enabled = isRootAvailable,
onCheckedChange = { enabled ->
if (enabled) {
val success = RootKeepAliveHelper.applyRootKeepAlive(context.packageName)
if (success) {
viewModel.saveRootKeepAlive(true)
Toast.makeText(context, "Root 保活已启用", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Root 保活应用失败,请检查 Root 权限", Toast.LENGTH_LONG).show()
}
} else {
RootKeepAliveHelper.removeRootKeepAlive(context.packageName)
viewModel.saveRootKeepAlive(false)
Toast.makeText(context, "Root 保活已关闭", Toast.LENGTH_SHORT).show()
}
},
)
},
modifier = Modifier.clickable(enabled = isRootAvailable) {
val newState = !rootKeepAlive
if (newState) {
val success = RootKeepAliveHelper.applyRootKeepAlive(context.packageName)
if (success) {
viewModel.saveRootKeepAlive(true)
Toast.makeText(context, "Root 保活已启用", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Root 保活应用失败", Toast.LENGTH_LONG).show()
}
} else {
RootKeepAliveHelper.removeRootKeepAlive(context.packageName)
viewModel.saveRootKeepAlive(false)
Toast.makeText(context, "Root 保活已关闭", Toast.LENGTH_SHORT).show()
}
},
)
// System wakelock toggle (held only while app is alive, reapplied on boot)
var sysWakeLockHeld by remember { mutableStateOf(false) }
ListItem(
headlineContent = { Text("系统级 WakeLock (隐藏)") },
supportingContent = {
Text(
if (!isRootAvailable) "需要 Root 权限"
else if (sysWakeLockHeld) "已持有系统级内核锁,CPU永不休眠"
else "写入 /sys/power/wake_lock 阻止 CPU 休眠"
)
},
leadingContent = {
Icon(
Icons.Filled.Security,
contentDescription = null,
tint = if (sysWakeLockHeld) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailingContent = {
Switch(
checked = sysWakeLockHeld,
enabled = isRootAvailable,
onCheckedChange = { enable ->
if (enable) {
val ok = RootKeepAliveHelper.acquireSystemWakeLock("CyreneKA")
if (ok) {
sysWakeLockHeld = true
Toast.makeText(context, "系统 WakeLock 已持有 — 注意:将显著增加耗电", Toast.LENGTH_LONG).show()
} else {
Toast.makeText(context, "WakeLock 获取失败", Toast.LENGTH_SHORT).show()
}
} else {
val ok = RootKeepAliveHelper.releaseSystemWakeLock("CyreneKA")
if (ok) {
sysWakeLockHeld = false
Toast.makeText(context, "系统 WakeLock 已释放", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "WakeLock 释放失败", Toast.LENGTH_SHORT).show()
}
}
},
)
},
)
}
ListItem(
headlineContent = { Text("保活设置") },
supportingContent = { Text("前台服务、自启动管理、OEM厂商后台白名单") },
leadingContent = { Icon(Icons.Filled.Security, contentDescription = null) },
modifier = Modifier.clickable { onNavigateToKeepAlive() },
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -464,10 +827,6 @@ fun SettingsScreen(
) )
} }
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Runtime logs // Runtime logs
Text( Text(
text = "运行日志", text = "运行日志",
@@ -489,23 +848,20 @@ fun SettingsScreen(
} }
} }
TabRow(selectedTabIndex = selectedTab) { ScrollableTabRow(
selectedTabIndex = selectedTab,
edgePadding = 16.dp,
divider = {},
) {
tabs.forEachIndexed { index, label -> tabs.forEachIndexed { index, label ->
Tab( Tab(
selected = selectedTab == index, selected = selectedTab == index,
onClick = { selectedTab = index }, onClick = { selectedTab = index },
text = { Text(label, maxLines = 1) }, text = { Text(label, maxLines = 1, style = MaterialTheme.typography.labelMedium) },
) )
} }
} }
val filteredLogs = if (selectedTab == 0) {
logEntries
} else {
val cat = allCategories.getOrNull(selectedTab - 1)
if (cat != null) logEntries.filter { it.category == cat } else emptyList()
}
val currentCategory = if (selectedTab == 0) null else allCategories.getOrNull(selectedTab - 1) val currentCategory = if (selectedTab == 0) null else allCategories.getOrNull(selectedTab - 1)
Row( Row(
@@ -542,7 +898,14 @@ fun SettingsScreen(
} }
} }
if (filteredLogs.isEmpty()) { val displayLogs = if (selectedTab == 0) {
logEntries.takeLast(500)
} else {
val cat = allCategories.getOrNull(selectedTab - 1)
if (cat != null) logEntries.filter { it.category == cat }.takeLast(500) else emptyList()
}
if (displayLogs.isEmpty()) {
Text( Text(
text = "暂无${tabs[selectedTab]}日志", text = "暂无${tabs[selectedTab]}日志",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -550,16 +913,29 @@ fun SettingsScreen(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) )
} else { } else {
Column( // Log count header
Text(
text = "${displayLogs.size}${if (displayLogs.size >= 500) "+" else ""}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
// Fixed-height scrollable log area
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 12.dp)
.weight(1f, fill = false), .height(280.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
RoundedCornerShape(8.dp),
)
.padding(8.dp),
) { ) {
Column( Column(
modifier = Modifier.verticalScroll(scrollState), modifier = Modifier.verticalScroll(scrollState),
) { ) {
filteredLogs.takeLast(500).forEach { entry -> displayLogs.forEach { entry ->
Text( Text(
text = entry.formatted(), text = entry.formatted(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -1,58 +1,201 @@
package top.yeij.cyrene.ui.theme package top.yeij.cyrene.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
// Light theme // Each preset provides a seed color and light/dark primary colors
val LightPrimary = Color(0xFF6D3BC0) data class ThemePreset(
val LightOnPrimary = Color(0xFFFFFFFF) val seed: Long,
val LightPrimaryContainer = Color(0xFFEEDCFF) val lightPrimary: Color,
val LightOnPrimaryContainer = Color(0xFF250058) val darkPrimary: Color,
val LightSecondary = Color(0xFF625B71)
val LightOnSecondary = Color(0xFFFFFFFF)
val LightSecondaryContainer = Color(0xFFE8DEF8)
val LightOnSecondaryContainer = Color(0xFF1E192B)
val LightTertiary = Color(0xFF7E5260)
val LightOnTertiary = Color(0xFFFFFFFF)
val LightTertiaryContainer = Color(0xFFFFD9E3)
val LightOnTertiaryContainer = Color(0xFF31101D)
val LightBackground = Color(0xFFFFFBFF)
val LightOnBackground = Color(0xFF1C1B1F)
val LightSurface = Color(0xFFFFFBFF)
val LightOnSurface = Color(0xFF1C1B1F)
val LightSurfaceVariant = Color(0xFFE7E0EC)
val LightOnSurfaceVariant = Color(0xFF49454F)
val LightError = Color(0xFFBA1A1A)
val LightOutline = Color(0xFF79747E)
val LightOutlineVariant = Color(0xFFCAC4D0)
// Dark theme
val DarkPrimary = Color(0xFFD3BBFF)
val DarkOnPrimary = Color(0xFF3D0089)
val DarkPrimaryContainer = Color(0xFF541BA6)
val DarkOnPrimaryContainer = Color(0xFFEEDCFF)
val DarkSecondary = Color(0xFFCBC2DC)
val DarkOnSecondary = Color(0xFF332D41)
val DarkSecondaryContainer = Color(0xFF4A4458)
val DarkOnSecondaryContainer = Color(0xFFE8DEF8)
val DarkTertiary = Color(0xFFEFB8C8)
val DarkOnTertiary = Color(0xFF4A2532)
val DarkTertiaryContainer = Color(0xFF633B48)
val DarkOnTertiaryContainer = Color(0xFFFFD9E3)
val DarkBackground = Color(0xFF1C1B1F)
val DarkOnBackground = Color(0xFFE6E1E5)
val DarkSurface = Color(0xFF1C1B1F)
val DarkOnSurface = Color(0xFFE6E1E5)
val DarkSurfaceVariant = Color(0xFF49454F)
val DarkOnSurfaceVariant = Color(0xFFCAC4D0)
val DarkError = Color(0xFFFFB4AB)
val DarkOutline = Color(0xFF938F99)
val DarkOutlineVariant = Color(0xFF49454F)
// Preset seed colors for manual theme selection
val SeedColors = mapOf(
"default" to 0xFF6D3BC0, // Lavender
"sakura" to 0xFFFFB4C8, // Pink
"ocean" to 0xFF6BA4FF, // Blue
"forest" to 0xFF6BCF7C, // Green
"sunset" to 0xFFFF9E6B, // Orange
) )
val PresetThemeColors = mapOf(
"pink" to ThemePreset(
seed = 0xFFE91E8C,
lightPrimary = Color(0xFFC2185B),
darkPrimary = Color(0xFFFF80AB),
),
"sakura" to ThemePreset(
seed = 0xFFFFB4C8,
lightPrimary = Color(0xFFE91E63),
darkPrimary = Color(0xFFFFB4C8),
),
"lavender" to ThemePreset(
seed = 0xFF6D3BC0,
lightPrimary = Color(0xFF6D3BC0),
darkPrimary = Color(0xFFD3BBFF),
),
"ocean" to ThemePreset(
seed = 0xFF1565C0,
lightPrimary = Color(0xFF1565C0),
darkPrimary = Color(0xFF90CAF9),
),
"forest" to ThemePreset(
seed = 0xFF2E7D32,
lightPrimary = Color(0xFF2E7D32),
darkPrimary = Color(0xFFA5D6A7),
),
"sunset" to ThemePreset(
seed = 0xFFE65100,
lightPrimary = Color(0xFFE65100),
darkPrimary = Color(0xFFFFCC80),
),
"rose" to ThemePreset(
seed = 0xFFD81B60,
lightPrimary = Color(0xFFAD1457),
darkPrimary = Color(0xFFF48FB1),
),
"sky" to ThemePreset(
seed = 0xFF0277BD,
lightPrimary = Color(0xFF0277BD),
darkPrimary = Color(0xFF81D4FA),
),
"mint" to ThemePreset(
seed = 0xFF00695C,
lightPrimary = Color(0xFF00695C),
darkPrimary = Color(0xFF80CBC4),
),
)
val PresetColorLabels = mapOf(
"pink" to "昔涟粉",
"sakura" to "樱花粉",
"lavender" to "薰衣草紫",
"ocean" to "海洋蓝",
"forest" to "森林绿",
"sunset" to "日落橙",
"rose" to "玫瑰红",
"sky" to "天空蓝",
"mint" to "薄荷青",
)
fun getPreset(key: String): ThemePreset = PresetThemeColors[key] ?: PresetThemeColors["pink"]!!
// --- Color derivation via HSL — generates cohesive MD3-like schemes ---
private data class HSL(val h: Float, val s: Float, val l: Float)
private fun Color.toHSL(): HSL {
val r = red / 255f
val g = green / 255f
val b = blue / 255f
val maxV = max(max(r, g), b)
val minV = min(min(r, g), b)
val delta = maxV - minV
val l = (maxV + minV) / 2f
val s = if (delta == 0f) 0f else delta / (1f - abs(2f * l - 1f))
val h = when {
delta == 0f -> 0f
maxV == r -> 60f * (((g - b) / delta) % 6f)
maxV == g -> 60f * (((b - r) / delta) + 2f)
else -> 60f * (((r - g) / delta) + 4f)
}
return HSL(if (h < 0) h + 360f else h, s, l)
}
private fun hslToColor(h: Float, s: Float, l: Float): Color {
val c = (1f - abs(2f * l - 1f)) * s
val x = c * (1f - abs((h / 60f) % 2f - 1f))
val m = l - c / 2f
val (r, g, b) = when {
h < 60f -> Triple(c, x, 0f)
h < 120f -> Triple(x, c, 0f)
h < 180f -> Triple(0f, c, x)
h < 240f -> Triple(0f, x, c)
h < 300f -> Triple(x, 0f, c)
else -> Triple(c, 0f, x)
}
return Color((r + m).coerceIn(0f, 1f), (g + m).coerceIn(0f, 1f), (b + m).coerceIn(0f, 1f))
}
/**
* Build a full light ColorScheme derived from a primary color.
* All secondary/tertiary/container colors are computed from the primary
* so focus rings, ripples, badges, and containers all match the theme.
*/
fun buildLightScheme(primary: Color): ColorScheme {
val hsl = primary.toHSL()
val primaryContainer = hslToColor(hsl.h, 0.3f, 0.90f)
val onPrimaryContainer = hslToColor(hsl.h, 0.5f, 0.15f)
// Secondary: similar hue, less saturated
val secH = (hsl.h + 15f) % 360f
val secondary = hslToColor(secH, 0.35f, 0.42f)
val secondaryContainer = hslToColor(secH, 0.25f, 0.90f)
val onSecondaryContainer = hslToColor(secH, 0.3f, 0.15f)
// Tertiary: complementary hue shift
val terH = (hsl.h + 60f) % 360f
val tertiary = hslToColor(terH, 0.40f, 0.38f)
val tertiaryContainer = hslToColor(terH, 0.30f, 0.90f)
val onTertiaryContainer = hslToColor(terH, 0.35f, 0.15f)
val onPrimary = Color.White
val onSecondary = Color.White
val onTertiary = Color.White
val surfaceTint = primary
return lightColorScheme(
primary = primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = onPrimaryContainer,
secondary = secondary,
onSecondary = onSecondary,
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSecondaryContainer,
tertiary = tertiary,
onTertiary = onTertiary,
tertiaryContainer = tertiaryContainer,
onTertiaryContainer = onTertiaryContainer,
surfaceTint = surfaceTint,
)
}
/**
* Build a full dark ColorScheme derived from a primary color.
*/
fun buildDarkScheme(primary: Color): ColorScheme {
val hsl = primary.toHSL()
val primaryContainer = hslToColor(hsl.h, 0.40f, 0.22f)
val onPrimaryContainer = hslToColor(hsl.h, 0.30f, 0.88f)
val onPrimary = hslToColor(hsl.h, 0.5f, 0.10f)
val secH = (hsl.h + 15f) % 360f
val secondary = hslToColor(secH, 0.40f, 0.76f)
val secondaryContainer = hslToColor(secH, 0.30f, 0.22f)
val onSecondaryContainer = hslToColor(secH, 0.30f, 0.88f)
val onSecondary = hslToColor(secH, 0.3f, 0.12f)
val terH = (hsl.h + 60f) % 360f
val tertiary = hslToColor(terH, 0.40f, 0.80f)
val tertiaryContainer = hslToColor(terH, 0.30f, 0.22f)
val onTertiaryContainer = hslToColor(terH, 0.30f, 0.88f)
val onTertiary = hslToColor(terH, 0.3f, 0.12f)
val surfaceTint = primary
return darkColorScheme(
primary = primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = onPrimaryContainer,
secondary = secondary,
onSecondary = onSecondary,
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSecondaryContainer,
tertiary = tertiary,
onTertiary = onTertiary,
tertiaryContainer = tertiaryContainer,
onTertiaryContainer = onTertiaryContainer,
surfaceTint = surfaceTint,
)
}
@@ -4,10 +4,8 @@ import android.app.Activity
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
@@ -15,79 +13,38 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme(
primary = LightPrimary,
onPrimary = LightOnPrimary,
primaryContainer = LightPrimaryContainer,
onPrimaryContainer = LightOnPrimaryContainer,
secondary = LightSecondary,
onSecondary = LightOnSecondary,
secondaryContainer = LightSecondaryContainer,
onSecondaryContainer = LightOnSecondaryContainer,
tertiary = LightTertiary,
onTertiary = LightOnTertiary,
tertiaryContainer = LightTertiaryContainer,
onTertiaryContainer = LightOnTertiaryContainer,
background = LightBackground,
onBackground = LightOnBackground,
surface = LightSurface,
onSurface = LightOnSurface,
surfaceVariant = LightSurfaceVariant,
onSurfaceVariant = LightOnSurfaceVariant,
error = LightError,
outline = LightOutline,
outlineVariant = LightOutlineVariant,
)
private val DarkColorScheme = darkColorScheme(
primary = DarkPrimary,
onPrimary = DarkOnPrimary,
primaryContainer = DarkPrimaryContainer,
onPrimaryContainer = DarkOnPrimaryContainer,
secondary = DarkSecondary,
onSecondary = DarkOnSecondary,
secondaryContainer = DarkSecondaryContainer,
onSecondaryContainer = DarkOnSecondaryContainer,
tertiary = DarkTertiary,
onTertiary = DarkOnTertiary,
tertiaryContainer = DarkTertiaryContainer,
onTertiaryContainer = DarkOnTertiaryContainer,
background = DarkBackground,
onBackground = DarkOnBackground,
surface = DarkSurface,
onSurface = DarkOnSurface,
surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = DarkOnSurfaceVariant,
error = DarkError,
outline = DarkOutline,
outlineVariant = DarkOutlineVariant,
)
@Composable @Composable
fun CyreneTheme( fun CyreneTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true, presetKey: String = "pink",
useDynamicColor: Boolean = false,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val preset = getPreset(presetKey)
val colorScheme = when { val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
darkTheme -> DarkColorScheme darkTheme -> buildDarkScheme(preset.darkPrimary)
else -> LightColorScheme else -> buildLightScheme(preset.lightPrimary)
} }
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
val window = (view.context as Activity).window val window = (view.context as? Activity)?.window
window.statusBarColor = colorScheme.background.toArgb() if (window != null) {
window.navigationBarColor = colorScheme.background.toArgb() window.statusBarColor = colorScheme.background.toArgb()
window.decorView.setBackgroundColor(colorScheme.background.toArgb()) window.navigationBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).apply { window.decorView.setBackgroundColor(colorScheme.background.toArgb())
isAppearanceLightStatusBars = !darkTheme WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightNavigationBars = !darkTheme 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 android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import top.yeij.cyrene.MainActivity import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.R
import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.domain.model.Message
class NotificationHelper(private val context: Context) { class NotificationHelper(private val context: Context) {
@@ -47,7 +48,7 @@ class NotificationHelper(private val context: Context) {
} }
val notification = NotificationCompat.Builder(context, CHANNEL_ID) val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("昔涟") .setContentTitle("昔涟")
.setContentText(preview) .setContentText(preview)
.setAutoCancel(true) .setAutoCancel(true)
@@ -55,7 +56,9 @@ class NotificationHelper(private val context: Context) {
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.build() .build()
notificationManager.notify(message.id.hashCode(), notification) val notifyId = message.id.hashCode()
notificationManager.notify(notifyId, notification)
RuntimeLog.notify("posted", "Notification posted: id=$notifyId preview='$preview'")
} }
fun cancelAll() { fun cancelAll() {
@@ -0,0 +1,170 @@
package top.yeij.cyrene.util
import android.util.Log
import java.io.DataOutputStream
import java.io.File
/**
* Hidden root-based keep-alive operations. Only accessible via secret gesture in Settings.
* Performs system-level whitelisting that normal apps cannot do.
*/
object RootKeepAliveHelper {
private const val TAG = "CyreneRootKA"
private fun execRoot(vararg commands: String): Boolean {
return try {
val process = Runtime.getRuntime().exec("su")
val os = DataOutputStream(process.outputStream)
val shell = buildString {
append("export PATH=\$PATH:/system/bin:/system/xbin:/su/bin:/sbin:/vendor/bin\n")
for (cmd in commands) {
append(cmd).append(" 2>&1\n")
}
append("exit\n")
}
os.writeBytes(shell)
os.flush()
os.close()
process.waitFor()
val exitCode = process.exitValue()
if (exitCode != 0) {
Log.w(TAG, "Root command returned exit code $exitCode")
}
exitCode == 0
} catch (e: Exception) {
Log.e(TAG, "Root exec failed: ${e.message}")
false
}
}
fun isRootAvailable(): Boolean {
// Check common su binary locations
val suPaths = listOf(
"/system/bin/su",
"/system/xbin/su",
"/su/bin/su",
"/sbin/su",
"/system/sbin/su",
"/vendor/bin/su",
"/data/local/bin/su",
)
for (path in suPaths) {
if (File(path).exists()) return true
}
// Fallback: try running 'which su'
return try {
val p = Runtime.getRuntime().exec(arrayOf("which", "su"))
p.waitFor()
p.exitValue() == 0
} catch (_: Exception) {
false
}
}
/**
* Apply aggressive root-level keep-alive.
* @param packageName The app's package name.
*/
fun applyRootKeepAlive(packageName: String): Boolean {
if (!isRootAvailable()) {
Log.w(TAG, "Root not available, cannot apply root keep-alive")
return false
}
Log.i(TAG, "Applying root keep-alive for $packageName")
val commands = mutableListOf<String>()
// 1. Doze whitelist — prevent Doze from blocking network/wakelocks
commands.add("dumpsys deviceidle whitelist +$packageName")
// 2. Disable standby bucket — keep app in "active" bucket
commands.add("am set-standby-bucket $packageName active")
// 3. Grant WAKE_LOCK permission at system level (bypasses appops)
commands.add("appops set $packageName WAKE_LOCK allow")
// 4. Disable battery optimization via system settings
commands.add("settings put global app_restrictions_enabled false")
commands.add("cmd appops set $packageName RUN_IN_BACKGROUND allow")
commands.add("cmd appops set $packageName RUN_ANY_IN_BACKGROUND allow")
// 5. Make app not battery-restricted (hidden API)
commands.add("cmd deviceidle tempwhitelist $packageName")
// 6. Set app as START_FOREGROUND always allowed
commands.add("appops set $packageName START_FOREGROUND allow")
// 7. Persistent alarm allowance
commands.add("appops set $packageName SCHEDULE_EXACT_ALARM allow")
val success = execRoot(*commands.toTypedArray())
if (success) {
Log.i(TAG, "Root keep-alive applied successfully")
} else {
Log.e(TAG, "Failed to apply root keep-alive")
}
return success
}
/**
* Remove root-level keep-alive settings.
*/
fun removeRootKeepAlive(packageName: String): Boolean {
if (!isRootAvailable()) return false
Log.i(TAG, "Removing root keep-alive for $packageName")
val commands = listOf(
"dumpsys deviceidle whitelist -$packageName",
"am set-standby-bucket $packageName rarely",
"appops set $packageName WAKE_LOCK default",
"appops set $packageName RUN_IN_BACKGROUND default",
"appops set $packageName RUN_ANY_IN_BACKGROUND default",
"appops set $packageName START_FOREGROUND default",
"appops set $packageName SCHEDULE_EXACT_ALARM default",
)
val success = execRoot(*commands.toTypedArray())
if (success) {
Log.i(TAG, "Root keep-alive removed successfully")
}
return success
}
/**
* Hold a system-level wakelock. Use sparingly — drains battery.
* Released automatically when the process exits or can be released manually.
*/
private var wakeLockFile: File? = null
fun acquireSystemWakeLock(tag: String): Boolean {
if (!isRootAvailable()) return false
val lockPath = "/sys/power/wake_lock"
return try {
execRoot("echo '$tag' > $lockPath")
wakeLockFile = File(lockPath)
Log.i(TAG, "System wakelock acquired: $tag")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to acquire system wakelock: ${e.message}")
false
}
}
fun releaseSystemWakeLock(tag: String): Boolean {
if (!isRootAvailable()) return false
val lockPath = "/sys/power/wake_unlock"
return try {
execRoot("echo '$tag' > $lockPath")
wakeLockFile = null
Log.i(TAG, "System wakelock released: $tag")
true
} catch (e: Exception) {
Log.e(TAG, "Failed to release system wakelock: ${e.message}")
false
}
}
}
@@ -20,6 +20,7 @@ enum class LogCategory(val label: String) {
GENERAL("通用"), GENERAL("通用"),
HTTP("网络"), HTTP("网络"),
VOICE("语音"), VOICE("语音"),
NOTIFY("通知"),
} }
data class LogEntry( data class LogEntry(
@@ -59,6 +60,7 @@ object RuntimeLog {
fun general(tag: String, message: String) = log(LogCategory.GENERAL, tag, message) fun general(tag: String, message: String) = log(LogCategory.GENERAL, tag, message)
fun http(tag: String, message: String) = log(LogCategory.HTTP, tag, message) fun http(tag: String, message: String) = log(LogCategory.HTTP, tag, message)
fun voice(tag: String, message: String) = log(LogCategory.VOICE, tag, message) fun voice(tag: String, message: String) = log(LogCategory.VOICE, tag, message)
fun notify(tag: String, message: String) = log(LogCategory.NOTIFY, tag, message)
@Synchronized @Synchronized
fun getByCategory(category: LogCategory): List<LogEntry> { fun getByCategory(category: LogCategory): List<LogEntry> {
@@ -1,16 +1,28 @@
package top.yeij.cyrene.viewmodel package top.yeij.cyrene.viewmodel
import android.app.Application
import android.net.Uri
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.data.remote.dto.WSAttachment
import top.yeij.cyrene.domain.model.Conversation import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.domain.repository.ChatRepository import top.yeij.cyrene.domain.repository.ChatRepository
@@ -20,41 +32,22 @@ import top.yeij.cyrene.util.VoiceRecorder
private fun List<Message>.deduplicate(): List<Message> { private fun List<Message>.deduplicate(): List<Message> {
if (isEmpty()) return this if (isEmpty()) return this
val result = mutableListOf(this[0]) val seen = mutableSetOf<String>()
for (i in 1 until size) { return filter { seen.add(it.id) }
val prev = result.last()
val curr = this[i]
val isDuplicate = curr.id == prev.id ||
(curr.role == prev.role && curr.content == prev.content && curr.msgType == prev.msgType)
if (!isDuplicate) {
result.add(curr)
}
}
return result
}
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
if (size < 3) return this
val toRemove = mutableSetOf<String>()
for (msg in this) {
val containedCount = count { other ->
other.id != msg.id &&
other.content.isNotBlank() &&
other.content.length < msg.content.length &&
msg.content.contains(other.content) &&
kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
}
if (containedCount >= 2) {
toRemove.add(msg.id)
}
}
return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
} }
class ChatViewModel( class ChatViewModel(
application: Application,
private val chatRepository: ChatRepository, private val chatRepository: ChatRepository,
private val voiceRecorder: VoiceRecorder, private val voiceRecorder: VoiceRecorder,
) : ViewModel() { private val preferencesDataStore: PreferencesDataStore,
private val apiService: ApiService,
) : AndroidViewModel(application) {
companion object {
private var instanceCounter = 0
}
private val instanceId = ++instanceCounter
val isConnected: StateFlow<Boolean> = chatRepository.connectionState val isConnected: StateFlow<Boolean> = chatRepository.connectionState
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
@@ -91,31 +84,36 @@ class ChatViewModel(
private var currentSessionId: String? = null private var currentSessionId: String? = null
private var dbObserverJob: Job? = null private var dbObserverJob: Job? = null
private var sendTimeoutJob: Job? = null
init { init {
// Phase 1: find/create main session, reconnect WS, load server history RuntimeLog.general("app", "ChatViewModel instance #$instanceId created")
viewModelScope.launch { viewModelScope.launch {
try { try {
RuntimeLog.general("app", "ChatViewModel #$instanceId — initializing session...")
val sessionId = chatRepository.initializeSession() val sessionId = chatRepository.initializeSession()
currentSessionId = sessionId currentSessionId = sessionId
chatRepository.currentSessionId = sessionId chatRepository.currentSessionId = sessionId
RuntimeLog.general("app", "Session initialized: $sessionId")
chatRepository.ensureConnected() chatRepository.ensureConnected()
loadMessagesFromDb(sessionId) } catch (e: Exception) {
val serverMessages = chatRepository.loadMessagesFromServer(sessionId) RuntimeLog.general("app", "initializeSession failed: ${e.message}")
if (serverMessages.isNotEmpty()) { }
val serverIds = serverMessages.map { it.id }.toSet() // Always try to load from DB, even if initializeSession failed.
_currentMessages.update { current -> // After process death the persisted session ID gives us the history.
val localOnly = current.filter { it.id !in serverIds } val sid = currentSessionId
(serverMessages + localOnly) ?: preferencesDataStore.currentSessionId.firstOrNull()
.sortedBy { it.timestamp } if (sid != null) {
.deduplicate() currentSessionId = sid
.removeWrappingDuplicates() chatRepository.currentSessionId = sid
} RuntimeLog.general("app", "Loading messages from DB for session=$sid")
} loadMessagesFromDb(sid)
} catch (_: Exception) { } } else {
RuntimeLog.general("app", "No session ID available — cannot load messages")
}
} }
// Observe incoming live messages with atomic dedup // Observe incoming live messages — insert at correct descending position
viewModelScope.launch { viewModelScope.launch {
chatRepository.observeMessages().collect { message -> chatRepository.observeMessages().collect { message ->
try { try {
@@ -125,18 +123,20 @@ class ChatViewModel(
if (existingIdx >= 0) { if (existingIdx >= 0) {
updated[existingIdx] = message updated[existingIdx] = message
} else { } else {
val isDup = updated.any { // Insert at correct position for descending timestamp (newest first)
it.role == message.role && it.content == message.content && it.msgType == message.msgType val insertAt = updated.indexOfFirst { it.timestamp <= message.timestamp }
} if (insertAt >= 0) updated.add(insertAt, message) else updated.add(message)
if (!isDup) { val idx = _messageAnimIndex.value.toMutableMap()
updated.add(message) idx[message.id] = animCounter++
val idx = _messageAnimIndex.value.toMutableMap() _messageAnimIndex.value = idx
idx[message.id] = animCounter++
_messageAnimIndex.value = idx
}
} }
updated.deduplicate() updated.deduplicate()
} }
// Any non-user response means the server acknowledged our message
if (_isSending.value && message.role != "user") {
_isSending.value = false
sendTimeoutJob?.cancel()
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ChatViewModel", "Error processing message: ${e.message}", e) Log.e("ChatViewModel", "Error processing message: ${e.message}", e)
} }
@@ -151,12 +151,25 @@ class ChatViewModel(
animCounter = 0 animCounter = 0
} }
} }
// Observe message removals (e.g. wrapping stream_end deduped by review items)
viewModelScope.launch {
chatRepository.messageRemovals.collect { msgId ->
_currentMessages.update { list -> list.filter { it.id != msgId } }
val idx = _messageAnimIndex.value.toMutableMap()
idx.remove(msgId)
_messageAnimIndex.value = idx
}
}
// Reset user-side sending state when server starts responding // Reset user-side sending state when server starts responding
viewModelScope.launch { viewModelScope.launch {
chatRepository.isAssistantStreaming.collect { streaming -> chatRepository.isAssistantStreaming.collect { streaming ->
if (streaming) _isSending.value = false if (streaming) {
_isSending.value = false
sendTimeoutJob?.cancel()
}
} }
} }
} }
// --- Voice recording (WeChat-style gesture) --- // --- Voice recording (WeChat-style gesture) ---
@@ -185,6 +198,113 @@ class ChatViewModel(
voiceRecorder.cancel() voiceRecorder.cancel()
} }
// --- Image attachments ---
private val _selectedImageUris = MutableStateFlow<List<Uri>>(emptyList())
val selectedImageUris: StateFlow<List<Uri>> = _selectedImageUris.asStateFlow()
fun addImages(uris: List<Uri>) {
_selectedImageUris.update { it + uris }
}
fun removeImage(index: Int) {
_selectedImageUris.update { list ->
list.filterIndexed { i, _ -> i != index }
}
}
fun clearImages() {
_selectedImageUris.value = emptyList()
}
private data class UploadResult(
val attachment: WSAttachment,
val thumbnailUrl: String,
)
private suspend fun uploadAndBuildAttachment(uri: Uri): UploadResult? {
return withContext(Dispatchers.IO) {
try {
val cr = getApplication<Application>().contentResolver
val mimeType = cr.getType(uri) ?: "image/jpeg"
val filename = uri.lastPathSegment ?: "image"
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: return@withContext null
if (bytes.isEmpty()) return@withContext null
// Upload to server, get file_id
val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", filename, requestBody)
val response = apiService.uploadFile(part)
if (!response.isSuccessful) {
Log.e("ChatViewModel", "Upload failed: ${response.code()} ${response.message()}")
return@withContext null
}
val fileId = response.body()?.id ?: return@withContext null
// Construct thumbnail URL
val baseUrl = preferencesDataStore.baseUrl.firstOrNull()
?.trimEnd('/') ?: "http://10.0.2.2:8080"
val thumbnailUrl = "$baseUrl/api/v1/files/$fileId/thumbnail"
val attachment = WSAttachment(
type = "image",
fileId = fileId,
thumbnailUrl = thumbnailUrl,
filename = filename,
size = bytes.size.toLong(),
)
UploadResult(attachment, thumbnailUrl)
} catch (e: Exception) {
Log.e("ChatViewModel", "Failed to upload image: ${e.message}", e)
null
}
}
}
// Override sendMessage to support image attachments
fun sendMessage() {
val text = _inputText.value.trim()
val uris = _selectedImageUris.value
if (text.isEmpty() && uris.isEmpty()) return
val sid = currentSessionId
if (sid == null) {
RuntimeLog.chat("send", "Cannot send — no current session")
return
}
_inputText.value = ""
_isSending.value = true
sendTimeoutJob?.cancel()
sendTimeoutJob = viewModelScope.launch {
delay(15_000L)
if (_isSending.value) {
Log.w("ChatViewModel", "Send timeout — no response in 15s, resetting")
_isSending.value = false
}
}
val localUriStrings = uris.map { it.toString() }
viewModelScope.launch {
val results = uris.mapNotNull { uploadAndBuildAttachment(it) }
clearImages()
val attachments = results.map { it.attachment }
val thumbnailUrls = results.map { it.thumbnailUrl }
try {
chatRepository.sendMessage(
text, sid,
attachments = attachments.ifEmpty { null },
localImageUris = thumbnailUrls.ifEmpty { localUriStrings },
)
} catch (e: Exception) {
Log.e("ChatViewModel", "sendMessage failed: ${e.message}", e)
_isSending.value = false
sendTimeoutJob?.cancel()
}
}
}
private fun loadMessagesFromDb(sessionId: String) { private fun loadMessagesFromDb(sessionId: String) {
dbObserverJob?.cancel() dbObserverJob?.cancel()
dbObserverJob = viewModelScope.launch { dbObserverJob = viewModelScope.launch {
@@ -194,9 +314,8 @@ class ChatViewModel(
val live = current.associateBy { it.id } val live = current.associateBy { it.id }
val db = messages.associateBy { it.id } val db = messages.associateBy { it.id }
(db + live).values (db + live).values
.sortedBy { it.timestamp } .sortedByDescending { it.timestamp }
.deduplicate() .deduplicate()
.removeWrappingDuplicates()
} }
val idx = _messageAnimIndex.value.toMutableMap() val idx = _messageAnimIndex.value.toMutableMap()
messages.forEach { m -> messages.forEach { m ->
@@ -206,6 +325,7 @@ class ChatViewModel(
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ChatViewModel", "Error loading messages: ${e.message}", e) Log.e("ChatViewModel", "Error loading messages: ${e.message}", e)
RuntimeLog.general("app", "loadMessagesFromDb failed: ${e.message}")
} }
} }
} }
@@ -214,19 +334,6 @@ class ChatViewModel(
_inputText.value = text _inputText.value = text
} }
fun sendMessage() {
val text = _inputText.value.trim()
if (text.isEmpty()) return
_inputText.value = ""
_isSending.value = true
val sid = currentSessionId
viewModelScope.launch {
chatRepository.sendMessage(text, sid)
}
}
fun switchSession(sessionId: String) { fun switchSession(sessionId: String) {
currentSessionId = sessionId currentSessionId = sessionId
chatRepository.currentSessionId = sessionId chatRepository.currentSessionId = sessionId
@@ -236,8 +343,8 @@ class ChatViewModel(
viewModelScope.launch { viewModelScope.launch {
chatRepository.connectWebSocket(sessionId) chatRepository.connectWebSocket(sessionId)
chatRepository.loadMessagesFromServer(sessionId) chatRepository.loadMessagesFromServer(sessionId)
loadMessagesFromDb(sessionId)
} }
loadMessagesFromDb(sessionId)
} }
fun refreshMessages() { fun refreshMessages() {
@@ -245,20 +352,14 @@ class ChatViewModel(
viewModelScope.launch { viewModelScope.launch {
_isRefreshing.value = true _isRefreshing.value = true
try { try {
// Clear all messages and let the DB observer rebuild from scratch.
// This avoids duplicates that occur when local-UUID messages survive
// the merge alongside server-ID versions loaded from HTTP.
_currentMessages.value = emptyList()
if (!isConnected.value) { if (!isConnected.value) {
chatRepository.ensureConnected() chatRepository.ensureConnected()
} }
val serverMessages = chatRepository.loadMessagesFromServer(sid) chatRepository.loadMessagesFromServer(sid)
if (serverMessages.isNotEmpty()) {
val serverIds = serverMessages.map { it.id }.toSet()
_currentMessages.update { current ->
val localOnly = current.filter { it.id !in serverIds }
(serverMessages + localOnly)
.sortedBy { it.timestamp }
.deduplicate()
.removeWrappingDuplicates()
}
}
} catch (_: Exception) { } } catch (_: Exception) { }
_isRefreshing.value = false _isRefreshing.value = false
} }
@@ -19,17 +19,8 @@ import top.yeij.cyrene.voice.tts.TextToSpeechEngine
private fun List<Message>.deduplicate(): List<Message> { private fun List<Message>.deduplicate(): List<Message> {
if (isEmpty()) return this if (isEmpty()) return this
val result = mutableListOf(this[0]) val seen = mutableSetOf<String>()
for (i in 1 until size) { return filter { seen.add(it.id) }
val prev = result.last()
val curr = this[i]
val isDuplicate = curr.id == prev.id ||
(curr.role == prev.role && curr.content == prev.content && curr.msgType == prev.msgType)
if (!isDuplicate) {
result.add(curr)
}
}
return result
} }
private fun List<Message>.removeWrappingDuplicates(): List<Message> { private fun List<Message>.removeWrappingDuplicates(): List<Message> {
@@ -82,6 +73,7 @@ class OverlayViewModel(
val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow() val messageAnimIndex: StateFlow<Map<String, Int>> = _messageAnimIndex.asStateFlow()
private var silenceTimer: Job? = null private var silenceTimer: Job? = null
private var processingTimeoutJob: Job? = null
private var lastAssistantMessageId: String? = null private var lastAssistantMessageId: String? = null
init { init {
@@ -89,23 +81,26 @@ class OverlayViewModel(
chatRepository.observeMessages().collect { message -> chatRepository.observeMessages().collect { message ->
_messages.update { list -> _messages.update { list ->
val updated = list.toMutableList() val updated = list.toMutableList()
val idx = updated.indexOfLast { it.id == message.id } val existingIdx = updated.indexOfLast { it.id == message.id }
if (idx >= 0) { if (existingIdx >= 0) {
updated[idx] = message updated[existingIdx] = message
} else { } else {
val isDup = updated.any { // Insert at correct position for ascending timestamp (oldest first for top-down layout)
it.role == message.role && it.content == message.content && it.msgType == message.msgType val insertAt = updated.indexOfFirst { it.timestamp >= message.timestamp }
} if (insertAt >= 0) updated.add(insertAt, message) else updated.add(message)
if (!isDup) { val animIdx = _messageAnimIndex.value.toMutableMap()
updated.add(message) animIdx[message.id] = animCounter++
val animIdx = _messageAnimIndex.value.toMutableMap() _messageAnimIndex.value = animIdx
animIdx[message.id] = animCounter++
_messageAnimIndex.value = animIdx
}
} }
updated.deduplicate() updated.deduplicate()
} }
// Any non-user response means the server acknowledged our message
if (_state.value == OverlayState.PROCESSING && message.role != "user") {
cancelProcessingTimeout()
setWaiting()
}
if (message.role == "assistant" && !message.isStreaming && message.msgType == "chat") { if (message.role == "assistant" && !message.isStreaming && message.msgType == "chat") {
if (message.id != lastAssistantMessageId && message.content.isNotBlank()) { if (message.id != lastAssistantMessageId && message.content.isNotBlank()) {
lastAssistantMessageId = message.id lastAssistantMessageId = message.id
@@ -114,6 +109,18 @@ class OverlayViewModel(
} }
} }
} }
viewModelScope.launch {
chatRepository.isAssistantStreaming.collect { streaming ->
if (streaming) {
cancelProcessingTimeout()
} else if (_state.value == OverlayState.PROCESSING) {
delay(500)
if (_state.value == OverlayState.PROCESSING) {
setWaiting()
}
}
}
}
viewModelScope.launch { viewModelScope.launch {
ttsEngine.onDone.collect { ttsEngine.onDone.collect {
if (_state.value == OverlayState.SPEAKING) { if (_state.value == OverlayState.SPEAKING) {
@@ -128,6 +135,14 @@ class OverlayViewModel(
animCounter = 0 animCounter = 0
} }
} }
viewModelScope.launch {
chatRepository.messageRemovals.collect { msgId ->
_messages.update { list -> list.filter { it.id != msgId } }
val idx = _messageAnimIndex.value.toMutableMap()
idx.remove(msgId)
_messageAnimIndex.value = idx
}
}
} }
fun onInputChanged(text: String) { fun onInputChanged(text: String) {
@@ -141,8 +156,14 @@ class OverlayViewModel(
_state.value = OverlayState.PROCESSING _state.value = OverlayState.PROCESSING
cancelSilenceTimer() cancelSilenceTimer()
startProcessingTimeout()
viewModelScope.launch { viewModelScope.launch {
chatRepository.sendMessage(text, null) 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 if (base64.isNullOrBlank()) return
_state.value = OverlayState.PROCESSING _state.value = OverlayState.PROCESSING
startProcessingTimeout()
viewModelScope.launch { viewModelScope.launch {
chatRepository.sendVoiceInput(base64, "voice_msg") chatRepository.sendVoiceInput(base64, "voice_msg")
} }
@@ -229,6 +251,22 @@ class OverlayViewModel(
silenceTimer = null silenceTimer = null
} }
private fun startProcessingTimeout() {
cancelProcessingTimeout()
processingTimeoutJob = viewModelScope.launch {
delay(15_000L)
if (_state.value == OverlayState.PROCESSING) {
Log.w("OverlayVM", "Processing timeout — no response in 15s, resetting to WAITING")
setWaiting()
}
}
}
private fun cancelProcessingTimeout() {
processingTimeoutJob?.cancel()
processingTimeoutJob = null
}
override fun onCleared() { override fun onCleared() {
voiceRecorder.cancel() voiceRecorder.cancel()
ttsEngine.shutdown() ttsEngine.shutdown()
@@ -48,6 +48,18 @@ class SettingsViewModel(
private val _autoScreenContext = MutableStateFlow(false) private val _autoScreenContext = MutableStateFlow(false)
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow() val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
private val _themeColor = MutableStateFlow("pink")
val themeColor: StateFlow<String> = _themeColor.asStateFlow()
private val _enterToSend = MutableStateFlow(false)
val enterToSend: StateFlow<Boolean> = _enterToSend.asStateFlow()
private val _typingIndicatorStyle = MutableStateFlow("bubble")
val typingIndicatorStyle: StateFlow<String> = _typingIndicatorStyle.asStateFlow()
private val _rootKeepAlive = MutableStateFlow(false)
val rootKeepAlive: StateFlow<Boolean> = _rootKeepAlive.asStateFlow()
private val _isLoggedIn = MutableStateFlow(false) private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow() val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
@@ -61,6 +73,26 @@ class SettingsViewModel(
_autoScreenContext.value = value _autoScreenContext.value = value
} }
} }
scope.launch {
preferencesDataStore.typingIndicatorStyle.collect { value ->
_typingIndicatorStyle.value = value
}
}
scope.launch {
preferencesDataStore.enterToSend.collect { value ->
_enterToSend.value = value
}
}
scope.launch {
preferencesDataStore.rootKeepAlive.collect { value ->
_rootKeepAlive.value = value
}
}
scope.launch {
preferencesDataStore.themeColor.collect { value ->
_themeColor.value = value
}
}
scope.launch { scope.launch {
combine( combine(
preferencesDataStore.baseUrl, preferencesDataStore.baseUrl,
@@ -165,6 +197,26 @@ class SettingsViewModel(
scope.launch { preferencesDataStore.saveAutoScreenContext(enabled) } scope.launch { preferencesDataStore.saveAutoScreenContext(enabled) }
} }
fun saveTypingIndicatorStyle(style: String) {
_typingIndicatorStyle.value = style
scope.launch { preferencesDataStore.saveTypingIndicatorStyle(style) }
}
fun saveEnterToSend(enabled: Boolean) {
_enterToSend.value = enabled
scope.launch { preferencesDataStore.saveEnterToSend(enabled) }
}
fun saveThemeColor(color: String) {
_themeColor.value = color
scope.launch { preferencesDataStore.saveThemeColor(color) }
}
fun saveRootKeepAlive(enabled: Boolean) {
_rootKeepAlive.value = enabled
scope.launch { preferencesDataStore.saveRootKeepAlive(enabled) }
}
fun clearLocalMessages() { fun clearLocalMessages() {
scope.launch { scope.launch {
chatRepository.clearLocalMessages() chatRepository.clearLocalMessages()
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#6D3BC0"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- 昔涟首字母 C,圆形背景 -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,30 C67.255,30 78,40.745 78,54 C78,67.255 67.255,78 54,78 C40.745,78 30,67.255 30,54 C30,40.745 40.745,30 54,30 Z M54,36 C44.059,36 36,44.059 36,54 C36,63.941 44.059,72 54,72 C58.935,72 63.437,70.1 66.878,66.878 C68.523,65.301 69.761,63.394 70.505,61.289 C70.963,59.947 71.213,58.524 71.233,57.067 C71.239,55.712 71.041,54.369 70.646,53.084 L66.757,58.243 L58.243,49.729 L53.084,53.619 L44.57,45.106 L45.398,44.278 C47.881,41.795 51.235,40.233 54,40.233 Z" />
</vector>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+4
View File
@@ -13,6 +13,7 @@ koin = "4.0.0"
datastore = "1.1.1" datastore = "1.1.1"
coroutines = "1.9.0" coroutines = "1.9.0"
material3 = "1.3.1" material3 = "1.3.1"
coil = "2.7.0"
[libraries] [libraries]
# Compose BOM # Compose BOM
@@ -56,6 +57,9 @@ coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-andro
# Core # Core
core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" } core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.15.0" }
# Coil
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
# Biometric # Biometric
biometric = { group = "androidx.biometric", name = "biometric", version = "1.1.0" } biometric = { group = "androidx.biometric", name = "biometric", version = "1.1.0" }