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>
This commit is contained in:
2026-05-28 12:56:21 +08:00
parent e65a35a239
commit 6394099e2e
17 changed files with 1155 additions and 179 deletions
+25 -1
View File
@@ -22,6 +22,12 @@
<!-- 锁屏交互 -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- 激进保活 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- 查询其他应用(检查默认助手设置) -->
<queries>
<intent>
@@ -89,7 +95,25 @@
<service
android:name=".service.WebSocketKeepAliveService"
android:exported="false"
android:foregroundServiceType="dataSync" />
android:foregroundServiceType="dataSync|specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="websocket_keepalive_for_push_message_delivery" />
</service>
<!-- 开机自启 -->
<receiver
android:name=".service.BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- 定时保活唤醒 -->
<receiver
android:name=".service.KeepAliveReceiver"
android:exported="false" />
<!-- FileProvider:日志分享 -->
<provider
@@ -1,9 +1,15 @@
package top.yeij.cyrene
import android.Manifest
import android.app.Activity
import android.app.Application
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -17,7 +23,8 @@ import top.yeij.cyrene.data.remote.AuthInterceptor
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
import top.yeij.cyrene.di.appModule
import top.yeij.cyrene.util.NotificationHelper
import top.yeij.cyrene.service.KeepAliveReceiver
import top.yeij.cyrene.util.RootKeepAliveHelper
import top.yeij.cyrene.util.RuntimeLog
import java.util.concurrent.atomic.AtomicInteger
@@ -25,8 +32,6 @@ class CyreneApplication : Application() {
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val activityCount = AtomicInteger(0)
@Volatile
private var notificationHelper: NotificationHelper? = null
override fun onCreate() {
super.onCreate()
@@ -42,7 +47,7 @@ class CyreneApplication : Application() {
override fun onActivityStarted(activity: Activity) {
if (activityCount.incrementAndGet() == 1) {
RuntimeLog.general("app", "App in foreground")
notificationHelper?.cancelAll()
getRepo()?.cancelNotifications()
getRepo()?.onAppForeground()
}
}
@@ -61,16 +66,6 @@ class CyreneApplication : Application() {
override fun onActivityDestroyed(activity: Activity) {}
})
// Set up background notification callback once Koin is ready
initScope.launch {
val helper = NotificationHelper(this@CyreneApplication)
notificationHelper = helper
val repo = getRepo()
repo?.setNotificationCallback { message ->
helper.showMessageNotification(message)
}
}
initScope.launch {
val koin = GlobalContext.get()
val prefs: PreferencesDataStore = koin.get()
@@ -84,6 +79,49 @@ class CyreneApplication : Application() {
authInterceptor.token = token
}
}
// Schedule periodic keep-alive on first launch
scheduleInitialKeepAlive()
// Check battery optimization
checkBatteryOptimization()
// Apply root keep-alive if enabled
initScope.launch {
val koin = GlobalContext.get()
val prefs: PreferencesDataStore = koin.get()
val enabled = prefs.rootKeepAlive.firstOrNull() ?: false
if (enabled) {
val ok = RootKeepAliveHelper.applyRootKeepAlive(packageName)
if (ok) {
RuntimeLog.general("app", "Root keep-alive re-applied on boot")
} else if (RootKeepAliveHelper.isRootAvailable()) {
RuntimeLog.general("app", "Root keep-alive re-apply failed despite root being available")
}
// Only attempt system wakelock if root keep-alive was enabled
// We don't persist the wakelock across reboots since it's per-session
}
}
}
private fun scheduleInitialKeepAlive() {
try {
KeepAliveReceiver.schedule(this)
RuntimeLog.general("app", "Initial keep-alive alarm scheduled")
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule initial keep-alive: ${e.message}")
}
}
private fun checkBatteryOptimization() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val pm = getSystemService(PowerManager::class.java)
if (pm?.isIgnoringBatteryOptimizations(packageName) == false) {
Log.i(TAG, "App is NOT exempt from battery optimization")
// Note: we can't request exemption from Application context directly.
// SettingsScreen should offer a button to open the exemption dialog.
}
}
}
private fun getRepo(): ChatRepositoryImpl? {
@@ -33,13 +33,19 @@ class MainActivity : ComponentActivity() {
setContent {
val prefs: PreferencesDataStore = koinInject()
val themeMode by prefs.themeMode.collectAsState(initial = null)
val themeColor by prefs.themeColor.collectAsState(initial = "pink")
val darkTheme = when (themeMode) {
"light" -> false
"dark" -> true
else -> isSystemInDarkTheme()
}
val useDynamic = themeColor == "monet" && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
CyreneTheme(darkTheme = darkTheme) {
CyreneTheme(
darkTheme = darkTheme,
presetKey = themeColor,
useDynamicColor = useDynamic,
) {
val navController = rememberNavController()
CyreneNavGraph(
@@ -35,6 +35,8 @@ class PreferencesDataStore(private val context: 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" }
@@ -49,6 +51,18 @@ class PreferencesDataStore(private val context: Context) {
context.dataStore.edit { it[KEY_ENTER_TO_SEND] = enabled }
}
val rootKeepAlive: Flow<Boolean> = context.dataStore.data.map { it[KEY_ROOT_KEEPALIVE] ?: false }
suspend fun saveRootKeepAlive(enabled: Boolean) {
context.dataStore.edit { it[KEY_ROOT_KEEPALIVE] = enabled }
}
val themeColor: Flow<String> = context.dataStore.data.map { it[KEY_THEME_COLOR] ?: "pink" }
suspend fun saveThemeColor(color: String) {
context.dataStore.edit { it[KEY_THEME_COLOR] = color }
}
val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
val refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
val baseUrl: Flow<String?> = context.dataStore.data.map { it[KEY_BASE_URL] }
@@ -27,8 +27,10 @@ import top.yeij.cyrene.data.remote.dto.WSServerMessage
import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.domain.repository.ChatRepository
import top.yeij.cyrene.service.KeepAliveReceiver
import top.yeij.cyrene.service.WebSocketKeepAliveService
import top.yeij.cyrene.service.WebSocketService
import top.yeij.cyrene.util.NotificationHelper
import top.yeij.cyrene.util.RuntimeLog
import java.util.UUID
@@ -39,6 +41,7 @@ class ChatRepositoryImpl(
private val webSocketService: WebSocketService,
private val apiService: ApiService,
private val preferencesDataStore: PreferencesDataStore,
private val notificationHelper: NotificationHelper,
) : ChatRepository {
private val exceptionHandler = CoroutineExceptionHandler { _, e ->
@@ -68,7 +71,7 @@ class ChatRepositoryImpl(
override var currentSessionId: String? = null
private var isAppInForeground = false
private var onBackgroundNotification: ((Message) -> Unit)? = null
private var hasEverBeenForeground = false
private var historyRequested = false
private val notifiedMessageIds = mutableSetOf<String>()
@@ -80,13 +83,16 @@ class ChatRepositoryImpl(
private var lastResponseContent: String? = null
private var lastResponseTime = 0L
fun setNotificationCallback(callback: ((Message) -> Unit)?) {
onBackgroundNotification = callback
fun cancelNotifications() {
notificationHelper.cancelAll()
}
override fun onAppForeground() {
isAppInForeground = true
hasEverBeenForeground = true
notifiedMessageIds.clear()
notificationHelper.cancelAll()
KeepAliveReceiver.cancel(app)
WebSocketKeepAliveService.stop(app)
// Always reconnect and sync history when returning to foreground
webSocketService.forceReconnect()
@@ -99,10 +105,17 @@ class ChatRepositoryImpl(
override fun onAppBackground() {
isAppInForeground = false
if (_connectionState.value) {
// Always start keep-alive — connection may be silently dead and need recovery
WebSocketKeepAliveService.start(app)
RuntimeLog.general("app", "Started keep-alive service for background")
KeepAliveReceiver.schedule(app)
// Force reconnect after foreground service is up so the socket
// is tied to the service's lifecycle, not the foreground activity's
scope.launch {
kotlinx.coroutines.delay(1500) // let the service start first
webSocketService.forceReconnect()
RuntimeLog.general("app", "Background reconnect after service start, connected=${_connectionState.value}")
}
RuntimeLog.general("app", "Started keep-alive service + periodic alarm for background (connected=${_connectionState.value})")
}
init {
@@ -184,7 +197,7 @@ class ChatRepositoryImpl(
}
override suspend fun ensureConnected() {
if (_connectionState.value) return
// Always force reconnect — connectionState may be stuck at true on a silently dead socket
webSocketService.forceReconnect()
}
@@ -603,6 +616,7 @@ class ChatRepositoryImpl(
"multi_message" -> {
recentParsedContents.clear()
var isFirst = true
wsMsg.multiMessages?.forEach { item ->
val content = item.content ?: ""
recentParsedContents.add(content)
@@ -614,7 +628,9 @@ class ChatRepositoryImpl(
msgType = item.msgType ?: "chat",
timestamp = wsMsg.timestamp ?: System.currentTimeMillis(),
isStreaming = false,
shouldNotify = isFirst,
)
isFirst = false
}
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
cleanupWrappingResponse()
@@ -657,9 +673,10 @@ class ChatRepositoryImpl(
)
_incomingMessages.tryEmit(message)
if (shouldNotify && !isAppInForeground && role == "assistant" && !isStreaming) {
if (shouldNotify && hasEverBeenForeground && !isAppInForeground && role == "assistant" && !isStreaming) {
if (notifiedMessageIds.add(id)) {
onBackgroundNotification?.invoke(message)
notificationHelper.showMessageNotification(message)
RuntimeLog.general("app", "Notification sent for msgId=$id")
}
}
}
@@ -25,6 +25,7 @@ import top.yeij.cyrene.viewmodel.IoTViewModel
import top.yeij.cyrene.viewmodel.OverlayViewModel
import top.yeij.cyrene.viewmodel.ProfileViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel
import top.yeij.cyrene.util.NotificationHelper
import top.yeij.cyrene.util.VoiceRecorder
import top.yeij.cyrene.voice.stt.BackendSttProvider
import top.yeij.cyrene.voice.stt.DashScopeSttService
@@ -34,6 +35,9 @@ import top.yeij.cyrene.voice.tts.TextToSpeechEngine
val appModule = module {
// Notifications
single { NotificationHelper(androidContext()) }
// DataStore
single { PreferencesDataStore(androidContext()) }
@@ -63,7 +67,7 @@ val appModule = module {
// Repositories
single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) }
single<ChatRepository> { ChatRepositoryImpl(androidContext() as android.app.Application, get(), get(), get(), get(), get()) }
single<ChatRepository> { ChatRepositoryImpl(androidContext() as android.app.Application, get(), get(), get(), get(), get(), get()) }
single<IoTRepository> { IoTRepositoryImpl(get(), get()) }
// UseCases
@@ -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"
}
}
@@ -68,10 +68,11 @@ class CyreneVoiceInteractionSession(context: Context) :
val vm = overlayViewModel
val session = this@CyreneVoiceInteractionSession
val darkTheme = runBlocking {
val (darkTheme, themeColorKey) = runBlocking {
val prefs = GlobalContext.get().get<PreferencesDataStore>()
val mode = prefs.themeMode.firstOrNull()
when (mode) {
val color = prefs.themeColor.firstOrNull() ?: "pink"
val dark = when (mode) {
"light" -> false
"dark" -> true
else -> {
@@ -80,6 +81,7 @@ class CyreneVoiceInteractionSession(context: Context) :
nightMode == Configuration.UI_MODE_NIGHT_YES
}
}
Pair(dark, color)
}
return ComposeView(context).apply {
@@ -93,7 +95,11 @@ class CyreneVoiceInteractionSession(context: Context) :
setViewTreeLifecycleOwner(session)
setViewTreeSavedStateRegistryOwner(session)
setContent {
CyreneTheme(darkTheme = darkTheme) {
CyreneTheme(
darkTheme = darkTheme,
presetKey = themeColorKey,
useDynamicColor = themeColorKey == "monet",
) {
if (vm != null) {
OverlayContent(
onDismiss = { finish() },
@@ -0,0 +1,103 @@
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
class KeepAliveReceiver : BroadcastReceiver() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "Keep-alive alarm fired")
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")
return@launch
}
// Always restart foreground service
if (!WebSocketKeepAliveService.isRunning) {
WebSocketKeepAliveService.start(context)
Log.i(TAG, "Keep-alive service restarted")
}
// Always force reconnect — connectionState may be stuck at true on a dead socket
val repo: ChatRepositoryImpl = GlobalContext.get().get()
repo.ensureConnected()
Log.i(TAG, "WebSocket reconnection triggered, connected=${repo.connectionState.value}")
// Schedule next wake-up
schedule(context)
Log.d(TAG, "Keep-alive check complete, connected=${repo.connectionState.value}")
} catch (e: Throwable) {
Log.e(TAG, "Keep-alive check failed: ${e.message}", e)
// Schedule next anyway
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}")
}
}
}
}
@@ -1,31 +1,56 @@
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
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")
}
override fun onDestroy() {
isRunning = false
releaseWakeLock()
scheduleRestart()
Log.i(TAG, "Service destroyed, restart scheduled")
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 {
@@ -35,16 +60,55 @@ class WebSocketKeepAliveService : Service() {
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("昔涟")
.setContentText("连接,可在后台接收消息")
.setContentText("后台连接,可接收消息推送")
.setOngoing(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.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)
return START_STICKY
}
}
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() {
@@ -61,6 +125,7 @@ class WebSocketKeepAliveService : Service() {
}
companion object {
private const val TAG = "CyreneKeepAlive"
private const val CHANNEL_ID = "cyrene_keepalive"
private const val NOTIFICATION_ID = 1
@@ -80,5 +145,7 @@ class WebSocketKeepAliveService : Service() {
Intent(context, WebSocketKeepAliveService::class.java)
)
}
const val RESTART_DELAY_MS = 60_000L
}
}
@@ -21,6 +21,7 @@ import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.atomic.AtomicLong
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.dto.WSClientMessage
import top.yeij.cyrene.data.remote.dto.WSServerMessage
@@ -47,6 +48,8 @@ class WebSocketService(
private var shouldReconnect = true
private var currentSessionId: String? = null
private val connectionId = AtomicInteger(0)
@Volatile private var lastMessageReceived = System.currentTimeMillis()
private val deadConnectionTimeoutMs = 30_000L // No message for 30s = treat as dead
private var clientId: String = ""
private var deviceName: String = ""
@@ -136,6 +139,7 @@ class WebSocketService(
override fun onMessage(webSocket: WebSocket, text: String) {
if (connectionId.get() != connId) return
lastMessageReceived = System.currentTimeMillis()
try {
val msg = gson.fromJson(text, WSServerMessage::class.java)
_incomingMessages.tryEmit(msg)
@@ -177,7 +181,8 @@ class WebSocketService(
if (errorMsg != null) {
_connectionError.value = errorMsg
}
// onClosed will always follow, which triggers scheduleReconnect
// onClosed may or may not follow — schedule reconnect directly
scheduleReconnect()
}
})
}
@@ -269,9 +274,15 @@ class WebSocketService(
cancelHeartbeat()
heartbeatJob = scope.launch {
while (_isConnected.value) {
delay(30_000)
if (_isConnected.value) {
delay(15_000)
if (!_isConnected.value) break
sendPing()
// Check if connection is silently dead (no message received in 60s)
val sinceLastMsg = System.currentTimeMillis() - lastMessageReceived
if (sinceLastMsg > deadConnectionTimeoutMs) {
Log.w(TAG, "No message received for ${sinceLastMsg}ms — connection may be dead, forcing reconnect")
forceReconnect()
break
}
}
}
@@ -531,7 +531,7 @@ private fun ChatMessageBubble(
),
) {
Text(
text = content,
text = renderInlineMarkdown(content),
modifier = Modifier.padding(12.dp),
color = if (isUser)
MaterialTheme.colorScheme.onPrimary
@@ -634,19 +634,44 @@ private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
// --- System info bubble ---
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) {
var showMenu by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.Center,
) {
Box {
Text(
text = content,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.combinedClickable(
onClick = {},
onLongClick = { showMenu = true },
),
)
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text("复制") },
leadingIcon = {
Icon(Icons.Default.ContentCopy, contentDescription = null)
},
onClick = {
showMenu = false
clipboardManager.setText(AnnotatedString(content))
},
)
}
}
}
}
@@ -1,11 +1,18 @@
package top.yeij.cyrene.ui.screens.settings
import android.os.Build
import android.widget.Toast
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -15,20 +22,23 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.BatterySaver
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.SettingsBrightness
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.SettingsBrightness
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
@@ -42,18 +52,24 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.compose.ui.platform.LocalContext
@@ -62,11 +78,15 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import top.yeij.cyrene.ui.theme.PresetColorLabels
import top.yeij.cyrene.ui.theme.PresetThemeColors
import top.yeij.cyrene.util.KeepAliveManager
import top.yeij.cyrene.util.LogCategory
import top.yeij.cyrene.util.RootKeepAliveHelper
import top.yeij.cyrene.util.RuntimeLog
import top.yeij.cyrene.viewmodel.SettingsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
fun SettingsScreen(
onBack: () -> Unit,
@@ -81,7 +101,9 @@ fun SettingsScreen(
val dashScopeModel by viewModel.dashScopeModel.collectAsState()
val autoScreenContext by viewModel.autoScreenContext.collectAsState()
val typingIndicatorStyle by viewModel.typingIndicatorStyle.collectAsState()
val themeColor by viewModel.themeColor.collectAsState()
val enterToSend by viewModel.enterToSend.collectAsState()
val rootKeepAlive by viewModel.rootKeepAlive.collectAsState()
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -206,11 +228,139 @@ fun SettingsScreen(
},
)
var showColorDialog by remember { mutableStateOf(false) }
val currentColorLabel = PresetColorLabels[themeColor] ?: "昔涟粉"
ListItem(
headlineContent = { Text("主题色") },
supportingContent = { Text("昔涟紫") },
leadingContent = { Icon(Icons.Filled.Palette, contentDescription = null) },
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(
@@ -240,6 +390,189 @@ fun SettingsScreen(
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Background keep-alive
val keepAliveManager = remember { KeepAliveManager(context) }
var isBatteryExempt by remember { mutableStateOf(keepAliveManager.isBatteryOptimizationExempt()) }
val lifecycleOwner = LocalLifecycleOwner.current
// Re-check battery exemption when returning from system settings
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.lifecycle.addObserver(object : androidx.lifecycle.DefaultLifecycleObserver {
override fun onResume(owner: androidx.lifecycle.LifecycleOwner) {
isBatteryExempt = keepAliveManager.isBatteryOptimizationExempt()
}
})
}
// Hidden root toggle: tap section title 5 times to reveal
var rootTapCount by remember { mutableIntStateOf(0) }
var rootRevealed by remember { mutableStateOf(false) }
Text(
text = "后台保活",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(16.dp)
.combinedClickable(
onClick = {
if (!rootRevealed) {
rootTapCount++
if (rootTapCount >= 5) {
rootRevealed = true
rootTapCount = 0
Toast.makeText(context, "已解锁 Root 保活选项", Toast.LENGTH_SHORT).show()
}
}
},
),
)
ListItem(
headlineContent = { Text("忽略电池优化") },
supportingContent = {
Text(
if (isBatteryExempt) "已允许,后台连接更稳定"
else "未允许,建议开启以确保消息推送及时送达"
)
},
leadingContent = {
Icon(
Icons.Filled.BatterySaver,
contentDescription = null,
tint = if (isBatteryExempt) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error,
)
},
modifier = Modifier.clickable {
if (!isBatteryExempt) {
try {
context.startActivity(keepAliveManager.openBatteryOptimizationSettings())
} catch (_: Exception) {
Toast.makeText(context, "无法打开电池优化设置", Toast.LENGTH_SHORT).show()
}
}
},
)
// Root-level keep-alive (hidden by default, revealed after 5 taps)
if (rootRevealed) {
val isRootAvailable = remember { RootKeepAliveHelper.isRootAvailable() }
ListItem(
headlineContent = { Text("Root 保活 (隐藏)") },
supportingContent = {
Text(
if (!isRootAvailable) "未检测到 Root 权限"
else if (rootKeepAlive) "已启用 — 系统级白名单、Doze豁免、强制后台运行"
else "使用 Root 权限将应用加入系统级白名单,对抗任何保活限制"
)
},
leadingContent = {
Icon(
Icons.Filled.Terminal,
contentDescription = null,
tint = if (rootKeepAlive && isRootAvailable) MaterialTheme.colorScheme.tertiary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailingContent = {
Switch(
checked = rootKeepAlive,
enabled = isRootAvailable,
onCheckedChange = { enabled ->
if (enabled) {
val success = RootKeepAliveHelper.applyRootKeepAlive(context.packageName)
if (success) {
viewModel.saveRootKeepAlive(true)
Toast.makeText(context, "Root 保活已启用", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Root 保活应用失败,请检查 Root 权限", Toast.LENGTH_LONG).show()
}
} else {
RootKeepAliveHelper.removeRootKeepAlive(context.packageName)
viewModel.saveRootKeepAlive(false)
Toast.makeText(context, "Root 保活已关闭", Toast.LENGTH_SHORT).show()
}
},
)
},
modifier = Modifier.clickable(enabled = isRootAvailable) {
val newState = !rootKeepAlive
if (newState) {
val success = RootKeepAliveHelper.applyRootKeepAlive(context.packageName)
if (success) {
viewModel.saveRootKeepAlive(true)
Toast.makeText(context, "Root 保活已启用", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Root 保活应用失败", Toast.LENGTH_LONG).show()
}
} else {
RootKeepAliveHelper.removeRootKeepAlive(context.packageName)
viewModel.saveRootKeepAlive(false)
Toast.makeText(context, "Root 保活已关闭", Toast.LENGTH_SHORT).show()
}
},
)
// System wakelock toggle (held only while app is alive, reapplied on boot)
var sysWakeLockHeld by remember { mutableStateOf(false) }
ListItem(
headlineContent = { Text("系统级 WakeLock (隐藏)") },
supportingContent = {
Text(
if (!isRootAvailable) "需要 Root 权限"
else if (sysWakeLockHeld) "已持有系统级内核锁,CPU永不休眠"
else "写入 /sys/power/wake_lock 阻止 CPU 休眠"
)
},
leadingContent = {
Icon(
Icons.Filled.Security,
contentDescription = null,
tint = if (sysWakeLockHeld) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailingContent = {
Switch(
checked = sysWakeLockHeld,
enabled = isRootAvailable,
onCheckedChange = { enable ->
if (enable) {
val ok = RootKeepAliveHelper.acquireSystemWakeLock("CyreneKA")
if (ok) {
sysWakeLockHeld = true
Toast.makeText(context, "系统 WakeLock 已持有 — 注意:将显著增加耗电", Toast.LENGTH_LONG).show()
} else {
Toast.makeText(context, "WakeLock 获取失败", Toast.LENGTH_SHORT).show()
}
} else {
val ok = RootKeepAliveHelper.releaseSystemWakeLock("CyreneKA")
if (ok) {
sysWakeLockHeld = false
Toast.makeText(context, "系统 WakeLock 已释放", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "WakeLock 释放失败", Toast.LENGTH_SHORT).show()
}
}
},
)
},
)
}
ListItem(
headlineContent = { Text("保活设置") },
supportingContent = { Text("前台服务、自启动管理、OEM厂商后台白名单") },
leadingContent = { Icon(Icons.Filled.Security, contentDescription = null) },
modifier = Modifier.clickable { onNavigateToKeepAlive() },
)
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Voice
Text(
text = "语音",
@@ -493,28 +826,6 @@ fun SettingsScreen(
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Keep-alive
Text(
text = "后台保活",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp),
)
ListItem(
headlineContent = { Text("保活设置") },
supportingContent = { Text("前台服务、电池优化、自启动等保活方式") },
leadingContent = { Icon(Icons.Filled.Security, contentDescription = null) },
modifier = Modifier.clickable { onNavigateToKeepAlive() },
)
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Runtime logs
Text(
text = "运行日志",
@@ -1,58 +1,201 @@
package top.yeij.cyrene.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
// Light theme
val LightPrimary = Color(0xFF6D3BC0)
val LightOnPrimary = Color(0xFFFFFFFF)
val LightPrimaryContainer = Color(0xFFEEDCFF)
val LightOnPrimaryContainer = Color(0xFF250058)
val LightSecondary = Color(0xFF625B71)
val LightOnSecondary = Color(0xFFFFFFFF)
val LightSecondaryContainer = Color(0xFFE8DEF8)
val LightOnSecondaryContainer = Color(0xFF1E192B)
val LightTertiary = Color(0xFF7E5260)
val LightOnTertiary = Color(0xFFFFFFFF)
val LightTertiaryContainer = Color(0xFFFFD9E3)
val LightOnTertiaryContainer = Color(0xFF31101D)
val LightBackground = Color(0xFFFFFBFF)
val LightOnBackground = Color(0xFF1C1B1F)
val LightSurface = Color(0xFFFFFBFF)
val LightOnSurface = Color(0xFF1C1B1F)
val LightSurfaceVariant = Color(0xFFE7E0EC)
val LightOnSurfaceVariant = Color(0xFF49454F)
val LightError = Color(0xFFBA1A1A)
val LightOutline = Color(0xFF79747E)
val LightOutlineVariant = Color(0xFFCAC4D0)
// Dark theme
val DarkPrimary = Color(0xFFD3BBFF)
val DarkOnPrimary = Color(0xFF3D0089)
val DarkPrimaryContainer = Color(0xFF541BA6)
val DarkOnPrimaryContainer = Color(0xFFEEDCFF)
val DarkSecondary = Color(0xFFCBC2DC)
val DarkOnSecondary = Color(0xFF332D41)
val DarkSecondaryContainer = Color(0xFF4A4458)
val DarkOnSecondaryContainer = Color(0xFFE8DEF8)
val DarkTertiary = Color(0xFFEFB8C8)
val DarkOnTertiary = Color(0xFF4A2532)
val DarkTertiaryContainer = Color(0xFF633B48)
val DarkOnTertiaryContainer = Color(0xFFFFD9E3)
val DarkBackground = Color(0xFF1C1B1F)
val DarkOnBackground = Color(0xFFE6E1E5)
val DarkSurface = Color(0xFF1C1B1F)
val DarkOnSurface = Color(0xFFE6E1E5)
val DarkSurfaceVariant = Color(0xFF49454F)
val DarkOnSurfaceVariant = Color(0xFFCAC4D0)
val DarkError = Color(0xFFFFB4AB)
val DarkOutline = Color(0xFF938F99)
val DarkOutlineVariant = Color(0xFF49454F)
// Preset seed colors for manual theme selection
val SeedColors = mapOf(
"default" to 0xFF6D3BC0, // Lavender
"sakura" to 0xFFFFB4C8, // Pink
"ocean" to 0xFF6BA4FF, // Blue
"forest" to 0xFF6BCF7C, // Green
"sunset" to 0xFFFF9E6B, // Orange
// Each preset provides a seed color and light/dark primary colors
data class ThemePreset(
val seed: Long,
val lightPrimary: Color,
val darkPrimary: Color,
)
val PresetThemeColors = mapOf(
"pink" to ThemePreset(
seed = 0xFFE91E8C,
lightPrimary = Color(0xFFC2185B),
darkPrimary = Color(0xFFFF80AB),
),
"sakura" to ThemePreset(
seed = 0xFFFFB4C8,
lightPrimary = Color(0xFFE91E63),
darkPrimary = Color(0xFFFFB4C8),
),
"lavender" to ThemePreset(
seed = 0xFF6D3BC0,
lightPrimary = Color(0xFF6D3BC0),
darkPrimary = Color(0xFFD3BBFF),
),
"ocean" to ThemePreset(
seed = 0xFF1565C0,
lightPrimary = Color(0xFF1565C0),
darkPrimary = Color(0xFF90CAF9),
),
"forest" to ThemePreset(
seed = 0xFF2E7D32,
lightPrimary = Color(0xFF2E7D32),
darkPrimary = Color(0xFFA5D6A7),
),
"sunset" to ThemePreset(
seed = 0xFFE65100,
lightPrimary = Color(0xFFE65100),
darkPrimary = Color(0xFFFFCC80),
),
"rose" to ThemePreset(
seed = 0xFFD81B60,
lightPrimary = Color(0xFFAD1457),
darkPrimary = Color(0xFFF48FB1),
),
"sky" to ThemePreset(
seed = 0xFF0277BD,
lightPrimary = Color(0xFF0277BD),
darkPrimary = Color(0xFF81D4FA),
),
"mint" to ThemePreset(
seed = 0xFF00695C,
lightPrimary = Color(0xFF00695C),
darkPrimary = Color(0xFF80CBC4),
),
)
val PresetColorLabels = mapOf(
"pink" to "昔涟粉",
"sakura" to "樱花粉",
"lavender" to "薰衣草紫",
"ocean" to "海洋蓝",
"forest" to "森林绿",
"sunset" to "日落橙",
"rose" to "玫瑰红",
"sky" to "天空蓝",
"mint" to "薄荷青",
)
fun getPreset(key: String): ThemePreset = PresetThemeColors[key] ?: PresetThemeColors["pink"]!!
// --- Color derivation via HSL — generates cohesive MD3-like schemes ---
private data class HSL(val h: Float, val s: Float, val l: Float)
private fun Color.toHSL(): HSL {
val r = red / 255f
val g = green / 255f
val b = blue / 255f
val maxV = max(max(r, g), b)
val minV = min(min(r, g), b)
val delta = maxV - minV
val l = (maxV + minV) / 2f
val s = if (delta == 0f) 0f else delta / (1f - abs(2f * l - 1f))
val h = when {
delta == 0f -> 0f
maxV == r -> 60f * (((g - b) / delta) % 6f)
maxV == g -> 60f * (((b - r) / delta) + 2f)
else -> 60f * (((r - g) / delta) + 4f)
}
return HSL(if (h < 0) h + 360f else h, s, l)
}
private fun hslToColor(h: Float, s: Float, l: Float): Color {
val c = (1f - abs(2f * l - 1f)) * s
val x = c * (1f - abs((h / 60f) % 2f - 1f))
val m = l - c / 2f
val (r, g, b) = when {
h < 60f -> Triple(c, x, 0f)
h < 120f -> Triple(x, c, 0f)
h < 180f -> Triple(0f, c, x)
h < 240f -> Triple(0f, x, c)
h < 300f -> Triple(x, 0f, c)
else -> Triple(c, 0f, x)
}
return Color((r + m).coerceIn(0f, 1f), (g + m).coerceIn(0f, 1f), (b + m).coerceIn(0f, 1f))
}
/**
* Build a full light ColorScheme derived from a primary color.
* All secondary/tertiary/container colors are computed from the primary
* so focus rings, ripples, badges, and containers all match the theme.
*/
fun buildLightScheme(primary: Color): ColorScheme {
val hsl = primary.toHSL()
val primaryContainer = hslToColor(hsl.h, 0.3f, 0.90f)
val onPrimaryContainer = hslToColor(hsl.h, 0.5f, 0.15f)
// Secondary: similar hue, less saturated
val secH = (hsl.h + 15f) % 360f
val secondary = hslToColor(secH, 0.35f, 0.42f)
val secondaryContainer = hslToColor(secH, 0.25f, 0.90f)
val onSecondaryContainer = hslToColor(secH, 0.3f, 0.15f)
// Tertiary: complementary hue shift
val terH = (hsl.h + 60f) % 360f
val tertiary = hslToColor(terH, 0.40f, 0.38f)
val tertiaryContainer = hslToColor(terH, 0.30f, 0.90f)
val onTertiaryContainer = hslToColor(terH, 0.35f, 0.15f)
val onPrimary = Color.White
val onSecondary = Color.White
val onTertiary = Color.White
val surfaceTint = primary
return lightColorScheme(
primary = primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = onPrimaryContainer,
secondary = secondary,
onSecondary = onSecondary,
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSecondaryContainer,
tertiary = tertiary,
onTertiary = onTertiary,
tertiaryContainer = tertiaryContainer,
onTertiaryContainer = onTertiaryContainer,
surfaceTint = surfaceTint,
)
}
/**
* Build a full dark ColorScheme derived from a primary color.
*/
fun buildDarkScheme(primary: Color): ColorScheme {
val hsl = primary.toHSL()
val primaryContainer = hslToColor(hsl.h, 0.40f, 0.22f)
val onPrimaryContainer = hslToColor(hsl.h, 0.30f, 0.88f)
val onPrimary = hslToColor(hsl.h, 0.5f, 0.10f)
val secH = (hsl.h + 15f) % 360f
val secondary = hslToColor(secH, 0.40f, 0.76f)
val secondaryContainer = hslToColor(secH, 0.30f, 0.22f)
val onSecondaryContainer = hslToColor(secH, 0.30f, 0.88f)
val onSecondary = hslToColor(secH, 0.3f, 0.12f)
val terH = (hsl.h + 60f) % 360f
val tertiary = hslToColor(terH, 0.40f, 0.80f)
val tertiaryContainer = hslToColor(terH, 0.30f, 0.22f)
val onTertiaryContainer = hslToColor(terH, 0.30f, 0.88f)
val onTertiary = hslToColor(terH, 0.3f, 0.12f)
val surfaceTint = primary
return darkColorScheme(
primary = primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = onPrimaryContainer,
secondary = secondary,
onSecondary = onSecondary,
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSecondaryContainer,
tertiary = tertiary,
onTertiary = onTertiary,
tertiaryContainer = tertiaryContainer,
onTertiaryContainer = onTertiaryContainer,
surfaceTint = surfaceTint,
)
}
@@ -4,10 +4,8 @@ import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
@@ -15,67 +13,22 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme(
primary = LightPrimary,
onPrimary = LightOnPrimary,
primaryContainer = LightPrimaryContainer,
onPrimaryContainer = LightOnPrimaryContainer,
secondary = LightSecondary,
onSecondary = LightOnSecondary,
secondaryContainer = LightSecondaryContainer,
onSecondaryContainer = LightOnSecondaryContainer,
tertiary = LightTertiary,
onTertiary = LightOnTertiary,
tertiaryContainer = LightTertiaryContainer,
onTertiaryContainer = LightOnTertiaryContainer,
background = LightBackground,
onBackground = LightOnBackground,
surface = LightSurface,
onSurface = LightOnSurface,
surfaceVariant = LightSurfaceVariant,
onSurfaceVariant = LightOnSurfaceVariant,
error = LightError,
outline = LightOutline,
outlineVariant = LightOutlineVariant,
)
private val DarkColorScheme = darkColorScheme(
primary = DarkPrimary,
onPrimary = DarkOnPrimary,
primaryContainer = DarkPrimaryContainer,
onPrimaryContainer = DarkOnPrimaryContainer,
secondary = DarkSecondary,
onSecondary = DarkOnSecondary,
secondaryContainer = DarkSecondaryContainer,
onSecondaryContainer = DarkOnSecondaryContainer,
tertiary = DarkTertiary,
onTertiary = DarkOnTertiary,
tertiaryContainer = DarkTertiaryContainer,
onTertiaryContainer = DarkOnTertiaryContainer,
background = DarkBackground,
onBackground = DarkOnBackground,
surface = DarkSurface,
onSurface = DarkOnSurface,
surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = DarkOnSurfaceVariant,
error = DarkError,
outline = DarkOutline,
outlineVariant = DarkOutlineVariant,
)
@Composable
fun CyreneTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
presetKey: String = "pink",
useDynamicColor: Boolean = false,
content: @Composable () -> Unit,
) {
val preset = getPreset(presetKey)
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
darkTheme -> buildDarkScheme(preset.darkPrimary)
else -> buildLightScheme(preset.lightPrimary)
}
val view = LocalView.current
@@ -91,7 +44,6 @@ fun CyreneTheme(
isAppearanceLightNavigationBars = !darkTheme
}
} else {
// Non-Activity context (e.g. VoiceInteractionSession overlay) — transparent
view.rootView?.setBackgroundColor(android.graphics.Color.TRANSPARENT)
}
}
@@ -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
}
}
}
@@ -48,12 +48,18 @@ class SettingsViewModel(
private val _autoScreenContext = MutableStateFlow(false)
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
private val _themeColor = MutableStateFlow("pink")
val themeColor: StateFlow<String> = _themeColor.asStateFlow()
private val _enterToSend = MutableStateFlow(false)
val enterToSend: StateFlow<Boolean> = _enterToSend.asStateFlow()
private val _typingIndicatorStyle = MutableStateFlow("bubble")
val typingIndicatorStyle: StateFlow<String> = _typingIndicatorStyle.asStateFlow()
private val _rootKeepAlive = MutableStateFlow(false)
val rootKeepAlive: StateFlow<Boolean> = _rootKeepAlive.asStateFlow()
private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
@@ -77,6 +83,16 @@ class SettingsViewModel(
_enterToSend.value = value
}
}
scope.launch {
preferencesDataStore.rootKeepAlive.collect { value ->
_rootKeepAlive.value = value
}
}
scope.launch {
preferencesDataStore.themeColor.collect { value ->
_themeColor.value = value
}
}
scope.launch {
combine(
preferencesDataStore.baseUrl,
@@ -191,6 +207,16 @@ class SettingsViewModel(
scope.launch { preferencesDataStore.saveEnterToSend(enabled) }
}
fun saveThemeColor(color: String) {
_themeColor.value = color
scope.launch { preferencesDataStore.saveThemeColor(color) }
}
fun saveRootKeepAlive(enabled: Boolean) {
_rootKeepAlive.value = enabled
scope.launch { preferencesDataStore.saveRootKeepAlive(enabled) }
}
fun clearLocalMessages() {
scope.launch {
chatRepository.clearLocalMessages()