Add a new internal-only 'labs' setting screen.

This commit is contained in:
Greyson Parrelli
2026-03-17 10:45:49 -04:00
committed by Michelle Tang
parent a37680685f
commit 95149764eb
12 changed files with 241 additions and 8 deletions

View File

@@ -109,6 +109,7 @@ class AppSettingsFragment : ComposeFragment(), Callbacks {
is AppSettingsRoute.Payments -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_paymentsActivity)
is AppSettingsRoute.HelpRoute.Settings -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_helpSettingsFragment)
is AppSettingsRoute.Invite -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_inviteFragment)
is AppSettingsRoute.LabsRoute.Labs -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_labsSettingsFragment)
is AppSettingsRoute.InternalRoute.Internal -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_internalSettingsFragment)
is AppSettingsRoute.AccountRoute.ManageProfile -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
is AppSettingsRoute.UsernameLinkRoute.UsernameLink -> findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
@@ -531,9 +532,20 @@ private fun AppSettingsContent(
Dividers.Default()
}
item {
Rows.TextRow(
text = "Labs",
icon = painterResource(R.drawable.symbol_flash_24),
onClick = {
callbacks.navigate(AppSettingsRoute.LabsRoute.Labs)
}
)
}
item {
Rows.TextRow(
text = stringResource(R.string.preferences__internal_preferences),
icon = painterResource(R.drawable.symbol_key_24),
onClick = {
callbacks.navigate(AppSettingsRoute.InternalRoute.Internal)
}

View File

@@ -0,0 +1,11 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.labs
sealed interface LabsSettingsEvents {
data class ToggleIndividualChatPlaintextExport(val enabled: Boolean) : LabsSettingsEvents
data class ToggleStoryArchive(val enabled: Boolean) : LabsSettingsEvents
}

View File

@@ -0,0 +1,111 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.labs
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
class LabsSettingsFragment : ComposeFragment() {
private val viewModel: LabsSettingsViewModel by viewModels()
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsState()
LabsSettingsContent(
state = state,
onEvent = viewModel::onEvent,
onNavigationClick = { findNavController().popBackStack() }
)
}
}
@Composable
private fun LabsSettingsContent(
state: LabsSettingsState,
onEvent: (LabsSettingsEvents) -> Unit,
onNavigationClick: () -> Unit
) {
Scaffolds.Settings(
title = "Labs",
navigationContentDescription = "Go back",
navigationIcon = SignalIcons.ArrowStart.imageVector,
onNavigationClick = onNavigationClick
) { contentPadding ->
LazyColumn(
contentPadding = contentPadding
) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
.padding(vertical = 16.dp)
.background(
color = SignalTheme.colors.colorSurface2,
shape = RoundedCornerShape(12.dp)
)
.padding(16.dp)
) {
Icon(
painter = painterResource(R.drawable.symbol_info_fill_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Text(
text = "These are internal-only pre-release features. They are all in various unfinished states. They may need more polish, finalized designs, or cross-client compatibility before they can be released.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp)
)
}
}
item {
Rows.ToggleRow(
checked = state.individualChatPlaintextExport,
text = "Individual Chat Plaintext Export",
label = "Enable exporting individual chats as a collection of human-readable plaintext files. New entry in the three-dot menu in the chat screen.",
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleIndividualChatPlaintextExport(it)) }
)
}
item {
Rows.ToggleRow(
checked = state.storyArchive,
text = "Story Archive",
label = "Keep your own stories for longer and view them later. A new button on the toolbar on the stories tab.",
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleStoryArchive(it)) }
)
}
}
}
}

View File

@@ -0,0 +1,14 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.labs
import androidx.compose.runtime.Immutable
@Immutable
data class LabsSettingsState(
val individualChatPlaintextExport: Boolean = false,
val storyArchive: Boolean = false
)

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.labs
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.thoughtcrime.securesms.keyvalue.SignalStore
class LabsSettingsViewModel : ViewModel() {
private val _state = MutableStateFlow(loadState())
val state: StateFlow<LabsSettingsState> = _state
fun onEvent(event: LabsSettingsEvents) {
when (event) {
is LabsSettingsEvents.ToggleIndividualChatPlaintextExport -> {
SignalStore.labs.individualChatPlaintextExport = event.enabled
_state.value = _state.value.copy(individualChatPlaintextExport = event.enabled)
}
is LabsSettingsEvents.ToggleStoryArchive -> {
SignalStore.labs.storyArchive = event.enabled
_state.value = _state.value.copy(storyArchive = event.enabled)
}
}
}
private fun loadState(): LabsSettingsState {
return LabsSettingsState(
individualChatPlaintextExport = SignalStore.labs.individualChatPlaintextExport,
storyArchive = SignalStore.labs.storyArchive
)
}
}

View File

