feat: auto screen context toggle + fix dark mode status bar

- Add auto_screen_context preference (boolean, default false) to DataStore
- Add Switch toggle in Settings > Voice section to control automatic screen
  content reading when assistant is invoked via power button
- CyreneVoiceInteractionSession onShow now checks the preference before
  calling AccessibilityService to read and send screen content
- Fix status bar white in dark mode: MainActivity now observes theme mode
  preference (light/dark/auto) and passes resolved boolean to CyreneTheme
- CyreneTheme sets statusBarColor, navigationBarColor, and
  isAppearanceLightStatusBars/isAppearanceLightNavigationBars explicitly

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 18:25:47 +08:00
parent 2725fdd1d5
commit 5247eef0fc
6 changed files with 84 additions and 5 deletions
@@ -7,8 +7,13 @@ import android.provider.Settings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import org.koin.compose.koinInject
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.service.CyreneVoiceInteractionService import top.yeij.cyrene.service.CyreneVoiceInteractionService
import top.yeij.cyrene.ui.navigation.CyreneNavGraph import top.yeij.cyrene.ui.navigation.CyreneNavGraph
import top.yeij.cyrene.ui.navigation.Routes import top.yeij.cyrene.ui.navigation.Routes
@@ -26,7 +31,15 @@ class MainActivity : ComponentActivity() {
isDefaultAssistant.value = checkIsDefaultAssistant() isDefaultAssistant.value = checkIsDefaultAssistant()
setContent { setContent {
CyreneTheme { val prefs: PreferencesDataStore = koinInject()
val themeMode by prefs.themeMode.collectAsState(initial = null)
val darkTheme = when (themeMode) {
"light" -> false
"dark" -> true
else -> isSystemInDarkTheme()
}
CyreneTheme(darkTheme = darkTheme) {
val navController = rememberNavController() val navController = rememberNavController()
CyreneNavGraph( CyreneNavGraph(
@@ -3,6 +3,7 @@ package top.yeij.cyrene.data.local
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
@@ -31,6 +32,7 @@ class PreferencesDataStore(private val context: Context) {
private val KEY_PROFILE_NICKNAME = stringPreferencesKey("profile_nickname") private val KEY_PROFILE_NICKNAME = stringPreferencesKey("profile_nickname")
private val KEY_PROFILE_IS_ADMIN = stringPreferencesKey("profile_is_admin") private val KEY_PROFILE_IS_ADMIN = stringPreferencesKey("profile_is_admin")
private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at") private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at")
private val KEY_AUTO_SCREEN_CONTEXT = booleanPreferencesKey("auto_screen_context")
} }
val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] } val token: Flow<String?> = context.dataStore.data.map { it[KEY_TOKEN] }
@@ -115,6 +117,12 @@ class PreferencesDataStore(private val context: Context) {
} }
} }
val autoScreenContext: Flow<Boolean> = context.dataStore.data.map { it[KEY_AUTO_SCREEN_CONTEXT] ?: false }
suspend fun saveAutoScreenContext(enabled: Boolean) {
context.dataStore.edit { it[KEY_AUTO_SCREEN_CONTEXT] = enabled }
}
suspend fun clearProfileCache() { suspend fun clearProfileCache() {
context.dataStore.edit { context.dataStore.edit {
it.remove(KEY_PROFILE_USER_ID) it.remove(KEY_PROFILE_USER_ID)
@@ -16,8 +16,11 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
import top.yeij.cyrene.MainActivity import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.ui.overlay.OverlayContent import top.yeij.cyrene.ui.overlay.OverlayContent
import top.yeij.cyrene.ui.theme.CyreneTheme import top.yeij.cyrene.ui.theme.CyreneTheme
import top.yeij.cyrene.util.Constants import top.yeij.cyrene.util.Constants
@@ -92,12 +95,21 @@ class CyreneVoiceInteractionSession(context: Context) :
// Configure window: extend behind status bar, don't resize for IME // Configure window: extend behind status bar, don't resize for IME
configureWindow() configureWindow()
// Only read screen content if user enabled it in settings (default off)
val autoScreenContext = try {
val prefs: PreferencesDataStore = GlobalContext.get().get()
runBlocking { prefs.autoScreenContext.firstOrNull() } ?: false
} catch (_: Exception) {
false
}
if (autoScreenContext) {
val screenContent = CyreneAccessibilityService.getScreenContent() val screenContent = CyreneAccessibilityService.getScreenContent()
if (screenContent.isNotBlank()) { if (screenContent.isNotBlank()) {
overlayViewModel?.sendScreenContext(screenContent) overlayViewModel?.sendScreenContext(screenContent)
RuntimeLog.general("overlay", "Screen context sent, len=${screenContent.length}") RuntimeLog.general("overlay", "Screen context sent, len=${screenContent.length}")
} }
} }
}
private fun configureWindow() { private fun configureWindow() {
try { try {
@@ -72,6 +72,7 @@ fun SettingsScreen(
val dashScopeApiKey by viewModel.dashScopeApiKey.collectAsState() val dashScopeApiKey by viewModel.dashScopeApiKey.collectAsState()
val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState() val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState()
val dashScopeModel by viewModel.dashScopeModel.collectAsState() val dashScopeModel by viewModel.dashScopeModel.collectAsState()
val autoScreenContext by viewModel.autoScreenContext.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -224,6 +225,20 @@ fun SettingsScreen(
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
) )
Spacer(modifier = Modifier.height(8.dp))
ListItem(
headlineContent = { Text("自动读取屏幕内容") },
supportingContent = { Text("电源键唤起助手时自动截取当前屏幕内容并加入对话上下文") },
trailingContent = {
androidx.compose.material3.Switch(
checked = autoScreenContext,
onCheckedChange = { viewModel.saveAutoScreenContext(it) },
)
},
modifier = Modifier.clickable { viewModel.saveAutoScreenContext(!autoScreenContext) },
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider() HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -1,5 +1,6 @@
package top.yeij.cyrene.ui.theme package top.yeij.cyrene.ui.theme
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
@@ -8,7 +9,11 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme( private val LightColorScheme = lightColorScheme(
primary = LightPrimary, primary = LightPrimary,
@@ -73,6 +78,19 @@ fun CyreneTheme(
else -> LightColorScheme else -> LightColorScheme
} }
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.background.toArgb()
window.navigationBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme
}
}
}
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = CyreneTypography, typography = CyreneTypography,
@@ -45,6 +45,9 @@ class SettingsViewModel(
private val _dashScopeModel = MutableStateFlow("fun-asr-realtime") private val _dashScopeModel = MutableStateFlow("fun-asr-realtime")
val dashScopeModel: StateFlow<String> = _dashScopeModel.asStateFlow() val dashScopeModel: StateFlow<String> = _dashScopeModel.asStateFlow()
private val _autoScreenContext = MutableStateFlow(false)
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
private val _isLoggedIn = MutableStateFlow(false) private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow() val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
@@ -53,6 +56,11 @@ class SettingsViewModel(
_isLoggedIn.value = authRepository.isLoggedIn() _isLoggedIn.value = authRepository.isLoggedIn()
} }
// Single collector for all DataStore preferences — avoids subscriber explosion // Single collector for all DataStore preferences — avoids subscriber explosion
scope.launch {
preferencesDataStore.autoScreenContext.collect { value ->
_autoScreenContext.value = value
}
}
scope.launch { scope.launch {
combine( combine(
preferencesDataStore.baseUrl, preferencesDataStore.baseUrl,
@@ -152,6 +160,11 @@ class SettingsViewModel(
scope.launch { preferencesDataStore.saveDashScopeModel(model) } scope.launch { preferencesDataStore.saveDashScopeModel(model) }
} }
fun saveAutoScreenContext(enabled: Boolean) {
_autoScreenContext.value = enabled
scope.launch { preferencesDataStore.saveAutoScreenContext(enabled) }
}
fun clearLocalMessages() { fun clearLocalMessages() {
scope.launch { scope.launch {
chatRepository.clearLocalMessages() chatRepository.clearLocalMessages()