fix: WebSocket dead-connection recovery, notification delivery, theme system overhaul
- Detect silent WebSocket drops via 30s no-message timeout + 15s heartbeat - Force reconnect in onAppBackground via foreground service context - Reduce KeepAlive interval from 15min to 5min for faster background recovery - Replace callback-based notification with direct NotificationHelper injection - Suppress notifications during initial launch and when app is foregrounded - 9 theme color presets (pink default) + Monet dynamic color (Android 12+) - Full HSL-derived MD3 ColorScheme replacing stale purple-only scheme - Inline markdown rendering for chat messages (bold, italic, code, links) - Long-press copy on error/system messages - Hidden root keep-alive toggle (5-tap) with system-level commands - BootReceiver to reapply keep-alive and restart service on boot Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,12 @@
|
||||
<!-- 锁屏交互 -->
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
|
||||
<!-- 激进保活 -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!-- 查询其他应用(检查默认助手设置) -->
|
||||
<queries>
|
||||
<intent>
|
||||
@@ -89,7 +95,25 @@
|
||||
<service
|
||||
android:name=".service.WebSocketKeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
android:foregroundServiceType="dataSync|specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="websocket_keepalive_for_push_message_delivery" />
|
||||
</service>
|
||||
|
||||
<!-- 开机自启 -->
|
||||
<receiver
|
||||
android:name=".service.BootReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- 定时保活唤醒 -->
|
||||
<receiver
|
||||
android:name=".service.KeepAliveReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- FileProvider:日志分享 -->
|
||||
<provider
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package top.yeij.cyrene
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -17,7 +23,8 @@ import top.yeij.cyrene.data.remote.AuthInterceptor
|
||||
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
|
||||
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||
import top.yeij.cyrene.di.appModule
|
||||
import top.yeij.cyrene.util.NotificationHelper
|
||||
import top.yeij.cyrene.service.KeepAliveReceiver
|
||||
import top.yeij.cyrene.util.RootKeepAliveHelper
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@@ -25,8 +32,6 @@ class CyreneApplication : Application() {
|
||||
|
||||
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val activityCount = AtomicInteger(0)
|
||||
@Volatile
|
||||
private var notificationHelper: NotificationHelper? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -42,7 +47,7 @@ class CyreneApplication : Application() {
|
||||
override fun onActivityStarted(activity: Activity) {
|
||||
if (activityCount.incrementAndGet() == 1) {
|
||||
RuntimeLog.general("app", "App in foreground")
|
||||
notificationHelper?.cancelAll()
|
||||
getRepo()?.cancelNotifications()
|
||||
getRepo()?.onAppForeground()
|
||||
}
|
||||
}
|
||||
@@ -61,16 +66,6 @@ class CyreneApplication : Application() {
|
||||
override fun onActivityDestroyed(activity: Activity) {}
|
||||
})
|
||||
|
||||
// Set up background notification callback once Koin is ready
|
||||
initScope.launch {
|
||||
val helper = NotificationHelper(this@CyreneApplication)
|
||||
notificationHelper = helper
|
||||
val repo = getRepo()
|
||||
repo?.setNotificationCallback { message ->
|
||||
helper.showMessageNotification(message)
|
||||
}
|
||||
}
|
||||
|
||||
initScope.launch {
|
||||
val koin = GlobalContext.get()
|
||||
val prefs: PreferencesDataStore = koin.get()
|
||||
@@ -84,6 +79,49 @@ class CyreneApplication : Application() {
|
||||
authInterceptor.token = token
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule periodic keep-alive on first launch
|
||||
scheduleInitialKeepAlive()
|
||||
|
||||
// Check battery optimization
|
||||
checkBatteryOptimization()
|
||||
|
||||
// Apply root keep-alive if enabled
|
||||
initScope.launch {
|
||||
val koin = GlobalContext.get()
|
||||
val prefs: PreferencesDataStore = koin.get()
|
||||
val enabled = prefs.rootKeepAlive.firstOrNull() ?: false
|
||||
if (enabled) {
|
||||
val ok = RootKeepAliveHelper.applyRootKeepAlive(packageName)
|
||||
if (ok) {
|
||||
RuntimeLog.general("app", "Root keep-alive re-applied on boot")
|
||||
} else if (RootKeepAliveHelper.isRootAvailable()) {
|
||||
RuntimeLog.general("app", "Root keep-alive re-apply failed despite root being available")
|
||||
}
|
||||
// Only attempt system wakelock if root keep-alive was enabled
|
||||
// We don't persist the wakelock across reboots since it's per-session
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleInitialKeepAlive() {
|
||||
try {
|
||||
KeepAliveReceiver.schedule(this)
|
||||
RuntimeLog.general("app", "Initial keep-alive alarm scheduled")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to schedule initial keep-alive: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkBatteryOptimization() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val pm = getSystemService(PowerManager::class.java)
|
||||
if (pm?.isIgnoringBatteryOptimizations(packageName) == false) {
|
||||
Log.i(TAG, "App is NOT exempt from battery optimization")
|
||||
// Note: we can't request exemption from Application context directly.
|
||||
// SettingsScreen should offer a button to open the exemption dialog.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRepo(): ChatRepositoryImpl? {
|
||||
|
||||
@@ -33,13 +33,19 @@ class MainActivity : ComponentActivity() {
|
||||
setContent {
|
||||
val prefs: PreferencesDataStore = koinInject()
|
||||
val themeMode by prefs.themeMode.collectAsState(initial = null)
|
||||
val themeColor by prefs.themeColor.collectAsState(initial = "pink")
|
||||
val darkTheme = when (themeMode) {
|
||||
"light" -> false
|
||||
"dark" -> true
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
val useDynamic = themeColor == "monet" && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
|
||||
|
||||
CyreneTheme(darkTheme = darkTheme) {
|
||||
CyreneTheme(
|
||||
darkTheme = darkTheme,
|
||||
presetKey = themeColor,
|
||||
useDynamicColor = useDynamic,
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
CyreneNavGraph(
|
||||
|
||||
@@ -35,6 +35,8 @@ class PreferencesDataStore(private val context: Context) {
|
||||
private val KEY_AUTO_SCREEN_CONTEXT = booleanPreferencesKey("auto_screen_context")
|
||||
private val KEY_TYPING_INDICATOR_STYLE = stringPreferencesKey("typing_indicator_style")
|
||||
private val KEY_ENTER_TO_SEND = booleanPreferencesKey("enter_to_send")
|
||||
private val KEY_ROOT_KEEPALIVE = booleanPreferencesKey("root_keepalive")
|
||||
private val KEY_THEME_COLOR = stringPreferencesKey("theme_color")
|
||||
}
|
||||
|
||||
val typingIndicatorStyle: Flow<String> = context.dataStore.data.map { it[KEY_TYPING_INDICATOR_STYLE] ?: "bubble" }
|
||||
@@ -49,6 +51,18 @@ class PreferencesDataStore(private val context: Context) {
|
||||
context.dataStore.edit { it[KEY_ENTER_TO_SEND] = enabled }
|
||||
}
|
||||
|
||||
val rootKeepAlive: Flow<Boolean> = context.dataStore.data.map { it[KEY_ROOT_KEEPALIVE] ?: false }
|
||||
|
||||
suspend fun saveRootKeepAlive(enabled: Boolean) {
|
||||
context.dataStore.edit { it[KEY_ROOT_KEEPALIVE] = enabled }
|
||||
}
|
||||
|
||||
val themeColor: Flow<String> = context.dataStore.data.map { it[KEY_THEME_COLOR] ?: "pink" }
|
||||
|
||||
suspend fun saveThemeColor(color: String) {
|
||||
context.dataStore.edit { it[KEY_THEME_COLOR] = color }
|
||||
}
|
||||
|
||||
val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
|
||||
val refreshToken: Flow<String?> = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] }
|
||||
val baseUrl: Flow<String?> = context.dataStore.data.map { it[KEY_BASE_URL] }
|
||||
|
||||
@@ -27,8 +27,10 @@ import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||
import top.yeij.cyrene.domain.model.Conversation
|
||||
import top.yeij.cyrene.domain.model.Message
|
||||
import top.yeij.cyrene.domain.repository.ChatRepository
|
||||
import top.yeij.cyrene.service.KeepAliveReceiver
|
||||
import top.yeij.cyrene.service.WebSocketKeepAliveService
|
||||
import top.yeij.cyrene.service.WebSocketService
|
||||
import top.yeij.cyrene.util.NotificationHelper
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import java.util.UUID
|
||||
|
||||
@@ -39,6 +41,7 @@ class ChatRepositoryImpl(
|
||||
private val webSocketService: WebSocketService,
|
||||
private val apiService: ApiService,
|
||||
private val preferencesDataStore: PreferencesDataStore,
|
||||
private val notificationHelper: NotificationHelper,
|
||||
) : ChatRepository {
|
||||
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, e ->
|
||||
@@ -68,7 +71,7 @@ class ChatRepositoryImpl(
|
||||
override var currentSessionId: String? = null
|
||||
|
||||
private var isAppInForeground = false
|
||||
private var onBackgroundNotification: ((Message) -> Unit)? = null
|
||||
private var hasEverBeenForeground = false
|
||||
private var historyRequested = false
|
||||
private val notifiedMessageIds = mutableSetOf<String>()
|
||||
|
||||
@@ -80,13 +83,16 @@ class ChatRepositoryImpl(
|
||||
private var lastResponseContent: String? = null
|
||||
private var lastResponseTime = 0L
|
||||
|
||||
fun setNotificationCallback(callback: ((Message) -> Unit)?) {
|
||||
onBackgroundNotification = callback
|
||||
fun cancelNotifications() {
|
||||
notificationHelper.cancelAll()
|
||||
}
|
||||
|
||||
override fun onAppForeground() {
|
||||
isAppInForeground = true
|
||||
hasEverBeenForeground = true
|
||||
notifiedMessageIds.clear()
|
||||
notificationHelper.cancelAll()
|
||||
KeepAliveReceiver.cancel(app)
|
||||
WebSocketKeepAliveService.stop(app)
|
||||
// Always reconnect and sync history when returning to foreground
|
||||
webSocketService.forceReconnect()
|
||||
@@ -99,10 +105,17 @@ class ChatRepositoryImpl(
|
||||
|
||||
override fun onAppBackground() {
|
||||
isAppInForeground = false
|
||||
if (_connectionState.value) {
|
||||
// Always start keep-alive — connection may be silently dead and need recovery
|
||||
WebSocketKeepAliveService.start(app)
|
||||
RuntimeLog.general("app", "Started keep-alive service for background")
|
||||
KeepAliveReceiver.schedule(app)
|
||||
// Force reconnect after foreground service is up so the socket
|
||||
// is tied to the service's lifecycle, not the foreground activity's
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(1500) // let the service start first
|
||||
webSocketService.forceReconnect()
|
||||
RuntimeLog.general("app", "Background reconnect after service start, connected=${_connectionState.value}")
|
||||
}
|
||||
RuntimeLog.general("app", "Started keep-alive service + periodic alarm for background (connected=${_connectionState.value})")
|
||||
}
|
||||
|
||||
init {
|
||||
@@ -184,7 +197,7 @@ class ChatRepositoryImpl(
|
||||
}
|
||||
|
||||
override suspend fun ensureConnected() {
|
||||
if (_connectionState.value) return
|
||||
// Always force reconnect — connectionState may be stuck at true on a silently dead socket
|
||||
webSocketService.forceReconnect()
|
||||
}
|
||||
|
||||
@@ -603,6 +616,7 @@ class ChatRepositoryImpl(
|
||||
|
||||
"multi_message" -> {
|
||||
recentParsedContents.clear()
|
||||
var isFirst = true
|
||||
wsMsg.multiMessages?.forEach { item ->
|
||||
val content = item.content ?: ""
|
||||
recentParsedContents.add(content)
|
||||
@@ -614,7 +628,9 @@ class ChatRepositoryImpl(
|
||||
msgType = item.msgType ?: "chat",
|
||||
timestamp = wsMsg.timestamp ?: System.currentTimeMillis(),
|
||||
isStreaming = false,
|
||||
shouldNotify = isFirst,
|
||||
)
|
||||
isFirst = false
|
||||
}
|
||||
if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
|
||||
cleanupWrappingResponse()
|
||||
@@ -657,9 +673,10 @@ class ChatRepositoryImpl(
|
||||
)
|
||||
_incomingMessages.tryEmit(message)
|
||||
|
||||
if (shouldNotify && !isAppInForeground && role == "assistant" && !isStreaming) {
|
||||
if (shouldNotify && hasEverBeenForeground && !isAppInForeground && role == "assistant" && !isStreaming) {
|
||||
if (notifiedMessageIds.add(id)) {
|
||||
onBackgroundNotification?.invoke(message)
|
||||
notificationHelper.showMessageNotification(message)
|
||||
RuntimeLog.general("app", "Notification sent for msgId=$id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import top.yeij.cyrene.viewmodel.IoTViewModel
|
||||
import top.yeij.cyrene.viewmodel.OverlayViewModel
|
||||
import top.yeij.cyrene.viewmodel.ProfileViewModel
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
import top.yeij.cyrene.util.NotificationHelper
|
||||
import top.yeij.cyrene.util.VoiceRecorder
|
||||
import top.yeij.cyrene.voice.stt.BackendSttProvider
|
||||
import top.yeij.cyrene.voice.stt.DashScopeSttService
|
||||
@@ -34,6 +35,9 @@ import top.yeij.cyrene.voice.tts.TextToSpeechEngine
|
||||
|
||||
val appModule = module {
|
||||
|
||||
// Notifications
|
||||
single { NotificationHelper(androidContext()) }
|
||||
|
||||
// DataStore
|
||||
single { PreferencesDataStore(androidContext()) }
|
||||
|
||||
@@ -63,7 +67,7 @@ val appModule = module {
|
||||
|
||||
// Repositories
|
||||
single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) }
|
||||
single<ChatRepository> { ChatRepositoryImpl(androidContext() as android.app.Application, get(), get(), get(), get(), get()) }
|
||||
single<ChatRepository> { ChatRepositoryImpl(androidContext() as android.app.Application, get(), get(), get(), get(), get(), get()) }
|
||||
single<IoTRepository> { IoTRepositoryImpl(get(), get()) }
|
||||
|
||||
// UseCases
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.context.GlobalContext
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
Log.i(TAG, "Boot completed, restoring background connection")
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
// Wait a moment for Koin to initialize
|
||||
kotlinx.coroutines.delay(5000)
|
||||
|
||||
val prefs: PreferencesDataStore = GlobalContext.get().get()
|
||||
val token = prefs.token.firstOrNull()
|
||||
if (token.isNullOrBlank()) {
|
||||
Log.i(TAG, "No auth token, skipping auto-connect")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Start keep-alive service
|
||||
WebSocketKeepAliveService.start(context)
|
||||
|
||||
// Always reconnect — session may be stale
|
||||
val repo: ChatRepositoryImpl = GlobalContext.get().get()
|
||||
repo.ensureConnected()
|
||||
Log.i(TAG, "Boot connection restored, connected=${repo.connectionState.value}")
|
||||
|
||||
// Schedule periodic wake-up
|
||||
KeepAliveReceiver.schedule(context)
|
||||
|
||||
Log.i(TAG, "Background connection restored")
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to restore connection on boot: ${e.message}", e)
|
||||
// Fallback: still try to start the service
|
||||
try { WebSocketKeepAliveService.start(context) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneBoot"
|
||||
}
|
||||
}
|
||||
@@ -68,10 +68,11 @@ class CyreneVoiceInteractionSession(context: Context) :
|
||||
val vm = overlayViewModel
|
||||
val session = this@CyreneVoiceInteractionSession
|
||||
|
||||
val darkTheme = runBlocking {
|
||||
val (darkTheme, themeColorKey) = runBlocking {
|
||||
val prefs = GlobalContext.get().get<PreferencesDataStore>()
|
||||
val mode = prefs.themeMode.firstOrNull()
|
||||
when (mode) {
|
||||
val color = prefs.themeColor.firstOrNull() ?: "pink"
|
||||
val dark = when (mode) {
|
||||
"light" -> false
|
||||
"dark" -> true
|
||||
else -> {
|
||||
@@ -80,6 +81,7 @@ class CyreneVoiceInteractionSession(context: Context) :
|
||||
nightMode == Configuration.UI_MODE_NIGHT_YES
|
||||
}
|
||||
}
|
||||
Pair(dark, color)
|
||||
}
|
||||
|
||||
return ComposeView(context).apply {
|
||||
@@ -93,7 +95,11 @@ class CyreneVoiceInteractionSession(context: Context) :
|
||||
setViewTreeLifecycleOwner(session)
|
||||
setViewTreeSavedStateRegistryOwner(session)
|
||||
setContent {
|
||||
CyreneTheme(darkTheme = darkTheme) {
|
||||
CyreneTheme(
|
||||
darkTheme = darkTheme,
|
||||
presetKey = themeColorKey,
|
||||
useDynamicColor = themeColorKey == "monet",
|
||||
) {
|
||||
if (vm != null) {
|
||||
OverlayContent(
|
||||
onDismiss = { finish() },
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.context.GlobalContext
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
|
||||
|
||||
class KeepAliveReceiver : BroadcastReceiver() {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "Keep-alive alarm fired")
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val prefs: PreferencesDataStore = GlobalContext.get().get()
|
||||
val token = prefs.token.firstOrNull()
|
||||
if (token.isNullOrBlank()) {
|
||||
Log.d(TAG, "No auth token, skipping wake-up")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Always restart foreground service
|
||||
if (!WebSocketKeepAliveService.isRunning) {
|
||||
WebSocketKeepAliveService.start(context)
|
||||
Log.i(TAG, "Keep-alive service restarted")
|
||||
}
|
||||
|
||||
// Always force reconnect — connectionState may be stuck at true on a dead socket
|
||||
val repo: ChatRepositoryImpl = GlobalContext.get().get()
|
||||
repo.ensureConnected()
|
||||
Log.i(TAG, "WebSocket reconnection triggered, connected=${repo.connectionState.value}")
|
||||
|
||||
// Schedule next wake-up
|
||||
schedule(context)
|
||||
|
||||
Log.d(TAG, "Keep-alive check complete, connected=${repo.connectionState.value}")
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Keep-alive check failed: ${e.message}", e)
|
||||
// Schedule next anyway
|
||||
schedule(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneKeepAlive"
|
||||
|
||||
const val INTERVAL_MS = 5 * 60 * 1000L // 5 minutes
|
||||
|
||||
fun schedule(context: Context) {
|
||||
try {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = Intent(context, KeepAliveReceiver::class.java)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context, 0, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
// Cancel existing alarm first
|
||||
alarmManager.cancel(pendingIntent)
|
||||
|
||||
val triggerAt = System.currentTimeMillis() + INTERVAL_MS
|
||||
if (alarmManager.canScheduleExactAlarms()) {
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent
|
||||
)
|
||||
} else {
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to schedule keep-alive: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
try {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = Intent(context, KeepAliveReceiver::class.java)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context, 0, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
alarmManager.cancel(pendingIntent)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to cancel keep-alive: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,56 @@
|
||||
package top.yeij.cyrene.service
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import top.yeij.cyrene.MainActivity
|
||||
import top.yeij.cyrene.R
|
||||
|
||||
class WebSocketKeepAliveService : Service() {
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
isRunning = true
|
||||
createChannel()
|
||||
acquireWakeLock()
|
||||
Log.i(TAG, "Service created, wakeLock held")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
isRunning = false
|
||||
releaseWakeLock()
|
||||
scheduleRestart()
|
||||
Log.i(TAG, "Service destroyed, restart scheduled")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "onStartCommand flags=$flags startId=$startId")
|
||||
startForegroundNotification()
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
// App swiped away from recents — schedule restart and let it die
|
||||
Log.i(TAG, "Task removed, scheduling restart")
|
||||
scheduleRestart()
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
private fun startForegroundNotification() {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
@@ -35,16 +60,55 @@ class WebSocketKeepAliveService : Service() {
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("昔涟")
|
||||
.setContentText("已连接,可在后台接收消息")
|
||||
.setContentText("后台连接中,可接收消息推送")
|
||||
.setOngoing(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.build()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(NOTIFICATION_ID, notification, 0x40000001 /* dataSync | specialUse */)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
return START_STICKY
|
||||
}
|
||||
}
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
if (wakeLock?.isHeld == true) return
|
||||
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = pm.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"Cyrene:WebSocketKeepAlive"
|
||||
).apply {
|
||||
acquire(10 * 60 * 1000L) // 10 min timeout as safety net
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
try { wakeLock?.release() } catch (_: Exception) { }
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
private fun scheduleRestart() {
|
||||
try {
|
||||
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = Intent(this, KeepAliveReceiver::class.java)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
this, 0, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val triggerAt = System.currentTimeMillis() + RESTART_DELAY_MS
|
||||
if (alarmManager.canScheduleExactAlarms()) {
|
||||
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
|
||||
} else {
|
||||
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pendingIntent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to schedule restart: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
@@ -61,6 +125,7 @@ class WebSocketKeepAliveService : Service() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CyreneKeepAlive"
|
||||
private const val CHANNEL_ID = "cyrene_keepalive"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
|
||||
@@ -80,5 +145,7 @@ class WebSocketKeepAliveService : Service() {
|
||||
Intent(context, WebSocketKeepAliveService::class.java)
|
||||
)
|
||||
}
|
||||
|
||||
const val RESTART_DELAY_MS = 60_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import top.yeij.cyrene.data.local.PreferencesDataStore
|
||||
import top.yeij.cyrene.data.remote.dto.WSClientMessage
|
||||
import top.yeij.cyrene.data.remote.dto.WSServerMessage
|
||||
@@ -47,6 +48,8 @@ class WebSocketService(
|
||||
private var shouldReconnect = true
|
||||
private var currentSessionId: String? = null
|
||||
private val connectionId = AtomicInteger(0)
|
||||
@Volatile private var lastMessageReceived = System.currentTimeMillis()
|
||||
private val deadConnectionTimeoutMs = 30_000L // No message for 30s = treat as dead
|
||||
|
||||
private var clientId: String = ""
|
||||
private var deviceName: String = ""
|
||||
@@ -136,6 +139,7 @@ class WebSocketService(
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (connectionId.get() != connId) return
|
||||
lastMessageReceived = System.currentTimeMillis()
|
||||
try {
|
||||
val msg = gson.fromJson(text, WSServerMessage::class.java)
|
||||
_incomingMessages.tryEmit(msg)
|
||||
@@ -177,7 +181,8 @@ class WebSocketService(
|
||||
if (errorMsg != null) {
|
||||
_connectionError.value = errorMsg
|
||||
}
|
||||
// onClosed will always follow, which triggers scheduleReconnect
|
||||
// onClosed may or may not follow — schedule reconnect directly
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -269,9 +274,15 @@ class WebSocketService(
|
||||
cancelHeartbeat()
|
||||
heartbeatJob = scope.launch {
|
||||
while (_isConnected.value) {
|
||||
delay(30_000)
|
||||
if (_isConnected.value) {
|
||||
delay(15_000)
|
||||
if (!_isConnected.value) break
|
||||
sendPing()
|
||||
// Check if connection is silently dead (no message received in 60s)
|
||||
val sinceLastMsg = System.currentTimeMillis() - lastMessageReceived
|
||||
if (sinceLastMsg > deadConnectionTimeoutMs) {
|
||||
Log.w(TAG, "No message received for ${sinceLastMsg}ms — connection may be dead, forcing reconnect")
|
||||
forceReconnect()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,7 +531,7 @@ private fun ChatMessageBubble(
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = content,
|
||||
text = renderInlineMarkdown(content),
|
||||
modifier = Modifier.padding(12.dp),
|
||||
color = if (isUser)
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
@@ -634,19 +634,44 @@ private fun ToolProgressBubble(content: String, modifier: Modifier = Modifier) {
|
||||
|
||||
// --- System info bubble ---
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SystemInfoBubble(content: String, modifier: Modifier = Modifier) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Box {
|
||||
Text(
|
||||
text = content,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = { showMenu = true },
|
||||
),
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("复制") },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
showMenu = false
|
||||
clipboardManager.setText(AnnotatedString(content))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
package top.yeij.cyrene.ui.screens.settings
|
||||
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -15,20 +22,23 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.BatterySaver
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.DarkMode
|
||||
import androidx.compose.material.icons.filled.DeleteForever
|
||||
import androidx.compose.material.icons.filled.LightMode
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.SettingsBrightness
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.SettingsBrightness
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
@@ -42,18 +52,24 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -62,11 +78,15 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import top.yeij.cyrene.ui.theme.PresetColorLabels
|
||||
import top.yeij.cyrene.ui.theme.PresetThemeColors
|
||||
import top.yeij.cyrene.util.KeepAliveManager
|
||||
import top.yeij.cyrene.util.LogCategory
|
||||
import top.yeij.cyrene.util.RootKeepAliveHelper
|
||||
import top.yeij.cyrene.util.RuntimeLog
|
||||
import top.yeij.cyrene.viewmodel.SettingsViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
@@ -81,7 +101,9 @@ fun SettingsScreen(
|
||||
val dashScopeModel by viewModel.dashScopeModel.collectAsState()
|
||||
val autoScreenContext by viewModel.autoScreenContext.collectAsState()
|
||||
val typingIndicatorStyle by viewModel.typingIndicatorStyle.collectAsState()
|
||||
val themeColor by viewModel.themeColor.collectAsState()
|
||||
val enterToSend by viewModel.enterToSend.collectAsState()
|
||||
val rootKeepAlive by viewModel.rootKeepAlive.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -206,11 +228,139 @@ fun SettingsScreen(
|
||||
},
|
||||
)
|
||||
|
||||
var showColorDialog by remember { mutableStateOf(false) }
|
||||
val currentColorLabel = PresetColorLabels[themeColor] ?: "昔涟粉"
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("主题色") },
|
||||
supportingContent = { Text("昔涟紫") },
|
||||
leadingContent = { Icon(Icons.Filled.Palette, contentDescription = null) },
|
||||
supportingContent = { Text(currentColorLabel) },
|
||||
leadingContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.background(
|
||||
color = androidx.compose.ui.graphics.Color(
|
||||
(PresetThemeColors[themeColor]?.seed ?: 0xFFE91E8C).toInt()
|
||||
),
|
||||
shape = CircleShape,
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { showColorDialog = true },
|
||||
)
|
||||
|
||||
if (showColorDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showColorDialog = false },
|
||||
title = { Text("选择主题色") },
|
||||
text = {
|
||||
Column {
|
||||
// Monet option (Android 12+)
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
||||
val isMonet = themeColor == "monet"
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
viewModel.saveThemeColor("monet")
|
||||
showColorDialog = false
|
||||
}
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.background(
|
||||
brush = androidx.compose.ui.graphics.Brush.horizontalGradient(
|
||||
listOf(
|
||||
androidx.compose.ui.graphics.Color(0xFF4ECDC4),
|
||||
androidx.compose.ui.graphics.Color(0xFFFF6B6B),
|
||||
androidx.compose.ui.graphics.Color(0xFFFFE66D),
|
||||
androidx.compose.ui.graphics.Color(0xFF45B7D1),
|
||||
)
|
||||
),
|
||||
shape = CircleShape,
|
||||
)
|
||||
.then(
|
||||
if (isMonet) Modifier.border(
|
||||
3.dp,
|
||||
MaterialTheme.colorScheme.primary,
|
||||
CircleShape,
|
||||
) else Modifier
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "莫奈取色",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = "跟随壁纸",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Preset color chips
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
PresetThemeColors.keys.forEach { key ->
|
||||
val isSelected = themeColor == key
|
||||
val seedColor = androidx.compose.ui.graphics.Color(
|
||||
(PresetThemeColors[key]?.seed ?: 0xFFE91E8C).toInt()
|
||||
)
|
||||
val label = PresetColorLabels[key] ?: key
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
viewModel.saveThemeColor(key)
|
||||
showColorDialog = false
|
||||
}
|
||||
.padding(4.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.background(seedColor, CircleShape)
|
||||
.then(
|
||||
if (isSelected) Modifier.border(
|
||||
3.dp,
|
||||
MaterialTheme.colorScheme.primary,
|
||||
CircleShape,
|
||||
) else Modifier.border(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant,
|
||||
CircleShape,
|
||||
)
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showColorDialog = false }) {
|
||||
Text("关闭")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val indicatorStyleLabel = if (typingIndicatorStyle == "text") "文字" else "气泡"
|
||||
ListItem(
|
||||
@@ -240,6 +390,189 @@ fun SettingsScreen(
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Background keep-alive
|
||||
val keepAliveManager = remember { KeepAliveManager(context) }
|
||||
var isBatteryExempt by remember { mutableStateOf(keepAliveManager.isBatteryOptimizationExempt()) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
// Re-check battery exemption when returning from system settings
|
||||
LaunchedEffect(lifecycleOwner) {
|
||||
lifecycleOwner.lifecycle.addObserver(object : androidx.lifecycle.DefaultLifecycleObserver {
|
||||
override fun onResume(owner: androidx.lifecycle.LifecycleOwner) {
|
||||
isBatteryExempt = keepAliveManager.isBatteryOptimizationExempt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Hidden root toggle: tap section title 5 times to reveal
|
||||
var rootTapCount by remember { mutableIntStateOf(0) }
|
||||
var rootRevealed by remember { mutableStateOf(false) }
|
||||
|
||||
Text(
|
||||
text = "后台保活",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (!rootRevealed) {
|
||||
rootTapCount++
|
||||
if (rootTapCount >= 5) {
|
||||
rootRevealed = true
|
||||
rootTapCount = 0
|
||||
Toast.makeText(context, "已解锁 Root 保活选项", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("忽略电池优化") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (isBatteryExempt) "已允许,后台连接更稳定"
|
||||
else "未允许,建议开启以确保消息推送及时送达"
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Filled.BatterySaver,
|
||||
contentDescription = null,
|
||||
tint = if (isBatteryExempt) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
if (!isBatteryExempt) {
|
||||
try {
|
||||
context.startActivity(keepAliveManager.openBatteryOptimizationSettings())
|
||||
} catch (_: Exception) {
|
||||
Toast.makeText(context, "无法打开电池优化设置", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Root-level keep-alive (hidden by default, revealed after 5 taps)
|
||||
if (rootRevealed) {
|
||||
val isRootAvailable = remember { RootKeepAliveHelper.isRootAvailable() }
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("Root 保活 (隐藏)") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (!isRootAvailable) "未检测到 Root 权限"
|
||||
else if (rootKeepAlive) "已启用 — 系统级白名单、Doze豁免、强制后台运行"
|
||||
else "使用 Root 权限将应用加入系统级白名单,对抗任何保活限制"
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Filled.Terminal,
|
||||
contentDescription = null,
|
||||
tint = if (rootKeepAlive && isRootAvailable) MaterialTheme.colorScheme.tertiary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = rootKeepAlive,
|
||||
enabled = isRootAvailable,
|
||||
onCheckedChange = { enabled ->
|
||||
if (enabled) {
|
||||
val success = RootKeepAliveHelper.applyRootKeepAlive(context.packageName)
|
||||
if (success) {
|
||||
viewModel.saveRootKeepAlive(true)
|
||||
Toast.makeText(context, "Root 保活已启用", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, "Root 保活应用失败,请检查 Root 权限", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
} else {
|
||||
RootKeepAliveHelper.removeRootKeepAlive(context.packageName)
|
||||
viewModel.saveRootKeepAlive(false)
|
||||
Toast.makeText(context, "Root 保活已关闭", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable(enabled = isRootAvailable) {
|
||||
val newState = !rootKeepAlive
|
||||
if (newState) {
|
||||
val success = RootKeepAliveHelper.applyRootKeepAlive(context.packageName)
|
||||
if (success) {
|
||||
viewModel.saveRootKeepAlive(true)
|
||||
Toast.makeText(context, "Root 保活已启用", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, "Root 保活应用失败", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
} else {
|
||||
RootKeepAliveHelper.removeRootKeepAlive(context.packageName)
|
||||
viewModel.saveRootKeepAlive(false)
|
||||
Toast.makeText(context, "Root 保活已关闭", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// System wakelock toggle (held only while app is alive, reapplied on boot)
|
||||
var sysWakeLockHeld by remember { mutableStateOf(false) }
|
||||
ListItem(
|
||||
headlineContent = { Text("系统级 WakeLock (隐藏)") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (!isRootAvailable) "需要 Root 权限"
|
||||
else if (sysWakeLockHeld) "已持有系统级内核锁,CPU永不休眠"
|
||||
else "写入 /sys/power/wake_lock 阻止 CPU 休眠"
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
Icons.Filled.Security,
|
||||
contentDescription = null,
|
||||
tint = if (sysWakeLockHeld) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = sysWakeLockHeld,
|
||||
enabled = isRootAvailable,
|
||||
onCheckedChange = { enable ->
|
||||
if (enable) {
|
||||
val ok = RootKeepAliveHelper.acquireSystemWakeLock("CyreneKA")
|
||||
if (ok) {
|
||||
sysWakeLockHeld = true
|
||||
Toast.makeText(context, "系统 WakeLock 已持有 — 注意:将显著增加耗电", Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
Toast.makeText(context, "WakeLock 获取失败", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
val ok = RootKeepAliveHelper.releaseSystemWakeLock("CyreneKA")
|
||||
if (ok) {
|
||||
sysWakeLockHeld = false
|
||||
Toast.makeText(context, "系统 WakeLock 已释放", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, "WakeLock 释放失败", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text("保活设置") },
|
||||
supportingContent = { Text("前台服务、自启动管理、OEM厂商后台白名单") },
|
||||
leadingContent = { Icon(Icons.Filled.Security, contentDescription = null) },
|
||||
modifier = Modifier.clickable { onNavigateToKeepAlive() },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Voice
|
||||
Text(
|
||||
text = "语音",
|
||||
@@ -493,28 +826,6 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Keep-alive
|
||||
Text(
|
||||
text = "后台保活",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text("保活设置") },
|
||||
supportingContent = { Text("前台服务、电池优化、自启动等保活方式") },
|
||||
leadingContent = { Icon(Icons.Filled.Security, contentDescription = null) },
|
||||
modifier = Modifier.clickable { onNavigateToKeepAlive() },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Runtime logs
|
||||
Text(
|
||||
text = "运行日志",
|
||||
|
||||
@@ -1,58 +1,201 @@
|
||||
package top.yeij.cyrene.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
// Light theme
|
||||
val LightPrimary = Color(0xFF6D3BC0)
|
||||
val LightOnPrimary = Color(0xFFFFFFFF)
|
||||
val LightPrimaryContainer = Color(0xFFEEDCFF)
|
||||
val LightOnPrimaryContainer = Color(0xFF250058)
|
||||
val LightSecondary = Color(0xFF625B71)
|
||||
val LightOnSecondary = Color(0xFFFFFFFF)
|
||||
val LightSecondaryContainer = Color(0xFFE8DEF8)
|
||||
val LightOnSecondaryContainer = Color(0xFF1E192B)
|
||||
val LightTertiary = Color(0xFF7E5260)
|
||||
val LightOnTertiary = Color(0xFFFFFFFF)
|
||||
val LightTertiaryContainer = Color(0xFFFFD9E3)
|
||||
val LightOnTertiaryContainer = Color(0xFF31101D)
|
||||
val LightBackground = Color(0xFFFFFBFF)
|
||||
val LightOnBackground = Color(0xFF1C1B1F)
|
||||
val LightSurface = Color(0xFFFFFBFF)
|
||||
val LightOnSurface = Color(0xFF1C1B1F)
|
||||
val LightSurfaceVariant = Color(0xFFE7E0EC)
|
||||
val LightOnSurfaceVariant = Color(0xFF49454F)
|
||||
val LightError = Color(0xFFBA1A1A)
|
||||
val LightOutline = Color(0xFF79747E)
|
||||
val LightOutlineVariant = Color(0xFFCAC4D0)
|
||||
|
||||
// Dark theme
|
||||
val DarkPrimary = Color(0xFFD3BBFF)
|
||||
val DarkOnPrimary = Color(0xFF3D0089)
|
||||
val DarkPrimaryContainer = Color(0xFF541BA6)
|
||||
val DarkOnPrimaryContainer = Color(0xFFEEDCFF)
|
||||
val DarkSecondary = Color(0xFFCBC2DC)
|
||||
val DarkOnSecondary = Color(0xFF332D41)
|
||||
val DarkSecondaryContainer = Color(0xFF4A4458)
|
||||
val DarkOnSecondaryContainer = Color(0xFFE8DEF8)
|
||||
val DarkTertiary = Color(0xFFEFB8C8)
|
||||
val DarkOnTertiary = Color(0xFF4A2532)
|
||||
val DarkTertiaryContainer = Color(0xFF633B48)
|
||||
val DarkOnTertiaryContainer = Color(0xFFFFD9E3)
|
||||
val DarkBackground = Color(0xFF1C1B1F)
|
||||
val DarkOnBackground = Color(0xFFE6E1E5)
|
||||
val DarkSurface = Color(0xFF1C1B1F)
|
||||
val DarkOnSurface = Color(0xFFE6E1E5)
|
||||
val DarkSurfaceVariant = Color(0xFF49454F)
|
||||
val DarkOnSurfaceVariant = Color(0xFFCAC4D0)
|
||||
val DarkError = Color(0xFFFFB4AB)
|
||||
val DarkOutline = Color(0xFF938F99)
|
||||
val DarkOutlineVariant = Color(0xFF49454F)
|
||||
|
||||
// Preset seed colors for manual theme selection
|
||||
val SeedColors = mapOf(
|
||||
"default" to 0xFF6D3BC0, // Lavender
|
||||
"sakura" to 0xFFFFB4C8, // Pink
|
||||
"ocean" to 0xFF6BA4FF, // Blue
|
||||
"forest" to 0xFF6BCF7C, // Green
|
||||
"sunset" to 0xFFFF9E6B, // Orange
|
||||
// Each preset provides a seed color and light/dark primary colors
|
||||
data class ThemePreset(
|
||||
val seed: Long,
|
||||
val lightPrimary: Color,
|
||||
val darkPrimary: Color,
|
||||
)
|
||||
|
||||
val PresetThemeColors = mapOf(
|
||||
"pink" to ThemePreset(
|
||||
seed = 0xFFE91E8C,
|
||||
lightPrimary = Color(0xFFC2185B),
|
||||
darkPrimary = Color(0xFFFF80AB),
|
||||
),
|
||||
"sakura" to ThemePreset(
|
||||
seed = 0xFFFFB4C8,
|
||||
lightPrimary = Color(0xFFE91E63),
|
||||
darkPrimary = Color(0xFFFFB4C8),
|
||||
),
|
||||
"lavender" to ThemePreset(
|
||||
seed = 0xFF6D3BC0,
|
||||
lightPrimary = Color(0xFF6D3BC0),
|
||||
darkPrimary = Color(0xFFD3BBFF),
|
||||
),
|
||||
"ocean" to ThemePreset(
|
||||
seed = 0xFF1565C0,
|
||||
lightPrimary = Color(0xFF1565C0),
|
||||
darkPrimary = Color(0xFF90CAF9),
|
||||
),
|
||||
"forest" to ThemePreset(
|
||||
seed = 0xFF2E7D32,
|
||||
lightPrimary = Color(0xFF2E7D32),
|
||||
darkPrimary = Color(0xFFA5D6A7),
|
||||
),
|
||||
"sunset" to ThemePreset(
|
||||
seed = 0xFFE65100,
|
||||
lightPrimary = Color(0xFFE65100),
|
||||
darkPrimary = Color(0xFFFFCC80),
|
||||
),
|
||||
"rose" to ThemePreset(
|
||||
seed = 0xFFD81B60,
|
||||
lightPrimary = Color(0xFFAD1457),
|
||||
darkPrimary = Color(0xFFF48FB1),
|
||||
),
|
||||
"sky" to ThemePreset(
|
||||
seed = 0xFF0277BD,
|
||||
lightPrimary = Color(0xFF0277BD),
|
||||
darkPrimary = Color(0xFF81D4FA),
|
||||
),
|
||||
"mint" to ThemePreset(
|
||||
seed = 0xFF00695C,
|
||||
lightPrimary = Color(0xFF00695C),
|
||||
darkPrimary = Color(0xFF80CBC4),
|
||||
),
|
||||
)
|
||||
|
||||
val PresetColorLabels = mapOf(
|
||||
"pink" to "昔涟粉",
|
||||
"sakura" to "樱花粉",
|
||||
"lavender" to "薰衣草紫",
|
||||
"ocean" to "海洋蓝",
|
||||
"forest" to "森林绿",
|
||||
"sunset" to "日落橙",
|
||||
"rose" to "玫瑰红",
|
||||
"sky" to "天空蓝",
|
||||
"mint" to "薄荷青",
|
||||
)
|
||||
|
||||
fun getPreset(key: String): ThemePreset = PresetThemeColors[key] ?: PresetThemeColors["pink"]!!
|
||||
|
||||
// --- Color derivation via HSL — generates cohesive MD3-like schemes ---
|
||||
|
||||
private data class HSL(val h: Float, val s: Float, val l: Float)
|
||||
|
||||
private fun Color.toHSL(): HSL {
|
||||
val r = red / 255f
|
||||
val g = green / 255f
|
||||
val b = blue / 255f
|
||||
val maxV = max(max(r, g), b)
|
||||
val minV = min(min(r, g), b)
|
||||
val delta = maxV - minV
|
||||
val l = (maxV + minV) / 2f
|
||||
val s = if (delta == 0f) 0f else delta / (1f - abs(2f * l - 1f))
|
||||
val h = when {
|
||||
delta == 0f -> 0f
|
||||
maxV == r -> 60f * (((g - b) / delta) % 6f)
|
||||
maxV == g -> 60f * (((b - r) / delta) + 2f)
|
||||
else -> 60f * (((r - g) / delta) + 4f)
|
||||
}
|
||||
return HSL(if (h < 0) h + 360f else h, s, l)
|
||||
}
|
||||
|
||||
private fun hslToColor(h: Float, s: Float, l: Float): Color {
|
||||
val c = (1f - abs(2f * l - 1f)) * s
|
||||
val x = c * (1f - abs((h / 60f) % 2f - 1f))
|
||||
val m = l - c / 2f
|
||||
val (r, g, b) = when {
|
||||
h < 60f -> Triple(c, x, 0f)
|
||||
h < 120f -> Triple(x, c, 0f)
|
||||
h < 180f -> Triple(0f, c, x)
|
||||
h < 240f -> Triple(0f, x, c)
|
||||
h < 300f -> Triple(x, 0f, c)
|
||||
else -> Triple(c, 0f, x)
|
||||
}
|
||||
return Color((r + m).coerceIn(0f, 1f), (g + m).coerceIn(0f, 1f), (b + m).coerceIn(0f, 1f))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full light ColorScheme derived from a primary color.
|
||||
* All secondary/tertiary/container colors are computed from the primary
|
||||
* so focus rings, ripples, badges, and containers all match the theme.
|
||||
*/
|
||||
fun buildLightScheme(primary: Color): ColorScheme {
|
||||
val hsl = primary.toHSL()
|
||||
|
||||
val primaryContainer = hslToColor(hsl.h, 0.3f, 0.90f)
|
||||
val onPrimaryContainer = hslToColor(hsl.h, 0.5f, 0.15f)
|
||||
|
||||
// Secondary: similar hue, less saturated
|
||||
val secH = (hsl.h + 15f) % 360f
|
||||
val secondary = hslToColor(secH, 0.35f, 0.42f)
|
||||
val secondaryContainer = hslToColor(secH, 0.25f, 0.90f)
|
||||
val onSecondaryContainer = hslToColor(secH, 0.3f, 0.15f)
|
||||
|
||||
// Tertiary: complementary hue shift
|
||||
val terH = (hsl.h + 60f) % 360f
|
||||
val tertiary = hslToColor(terH, 0.40f, 0.38f)
|
||||
val tertiaryContainer = hslToColor(terH, 0.30f, 0.90f)
|
||||
val onTertiaryContainer = hslToColor(terH, 0.35f, 0.15f)
|
||||
|
||||
val onPrimary = Color.White
|
||||
val onSecondary = Color.White
|
||||
val onTertiary = Color.White
|
||||
val surfaceTint = primary
|
||||
|
||||
return lightColorScheme(
|
||||
primary = primary,
|
||||
onPrimary = onPrimary,
|
||||
primaryContainer = primaryContainer,
|
||||
onPrimaryContainer = onPrimaryContainer,
|
||||
secondary = secondary,
|
||||
onSecondary = onSecondary,
|
||||
secondaryContainer = secondaryContainer,
|
||||
onSecondaryContainer = onSecondaryContainer,
|
||||
tertiary = tertiary,
|
||||
onTertiary = onTertiary,
|
||||
tertiaryContainer = tertiaryContainer,
|
||||
onTertiaryContainer = onTertiaryContainer,
|
||||
surfaceTint = surfaceTint,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full dark ColorScheme derived from a primary color.
|
||||
*/
|
||||
fun buildDarkScheme(primary: Color): ColorScheme {
|
||||
val hsl = primary.toHSL()
|
||||
|
||||
val primaryContainer = hslToColor(hsl.h, 0.40f, 0.22f)
|
||||
val onPrimaryContainer = hslToColor(hsl.h, 0.30f, 0.88f)
|
||||
val onPrimary = hslToColor(hsl.h, 0.5f, 0.10f)
|
||||
|
||||
val secH = (hsl.h + 15f) % 360f
|
||||
val secondary = hslToColor(secH, 0.40f, 0.76f)
|
||||
val secondaryContainer = hslToColor(secH, 0.30f, 0.22f)
|
||||
val onSecondaryContainer = hslToColor(secH, 0.30f, 0.88f)
|
||||
val onSecondary = hslToColor(secH, 0.3f, 0.12f)
|
||||
|
||||
val terH = (hsl.h + 60f) % 360f
|
||||
val tertiary = hslToColor(terH, 0.40f, 0.80f)
|
||||
val tertiaryContainer = hslToColor(terH, 0.30f, 0.22f)
|
||||
val onTertiaryContainer = hslToColor(terH, 0.30f, 0.88f)
|
||||
val onTertiary = hslToColor(terH, 0.3f, 0.12f)
|
||||
|
||||
val surfaceTint = primary
|
||||
|
||||
return darkColorScheme(
|
||||
primary = primary,
|
||||
onPrimary = onPrimary,
|
||||
primaryContainer = primaryContainer,
|
||||
onPrimaryContainer = onPrimaryContainer,
|
||||
secondary = secondary,
|
||||
onSecondary = onSecondary,
|
||||
secondaryContainer = secondaryContainer,
|
||||
onSecondaryContainer = onSecondaryContainer,
|
||||
tertiary = tertiary,
|
||||
onTertiary = onTertiary,
|
||||
tertiaryContainer = tertiaryContainer,
|
||||
onTertiaryContainer = onTertiaryContainer,
|
||||
surfaceTint = surfaceTint,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,8 @@ import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
@@ -15,67 +13,22 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = LightPrimary,
|
||||
onPrimary = LightOnPrimary,
|
||||
primaryContainer = LightPrimaryContainer,
|
||||
onPrimaryContainer = LightOnPrimaryContainer,
|
||||
secondary = LightSecondary,
|
||||
onSecondary = LightOnSecondary,
|
||||
secondaryContainer = LightSecondaryContainer,
|
||||
onSecondaryContainer = LightOnSecondaryContainer,
|
||||
tertiary = LightTertiary,
|
||||
onTertiary = LightOnTertiary,
|
||||
tertiaryContainer = LightTertiaryContainer,
|
||||
onTertiaryContainer = LightOnTertiaryContainer,
|
||||
background = LightBackground,
|
||||
onBackground = LightOnBackground,
|
||||
surface = LightSurface,
|
||||
onSurface = LightOnSurface,
|
||||
surfaceVariant = LightSurfaceVariant,
|
||||
onSurfaceVariant = LightOnSurfaceVariant,
|
||||
error = LightError,
|
||||
outline = LightOutline,
|
||||
outlineVariant = LightOutlineVariant,
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = DarkPrimary,
|
||||
onPrimary = DarkOnPrimary,
|
||||
primaryContainer = DarkPrimaryContainer,
|
||||
onPrimaryContainer = DarkOnPrimaryContainer,
|
||||
secondary = DarkSecondary,
|
||||
onSecondary = DarkOnSecondary,
|
||||
secondaryContainer = DarkSecondaryContainer,
|
||||
onSecondaryContainer = DarkOnSecondaryContainer,
|
||||
tertiary = DarkTertiary,
|
||||
onTertiary = DarkOnTertiary,
|
||||
tertiaryContainer = DarkTertiaryContainer,
|
||||
onTertiaryContainer = DarkOnTertiaryContainer,
|
||||
background = DarkBackground,
|
||||
onBackground = DarkOnBackground,
|
||||
surface = DarkSurface,
|
||||
onSurface = DarkOnSurface,
|
||||
surfaceVariant = DarkSurfaceVariant,
|
||||
onSurfaceVariant = DarkOnSurfaceVariant,
|
||||
error = DarkError,
|
||||
outline = DarkOutline,
|
||||
outlineVariant = DarkOutlineVariant,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CyreneTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = false,
|
||||
presetKey: String = "pink",
|
||||
useDynamicColor: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val preset = getPreset(presetKey)
|
||||
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
darkTheme -> buildDarkScheme(preset.darkPrimary)
|
||||
else -> buildLightScheme(preset.lightPrimary)
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
@@ -91,7 +44,6 @@ fun CyreneTheme(
|
||||
isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
} else {
|
||||
// Non-Activity context (e.g. VoiceInteractionSession overlay) — transparent
|
||||
view.rootView?.setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package top.yeij.cyrene.util
|
||||
|
||||
import android.util.Log
|
||||
import java.io.DataOutputStream
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Hidden root-based keep-alive operations. Only accessible via secret gesture in Settings.
|
||||
* Performs system-level whitelisting that normal apps cannot do.
|
||||
*/
|
||||
object RootKeepAliveHelper {
|
||||
|
||||
private const val TAG = "CyreneRootKA"
|
||||
|
||||
private fun execRoot(vararg commands: String): Boolean {
|
||||
return try {
|
||||
val process = Runtime.getRuntime().exec("su")
|
||||
val os = DataOutputStream(process.outputStream)
|
||||
val shell = buildString {
|
||||
append("export PATH=\$PATH:/system/bin:/system/xbin:/su/bin:/sbin:/vendor/bin\n")
|
||||
for (cmd in commands) {
|
||||
append(cmd).append(" 2>&1\n")
|
||||
}
|
||||
append("exit\n")
|
||||
}
|
||||
os.writeBytes(shell)
|
||||
os.flush()
|
||||
os.close()
|
||||
process.waitFor()
|
||||
val exitCode = process.exitValue()
|
||||
if (exitCode != 0) {
|
||||
Log.w(TAG, "Root command returned exit code $exitCode")
|
||||
}
|
||||
exitCode == 0
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Root exec failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun isRootAvailable(): Boolean {
|
||||
// Check common su binary locations
|
||||
val suPaths = listOf(
|
||||
"/system/bin/su",
|
||||
"/system/xbin/su",
|
||||
"/su/bin/su",
|
||||
"/sbin/su",
|
||||
"/system/sbin/su",
|
||||
"/vendor/bin/su",
|
||||
"/data/local/bin/su",
|
||||
)
|
||||
for (path in suPaths) {
|
||||
if (File(path).exists()) return true
|
||||
}
|
||||
|
||||
// Fallback: try running 'which su'
|
||||
return try {
|
||||
val p = Runtime.getRuntime().exec(arrayOf("which", "su"))
|
||||
p.waitFor()
|
||||
p.exitValue() == 0
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply aggressive root-level keep-alive.
|
||||
* @param packageName The app's package name.
|
||||
*/
|
||||
fun applyRootKeepAlive(packageName: String): Boolean {
|
||||
if (!isRootAvailable()) {
|
||||
Log.w(TAG, "Root not available, cannot apply root keep-alive")
|
||||
return false
|
||||
}
|
||||
|
||||
Log.i(TAG, "Applying root keep-alive for $packageName")
|
||||
|
||||
val commands = mutableListOf<String>()
|
||||
|
||||
// 1. Doze whitelist — prevent Doze from blocking network/wakelocks
|
||||
commands.add("dumpsys deviceidle whitelist +$packageName")
|
||||
|
||||
// 2. Disable standby bucket — keep app in "active" bucket
|
||||
commands.add("am set-standby-bucket $packageName active")
|
||||
|
||||
// 3. Grant WAKE_LOCK permission at system level (bypasses appops)
|
||||
commands.add("appops set $packageName WAKE_LOCK allow")
|
||||
|
||||
// 4. Disable battery optimization via system settings
|
||||
commands.add("settings put global app_restrictions_enabled false")
|
||||
commands.add("cmd appops set $packageName RUN_IN_BACKGROUND allow")
|
||||
commands.add("cmd appops set $packageName RUN_ANY_IN_BACKGROUND allow")
|
||||
|
||||
// 5. Make app not battery-restricted (hidden API)
|
||||
commands.add("cmd deviceidle tempwhitelist $packageName")
|
||||
|
||||
// 6. Set app as START_FOREGROUND always allowed
|
||||
commands.add("appops set $packageName START_FOREGROUND allow")
|
||||
|
||||
// 7. Persistent alarm allowance
|
||||
commands.add("appops set $packageName SCHEDULE_EXACT_ALARM allow")
|
||||
|
||||
val success = execRoot(*commands.toTypedArray())
|
||||
if (success) {
|
||||
Log.i(TAG, "Root keep-alive applied successfully")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to apply root keep-alive")
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove root-level keep-alive settings.
|
||||
*/
|
||||
fun removeRootKeepAlive(packageName: String): Boolean {
|
||||
if (!isRootAvailable()) return false
|
||||
|
||||
Log.i(TAG, "Removing root keep-alive for $packageName")
|
||||
|
||||
val commands = listOf(
|
||||
"dumpsys deviceidle whitelist -$packageName",
|
||||
"am set-standby-bucket $packageName rarely",
|
||||
"appops set $packageName WAKE_LOCK default",
|
||||
"appops set $packageName RUN_IN_BACKGROUND default",
|
||||
"appops set $packageName RUN_ANY_IN_BACKGROUND default",
|
||||
"appops set $packageName START_FOREGROUND default",
|
||||
"appops set $packageName SCHEDULE_EXACT_ALARM default",
|
||||
)
|
||||
|
||||
val success = execRoot(*commands.toTypedArray())
|
||||
if (success) {
|
||||
Log.i(TAG, "Root keep-alive removed successfully")
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold a system-level wakelock. Use sparingly — drains battery.
|
||||
* Released automatically when the process exits or can be released manually.
|
||||
*/
|
||||
private var wakeLockFile: File? = null
|
||||
|
||||
fun acquireSystemWakeLock(tag: String): Boolean {
|
||||
if (!isRootAvailable()) return false
|
||||
val lockPath = "/sys/power/wake_lock"
|
||||
return try {
|
||||
execRoot("echo '$tag' > $lockPath")
|
||||
wakeLockFile = File(lockPath)
|
||||
Log.i(TAG, "System wakelock acquired: $tag")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to acquire system wakelock: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseSystemWakeLock(tag: String): Boolean {
|
||||
if (!isRootAvailable()) return false
|
||||
val lockPath = "/sys/power/wake_unlock"
|
||||
return try {
|
||||
execRoot("echo '$tag' > $lockPath")
|
||||
wakeLockFile = null
|
||||
Log.i(TAG, "System wakelock released: $tag")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to release system wakelock: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,12 +48,18 @@ class SettingsViewModel(
|
||||
private val _autoScreenContext = MutableStateFlow(false)
|
||||
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
|
||||
|
||||
private val _themeColor = MutableStateFlow("pink")
|
||||
val themeColor: StateFlow<String> = _themeColor.asStateFlow()
|
||||
|
||||
private val _enterToSend = MutableStateFlow(false)
|
||||
val enterToSend: StateFlow<Boolean> = _enterToSend.asStateFlow()
|
||||
|
||||
private val _typingIndicatorStyle = MutableStateFlow("bubble")
|
||||
val typingIndicatorStyle: StateFlow<String> = _typingIndicatorStyle.asStateFlow()
|
||||
|
||||
private val _rootKeepAlive = MutableStateFlow(false)
|
||||
val rootKeepAlive: StateFlow<Boolean> = _rootKeepAlive.asStateFlow()
|
||||
|
||||
private val _isLoggedIn = MutableStateFlow(false)
|
||||
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
|
||||
|
||||
@@ -77,6 +83,16 @@ class SettingsViewModel(
|
||||
_enterToSend.value = value
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
preferencesDataStore.rootKeepAlive.collect { value ->
|
||||
_rootKeepAlive.value = value
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
preferencesDataStore.themeColor.collect { value ->
|
||||
_themeColor.value = value
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
combine(
|
||||
preferencesDataStore.baseUrl,
|
||||
@@ -191,6 +207,16 @@ class SettingsViewModel(
|
||||
scope.launch { preferencesDataStore.saveEnterToSend(enabled) }
|
||||
}
|
||||
|
||||
fun saveThemeColor(color: String) {
|
||||
_themeColor.value = color
|
||||
scope.launch { preferencesDataStore.saveThemeColor(color) }
|
||||
}
|
||||
|
||||
fun saveRootKeepAlive(enabled: Boolean) {
|
||||
_rootKeepAlive.value = enabled
|
||||
scope.launch { preferencesDataStore.saveRootKeepAlive(enabled) }
|
||||
}
|
||||
|
||||
fun clearLocalMessages() {
|
||||
scope.launch {
|
||||
chatRepository.clearLocalMessages()
|
||||
|
||||
Reference in New Issue
Block a user