PM-11224 Add menu to update feature flags with overridden values in real time (#3838)

This commit is contained in:
Dave Severns 2024-08-29 14:07:21 -04:00 committed by GitHub
parent 2a057bb1fb
commit 3c39d8beac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1451 additions and 17 deletions

View file

@ -61,6 +61,8 @@ android {
signingConfig = signingConfigs.getByName("debug")
isDebuggable = true
isMinifyEnabled = false
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "true")
}
// Beta and Release variants are identical except beta has a different package name
@ -72,6 +74,8 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false")
}
release {
isDebuggable = false
@ -80,6 +84,8 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false")
}
}

View file

@ -2,6 +2,8 @@ package com.x8bit.bitwarden
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.viewModels
@ -11,17 +13,18 @@ import androidx.compose.runtime.getValue
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.composition.LocalManagerProvider
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavScreen
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
/**
@ -42,13 +45,14 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var settingsRepository: SettingsRepository
@Inject
lateinit var debugLaunchManager: DebugMenuLaunchManager
override fun onCreate(savedInstanceState: Bundle?) {
var shouldShowSplashScreen = true
installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen }
super.onCreate(savedInstanceState)
observeViewModelEvents()
if (savedInstanceState == null) {
mainViewModel.trySendAction(
MainAction.ReceiveFirstIntent(
@ -66,11 +70,20 @@ class MainActivity : AppCompatActivity() {
}
setContent {
val state by mainViewModel.stateFlow.collectAsStateWithLifecycle()
val navController = rememberNavController()
EventsEffect(viewModel = mainViewModel) { event ->
when (event) {
is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
MainEvent.Recreate -> handleRecreate()
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
}
}
updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed)
LocalManagerProvider {
BitwardenTheme(theme = state.theme) {
RootNavScreen(
onSplashScreenRemoved = { shouldShowSplashScreen = false },
navController = navController,
)
}
}
@ -93,16 +106,18 @@ class MainActivity : AppCompatActivity() {
currentFocus?.clearFocus()
}
private fun observeViewModelEvents() {
mainViewModel
.eventFlow
.onEach { event ->
when (event) {
is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
MainEvent.Recreate -> handleRecreate()
}
}
.launchIn(lifecycleScope)
override fun dispatchTouchEvent(event: MotionEvent): Boolean = debugLaunchManager
.actionOnInputEvent(event = event, action = ::sendOpenDebugMenuEvent)
.takeIf { it }
?: super.dispatchTouchEvent(event)
override fun dispatchKeyEvent(event: KeyEvent): Boolean = debugLaunchManager
.actionOnInputEvent(event = event, action = ::sendOpenDebugMenuEvent)
.takeIf { it }
?: super.dispatchKeyEvent(event)
private fun sendOpenDebugMenuEvent() {
mainViewModel.trySendAction(MainAction.OpenDebugMenu)
}
private fun handleCompleteAutofill(event: MainEvent.CompleteAutofill) {

View file

@ -140,9 +140,14 @@ class MainViewModel @Inject constructor(
is MainAction.Internal.VaultUnlockStateChange -> handleVaultUnlockStateChange()
is MainAction.ReceiveFirstIntent -> handleFirstIntentReceived(action)
is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action)
MainAction.OpenDebugMenu -> handleOpenDebugMenu()
}
}
private fun handleOpenDebugMenu() {
sendEvent(MainEvent.NavigateToDebugMenu)
}
private fun handleAutofillSelectionReceive(
action: MainAction.Internal.AutofillSelectionReceive,
) {
@ -315,6 +320,11 @@ sealed class MainAction {
*/
data class ReceiveNewIntent(val intent: Intent) : MainAction()
/**
* Receive event to open the debug menu.
*/
data object OpenDebugMenu : MainAction()
/**
* Actions for internal use by the ViewModel.
*/
@ -366,4 +376,9 @@ sealed class MainEvent {
* Event indicating that the UI should recreate itself.
*/
data object Recreate : MainEvent()
/**
* Navigate to the debug menu.
*/
data object NavigateToDebugMenu : MainEvent()
}

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
/**
* Disk data source for saved feature flag overrides.
*/
interface FeatureFlagOverrideDiskSource {
/**
* Save a feature flag [FlagKey] to disk.
*/
fun <T : Any> saveFeatureFlag(key: FlagKey<T>, value: T)
/**
* Get a feature flag value based on the associated [FlagKey] from disk.
*/
fun <T : Any> getFeatureFlag(key: FlagKey<T>): T?
}

View file

@ -0,0 +1,37 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import android.content.SharedPreferences
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
/**
* Default implementation of the [FeatureFlagOverrideDiskSource]
*/
class FeatureFlagOverrideDiskSourceImpl(
sharedPreferences: SharedPreferences,
) : FeatureFlagOverrideDiskSource, BaseDiskSource(sharedPreferences) {
override fun <T : Any> saveFeatureFlag(key: FlagKey<T>, value: T) {
when (key.defaultValue) {
is Boolean -> putBoolean(key.keyName, value as Boolean)
is String -> putString(key.keyName, value as String)
is Int -> putInt(key.keyName, value as Int)
else -> Unit
}
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getFeatureFlag(key: FlagKey<T>): T? {
return try {
when (key.defaultValue) {
is Boolean -> getBoolean(key.keyName) as? T
is String -> getString(key.keyName) as? T
is Int -> getInt(key.keyName) as? T
else -> null
}
} catch (castException: ClassCastException) {
null
} catch (numberFormatException: NumberFormatException) {
null
}
}
}

View file

@ -12,6 +12,8 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
@ -149,4 +151,12 @@ object PlatformDiskModule {
sharedPreferences = sharedPreferences,
json = json,
)
@Provides
@Singleton
fun provideFeatureFlagOverrideDiskSource(
@UnencryptedPreferences sharedPreferences: SharedPreferences,
): FeatureFlagOverrideDiskSource = FeatureFlagOverrideDiskSourceImpl(
sharedPreferences = sharedPreferences,
)
}

