From cd151d083c4b216ff88c1c13c0a07d4a6196c068 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Tue, 22 Sep 2026 16:55:28 -0400 Subject: [PATCH] Add internal remote config override screen. --- .../app/internal/InternalSettingsFragment.kt | 8 + .../InternalRemoteConfigAction.kt | 11 + .../remoteconfig/InternalRemoteConfigEvent.kt | 26 ++ .../InternalRemoteConfigFragment.kt | 38 +++ .../InternalRemoteConfigScreen.kt | 317 ++++++++++++++++++ .../remoteconfig/InternalRemoteConfigState.kt | 71 ++++ .../InternalRemoteConfigViewModel.kt | 205 +++++++++++ .../securesms/keyvalue/InternalValues.kt | 14 + .../logsubmit/LogSectionRemoteConfig.java | 11 + .../securesms/util/RemoteConfig.kt | 83 ++++- app/src/main/protowire/KeyValue.proto | 4 + .../app_settings_with_change_number.xml | 8 + .../util/RemoteConfigOverrideTest.kt | 143 ++++++++ .../util/RemoteConfig_StaticValuesTest.kt | 2 + 14 files changed, 939 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigAction.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigEvent.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigViewModel.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/util/RemoteConfigOverrideTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 8d09293967..bcf919ae8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -227,6 +227,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter } ) + clickPref( + title = DSLSettingsText.from("Override remote config"), + summary = DSLSettingsText.from("View every remote config flag and locally override any of them."), + onClick = { + findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalRemoteConfigFragment()) + } + ) + clickPref( title = DSLSettingsText.from("Refresh remote config"), summary = DSLSettingsText.from("Forces a refresh of the remote config locally instead of waiting for the elapsed time."), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigAction.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigAction.kt new file mode 100644 index 0000000000..9ae8b79e55 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigAction.kt @@ -0,0 +1,11 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.internal.remoteconfig + +sealed interface InternalRemoteConfigAction { + data object Exit : InternalRemoteConfigAction + data object RestartApp : InternalRemoteConfigAction +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigEvent.kt new file mode 100644 index 0000000000..0f79f9d8d6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigEvent.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.internal.remoteconfig + +sealed interface InternalRemoteConfigEvent { + data object Initialize : InternalRemoteConfigEvent + data object BackClicked : InternalRemoteConfigEvent + data class FilterChanged(val filter: String) : InternalRemoteConfigEvent + + /** Opens the editor for the config with this key. */ + data class ConfigClicked(val key: String) : InternalRemoteConfigEvent + + /** The value being typed (or picked) in the editor, before it's saved. */ + data class EditorValueChanged(val value: String) : InternalRemoteConfigEvent + data object EditorSaveClicked : InternalRemoteConfigEvent + data object EditorClearClicked : InternalRemoteConfigEvent + data object EditorDismissed : InternalRemoteConfigEvent + data object ClearAllClicked : InternalRemoteConfigEvent + data object ClearAllConfirmed : InternalRemoteConfigEvent + data object ClearAllDismissed : InternalRemoteConfigEvent + data object RestartConfirmed : InternalRemoteConfigEvent + data object RestartDismissed : InternalRemoteConfigEvent +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigFragment.kt new file mode 100644 index 0000000000..27d1c5b057 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigFragment.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.internal.remoteconfig + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.signal.core.ui.compose.CollectActions +import org.signal.core.ui.compose.ComposeFragment +import org.signal.core.util.AppUtil + +class InternalRemoteConfigFragment : ComposeFragment() { + + private val viewModel: InternalRemoteConfigViewModel by viewModels() + + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsStateWithLifecycle() + + CollectActions(viewModel.actions) { action -> handleAction(action) } + + InternalRemoteConfigScreen( + state = state, + onEvent = viewModel::onEvent + ) + } + + private fun handleAction(action: InternalRemoteConfigAction) { + when (action) { + InternalRemoteConfigAction.Exit -> requireActivity().onBackPressedDispatcher.onBackPressed() + InternalRemoteConfigAction.RestartApp -> AppUtil.restart(requireContext()) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigScreen.kt new file mode 100644 index 0000000000..b93f4fabc6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigScreen.kt @@ -0,0 +1,317 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.internal.remoteconfig + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.ClearableTextField +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Rows +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalIcons + +/** Lists every remote config flag, with a filter, and lets an internal user override any of them. */ +@Composable +fun InternalRemoteConfigScreen( + state: InternalRemoteConfigState, + onEvent: (InternalRemoteConfigEvent) -> Unit +) { + val listState = rememberLazyListState() + + LaunchedEffect(state.filter, state.overrideCount) { + listState.scrollToItem(0) + } + + Scaffolds.Settings( + title = "Remote config", + onNavigationClick = { onEvent(InternalRemoteConfigEvent.BackClicked) }, + navigationIcon = SignalIcons.ArrowStart.imageVector, + actions = { + if (state.overrideCount > 0) { + IconButton(onClick = { onEvent(InternalRemoteConfigEvent.ClearAllClicked) }) { + Icon(painter = SignalIcons.Trash.painter, contentDescription = "Clear all overrides") + } + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + ClearableTextField( + value = state.filter, + onValueChange = { onEvent(InternalRemoteConfigEvent.FilterChanged(it)) }, + hint = "Filter by key or value", + clearContentDescription = "Clear filter", + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + + Text( + text = if (state.overrideCount > 0) { + "${state.overrideCount} override(s) active. Overrides are applied on top of the values from the service and persist across restarts." + } else { + "Tap a flag to override it locally. Overrides are applied on top of the values from the service and persist across restarts." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + HorizontalDivider() + + if (state.showEmptyState) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "No flags match this filter.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + items(state.configs, key = { it.key }) { item -> + ConfigRow( + item = item, + onClick = { onEvent(InternalRemoteConfigEvent.ConfigClicked(item.key)) } + ) + } + } + } + } + } + + if (state.editor != null) { + EditOverrideDialog( + editor = state.editor, + onEvent = onEvent + ) + } + + if (state.showRestartDialog) { + Dialogs.SimpleAlertDialog( + title = "Restart app?", + body = if (state.restartPromptKeys.size == 1) { + "${state.restartPromptKeys.first()} isn't hot-swappable, so anything that already read it is still using the old value. Restart to apply it everywhere." + } else { + "${state.restartPromptKeys.size} of the flags you changed aren't hot-swappable, so anything that already read them is still using the old values. Restart to apply them everywhere." + }, + confirm = "Restart", + dismiss = "Not now", + onConfirm = { onEvent(InternalRemoteConfigEvent.RestartConfirmed) }, + onDismiss = { onEvent(InternalRemoteConfigEvent.RestartDismissed) } + ) + } + + if (state.showClearAllDialog) { + Dialogs.SimpleAlertDialog( + title = "Clear all overrides?", + body = "All ${state.overrideCount} override(s) will be removed and the values from the service will be used instead.", + confirm = "Clear", + dismiss = "Cancel", + onConfirm = { onEvent(InternalRemoteConfigEvent.ClearAllConfirmed) }, + onDismiss = { onEvent(InternalRemoteConfigEvent.ClearAllDismissed) } + ) + } +} + +@Composable +private fun ConfigRow(item: RemoteConfigListItem, onClick: () -> Unit) { + val label = buildString { + append(item.effectiveValue) + if (item.isOverridden) { + append("\nOVERRIDDEN (service value: ${item.remoteValue ?: "none"})") + } + if (!item.active) { + append("\nInactive: the service value is ignored, but an override from here still applies") + } + } + + Rows.TextRow( + text = AnnotatedString(item.key), + label = AnnotatedString(label), + foregroundTint = if (item.isOverridden) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + onClick = onClick + ) +} + +@Composable +private fun EditOverrideDialog( + editor: InternalRemoteConfigState.Editor, + onEvent: (InternalRemoteConfigEvent) -> Unit +) { + val item = editor.config + + Dialogs.BaseAlertDialog( + onDismissRequest = { onEvent(InternalRemoteConfigEvent.EditorDismissed) }, + modifier = Modifier, + title = { + Text( + text = item.key, + style = MaterialTheme.typography.titleMedium + ) + }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + SelectionContainer { + Column { + DetailLine(name = "Current", value = item.effectiveValue) + DetailLine(name = "Service", value = item.remoteValue ?: "none") + DetailLine(name = "Default", value = item.defaultValue) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (item.isBoolean) { + Rows.RadioRow( + selected = editor.value.toBoolean(), + text = "true", + modifier = Modifier + .fillMaxWidth() + .clickable { onEvent(InternalRemoteConfigEvent.EditorValueChanged("true")) } + ) + Rows.RadioRow( + selected = !editor.value.toBoolean(), + text = "false", + modifier = Modifier + .fillMaxWidth() + .clickable { onEvent(InternalRemoteConfigEvent.EditorValueChanged("false")) } + ) + } else { + TextField( + value = editor.value, + onValueChange = { onEvent(InternalRemoteConfigEvent.EditorValueChanged(it)) }, + label = { Text("Override value") }, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "This is the raw value the service would have sent. The flag's own transformer is still applied on top of it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (!item.hotSwappable) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "This flag isn't hot-swappable, so you'll be offered a restart after changing it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + confirmButton = { + TextButton(onClick = { onEvent(InternalRemoteConfigEvent.EditorSaveClicked) }) { + Text(text = "Save") + } + }, + dismissButton = { + Row { + if (item.isOverridden) { + TextButton(onClick = { onEvent(InternalRemoteConfigEvent.EditorClearClicked) }) { + Text(text = "Clear") + } + } + TextButton(onClick = { onEvent(InternalRemoteConfigEvent.EditorDismissed) }) { + Text(text = "Cancel") + } + } + } + ) +} + +@Composable +private fun DetailLine(name: String, value: String) { + Text( + text = "$name: $value", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +@DayNightPreviews +@Composable +private fun InternalRemoteConfigScreenPreview() { + Previews.Preview { + InternalRemoteConfigScreen( + state = InternalRemoteConfigState( + loaded = true, + overrideCount = 1, + configs = listOf( + RemoteConfigListItem( + key = "android.contactSharingV2", + effectiveValue = "true", + defaultValue = "false", + rawDefaultValue = "false", + remoteValue = "false", + overrideValue = "true", + isBoolean = true, + hotSwappable = true, + active = true + ), + RemoteConfigListItem( + key = "global.groupsv2.maxGroupSize", + effectiveValue = "151", + defaultValue = "151", + rawDefaultValue = "", + remoteValue = null, + overrideValue = null, + isBoolean = false, + hotSwappable = true, + active = true + ) + ) + ), + onEvent = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigState.kt new file mode 100644 index 0000000000..420ad5c388 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigState.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.internal.remoteconfig + +/** State for the remote config override screen. */ +data class InternalRemoteConfigState( + /** Whether the configs have been read at least once, so an empty list means the filter matched nothing. */ + val loaded: Boolean = false, + + /** Filtered by [filter] and ordered for display: overridden flags first, then alphabetical. */ + val configs: List = emptyList(), + val filter: String = "", + val overrideCount: Int = 0, + val editor: Editor? = null, + val showClearAllDialog: Boolean = false, + + /** Keys just changed that aren't hot-swappable, so a restart is needed for them to fully apply. */ + val restartPromptKeys: List = emptyList() +) { + val showEmptyState: Boolean + get() = loaded && configs.isEmpty() + + val showRestartDialog: Boolean + get() = restartPromptKeys.isNotEmpty() + + /** The open editor and the value being edited in it, before it's saved. */ + data class Editor( + val config: RemoteConfigListItem, + val value: String + ) + + /** The list is every config in the app, so it's summarized rather than dumped into the log. */ + override fun toString(): String { + return "InternalRemoteConfigState(loaded=$loaded, configs=${configs.size}, filterLength=${filter.length}, overrideCount=$overrideCount, editor=${editor?.config?.key}, showClearAllDialog=$showClearAllDialog, restartPromptKeys=$restartPromptKeys)" + } +} + +/** A single remote config flag, as rendered in the list. */ +data class RemoteConfigListItem( + /** The key used to identify the config on the service. */ + val key: String, + + /** What the app currently reads, taking any override into account. */ + val effectiveValue: String, + + /** What the app would read if the service sent us nothing. */ + val defaultValue: String, + + /** The raw value that produces [defaultValue], since [defaultValue] has already been transformed. */ + val rawDefaultValue: String, + + /** The raw value from the service, if any. */ + val remoteValue: String?, + + /** The raw override in effect, if any. */ + val overrideValue: String?, + + /** Booleans get a simpler editor than a text field. */ + val isBoolean: Boolean, + + val hotSwappable: Boolean, + val active: Boolean +) { + val isOverridden: Boolean = overrideValue != null + + /** What the editor starts with: the override if there is one, otherwise the service's value. */ + val initialEditorValue: String = overrideValue ?: remoteValue ?: rawDefaultValue +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigViewModel.kt new file mode 100644 index 0000000000..ab07e4618e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/remoteconfig/InternalRemoteConfigViewModel.kt @@ -0,0 +1,205 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.internal.remoteconfig + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import org.signal.core.ui.compose.EventDrivenViewModel +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.RemoteConfig + +/** Lets internal users override any [RemoteConfig] value. */ +class InternalRemoteConfigViewModel : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(InternalRemoteConfigViewModel::class) + + /** Overridden flags first, alphabetical within each group. */ + private val DISPLAY_ORDER = compareByDescending { it.isOverridden }.thenBy { it.key } + } + + private val _state = MutableStateFlow(InternalRemoteConfigState()) + val state: StateFlow = _state.asStateFlow() + + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _actions.receiveAsFlow() + + /** Every config, unfiltered. The state holds this narrowed by the filter. */ + private var allConfigs: List = emptyList() + + init { + _state + .onEach { Log.d(TAG, "[State] $it") } + .launchIn(viewModelScope) + + onEvent(InternalRemoteConfigEvent.Initialize) + } + + override suspend fun processEvent(event: InternalRemoteConfigEvent) { + when (event) { + InternalRemoteConfigEvent.Initialize -> { + reload() + } + + InternalRemoteConfigEvent.BackClicked -> { + _actions.send(InternalRemoteConfigAction.Exit) + } + + is InternalRemoteConfigEvent.FilterChanged -> { + _state.update { it.copy(filter = event.filter).withVisibleConfigs() } + } + + is InternalRemoteConfigEvent.ConfigClicked -> { + val config = allConfigs.firstOrNull { it.key == event.key } + + if (config == null) { + Log.w(TAG, "Tried to edit a config we don't know about! (key: ${event.key})") + return + } + + _state.update { it.copy(editor = InternalRemoteConfigState.Editor(config = config, value = config.initialEditorValue)) } + } + + is InternalRemoteConfigEvent.EditorValueChanged -> { + _state.update { it.copy(editor = it.editor?.copy(value = event.value)) } + } + + InternalRemoteConfigEvent.EditorSaveClicked -> { + val editor = _state.value.editor ?: return + val changed = editor.value != editor.config.overrideValue + + applyOverrides(RemoteConfig.overrides + (editor.config.key to editor.value)) + + _state.update { it.copy(editor = null) } + reload() + + if (changed) { + promptRestartFor(listOf(editor.config)) + } + } + + InternalRemoteConfigEvent.EditorClearClicked -> { + val editor = _state.value.editor ?: return + + applyOverrides(RemoteConfig.overrides - editor.config.key) + + _state.update { it.copy(editor = null) } + reload() + + promptRestartFor(listOf(editor.config)) + } + + InternalRemoteConfigEvent.EditorDismissed -> { + _state.update { it.copy(editor = null) } + } + + InternalRemoteConfigEvent.ClearAllClicked -> { + _state.update { it.copy(showClearAllDialog = true) } + } + + InternalRemoteConfigEvent.ClearAllConfirmed -> { + val cleared = allConfigs.filter { it.isOverridden } + + applyOverrides(emptyMap()) + + _state.update { it.copy(showClearAllDialog = false) } + reload() + + promptRestartFor(cleared) + } + + InternalRemoteConfigEvent.ClearAllDismissed -> { + _state.update { it.copy(showClearAllDialog = false) } + } + + InternalRemoteConfigEvent.RestartConfirmed -> { + _state.update { it.copy(restartPromptKeys = emptyList()) } + _actions.send(InternalRemoteConfigAction.RestartApp) + } + + InternalRemoteConfigEvent.RestartDismissed -> { + _state.update { it.copy(restartPromptKeys = emptyList()) } + } + } + } + + /** Non-hot-swappable flags are only meant to change between sessions, so anything that already read one still holds the old value. */ + private fun promptRestartFor(changed: List) { + val needsRestart = changed.filterNot { it.hotSwappable }.map { it.key } + + if (needsRestart.isNotEmpty()) { + _state.update { it.copy(restartPromptKeys = needsRestart) } + } + } + + /** Persists what [RemoteConfig] accepted rather than what was asked for, so disk and memory can't drift. */ + private suspend fun applyOverrides(updated: Map) { + withContext(Dispatchers.Default) { + RemoteConfig.overrides = updated + SignalStore.internal.remoteConfigOverrides = RemoteConfig.overrides + } + } + + private suspend fun reload() { + allConfigs = withContext(Dispatchers.Default) { readConfigs() } + _state.update { it.withVisibleConfigs() } + } + + /** Reads every config through its own transformer, so what's rendered is what the app reads. */ + private fun readConfigs(): List { + val remoteValues = RemoteConfig.memoryValues + val overrides = RemoteConfig.overrides + + return RemoteConfig.overridableConfigs.map { (key, config) -> + val default = config.resolveDefault() + + RemoteConfigListItem( + key = key, + effectiveValue = config.resolve().toDisplayString(), + defaultValue = default.toDisplayString(), + rawDefaultValue = if (default is Boolean) config.rawBooleanFor(default) else "", + remoteValue = remoteValues[key]?.toString(), + overrideValue = overrides[key], + isBoolean = default is Boolean, + hotSwappable = config.hotSwappable, + active = config.active + ) + } + } + + /** Inverted flags like kill switches turn "false" into true, so the raw value has to be found through the transformer. */ + private fun RemoteConfig.Config<*>.rawBooleanFor(value: Boolean): String { + return if (transformer("true") == value) "true" else "false" + } + + private fun Any?.toDisplayString(): String = this?.toString() ?: "null" + + private fun InternalRemoteConfigState.withVisibleConfigs(): InternalRemoteConfigState { + val query = filter.trim() + val visible = if (query.isEmpty()) { + allConfigs + } else { + allConfigs.filter { it.key.contains(query, ignoreCase = true) || it.effectiveValue.contains(query, ignoreCase = true) } + } + + return copy( + loaded = true, + configs = visible.sortedWith(DISPLAY_ORDER), + overrideCount = allConfigs.count { it.isOverridden } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt index c2f8beb334..a61332f0ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt @@ -5,6 +5,7 @@ import org.signal.ringrtc.CallManager.DataMode import org.thoughtcrime.securesms.BuildConfig import org.thoughtcrime.securesms.database.model.IssuePriority import org.thoughtcrime.securesms.keyvalue.protos.IssueNotifyTimes +import org.thoughtcrime.securesms.keyvalue.protos.RemoteConfigOverrides import org.thoughtcrime.securesms.util.Environment.Calling.defaultSfuUrl import org.thoughtcrime.securesms.util.RemoteConfig @@ -45,6 +46,7 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal const val ANR_DETECTION_CRASH: String = "internal.anr_detection_crash" const val ISSUE_NOTIFICATION_PRIORITY: String = "internal.issue_notification_priority" const val ISSUE_NOTIFY_TIMES: String = "internal.issue_notify_times" + const val REMOTE_CONFIG_OVERRIDES: String = "internal.remote_config_overrides" } public override fun onFirstEverAppLaunch() = Unit @@ -230,6 +232,18 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal /** Enable ANR detector forcing a crash. */ var anrDetectionCrashes by booleanValue(ANR_DETECTION_CRASH, true).falseForExternalUsers() + private var remoteConfigOverridesProto: RemoteConfigOverrides by protoValue(REMOTE_CONFIG_OVERRIDES, RemoteConfigOverrides(), RemoteConfigOverrides.ADAPTER) + + /** + * Locally-overridden [RemoteConfig] values, keyed by config key. This isn't gated on the + * internal user flag, because it's read while remote config is initializing. + */ + var remoteConfigOverrides: Map + get() = remoteConfigOverridesProto.valueByKey + set(value) { + remoteConfigOverridesProto = RemoteConfigOverrides(valueByKey = value) + } + private fun SignalStoreValueDelegate.defaultForExternalUsers(): SignalStoreValueDelegate { return this.withPrecondition { RemoteConfig.internalUser } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionRemoteConfig.java b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionRemoteConfig.java index 804a09df46..843b351230 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionRemoteConfig.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionRemoteConfig.java @@ -19,6 +19,7 @@ public class LogSectionRemoteConfig implements LogSection { @Override public @NonNull CharSequence getContent(@NonNull Context context) { StringBuilder out = new StringBuilder(); + Map overrides = RemoteConfig.getOverrides(); Map memory = RemoteConfig.getMemoryValues(); Map disk = RemoteConfig.getDebugDiskValues(); Map pending = RemoteConfig.getDebugPendingDiskValues(); @@ -26,6 +27,16 @@ public class LogSectionRemoteConfig implements LogSection { int diskLength = disk.keySet().stream().map(String::length).max(Integer::compareTo).orElse(0); int pendingLength = pending.keySet().stream().map(String::length).max(Integer::compareTo).orElse(0); + if (!overrides.isEmpty()) { + int overrideLength = overrides.keySet().stream().map(String::length).max(Integer::compareTo).orElse(0); + + out.append("-- Internal Overrides (these take precedence over everything below!)\n"); + for (Map.Entry entry : overrides.entrySet()) { + out.append(Util.rightPad(entry.getKey(), overrideLength)).append(": ").append(entry.getValue()).append("\n"); + } + out.append("\n"); + } + out.append("-- Memory\n"); for (Map.Entry entry : memory.entrySet()) { out.append(Util.rightPad(entry.getKey(), remoteLength)).append(": ").append(entry.getValue()).append("\n"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index 313e682b59..ab1def357e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -53,6 +53,8 @@ import kotlin.time.toDuration object RemoteConfig { private val TAG = Log.tag(RemoteConfig::class.java) + private const val INTERNAL_USER_KEY: String = "android.internalUser" + // region Core behavior private val FETCH_INTERVAL = 2.hours @@ -60,6 +62,10 @@ object RemoteConfig { @VisibleForTesting val REMOTE_VALUES: MutableMap = TreeMap() + /** Internal-user-only values that sit in front of [REMOTE_VALUES]. */ + @Volatile + private var overrideValues: Map = emptyMap() + @VisibleForTesting val configsByKey: MutableMap> = mutableMapOf() @@ -100,6 +106,7 @@ object RemoteConfig { SignalStore.remoteConfig.currentConfig = mapToJson(pending) REMOTE_VALUES.putAll(pending) + loadOverrides() triggerFlagChangeListeners(changes) Log.i(TAG, "init() $REMOTE_VALUES") @@ -347,6 +354,71 @@ object RemoteConfig { // endregion + // region Internal overrides + + /** The raw value a config reads from, preferring an internal override over what the service sent us. */ + private fun effectiveRawValue(key: String): Any? { + val overrides = overrideValues + return if (overrides.isEmpty()) REMOTE_VALUES[key] else overrides[key] ?: REMOTE_VALUES[key] + } + + /** + * Overriding [INTERNAL_USER_KEY] would hide internal settings, and with it the only way to clear the override, so + * it's left out. Note [internalUserDisabled] already offers that experiment in a form that a restart undoes. + */ + private fun isOverridable(key: String): Boolean = configsByKey.containsKey(key) && key != INTERNAL_USER_KEY + + /** Reads any persisted overrides into memory. Only internal users can have them. */ + private fun loadOverrides() { + overrideValues = emptyMap() + + // Read through the config rather than the delegate, which would re-enter the init() we're in the middle of. + if (configsByKey[INTERNAL_USER_KEY]?.resolve() != true) { + return + } + + overrideValues = SignalStore.internal.remoteConfigOverrides.filterKeys { isOverridable(it) } + + if (overrideValues.isNotEmpty()) { + Log.w(TAG, "[Override] Reading with ${overrideValues.size} local override(s)! $overrideValues") + } + } + + /** Every config that internal settings is allowed to override, keyed by the key used to identify it on the service. */ + @get:Synchronized + val overridableConfigs: Map> + get() = TreeMap(configsByKey.filterKeys { isOverridable(it) }) + + /** The raw values that sit in front of the ones from the service, keyed by config key. */ + @JvmStatic + var overrides: Map + @Synchronized + get() = TreeMap(overrideValues) + + @Synchronized + set(value) { + val previous = overrideValues + val updated: Map = TreeMap(value.filterKeys { isOverridable(it) }) + + overrideValues = updated + + Log.w(TAG, "[Override] Before: $previous") + Log.w(TAG, "[Override] After : $updated") + + val changes = (previous.keys + updated.keys) + .filter { previous[it] != updated[it] } + .associateWith { key -> + ConfigChange( + oldValue = previous[key] ?: REMOTE_VALUES[key], + newValue = updated[key] ?: REMOTE_VALUES[key] + ) + } + + triggerFlagChangeListeners(changes) + } + + // endregion + // region Conversion utilities private fun Any?.asBoolean(defaultValue: Boolean): Boolean { return when (this) { @@ -410,6 +482,7 @@ object RemoteConfig { /** * If this is false, the remote value of the flag will be ignored, and we'll only ever use the default value. + * An internal override still applies, so that inactive flags remain testable. */ val active: Boolean, @@ -438,8 +511,14 @@ object RemoteConfig { } } - return transformer(REMOTE_VALUES[key]) + return transformer(effectiveRawValue(key)) } + + /** What this config resolves to right now, for readers that don't have a property to delegate to. */ + internal fun resolve(): T = transformer(effectiveRawValue(key)) + + /** What this config would resolve to if the service hadn't sent us anything. */ + internal fun resolveDefault(): T = transformer(null) } private fun remoteBoolean( @@ -658,7 +737,7 @@ object RemoteConfig { @JvmStatic @get:JvmName("internalUser") val internalUser: Boolean by remoteValue( - key = "android.internalUser", + key = INTERNAL_USER_KEY, hotSwappable = true ) { value -> when { diff --git a/app/src/main/protowire/KeyValue.proto b/app/src/main/protowire/KeyValue.proto index b5f1dbfa59..4398aa7583 100644 --- a/app/src/main/protowire/KeyValue.proto +++ b/app/src/main/protowire/KeyValue.proto @@ -116,3 +116,7 @@ message StorageSyncLoopState { uint32 rateLevel = 4; uint64 rateLevelAsOf = 5; } + +message RemoteConfigOverrides { + map valueByKey = 1; +} diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index 9932a5df6f..f8eea01d2b 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -1025,6 +1025,9 @@ + + + diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/RemoteConfigOverrideTest.kt b/app/src/test/java/org/thoughtcrime/securesms/util/RemoteConfigOverrideTest.kt new file mode 100644 index 0000000000..8e709b29f4 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/RemoteConfigOverrideTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.util + +import android.app.Application +import assertk.assertThat +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import org.json.JSONObject +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.testutil.LogRecorder +import org.thoughtcrime.securesms.testutil.SignalStoreRule + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, manifest = Config.NONE) +class RemoteConfigOverrideTest { + + companion object { + private const val INTERNAL_USER = "android.internalUser" + private const val BOOLEAN_FLAG = "android.contactSharingV2" + private const val INT_FLAG = "global.pinnedChatLimit" + } + + @get:Rule + val signalStore = SignalStoreRule() + + @Before + fun setup() { + Log.initialize(LogRecorder()) + RemoteConfig.underTest = true + reset() + } + + @After + fun tearDown() { + reset() + RemoteConfig.underTest = false + } + + @Test + fun `override wins over the value from the service`() { + setServiceValues(INTERNAL_USER to true, BOOLEAN_FLAG to false) + SignalStore.internal.remoteConfigOverrides = mapOf(BOOLEAN_FLAG to "true") + + RemoteConfig.init() + + assertThat(RemoteConfig.contactSharingV2).isTrue() + } + + @Test + fun `override string is coerced by the config transformer`() { + setServiceValues(INTERNAL_USER to true, INT_FLAG to 4) + SignalStore.internal.remoteConfigOverrides = mapOf(INT_FLAG to "9") + + RemoteConfig.init() + + assertThat(RemoteConfig.pinnedChatLimit).isEqualTo(9) + } + + @Test + fun `overrides are ignored for non-internal users`() { + setServiceValues(INTERNAL_USER to false, BOOLEAN_FLAG to false) + SignalStore.internal.remoteConfigOverrides = mapOf(BOOLEAN_FLAG to "true") + + RemoteConfig.init() + + assertThat(RemoteConfig.overrides).isEmpty() + assertThat(RemoteConfig.contactSharingV2).isFalse() + } + + @Test + fun `internalUser cannot be overridden`() { + setServiceValues(INTERNAL_USER to true) + SignalStore.internal.remoteConfigOverrides = mapOf(INTERNAL_USER to "false") + + RemoteConfig.init() + + assertThat(RemoteConfig.overrides).isEmpty() + assertThat(RemoteConfig.internalUser).isTrue() + } + + @Test + fun `overrides for unknown keys are dropped`() { + setServiceValues(INTERNAL_USER to true) + SignalStore.internal.remoteConfigOverrides = mapOf("android.notARealFlag" to "true") + + RemoteConfig.init() + + assertThat(RemoteConfig.overrides).isEmpty() + } + + @Test + fun `setting overrides applies immediately`() { + setServiceValues(INTERNAL_USER to true, BOOLEAN_FLAG to false) + RemoteConfig.init() + + RemoteConfig.overrides = mapOf(BOOLEAN_FLAG to "true") + assertThat(RemoteConfig.contactSharingV2).isTrue() + + RemoteConfig.overrides = emptyMap() + assertThat(RemoteConfig.contactSharingV2).isFalse() + } + + @Test + fun `overridableConfigs excludes internalUser`() { + assertThat(RemoteConfig.overridableConfigs.containsKey(INTERNAL_USER)).isFalse() + assertThat(RemoteConfig.overridableConfigs.containsKey(BOOLEAN_FLAG)).isTrue() + } + + @Test + fun `persisted overrides round-trip through the store`() { + val overrides = mapOf(BOOLEAN_FLAG to "true", INT_FLAG to "9") + + SignalStore.internal.remoteConfigOverrides = overrides + + assertThat(SignalStore.internal.remoteConfigOverrides).isEqualTo(overrides) + } + + private fun setServiceValues(vararg values: Pair) { + val json = JSONObject() + values.forEach { (key, value) -> json.put(key, value) } + SignalStore.remoteConfig.currentConfig = json.toString() + } + + private fun reset() { + RemoteConfig.overrides = emptyMap() + RemoteConfig.REMOTE_VALUES.clear() + RemoteConfig.initialized = false + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/RemoteConfig_StaticValuesTest.kt b/app/src/test/java/org/thoughtcrime/securesms/util/RemoteConfig_StaticValuesTest.kt index 617a25d5bd..5a9228bcec 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/util/RemoteConfig_StaticValuesTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/util/RemoteConfig_StaticValuesTest.kt @@ -57,6 +57,8 @@ class RemoteConfig_StaticValuesTest { "DEVICE_SPECIFIC_NOTIFICATION_CONFIG", "PROMPT_BATTERY_SAVER", "PROMPT_FOR_NOTIFICATION_LOGS", + "overridableConfigs", + "overrides", "REMOTE_VALUES" )