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.compose.setContent
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.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.ui.navigation.CyreneNavGraph
import top.yeij.cyrene.ui.navigation.Routes
@@ -26,7 +31,15 @@ class MainActivity : ComponentActivity() {
isDefaultAssistant.value = checkIsDefaultAssistant()
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()
CyreneNavGraph(
@@ -3,6 +3,7 @@ package top.yeij.cyrene.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
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_IS_ADMIN = stringPreferencesKey("profile_is_admin")
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] }
@@ -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() {
context.dataStore.edit {
it.remove(KEY_PROFILE_USER_ID)
@@ -16,8 +16,11 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext
import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.ui.overlay.OverlayContent
import top.yeij.cyrene.ui.theme.CyreneTheme
import top.yeij.cyrene.util.Constants
@@ -92,10 +95,19 @@ class CyreneVoiceInteractionSession(context: Context) :
// Configure window: extend behind status bar, don't resize for IME
configureWindow()
val screenContent = CyreneAccessibilityService.getScreenContent()
if (screenContent.isNotBlank()) {
overlayViewModel?.sendScreenContext(screenContent)
RuntimeLog.general("overlay", "Screen context sent, len=${screenContent.length}")
// 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()
if (screenContent.isNotBlank()) {
overlayViewModel?.sendScreenContext(screenContent)
RuntimeLog.general("overlay", "Screen context sent, len=${screenContent.length}")
}
}
}
@@ -72,6 +72,7 @@ fun SettingsScreen(
val dashScopeApiKey by viewModel.dashScopeApiKey.collectAsState()
val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState()
val dashScopeModel by viewModel.dashScopeModel.collectAsState()
val autoScreenContext by viewModel.autoScreenContext.collectAsState()
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -224,6 +225,20 @@ fun SettingsScreen(
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))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
@@ -1,5 +1,6 @@
package top.yeij.cyrene.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
@@ -8,7 +9,11 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme(
primary = LightPrimary,
@@ -73,6 +78,19 @@ fun CyreneTheme(
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(
colorScheme = colorScheme,
typography = CyreneTypography,
@@ -45,6 +45,9 @@ class SettingsViewModel(
private val _dashScopeModel = MutableStateFlow("fun-asr-realtime")
val dashScopeModel: StateFlow<String> = _dashScopeModel.asStateFlow()
private val _autoScreenContext = MutableStateFlow(false)
val autoScreenContext: StateFlow<Boolean> = _autoScreenContext.asStateFlow()
private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn.asStateFlow()
@@ -53,6 +56,11 @@ class SettingsViewModel(
_isLoggedIn.value = authRepository.isLoggedIn()
}
// Single collector for all DataStore preferences — avoids subscriber explosion
scope.launch {
preferencesDataStore.autoScreenContext.collect { value ->
_autoScreenContext.value = value
}
}
scope.launch {
combine(
preferencesDataStore.baseUrl,
@@ -152,6 +160,11 @@ class SettingsViewModel(
scope.launch { preferencesDataStore.saveDashScopeModel(model) }
}
fun saveAutoScreenContext(enabled: Boolean) {
_autoScreenContext.value = enabled
scope.launch { preferencesDataStore.saveAutoScreenContext(enabled) }
}
fun clearLocalMessages() {
scope.launch {
chatRepository.clearLocalMessages()