forked from dsutanto/bChot-android
First Commit
This commit is contained in:
21
libraries/featureflag/api/build.gradle.kts
Normal file
21
libraries/featureflag/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
36
libraries/featureflag/impl/build.gradle.kts
Normal file
36
libraries/featureflag/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
22
libraries/featureflag/test/build.gradle.kts
Normal file
22
libraries/featureflag/test/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
20
libraries/featureflag/ui/build.gradle.kts
Normal file
20
libraries/featureflag/ui/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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 = { _, _ -> }
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user