Merge pull request #5557 from element-hq/feature/bma/sortFF
Sort feature flags
This commit is contained in:
commit
c962201722
18 changed files with 337 additions and 203 deletions
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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?,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,5 @@ dependencies {
|
||||||
|
|
||||||
testCommonDependencies(libs)
|
testCommonDependencies(libs)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
testImplementation(projects.libraries.featureflag.test)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue