From 95149764ebac846657bf88df24d6a07c42bcf335 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 17 Mar 2026 10:45:49 -0400 Subject: [PATCH] Add a new internal-only 'labs' setting screen. --- .../settings/app/AppSettingsFragment.kt | 12 ++ .../settings/app/labs/LabsSettingsEvents.kt | 11 ++ .../settings/app/labs/LabsSettingsFragment.kt | 111 ++++++++++++++++++ .../settings/app/labs/LabsSettingsState.kt | 14 +++ .../app/labs/LabsSettingsViewModel.kt | 37 ++++++ .../settings/app/routes/AppSettingsRoute.kt | 5 + .../conversation/ConversationOptionsMenu.kt | 6 +- .../securesms/keyvalue/LabsValues.kt | 22 ++++ .../securesms/keyvalue/SignalStore.kt | 10 +- .../securesms/main/MainToolbar.kt | 4 +- .../story/StoriesPrivacySettingsFragment.kt | 3 +- .../app_settings_with_change_number.xml | 14 +++ 12 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index f560423bde..da36ad10b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt new file mode 100644 index 0000000000..ef8e2e1bdd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt new file mode 100644 index 0000000000..da0d423f41 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt @@ -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)) } + ) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt new file mode 100644 index 0000000000..e7b7c4a72f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt new file mode 100644 index 0000000000..6380264dad --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt @@ -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 = _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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/routes/AppSettingsRoute.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/routes/AppSettingsRoute.kt index 846e12ea11..7b64a1ea18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/routes/AppSettingsRoute.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/routes/AppSettingsRoute.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt index 6fae813899..92d378a620 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt new file mode 100644 index 0000000000..fafd0cfdca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt @@ -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 = emptyList() + + var individualChatPlaintextExport by booleanValue(INDIVIDUAL_CHAT_PLAINTEXT_EXPORT, true).falseForExternalUsers() + + var storyArchive by booleanValue(STORY_ARCHIVE, true).falseForExternalUsers() + + private fun SignalStoreValueDelegate.falseForExternalUsers(): SignalStoreValueDelegate { + return this.map { actualValue -> RemoteConfig.internalUser && actualValue } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt index cb90265e18..650ad7dc30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt index 4afd1eaf11..a78a565093 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt @@ -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 ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt index c4dc81a191..713683668d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt @@ -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) 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 597492e399..8d2713d4df 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 @@ -134,6 +134,13 @@ app:exitAnim="@anim/fragment_open_exit" app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + + + + +