From 6394099e2e1f218d78a5bc71e68f430ece0cf5b7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 28 May 2026 12:56:21 +0800 Subject: [PATCH] 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 --- app/src/main/AndroidManifest.xml | 26 +- .../java/top/yeij/cyrene/CyreneApplication.kt | 66 +++- .../main/java/top/yeij/cyrene/MainActivity.kt | 8 +- .../cyrene/data/local/PreferencesDataStore.kt | 14 + .../data/repository/ChatRepositoryImpl.kt | 35 +- .../main/java/top/yeij/cyrene/di/AppModule.kt | 6 +- .../top/yeij/cyrene/service/BootReceiver.kt | 59 +++ .../service/CyreneVoiceInteractionSession.kt | 12 +- .../yeij/cyrene/service/KeepAliveReceiver.kt | 103 +++++ .../service/WebSocketKeepAliveService.kt | 77 +++- .../yeij/cyrene/service/WebSocketService.kt | 19 +- .../yeij/cyrene/ui/components/ChatBubble.kt | 39 +- .../ui/screens/settings/SettingsScreen.kt | 363 ++++++++++++++++-- .../java/top/yeij/cyrene/ui/theme/Color.kt | 249 +++++++++--- .../java/top/yeij/cyrene/ui/theme/Theme.kt | 62 +-- .../yeij/cyrene/util/RootKeepAliveHelper.kt | 170 ++++++++ .../cyrene/viewmodel/SettingsViewModel.kt | 26 ++ 17 files changed, 1155 insertions(+), 179 deletions(-) create mode 100644 app/src/main/java/top/yeij/cyrene/service/BootReceiver.kt create mode 100644 app/src/main/java/top/yeij/cyrene/service/KeepAliveReceiver.kt create mode 100644 app/src/main/java/top/yeij/cyrene/util/RootKeepAliveHelper.kt 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()