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