View file

@ -0,0 +1,37 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* The [FeatureFlagManager] implementation for the debug menu. This manager uses the
* values returned from the [debugMenuRepository] if they are available. otherwise it will use
* the default [FeatureFlagManager].
*/
class DebugMenuFeatureFlagManagerImpl(
private val defaultFeatureFlagManager: FeatureFlagManager,
private val debugMenuRepository: DebugMenuRepository,
) : FeatureFlagManager by defaultFeatureFlagManager {
override fun <T : Any> getFeatureFlagFlow(key: FlagKey<T>): Flow<T> {
return debugMenuRepository.featureFlagOverridesUpdatedFlow.map { _ ->
debugMenuRepository
.getFeatureFlag(key)
?: defaultFeatureFlagManager.getFeatureFlag(key = key)
}
}
override suspend fun <T : Any> getFeatureFlag(key: FlagKey<T>, forceRefresh: Boolean): T {
return debugMenuRepository
.getFeatureFlag(key)
?: defaultFeatureFlagManager.getFeatureFlag(key = key, forceRefresh = forceRefresh)
}
override fun <T : Any> getFeatureFlag(key: FlagKey<T>): T {
return debugMenuRepository
.getFeatureFlag(key)
?: defaultFeatureFlagManager.getFeatureFlag(key = key)
}
}

View file