@@ -114,6 +114,11 @@ sealed interface AppSettingsRoute : Parcelable {
data object Featured : DonationsRoute
}
@Parcelize
sealed interface LabsRoute : AppSettingsRoute {
data object Labs : LabsRoute
}
@Parcelize
sealed interface InternalRoute : AppSettingsRoute {
data object Internal : InternalRoute

View File

@@ -16,9 +16,9 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* Delegate object for managing the conversation options menu
@@ -164,8 +164,8 @@ internal object ConversationOptionsMenu {
hideMenuItem(menu, R.id.menu_add_shortcut)
}
if (RemoteConfig.internalUser) {
menu.findItem(R.id.menu_export)?.title = menu.findItem(R.id.menu_export)?.title.toString() + " (Internal Only)"
if (SignalStore.labs.individualChatPlaintextExport) {
menu.findItem(R.id.menu_export)?.title = menu.findItem(R.id.menu_export)?.title.toString() + " (Labs)"
} else {
hideMenuItem(menu, R.id.menu_export)
}

View File

@@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.keyvalue
import org.thoughtcrime.securesms.util.RemoteConfig
class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
companion object {
const val INDIVIDUAL_CHAT_PLAINTEXT_EXPORT: String = "labs.individual_chat_plaintext_export"
const val STORY_ARCHIVE: String = "labs.story_archive"
}
public override fun onFirstEverAppLaunch() = Unit
public override fun getKeysToIncludeInBackup(): List<String> = emptyList()
var individualChatPlaintextExport by booleanValue(INDIVIDUAL_CHAT_PLAINTEXT_EXPORT, true).falseForExternalUsers()
var storyArchive by booleanValue(STORY_ARCHIVE, true).falseForExternalUsers()
private fun SignalStoreValueDelegate<Boolean>.falseForExternalUsers(): SignalStoreValueDelegate<Boolean> {
return this.map { actualValue -> RemoteConfig.internalUser && actualValue }
}
}

View File

@@ -38,6 +38,7 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
val apkUpdateValues = ApkUpdateValues(store)
val backupValues = BackupValues(store)
val callQualityValues = CallQualityValues(store)
val labsValues = LabsValues(store)
val plainTextValues = PlainTextSharedPrefsDataStore(context)
@@ -86,6 +87,7 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
apkUpdate.onFirstEverAppLaunch()
backup.onFirstEverAppLaunch()
callQuality.onFirstEverAppLaunch()
labs.onFirstEverAppLaunch()
}
@JvmStatic
@@ -118,7 +120,8 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
story.keysToIncludeInBackup +
apkUpdate.keysToIncludeInBackup +
backup.keysToIncludeInBackup +
callQuality.keysToIncludeInBackup
callQuality.keysToIncludeInBackup +
labs.keysToIncludeInBackup
}
/**
@@ -274,6 +277,11 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
val callQuality: CallQualityValues
get() = instance!!.callQualityValues
@JvmStatic
@get:JvmName("labs")
val labs: LabsValues
get() = instance!!.labsValues
val groupsV2AciAuthorizationCache: GroupsV2AuthorizationSignalStoreCache
get() = GroupsV2AuthorizationSignalStoreCache.createAciCache(instance!!.store)

View File

@@ -81,9 +81,9 @@ import org.thoughtcrime.securesms.calls.log.CallLogFilter
import org.thoughtcrime.securesms.components.compose.ActionModeTopBar
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageSmall
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.rememberRecipientField
import org.thoughtcrime.securesms.util.RemoteConfig
interface MainToolbarCallback {
fun onNewGroupClick()
@@ -408,7 +408,7 @@ private fun PrimaryToolbar(
NotificationProfileAction(state, callback)
ProxyAction(state, callback)
if (state.destination == MainNavigationListLocation.STORIES && RemoteConfig.internalUser) {
if (state.destination == MainNavigationListLocation.STORIES && SignalStore.labs.storyArchive) {
IconButtons.IconButton(
onClick = callback::onStoryArchiveClick
) {

View File

@@ -27,7 +27,6 @@ import org.thoughtcrime.securesms.stories.archive.StoryArchiveDuration
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryFlowDialogFragment
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryWithViewersFragment
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -170,7 +169,7 @@ class StoriesPrivacySettingsFragment :
}
)
if (RemoteConfig.internalUser) {
if (SignalStore.labs.storyArchive) {
dividerPref()
sectionHeaderPref(R.string.StoryArchive__archive)

View File

@@ -134,6 +134,13 @@
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_labsSettingsFragment"
app:destination="@id/labsSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_manageDonationsFragment"
app:destination="@id/manageDonationsFragment"
@@ -823,6 +830,13 @@
<!-- endregion -->
<!-- Labs Settings -->
<fragment
android:id="@+id/labsSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.labs.LabsSettingsFragment"
android:label="labs_settings_fragment" />
<!-- Internal Settings -->
<fragment