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
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.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.runtime.snapshots.SnapshotStateList
import dev.zacsweers.metro.Inject
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.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.ComputeCacheSizeUseCase
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.Presenter
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.meta.BuildMeta
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.FeatureFlags
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@@ -61,12 +61,8 @@ class DeveloperSettingsPresenter(
@Composable
override fun present(): DeveloperSettingsState {
val rageshakeState = rageshakePresenter.present()
val features = remember {
mutableStateMapOf<String, Feature>()
}
val enabledFeatures = remember {
mutableStateMapOf<String, Boolean>()
mutableStateListOf<EnabledFeature>()
}
val cacheSize = remember {
mutableStateOf<AsyncData<String>>(AsyncData.Uninitialized)
@@ -89,13 +85,12 @@ class DeveloperSettingsPresenter(
val tracingLogPacks by produceState(persistentListOf()) {
appPreferencesStore.getTracingLogPacksFlow()
// Sort the entries alphabetically by its title
.map { it.sortedBy { it.title } }
.map { it.sortedBy { pack -> pack.title } }
.collectLatest { value = it.toImmutableList() }
}
LaunchedEffect(Unit) {
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) {
@@ -105,11 +100,10 @@ class DeveloperSettingsPresenter(
}
}
.forEach { feature ->
features[feature.key] = feature
enabledFeatures[feature.key] = featureFlagService.isFeatureEnabled(feature)
enabledFeatures.add(EnabledFeature(feature, featureFlagService.isFeatureEnabled(feature)))
}
}
val featureUiModels = createUiModels(features, enabledFeatures)
val featureUiModels = createUiModels(enabledFeatures)
val coroutineScope = rememberCoroutineScope()
// Compute cache size each time the clear cache action value is changed
LaunchedEffect(clearCacheAction.value.isSuccess()) {
@@ -119,10 +113,9 @@ class DeveloperSettingsPresenter(
fun handleEvents(event: DeveloperSettingsEvents) {
when (event) {
is DeveloperSettingsEvents.UpdateEnabledFeature -> coroutineScope.updateEnabledFeature(
features,
enabledFeatures,
event.feature,
event.isEnabled,
enabledFeatures = enabledFeatures,
featureKey = event.feature.key,
enabled = event.isEnabled,
triggerClearCache = { handleEvents(DeveloperSettingsEvents.ClearCache) }
)
is DeveloperSettingsEvents.SetCustomElementCallBaseUrl -> coroutineScope.launch {
@@ -154,7 +147,7 @@ class DeveloperSettingsPresenter(
}
return DeveloperSettingsState(
features = featureUiModels.toImmutableList(),
features = featureUiModels,
cacheSize = cacheSize.value,
clearCacheAction = clearCacheAction.value,
rageshakeState = rageshakeState,
@@ -172,35 +165,33 @@ class DeveloperSettingsPresenter(
@Composable
private fun createUiModels(
features: SnapshotStateMap<String, Feature>,
enabledFeatures: SnapshotStateMap<String, Boolean>
): List<FeatureUiModel> {
return features.values.map { feature ->
key(feature.key) {
val isEnabled = enabledFeatures[feature.key].orFalse()
remember(feature, isEnabled) {
enabledFeatures: SnapshotStateList<EnabledFeature>,
): ImmutableList<FeatureUiModel> {
return enabledFeatures.map { enabledFeature ->
key(enabledFeature.feature.key) {
remember(enabledFeature) {
FeatureUiModel(
key = feature.key,
title = feature.title,
description = feature.description,
key = enabledFeature.feature.key,
title = enabledFeature.feature.title,
description = enabledFeature.feature.description,
icon = null,
isEnabled = isEnabled
isEnabled = enabledFeature.isEnabled
)
}
}
}
}.toImmutableList()
}
private fun CoroutineScope.updateEnabledFeature(
features: SnapshotStateMap<String, Feature>,
enabledFeatures: SnapshotStateMap<String, Boolean>,
featureUiModel: FeatureUiModel,
enabledFeatures: SnapshotStateList<EnabledFeature>,
featureKey: String,
enabled: Boolean,
@Suppress("UNUSED_PARAMETER") triggerClearCache: () -> Unit,
) = 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)) {
enabledFeatures[featureUiModel.key] = enabled
enabledFeatures[featureIndex] = enabledFeatures[featureIndex].copy(isEnabled = enabled)
}
}

View File

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

View File

@@ -7,6 +7,7 @@
package io.element.android.features.preferences.impl.developer
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.developer.tracing.LogLevelItem
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.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
@@ -50,9 +52,20 @@ fun DeveloperSettingsView(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (state.showLoader) {
ProgressDialog()
}
BackHandler(
enabled = !state.showLoader,
onBack = onBackClick,
)
PreferencePage(
modifier = modifier,
onBackClick = onBackClick,
onBackClick = {
if (!state.showLoader) {
onBackClick()
}
},
title = stringResource(id = CommonStrings.common_developer_options)
) {
// 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.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateListOf
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 androidx.compose.runtime.snapshots.SnapshotStateList
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.model.EnabledFeature
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
@@ -42,46 +41,38 @@ class LabsPresenter(
@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>()
mutableStateListOf<EnabledFeature>()
}
LaunchedEffect(Unit) {
for (feature in features.values) {
val isEnabled = featureFlagService.isFeatureEnabled(feature)
enabledFeatures[feature.key] = isEnabled
}
featureFlagService.getAvailableFeatures(isInLabs = true)
.forEach { feature ->
enabledFeatures.add(EnabledFeature(feature, featureFlagService.isFeatureEnabled(feature)))
}
}
var isApplyingChanges by remember { mutableStateOf(false) }
val featureUiModels = createUiModels(features, enabledFeatures)
val featureUiModels = createUiModels(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
val featureIndex = enabledFeatures.indexOfFirst { it.feature.key == event.feature.key }.takeIf { it != -1 } ?: return@launch
val enabledFeature = enabledFeatures[featureIndex]
val feature = enabledFeature.feature
val newValue = enabledFeature.isEnabled.not()
if (featureFlagService.setFeatureEnabled(feature, newValue)) {
enabledFeatures[featureIndex] = enabledFeatures[featureIndex].copy(isEnabled = newValue)
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,
@@ -91,31 +82,29 @@ class LabsPresenter(
@Composable
private fun createUiModels(
features: SnapshotStateMap<String, Feature>,
enabledFeatures: SnapshotStateMap<String, Boolean>
enabledFeatures: SnapshotStateList<EnabledFeature>,
): ImmutableList<FeatureUiModel> {
return features.values.map { feature ->
key(feature.key) {
val isEnabled = enabledFeatures[feature.key].orFalse()
val title = when (feature) {
return enabledFeatures.map { enabledFeature ->
key(enabledFeature.feature.key) {
val title = when (enabledFeature.feature) {
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)
else -> feature.description
else -> enabledFeature.feature.description
}
val icon = when (feature) {
val icon = when (enabledFeature.feature) {
FeatureFlags.Threads -> CompoundIcons.Threads()
else -> null
}
remember(feature, isEnabled) {
remember(enabledFeature) {
FeatureUiModel(
key = feature.key,
key = enabledFeature.feature.key,
title = title,
description = description,
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)
}
val showLabsItem = remember { featureFlagService.getAvailableFeatures().any { it.isInLabs && !it.isFinished } }
val showLabsItem = remember { featureFlagService.getAvailableFeatures(isInLabs = true).isNotEmpty() }
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.core.meta.BuildMeta
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.test.FakeFeature
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@@ -41,15 +42,17 @@ class DeveloperSettingsPresenterTest {
@Test
fun `present - ensures initial states are correct`() = runTest {
val availableFeatures = listOf(
FakeFeature(
key = "feature_1",
title = "Feature 1",
isInLabs = false,
val getAvailableFeaturesResult = lambdaRecorder<Boolean, Boolean, List<Feature>> { _, _ ->
listOf(
FakeFeature(
key = "feature_1",
title = "Feature 1",
isInLabs = false,
)
)
)
}
val presenter = createDeveloperSettingsPresenter(
featureFlagService = FakeFeatureFlagService(providedAvailableFeatures = availableFeatures)
featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult)
)
presenter.test {
awaitItem().also { state ->
@@ -73,6 +76,8 @@ class DeveloperSettingsPresenterTest {
awaitItem().also { state ->
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(
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(
providedAvailableFeatures = listOf(
FakeFeature(
key = "feature_1",
title = "Feature 1",
isInLabs = false,
getAvailableFeaturesResult = { _, _ ->
listOf(
FakeFeature(
key = "feature_1",
title = "Feature 1",
isInLabs = false,
)
)
)
}
),
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),
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.FakeFeatureFlagService
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 kotlinx.coroutines.test.runTest
import org.junit.Test
class LabsPresenterTest {
@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(
FakeFeature(
key = "feature_1",
@@ -30,24 +32,23 @@ class LabsPresenterTest {
),
FakeFeature(
key = "feature_2",
title = "Feature 2",
isInLabs = false,
),
FakeFeature(
key = "feature_3",
title = "Feature 3",
isInLabs = true,
isFinished = true,
)
)
val getAvailableFeaturesResult = lambdaRecorder<Boolean, Boolean, List<Feature>> { _, _ ->
availableFeatures
}
createLabsPresenter(
availableFeatures = availableFeatures,
getAvailableFeaturesResult = getAvailableFeaturesResult,
).test {
skipItems(1)
val receivedFeatures = awaitItem().features
assertThat(receivedFeatures).hasSize(1)
assertThat(receivedFeatures.first().key).isEqualTo(availableFeatures.first().key)
cancelAndIgnoreRemainingEvents()
assertThat(receivedFeatures).hasSize(2)
assertThat(receivedFeatures[0].key).isEqualTo(availableFeatures[0].key)
assertThat(receivedFeatures[1].key).isEqualTo(availableFeatures[1].key)
getAvailableFeaturesResult.assertions().isCalledOnce()
.with(value(false), value(true))
}
}
@@ -61,19 +62,15 @@ class LabsPresenterTest {
),
)
createLabsPresenter(
availableFeatures = availableFeatures,
getAvailableFeaturesResult = { _, _ -> availableFeatures },
).test {
skipItems(1)
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()
@@ -92,21 +89,17 @@ class LabsPresenterTest {
val clearCacheUseCase = FakeClearCacheUseCase()
createLabsPresenter(
availableFeatures = availableFeatures,
getAvailableFeaturesResult = { _, _ -> availableFeatures },
clearCacheUseCase = clearCacheUseCase,
).test {
skipItems(1)
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()
@@ -114,12 +107,12 @@ class LabsPresenterTest {
}
private fun createLabsPresenter(
availableFeatures: List<Feature> = emptyList(),
getAvailableFeaturesResult: (Boolean, Boolean) -> List<Feature> = { _, _ -> emptyList() },
clearCacheUseCase: ClearCacheUseCase = FakeClearCacheUseCase(),
): LabsPresenter {
return LabsPresenter(
stringProvider = FakeStringProvider(),
featureFlagService = FakeFeatureFlagService(providedAvailableFeatures = availableFeatures),
featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult),
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 {
createPresenter(
featureFlagService = FakeFeatureFlagService(
providedAvailableFeatures = listOf(
FakeFeature(
key = "feature_1",
title = "Feature 1",
isInLabs = true,
isFinished = false,
getAvailableFeaturesResult = { _, _ ->
listOf(
FakeFeature(
key = "feature_1",
title = "Feature 1",
isInLabs = true,
isFinished = false,
)
)
)
}
),
matrixClient = FakeMatrixClient(
canDeactivateAccountResult = { true },
@@ -212,20 +214,16 @@ class PreferencesRootPresenterTest {
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,
)
)
getAvailableFeaturesResult = { _, _ ->
emptyList()
}
),
matrixClient = FakeMatrixClient(
canDeactivateAccountResult = { true },
accountManagementUrlResult = { Result.success(null) },
),
).test {
skipItems(1)
assertThat(awaitItem().showLabsItem).isFalse()
cancelAndIgnoreRemainingEvents()
}

View File

@@ -35,7 +35,12 @@ interface FeatureFlagService {
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)
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.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
@@ -24,25 +23,30 @@ import kotlinx.coroutines.flow.flowOf
class DefaultFeatureFlagService(
private val providers: Set<@JvmSuppressWildcards FeatureFlagProvider>,
private val buildMeta: BuildMeta,
private val featuresProvider: FeaturesProvider,
) : FeatureFlagService {
override fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean> {
return providers.filter { it.hasFeature(feature) }
.sortedByDescending(FeatureFlagProvider::priority)
.firstOrNull()
.maxByOrNull(FeatureFlagProvider::priority)
?.isFeatureEnabledFlow(feature)
?: flowOf(feature.defaultValue(buildMeta))
}
override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean {
return providers.filterIsInstance<MutableFeatureFlagProvider>()
.sortedBy(FeatureFlagProvider::priority)
.firstOrNull()
.maxByOrNull(FeatureFlagProvider::priority)
?.setFeatureEnabled(feature, enabled)
?.let { true }
?: false
}
override fun getAvailableFeatures(): List<Feature> {
return FeatureFlags.entries.filter { !it.isFinished }
override fun getAvailableFeatures(
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 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 kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultFeatureFlagServiceTest {
private val aFeature = FakeFeature(
key = "test_feature",
title = "Test Feature",
)
@Test
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 featureFlagService = DefaultFeatureFlagService(emptySet(), buildMeta)
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Space).test {
assertThat(awaitItem()).isEqualTo(FeatureFlags.Space.defaultValue(buildMeta))
val featureFlagService = createDefaultFeatureFlagService(buildMeta = buildMeta)
featureFlagService.isFeatureEnabledFlow(featureWithDefaultToFalse).test {
assertThat(awaitItem()).isFalse()
cancelAndIgnoreRemainingEvents()
}
featureFlagService.isFeatureEnabledFlow(featureWithDefaultToTrue).test {
assertThat(awaitItem()).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `given service without provider when set enabled feature is called then it returns false`() = runTest {
val featureFlagService = DefaultFeatureFlagService(emptySet(), aBuildMeta())
val result = featureFlagService.setFeatureEnabled(FeatureFlags.Space, true)
val featureFlagService = createDefaultFeatureFlagService()
val result = featureFlagService.setFeatureEnabled(aFeature, true)
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 {
val buildMeta = aBuildMeta()
val featureFlagProvider = FakeMutableFeatureFlagProvider(0, buildMeta)
val featureFlagService = DefaultFeatureFlagService(setOf(featureFlagProvider), buildMeta)
val result = featureFlagService.setFeatureEnabled(FeatureFlags.Space, true)
val featureFlagService = createDefaultFeatureFlagService(
providers = setOf(featureFlagProvider),
buildMeta = buildMeta,
)
val result = featureFlagService.setFeatureEnabled(aFeature, true)
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 {
val buildMeta = aBuildMeta()
val featureFlagProvider = FakeMutableFeatureFlagProvider(0, buildMeta)
val featureFlagService = DefaultFeatureFlagService(setOf(featureFlagProvider), buildMeta)
featureFlagService.setFeatureEnabled(FeatureFlags.Space, true)
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Space).test {
val featureFlagService = createDefaultFeatureFlagService(
providers = setOf(featureFlagProvider),
buildMeta = buildMeta
)
featureFlagService.setFeatureEnabled(aFeature, true)
featureFlagService.isFeatureEnabledFlow(aFeature).test {
assertThat(awaitItem()).isTrue()
featureFlagService.setFeatureEnabled(FeatureFlags.Space, false)
featureFlagService.setFeatureEnabled(aFeature, false)
assertThat(awaitItem()).isFalse()
}
}
@@ -59,11 +86,84 @@ class DefaultFeatureFlagServiceTest {
val buildMeta = aBuildMeta()
val lowPriorityFeatureFlagProvider = FakeMutableFeatureFlagProvider(LOW_PRIORITY, buildMeta)
val highPriorityFeatureFlagProvider = FakeMutableFeatureFlagProvider(HIGH_PRIORITY, buildMeta)
val featureFlagService = DefaultFeatureFlagService(setOf(lowPriorityFeatureFlagProvider, highPriorityFeatureFlagProvider), buildMeta)
lowPriorityFeatureFlagProvider.setFeatureEnabled(FeatureFlags.Space, false)
highPriorityFeatureFlagProvider.setFeatureEnabled(FeatureFlags.Space, true)
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Space).test {
val featureFlagService = createDefaultFeatureFlagService(
providers = setOf(lowPriorityFeatureFlagProvider, highPriorityFeatureFlagProvider),
buildMeta = buildMeta
)
lowPriorityFeatureFlagProvider.setFeatureEnabled(aFeature, false)
highPriorityFeatureFlagProvider.setFeatureEnabled(aFeature, true)
featureFlagService.isFeatureEnabledFlow(aFeature).test {
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(
initialState: Map<String, Boolean> = emptyMap(),
private val buildMeta: BuildMeta = aBuildMeta(),
var providedAvailableFeatures: List<Feature> = emptyList(),
private val getAvailableFeaturesResult: (Boolean, Boolean) -> List<Feature> = { _, _ -> emptyList() },
) : FeatureFlagService {
private val enabledFeatures = initialState
.mapValues { MutableStateFlow(it.value) }
@@ -33,7 +33,10 @@ class FakeFeatureFlagService(
return enabledFeatures.getOrPut(feature.key) { MutableStateFlow(feature.defaultValue(buildMeta)) }
}
override fun getAvailableFeatures(): List<Feature> {
return providedAvailableFeatures
override fun getAvailableFeatures(
includeFinishedFeatures: Boolean,
isInLabs: Boolean,
): List<Feature> {
return getAvailableFeaturesResult(includeFinishedFeatures, isInLabs)
}
}