@ -40,7 +40,11 @@ class FeatureFlagManagerImpl(
.getFlagValueOrDefault(key = key)
}
private fun <T : Any> ServerConfig?.getFlagValueOrDefault(key: FlagKey<T>): T {
/**
* Extract the value of a [FlagKey] from the [ServerConfig]. If there is an issue with retrieving
* or if the value is null, the default value will be returned.
*/
fun <T : Any> ServerConfig?.getFlagValueOrDefault(key: FlagKey<T>): T {
val defaultValue = key.defaultValue
if (!key.isRemotelyConfigured) return key.defaultValue
return this

View file

@ -22,6 +22,7 @@ import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.CrashLogsManager
import com.x8bit.bitwarden.data.platform.manager.CrashLogsManagerImpl
import com.x8bit.bitwarden.data.platform.manager.DebugMenuFeatureFlagManagerImpl
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManagerImpl
import com.x8bit.bitwarden.data.platform.manager.NetworkConfigManager
@ -48,6 +49,7 @@ import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManage
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManagerImpl
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@ -141,11 +143,20 @@ object PlatformManagerModule {
@Provides
@Singleton
fun providesFeatureFlagManager(
debugMenuRepository: DebugMenuRepository,
serverConfigRepository: ServerConfigRepository,
): FeatureFlagManager =
): FeatureFlagManager = if (debugMenuRepository.isDebugMenuEnabled) {
DebugMenuFeatureFlagManagerImpl(
debugMenuRepository = debugMenuRepository,
defaultFeatureFlagManager = FeatureFlagManagerImpl(
serverConfigRepository = serverConfigRepository,
),
)
} else {
FeatureFlagManagerImpl(
serverConfigRepository = serverConfigRepository,
)
}
@Provides
@Singleton

View file

@ -19,6 +19,19 @@ sealed class FlagKey<out T : Any> {
*/
abstract val isRemotelyConfigured: Boolean
companion object {
/**
* List of all flag keys to consider
*/
val activeFlags: List<FlagKey<*>> by lazy {
listOf(
EmailVerification,
OnboardingFlow,
OnboardingCarousel,
)
}
}
/**
* Data object holding the key for Email Verification feature.
*/

View file

@ -0,0 +1,35 @@
package com.x8bit.bitwarden.data.platform.repository
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import kotlinx.coroutines.flow.Flow
/**
* Repository for accessing data required or associated with the debug menu.
*/
interface DebugMenuRepository {
/**
* Value to determine if the debug menu is enabled.
*/
val isDebugMenuEnabled: Boolean
/**
* Observable flow for when any of the feature flag overrides have been updated.
*/
val featureFlagOverridesUpdatedFlow: Flow<Unit>
/**
* Update a feature flag which matches the given [key] to the given [value].
*/
fun <T : Any> updateFeatureFlag(key: FlagKey<T>, value: T)
/**
* Get a feature flag value based on the associated [FlagKey].
*/
fun <T : Any> getFeatureFlag(key: FlagKey<T>): T?
/**
* Reset all feature flag overrides to their default values or values from the network.
*/
fun resetFeatureFlagOverrides()
}

View file

@ -0,0 +1,45 @@
package com.x8bit.bitwarden.data.platform.repository
import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.manager.getFlagValueOrDefault
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onSubscription
/**
* Default implementation of the [DebugMenuRepository]
*/
class DebugMenuRepositoryImpl(
private val featureFlagOverrideDiskSource: FeatureFlagOverrideDiskSource,
private val serverConfigRepository: ServerConfigRepository,
) : DebugMenuRepository {
private val mutableOverridesUpdatedFlow = bufferedMutableSharedFlow<Unit>(replay = 1)
override val featureFlagOverridesUpdatedFlow: Flow<Unit> = mutableOverridesUpdatedFlow
.onSubscription { emit(Unit) }
override val isDebugMenuEnabled: Boolean
get() = BuildConfig.HAS_DEBUG_MENU
override fun <T : Any> updateFeatureFlag(key: FlagKey<T>, value: T) {
featureFlagOverrideDiskSource.saveFeatureFlag(key = key, value = value)
mutableOverridesUpdatedFlow.tryEmit(Unit)
}
override fun <T : Any> getFeatureFlag(key: FlagKey<T>): T? =
featureFlagOverrideDiskSource.getFeatureFlag(
key = key,
)
override fun resetFeatureFlagOverrides() {
val currentServerConfig = serverConfigRepository.serverConfigStateFlow.value
FlagKey.activeFlags.forEach { flagKey ->
updateFeatureFlag(
flagKey,
currentServerConfig.getFlagValueOrDefault(flagKey),
)
}
}
}

View file

@ -5,11 +5,14 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.datasource.network.service.ConfigService
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepositoryImpl
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepositoryImpl
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
@ -81,4 +84,14 @@ object PlatformRepositoryModule {
dispatcherManager = dispatcherManager,
policyManager = policyManager,
)
@Provides
@Singleton
fun provideDebugMenuRepository(
featureFlagOverrideDiskSource: FeatureFlagOverrideDiskSource,
serverConfigRepository: ServerConfigRepository,
): DebugMenuRepository = DebugMenuRepositoryImpl(
featureFlagOverrideDiskSource = featureFlagOverrideDiskSource,
serverConfigRepository = serverConfigRepository,
)
}

View file

@ -0,0 +1,29 @@
package com.x8bit.bitwarden.ui.platform.feature.debugmenu
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import com.x8bit.bitwarden.ui.platform.base.util.composableWithPushTransitions
private const val DEBUG_MENU = "debug_menu"
/**
* Navigate to the setup unlock screen.
*/
fun NavController.navigateToDebugMenuScreen() {
this.navigate(DEBUG_MENU) {
launchSingleTop = true
}
}
/**
* Add the setup unlock screen to the nav graph.
*/
fun NavGraphBuilder.setupDebugMenuDestination(
onNavigateBack: () -> Unit,
) {
composableWithPushTransitions(
route = DEBUG_MENU,
) {
DebugMenuScreen(onNavigateBack = onNavigateBack)
}
}

View file

@ -0,0 +1,135 @@
package com.x8bit.bitwarden.ui.platform.feature.debugmenu
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.components.ListItemContent
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Top level screen for the debug menu.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DebugMenuScreen(
onNavigateBack: () -> Unit,
viewModel: DebugMenuViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
DebugMenuEvent.NavigateBack -> onNavigateBack()
}
}
BitwardenScaffold(
topBar = {
BitwardenTopAppBar(
title = stringResource(R.string.debug_menu),
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
navigationIcon = NavigationIcon(
navigationIcon = rememberVectorPainter(R.drawable.ic_back),
navigationIconContentDescription = stringResource(id = R.string.back),
onNavigationIconClick = remember(viewModel) {
{
viewModel.trySendAction(DebugMenuAction.NavigateBack)
}
},
),
)
},
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding),
) {
Spacer(modifier = Modifier.height(16.dp))
FeatureFlagContent(
featureFlagMap = state.featureFlags,
onValueChange = { key, value ->
viewModel.trySendAction(DebugMenuAction.UpdateFeatureFlag(key, value))
},
onResetValues = {
viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues)
},
)
}
}
}
@Composable
private fun FeatureFlagContent(
featureFlagMap: Map<FlagKey<Any>, Any>,
onValueChange: (key: FlagKey<Any>, value: Any) -> Unit,
onResetValues: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
) {
Spacer(modifier = Modifier.height(8.dp))
BitwardenListHeaderText(
label = stringResource(R.string.feature_flags),
modifier = Modifier.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
featureFlagMap.forEach { featureFlag ->
featureFlag.key.ListItemContent(
currentValue = featureFlag.value,
onValueChange = onValueChange,
modifier = Modifier.standardHorizontalMargin(),
)
HorizontalDivider()
}
Spacer(modifier = Modifier.height(16.dp))
BitwardenFilledButton(
label = stringResource(R.string.reset_values),
onClick = onResetValues,
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
}
}
@Preview(showBackground = true)
@Composable
private fun FeatureFlagContent_preview() {
BitwardenTheme {
FeatureFlagContent(
featureFlagMap = mapOf(
FlagKey.EmailVerification to true,
FlagKey.OnboardingCarousel to true,
FlagKey.OnboardingFlow to false,
),
onValueChange = { _, _ -> },
onResetValues = { },
)
}
}

View file

@ -0,0 +1,126 @@
package com.x8bit.bitwarden.ui.platform.feature.debugmenu
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* ViewModel for the [DebugMenuScreen]
*/
@HiltViewModel
class DebugMenuViewModel @Inject constructor(
featureFlagManager: FeatureFlagManager,
private val debugMenuRepository: DebugMenuRepository,
) : BaseViewModel<DebugMenuState, DebugMenuEvent, DebugMenuAction>(
initialState = DebugMenuState(featureFlags = emptyMap()),
) {
private var featureFlagResetJob: Job? = null
init {
combine(
featureFlagManager.getFeatureFlagFlow(FlagKey.EmailVerification),
featureFlagManager.getFeatureFlagFlow(FlagKey.OnboardingCarousel),
featureFlagManager.getFeatureFlagFlow(FlagKey.OnboardingFlow),
) { (emailVerification, onboardingCarousel, onboardingFlow) ->
sendAction(
DebugMenuAction.Internal.UpdateFeatureFlagMap(
mapOf(
FlagKey.EmailVerification to emailVerification,
FlagKey.OnboardingCarousel to onboardingCarousel,
FlagKey.OnboardingFlow to onboardingFlow,
),
),
)
}
.launchIn(viewModelScope)
}
override fun handleAction(action: DebugMenuAction) {
when (action) {
is DebugMenuAction.UpdateFeatureFlag<*> -> handleUpdateFeatureFlag(action)
is DebugMenuAction.Internal.UpdateFeatureFlagMap -> handleUpdateFeatureFlagMap(action)
DebugMenuAction.NavigateBack -> handleNavigateBack()
DebugMenuAction.ResetFeatureFlagValues -> handleResetFeatureFlagValues()
}
}
private fun handleResetFeatureFlagValues() {
featureFlagResetJob?.cancel()
featureFlagResetJob = viewModelScope.launch {
debugMenuRepository.resetFeatureFlagOverrides()
}
}
private fun handleNavigateBack() {
sendEvent(DebugMenuEvent.NavigateBack)
}
private fun handleUpdateFeatureFlagMap(action: DebugMenuAction.Internal.UpdateFeatureFlagMap) {
mutableStateFlow.update {
it.copy(featureFlags = action.newMap)
}
}
private fun handleUpdateFeatureFlag(action: DebugMenuAction.UpdateFeatureFlag<*>) {
debugMenuRepository.updateFeatureFlag(action.flagKey, action.newValue)
}
}
/**
* State for the [DebugMenuViewModel]
*/
data class DebugMenuState(
val featureFlags: Map<FlagKey<Any>, Any>,
)
/**
* Models event for the [DebugMenuViewModel] to send to the UI.
*/
sealed class DebugMenuEvent {
/**
* Navigates back to previous screen.
*/
data object NavigateBack : DebugMenuEvent()
}
/**
* Models action for the [DebugMenuViewModel] to handle.
*/
sealed class DebugMenuAction {
/**
* Updates a feature flag for the given [FlagKey] to the given [newValue].
*/
data class UpdateFeatureFlag<T : Any>(val flagKey: FlagKey<T>, val newValue: T) :
DebugMenuAction()
/**
* The user has clicked "back" button.
*/
data object NavigateBack : DebugMenuAction()
/**
* The user has clicked "reset" button.
*/
data object ResetFeatureFlagValues : DebugMenuAction()
/**
* Internal actions not triggered from the UI.
*/
sealed class Internal : DebugMenuAction() {
/**
* Update the feature flag map with the new value.
*/
data class UpdateFeatureFlagMap(val newMap: Map<FlagKey<Any>, Any>) : Internal()
}
}

View file

@ -0,0 +1,68 @@
package com.x8bit.bitwarden.ui.platform.feature.debugmenu.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenWideSwitch
/**
* Creates a list item for a [FlagKey].
*/
@Suppress("UNCHECKED_CAST")
@Composable
fun <T : Any> FlagKey<T>.ListItemContent(
currentValue: T,
onValueChange: (key: FlagKey<T>, value: T) -> Unit,
modifier: Modifier = Modifier,
) = when (val flagKey = this) {
FlagKey.DummyBoolean,
is FlagKey.DummyInt,
FlagKey.DummyString,
-> Unit
FlagKey.EmailVerification,
FlagKey.OnboardingCarousel,
FlagKey.OnboardingFlow,
-> BooleanFlagItem(
label = flagKey.getDisplayLabel(),
key = flagKey as FlagKey<Boolean>,
currentValue = currentValue as Boolean,
onValueChange = onValueChange as (FlagKey<Boolean>, Boolean) -> Unit,
modifier = modifier,
)
}
/**
* The UI layout for a boolean backed flag key.
*/
@Composable
private fun BooleanFlagItem(
label: String,
key: FlagKey<Boolean>,
currentValue: Boolean,
onValueChange: (key: FlagKey<Boolean>, value: Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
BitwardenWideSwitch(
label = label,
isChecked = currentValue,
onCheckedChange = {
onValueChange(key, it)
},
modifier = modifier,
)
}
@Composable
private fun <T : Any> FlagKey<T>.getDisplayLabel(): String = when (this) {
FlagKey.DummyBoolean,
is FlagKey.DummyInt,
FlagKey.DummyString,
-> this.keyName
FlagKey.EmailVerification -> stringResource(R.string.email_verification)
FlagKey.OnboardingCarousel -> stringResource(R.string.onboarding_carousel)
FlagKey.OnboardingFlow -> stringResource(R.string.onboarding_flow)
}

View file

@ -0,0 +1,22 @@
package com.x8bit.bitwarden.ui.platform.feature.debugmenu.di
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugLaunchManagerImpl
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
/**
* Provides dependencies for the debug menu.
*/
@Module
@InstallIn(SingletonComponent::class)
class DebugMenuModule {
@Provides
fun provideDebugMenuLaunchManager(
debugMenuRepository: DebugMenuRepository,
): DebugMenuLaunchManager = DebugLaunchManagerImpl(debugMenuRepository = debugMenuRepository)
}

View file

@ -0,0 +1,67 @@
package com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager
import android.view.InputEvent
import android.view.KeyEvent
import android.view.MotionEvent
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
private const val TAP_TIME_THRESHOLD_MILLIS = 500
private const val POINTERS_REQUIRED = 3
/**
* Default implementation of the [DebugMenuLaunchManager]
*/
class DebugLaunchManagerImpl(
private val debugMenuRepository: DebugMenuRepository,
) : DebugMenuLaunchManager {
private val tapEventQueue: ArrayDeque<Long> = ArrayDeque()
override fun actionOnInputEvent(
event: InputEvent,
action: () -> Unit,
): Boolean {
val shouldTakeAction = when (event) {
is KeyEvent -> event.debugTrigger()
is MotionEvent -> shouldHandleMotionEvent(event)
else -> false
}
if (shouldTakeAction) {
action()
}
return shouldTakeAction
}
private fun shouldHandleMotionEvent(event: MotionEvent): Boolean {
if (!event.debugTrigger()) return false
// Pop old tap events until we have ones within our threshold
while (
tapEventQueue
.firstOrNull()
?.let { event.eventTime - it >= TAP_TIME_THRESHOLD_MILLIS } == true
) {
tapEventQueue.removeFirst()
}
// Add this tap event
tapEventQueue.add(event.eventTime)
return event.eventTime - tapEventQueue.first() < TAP_TIME_THRESHOLD_MILLIS &&
tapEventQueue.size >= POINTERS_REQUIRED
}
/**
* This is the equivalent of the entry of `shift` + `~` on a US keyboard.
*/
private fun KeyEvent.debugTrigger(): Boolean =
action == KeyEvent.ACTION_DOWN &&
keyCode == KeyEvent.KEYCODE_GRAVE &&
isShiftPressed &&
debugMenuRepository.isDebugMenuEnabled
private fun MotionEvent.debugTrigger(): Boolean =
action and MotionEvent.ACTION_MASK == MotionEvent.ACTION_POINTER_DOWN &&
pointerCount == POINTERS_REQUIRED &&
debugMenuRepository.isDebugMenuEnabled
}

View file

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager
import android.view.InputEvent
/**
* Manager for abstracting the logic of launching debug menu.
*/
interface DebugMenuLaunchManager {
/**
* Defines an interface to action on specific input events.
* @param event the input event to evaluate
* @param action the action to perform if the event matches
*
* @return true if the action was performed, false otherwise.
*/
fun actionOnInputEvent(event: InputEvent, action: () -> Unit): Boolean
}

View file

@ -34,6 +34,7 @@ import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.VAULT_UNLOCK_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.navigateToVaultUnlock
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.vaultUnlockDestination
import com.x8bit.bitwarden.ui.auth.feature.welcome.navigateToWelcome
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.setupDebugMenuDestination
import com.x8bit.bitwarden.ui.platform.feature.rootnav.util.toVaultItemListingType
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.navigateToLoginApproval
import com.x8bit.bitwarden.ui.platform.feature.splash.SPLASH_ROUTE
@ -87,6 +88,7 @@ fun RootNavScreen(
trustedDeviceGraph(navController)
vaultUnlockDestination()
vaultUnlockedGraph(navController)
setupDebugMenuDestination(onNavigateBack = { navController.popBackStack() })
}
val targetRoute = when (state) {

View file

@ -6,4 +6,13 @@
<string name="duo_org_title" translatable="false">Duo (%1$s)</string>
<string name="json_extension" translatable="false">.json</string>
<string name="json_extension_formatted" translatable="false">.json (%1$s)</string>
<!-- Debug Menu -->
<string name="email_verification">Email Verification</string>
<string name="onboarding_carousel">Onboarding Carousel</string>
<string name="onboarding_flow">Onboarding Flow</string>
<string name="feature_flags">Feature Flags:</string>
<string name="debug_menu">Debug Menu</string>
<string name="reset_values">Reset values</string>
<!-- /Debug Menu -->
</resources>

View file

@ -746,6 +746,16 @@ class MainViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `send NavigateToDebugMenu action when OpenDebugMenu action is sent`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(MainAction.OpenDebugMenu)
viewModel.eventFlow.test {
assertEquals(MainEvent.NavigateToDebugMenu, awaitItem())
}
}
private fun createViewModel(
initialSpecialCircumstance: SpecialCircumstance? = null,
) = MainViewModel(

View file

@ -0,0 +1,117 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import androidx.core.content.edit
import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class FeatureFlagOverrideDiskSourceTest {
private val fakeSharedPreferences = FakeSharedPreferences()
private val featureFlagOverrideDiskSource = FeatureFlagOverrideDiskSourceImpl(
sharedPreferences = fakeSharedPreferences,
)
@Test
fun `call to save feature flag should update SharedPreferences for booleans`() {
val key = FlagKey.DummyBoolean
assertFalse(
fakeSharedPreferences.getBoolean(
"$BASE_STORAGE_PREFIX${key.keyName}",
false,
),
)
val value = true
featureFlagOverrideDiskSource.saveFeatureFlag(key, value)
assertTrue(
fakeSharedPreferences.getBoolean(
"$BASE_STORAGE_PREFIX${key.keyName}",
false,
),
)
}
@Test
fun `call to get feature flag should return correct value for booleans`() {
val key = FlagKey.DummyBoolean
fakeSharedPreferences.edit {
putBoolean("$BASE_STORAGE_PREFIX${key.keyName}", true)
}
val actual = featureFlagOverrideDiskSource.getFeatureFlag(key)
assertTrue(actual!!)
}
@Test
fun `call to save feature flag should update SharedPreferences for strings`() {
val key = FlagKey.DummyString
assertNull(
fakeSharedPreferences.getString(
"$BASE_STORAGE_PREFIX${key.keyName}",
null,
),
)
val expectedValue = "string"
featureFlagOverrideDiskSource.saveFeatureFlag(key, expectedValue)
assertEquals(
fakeSharedPreferences.getString(
"$BASE_STORAGE_PREFIX${key.keyName}",
null,
),
expectedValue,
)
}
@Test
fun `call to get feature flag should return correct value for strings`() {
val key = FlagKey.DummyString
assertNull(featureFlagOverrideDiskSource.getFeatureFlag(key))
val expectedValue = "string"
fakeSharedPreferences.edit {
putString("$BASE_STORAGE_PREFIX${key.keyName}", expectedValue)
}
val actual = featureFlagOverrideDiskSource.getFeatureFlag(key)
assertEquals(actual, expectedValue)
}
@Test
fun `call to save feature flag should update SharedPreferences for ints`() {
val key = FlagKey.DummyInt()
assertEquals(
fakeSharedPreferences.getInt(
"$BASE_STORAGE_PREFIX${key.keyName}",
0,
),
0,
)
val expectedValue = 1
featureFlagOverrideDiskSource.saveFeatureFlag(key, expectedValue)
assertEquals(
fakeSharedPreferences.getInt(
"$BASE_STORAGE_PREFIX${key.keyName}",
0,
),
expectedValue,
)
}
@Test
fun `call to get feature flag should return correct value for ints`() {
val key = FlagKey.DummyInt()
assertNull(featureFlagOverrideDiskSource.getFeatureFlag(key))
val expectedValue = 1
fakeSharedPreferences.edit {
putInt("$BASE_STORAGE_PREFIX${key.keyName}", expectedValue)
}
val actual = featureFlagOverrideDiskSource.getFeatureFlag(key)
assertEquals(actual, expectedValue)
}
}
private const val BASE_STORAGE_PREFIX = "bwPreferencesStorage:"

View file

@ -0,0 +1,112 @@
package com.x8bit.bitwarden.data.platform.manager
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class DebugMenuFeatureFlagManagerTest {
private val mockFeatureFlagManager = mockk<FeatureFlagManager>(relaxed = true) {
every { getFeatureFlag<Boolean>(any()) } returns true
}
private val mutableOverridesUpdateFlow = bufferedMutableSharedFlow<Unit>()
private val mockDebugMenuRepository = mockk<DebugMenuRepository>(relaxed = true) {
every { updateFeatureFlag(any(), any()) } just runs
every { featureFlagOverridesUpdatedFlow } returns mutableOverridesUpdateFlow
}
private val debugMenuFeatureFlagManager = DebugMenuFeatureFlagManagerImpl(
defaultFeatureFlagManager = mockFeatureFlagManager,
debugMenuRepository = mockDebugMenuRepository,
)
@Test
fun `If value exists in repository return that value for requested FlagKey`() {
val flagKey = FlagKey.DummyBoolean
val expectedValue = true
every { mockDebugMenuRepository.getFeatureFlag(flagKey) } returns expectedValue
assertTrue(debugMenuFeatureFlagManager.getFeatureFlag(flagKey))
verify(exactly = 0) { mockFeatureFlagManager.getFeatureFlag(flagKey) }
}
@Test
fun `If value does not exist in repository return that value from the default manager`() {
val flagKey = FlagKey.DummyBoolean
every { mockDebugMenuRepository.getFeatureFlag(flagKey) } returns null
assertTrue(debugMenuFeatureFlagManager.getFeatureFlag(flagKey))
verify(exactly = 1) { mockFeatureFlagManager.getFeatureFlag(flagKey) }
}
@Suppress("MaxLineLength")
@Test
fun `get feature flag with force refresh will call the default manager to use as the fallback value`() =
runTest {
val flagKey = FlagKey.DummyBoolean
val expectedValue = true
coEvery {
mockFeatureFlagManager.getFeatureFlag(key = flagKey, forceRefresh = true)
} returns expectedValue
every { mockDebugMenuRepository.getFeatureFlag(flagKey) } returns null
assertTrue(
debugMenuFeatureFlagManager.getFeatureFlag(
key = flagKey,
forceRefresh = true,
),
)
coVerify(exactly = 1) {
mockFeatureFlagManager.getFeatureFlag(
key = flagKey,
forceRefresh = true,
)
}
}
@Test
fun `when repository update flow emits, the feature flag flow will refresh to the value`() =
runTest {
val flagKey = FlagKey.DummyBoolean
every { mockDebugMenuRepository.getFeatureFlag(flagKey) } returns true
debugMenuFeatureFlagManager.getFeatureFlagFlow(flagKey).test {
mutableOverridesUpdateFlow.emit(Unit)
assertEquals(true, awaitItem())
cancel()
}
}
@Suppress("MaxLineLength")
@Test
fun `when repository update flow emits the flow will refresh to the value from default manager if repo returns null`() =
runTest {
val flagKey = FlagKey.DummyBoolean
every { mockDebugMenuRepository.getFeatureFlag(flagKey) } returns null
debugMenuFeatureFlagManager.getFeatureFlagFlow(flagKey).test {
mutableOverridesUpdateFlow.emit(Unit)
assertEquals(true, awaitItem())
cancel()
}
verify(exactly = 1) { mockFeatureFlagManager.getFeatureFlag(flagKey) }
}
}

View file

@ -0,0 +1,151 @@
package com.x8bit.bitwarden.data.platform.repository
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponseJson
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonPrimitive
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class DebugMenuRepositoryTest {
private val mockFeatureFlagOverrideDiskSource = mockk<FeatureFlagOverrideDiskSource>() {
every { getFeatureFlag(FlagKey.DummyBoolean) } returns true
every { getFeatureFlag(FlagKey.DummyString) } returns TEST_STRING_VALUE
every { getFeatureFlag(FlagKey.DummyInt()) } returns TEST_INT_VALUE
every { saveFeatureFlag(any(), any()) } just runs
}
private val mutableServerConfigStateFlow = MutableStateFlow<ServerConfig?>(null)
private val mockServerConfigRepository = mockk<ServerConfigRepository>() {
every { serverConfigStateFlow } returns mutableServerConfigStateFlow
}
private val debugMenuRepository = DebugMenuRepositoryImpl(
featureFlagOverrideDiskSource = mockFeatureFlagOverrideDiskSource,
serverConfigRepository = mockServerConfigRepository,
)
@Test
fun `updateFeatureFlag should save the feature flag to disk`() {
debugMenuRepository.updateFeatureFlag(FlagKey.DummyBoolean, true)
verify(exactly = 1) {
mockFeatureFlagOverrideDiskSource.saveFeatureFlag(
FlagKey.DummyBoolean,
true,
)
}
}
@Test
fun `updateFeatureFlag should cause the feature flag overrides updated flow to emit`() =
runTest {
debugMenuRepository.updateFeatureFlag(FlagKey.DummyBoolean, true)
debugMenuRepository.featureFlagOverridesUpdatedFlow.test {
awaitItem() // initial value on subscription
awaitItem()
cancel()
}
}
@Test
fun `getFeatureFlag should return the feature flag boolean value from disk`() {
assertTrue(debugMenuRepository.getFeatureFlag(FlagKey.DummyBoolean)!!)
}
@Test
fun `getFeatureFlag should return the feature flag string value from disk`() {
assertEquals(TEST_STRING_VALUE, debugMenuRepository.getFeatureFlag(FlagKey.DummyString)!!)
}
@Test
fun `getFeatureFlag should return the feature flag int value from disk`() {
assertEquals(TEST_INT_VALUE, debugMenuRepository.getFeatureFlag(FlagKey.DummyInt())!!)
}
@Test
fun `getFeatureFlag should return null if the feature flag does not exist in disk`() {
every { mockFeatureFlagOverrideDiskSource.getFeatureFlag<Boolean>(any()) } returns null
assertNull(debugMenuRepository.getFeatureFlag(FlagKey.DummyBoolean))
}
@Suppress("MaxLineLength")
@Test
fun `resetFeatureFlagOverrides should reset flags to default values if they don't exist in server config`() =
runTest {
debugMenuRepository.resetFeatureFlagOverrides()
verify(exactly = 1) {
mockFeatureFlagOverrideDiskSource.saveFeatureFlag(
FlagKey.EmailVerification,
FlagKey.EmailVerification.defaultValue,
)
mockFeatureFlagOverrideDiskSource.saveFeatureFlag(
FlagKey.OnboardingCarousel,
FlagKey.OnboardingCarousel.defaultValue,
)
mockFeatureFlagOverrideDiskSource.saveFeatureFlag(
FlagKey.OnboardingFlow,
FlagKey.OnboardingFlow.defaultValue,
)
}
debugMenuRepository.featureFlagOverridesUpdatedFlow.test {
awaitItem() // initial value on subscription
awaitItem()
expectNoEvents()
}
}
@Suppress("MaxLineLength")
@Test
fun `resetFeatureFlagOverrides should save all feature flags to values from the server config if remote configured is on`() =
runTest {
val mockServerData = mockk<ConfigResponseJson>(relaxed = true) {
every { featureStates } returns mapOf(
FlagKey.EmailVerification.keyName to JsonPrimitive(true),
FlagKey.OnboardingCarousel.keyName to JsonPrimitive(false),
FlagKey.OnboardingFlow.keyName to JsonPrimitive(true),
)
}
val mockServerConfig = mockk<ServerConfig>(relaxed = true) {
every { serverData } returns mockServerData
}
mutableServerConfigStateFlow.value = mockServerConfig
debugMenuRepository.resetFeatureFlagOverrides()
assertTrue(FlagKey.EmailVerification.isRemotelyConfigured)
assertFalse(FlagKey.OnboardingCarousel.isRemotelyConfigured)
verify(exactly = 1) {
mockFeatureFlagOverrideDiskSource.saveFeatureFlag(FlagKey.EmailVerification, true)
mockFeatureFlagOverrideDiskSource.saveFeatureFlag(
FlagKey.OnboardingCarousel,
false,
)
mockFeatureFlagOverrideDiskSource.saveFeatureFlag(
FlagKey.OnboardingFlow,
false,
)
}
debugMenuRepository.featureFlagOverridesUpdatedFlow.test {
awaitItem() // initial value on subscription
awaitItem()
cancel()
}
unmockkStatic(FlagKey.OnboardingFlow::class)
}
}
private const val TEST_STRING_VALUE = "test"
private const val TEST_INT_VALUE = 100

View file

@ -0,0 +1,107 @@
package com.x8bit.bitwarden.ui.platform.feature.debugmenu
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.printToLog
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class DebugMenuScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private val mutableEventFlow = bufferedMutableSharedFlow<DebugMenuEvent>()
private val mutableStateFlow = MutableStateFlow(DebugMenuState(featureFlags = emptyMap()))
private val viewModel = mockk<DebugMenuViewModel>(relaxed = true) {
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
}
@Before
fun setup() {
composeTestRule.setContent {
DebugMenuScreen(
onNavigateBack = { onNavigateBackCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `onNavigateBack should set onNavigateBackCalled to true`() {
mutableEventFlow.tryEmit(DebugMenuEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `onNavigateBack should send action to viewModel`() {
composeTestRule.onRoot().printToLog("djf")
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
verify { viewModel.trySendAction(DebugMenuAction.NavigateBack) }
}
@Test
fun `feature flag content should not display if the state is empty`() {
composeTestRule
.onNodeWithText("Email Verification", ignoreCase = true)
.assertDoesNotExist()
}
@Test
fun `feature flag content should display if the state is not empty`() {
mutableStateFlow.tryEmit(
DebugMenuState(
featureFlags = mapOf(
FlagKey.EmailVerification to true,
),
),
)
composeTestRule
.onNodeWithText("Email Verification", ignoreCase = true)
.assertExists()
}
@Test
fun `boolean feature flag content should send action when clicked`() {
mutableStateFlow.tryEmit(
DebugMenuState(
featureFlags = mapOf(
FlagKey.EmailVerification to true,
),
),
)
composeTestRule
.onNodeWithText("Email Verification", ignoreCase = true)
.performClick()
verify {
viewModel.trySendAction(
DebugMenuAction.UpdateFeatureFlag(
FlagKey.EmailVerification,
false,
),
)
}
}
@Test
fun `reset feature flag values should send action when clicked`() {
composeTestRule
.onNodeWithText("Reset Values", ignoreCase = true)
.performClick()
verify { viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues) }
}
}

View file

@ -0,0 +1,92 @@
package com.x8bit.bitwarden.ui.platform.feature.debugmenu
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class DebugMenuViewModelTest : BaseViewModelTest() {
private val mockFeatureFlagManager = mockk<FeatureFlagManager>(relaxed = true) {
every { getFeatureFlagFlow<Boolean>(any()) } returns flowOf(true)
}
private val mockDebugMenuRepository = mockk<DebugMenuRepository>(relaxed = true) {
coEvery { resetFeatureFlagOverrides() } just runs
every { updateFeatureFlag<Boolean>(any(), any()) } just runs
}
@Test
fun `initial state should be correct`() {
val viewModel = createViewModel()
assertEquals(viewModel.stateFlow.value, DEFAULT_STATE)
}
@Test
fun `handleUpdateFeatureFlag should update the feature flag`() {
val viewModel = createViewModel()
assertEquals(viewModel.stateFlow.value, DEFAULT_STATE)
viewModel.trySendAction(
DebugMenuAction.Internal.UpdateFeatureFlagMap(UPDATED_MAP_VALUE),
)
assertEquals(viewModel.stateFlow.value, DebugMenuState(UPDATED_MAP_VALUE))
}
@Test
fun `handleResetFeatureFlagValues should reset the feature flag values`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues)
coVerify(exactly = 1) { mockDebugMenuRepository.resetFeatureFlagOverrides() }
}
@Test
fun `handleNavigateBack should send NavigateBack event`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(DebugMenuAction.NavigateBack)
viewModel.eventFlow.test {
assertEquals(DebugMenuEvent.NavigateBack, awaitItem())
}
}
@Test
fun `handleUpdateFeatureFlag should update the feature flag via the repository`() {
val viewModel = createViewModel()
viewModel.trySendAction(
DebugMenuAction.UpdateFeatureFlag(FlagKey.EmailVerification, false),
)
verify { mockDebugMenuRepository.updateFeatureFlag(FlagKey.EmailVerification, false) }
}
private fun createViewModel(): DebugMenuViewModel = DebugMenuViewModel(
featureFlagManager = mockFeatureFlagManager,
debugMenuRepository = mockDebugMenuRepository,
)
}
private val DEFAULT_MAP_VALUE: Map<FlagKey<Any>, Any> = mapOf(
FlagKey.EmailVerification to true,
FlagKey.OnboardingCarousel to true,
FlagKey.OnboardingFlow to true,
)
private val UPDATED_MAP_VALUE: Map<FlagKey<Any>, Any> = mapOf(
FlagKey.EmailVerification to false,
FlagKey.OnboardingCarousel to true,
FlagKey.OnboardingFlow to false,
)
private val DEFAULT_STATE = DebugMenuState(
featureFlags = DEFAULT_MAP_VALUE,
)

View file

@ -0,0 +1,109 @@
package com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager
import android.view.KeyEvent
import android.view.MotionEvent
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class DebugLaunchManagerTest {
private val mockDebugMenuRepository = mockk<DebugMenuRepository>(relaxed = true) {
every { isDebugMenuEnabled } returns true
}
private val mockKeyEvent = mockk<KeyEvent>(relaxed = true) {
every { action } returns KeyEvent.ACTION_DOWN
every { keyCode } returns KeyEvent.KEYCODE_GRAVE
every { isShiftPressed } returns true
}
private val mockMotionEvent = mockk<MotionEvent>(relaxed = true) {
every { action and MotionEvent.ACTION_MASK } returns MotionEvent.ACTION_POINTER_DOWN
every { pointerCount } returns 3
}
private var actionHasBeenCalled = false
private val action: () -> Unit = { actionHasBeenCalled = true }
private val debugLaunchManager =
DebugLaunchManagerImpl(debugMenuRepository = mockDebugMenuRepository)
@Test
fun `actionOnInputEvent should return true when KeyEvent is debug trigger`() {
assertFalse(actionHasBeenCalled)
val result = debugLaunchManager.actionOnInputEvent(event = mockKeyEvent, action = action)
assertTrue(result)
assertTrue(actionHasBeenCalled)
}
@Suppress("MaxLineLength")
@Test
fun `actionOnInputEvent should return true when TouchEvent is debug trigger done 3 times in a row`() {
assertFalse(actionHasBeenCalled)
debugLaunchManager.actionOnInputEvent(event = mockMotionEvent, action = action)
debugLaunchManager.actionOnInputEvent(event = mockMotionEvent, action = action)
val result = debugLaunchManager.actionOnInputEvent(event = mockMotionEvent, action = action)
assertTrue(result)
assertTrue(actionHasBeenCalled)
}
@Test
fun `actionOnInputEvent should return false when debug menu is not enabled`() {
every { mockDebugMenuRepository.isDebugMenuEnabled } returns false
assertFalse(actionHasBeenCalled)
val result = debugLaunchManager.actionOnInputEvent(event = mockKeyEvent, action = action)
assertFalse(result)
assertFalse(actionHasBeenCalled)
}
@Test
fun `actionOnInputEvent should return false when key event is not debug trigger`() {
assertFalse(actionHasBeenCalled)
val result = debugLaunchManager
.actionOnInputEvent(
event = mockKeyEvent.apply {
every { action } returns KeyEvent.ACTION_UP
},
action = action,
)
assertFalse(result)
assertFalse(actionHasBeenCalled)
}
@Test
fun `actionOnInputEvent should return false when touch event is not debug trigger`() {
assertFalse(actionHasBeenCalled)
debugLaunchManager.actionOnInputEvent(event = mockMotionEvent, action = action)
debugLaunchManager.actionOnInputEvent(event = mockMotionEvent, action = action)
val result = debugLaunchManager.actionOnInputEvent(
event = mockMotionEvent.apply {
every { pointerCount } returns 100
},
action = action,
)
assertFalse(result)
assertFalse(actionHasBeenCalled)
}
@Test
fun `if touch action input takes place too slow should return false`() {
val eventTimeMillis = 100L
assertFalse(actionHasBeenCalled)
debugLaunchManager.actionOnInputEvent(event = mockMotionEvent, action = action)
debugLaunchManager.actionOnInputEvent(event = mockMotionEvent.apply {
every { eventTime } returns eventTimeMillis
}, action = action)
val result = debugLaunchManager.actionOnInputEvent(
event = mockMotionEvent.apply {
every { eventTime } returns eventTimeMillis + 501
},
action = action,
)
assertFalse(result)
assertFalse(actionHasBeenCalled)
}
}