Add internal remote config override screen.

This commit is contained in:
Cody Henthorne
2026-09-23 16:12:23 -04:00
committed by Michelle Tang
parent 21fba1b4b7
commit cd151d083c
14 changed files with 939 additions and 2 deletions
@@ -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."),
@@ -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
}
@@ -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
}
@@ -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())
}
}
}
@@ -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 = {}
)
}
}
@@ -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<RemoteConfigListItem> = 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<String> = 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
}
@@ -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<InternalRemoteConfigEvent>(TAG) {
companion object {
private val TAG = Log.tag(InternalRemoteConfigViewModel::class)
/** Overridden flags first, alphabetical within each group. */
private val DISPLAY_ORDER = compareByDescending<RemoteConfigListItem> { it.isOverridden }.thenBy { it.key }
}
private val _state = MutableStateFlow(InternalRemoteConfigState())
val state: StateFlow<InternalRemoteConfigState> = _state.asStateFlow()
private val _actions = Channel<InternalRemoteConfigAction>(Channel.BUFFERED)
val actions: Flow<InternalRemoteConfigAction> = _actions.receiveAsFlow()
/** Every config, unfiltered. The state holds this narrowed by the filter. */
private var allConfigs: List<RemoteConfigListItem> = 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<RemoteConfigListItem>) {
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<String, String>) {
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<RemoteConfigListItem> {
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 }
)
}
}
@@ -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<String, String>
get() = remoteConfigOverridesProto.valueByKey
set(value) {
remoteConfigOverridesProto = RemoteConfigOverrides(valueByKey = value)
}
private fun <T> SignalStoreValueDelegate<T>.defaultForExternalUsers(): SignalStoreValueDelegate<T> {
return this.withPrecondition { RemoteConfig.internalUser }
}
@@ -19,6 +19,7 @@ public class LogSectionRemoteConfig implements LogSection {
@Override
public @NonNull CharSequence getContent(@NonNull Context context) {
StringBuilder out = new StringBuilder();
Map<String, String> overrides = RemoteConfig.getOverrides();
Map<String, Object> memory = RemoteConfig.getMemoryValues();
Map<String, Object> disk = RemoteConfig.getDebugDiskValues();
Map<String, Object> 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<String, String> 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<String, Object> entry : memory.entrySet()) {
out.append(Util.rightPad(entry.getKey(), remoteLength)).append(": ").append(entry.getValue()).append("\n");
@@ -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<String, Any> = TreeMap()
/** Internal-user-only values that sit in front of [REMOTE_VALUES]. */
@Volatile
private var overrideValues: Map<String, String> = emptyMap()
@VisibleForTesting
val configsByKey: MutableMap<String, Config<*>> = 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<String, Config<*>>
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<String, String>
@Synchronized
get() = TreeMap(overrideValues)
@Synchronized
set(value) {
val previous = overrideValues
val updated: Map<String, String> = 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 {
+4
View File
@@ -116,3 +116,7 @@ message StorageSyncLoopState {
uint32 rateLevel = 4;
uint64 rateLevelAsOf = 5;
}
message RemoteConfigOverrides {
map<string, string> valueByKey = 1;
}
@@ -1025,6 +1025,9 @@
<action
android:id="@+id/action_internalSettingsFragment_to_internalIssuesFragment"
app:destination="@id/internalIssuesFragment" />
<action
android:id="@+id/action_internalSettingsFragment_to_internalRemoteConfigFragment"
app:destination="@id/internalRemoteConfigFragment" />
</fragment>
<fragment
@@ -1097,6 +1100,11 @@
android:name="org.thoughtcrime.securesms.components.settings.app.internal.issues.InternalIssuesFragment"
android:label="internal_issues_fragment" />
<fragment
android:id="@+id/internalRemoteConfigFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.remoteconfig.InternalRemoteConfigFragment"
android:label="internal_remote_config_fragment" />
<!-- endregion -->
<!-- App updates -->
@@ -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<String, Any>) {
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
}
}
@@ -57,6 +57,8 @@ class RemoteConfig_StaticValuesTest {
"DEVICE_SPECIFIC_NOTIFICATION_CONFIG",
"PROMPT_BATTERY_SAVER",
"PROMPT_FOR_NOTIFICATION_LOGS",
"overridableConfigs",
"overrides",
"REMOTE_VALUES"
)