mirror of
https://github.com/bitwarden/android.git
synced 2024-11-21 17:05:44 +03:00
PM-11224 Add menu to update feature flags with overridden values in real time (#3838)
This commit is contained in:
parent
2a057bb1fb
commit
3c39d8beac
29 changed files with 1451 additions and 17 deletions
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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?
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 = { },
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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:"
|
|
@ -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) }
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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) }
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue