mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-28 07:51:42 +01:00
Add internal remote config override screen.
This commit is contained in:
committed by
Michelle Tang
parent
21fba1b4b7
commit
cd151d083c
+8
@@ -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."),
|
||||
|
||||
+11
@@ -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
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
+38
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+317
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+71
@@ -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
|
||||
}
|
||||
+205
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user