Skip to content

Commit 9714abe

Browse files
Add Labs screen for beta testing of public features (#5465)
* Add Labs screen: - Make `Feature` have an `isInLabs` boolean to distinguish private feature flags from public ones. - Have `FeatureFlagsService` provide the list of available flags. - Display the labs item in the settings screen only if there are available public features. - Remove public feature toggles from developer options. - Implement the labs screen with the public features. - Add a clear cache step to the threads feature toggle - Update screenshots --------- Co-authored-by: ElementBot <[email protected]>
1 parent a497703 commit 9714abe

File tree

34 files changed

+684
-17
lines changed

34 files changed

+684
-17
lines changed

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import io.element.android.features.preferences.impl.advanced.AdvancedSettingsNod
3030
import io.element.android.features.preferences.impl.analytics.AnalyticsSettingsNode
3131
import io.element.android.features.preferences.impl.blockedusers.BlockedUsersNode
3232
import io.element.android.features.preferences.impl.developer.DeveloperSettingsNode
33+
import io.element.android.features.preferences.impl.labs.LabsNode
3334
import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode
3435
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode
3536
import io.element.android.features.preferences.impl.root.PreferencesRootNode
@@ -75,6 +76,9 @@ class PreferencesFlowNode(
7576
@Parcelize
7677
data object AdvancedSettings : NavTarget
7778

79+
@Parcelize
80+
data object Labs : NavTarget
81+
7882
@Parcelize
7983
data object AnalyticsSettings : NavTarget
8084

@@ -152,6 +156,10 @@ class PreferencesFlowNode(
152156
backstack.push(NavTarget.AdvancedSettings)
153157
}
154158

159+
override fun onOpenLabs() {
160+
backstack.push(NavTarget.Labs)
161+
}
162+
155163
override fun onOpenUserProfile(matrixUser: MatrixUser) {
156164
backstack.push(NavTarget.UserProfile(matrixUser))
157165
}
@@ -178,6 +186,9 @@ class PreferencesFlowNode(
178186
}
179187
createNode<DeveloperSettingsNode>(buildContext, listOf(developerSettingsCallback))
180188
}
189+
NavTarget.Labs -> {
190+
createNode<LabsNode>(buildContext)
191+
}
181192
NavTarget.About -> {
182193
val callback = object : AboutNode.Callback {
183194
override fun openOssLicenses() {

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ class DeveloperSettingsPresenter(
9090
}
9191

9292
LaunchedEffect(Unit) {
93-
FeatureFlags.entries
94-
.filter { it.isFinished.not() }
93+
featureFlagService.getAvailableFeatures()
94+
.filter { it.isInLabs.not() && it.isFinished.not() }
9595
.run {
9696
// Never display room directory search in release builds for Play Store
9797
if (buildMeta.flavorDescription == "GooglePlay" && buildMeta.buildType == BuildType.RELEASE) {
@@ -169,6 +169,7 @@ class DeveloperSettingsPresenter(
169169
key = feature.key,
170170
title = feature.title,
171171
description = feature.description,
172+
icon = null,
172173
isEnabled = isEnabled
173174
)
174175
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.preferences.impl.labs
9+
10+
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
11+
12+
sealed interface LabsEvents {
13+
data class ToggleFeature(val feature: FeatureUiModel) : LabsEvents
14+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.preferences.impl.labs
9+
10+
import androidx.compose.runtime.Composable
11+
import androidx.compose.ui.Modifier
12+
import com.bumble.appyx.core.modality.BuildContext
13+
import com.bumble.appyx.core.node.Node
14+
import com.bumble.appyx.core.plugin.Plugin
15+
import dev.zacsweers.metro.Assisted
16+
import dev.zacsweers.metro.AssistedInject
17+
import io.element.android.annotations.ContributesNode
18+
import io.element.android.libraries.di.SessionScope
19+
20+
@ContributesNode(SessionScope::class)
21+
@AssistedInject
22+
class LabsNode(
23+
@Assisted buildContext: BuildContext,
24+
@Assisted plugins: List<Plugin>,
25+
private val presenter: LabsPresenter,
26+
) : Node(buildContext, plugins = plugins) {
27+
@Composable
28+
override fun View(modifier: Modifier) {
29+
val state = presenter.present()
30+
LabsView(state = state, onBack = ::navigateUp)
31+
}
32+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.preferences.impl.labs
9+
10+
import androidx.compose.runtime.Composable
11+
import androidx.compose.runtime.LaunchedEffect
12+
import androidx.compose.runtime.getValue
13+
import androidx.compose.runtime.key
14+
import androidx.compose.runtime.mutableStateMapOf
15+
import androidx.compose.runtime.mutableStateOf
16+
import androidx.compose.runtime.remember
17+
import androidx.compose.runtime.rememberCoroutineScope
18+
import androidx.compose.runtime.setValue
19+
import androidx.compose.runtime.snapshots.SnapshotStateMap
20+
import dev.zacsweers.metro.Inject
21+
import io.element.android.compound.tokens.generated.CompoundIcons
22+
import io.element.android.features.preferences.impl.R
23+
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
24+
import io.element.android.libraries.architecture.Presenter
25+
import io.element.android.libraries.core.bool.orFalse
26+
import io.element.android.libraries.designsystem.theme.components.IconSource
27+
import io.element.android.libraries.featureflag.api.Feature
28+
import io.element.android.libraries.featureflag.api.FeatureFlagService
29+
import io.element.android.libraries.featureflag.api.FeatureFlags
30+
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
31+
import io.element.android.services.toolbox.api.strings.StringProvider
32+
import kotlinx.collections.immutable.ImmutableList
33+
import kotlinx.collections.immutable.toImmutableList
34+
import kotlinx.coroutines.launch
35+
36+
@Inject
37+
class LabsPresenter(
38+
private val stringProvider: StringProvider,
39+
private val featureFlagService: FeatureFlagService,
40+
private val clearCacheUseCase: ClearCacheUseCase,
41+
) : Presenter<LabsState> {
42+
@Composable
43+
override fun present(): LabsState {
44+
val coroutineScope = rememberCoroutineScope()
45+
val features = remember {
46+
val entries = featureFlagService.getAvailableFeatures()
47+
.filter { it.isInLabs && !it.isFinished }
48+
.map { it.key to it }
49+
mutableStateMapOf(*entries.toTypedArray())
50+
}
51+
val enabledFeatures = remember {
52+
mutableStateMapOf<String, Boolean>()
53+
}
54+
55+
LaunchedEffect(Unit) {
56+
for (feature in features.values) {
57+
val isEnabled = featureFlagService.isFeatureEnabled(feature)
58+
enabledFeatures[feature.key] = isEnabled
59+
}
60+
}
61+
62+
var isApplyingChanges by remember { mutableStateOf(false) }
63+
64+
val featureUiModels = createUiModels(features, enabledFeatures)
65+
66+
fun handleEvent(event: LabsEvents) {
67+
when (event) {
68+
is LabsEvents.ToggleFeature -> coroutineScope.launch {
69+
val feature = features[event.feature.key] ?: return@launch
70+
val isEnabled = featureFlagService.isFeatureEnabled(feature)
71+
featureFlagService.setFeatureEnabled(feature = feature, enabled = !isEnabled)
72+
enabledFeatures[feature.key] = !isEnabled
73+
74+
when (feature.key) {
75+
FeatureFlags.Threads.key -> {
76+
// Threads require a cache clear to recreate the event cache
77+
clearCacheUseCase()
78+
isApplyingChanges = true
79+
}
80+
}
81+
}
82+
}
83+
}
84+
85+
return LabsState(
86+
features = featureUiModels,
87+
isApplyingChanges = isApplyingChanges,
88+
eventSink = ::handleEvent,
89+
)
90+
}
91+
92+
@Composable
93+
private fun createUiModels(
94+
features: SnapshotStateMap<String, Feature>,
95+
enabledFeatures: SnapshotStateMap<String, Boolean>
96+
): ImmutableList<FeatureUiModel> {
97+
return features.values.map { feature ->
98+
key(feature.key) {
99+
val isEnabled = enabledFeatures[feature.key].orFalse()
100+
val title = when (feature) {
101+
FeatureFlags.Threads -> stringProvider.getString(R.string.screen_labs_enable_threads)
102+
else -> feature.title
103+
}
104+
val description = when (feature) {
105+
FeatureFlags.Threads -> stringProvider.getString(R.string.screen_labs_enable_threads_description)
106+
else -> feature.description
107+
}
108+
val icon = when (feature) {
109+
FeatureFlags.Threads -> CompoundIcons.Threads()
110+
else -> null
111+
}
112+
remember(feature, isEnabled) {
113+
FeatureUiModel(
114+
key = feature.key,
115+
title = title,
116+
description = description,
117+
icon = icon?.let(IconSource::Vector),
118+
isEnabled = isEnabled
119+
)
120+
}
121+
}
122+
}.toImmutableList()
123+
}
124+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.preferences.impl.labs
9+
10+
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
11+
import kotlinx.collections.immutable.ImmutableList
12+
13+
data class LabsState(
14+
val features: ImmutableList<FeatureUiModel>,
15+
val isApplyingChanges: Boolean,
16+
val eventSink: (LabsEvents) -> Unit,
17+
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.preferences.impl.labs
9+
10+
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
11+
import io.element.android.libraries.designsystem.icons.CompoundDrawables
12+
import io.element.android.libraries.designsystem.theme.components.IconSource
13+
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
14+
import kotlinx.collections.immutable.toImmutableList
15+
16+
internal class LabsStateProvider : PreviewParameterProvider<LabsState> {
17+
override val values: Sequence<LabsState>
18+
get() = sequenceOf(
19+
aLabsState(features = aFeatureList()),
20+
aLabsState(features = aFeatureList(), isApplyingChanges = true),
21+
)
22+
}
23+
24+
internal fun aLabsState(
25+
features: List<FeatureUiModel> = emptyList(),
26+
isApplyingChanges: Boolean = false,
27+
) = LabsState(
28+
features = features.toImmutableList(),
29+
isApplyingChanges = isApplyingChanges,
30+
eventSink = {},
31+
)
32+
33+
internal fun aFeatureList() = listOf(
34+
FeatureUiModel(
35+
key = "feature_1",
36+
title = "Feature 1",
37+
description = "This is a description of feature 1.",
38+
isEnabled = true,
39+
icon = IconSource.Resource(CompoundDrawables.ic_compound_threads),
40+
),
41+
FeatureUiModel(
42+
key = "feature_2",
43+
title = "Feature 2",
44+
description = "This is a description of feature 2.",
45+
isEnabled = false,
46+
icon = IconSource.Resource(CompoundDrawables.ic_compound_video_call),
47+
)
48+
)

0 commit comments

Comments
 (0)