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.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>
@@ -89,7 +95,25 @@
<service <service
android:name=".service.WebSocketKeepAliveService" android:name=".service.WebSocketKeepAliveService"
android:exported="false" 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:日志分享 --> <!-- FileProvider:日志分享 -->
<provider <provider
@@ -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()
@@ -42,7 +47,7 @@ class CyreneApplication : Application() {
override fun onActivityStarted(activity: Activity) { override fun onActivityStarted(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()
} }
} }
@@ -61,16 +66,6 @@ class CyreneApplication : Application() {
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,6 +79,49 @@ 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? {
@@ -33,13 +33,19 @@ class MainActivity : ComponentActivity() {
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(
@@ -35,6 +35,8 @@ class PreferencesDataStore(private val context: Context) {
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_TYPING_INDICATOR_STYLE = stringPreferencesKey("typing_indicator_style")
private val KEY_ENTER_TO_SEND = booleanPreferencesKey("enter_to_send") 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" } 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 } 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] }
val refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_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] } 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.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.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
@@ -39,6 +41,7 @@ class ChatRepositoryImpl(
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 ->
@@ -68,7 +71,7 @@ class ChatRepositoryImpl(
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>()
@@ -80,13 +83,16 @@ 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()
} }
override fun onAppForeground() { override fun onAppForeground() {
isAppInForeground = true isAppInForeground = true
hasEverBeenForeground = true
notifiedMessageIds.clear() notifiedMessageIds.clear()
notificationHelper.cancelAll()
KeepAliveReceiver.cancel(app)
WebSocketKeepAliveService.stop(app) WebSocketKeepAliveService.stop(app)
// Always reconnect and sync history when returning to foreground // Always reconnect and sync history when returning to foreground
webSocketService.forceReconnect() webSocketService.forceReconnect()
@@ -99,10 +105,17 @@ class ChatRepositoryImpl(
override fun onAppBackground() { override fun onAppBackground() {
isAppInForeground = false isAppInForeground = false
if (_connectionState.value) { // Always start keep-alive — connection may be silently dead and need recovery
WebSocketKeepAliveService.start(app) 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 { init {
@@ -184,7 +197,7 @@ 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()
} }
@@ -603,6 +616,7 @@ class ChatRepositoryImpl(
"multi_message" -> { "multi_message" -> {
recentParsedContents.clear() recentParsedContents.clear()
var isFirst = true
wsMsg.multiMessages?.forEach { item -> wsMsg.multiMessages?.forEach { item ->
val content = item.content ?: "" val content = item.content ?: ""
recentParsedContents.add(content) recentParsedContents.add(content)
@@ -614,7 +628,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()
@@ -657,9 +673,10 @@ class ChatRepositoryImpl(
) )
_incomingMessages.tryEmit(message) _incomingMessages.tryEmit(message)
if (shouldNotify && !isAppInForeground && role == "assistant" && !isStreaming) { if (shouldNotify && hasEverBeenForeground && !isAppInForeground && role == "assistant" && !isStreaming) {
if (notifiedMessageIds.add(id)) { 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.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(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()) } single<IoTRepository> { IoTRepositoryImpl(get(), get()) }
// UseCases // 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 vm = overlayViewModel
val session = this@CyreneVoiceInteractionSession val session = this@CyreneVoiceInteractionSession
val darkTheme = runBlocking { val (darkTheme, themeColorKey) = runBlocking {
val prefs = GlobalContext.get().get<PreferencesDataStore>() val prefs = GlobalContext.get().get<PreferencesDataStore>()
val mode = prefs.themeMode.firstOrNull() val mode = prefs.themeMode.firstOrNull()
when (mode) { val color = prefs.themeColor.firstOrNull() ?: "pink"
val dark = when (mode) {
"light" -> false "light" -> false
"dark" -> true "dark" -> true
else -> { else -> {
@@ -80,6 +81,7 @@ class CyreneVoiceInteractionSession(context: Context) :
nightMode == Configuration.UI_MODE_NIGHT_YES nightMode == Configuration.UI_MODE_NIGHT_YES
} }
} }
Pair(dark, color)
} }
return ComposeView(context).apply { return ComposeView(context).apply {
@@ -93,7 +95,11 @@ class CyreneVoiceInteractionSession(context: Context) :
setViewTreeLifecycleOwner(session) setViewTreeLifecycleOwner(session)
setViewTreeSavedStateRegistryOwner(session) setViewTreeSavedStateRegistryOwner(session)
setContent { setContent {
CyreneTheme(darkTheme = darkTheme) { CyreneTheme(
darkTheme = darkTheme,
presetKey = themeColorKey,
useDynamicColor = themeColorKey == "monet",
) {
if (vm != null) { if (vm != null) {
OverlayContent( OverlayContent(
onDismiss = { finish() }, 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 package top.yeij.cyrene.service
import android.app.AlarmManager
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager
import android.util.Log
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
class WebSocketKeepAliveService : Service() { class WebSocketKeepAliveService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
isRunning = true isRunning = true
createChannel() createChannel()
acquireWakeLock()
Log.i(TAG, "Service created, wakeLock held")
} }
override fun onDestroy() { override fun onDestroy() {
isRunning = false isRunning = false
releaseWakeLock()
scheduleRestart()
Log.i(TAG, "Service destroyed, restart scheduled")
super.onDestroy() super.onDestroy()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 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( val pendingIntent = PendingIntent.getActivity(
this, 0, this, 0,
Intent(this, MainActivity::class.java).apply { Intent(this, MainActivity::class.java).apply {
@@ -35,16 +60,55 @@ class WebSocketKeepAliveService : Service() {
) )
val notification = NotificationCompat.Builder(this, CHANNEL_ID) val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("昔涟") .setContentTitle("昔涟")
.setContentText("连接,可在后台接收消息") .setContentText("后台连接,可接收消息推送")
.setOngoing(true) .setOngoing(true)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_LOW) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build() .build()
startForeground(NOTIFICATION_ID, notification) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
return START_STICKY 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() { private fun createChannel() {
@@ -61,6 +125,7 @@ class WebSocketKeepAliveService : Service() {
} }
companion object { companion object {
private const val TAG = "CyreneKeepAlive"
private const val CHANNEL_ID = "cyrene_keepalive" private const val CHANNEL_ID = "cyrene_keepalive"
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
@@ -80,5 +145,7 @@ class WebSocketKeepAliveService : Service() {
Intent(context, WebSocketKeepAliveService::class.java) Intent(context, WebSocketKeepAliveService::class.java)
) )
} }
const val RESTART_DELAY_MS = 60_000L
} }
} }
@@ -21,6 +21,7 @@ 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.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
@@ -47,6 +48,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 = ""
@@ -136,6 +139,7 @@ class WebSocketService(
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)
_incomingMessages.tryEmit(msg) _incomingMessages.tryEmit(msg)
@@ -177,7 +181,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()
} }
}) })
} }
@@ -269,9 +274,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
} }
} }
} }
@@ -531,7 +531,7 @@ private fun ChatMessageBubble(
), ),
) { ) {
Text( Text(
text = content, text = renderInlineMarkdown(content),
modifier = Modifier.padding(12.dp), modifier = Modifier.padding(12.dp),
color = if (isUser) color = if (isUser)
MaterialTheme.colorScheme.onPrimary MaterialTheme.colorScheme.onPrimary
@@ -634,19 +634,44 @@ private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
// --- System info bubble --- // --- 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))
},
)
}
}
} }
} }
@@ -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,20 +22,23 @@ 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.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.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.SettingsBrightness
import androidx.compose.material.icons.filled.Security 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.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
@@ -42,18 +52,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.Switch
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow 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
@@ -62,11 +78,15 @@ 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,
@@ -81,7 +101,9 @@ fun SettingsScreen(
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 typingIndicatorStyle by viewModel.typingIndicatorStyle.collectAsState()
val themeColor by viewModel.themeColor.collectAsState()
val enterToSend by viewModel.enterToSend.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()
@@ -206,12 +228,140 @@ fun SettingsScreen(
}, },
) )
var showColorDialog by remember { mutableStateOf(false) }
val currentColorLabel = PresetColorLabels[themeColor] ?: "昔涟粉"
ListItem( ListItem(
headlineContent = { Text("主题色") }, headlineContent = { Text("主题色") },
supportingContent = { Text("昔涟紫") }, supportingContent = { Text(currentColorLabel) },
leadingContent = { Icon(Icons.Filled.Palette, contentDescription = null) }, 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 "气泡" val indicatorStyleLabel = if (typingIndicatorStyle == "text") "文字" else "气泡"
ListItem( ListItem(
headlineContent = { Text("正在输入指示器") }, headlineContent = { Text("正在输入指示器") },
@@ -240,6 +390,189 @@ fun SettingsScreen(
HorizontalDivider() HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp)) 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 // Voice
Text( Text(
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 // Runtime logs
Text( Text(
text = "运行日志", text = "运行日志",
@@ -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,67 +13,22 @@ 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 = false, 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
@@ -91,7 +44,6 @@ fun CyreneTheme(
isAppearanceLightNavigationBars = !darkTheme isAppearanceLightNavigationBars = !darkTheme
} }
} else { } else {
// Non-Activity context (e.g. VoiceInteractionSession overlay) — transparent
view.rootView?.setBackgroundColor(android.graphics.Color.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) 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) private val _enterToSend = MutableStateFlow(false)
val enterToSend: StateFlow<Boolean> = _enterToSend.asStateFlow() val enterToSend: StateFlow<Boolean> = _enterToSend.asStateFlow()
private val _typingIndicatorStyle = MutableStateFlow("bubble") private val _typingIndicatorStyle = MutableStateFlow("bubble")
val typingIndicatorStyle: StateFlow<String> = _typingIndicatorStyle.asStateFlow() 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()
@@ -77,6 +83,16 @@ class SettingsViewModel(
_enterToSend.value = 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,
@@ -191,6 +207,16 @@ class SettingsViewModel(
scope.launch { preferencesDataStore.saveEnterToSend(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()