[BWA-86] Debug Menu #4 (#276)

This commit is contained in:
André Bispo
2024-11-18 22:39:03 +00:00
committed by GitHub
parent 5f109b5085
commit f3f51cf244
26 changed files with 1358 additions and 7 deletions

View File

@@ -63,6 +63,7 @@ android {
signingConfig = signingConfigs.getByName("debug")
isDebuggable = true
isMinifyEnabled = false
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "true")
}
release {
@@ -77,6 +78,7 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false")
}
}
compileOptions {

View File

@@ -2,6 +2,8 @@ package com.bitwarden.authenticator
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
@@ -10,12 +12,17 @@ import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.bitwarden.authenticator.data.platform.util.isSuspicious
import com.bitwarden.authenticator.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager
import com.bitwarden.authenticator.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
import com.bitwarden.authenticator.ui.platform.feature.rootnav.RootNavScreen
import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
/**
* Primary entry point for the application.
@@ -25,14 +32,15 @@ class MainActivity : AppCompatActivity() {
private val mainViewModel: MainViewModel by viewModels()
@Inject
lateinit var debugLaunchManager: DebugMenuLaunchManager
override fun onCreate(savedInstanceState: Bundle?) {
sanitizeIntent()
var shouldShowSplashScreen = true
installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen }
super.onCreate(savedInstanceState)
observeViewModelEvents()
if (savedInstanceState == null) {
mainViewModel.trySendAction(
MainAction.ReceiveFirstIntent(
@@ -43,11 +51,13 @@ class MainActivity : AppCompatActivity() {
setContent {
val state by mainViewModel.stateFlow.collectAsStateWithLifecycle()
val navController = rememberNavController()
observeViewModelEvents(navController)
AuthenticatorTheme(
theme = state.theme,
) {
RootNavScreen(
navController = navController,
onSplashScreenRemoved = { shouldShowSplashScreen = false },
onExitApplication = { finishAffinity() },
)
@@ -72,7 +82,7 @@ class MainActivity : AppCompatActivity() {
}
}
private fun observeViewModelEvents() {
private fun observeViewModelEvents(navController: NavHostController) {
mainViewModel
.eventFlow
.onEach { event ->
@@ -80,11 +90,27 @@ class MainActivity : AppCompatActivity() {
is MainEvent.ScreenCaptureSettingChange -> {
handleScreenCaptureSettingChange(event)
}
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
}
}
.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 handleScreenCaptureSettingChange(event: MainEvent.ScreenCaptureSettingChange) {
if (event.isAllowed) {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)

View File

@@ -3,6 +3,7 @@ package com.bitwarden.authenticator
import android.content.Intent
import android.os.Parcelable
import androidx.lifecycle.viewModelScope
import com.bitwarden.authenticator.data.platform.repository.ServerConfigRepository
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticator.ui.platform.base.BaseViewModel
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme
@@ -10,6 +11,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@@ -19,6 +21,7 @@ import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
settingsRepository: SettingsRepository,
configRepository: ServerConfigRepository,
) : BaseViewModel<MainState, MainEvent, MainAction>(
MainState(
theme = settingsRepository.appTheme,
@@ -37,6 +40,9 @@ class MainViewModel @Inject constructor(
sendEvent(MainEvent.ScreenCaptureSettingChange(isAllowed))
}
.launchIn(viewModelScope)
viewModelScope.launch {
configRepository.getServerConfig(forceRefresh = false)
}
}
override fun handleAction(action: MainAction) {
@@ -44,9 +50,14 @@ class MainViewModel @Inject constructor(
is MainAction.Internal.ThemeUpdate -> handleThemeUpdated(action)
is MainAction.ReceiveFirstIntent -> handleFirstIntentReceived(action)
is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action)
MainAction.OpenDebugMenu -> handleOpenDebugMenu()
}
}
private fun handleOpenDebugMenu() {
sendEvent(MainEvent.NavigateToDebugMenu)
}
private fun handleThemeUpdated(action: MainAction.Internal.ThemeUpdate) {
mutableStateFlow.update { it.copy(theme = action.theme) }
}
@@ -95,6 +106,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.
*/
@@ -114,6 +130,11 @@ sealed class MainAction {
*/
sealed class MainEvent {
/**
* Navigate to the debug menu.
*/
data object NavigateToDebugMenu : MainEvent()
/**
* Event indicating a change in the screen capture setting.
*/

View File

@@ -0,0 +1,37 @@
package com.bitwarden.authenticator.data.platform.manager
import com.bitwarden.authenticator.data.platform.manager.model.FlagKey
import com.bitwarden.authenticator.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

@@ -9,6 +9,7 @@ import com.bitwarden.authenticator.data.platform.manager.BitwardenEncodingManage
import com.bitwarden.authenticator.data.platform.manager.BitwardenEncodingManagerImpl
import com.bitwarden.authenticator.data.platform.manager.CrashLogsManager
import com.bitwarden.authenticator.data.platform.manager.CrashLogsManagerImpl
import com.bitwarden.authenticator.data.platform.manager.DebugMenuFeatureFlagManagerImpl
import com.bitwarden.authenticator.data.platform.manager.DispatcherManager
import com.bitwarden.authenticator.data.platform.manager.DispatcherManagerImpl
import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager
@@ -19,6 +20,7 @@ import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClip
import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManagerImpl
import com.bitwarden.authenticator.data.platform.manager.imports.ImportManager
import com.bitwarden.authenticator.data.platform.manager.imports.ImportManagerImpl
import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository
import com.bitwarden.authenticator.data.platform.repository.ServerConfigRepository
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import dagger.Module
@@ -79,7 +81,19 @@ object PlatformManagerModule {
@Provides
@Singleton
fun provideFeatureFlagManager(
fun providesFeatureFlagManager(
debugMenuRepository: DebugMenuRepository,
serverConfigRepository: ServerConfigRepository,
): FeatureFlagManager = FeatureFlagManagerImpl(serverConfigRepository)
): FeatureFlagManager = if (debugMenuRepository.isDebugMenuEnabled) {
DebugMenuFeatureFlagManagerImpl(
debugMenuRepository = debugMenuRepository,
defaultFeatureFlagManager = FeatureFlagManagerImpl(
serverConfigRepository = serverConfigRepository,
),
)
} else {
FeatureFlagManagerImpl(
serverConfigRepository = serverConfigRepository,
)
}
}

View File

@@ -0,0 +1,35 @@
package com.bitwarden.authenticator.data.platform.repository
import com.bitwarden.authenticator.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.bitwarden.authenticator.data.platform.repository
import com.bitwarden.authenticator.BuildConfig
import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.bitwarden.authenticator.data.platform.manager.getFlagValueOrDefault
import com.bitwarden.authenticator.data.platform.manager.model.FlagKey
import com.bitwarden.authenticator.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

@@ -4,10 +4,13 @@ import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource
import com.bitwarden.authenticator.data.platform.datasource.disk.ConfigDiskSource
import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagDiskSource
import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
import com.bitwarden.authenticator.data.platform.datasource.network.service.ConfigService
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.authenticator.data.platform.manager.DispatcherManager
import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository
import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepositoryImpl
import com.bitwarden.authenticator.data.platform.repository.FeatureFlagRepository
import com.bitwarden.authenticator.data.platform.repository.FeatureFlagRepositoryImpl
import com.bitwarden.authenticator.data.platform.repository.ServerConfigRepository
@@ -70,4 +73,14 @@ object PlatformRepositoryModule {
featureFlagDiskSource = featureFlagDiskSource,
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun provideDebugMenuRepository(
featureFlagOverrideDiskSource: FeatureFlagOverrideDiskSource,
serverConfigRepository: ServerConfigRepository,
): DebugMenuRepository = DebugMenuRepositoryImpl(
featureFlagOverrideDiskSource = featureFlagOverrideDiskSource,
serverConfigRepository = serverConfigRepository,
)
}

View File

@@ -1,5 +1,6 @@
package com.bitwarden.authenticator.ui.platform.base.util
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DividerDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
@@ -8,10 +9,13 @@ import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.bitwarden.authenticator.data.platform.annotation.OmitFromCoverage
import com.bitwarden.authenticator.ui.platform.util.isPortrait
/**
* This is a [Modifier] extension for drawing a divider at the bottom of the composable.
@@ -57,3 +61,17 @@ fun Modifier.mirrorIfRtl(): Modifier =
} else {
this
}
/**
* This is a [Modifier] extension for ensuring that the content uses the standard horizontal margin.
*/
@OmitFromCoverage
@Stable
@Composable
fun Modifier.standardHorizontalMargin(
portrait: Dp = 16.dp,
landscape: Dp = 48.dp,
): Modifier {
val config = LocalConfiguration.current
return this.padding(horizontal = if (config.isPortrait) portrait else landscape)
}

View File

@@ -0,0 +1,30 @@
package com.bitwarden.authenticator.ui.platform.components.divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* A divider line.
*
* @param modifier The [Modifier] to be applied to this divider.
* @param thickness The thickness of this divider. Using [Dp.Hairline] will produce a single pixel
* divider regardless of screen density.
* @param color The color of this divider.
*/
@Composable
fun BitwardenHorizontalDivider(
modifier: Modifier = Modifier,
thickness: Dp = 1.dp,
color: Color = MaterialTheme.colorScheme.outline,
) {
HorizontalDivider(
modifier = modifier,
thickness = thickness,
color = color,
)
}

View File

@@ -0,0 +1,29 @@
package com.bitwarden.authenticator.ui.platform.feature.debugmenu
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import com.bitwarden.authenticator.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,148 @@
package com.bitwarden.authenticator.ui.platform.feature.debugmenu
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
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.input.nestedscroll.nestedScroll
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.bitwarden.authenticator.R
import com.bitwarden.authenticator.data.platform.manager.model.FlagKey
import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect
import com.bitwarden.authenticator.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.authenticator.ui.platform.components.appbar.NavigationIcon
import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.authenticator.ui.platform.components.divider.BitwardenHorizontalDivider
import com.bitwarden.authenticator.ui.platform.components.header.BitwardenListHeaderText
import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.authenticator.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.authenticator.ui.platform.feature.debugmenu.components.ListItemContent
import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme
/**
* Top level screen for the debug menu.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun DebugMenuScreen(
onNavigateBack: () -> Unit,
viewModel: DebugMenuViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
DebugMenuEvent.NavigateBack -> onNavigateBack()
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(R.string.debug_menu),
scrollBehavior = scrollBehavior,
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
.verticalScroll(rememberScrollState())
.padding(innerPadding),
) {
Spacer(modifier = Modifier.height(16.dp))
FeatureFlagContent(
featureFlagMap = state.featureFlags,
onValueChange = remember(viewModel) {
{ key, value ->
viewModel.trySendAction(DebugMenuAction.UpdateFeatureFlag(key, value))
}
},
onResetValues = remember(viewModel) {
{
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))
BitwardenHorizontalDivider()
featureFlagMap.forEach { featureFlag ->
featureFlag.key.ListItemContent(
currentValue = featureFlag.value,
onValueChange = onValueChange,
modifier = Modifier.standardHorizontalMargin(),
)
BitwardenHorizontalDivider()
}
Spacer(modifier = Modifier.height(12.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() {
AuthenticatorTheme {
FeatureFlagContent(
featureFlagMap = mapOf(
FlagKey.BitwardenAuthenticationEnabled to true,
FlagKey.PasswordManagerSync to false,
),
onValueChange = { _, _ -> },
onResetValues = { },
)
}
}

View File

@@ -0,0 +1,119 @@
package com.bitwarden.authenticator.ui.platform.feature.debugmenu
import androidx.lifecycle.viewModelScope
import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager
import com.bitwarden.authenticator.data.platform.manager.model.FlagKey
import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository
import com.bitwarden.authenticator.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.map
import kotlinx.coroutines.flow.onEach
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(
flows = FlagKey.activeFlags.map { flagKey ->
featureFlagManager.getFeatureFlagFlow(flagKey).map { flagKey to it }
},
) { DebugMenuAction.Internal.UpdateFeatureFlagMap(it.toMap()) }
.onEach(::sendAction)
.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 for the feature flag section.
*/
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.bitwarden.authenticator.ui.platform.feature.debugmenu.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.bitwarden.authenticator.R
import com.bitwarden.authenticator.data.platform.manager.model.FlagKey
import com.bitwarden.authenticator.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.BitwardenAuthenticationEnabled,
FlagKey.PasswordManagerSync,
-> 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.BitwardenAuthenticationEnabled ->
stringResource(R.string.bitwarden_authentication_enabled)
FlagKey.PasswordManagerSync -> stringResource(R.string.password_manager_sync)
}

View File

@@ -0,0 +1,22 @@
package com.bitwarden.authenticator.ui.platform.feature.debugmenu.di
import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository
import com.bitwarden.authenticator.ui.platform.feature.debugmenu.manager.DebugLaunchManagerImpl
import com.bitwarden.authenticator.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.bitwarden.authenticator.ui.platform.feature.debugmenu.manager
import android.view.InputEvent
import android.view.KeyEvent
import android.view.MotionEvent
import com.bitwarden.authenticator.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.bitwarden.authenticator.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

@@ -19,6 +19,7 @@ import com.bitwarden.authenticator.ui.auth.unlock.unlockDestination
import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.AUTHENTICATOR_GRAPH_ROUTE
import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.authenticatorGraph
import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.navigateToAuthenticatorGraph
import com.bitwarden.authenticator.ui.platform.feature.debugmenu.setupDebugMenuDestination
import com.bitwarden.authenticator.ui.platform.feature.splash.SPLASH_ROUTE
import com.bitwarden.authenticator.ui.platform.feature.splash.navigateToSplash
import com.bitwarden.authenticator.ui.platform.feature.splash.splashDestination
@@ -80,6 +81,11 @@ fun RootNavScreen(
viewModel.trySendAction(RootNavAction.Internal.AppUnlocked)
},
)
setupDebugMenuDestination(
onNavigateBack = {
navController.popBackStack()
},
)
authenticatorGraph(
navController = navController,
onNavigateBack = onExitApplication,

View File

@@ -0,0 +1,15 @@
@file:OmitFromCoverage
package com.bitwarden.authenticator.ui.platform.util
import android.content.res.Configuration
import com.bitwarden.authenticator.data.platform.annotation.OmitFromCoverage
/**
* A helper method to indicate if the current UI configuration is portrait or not.
*/
val Configuration.isPortrait: Boolean
get() = when (this.orientation) {
Configuration.ORIENTATION_LANDSCAPE -> false
else -> true
}

View File

@@ -6,4 +6,12 @@
<string name="import_format_label_2fas_json">2FAS (no password)</string>
<string name="import_format_label_lastpass_json">LastPass (.json)</string>
<string name="import_format_label_aegis_json">Aegis (.json)</string>
<!-- Debug Menu -->
<string name="feature_flags">Feature Flags:</string>
<string name="debug_menu">Debug Menu</string>
<string name="reset_values">Reset values</string>
<string name="bitwarden_authentication_enabled">Bitwarden authentication enabled</string>
<string name="password_manager_sync">Password manager sync</string>
</resources>

View File

@@ -1,12 +1,15 @@
package com.bitwarden.authenticator
import app.cash.turbine.test
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticator.data.platform.repository.util.FakeServerConfigRepository
import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -20,11 +23,15 @@ class MainViewModelTest : BaseViewModelTest() {
every { appThemeStateFlow } returns mutableAppThemeFlow
every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedFlow
}
private val fakeServerConfigRepository = FakeServerConfigRepository()
private lateinit var mainViewModel: MainViewModel
@BeforeEach
fun setUp() {
mainViewModel = MainViewModel(settingsRepository)
mainViewModel = MainViewModel(
settingsRepository,
fakeServerConfigRepository,
)
}
@Test
@@ -52,4 +59,14 @@ class MainViewModelTest : BaseViewModelTest() {
settingsRepository.appThemeStateFlow
}
}
@Test
fun `send NavigateToDebugMenu action when OpenDebugMenu action is sent`() = runTest {
mainViewModel.trySendAction(MainAction.OpenDebugMenu)
mainViewModel.eventFlow.test {
awaitItem() // ignore first event
assertEquals(MainEvent.NavigateToDebugMenu, awaitItem())
}
}
}

