Merge pull request #5557 from element-hq/feature/bma/sortFF

Sort feature flags
This commit is contained in:
Benoit Marty 2025-10-17 15:26:39 +02:00 committed by GitHub
commit c962201722
18 changed files with 337 additions and 203 deletions

View file

@ -13,17 +13,18 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.runtime.snapshots.SnapshotStateList
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.preferences.impl.developer.tracing.toLogLevel import io.element.android.features.preferences.impl.developer.tracing.toLogLevel
import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem
import io.element.android.features.preferences.impl.model.EnabledFeature
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
@ -31,15 +32,14 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -61,12 +61,8 @@ class DeveloperSettingsPresenter(
@Composable @Composable
override fun present(): DeveloperSettingsState { override fun present(): DeveloperSettingsState {
val rageshakeState = rageshakePresenter.present() val rageshakeState = rageshakePresenter.present()
val features = remember {
mutableStateMapOf<String, Feature>()
}
val enabledFeatures = remember { val enabledFeatures = remember {
mutableStateMapOf<String, Boolean>() mutableStateListOf<EnabledFeature>()
} }
val cacheSize = remember { val cacheSize = remember {
mutableStateOf<AsyncData<String>>(AsyncData.Uninitialized) mutableStateOf<AsyncData<String>>(AsyncData.Uninitialized)
@ -89,13 +85,12 @@ class DeveloperSettingsPresenter(
val tracingLogPacks by produceState(persistentListOf()) { val tracingLogPacks by produceState(persistentListOf()) {
appPreferencesStore.getTracingLogPacksFlow() appPreferencesStore.getTracingLogPacksFlow()
// Sort the entries alphabetically by its title // Sort the entries alphabetically by its title
.map { it.sortedBy { it.title } } .map { it.sortedBy { pack -> pack.title } }
.collectLatest { value = it.toImmutableList() } .collectLatest { value = it.toImmutableList() }
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
featureFlagService.getAvailableFeatures() featureFlagService.getAvailableFeatures()
.filter { it.isInLabs.not() && it.isFinished.not() }
.run { .run {
// Never display room directory search in release builds for Play Store // Never display room directory search in release builds for Play Store
if (buildMeta.flavorDescription == "GooglePlay" && buildMeta.buildType == BuildType.RELEASE) { if (buildMeta.flavorDescription == "GooglePlay" && buildMeta.buildType == BuildType.RELEASE) {
@ -105,11 +100,10 @@ class DeveloperSettingsPresenter(
} }
} }
.forEach { feature -> .forEach { feature ->
features[feature.key] = feature enabledFeatures.add(EnabledFeature(feature, featureFlagService.isFeatureEnabled(feature)))
enabledFeatures[feature.key] = featureFlagService.isFeatureEnabled(feature)
} }
} }
val featureUiModels = createUiModels(features, enabledFeatures) val featureUiModels = createUiModels(enabledFeatures)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
// Compute cache size each time the clear cache action value is changed // Compute cache size each time the clear cache action value is changed
LaunchedEffect(clearCacheAction.value.isSuccess()) { LaunchedEffect(clearCacheAction.value.isSuccess()) {
@ -119,10 +113,9 @@ class DeveloperSettingsPresenter(
fun handleEvents(event: DeveloperSettingsEvents) { fun handleEvents(event: DeveloperSettingsEvents) {
when (event) { when (event) {
is DeveloperSettingsEvents.UpdateEnabledFeature -> coroutineScope.updateEnabledFeature( is DeveloperSettingsEvents.UpdateEnabledFeature -> coroutineScope.updateEnabledFeature(
features, enabledFeatures = enabledFeatures,
enabledFeatures, featureKey = event.feature.key,
event.feature, enabled = event.isEnabled,
event.isEnabled,
triggerClearCache = { handleEvents(DeveloperSettingsEvents.ClearCache) } triggerClearCache = { handleEvents(DeveloperSettingsEvents.ClearCache) }
) )
is DeveloperSettingsEvents.SetCustomElementCallBaseUrl -> coroutineScope.launch { is DeveloperSettingsEvents.SetCustomElementCallBaseUrl -> coroutineScope.launch {
@ -154,7 +147,7 @@ class DeveloperSettingsPresenter(
} }
return DeveloperSettingsState( return DeveloperSettingsState(
features = featureUiModels.toImmutableList(), features = featureUiModels,
cacheSize = cacheSize.value, cacheSize = cacheSize.value,
clearCacheAction = clearCacheAction.value, clearCacheAction = clearCacheAction.value,
rageshakeState = rageshakeState, rageshakeState = rageshakeState,
@ -172,35 +165,33 @@ class DeveloperSettingsPresenter(
@Composable @Composable
private fun createUiModels( private fun createUiModels(
features: SnapshotStateMap<String, Feature>, enabledFeatures: SnapshotStateList<EnabledFeature>,
enabledFeatures: SnapshotStateMap<String, Boolean> ): ImmutableList<FeatureUiModel> {
): List<FeatureUiModel> { return enabledFeatures.map { enabledFeature ->
return features.values.map { feature -> key(enabledFeature.feature.key) {
key(feature.key) { remember(enabledFeature) {
val isEnabled = enabledFeatures[feature.key].orFalse()
remember(feature, isEnabled) {
FeatureUiModel( FeatureUiModel(
key = feature.key, key = enabledFeature.feature.key,
title = feature.title, title = enabledFeature.feature.title,
description = feature.description, description = enabledFeature.feature.description,
icon = null, icon = null,
isEnabled = isEnabled isEnabled = enabledFeature.isEnabled
) )
} }
} }
} }.toImmutableList()
} }
private fun CoroutineScope.updateEnabledFeature( private fun CoroutineScope.updateEnabledFeature(
features: SnapshotStateMap<String, Feature>, enabledFeatures: SnapshotStateList<EnabledFeature>,
enabledFeatures: SnapshotStateMap<String, Boolean>, featureKey: String,
featureUiModel: FeatureUiModel,
enabled: Boolean, enabled: Boolean,
@Suppress("UNUSED_PARAMETER") triggerClearCache: () -> Unit, @Suppress("UNUSED_PARAMETER") triggerClearCache: () -> Unit,
) = launch { ) = launch {
val feature = features[featureUiModel.key] ?: return@launch val featureIndex = enabledFeatures.indexOfFirst { it.feature.key == featureKey }.takeIf { it != -1 } ?: return@launch
val feature = enabledFeatures[featureIndex].feature
if (featureFlagService.setFeatureEnabled(feature, enabled)) { if (featureFlagService.setFeatureEnabled(feature, enabled)) {
enabledFeatures[featureUiModel.key] = enabled enabledFeatures[featureIndex] = enabledFeatures[featureIndex].copy(isEnabled = enabled)
} }
} }

View file

@ -26,7 +26,9 @@ data class DeveloperSettingsState(
val isEnterpriseBuild: Boolean, val isEnterpriseBuild: Boolean,
val showColorPicker: Boolean, val showColorPicker: Boolean,
val eventSink: (DeveloperSettingsEvents) -> Unit val eventSink: (DeveloperSettingsEvents) -> Unit
) ) {
val showLoader = clearCacheAction is AsyncAction.Loading
}
data class CustomElementCallBaseUrlState( data class CustomElementCallBaseUrlState(
val baseUrl: String?, val baseUrl: String?,

View file

@ -7,6 +7,7 @@
package io.element.android.features.preferences.impl.developer package io.element.android.features.preferences.impl.developer
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics import androidx.compose.foundation.progressSemantics
@ -22,6 +23,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
@ -50,9 +52,20 @@ fun DeveloperSettingsView(
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (state.showLoader) {
ProgressDialog()
}
BackHandler(
enabled = !state.showLoader,
onBack = onBackClick,
)
PreferencePage( PreferencePage(
modifier = modifier, modifier = modifier,
onBackClick = onBackClick, onBackClick = {
if (!state.showLoader) {
onBackClick()
}
},
title = stringResource(id = CommonStrings.common_developer_options) title = stringResource(id = CommonStrings.common_developer_options)
) { ) {
// Note: this is OK to hardcode strings in this debug screen. // Note: this is OK to hardcode strings in this debug screen.

View file

@ -11,20 +11,19 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.runtime.snapshots.SnapshotStateList
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.model.EnabledFeature
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
import io.element.android.libraries.architecture.Presenter 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.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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
@ -42,46 +41,38 @@ class LabsPresenter(
@Composable @Composable
override fun present(): LabsState { override fun present(): LabsState {
val coroutineScope = rememberCoroutineScope() 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 { val enabledFeatures = remember {
mutableStateMapOf<String, Boolean>() mutableStateListOf<EnabledFeature>()
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
for (feature in features.values) { featureFlagService.getAvailableFeatures(isInLabs = true)
val isEnabled = featureFlagService.isFeatureEnabled(feature) .forEach { feature ->
enabledFeatures[feature.key] = isEnabled enabledFeatures.add(EnabledFeature(feature, featureFlagService.isFeatureEnabled(feature)))
} }
} }
var isApplyingChanges by remember { mutableStateOf(false) } var isApplyingChanges by remember { mutableStateOf(false) }
val featureUiModels = createUiModels(enabledFeatures)
val featureUiModels = createUiModels(features, enabledFeatures)
fun handleEvent(event: LabsEvents) { fun handleEvent(event: LabsEvents) {
when (event) { when (event) {
is LabsEvents.ToggleFeature -> coroutineScope.launch { is LabsEvents.ToggleFeature -> coroutineScope.launch {
val feature = features[event.feature.key] ?: return@launch val featureIndex = enabledFeatures.indexOfFirst { it.feature.key == event.feature.key }.takeIf { it != -1 } ?: return@launch
val isEnabled = featureFlagService.isFeatureEnabled(feature) val enabledFeature = enabledFeatures[featureIndex]
featureFlagService.setFeatureEnabled(feature = feature, enabled = !isEnabled) val feature = enabledFeature.feature
enabledFeatures[feature.key] = !isEnabled val newValue = enabledFeature.isEnabled.not()
if (featureFlagService.setFeatureEnabled(feature, newValue)) {
when (feature.key) { enabledFeatures[featureIndex] = enabledFeatures[featureIndex].copy(isEnabled = newValue)
FeatureFlags.Threads.key -> { when (feature.key) {
// Threads require a cache clear to recreate the event cache FeatureFlags.Threads.key -> {
clearCacheUseCase() // Threads require a cache clear to recreate the event cache
isApplyingChanges = true clearCacheUseCase()
isApplyingChanges = true
}
} }
} }
} }
} }
} }
return LabsState( return LabsState(
features = featureUiModels, features = featureUiModels,
isApplyingChanges = isApplyingChanges, isApplyingChanges = isApplyingChanges,
@ -91,31 +82,29 @@ class LabsPresenter(
@Composable @Composable
private fun createUiModels( private fun createUiModels(
features: SnapshotStateMap<String, Feature>, enabledFeatures: SnapshotStateList<EnabledFeature>,
enabledFeatures: SnapshotStateMap<String, Boolean>
): ImmutableList<FeatureUiModel> { ): ImmutableList<FeatureUiModel> {
return features.values.map { feature -> return enabledFeatures.map { enabledFeature ->
key(feature.key) { key(enabledFeature.feature.key) {
val isEnabled = enabledFeatures[feature.key].orFalse() val title = when (enabledFeature.feature) {
val title = when (feature) {
FeatureFlags.Threads -> stringProvider.getString(R.string.screen_labs_enable_threads) FeatureFlags.Threads -> stringProvider.getString(R.string.screen_labs_enable_threads)
else -> feature.title else -> enabledFeature.feature.title
} }
val description = when (feature) { val description = when (enabledFeature.feature) {
FeatureFlags.Threads -> stringProvider.getString(R.string.screen_labs_enable_threads_description) FeatureFlags.Threads -> stringProvider.getString(R.string.screen_labs_enable_threads_description)
else -> feature.description else -> enabledFeature.feature.description
} }
val icon = when (feature) { val icon = when (enabledFeature.feature) {
FeatureFlags.Threads -> CompoundIcons.Threads() FeatureFlags.Threads -> CompoundIcons.Threads()
else -> null else -> null
} }
remember(feature, isEnabled) { remember(enabledFeature) {
FeatureUiModel( FeatureUiModel(
key = feature.key, key = enabledFeature.feature.key,
title = title, title = title,
description = description, description = description,
icon = icon?.let(IconSource::Vector), icon = icon?.let(IconSource::Vector),
isEnabled = isEnabled isEnabled = enabledFeature.isEnabled
) )
} }
} }

View file

@ -0,0 +1,15 @@
/*
* 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.model
import io.element.android.libraries.featureflag.api.Feature
data class EnabledFeature(
val feature: Feature,
val isEnabled: Boolean,
)

View file

@ -112,7 +112,7 @@ class PreferencesRootPresenter(
.launchIn(this) .launchIn(this)
} }
val showLabsItem = remember { featureFlagService.getAvailableFeatures().any { it.isInLabs && !it.isFinished } } val showLabsItem = remember { featureFlagService.getAvailableFeatures(isInLabs = true).isNotEmpty() }
val directLogoutState = directLogoutPresenter.present() val directLogoutState = directLogoutPresenter.present()

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeature import io.element.android.libraries.featureflag.test.FakeFeature
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@ -41,15 +42,17 @@ class DeveloperSettingsPresenterTest {
@Test @Test
fun `present - ensures initial states are correct`() = runTest { fun `present - ensures initial states are correct`() = runTest {
val availableFeatures = listOf( val getAvailableFeaturesResult = lambdaRecorder<Boolean, Boolean, List<Feature>> { _, _ ->
FakeFeature( listOf(
key = "feature_1", FakeFeature(
title = "Feature 1", key = "feature_1",
isInLabs = false, title = "Feature 1",
isInLabs = false,
)
) )
) }
val presenter = createDeveloperSettingsPresenter( val presenter = createDeveloperSettingsPresenter(
featureFlagService = FakeFeatureFlagService(providedAvailableFeatures = availableFeatures) featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult)
) )
presenter.test { presenter.test {
awaitItem().also { state -> awaitItem().also { state ->
@ -73,6 +76,8 @@ class DeveloperSettingsPresenterTest {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.cacheSize).isInstanceOf(AsyncData.Success::class.java) assertThat(state.cacheSize).isInstanceOf(AsyncData.Success::class.java)
} }
getAvailableFeaturesResult.assertions().isCalledOnce()
.with(value(false), value(false))
} }
} }
@ -203,50 +208,17 @@ 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( private fun createDeveloperSettingsPresenter(
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService( featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(
providedAvailableFeatures = listOf( getAvailableFeaturesResult = { _, _ ->
FakeFeature( listOf(
key = "feature_1", FakeFeature(
title = "Feature 1", key = "feature_1",
isInLabs = false, title = "Feature 1",
isInLabs = false,
)
) )
) }
), ),
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(), cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),
clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(), clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(),

View file

@ -15,13 +15,15 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeature import io.element.android.libraries.featureflag.test.FakeFeature
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class LabsPresenterTest { class LabsPresenterTest {
@Test @Test
fun `present - ensures only unfinished features in labs are displayed`() = runTest { fun `present - ensures features are displayed in the correct order`() = runTest {
val availableFeatures = listOf( val availableFeatures = listOf(
FakeFeature( FakeFeature(
key = "feature_1", key = "feature_1",
@ -30,24 +32,23 @@ class LabsPresenterTest {
), ),
FakeFeature( FakeFeature(
key = "feature_2", key = "feature_2",
title = "Feature 2",
isInLabs = false,
),
FakeFeature(
key = "feature_3",
title = "Feature 3", title = "Feature 3",
isInLabs = true, isInLabs = true,
isFinished = true,
) )
) )
val getAvailableFeaturesResult = lambdaRecorder<Boolean, Boolean, List<Feature>> { _, _ ->
availableFeatures
}
createLabsPresenter( createLabsPresenter(
availableFeatures = availableFeatures, getAvailableFeaturesResult = getAvailableFeaturesResult,
).test { ).test {
skipItems(1)
val receivedFeatures = awaitItem().features val receivedFeatures = awaitItem().features
assertThat(receivedFeatures).hasSize(1) assertThat(receivedFeatures).hasSize(2)
assertThat(receivedFeatures.first().key).isEqualTo(availableFeatures.first().key) assertThat(receivedFeatures[0].key).isEqualTo(availableFeatures[0].key)
assertThat(receivedFeatures[1].key).isEqualTo(availableFeatures[1].key)
cancelAndIgnoreRemainingEvents() getAvailableFeaturesResult.assertions().isCalledOnce()
.with(value(false), value(true))
} }
} }
@ -61,19 +62,15 @@ class LabsPresenterTest {
), ),
) )
createLabsPresenter( createLabsPresenter(
availableFeatures = availableFeatures, getAvailableFeaturesResult = { _, _ -> availableFeatures },
).test { ).test {
skipItems(1)
val initialItem = awaitItem() val initialItem = awaitItem()
val feature = initialItem.features.first() val feature = initialItem.features.first()
assertThat(feature.isEnabled).isFalse() assertThat(feature.isEnabled).isFalse()
// Wait until the data finished loading
skipItems(1)
// Toggle the feature, should be true now // Toggle the feature, should be true now
initialItem.eventSink(LabsEvents.ToggleFeature(feature)) initialItem.eventSink(LabsEvents.ToggleFeature(feature))
assertThat(awaitItem().features.first().isEnabled).isTrue() assertThat(awaitItem().features.first().isEnabled).isTrue()
// Toggle the feature, should be false now // Toggle the feature, should be false now
initialItem.eventSink(LabsEvents.ToggleFeature(feature)) initialItem.eventSink(LabsEvents.ToggleFeature(feature))
assertThat(awaitItem().features.first().isEnabled).isFalse() assertThat(awaitItem().features.first().isEnabled).isFalse()
@ -92,21 +89,17 @@ class LabsPresenterTest {
val clearCacheUseCase = FakeClearCacheUseCase() val clearCacheUseCase = FakeClearCacheUseCase()
createLabsPresenter( createLabsPresenter(
availableFeatures = availableFeatures, getAvailableFeaturesResult = { _, _ -> availableFeatures },
clearCacheUseCase = clearCacheUseCase, clearCacheUseCase = clearCacheUseCase,
).test { ).test {
skipItems(1)
val initialItem = awaitItem() val initialItem = awaitItem()
val feature = initialItem.features.first() val feature = initialItem.features.first()
assertThat(feature.isEnabled).isFalse() assertThat(feature.isEnabled).isFalse()
assertThat(initialItem.isApplyingChanges).isFalse() assertThat(initialItem.isApplyingChanges).isFalse()
// Wait until the data finished loading
skipItems(1)
// Toggle the feature // Toggle the feature
initialItem.eventSink(LabsEvents.ToggleFeature(feature)) initialItem.eventSink(LabsEvents.ToggleFeature(feature))
assertThat(awaitItem().features.first().isEnabled).isTrue() assertThat(awaitItem().features.first().isEnabled).isTrue()
// The clear cache use case should have been called // The clear cache use case should have been called
assertThat(awaitItem().isApplyingChanges).isTrue() assertThat(awaitItem().isApplyingChanges).isTrue()
assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue() assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue()
@ -114,12 +107,12 @@ class LabsPresenterTest {
} }
private fun createLabsPresenter( private fun createLabsPresenter(
availableFeatures: List<Feature> = emptyList(), getAvailableFeaturesResult: (Boolean, Boolean) -> List<Feature> = { _, _ -> emptyList() },
clearCacheUseCase: ClearCacheUseCase = FakeClearCacheUseCase(), clearCacheUseCase: ClearCacheUseCase = FakeClearCacheUseCase(),
): LabsPresenter { ): LabsPresenter {
return LabsPresenter( return LabsPresenter(
stringProvider = FakeStringProvider(), stringProvider = FakeStringProvider(),
featureFlagService = FakeFeatureFlagService(providedAvailableFeatures = availableFeatures), featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult),
clearCacheUseCase = clearCacheUseCase, clearCacheUseCase = clearCacheUseCase,
) )
} }

View file

@ -189,14 +189,16 @@ class PreferencesRootPresenterTest {
fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest { fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest {
createPresenter( createPresenter(
featureFlagService = FakeFeatureFlagService( featureFlagService = FakeFeatureFlagService(
providedAvailableFeatures = listOf( getAvailableFeaturesResult = { _, _ ->
FakeFeature( listOf(
key = "feature_1", FakeFeature(
title = "Feature 1", key = "feature_1",
isInLabs = true, title = "Feature 1",
isFinished = false, isInLabs = true,
isFinished = false,
)
) )
) }
), ),
matrixClient = FakeMatrixClient( matrixClient = FakeMatrixClient(
canDeactivateAccountResult = { true }, canDeactivateAccountResult = { true },
@ -212,20 +214,16 @@ class PreferencesRootPresenterTest {
fun `present - labs can't be shown if all feature flags in labs are finished`() = runTest { fun `present - labs can't be shown if all feature flags in labs are finished`() = runTest {
createPresenter( createPresenter(
featureFlagService = FakeFeatureFlagService( featureFlagService = FakeFeatureFlagService(
providedAvailableFeatures = listOf( getAvailableFeaturesResult = { _, _ ->
FakeFeature( emptyList()
key = "feature_1", }
title = "Feature 1",
isInLabs = true,
isFinished = true,
)
)
), ),
matrixClient = FakeMatrixClient( matrixClient = FakeMatrixClient(
canDeactivateAccountResult = { true }, canDeactivateAccountResult = { true },
accountManagementUrlResult = { Result.success(null) }, accountManagementUrlResult = { Result.success(null) },
), ),
).test { ).test {
skipItems(1)
assertThat(awaitItem().showLabsItem).isFalse() assertThat(awaitItem().showLabsItem).isFalse()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }

View file

@ -35,7 +35,12 @@ interface FeatureFlagService {
suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean
/** /**
* @return the list of available (not finished) features that can be toggled. * @return the list of available features that can be toggled.
* @param includeFinishedFeatures whether to include finished features, default is false
* @param isInLabs whether the user is in labs (to include lab features), default is false
*/ */
fun getAvailableFeatures(): List<Feature> fun getAvailableFeatures(
includeFinishedFeatures: Boolean = false,
isInLabs: Boolean = false,
): List<Feature>
} }

View file

@ -31,4 +31,5 @@ dependencies {
testCommonDependencies(libs) testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test)
} }

View file

@ -14,7 +14,6 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.Feature import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlagService 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.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@ -24,25 +23,30 @@ import kotlinx.coroutines.flow.flowOf
class DefaultFeatureFlagService( class DefaultFeatureFlagService(
private val providers: Set<@JvmSuppressWildcards FeatureFlagProvider>, private val providers: Set<@JvmSuppressWildcards FeatureFlagProvider>,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val featuresProvider: FeaturesProvider,
) : FeatureFlagService { ) : FeatureFlagService {
override fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean> { override fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean> {
return providers.filter { it.hasFeature(feature) } return providers.filter { it.hasFeature(feature) }
.sortedByDescending(FeatureFlagProvider::priority) .maxByOrNull(FeatureFlagProvider::priority)
.firstOrNull()
?.isFeatureEnabledFlow(feature) ?.isFeatureEnabledFlow(feature)
?: flowOf(feature.defaultValue(buildMeta)) ?: flowOf(feature.defaultValue(buildMeta))
} }
override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean { override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean {
return providers.filterIsInstance<MutableFeatureFlagProvider>() return providers.filterIsInstance<MutableFeatureFlagProvider>()
.sortedBy(FeatureFlagProvider::priority) .maxByOrNull(FeatureFlagProvider::priority)
.firstOrNull()
?.setFeatureEnabled(feature, enabled) ?.setFeatureEnabled(feature, enabled)
?.let { true } ?.let { true }
?: false ?: false
} }
override fun getAvailableFeatures(): List<Feature> { override fun getAvailableFeatures(
return FeatureFlags.entries.filter { !it.isFinished } includeFinishedFeatures: Boolean,
isInLabs: Boolean,
): List<Feature> {
return featuresProvider.provide().filter { flag ->
(includeFinishedFeatures || !flag.isFinished) &&
flag.isInLabs == isInLabs
}
} }
} }

View file

@ -0,0 +1,24 @@
/*
* 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.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlags
fun interface FeaturesProvider {
fun provide(): List<Feature>
}
@ContributesBinding(AppScope::class)
@Inject
class DefaultFeaturesProvider : FeaturesProvider {
override fun provide(): List<Feature> = FeatureFlags.entries
}

View file

@ -9,26 +9,47 @@ package io.element.android.libraries.featureflag.impl
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.test.FakeFeature
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class DefaultFeatureFlagServiceTest { class DefaultFeatureFlagServiceTest {
private val aFeature = FakeFeature(
key = "test_feature",
title = "Test Feature",
)
@Test @Test
fun `given service without provider when feature is checked then it returns the default value`() = runTest { fun `given service without provider when feature is checked then it returns the default value`() = runTest {
val featureWithDefaultToFalse = FakeFeature(
key = "test_feature",
title = "Test Feature",
defaultValue = { false }
)
val featureWithDefaultToTrue = FakeFeature(
key = "test_feature_2",
title = "Test Feature 2",
defaultValue = { true }
)
val buildMeta = aBuildMeta() val buildMeta = aBuildMeta()
val featureFlagService = DefaultFeatureFlagService(emptySet(), buildMeta) val featureFlagService = createDefaultFeatureFlagService(buildMeta = buildMeta)
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Space).test { featureFlagService.isFeatureEnabledFlow(featureWithDefaultToFalse).test {
assertThat(awaitItem()).isEqualTo(FeatureFlags.Space.defaultValue(buildMeta)) assertThat(awaitItem()).isFalse()
cancelAndIgnoreRemainingEvents()
}
featureFlagService.isFeatureEnabledFlow(featureWithDefaultToTrue).test {
assertThat(awaitItem()).isTrue()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@Test @Test
fun `given service without provider when set enabled feature is called then it returns false`() = runTest { fun `given service without provider when set enabled feature is called then it returns false`() = runTest {
val featureFlagService = DefaultFeatureFlagService(emptySet(), aBuildMeta()) val featureFlagService = createDefaultFeatureFlagService()
val result = featureFlagService.setFeatureEnabled(FeatureFlags.Space, true) val result = featureFlagService.setFeatureEnabled(aFeature, true)
assertThat(result).isFalse() assertThat(result).isFalse()
} }
@ -36,8 +57,11 @@ class DefaultFeatureFlagServiceTest {
fun `given service with a runtime provider when set enabled feature is called then it returns true`() = runTest { fun `given service with a runtime provider when set enabled feature is called then it returns true`() = runTest {
val buildMeta = aBuildMeta() val buildMeta = aBuildMeta()
val featureFlagProvider = FakeMutableFeatureFlagProvider(0, buildMeta) val featureFlagProvider = FakeMutableFeatureFlagProvider(0, buildMeta)
val featureFlagService = DefaultFeatureFlagService(setOf(featureFlagProvider), buildMeta) val featureFlagService = createDefaultFeatureFlagService(
val result = featureFlagService.setFeatureEnabled(FeatureFlags.Space, true) providers = setOf(featureFlagProvider),
buildMeta = buildMeta,
)
val result = featureFlagService.setFeatureEnabled(aFeature, true)
assertThat(result).isTrue() assertThat(result).isTrue()
} }
@ -45,11 +69,14 @@ class DefaultFeatureFlagServiceTest {
fun `given service with a runtime provider and feature enabled when feature is checked then it returns the correct value`() = runTest { fun `given service with a runtime provider and feature enabled when feature is checked then it returns the correct value`() = runTest {
val buildMeta = aBuildMeta() val buildMeta = aBuildMeta()
val featureFlagProvider = FakeMutableFeatureFlagProvider(0, buildMeta) val featureFlagProvider = FakeMutableFeatureFlagProvider(0, buildMeta)
val featureFlagService = DefaultFeatureFlagService(setOf(featureFlagProvider), buildMeta) val featureFlagService = createDefaultFeatureFlagService(
featureFlagService.setFeatureEnabled(FeatureFlags.Space, true) providers = setOf(featureFlagProvider),
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Space).test { buildMeta = buildMeta
)
featureFlagService.setFeatureEnabled(aFeature, true)
featureFlagService.isFeatureEnabledFlow(aFeature).test {
assertThat(awaitItem()).isTrue() assertThat(awaitItem()).isTrue()
featureFlagService.setFeatureEnabled(FeatureFlags.Space, false) featureFlagService.setFeatureEnabled(aFeature, false)
assertThat(awaitItem()).isFalse() assertThat(awaitItem()).isFalse()
} }
} }
@ -59,11 +86,84 @@ class DefaultFeatureFlagServiceTest {
val buildMeta = aBuildMeta() val buildMeta = aBuildMeta()
val lowPriorityFeatureFlagProvider = FakeMutableFeatureFlagProvider(LOW_PRIORITY, buildMeta) val lowPriorityFeatureFlagProvider = FakeMutableFeatureFlagProvider(LOW_PRIORITY, buildMeta)
val highPriorityFeatureFlagProvider = FakeMutableFeatureFlagProvider(HIGH_PRIORITY, buildMeta) val highPriorityFeatureFlagProvider = FakeMutableFeatureFlagProvider(HIGH_PRIORITY, buildMeta)
val featureFlagService = DefaultFeatureFlagService(setOf(lowPriorityFeatureFlagProvider, highPriorityFeatureFlagProvider), buildMeta) val featureFlagService = createDefaultFeatureFlagService(
lowPriorityFeatureFlagProvider.setFeatureEnabled(FeatureFlags.Space, false) providers = setOf(lowPriorityFeatureFlagProvider, highPriorityFeatureFlagProvider),
highPriorityFeatureFlagProvider.setFeatureEnabled(FeatureFlags.Space, true) buildMeta = buildMeta
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Space).test { )
lowPriorityFeatureFlagProvider.setFeatureEnabled(aFeature, false)
highPriorityFeatureFlagProvider.setFeatureEnabled(aFeature, true)
featureFlagService.isFeatureEnabledFlow(aFeature).test {
assertThat(awaitItem()).isTrue() assertThat(awaitItem()).isTrue()
} }
} }
@Test
fun `getAvailableFeatures should return expected features`() {
val aFinishedLabFeature = FakeFeature(
key = "finished_lab_feature",
title = "Finished Lab Feature",
isFinished = true,
isInLabs = true,
)
val aFinishedDevFeature = FakeFeature(
key = "finished_dev_feature",
title = "Finished Dev Feature",
isFinished = true,
isInLabs = false,
)
val anUnfinishedLabFeature = FakeFeature(
key = "unfinished_lab_feature",
title = "Unfinished Lab Feature",
isFinished = false,
isInLabs = true,
)
val anUnfinishedDevFeature = FakeFeature(
key = "unfinished_dev_feature",
title = "Unfinished Dev Feature",
isFinished = false,
isInLabs = false,
)
val featureFlagService = createDefaultFeatureFlagService(
features = listOf(
aFinishedLabFeature,
aFinishedDevFeature,
anUnfinishedLabFeature,
anUnfinishedDevFeature,
),
)
assertThat(
featureFlagService.getAvailableFeatures(
includeFinishedFeatures = false,
isInLabs = true,
)
).containsExactly(anUnfinishedLabFeature)
assertThat(
featureFlagService.getAvailableFeatures(
includeFinishedFeatures = true,
isInLabs = true,
)
).containsExactly(aFinishedLabFeature, anUnfinishedLabFeature)
assertThat(
featureFlagService.getAvailableFeatures(
includeFinishedFeatures = false,
isInLabs = false,
)
).containsExactly(anUnfinishedDevFeature)
assertThat(
featureFlagService.getAvailableFeatures(
includeFinishedFeatures = true,
isInLabs = false,
)
).containsExactly(aFinishedDevFeature, anUnfinishedDevFeature)
}
} }
private fun createDefaultFeatureFlagService(
providers: Set<FeatureFlagProvider> = emptySet(),
buildMeta: BuildMeta = aBuildMeta(),
features: List<Feature> = emptyList(),
) = DefaultFeatureFlagService(
providers = providers,
buildMeta = buildMeta,
featuresProvider = { features }
)

View file

@ -0,0 +1,24 @@
/*
* 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.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.api.FeatureFlags
import org.junit.Test
class DefaultFeaturesProviderTest {
@Test
fun `provide should return all features`() {
val provider = DefaultFeaturesProvider()
val features = provider.provide()
assertThat(features.size).isEqualTo(FeatureFlags.entries.size)
FeatureFlags.entries.forEach {
assertThat(features.contains(it)).isTrue()
}
}
}

View file

@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
class FakeFeatureFlagService( class FakeFeatureFlagService(
initialState: Map<String, Boolean> = emptyMap(), initialState: Map<String, Boolean> = emptyMap(),
private val buildMeta: BuildMeta = aBuildMeta(), private val buildMeta: BuildMeta = aBuildMeta(),
var providedAvailableFeatures: List<Feature> = emptyList(), private val getAvailableFeaturesResult: (Boolean, Boolean) -> List<Feature> = { _, _ -> emptyList() },
) : FeatureFlagService { ) : FeatureFlagService {
private val enabledFeatures = initialState private val enabledFeatures = initialState
.mapValues { MutableStateFlow(it.value) } .mapValues { MutableStateFlow(it.value) }
@ -33,7 +33,10 @@ class FakeFeatureFlagService(
return enabledFeatures.getOrPut(feature.key) { MutableStateFlow(feature.defaultValue(buildMeta)) } return enabledFeatures.getOrPut(feature.key) { MutableStateFlow(feature.defaultValue(buildMeta)) }
} }
override fun getAvailableFeatures(): List<Feature> { override fun getAvailableFeatures(
return providedAvailableFeatures includeFinishedFeatures: Boolean,
isInLabs: Boolean,
): List<Feature> {
return getAvailableFeaturesResult(includeFinishedFeatures, isInLabs)
} }
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d4d03e543f38658338d7fedcc7f290ad973a68e1ec4a828d4651c188769e6769 oid sha256:84e4c7fd597e4cdcb043514b370b0fd1e3b65f42b8c7811a206433ad422841fe
size 46130 size 42445

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c8b5df08672db5d6c6df1daba79166bb3251edc6e612b29eaad881a2b2dc44d3 oid sha256:8880ddfe80d8062bcaceac1e9d1407e24d365e9c6b69fc8ce65e3ade973e8acf
size 44788 size 41037