First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.featureflag.api"
}
dependencies {
implementation(projects.appconfig)
implementation(projects.libraries.core)
implementation(libs.coroutines.core)
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.api
import io.element.android.libraries.core.meta.BuildMeta
interface Feature {
/**
* Unique key to identify the feature.
*/
val key: String
/**
* Title to show in the UI. Not needed to be translated as it's only dev accessible.
*/
val title: String
/**
* Optional description to give more context on the feature.
*/
val description: String?
/**
* Calculate the default value of the feature (enabled or disabled) given a [BuildMeta].
*/
val defaultValue: (BuildMeta) -> Boolean
/**
* Whether the feature is finished or not.
* If false: the feature is still in development, it will appear in the developer options screen to be able to enable it and test it.
* 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
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.api
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
interface FeatureFlagService {
/**
* @param feature the feature to check for
*
* @return true if the feature is enabled
*/
suspend fun isFeatureEnabled(feature: Feature): Boolean = isFeatureEnabledFlow(feature).first()
/**
* @param feature the feature to check for
*
* @return a flow of booleans, true if the feature is enabled, false if it is disabled.
*/
fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean>
/**
* @param feature the feature to enable or disable
* @param enabled true to enable the feature
*
* @return true if the method succeeds, ie if a [io.element.android.libraries.featureflag.impl.MutableFeatureFlagProvider]
* is registered
*/
suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean
/**
* @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(
includeFinishedFeatures: Boolean = false,
isInLabs: Boolean = false,
): List<Feature>
}

View File

@@ -0,0 +1,121 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.api
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
/**
* To enable or disable a FeatureFlags, change the `defaultValue` value.
*/
enum class FeatureFlags(
override val key: String,
override val title: String,
override val description: String? = null,
override val defaultValue: (BuildMeta) -> Boolean,
override val isFinished: Boolean,
override val isInLabs: Boolean = false,
) : Feature {
RoomDirectorySearch(
key = "feature.roomdirectorysearch",
title = "Room directory search",
description = "Allow user to search for public rooms in their homeserver",
defaultValue = { false },
isFinished = false,
),
ShowBlockedUsersDetails(
key = "feature.showBlockedUsersDetails",
title = "Show blocked users details",
description = "Show the name and avatar of blocked users in the blocked users list",
defaultValue = { false },
isFinished = false,
),
SyncOnPush(
key = "feature.syncOnPush",
title = "Sync on push",
description = "Subscribe to room sync when a push is received",
defaultValue = { true },
isFinished = false,
),
OnlySignedDeviceIsolationMode(
key = "feature.onlySignedDeviceIsolationMode",
title = "Exclude insecure devices when sending/receiving messages",
description = "This setting controls how end-to-end encryption (E2E) keys are shared." +
" Enabling it will prevent the inclusion of devices that have not been explicitly verified by their owners." +
" You'll have to stop and re-open the app manually for that setting to take effect.",
defaultValue = { false },
isFinished = false,
),
EnableKeyShareOnInvite(
key = "feature.enableKeyShareOnInvite",
title = "Share encrypted history with new members",
description = "When inviting a user to an encrypted room that has history visibility set to \"shared\"," +
" share encrypted history with that user, and accept encrypted history when you are invited to such a room." +
"\nRequires an app restart to take effect." +
"\n\nWARNING: this feature is EXPERIMENTAL and not all security precautions are implemented." +
" Do not enable on production accounts.",
defaultValue = { false },
isFinished = false,
),
Knock(
key = "feature.knock",
title = "Ask to join",
description = "Allow creating rooms which users can request access to.",
defaultValue = { false },
isFinished = false,
),
Space(
key = "feature.space",
title = "Spaces",
defaultValue = { true },
isFinished = false,
),
PrintLogsToLogcat(
key = "feature.print_logs_to_logcat",
title = "Print logs to logcat",
description = "Print logs to logcat in addition to log files. Requires an app restart to take effect." +
"\n\nWARNING: this will make the logs visible in the device logs and may affect performance. " +
"It's not intended for daily usage in release builds.",
defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE },
// False so it's displayed in the developer options screen
isFinished = false,
),
SelectableMediaQuality(
key = "feature.selectable_media_quality",
title = "Select media quality per upload",
description = "You can select the media quality for each attachment you upload.",
defaultValue = { false },
// False so it's displayed in the developer options screen
isFinished = false,
),
Threads(
key = "feature.thread_timeline",
title = "Threads",
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",
title = "Multi accounts",
description = "Allow the application to connect to multiple accounts at the same time." +
"\n\nWARNING: this feature is EXPERIMENTAL and UNSTABLE.",
defaultValue = { false },
isFinished = false,
),
SyncNotificationsWithWorkManager(
key = "feature.sync_notifications_with_workmanager",
title = "Sync notifications with WorkManager",
description = "Use WorkManager to schedule notification sync tasks when a push is received." +
" This should improve reliability and battery usage.",
defaultValue = { true },
isFinished = false,
),
}

View File

@@ -0,0 +1,36 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023, 2024 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.
*/
plugins {
id("io.element.android-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.libraries.featureflag.impl"
}
setupDependencyInjection()
dependencies {
api(projects.libraries.featureflag.api)
implementation(libs.androidx.datastore.preferences)
implementation(projects.appconfig)
implementation(projects.libraries.di)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.preferences.api)
implementation(libs.coroutines.core)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test)
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultFeatureFlagService(
private val providers: Set<@JvmSuppressWildcards FeatureFlagProvider>,
private val buildMeta: BuildMeta,
private val featuresProvider: FeaturesProvider,
) : FeatureFlagService {
override fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean> {
return providers.filter { it.hasFeature(feature) }
.maxByOrNull(FeatureFlagProvider::priority)
?.isFeatureEnabledFlow(feature)
?: flowOf(feature.defaultValue(buildMeta))
}
override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean {
return providers.filterIsInstance<MutableFeatureFlagProvider>()
.maxByOrNull(FeatureFlagProvider::priority)
?.setFeatureEnabled(feature, enabled)
?.let { true }
?: false
}
override fun getAvailableFeatures(
includeFinishedFeatures: Boolean,
isInLabs: Boolean,
): List<Feature> {
return featuresProvider.provide().filter { flag ->
(includeFinishedFeatures || !flag.isFinished) &&
flag.isInLabs == isInLabs
}
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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 io.element.android.libraries.featureflag.api.Feature
import kotlinx.coroutines.flow.Flow
interface FeatureFlagProvider {
val priority: Int
fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean>
fun hasFeature(feature: Feature): Boolean
}
const val LOW_PRIORITY = 0
const val MEDIUM_PRIORITY = 1
const val HIGH_PRIORITY = 2

View File

@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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 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)
class DefaultFeaturesProvider : FeaturesProvider {
override fun provide(): List<Feature> = FeatureFlags.entries
}

View File

@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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 io.element.android.libraries.featureflag.api.Feature
interface MutableFeatureFlagProvider : FeatureFlagProvider {
suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean)
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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 androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* Note: this will be used only in the nightly and in the debug build.
*/
@Inject
class PreferencesFeatureFlagProvider(
private val buildMeta: BuildMeta,
preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : MutableFeatureFlagProvider {
private val store = preferenceDataStoreFactory.create("elementx_featureflag")
override val priority = MEDIUM_PRIORITY
override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean) {
store.edit { prefs ->
prefs[booleanPreferencesKey(feature.key)] = enabled
}
}
override fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean> {
return store.data.map { prefs ->
prefs[booleanPreferencesKey(feature.key)] ?: feature.defaultValue(buildMeta)
}.distinctUntilChanged()
}
override fun hasFeature(feature: Feature): Boolean {
return true
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.di
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.ElementsIntoSet
import dev.zacsweers.metro.Provides
import io.element.android.libraries.featureflag.impl.FeatureFlagProvider
import io.element.android.libraries.featureflag.impl.PreferencesFeatureFlagProvider
@BindingContainer
@ContributesTo(AppScope::class)
object FeatureFlagModule {
@JvmStatic
@Provides
@ElementsIntoSet
fun providesFeatureFlagProvider(
mutableFeatureFlagProvider: PreferencesFeatureFlagProvider,
): Set<FeatureFlagProvider> {
return buildSet {
add(mutableFeatureFlagProvider)
}
}
}

View File

@@ -0,0 +1,170 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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 app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.test.FakeFeature
import io.element.android.libraries.matrix.test.core.aBuildMeta
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultFeatureFlagServiceTest {
private val aFeature = FakeFeature(
key = "test_feature",
title = "Test Feature",
)
@Test
fun `given service without provider when feature is checked then it returns the default value`() = runTest {
val featureWithDefaultToFalse = FakeFeature(
key = "test_feature",
title = "Test Feature",
defaultValue = { false }
)
val featureWithDefaultToTrue = FakeFeature(
key = "test_feature_2",
title = "Test Feature 2",
defaultValue = { true }
)
val buildMeta = aBuildMeta()
val featureFlagService = createDefaultFeatureFlagService(buildMeta = buildMeta)
featureFlagService.isFeatureEnabledFlow(featureWithDefaultToFalse).test {
assertThat(awaitItem()).isFalse()
cancelAndIgnoreRemainingEvents()
}
featureFlagService.isFeatureEnabledFlow(featureWithDefaultToTrue).test {
assertThat(awaitItem()).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `given service without provider when set enabled feature is called then it returns false`() = runTest {
val featureFlagService = createDefaultFeatureFlagService()
val result = featureFlagService.setFeatureEnabled(aFeature, true)
assertThat(result).isFalse()
}
@Test
fun `given service with a runtime provider when set enabled feature is called then it returns true`() = runTest {
val buildMeta = aBuildMeta()
val featureFlagProvider = FakeMutableFeatureFlagProvider(0, buildMeta)
val featureFlagService = createDefaultFeatureFlagService(
providers = setOf(featureFlagProvider),
buildMeta = buildMeta,
)
val result = featureFlagService.setFeatureEnabled(aFeature, true)
assertThat(result).isTrue()
}
@Test
fun `given service with a runtime provider and feature enabled when feature is checked then it returns the correct value`() = runTest {
val buildMeta = aBuildMeta()
val featureFlagProvider = FakeMutableFeatureFlagProvider(0, buildMeta)
val featureFlagService = createDefaultFeatureFlagService(
providers = setOf(featureFlagProvider),
buildMeta = buildMeta
)
featureFlagService.setFeatureEnabled(aFeature, true)
featureFlagService.isFeatureEnabledFlow(aFeature).test {
assertThat(awaitItem()).isTrue()
featureFlagService.setFeatureEnabled(aFeature, false)
assertThat(awaitItem()).isFalse()
}
}
@Test
fun `given service with 2 runtime providers when feature is checked then it uses the priority correctly`() = runTest {
val buildMeta = aBuildMeta()
val lowPriorityFeatureFlagProvider = FakeMutableFeatureFlagProvider(LOW_PRIORITY, buildMeta)
val highPriorityFeatureFlagProvider = FakeMutableFeatureFlagProvider(HIGH_PRIORITY, buildMeta)
val featureFlagService = createDefaultFeatureFlagService(
providers = setOf(lowPriorityFeatureFlagProvider, highPriorityFeatureFlagProvider),
buildMeta = buildMeta
)
lowPriorityFeatureFlagProvider.setFeatureEnabled(aFeature, false)
highPriorityFeatureFlagProvider.setFeatureEnabled(aFeature, true)
featureFlagService.isFeatureEnabledFlow(aFeature).test {
assertThat(awaitItem()).isTrue()
}
}
@Test
fun `getAvailableFeatures should return expected features`() {
val aFinishedLabFeature = FakeFeature(
key = "finished_lab_feature",
title = "Finished Lab Feature",
isFinished = true,
isInLabs = true,
)
val aFinishedDevFeature = FakeFeature(
key = "finished_dev_feature",
title = "Finished Dev Feature",
isFinished = true,
isInLabs = false,
)
val anUnfinishedLabFeature = FakeFeature(
key = "unfinished_lab_feature",
title = "Unfinished Lab Feature",
isFinished = false,
isInLabs = true,
)
val anUnfinishedDevFeature = FakeFeature(
key = "unfinished_dev_feature",
title = "Unfinished Dev Feature",
isFinished = false,
isInLabs = false,
)
val featureFlagService = createDefaultFeatureFlagService(
features = listOf(
aFinishedLabFeature,
aFinishedDevFeature,
anUnfinishedLabFeature,
anUnfinishedDevFeature,
),
)
assertThat(
featureFlagService.getAvailableFeatures(
includeFinishedFeatures = false,
isInLabs = true,
)
).containsExactly(anUnfinishedLabFeature)
assertThat(
featureFlagService.getAvailableFeatures(
includeFinishedFeatures = true,
isInLabs = true,
)
).containsExactly(aFinishedLabFeature, anUnfinishedLabFeature)
assertThat(
featureFlagService.getAvailableFeatures(
includeFinishedFeatures = false,
isInLabs = false,
)
).containsExactly(anUnfinishedDevFeature)
assertThat(
featureFlagService.getAvailableFeatures(
includeFinishedFeatures = true,
isInLabs = false,
)
).containsExactly(aFinishedDevFeature, anUnfinishedDevFeature)
}
}
private fun createDefaultFeatureFlagService(
providers: Set<FeatureFlagProvider> = emptySet(),
buildMeta: BuildMeta = aBuildMeta(),
features: List<Feature> = emptyList(),
) = DefaultFeatureFlagService(
providers = providers,
buildMeta = buildMeta,
featuresProvider = { features }
)

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.featureflag.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.api.FeatureFlags
import org.junit.Test
class DefaultFeaturesProviderTest {
@Test
fun `provide should return all features`() {
val provider = DefaultFeaturesProvider()
val features = provider.provide()
assertThat(features.size).isEqualTo(FeatureFlags.entries.size)
FeatureFlags.entries.forEach {
assertThat(features.contains(it)).isTrue()
}
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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 io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.Feature
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeMutableFeatureFlagProvider(
override val priority: Int,
private val buildMeta: BuildMeta,
) : MutableFeatureFlagProvider {
private val enabledFeatures = mutableMapOf<String, MutableStateFlow<Boolean>>()
override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean) {
val flow = enabledFeatures.getOrPut(feature.key) { MutableStateFlow(enabled) }
flow.emit(enabled)
}
override fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean> {
return enabledFeatures.getOrPut(feature.key) { MutableStateFlow(feature.defaultValue(buildMeta)) }
}
override fun hasFeature(feature: Feature): Boolean = true
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.featureflag.test"
}
dependencies {
api(projects.libraries.featureflag.api)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.test)
implementation(libs.coroutines.core)
}

View File

@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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

View File

@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeFeatureFlagService(
initialState: Map<String, Boolean> = emptyMap(),
private val buildMeta: BuildMeta = aBuildMeta(),
private val getAvailableFeaturesResult: (Boolean, Boolean) -> List<Feature> = { _, _ -> emptyList() },
) : FeatureFlagService {
private val enabledFeatures = initialState
.mapValues { MutableStateFlow(it.value) }
.toMutableMap()
override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean {
val flow = enabledFeatures.getOrPut(feature.key) { MutableStateFlow(enabled) }
flow.emit(enabled)
return true
}
override fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean> {
return enabledFeatures.getOrPut(feature.key) { MutableStateFlow(feature.defaultValue(buildMeta)) }
}
override fun getAvailableFeatures(
includeFinishedFeatures: Boolean,
isInLabs: Boolean,
): List<Feature> {
return getAvailableFeaturesResult(includeFinishedFeatures, isInLabs)
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.libraries.featureflag.ui"
}
dependencies {
implementation(projects.libraries.designsystem)
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.designsystem.components.preferences.PreferenceCheckbox
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList
import kotlinx.collections.immutable.ImmutableList
@Composable
fun FeatureListView(
features: ImmutableList<FeatureUiModel>,
onCheckedChange: (FeatureUiModel, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
) {
features.forEach { feature ->
fun onCheckedChange(isChecked: Boolean) {
onCheckedChange(feature, isChecked)
}
FeaturePreferenceView(feature = feature, onCheckedChange = ::onCheckedChange)
}
}
}
@Composable
private fun FeaturePreferenceView(
feature: FeatureUiModel,
onCheckedChange: (Boolean) -> Unit,
) {
PreferenceCheckbox(
title = feature.title,
supportingText = feature.description,
isChecked = feature.isEnabled,
onCheckedChange = onCheckedChange
)
}
@PreviewsDayNight
@Composable
internal fun FeatureListViewPreview() = ElementPreview {
FeatureListView(
features = aFeatureUiModelList(),
onCheckedChange = { _, _ -> }
)
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.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
)

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.ui.model
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
fun aFeatureUiModelList(): ImmutableList<FeatureUiModel> {
return persistentListOf(
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),
)
}