View File

@@ -0,0 +1,116 @@
package com.bitwarden.authenticator.data.platform.manager
import app.cash.turbine.test
import com.bitwarden.authenticator.data.platform.manager.model.FlagKey
import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository
import com.bitwarden.authenticator.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,167 @@
package com.bitwarden.authenticator.data.platform.repository
import app.cash.turbine.test
import com.bitwarden.authenticator.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.bitwarden.authenticator.data.platform.datasource.disk.model.ServerConfig
import com.bitwarden.authenticator.data.platform.datasource.network.model.ConfigResponseJson
import com.bitwarden.authenticator.data.platform.manager.model.FlagKey
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
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
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 io.mockk.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`() {
Assertions.assertTrue(debugMenuRepository.getFeatureFlag(FlagKey.DummyBoolean)!!)
}
@Test
fun `getFeatureFlag should return the feature flag string value from disk`() {
Assertions.assertEquals(
TEST_STRING_VALUE,
debugMenuRepository.getFeatureFlag(FlagKey.DummyString)!!,
)
}
@Test
fun `getFeatureFlag should return the feature flag int value from disk`() {
Assertions.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
Assertions.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.PasswordManagerSync,
FlagKey.PasswordManagerSync.defaultValue,
)
mockFeatureFlagOverrideDiskSource.saveFeatureFlag(
FlagKey.BitwardenAuthenticationEnabled,
FlagKey.BitwardenAuthenticationEnabled.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.PasswordManagerSync.keyName to JsonPrimitive(
true,
),
FlagKey.BitwardenAuthenticationEnabled.keyName to JsonPrimitive(
false,
),
)
}
val mockServerConfig =
mockk<ServerConfig>(
relaxed = true,
) {
every { serverData } returns mockServerData
}
mutableServerConfigStateFlow.value = mockServerConfig
debugMenuRepository.resetFeatureFlagOverrides()
Assertions.assertTrue(FlagKey.PasswordManagerSync.isRemotelyConfigured)
Assertions.assertFalse(FlagKey.BitwardenAuthenticationEnabled.isRemotelyConfigured)
verify(exactly = 1) {
mockFeatureFlagOverrideDiskSource.saveFeatureFlag(
FlagKey.PasswordManagerSync,
true,
)
mockFeatureFlagOverrideDiskSource.saveFeatureFlag(
FlagKey.BitwardenAuthenticationEnabled,
false,
)
}
debugMenuRepository.featureFlagOverridesUpdatedFlow.test {
awaitItem() // initial value on subscription
awaitItem()
cancel()
}
}
}
private const val TEST_STRING_VALUE = "test"
private const val TEST_INT_VALUE = 100

View File

@@ -0,0 +1,111 @@
package com.bitwarden.authenticator.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.performScrollTo
import androidx.compose.ui.test.printToLog
import com.bitwarden.authenticator.data.platform.manager.model.FlagKey
import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow
import com.bitwarden.authenticator.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("Password manager sync", ignoreCase = true)
.assertDoesNotExist()
}
@Test
fun `feature flag content should display if the state is not empty`() {
mutableStateFlow.tryEmit(
DebugMenuState(
featureFlags = mapOf(
FlagKey.PasswordManagerSync to true,
),
),
)
composeTestRule
.onNodeWithText("Password manager sync", ignoreCase = true)
.assertExists()
}
@Test
fun `boolean feature flag content should send action when clicked`() {
mutableStateFlow.tryEmit(
DebugMenuState(
featureFlags = mapOf(
FlagKey.PasswordManagerSync to true,
),
),
)
composeTestRule
.onNodeWithText("Password manager sync", ignoreCase = true)
.performClick()
verify {
viewModel.trySendAction(
DebugMenuAction.UpdateFeatureFlag(
FlagKey.PasswordManagerSync,
false,
),
)
}
}
@Test
fun `reset feature flag values should send action when clicked`() {
composeTestRule
.onNodeWithText("Reset Values", ignoreCase = true)
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues) }
}
}

View File

@@ -0,0 +1,90 @@
package com.bitwarden.authenticator.ui.platform.feature.debugmenu
import app.cash.turbine.test
import com.bitwarden.authenticator.data.platform.manager.FeatureFlagManager
import com.bitwarden.authenticator.data.platform.manager.model.FlagKey
import com.bitwarden.authenticator.data.platform.repository.DebugMenuRepository
import com.bitwarden.authenticator.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.PasswordManagerSync, false),
)
verify { mockDebugMenuRepository.updateFeatureFlag(FlagKey.PasswordManagerSync, false) }
}
private fun createViewModel(): DebugMenuViewModel = DebugMenuViewModel(
featureFlagManager = mockFeatureFlagManager,
debugMenuRepository = mockDebugMenuRepository,
)
}
private val DEFAULT_MAP_VALUE: Map<FlagKey<Any>, Any> = mapOf(
FlagKey.BitwardenAuthenticationEnabled to true,
FlagKey.PasswordManagerSync to true,
)
private val UPDATED_MAP_VALUE: Map<FlagKey<Any>, Any> = mapOf(
FlagKey.BitwardenAuthenticationEnabled to false,
FlagKey.PasswordManagerSync to false,
)
private val DEFAULT_STATE = DebugMenuState(
featureFlags = DEFAULT_MAP_VALUE,
)

View File

@@ -0,0 +1,109 @@
package com.bitwarden.authenticator.ui.platform.feature.debugmenu.manager
import android.view.KeyEvent
import android.view.MotionEvent
import com.bitwarden.authenticator.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)
}
}