Add Labs screen for beta testing of public features (#5465)
* Add Labs screen: - Make `Feature` have an `isInLabs` boolean to distinguish private feature flags from public ones. - Have `FeatureFlagsService` provide the list of available flags. - Display the labs item in the settings screen only if there are available public features. - Remove public feature toggles from developer options. - Implement the labs screen with the public features. - Add a clear cache step to the threads feature toggle - Update screenshots --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
parent
a497703a90
commit
9714abe032
34 changed files with 684 additions and 17 deletions
|
|
@ -30,6 +30,7 @@ import io.element.android.features.preferences.impl.advanced.AdvancedSettingsNod
|
|||
import io.element.android.features.preferences.impl.analytics.AnalyticsSettingsNode
|
||||
import io.element.android.features.preferences.impl.blockedusers.BlockedUsersNode
|
||||
import io.element.android.features.preferences.impl.developer.DeveloperSettingsNode
|
||||
import io.element.android.features.preferences.impl.labs.LabsNode
|
||||
import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode
|
||||
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode
|
||||
import io.element.android.features.preferences.impl.root.PreferencesRootNode
|
||||
|
|
@ -75,6 +76,9 @@ class PreferencesFlowNode(
|
|||
@Parcelize
|
||||
data object AdvancedSettings : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object Labs : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object AnalyticsSettings : NavTarget
|
||||
|
||||
|
|
@ -152,6 +156,10 @@ class PreferencesFlowNode(
|
|||
backstack.push(NavTarget.AdvancedSettings)
|
||||
}
|
||||
|
||||
override fun onOpenLabs() {
|
||||
backstack.push(NavTarget.Labs)
|
||||
}
|
||||
|
||||
override fun onOpenUserProfile(matrixUser: MatrixUser) {
|
||||
backstack.push(NavTarget.UserProfile(matrixUser))
|
||||
}
|
||||
|
|
@ -178,6 +186,9 @@ class PreferencesFlowNode(
|
|||
}
|
||||
createNode<DeveloperSettingsNode>(buildContext, listOf(developerSettingsCallback))
|
||||
}
|
||||
NavTarget.Labs -> {
|
||||
createNode<LabsNode>(buildContext)
|
||||
}
|
||||
NavTarget.About -> {
|
||||
val callback = object : AboutNode.Callback {
|
||||
override fun openOssLicenses() {
|
||||
|
|
|
|||
|
|
@ -90,8 +90,8 @@ class DeveloperSettingsPresenter(
|
|||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
FeatureFlags.entries
|
||||
.filter { it.isFinished.not() }
|
||||
featureFlagService.getAvailableFeatures()
|
||||
.filter { it.isInLabs.not() && it.isFinished.not() }
|
||||
.run {
|
||||
// Never display room directory search in release builds for Play Store
|
||||
if (buildMeta.flavorDescription == "GooglePlay" && buildMeta.buildType == BuildType.RELEASE) {
|
||||
|
|
@ -169,6 +169,7 @@ class DeveloperSettingsPresenter(
|
|||
key = feature.key,
|
||||
title = feature.title,
|
||||
description = feature.description,
|
||||
icon = null,
|
||||
isEnabled = isEnabled
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.labs
|
||||
|
||||
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
|
||||
|
||||
sealed interface LabsEvents {
|
||||
data class ToggleFeature(val feature: FeatureUiModel) : LabsEvents
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.labs
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
@AssistedInject
|
||||
class LabsNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: LabsPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
LabsView(state = state, onBack = ::navigateUp)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.labs
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateMap
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.featureflag.api.Feature
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
|
||||
import io.element.android.services.toolbox.api.strings.StringProvider
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Inject
|
||||
class LabsPresenter(
|
||||
private val stringProvider: StringProvider,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val clearCacheUseCase: ClearCacheUseCase,
|
||||
) : Presenter<LabsState> {
|
||||
@Composable
|
||||
override fun present(): LabsState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val features = remember {
|
||||
val entries = featureFlagService.getAvailableFeatures()
|
||||
.filter { it.isInLabs && !it.isFinished }
|
||||
.map { it.key to it }
|
||||
mutableStateMapOf(*entries.toTypedArray())
|
||||
}
|
||||
val enabledFeatures = remember {
|
||||
mutableStateMapOf<String, Boolean>()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
for (feature in features.values) {
|
||||
val isEnabled = featureFlagService.isFeatureEnabled(feature)
|
||||
enabledFeatures[feature.key] = isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
var isApplyingChanges by remember { mutableStateOf(false) }
|
||||
|
||||
val featureUiModels = createUiModels(features, enabledFeatures)
|
||||
|
||||
fun handleEvent(event: LabsEvents) {
|
||||
when (event) {
|
||||
is LabsEvents.ToggleFeature -> coroutineScope.launch {
|
||||
val feature = features[event.feature.key] ?: return@launch
|
||||
val isEnabled = featureFlagService.isFeatureEnabled(feature)
|
||||
featureFlagService.setFeatureEnabled(feature = feature, enabled = !isEnabled)
|
||||
enabledFeatures[feature.key] = !isEnabled
|
||||
|
||||
when (feature.key) {
|
||||
FeatureFlags.Threads.key -> {
|
||||
// Threads require a cache clear to recreate the event cache
|
||||
clearCacheUseCase()
|
||||
isApplyingChanges = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return LabsState(
|
||||
features = featureUiModels,
|
||||
isApplyingChanges = isApplyingChanges,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun createUiModels(
|
||||
features: SnapshotStateMap<String, Feature>,
|
||||
enabledFeatures: SnapshotStateMap<String, Boolean>
|
||||
): ImmutableList<FeatureUiModel> {
|
||||
return features.values.map { feature ->
|
||||
key(feature.key) {
|
||||
val isEnabled = enabledFeatures[feature.key].orFalse()
|
||||
val title = when (feature) {
|
||||
FeatureFlags.Threads -> stringProvider.getString(R.string.screen_labs_enable_threads)
|
||||
else -> feature.title
|
||||
}
|
||||
val description = when (feature) {
|
||||
FeatureFlags.Threads -> stringProvider.getString(R.string.screen_labs_enable_threads_description)
|
||||
else -> feature.description
|
||||
}
|
||||
val icon = when (feature) {
|
||||
FeatureFlags.Threads -> CompoundIcons.Threads()
|
||||
else -> null
|
||||
}
|
||||
remember(feature, isEnabled) {
|
||||
FeatureUiModel(
|
||||
key = feature.key,
|
||||
title = title,
|
||||
description = description,
|
||||
icon = icon?.let(IconSource::Vector),
|
||||
isEnabled = isEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
}.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.labs
|
||||
|
||||
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class LabsState(
|
||||
val features: ImmutableList<FeatureUiModel>,
|
||||
val isApplyingChanges: Boolean,
|
||||
val eventSink: (LabsEvents) -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.labs
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
internal class LabsStateProvider : PreviewParameterProvider<LabsState> {
|
||||
override val values: Sequence<LabsState>
|
||||
get() = sequenceOf(
|
||||
aLabsState(features = aFeatureList()),
|
||||
aLabsState(features = aFeatureList(), isApplyingChanges = true),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aLabsState(
|
||||
features: List<FeatureUiModel> = emptyList(),
|
||||
isApplyingChanges: Boolean = false,
|
||||
) = LabsState(
|
||||
features = features.toImmutableList(),
|
||||
isApplyingChanges = isApplyingChanges,
|
||||
eventSink = {},
|
||||
)
|
||||
|
||||
internal fun aFeatureList() = listOf(
|
||||
FeatureUiModel(
|
||||
key = "feature_1",
|
||||
title = "Feature 1",
|
||||
description = "This is a description of feature 1.",
|
||||
isEnabled = true,
|
||||
icon = IconSource.Resource(CompoundDrawables.ic_compound_threads),
|
||||
),
|
||||
FeatureUiModel(
|
||||
key = "feature_2",
|
||||
title = "Feature 2",
|
||||
description = "This is a description of feature 2.",
|
||||
isEnabled = false,
|
||||
icon = IconSource.Resource(CompoundDrawables.ic_compound_video_call),
|
||||
)
|
||||
)
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.labs
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.components.list.SwitchListItem
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
|
||||
/**
|
||||
* The contents of the Labs screen.
|
||||
* Design: https://www.figma.com/design/V0dkfRAW6T3yCQKjahpzkX/ER-46-EX--Threads?node-id=2004-27319&t=yssy1yYYigsGON3s-0
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LabsView(
|
||||
state: LabsState,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (state.isApplyingChanges) {
|
||||
ProgressDialog()
|
||||
}
|
||||
|
||||
BackHandler(
|
||||
enabled = !state.isApplyingChanges,
|
||||
onBack = onBack,
|
||||
)
|
||||
|
||||
HeaderFooterPage(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.imePadding(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
titleStr = stringResource(R.string.screen_labs_title),
|
||||
navigationIcon = {
|
||||
BackButton(onClick = onBack, enabled = !state.isApplyingChanges)
|
||||
}
|
||||
)
|
||||
},
|
||||
header = {
|
||||
IconTitleSubtitleMolecule(
|
||||
modifier = Modifier.padding(top = 24.dp, start = 24.dp, end = 24.dp),
|
||||
title = stringResource(R.string.screen_labs_header_title),
|
||||
subTitle = stringResource(R.string.screen_labs_header_description),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.Labs())
|
||||
)
|
||||
},
|
||||
contentPadding = PaddingValues(),
|
||||
content = {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 20.dp),
|
||||
) {
|
||||
items(items = state.features, key = { it.key }) { feature ->
|
||||
SwitchListItem(
|
||||
leadingContent = feature.icon?.let { ListItemContent.Icon(it) },
|
||||
headline = feature.title,
|
||||
supportingText = feature.description,
|
||||
value = feature.isEnabled,
|
||||
onChange = {
|
||||
state.eventSink(LabsEvents.ToggleFeature(feature))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun LabsViewPreview(@PreviewParameter(LabsStateProvider::class) state: LabsState) {
|
||||
ElementPreview {
|
||||
LabsView(state = state, onBack = {})
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ class PreferencesRootNode(
|
|||
fun onOpenNotificationSettings()
|
||||
fun onOpenLockScreenSettings()
|
||||
fun onOpenAdvancedSettings()
|
||||
fun onOpenLabs()
|
||||
fun onOpenUserProfile(matrixUser: MatrixUser)
|
||||
fun onOpenBlockedUsers()
|
||||
fun onSignOutClick()
|
||||
|
|
@ -69,6 +70,10 @@ class PreferencesRootNode(
|
|||
plugins<Callback>().forEach { it.onOpenAdvancedSettings() }
|
||||
}
|
||||
|
||||
private fun onOpenLabs() {
|
||||
plugins<Callback>().forEach { it.onOpenLabs() }
|
||||
}
|
||||
|
||||
private fun onOpenAnalytics() {
|
||||
plugins<Callback>().forEach { it.onOpenAnalytics() }
|
||||
}
|
||||
|
|
@ -131,6 +136,7 @@ class PreferencesRootNode(
|
|||
onSecureBackupClick = this::onSecureBackupClick,
|
||||
onOpenDeveloperSettings = this::onOpenDeveloperSettings,
|
||||
onOpenAdvancedSettings = this::onOpenAdvancedSettings,
|
||||
onOpenLabs = this::onOpenLabs,
|
||||
onManageAccountClick = { onManageAccountClick(activity, it, isDark) },
|
||||
onOpenNotificationSettings = this::onOpenNotificationSettings,
|
||||
onOpenLockScreenSettings = this::onOpenLockScreenSettings,
|
||||
|
|
|
|||
|
|
@ -112,6 +112,8 @@ class PreferencesRootPresenter(
|
|||
.launchIn(this)
|
||||
}
|
||||
|
||||
val showLabsItem = remember { featureFlagService.getAvailableFeatures().any { it.isInLabs && !it.isFinished } }
|
||||
|
||||
val directLogoutState = directLogoutPresenter.present()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
@ -146,6 +148,7 @@ class PreferencesRootPresenter(
|
|||
showDeveloperSettings = showDeveloperSettings,
|
||||
canDeactivateAccount = canDeactivateAccount,
|
||||
showBlockedUsersItem = showBlockedUsersItem,
|
||||
showLabsItem = showLabsItem,
|
||||
directLogoutState = directLogoutState,
|
||||
snackbarMessage = snackbarMessage,
|
||||
eventSink = ::handleEvent,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ data class PreferencesRootState(
|
|||
val showDeveloperSettings: Boolean,
|
||||
val canDeactivateAccount: Boolean,
|
||||
val showBlockedUsersItem: Boolean,
|
||||
val showLabsItem: Boolean,
|
||||
val directLogoutState: DirectLogoutState,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
val eventSink: (PreferencesRootEvents) -> Unit,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ fun aPreferencesRootState(
|
|||
canReportBug = true,
|
||||
showDeveloperSettings = true,
|
||||
showBlockedUsersItem = true,
|
||||
showLabsItem = true,
|
||||
canDeactivateAccount = true,
|
||||
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
|
||||
directLogoutState = aDirectLogoutState(),
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ fun PreferencesRootView(
|
|||
onOpenAbout: () -> Unit,
|
||||
onOpenDeveloperSettings: () -> Unit,
|
||||
onOpenAdvancedSettings: () -> Unit,
|
||||
onOpenLabs: () -> Unit,
|
||||
onOpenNotificationSettings: () -> Unit,
|
||||
onOpenUserProfile: (MatrixUser) -> Unit,
|
||||
onOpenBlockedUsers: () -> Unit,
|
||||
|
|
@ -110,6 +111,7 @@ fun PreferencesRootView(
|
|||
onOpenRageShake = onOpenRageShake,
|
||||
onOpenAdvancedSettings = onOpenAdvancedSettings,
|
||||
onOpenDeveloperSettings = onOpenDeveloperSettings,
|
||||
onOpenLabs = onOpenLabs,
|
||||
onSignOutClick = onSignOutClick,
|
||||
onDeactivateClick = onDeactivateClick,
|
||||
)
|
||||
|
|
@ -230,6 +232,7 @@ private fun ColumnScope.GeneralSection(
|
|||
onOpenAnalytics: () -> Unit,
|
||||
onOpenRageShake: () -> Unit,
|
||||
onOpenAdvancedSettings: () -> Unit,
|
||||
onOpenLabs: () -> Unit,
|
||||
onOpenDeveloperSettings: () -> Unit,
|
||||
onSignOutClick: () -> Unit,
|
||||
onDeactivateClick: () -> Unit,
|
||||
|
|
@ -258,6 +261,15 @@ private fun ColumnScope.GeneralSection(
|
|||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Settings())),
|
||||
onClick = onOpenAdvancedSettings,
|
||||
)
|
||||
|
||||
if (state.showLabsItem) {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(id = R.string.screen_labs_title)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Labs())),
|
||||
onClick = onOpenLabs,
|
||||
)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(id = CommonStrings.action_signout)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.SignOut())),
|
||||
|
|
@ -336,6 +348,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
|
|||
onOpenRageShake = {},
|
||||
onOpenDeveloperSettings = {},
|
||||
onOpenAdvancedSettings = {},
|
||||
onOpenLabs = {},
|
||||
onOpenAbout = {},
|
||||
onSecureBackupClick = {},
|
||||
onManageAccountClick = {},
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@
|
|||
<string name="screen_edit_profile_error_title">"Unable to update profile"</string>
|
||||
<string name="screen_edit_profile_title">"Edit profile"</string>
|
||||
<string name="screen_edit_profile_updating_details">"Updating profile…"</string>
|
||||
<string name="screen_labs_enable_threads">"Enable thread replies"</string>
|
||||
<string name="screen_labs_enable_threads_description">"The app will restart to apply this change."</string>
|
||||
<string name="screen_labs_header_description">"Try out our latest ideas in development. These features are not finalised; they may be unstable, may change."</string>
|
||||
<string name="screen_labs_header_title">"Feeling experimental?"</string>
|
||||
<string name="screen_labs_title">"Labs"</string>
|
||||
<string name="screen_notification_settings_additional_settings_section_title">"Additional settings"</string>
|
||||
<string name="screen_notification_settings_calls_label">"Audio and video calls"</string>
|
||||
<string name="screen_notification_settings_configuration_mismatch">"Configuration mismatch"</string>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import io.element.android.libraries.architecture.AsyncData
|
|||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeature
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||
|
|
@ -35,7 +36,16 @@ class DeveloperSettingsPresenterTest {
|
|||
|
||||
@Test
|
||||
fun `present - ensures initial states are correct`() = runTest {
|
||||
val presenter = createDeveloperSettingsPresenter()
|
||||
val availableFeatures = listOf(
|
||||
FakeFeature(
|
||||
key = "feature_1",
|
||||
title = "Feature 1",
|
||||
isInLabs = false,
|
||||
)
|
||||
)
|
||||
val presenter = createDeveloperSettingsPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(providedAvailableFeatures = availableFeatures)
|
||||
)
|
||||
presenter.test {
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.features).isEmpty()
|
||||
|
|
@ -50,8 +60,7 @@ class DeveloperSettingsPresenterTest {
|
|||
}
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.features).isNotEmpty()
|
||||
val numberOfModifiableFeatureFlags = FeatureFlags.entries.count { it.isFinished.not() }
|
||||
assertThat(state.features).hasSize(numberOfModifiableFeatureFlags)
|
||||
assertThat(state.features).hasSize(1)
|
||||
assertThat(state.tracingLogLevel.dataOrNull()).isEqualTo(LogLevelItem.INFO)
|
||||
}
|
||||
awaitItem().also { state ->
|
||||
|
|
@ -161,8 +170,51 @@ class DeveloperSettingsPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - won't display features in labs or finished`() = runTest {
|
||||
val availableFeatures = listOf(
|
||||
// Only this feature should be displayed
|
||||
FakeFeature(
|
||||
key = "feature_1",
|
||||
title = "Feature 1",
|
||||
isInLabs = false,
|
||||
),
|
||||
FakeFeature(
|
||||
key = "feature_2",
|
||||
title = "Feature 2",
|
||||
isInLabs = true,
|
||||
),
|
||||
FakeFeature(
|
||||
key = "feature_3",
|
||||
title = "Feature 3",
|
||||
isInLabs = false,
|
||||
isFinished = true,
|
||||
)
|
||||
)
|
||||
|
||||
val presenter = createDeveloperSettingsPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
providedAvailableFeatures = availableFeatures,
|
||||
)
|
||||
)
|
||||
presenter.test {
|
||||
skipItems(2)
|
||||
awaitItem().also { state ->
|
||||
assertThat(state.features).hasSize(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDeveloperSettingsPresenter(
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(
|
||||
providedAvailableFeatures = listOf(
|
||||
FakeFeature(
|
||||
key = "feature_1",
|
||||
title = "Feature 1",
|
||||
isInLabs = false,
|
||||
)
|
||||
)
|
||||
),
|
||||
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),
|
||||
clearCacheUseCase: FakeClearCacheUseCase = FakeClearCacheUseCase(),
|
||||
preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.labs
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
|
||||
import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
|
||||
import io.element.android.libraries.featureflag.api.Feature
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeature
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class LabsPresenterTest {
|
||||
@Test
|
||||
fun `present - ensures only unfinished features in labs are displayed`() = runTest {
|
||||
val availableFeatures = listOf(
|
||||
FakeFeature(
|
||||
key = "feature_1",
|
||||
title = "Feature 1",
|
||||
isInLabs = true,
|
||||
),
|
||||
FakeFeature(
|
||||
key = "feature_2",
|
||||
title = "Feature 2",
|
||||
isInLabs = false,
|
||||
),
|
||||
FakeFeature(
|
||||
key = "feature_3",
|
||||
title = "Feature 3",
|
||||
isInLabs = true,
|
||||
isFinished = true,
|
||||
)
|
||||
)
|
||||
createLabsPresenter(
|
||||
availableFeatures = availableFeatures,
|
||||
).test {
|
||||
val receivedFeatures = awaitItem().features
|
||||
assertThat(receivedFeatures).hasSize(1)
|
||||
assertThat(receivedFeatures.first().key).isEqualTo(availableFeatures.first().key)
|
||||
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ToggleFeature actually toggles the value`() = runTest {
|
||||
val availableFeatures = listOf(
|
||||
FakeFeature(
|
||||
key = "feature_1",
|
||||
title = "Feature 1",
|
||||
isInLabs = true,
|
||||
),
|
||||
)
|
||||
createLabsPresenter(
|
||||
availableFeatures = availableFeatures,
|
||||
).test {
|
||||
val initialItem = awaitItem()
|
||||
val feature = initialItem.features.first()
|
||||
assertThat(feature.isEnabled).isFalse()
|
||||
|
||||
// Wait until the data finished loading
|
||||
skipItems(1)
|
||||
|
||||
// Toggle the feature, should be true now
|
||||
initialItem.eventSink(LabsEvents.ToggleFeature(feature))
|
||||
assertThat(awaitItem().features.first().isEnabled).isTrue()
|
||||
|
||||
// Toggle the feature, should be false now
|
||||
initialItem.eventSink(LabsEvents.ToggleFeature(feature))
|
||||
assertThat(awaitItem().features.first().isEnabled).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ToggleFeature with the 'Threads' feature resets the cache`() = runTest {
|
||||
val availableFeatures = listOf(
|
||||
FakeFeature(
|
||||
key = FeatureFlags.Threads.key,
|
||||
title = "Threads",
|
||||
isInLabs = true,
|
||||
),
|
||||
)
|
||||
|
||||
val clearCacheUseCase = FakeClearCacheUseCase()
|
||||
createLabsPresenter(
|
||||
availableFeatures = availableFeatures,
|
||||
clearCacheUseCase = clearCacheUseCase,
|
||||
).test {
|
||||
val initialItem = awaitItem()
|
||||
val feature = initialItem.features.first()
|
||||
assertThat(feature.isEnabled).isFalse()
|
||||
assertThat(initialItem.isApplyingChanges).isFalse()
|
||||
|
||||
// Wait until the data finished loading
|
||||
skipItems(1)
|
||||
|
||||
// Toggle the feature
|
||||
initialItem.eventSink(LabsEvents.ToggleFeature(feature))
|
||||
assertThat(awaitItem().features.first().isEnabled).isTrue()
|
||||
|
||||
// The clear cache use case should have been called
|
||||
assertThat(awaitItem().isApplyingChanges).isTrue()
|
||||
assertThat(clearCacheUseCase.executeHasBeenCalled).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLabsPresenter(
|
||||
availableFeatures: List<Feature> = emptyList(),
|
||||
clearCacheUseCase: ClearCacheUseCase = FakeClearCacheUseCase(),
|
||||
): LabsPresenter {
|
||||
return LabsPresenter(
|
||||
stringProvider = FakeStringProvider(),
|
||||
featureFlagService = FakeFeatureFlagService(providedAvailableFeatures = availableFeatures),
|
||||
clearCacheUseCase = clearCacheUseCase,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import io.element.android.libraries.core.meta.BuildType
|
|||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeature
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.indicator.api.IndicatorService
|
||||
import io.element.android.libraries.indicator.test.FakeIndicatorService
|
||||
|
|
@ -184,6 +185,52 @@ class PreferencesRootPresenterTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest {
|
||||
createPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
providedAvailableFeatures = listOf(
|
||||
FakeFeature(
|
||||
key = "feature_1",
|
||||
title = "Feature 1",
|
||||
isInLabs = true,
|
||||
isFinished = false,
|
||||
)
|
||||
)
|
||||
),
|
||||
matrixClient = FakeMatrixClient(
|
||||
canDeactivateAccountResult = { true },
|
||||
accountManagementUrlResult = { Result.success(null) },
|
||||
),
|
||||
).test {
|
||||
assertThat(awaitItem().showLabsItem).isTrue()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - labs can't be shown if all feature flags in labs are finished`() = runTest {
|
||||
createPresenter(
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
providedAvailableFeatures = listOf(
|
||||
FakeFeature(
|
||||
key = "feature_1",
|
||||
title = "Feature 1",
|
||||
isInLabs = true,
|
||||
isFinished = true,
|
||||
)
|
||||
)
|
||||
),
|
||||
matrixClient = FakeMatrixClient(
|
||||
canDeactivateAccountResult = { true },
|
||||
accountManagementUrlResult = { Result.success(null) },
|
||||
),
|
||||
).test {
|
||||
assertThat(awaitItem().showLabsItem).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - multiple accounts`() = runTest {
|
||||
createPresenter(
|
||||
|
|
|
|||
|
|
@ -36,4 +36,10 @@ interface Feature {
|
|||
* If true: the feature is finished, it will not appear in the developer options screen.
|
||||
*/
|
||||
val isFinished: Boolean
|
||||
|
||||
/**
|
||||
* Whether the feature is only available in Labs (and not in developer options).
|
||||
* Feature flags that set this to `true` can be enabled by any users, not only those that have enabled developer mode.
|
||||
*/
|
||||
val isInLabs: Boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,4 +33,9 @@ interface FeatureFlagService {
|
|||
* is registered
|
||||
*/
|
||||
suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean
|
||||
|
||||
/**
|
||||
* @return the list of available (not finished) features that can be toggled.
|
||||
*/
|
||||
fun getAvailableFeatures(): List<Feature>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ enum class FeatureFlags(
|
|||
override val description: String? = null,
|
||||
override val defaultValue: (BuildMeta) -> Boolean,
|
||||
override val isFinished: Boolean,
|
||||
override val isInLabs: Boolean = false,
|
||||
) : Feature {
|
||||
RoomDirectorySearch(
|
||||
key = "feature.roomdirectorysearch",
|
||||
|
|
@ -98,6 +99,7 @@ enum class FeatureFlags(
|
|||
description = "Renders thread messages as a dedicated timeline. Restarting the app is required for this setting to fully take effect.",
|
||||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
isInLabs = true,
|
||||
),
|
||||
MultiAccount(
|
||||
key = "feature.multi_account",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import dev.zacsweers.metro.SingleIn
|
|||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.featureflag.api.Feature
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
|
|
@ -40,4 +41,8 @@ class DefaultFeatureFlagService(
|
|||
?.let { true }
|
||||
?: false
|
||||
}
|
||||
|
||||
override fun getAvailableFeatures(): List<Feature> {
|
||||
return FeatureFlags.entries.filter { !it.isFinished }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.featureflag.test
|
||||
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.featureflag.api.Feature
|
||||
|
||||
data class FakeFeature(
|
||||
override val key: String,
|
||||
override val title: String,
|
||||
override val description: String? = null,
|
||||
override val defaultValue: (BuildMeta) -> Boolean = { false },
|
||||
override val isFinished: Boolean = false,
|
||||
override val isInLabs: Boolean = false,
|
||||
) : Feature
|
||||
|
|
@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
class FakeFeatureFlagService(
|
||||
initialState: Map<String, Boolean> = emptyMap(),
|
||||
private val buildMeta: BuildMeta = aBuildMeta(),
|
||||
var providedAvailableFeatures: List<Feature> = emptyList(),
|
||||
) : FeatureFlagService {
|
||||
private val enabledFeatures = initialState
|
||||
.mapValues { MutableStateFlow(it.value) }
|
||||
|
|
@ -31,4 +32,8 @@ class FakeFeatureFlagService(
|
|||
override fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean> {
|
||||
return enabledFeatures.getOrPut(feature.key) { MutableStateFlow(feature.defaultValue(buildMeta)) }
|
||||
}
|
||||
|
||||
override fun getAvailableFeatures(): List<Feature> {
|
||||
return providedAvailableFeatures
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,12 @@
|
|||
|
||||
package io.element.android.libraries.featureflag.ui.model
|
||||
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
|
||||
data class FeatureUiModel(
|
||||
val key: String,
|
||||
val title: String,
|
||||
val description: String?,
|
||||
val icon: IconSource?,
|
||||
val isEnabled: Boolean
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import kotlinx.collections.immutable.persistentListOf
|
|||
|
||||
fun aFeatureUiModelList(): ImmutableList<FeatureUiModel> {
|
||||
return persistentListOf(
|
||||
FeatureUiModel("key1", "Display State Events", "Show state events in the timeline", true),
|
||||
FeatureUiModel("key2", "Display Room Events", null, false),
|
||||
FeatureUiModel(key = "key1", title = "Display State Events", description = "Show state events in the timeline", icon = null, isEnabled = true),
|
||||
FeatureUiModel(key = "key2", title = "Display Room Events", description = null, icon = null, isEnabled = false),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f8fc6f61ce73823af4b4edea7bb031fb15bd600b231df1173d33bfdbd09b5000
|
||||
size 43405
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f1f3b861b5bdf266a4844248e54d36b7d47eb6b43aae2e877ef981b955b97b54
|
||||
size 37476
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:73a6573fa506af844b8327a6a0da53a9a1bf4e222baa1281a992290423b22c5f
|
||||
size 42187
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f4f0d016343bce34896e7983877c9166ebe97e62423e177cacc0f67b467e5122
|
||||
size 35121
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bea2b1b58e31957bfef5a6317753a11b4ef34477858af0ab9a515a57f837af76
|
||||
size 39307
|
||||
oid sha256:c0e3bbf4ad7201f993d8ddc280046d770812a969bba407e729a4726eda04072c
|
||||
size 39369
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:44d8cececc9cf14291f2a23a762ed8628139b5fb3f15090b6ca0a2e733a3ac5a
|
||||
size 39137
|
||||
oid sha256:90094980761bdcb3ecd10666af31f45be487cd06b81fd46710a6012610d25329
|
||||
size 39204
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:61c4546a82519138144a2f1ab82f5200a0469fe428b06e2691f443e04df37ba6
|
||||
size 40217
|
||||
oid sha256:f98bbf43c73a0b79c7113180e117e8a7b391d4e4f1cb358aba8d1865bec5bba7
|
||||
size 40281
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2ae3fa88e84abbc6d83ff55b613758118c03a37a99d7e95cc40369e5222edc2e
|
||||
size 40266
|
||||
oid sha256:597ba7fae13fd3bc634aca0ef93e2e3626f552225b6848a5f7cff70fa04749de
|
||||
size 40327
|
||||
|
|
|
|||
|
|
@ -299,7 +299,8 @@
|
|||
"screen_notification_settings_.*",
|
||||
"screen_blocked_users_.*",
|
||||
"full_screen_intent_banner_.*",
|
||||
"troubleshoot_notifications_entry_point_.*"
|
||||
"troubleshoot_notifications_entry_point_.*",
|
||||
"screen\\.labs\\..*"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue