diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0f70773..fbc6255 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -22,6 +22,12 @@
+
+
+
+
+
+
@@ -89,7 +95,25 @@
+ android:foregroundServiceType="dataSync|specialUse">
+
+
+
+
+
+
+
+
+
+
+
+
- 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? {
diff --git a/app/src/main/java/top/yeij/cyrene/MainActivity.kt b/app/src/main/java/top/yeij/cyrene/MainActivity.kt
index 2e11bd4..d474574 100644
--- a/app/src/main/java/top/yeij/cyrene/MainActivity.kt
+++ b/app/src/main/java/top/yeij/cyrene/MainActivity.kt
@@ -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(
diff --git a/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt b/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt
index 86560b2..c8c6406 100644
--- a/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt
+++ b/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt
@@ -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 = 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 = 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 = 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 = context.dataStore.data.map { it[KEY_TOKEN] }
val refreshToken: Flow = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
val baseUrl: Flow = context.dataStore.data.map { it[KEY_BASE_URL] }
diff --git a/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt b/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt
index 9696c10..88a9826 100644
--- a/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt
+++ b/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt
@@ -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()
@@ -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) {
- WebSocketKeepAliveService.start(app)
- RuntimeLog.general("app", "Started keep-alive service for background")
+ // Always start keep-alive — connection may be silently dead and need recovery
+ WebSocketKeepAliveService.start(app)
+ 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")
}
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/di/AppModule.kt b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt
index 088ecf6..76dc938 100644
--- a/app/src/main/java/top/yeij/cyrene/di/AppModule.kt
+++ b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt
@@ -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 { AuthRepositoryImpl(get(), get(), get()) }
- single { ChatRepositoryImpl(androidContext() as android.app.Application, get(), get(), get(), get(), get()) }
+ single { ChatRepositoryImpl(androidContext() as android.app.Application, get(), get(), get(), get(), get(), get()) }
single { IoTRepositoryImpl(get(), get()) }
// UseCases
diff --git a/app/src/main/java/top/yeij/cyrene/service/BootReceiver.kt b/app/src/main/java/top/yeij/cyrene/service/BootReceiver.kt
new file mode 100644
index 0000000..6ab7127
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/service/BootReceiver.kt
@@ -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"
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt
index 2e77dbb..a44cb6d 100644
--- a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt
+++ b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt
@@ -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()
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() },
diff --git a/app/src/main/java/top/yeij/cyrene/service/KeepAliveReceiver.kt b/app/src/main/java/top/yeij/cyrene/service/KeepAliveReceiver.kt
new file mode 100644
index 0000000..61ddc68
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/service/KeepAliveReceiver.kt
@@ -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}")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/service/WebSocketKeepAliveService.kt b/app/src/main/java/top/yeij/cyrene/service/WebSocketKeepAliveService.kt
index cebb0da..9a8de84 100644
--- a/app/src/main/java/top/yeij/cyrene/service/WebSocketKeepAliveService.kt
+++ b/app/src/main/java/top/yeij/cyrene/service/WebSocketKeepAliveService.kt
@@ -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()
- startForeground(NOTIFICATION_ID, notification)
- return START_STICKY
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ startForeground(NOTIFICATION_ID, notification, 0x40000001 /* dataSync | specialUse */)
+ } else {
+ startForeground(NOTIFICATION_ID, notification)
+ }
+ }
+
+ private fun acquireWakeLock() {
+ if (wakeLock?.isHeld == true) return
+ val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
+ wakeLock = pm.newWakeLock(
+ PowerManager.PARTIAL_WAKE_LOCK,
+ "Cyrene:WebSocketKeepAlive"
+ ).apply {
+ acquire(10 * 60 * 1000L) // 10 min timeout as safety net
+ }
+ }
+
+ private fun releaseWakeLock() {
+ try { wakeLock?.release() } catch (_: Exception) { }
+ wakeLock = null
+ }
+
+ private fun scheduleRestart() {
+ try {
+ val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ val intent = Intent(this, KeepAliveReceiver::class.java)
+ val pendingIntent = PendingIntent.getBroadcast(
+ this, 0, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ val triggerAt = System.currentTimeMillis() + RESTART_DELAY_MS
+ if (alarmManager.canScheduleExactAlarms()) {
+ alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
+ } else {
+ alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to schedule restart: ${e.message}")
+ }
}
private fun createChannel() {
@@ -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
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt b/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt
index 3d54162..a67dbe9 100644
--- a/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt
+++ b/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt
@@ -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) {
- sendPing()
+ 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
}
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt b/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt
index b56defb..8628adc 100644
--- a/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt
+++ b/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt
@@ -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,
) {
- Text(
- text = content,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.error,
- textAlign = TextAlign.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))
+ },
+ )
+ }
+ }
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt
index 4812cc1..26f2712 100644
--- a/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt
+++ b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt
@@ -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,12 +228,140 @@ 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(
headlineContent = { Text("正在输入指示器") },
@@ -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 = "运行日志",
diff --git a/app/src/main/java/top/yeij/cyrene/ui/theme/Color.kt b/app/src/main/java/top/yeij/cyrene/ui/theme/Color.kt
index 8f73bc5..69b3c28 100644
--- a/app/src/main/java/top/yeij/cyrene/ui/theme/Color.kt
+++ b/app/src/main/java/top/yeij/cyrene/ui/theme/Color.kt
@@ -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,
+ )
+}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt b/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt
index 59eea3a..61494df 100644
--- a/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt
+++ b/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt
@@ -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)
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/util/RootKeepAliveHelper.kt b/app/src/main/java/top/yeij/cyrene/util/RootKeepAliveHelper.kt
new file mode 100644
index 0000000..f41c0d6
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/util/RootKeepAliveHelper.kt
@@ -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()
+
+ // 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
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt
index 4444fb2..7ca4057 100644
--- a/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt
+++ b/app/src/main/java/top/yeij/cyrene/viewmodel/SettingsViewModel.kt
@@ -48,12 +48,18 @@ class SettingsViewModel(
private val _autoScreenContext = MutableStateFlow(false)
val autoScreenContext: StateFlow = _autoScreenContext.asStateFlow()
+ private val _themeColor = MutableStateFlow("pink")
+ val themeColor: StateFlow = _themeColor.asStateFlow()
+
private val _enterToSend = MutableStateFlow(false)
val enterToSend: StateFlow = _enterToSend.asStateFlow()
private val _typingIndicatorStyle = MutableStateFlow("bubble")
val typingIndicatorStyle: StateFlow = _typingIndicatorStyle.asStateFlow()
+ private val _rootKeepAlive = MutableStateFlow(false)
+ val rootKeepAlive: StateFlow = _rootKeepAlive.asStateFlow()
+
private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn: StateFlow = _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()