Add Labs screen for beta testing of public features (#5465)

* Add Labs screen:

- Make `Feature` have an `isInLabs` boolean to distinguish private feature flags from public ones.
- Have `FeatureFlagsService` provide the list of available flags.
- Display the labs item in the settings screen only if there are available public features.
- Remove public feature toggles from developer options.
- Implement the labs screen with the public features.
- Add a clear cache step to the threads feature toggle
- Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa 2025-10-07 12:02:54 +02:00 committed by GitHub
parent a497703a90
commit 9714abe032
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 684 additions and 17 deletions

View file

@ -30,6 +30,7 @@ import io.element.android.features.preferences.impl.advanced.AdvancedSettingsNod
import io.element.android.features.preferences.impl.analytics.AnalyticsSettingsNode
import io.element.android.features.preferences.impl.blockedusers.BlockedUsersNode
import io.element.android.features.preferences.impl.developer.DeveloperSettingsNode
import io.element.android.features.preferences.impl.labs.LabsNode
import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode
import io.element.android.features.preferences.impl.root.PreferencesRootNode
@ -75,6 +76,9 @@ class PreferencesFlowNode(
@Parcelize
data object AdvancedSettings : NavTarget
@Parcelize
data object Labs : NavTarget
@Parcelize
data object AnalyticsSettings : NavTarget
@ -152,6 +156,10 @@ class PreferencesFlowNode(
backstack.push(NavTarget.AdvancedSettings)
}
override fun onOpenLabs() {
backstack.push(NavTarget.Labs)
}
override fun onOpenUserProfile(matrixUser: MatrixUser) {
backstack.push(NavTarget.UserProfile(matrixUser))
}
@ -178,6 +186,9 @@ class PreferencesFlowNode(
}
createNode<DeveloperSettingsNode>(buildContext, listOf(developerSettingsCallback))
}
NavTarget.Labs -> {
createNode<LabsNode>(buildContext)
}
NavTarget.About -> {
val callback = object : AboutNode.Callback {
override fun openOssLicenses() {

View file

@ -90,8 +90,8 @@ class DeveloperSettingsPresenter(
}
LaunchedEffect(Unit) {
FeatureFlags.entries
.filter { it.isFinished.not() }
featureFlagService.getAvailableFeatures()
.filter { it.isInLabs.not() && it.isFinished.not() }
.run {
// Never display room directory search in release builds for Play Store
if (buildMeta.flavorDescription == "GooglePlay" && buildMeta.buildType == BuildType.RELEASE) {
@ -169,6 +169,7 @@ class DeveloperSettingsPresenter(
key = feature.key,
title = feature.title,
description = feature.description,
icon = null,
isEnabled = isEnabled
)
}

View file

@ -0,0 +1,14 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.labs
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
sealed interface LabsEvents {
data class ToggleFeature(val feature: FeatureUiModel) : LabsEvents
}

View file

@ -0,0 +1,32 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.labs
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@AssistedInject
class LabsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: LabsPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
LabsView(state = state, onBack = ::navigateUp)
}
}

View file

@ -0,0 +1,124 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.labs
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap
import dev.zacsweers.metro.Inject
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@Inject
class LabsPresenter(
private val stringProvider: StringProvider,
private val featureFlagService: FeatureFlagService,
private val clearCacheUseCase: ClearCacheUseCase,
) : Presenter<LabsState> {
@Composable
override fun present(): LabsState {
val coroutineScope = rememberCoroutineScope()
val features = remember {
val entries = featureFlagService.getAvailableFeatures()
.filter { it.isInLabs && !it.isFinished }
.map { it.key to it }
mutableStateMapOf(*entries.toTypedArray())
}
val enabledFeatures = remember {
mutableStateMapOf<String, Boolean>()
}
LaunchedEffect(Unit) {
for (feature in features.values) {
val isEnabled = featureFlagService.isFeatureEnabled(feature)
enabledFeatures[feature.key] = isEnabled
}
}
var isApplyingChanges by remember { mutableStateOf(false) }
val featureUiModels = createUiModels(features, enabledFeatures)
fun handleEvent(event: LabsEvents) {
when (event) {
is LabsEvents.ToggleFeature -> coroutineScope.launch {
val feature = features[event.feature.key] ?: return@launch
val isEnabled = featureFlagService.isFeatureEnabled(feature)
featureFlagService.setFeatureEnabled(feature = feature, enabled = !isEnabled)
enabledFeatures[feature.key] = !isEnabled
when (feature.key) {
FeatureFlags.Threads.key -> {
// Threads require a cache clear to recreate the event cache
clearCacheUseCase()
isApplyingChanges = true
}
}
}
}
}
return LabsState(
features = featureUiModels,
isApplyingChanges = isApplyingChanges,
eventSink = ::handleEvent,
)
}
@Composable
private fun createUiModels(
features: SnapshotStateMap<String, Feature>,
enabledFeatures: SnapshotStateMap<String, Boolean>
): ImmutableList<FeatureUiModel> {
return features.values.map { feature ->
key(feature.key) {
val isEnabled = enabledFeatures[feature.key].orFalse()
val title = when (feature) {
FeatureFlags.Threads -> stringProvider.getString(R.string.screen_labs_enable_threads)
else -> feature.title
}
val description = when (feature) {
FeatureFlags.Threads -> stringProvider.getString(R.string.screen_labs_enable_threads_description)
else -> feature.description
}
val icon = when (feature) {
FeatureFlags.Threads -> CompoundIcons.Threads()
else -> null
}
remember(feature, isEnabled) {
FeatureUiModel(
key = feature.key,
title = title,
description = description,
icon = icon?.let(IconSource::Vector),
isEnabled = isEnabled
)
}
}
}.toImmutableList()
}
}

View file

@ -0,0 +1,17 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.labs
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import kotlinx.collections.immutable.ImmutableList
data class LabsState(
val features: ImmutableList<FeatureUiModel>,
val isApplyingChanges: Boolean,
val eventSink: (LabsEvents) -> Unit,
)

View file

@ -0,0 +1,48 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.labs
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.icons.CompoundDrawables
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import kotlinx.collections.immutable.toImmutableList
internal class LabsStateProvider : PreviewParameterProvider<LabsState> {
override val values: Sequence<LabsState>
get() = sequenceOf(
aLabsState(features = aFeatureList()),
aLabsState(features = aFeatureList(), isApplyingChanges = true),
)
}
internal fun aLabsState(
features: List<FeatureUiModel> = emptyList(),
isApplyingChanges: Boolean = false,
) = LabsState(
features = features.toImmutableList(),
isApplyingChanges = isApplyingChanges,
eventSink = {},
)
internal fun aFeatureList() = listOf(
FeatureUiModel(
key = "feature_1",
title = "Feature 1",
description = "This is a description of feature 1.",
isEnabled = true,
icon = IconSource.Resource(CompoundDrawables.ic_compound_threads),
),
FeatureUiModel(
key = "feature_2",
title = "Feature 2",
description = "This is a description of feature 2.",
isEnabled = false,
icon = IconSource.Resource(CompoundDrawables.ic_compound_video_call),
)
)

View file

@ -0,0 +1,107 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.labs
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.list.SwitchListItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.TopAppBar
/**
* The contents of the Labs screen.
* Design: https://www.figma.com/design/V0dkfRAW6T3yCQKjahpzkX/ER-46-EX--Threads?node-id=2004-27319&t=yssy1yYYigsGON3s-0
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LabsView(
state: LabsState,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
if (state.isApplyingChanges) {
ProgressDialog()
}
BackHandler(
enabled = !state.isApplyingChanges,
onBack = onBack,
)
HeaderFooterPage(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding(),
topBar = {
TopAppBar(
titleStr = stringResource(R.string.screen_labs_title),
navigationIcon = {
BackButton(onClick = onBack, enabled = !state.isApplyingChanges)
}
)
},
header = {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 24.dp, start = 24.dp, end = 24.dp),
title = stringResource(R.string.screen_labs_header_title),
subTitle = stringResource(R.string.screen_labs_header_description),
iconStyle = BigIcon.Style.Default(CompoundIcons.Labs())
)
},
contentPadding = PaddingValues(),
content = {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 20.dp),
) {
items(items = state.features, key = { it.key }) { feature ->
SwitchListItem(
leadingContent = feature.icon?.let { ListItemContent.Icon(it) },
headline = feature.title,
supportingText = feature.description,
value = feature.isEnabled,
onChange = {
state.eventSink(LabsEvents.ToggleFeature(feature))
}
)
}
}
}
)
}
@PreviewsDayNight
@Composable
internal fun LabsViewPreview(@PreviewParameter(LabsStateProvider::class) state: LabsState) {
ElementPreview {
LabsView(state = state, onBack = {})
}
}

View file

@ -43,6 +43,7 @@ class PreferencesRootNode(
fun onOpenNotificationSettings()
fun onOpenLockScreenSettings()
fun onOpenAdvancedSettings()
fun onOpenLabs()
fun onOpenUserProfile(matrixUser: MatrixUser)
fun onOpenBlockedUsers()
fun onSignOutClick()
@ -69,6 +70,10 @@ class PreferencesRootNode(
plugins<Callback>().forEach { it.onOpenAdvancedSettings() }
}
private fun onOpenLabs() {
plugins<Callback>().forEach { it.onOpenLabs() }
}
private fun onOpenAnalytics() {
plugins<Callback>().forEach { it.onOpenAnalytics() }
}
@ -131,6 +136,7 @@ class PreferencesRootNode(
onSecureBackupClick = this::onSecureBackupClick,
onOpenDeveloperSettings = this::onOpenDeveloperSettings,
onOpenAdvancedSettings = this::onOpenAdvancedSettings,
onOpenLabs = this::onOpenLabs,
onManageAccountClick = { onManageAccountClick(activity, it, isDark) },
onOpenNotificationSettings = this::onOpenNotificationSettings,
onOpenLockScreenSettings = this::onOpenLockScreenSettings,

View file

@ -112,6 +112,8 @@ class PreferencesRootPresenter(
.launchIn(this)
}
val showLabsItem = remember { featureFlagService.getAvailableFeatures().any { it.isInLabs && !it.isFinished } }
val directLogoutState = directLogoutPresenter.present()
LaunchedEffect(Unit) {
@ -146,6 +148,7 @@ class PreferencesRootPresenter(
showDeveloperSettings = showDeveloperSettings,
canDeactivateAccount = canDeactivateAccount,
showBlockedUsersItem = showBlockedUsersItem,
showLabsItem = showLabsItem,
directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvent,

View file

@ -28,6 +28,7 @@ data class PreferencesRootState(
val showDeveloperSettings: Boolean,
val canDeactivateAccount: Boolean,
val showBlockedUsersItem: Boolean,
val showLabsItem: Boolean,
val directLogoutState: DirectLogoutState,
val snackbarMessage: SnackbarMessage?,
val eventSink: (PreferencesRootEvents) -> Unit,

View file

@ -33,6 +33,7 @@ fun aPreferencesRootState(
canReportBug = true,
showDeveloperSettings = true,
showBlockedUsersItem = true,
showLabsItem = true,
canDeactivateAccount = true,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
directLogoutState = aDirectLogoutState(),

View file

@ -59,6 +59,7 @@ fun PreferencesRootView(
onOpenAbout: () -> Unit,
onOpenDeveloperSettings: () -> Unit,
onOpenAdvancedSettings: () -> Unit,
onOpenLabs: () -> Unit,
onOpenNotificationSettings: () -> Unit,
onOpenUserProfile: (MatrixUser) -> Unit,
onOpenBlockedUsers: () -> Unit,
@ -110,6 +111,7 @@ fun PreferencesRootView(
onOpenRageShake = onOpenRageShake,
onOpenAdvancedSettings = onOpenAdvancedSettings,
onOpenDeveloperSettings = onOpenDeveloperSettings,
onOpenLabs = onOpenLabs,
onSignOutClick = onSignOutClick,
onDeactivateClick = onDeactivateClick,
)
@ -230,6 +232,7 @@ private fun ColumnScope.GeneralSection(
onOpenAnalytics: () -> Unit,
onOpenRageShake: () -> Unit,
onOpenAdvancedSettings: () -> Unit,
onOpenLabs: () -> Unit,
onOpenDeveloperSettings: () -> Unit,
onSignOutClick: () -> Unit,
onDeactivateClick: () -> Unit,
@ -258,6 +261,15 @@ private fun ColumnScope.GeneralSection(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())),
onClick = onOpenAdvancedSettings,
)
if (state.showLabsItem) {
ListItem(
headlineContent = { Text(stringResource(id = R.string.screen_labs_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Labs())),
onClick = onOpenLabs,
)
}
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.action_signout)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.SignOut())),
@ -336,6 +348,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onOpenRageShake = {},
onOpenDeveloperSettings = {},
onOpenAdvancedSettings = {},
onOpenLabs = {},
onOpenAbout = {},
onSecureBackupClick = {},
onManageAccountClick = {},

View file

@ -44,6 +44,11 @@
<string name="screen_edit_profile_error_title">"Unable to update profile"</string>
<string name="screen_edit_profile_title">"Edit profile"</string>
<string name="screen_edit_profile_updating_details">"Updating profile…"</string>
<string name="screen_labs_enable_threads">"Enable thread replies"</string>
<string name="screen_labs_enable_threads_description">"The app will restart to apply this change."</string>
<string name="screen_labs_header_description">"Try out our latest ideas in development. These features are not finalised; they may be unstable, may change."</string>
<string name="screen_labs_header_title">"Feeling experimental?"</string>
<string name="screen_labs_title">"Labs"</string>
<string name="screen_notification_settings_additional_settings_section_title">"Additional settings"</string>
<string name="screen_notification_settings_calls_label">"Audio and video calls"</string>
<string name="screen_notification_settings_configuration_mismatch">"Configuration mismatch"</string>

View file

@ -19,6 +19,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeature
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
@ -35,7 +36,16 @@ class DeveloperSettingsPresenterTest {
@Test
fun `present - ensures initial states are correct`() = runTest {
val presenter = createDeveloperSettingsPresenter()
val availableFeatures = listOf(
FakeFeature(
key = "feature_1",
title = "Feature 1",
isInLabs = false,
)
)
val presenter = createDeveloperSettingsPresenter(
featureFlagService = FakeFeatureFlagService(providedAvailableFeatures = availableFeatures)
)
presenter.test {
awaitItem().also { state ->
assertThat(state.features).isEmpty()
@ -50,8 +60,7 @@ class DeveloperSettingsPresenterTest {
}
awaitItem().also { state ->
assertThat(state.features).isNotEmpty()
val numberOfModifiableFeatureFlags = FeatureFlags.entries.count { it.isFinished.not() }
assertThat(state.features).hasSize(numberOfModifiableFeatureFlags)
assertThat(state.features).hasSize(1)
assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.INFO)
}
awaitItem().also { state ->
@ -161,8 +170,51 @@ class DeveloperSettingsPresenterTest {
}
}
@Test
fun `present - won't display features in labs or finished`() = runTest {
val availableFeatures = listOf(
// Only this feature should be displayed
FakeFeature(
key = "feature_1",
title = "Feature 1",
isInLabs = false,
),
FakeFeature(
key = "feature_2",
title = "Feature 2",
isInLabs = true,
),
FakeFeature(
key = "feature_3",
title = "Feature 3",
isInLabs = false,
isFinished = true,
)
)
val presenter = createDeveloperSettingsPresenter(
featureFlagService = FakeFeatureFlagService(
providedAvailableFeatures = availableFeatures,
)
)
presenter.test {
skipItems(2)
awaitItem().also { state ->
assertThat(state.features).hasSize(1)
}
}
}
private fun createDeveloperSettingsPresenter(
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(
providedAvailableFeatures = listOf(
FakeFeature(
key = "feature_1",
title = "Feature 1",
isInLabs = false,
)
)
),
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),
clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(),
preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),

View file

@ -0,0 +1,126 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.labs
import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeature
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LabsPresenterTest {
@Test
fun `present - ensures only unfinished features in labs are displayed`() = runTest {
val availableFeatures = listOf(
FakeFeature(
key = "feature_1",
title = "Feature 1",
isInLabs = true,
),
FakeFeature(
key = "feature_2",
title = "Feature 2",
isInLabs = false,
),
FakeFeature(
key = "feature_3",
title = "Feature 3",
isInLabs = true,
isFinished = true,
)
)
createLabsPresenter(
availableFeatures = availableFeatures,
).test {
val receivedFeatures = awaitItem().features
assertThat(receivedFeatures).hasSize(1)
assertThat(receivedFeatures.first().key).isEqualTo(availableFeatures.first().key)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - ToggleFeature actually toggles the value`() = runTest {
val availableFeatures = listOf(
FakeFeature(
key = "feature_1",
title = "Feature 1",
isInLabs = true,
),
)
createLabsPresenter(
availableFeatures = availableFeatures,
).test {
val initialItem = awaitItem()
val feature = initialItem.features.first()
assertThat(feature.isEnabled).isFalse()
// Wait until the data finished loading
skipItems(1)
// Toggle the feature, should be true now
initialItem.eventSink(LabsEvents.ToggleFeature(feature))
assertThat(awaitItem().features.first().isEnabled).isTrue()
// Toggle the feature, should be false now
initialItem.eventSink(LabsEvents.ToggleFeature(feature))
assertThat(awaitItem().features.first().isEnabled).isFalse()
}
}
@Test
fun `present - ToggleFeature with the 'Threads' feature resets the cache`() = runTest {
val availableFeatures = listOf(
FakeFeature(
key = FeatureFlags.Threads.key,
title = "Threads",
isInLabs = true,
),
)
val clearCacheUseCase = FakeClearCacheUseCase()
createLabsPresenter(
availableFeatures = availableFeatures,
clearCacheUseCase = clearCacheUseCase,
).test {
val initialItem = awaitItem()
val feature = initialItem.features.first()
assertThat(feature.isEnabled).isFalse()
assertThat(initialItem.isApplyingChanges).isFalse()
// Wait until the data finished loading
skipItems(1)
// Toggle the feature
initialItem.eventSink(LabsEvents.ToggleFeature(feature))
assertThat(awaitItem().features.first().isEnabled).isTrue()
// The clear cache use case should have been called
assertThat(awaitItem().isApplyingChanges).isTrue()
assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue()
}
}
private fun createLabsPresenter(
availableFeatures: List<Feature> = emptyList(),
clearCacheUseCase: ClearCacheUseCase = FakeClearCacheUseCase(),
): LabsPresenter {
return LabsPresenter(
stringProvider = FakeStringProvider(),
featureFlagService = FakeFeatureFlagService(providedAvailableFeatures = availableFeatures),
clearCacheUseCase = clearCacheUseCase,
)
}
}

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeature
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.indicator.test.FakeIndicatorService
@ -184,6 +185,52 @@ class PreferencesRootPresenterTest {
}
}
@Test
fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest {
createPresenter(
featureFlagService = FakeFeatureFlagService(
providedAvailableFeatures = listOf(
FakeFeature(
key = "feature_1",
title = "Feature 1",
isInLabs = true,
isFinished = false,
)
)
),
matrixClient = FakeMatrixClient(
canDeactivateAccountResult = { true },
accountManagementUrlResult = { Result.success(null) },
),
).test {
assertThat(awaitItem().showLabsItem).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - labs can't be shown if all feature flags in labs are finished`() = runTest {
createPresenter(
featureFlagService = FakeFeatureFlagService(
providedAvailableFeatures = listOf(
FakeFeature(
key = "feature_1",
title = "Feature 1",
isInLabs = true,
isFinished = true,
)
)
),
matrixClient = FakeMatrixClient(
canDeactivateAccountResult = { true },
accountManagementUrlResult = { Result.success(null) },
),
).test {
assertThat(awaitItem().showLabsItem).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - multiple accounts`() = runTest {
createPresenter(

View file

@ -36,4 +36,10 @@ interface Feature {
* If true: the feature is finished, it will not appear in the developer options screen.
*/
val isFinished: Boolean
/**
* Whether the feature is only available in Labs (and not in developer options).
* Feature flags that set this to `true` can be enabled by any users, not only those that have enabled developer mode.
*/
val isInLabs: Boolean
}

View file

@ -33,4 +33,9 @@ interface FeatureFlagService {
* is registered
*/
suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean
/**
* @return the list of available (not finished) features that can be toggled.
*/
fun getAvailableFeatures(): List<Feature>
}

View file

@ -19,6 +19,7 @@ enum class FeatureFlags(
override val description: String? = null,
override val defaultValue: (BuildMeta) -> Boolean,
override val isFinished: Boolean,
override val isInLabs: Boolean = false,
) : Feature {
RoomDirectorySearch(
key = "feature.roomdirectorysearch",
@ -98,6 +99,7 @@ enum class FeatureFlags(
description = "Renders thread messages as a dedicated timeline. Restarting the app is required for this setting to fully take effect.",
defaultValue = { false },
isFinished = false,
isInLabs = true,
),
MultiAccount(
key = "feature.multi_account",

View file

@ -14,6 +14,7 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
@ -40,4 +41,8 @@ class DefaultFeatureFlagService(
?.let { true }
?: false
}
override fun getAvailableFeatures(): List<Feature> {
return FeatureFlags.entries.filter { !it.isFinished }
}
}

View file

@ -0,0 +1,20 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.featureflag.test
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.Feature
data class FakeFeature(
override val key: String,
override val title: String,
override val description: String? = null,
override val defaultValue: (BuildMeta) -> Boolean = { false },
override val isFinished: Boolean = false,
override val isInLabs: Boolean = false,
) : Feature

View file

@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
class FakeFeatureFlagService(
initialState: Map<String, Boolean> = emptyMap(),
private val buildMeta: BuildMeta = aBuildMeta(),
var providedAvailableFeatures: List<Feature> = emptyList(),
) : FeatureFlagService {
private val enabledFeatures = initialState
.mapValues { MutableStateFlow(it.value) }
@ -31,4 +32,8 @@ class FakeFeatureFlagService(
override fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean> {
return enabledFeatures.getOrPut(feature.key) { MutableStateFlow(feature.defaultValue(buildMeta)) }
}
override fun getAvailableFeatures(): List<Feature> {
return providedAvailableFeatures
}
}

View file

@ -7,9 +7,12 @@
package io.element.android.libraries.featureflag.ui.model
import io.element.android.libraries.designsystem.theme.components.IconSource
data class FeatureUiModel(
val key: String,
val title: String,
val description: String?,
val icon: IconSource?,
val isEnabled: Boolean
)

View file

@ -12,7 +12,7 @@ import kotlinx.collections.immutable.persistentListOf
fun aFeatureUiModelList(): ImmutableList<FeatureUiModel> {
return persistentListOf(
FeatureUiModel("key1", "Display State Events", "Show state events in the timeline", true),
FeatureUiModel("key2", "Display Room Events", null, false),
FeatureUiModel(key = "key1", title = "Display State Events", description = "Show state events in the timeline", icon = null, isEnabled = true),
FeatureUiModel(key = "key2", title = "Display Room Events", description = null, icon = null, isEnabled = false),
)
}

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f8fc6f61ce73823af4b4edea7bb031fb15bd600b231df1173d33bfdbd09b5000
size 43405

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f1f3b861b5bdf266a4844248e54d36b7d47eb6b43aae2e877ef981b955b97b54
size 37476

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:73a6573fa506af844b8327a6a0da53a9a1bf4e222baa1281a992290423b22c5f
size 42187

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f4f0d016343bce34896e7983877c9166ebe97e62423e177cacc0f67b467e5122
size 35121

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bea2b1b58e31957bfef5a6317753a11b4ef34477858af0ab9a515a57f837af76
size 39307
oid sha256:c0e3bbf4ad7201f993d8ddc280046d770812a969bba407e729a4726eda04072c
size 39369

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:44d8cececc9cf14291f2a23a762ed8628139b5fb3f15090b6ca0a2e733a3ac5a
size 39137
oid sha256:90094980761bdcb3ecd10666af31f45be487cd06b81fd46710a6012610d25329
size 39204

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:61c4546a82519138144a2f1ab82f5200a0469fe428b06e2691f443e04df37ba6
size 40217
oid sha256:f98bbf43c73a0b79c7113180e117e8a7b391d4e4f1cb358aba8d1865bec5bba7
size 40281

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2ae3fa88e84abbc6d83ff55b613758118c03a37a99d7e95cc40369e5222edc2e
size 40266
oid sha256:597ba7fae13fd3bc634aca0ef93e2e3626f552225b6848a5f7cff70fa04749de
size 40327

View file

@ -299,7 +299,8 @@
"screen_notification_settings_.*",
"screen_blocked_users_.*",
"full_screen_intent_banner_.*",
"troubleshoot_notifications_entry_point_.*"
"troubleshoot_notifications_entry_point_.*",
"screen\\.labs\\..*"
]
},
{