From f3f51cf244f44a70e6ed26031e29f09f8ad8a0a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bispo?= Date: Mon, 18 Nov 2024 22:39:03 +0000 Subject: [PATCH] [BWA-86] Debug Menu #4 (#276) --- app/build.gradle.kts | 2 + .../bitwarden/authenticator/MainActivity.kt | 34 +++- .../bitwarden/authenticator/MainViewModel.kt | 21 +++ .../DebugMenuFeatureFlagManagerImpl.kt | 37 ++++ .../manager/di/PlatformManagerModule.kt | 18 +- .../repository/DebugMenuRepository.kt | 35 ++++ .../repository/DebugMenuRepositoryImpl.kt | 45 +++++ .../repository/di/PlatformRepositoryModule.kt | 13 ++ .../platform/base/util/ModifierExtensions.kt | 18 ++ .../divider/BitwardenHorizontalDivider.kt | 30 ++++ .../feature/debugmenu/DebugMenuNavigation.kt | 29 +++ .../feature/debugmenu/DebugMenuScreen.kt | 148 ++++++++++++++++ .../feature/debugmenu/DebugMenuViewModel.kt | 119 +++++++++++++ .../components/FeatureFlagListItems.kt | 68 +++++++ .../feature/debugmenu/di/DebugMenuModule.kt | 22 +++ .../manager/DebugLaunchManagerImpl.kt | 67 +++++++ .../manager/DebugMenuLaunchManager.kt | 18 ++ .../platform/feature/rootnav/RootNavScreen.kt | 6 + .../platform/util/ConfigurationExtensions.kt | 15 ++ .../main/res/values/strings_non_localized.xml | 8 + .../authenticator/MainViewModelTest.kt | 19 +- .../DebugMenuFeatureFlagManagerTest.kt | 116 ++++++++++++ .../repository/DebugMenuRepositoryTest.kt | 167 ++++++++++++++++++ .../feature/debugmenu/DebugMenuScreenTest.kt | 111 ++++++++++++ .../debugmenu/DebugMenuViewModelTest.kt | 90 ++++++++++ .../manager/DebugLaunchManagerTest.kt | 109 ++++++++++++ 26 files changed, 1358 insertions(+), 7 deletions(-) create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/DebugMenuFeatureFlagManagerImpl.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepository.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepositoryImpl.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/divider/BitwardenHorizontalDivider.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuNavigation.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuScreen.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuViewModel.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/di/DebugMenuModule.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugLaunchManagerImpl.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugMenuLaunchManager.kt create mode 100644 app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ConfigurationExtensions.kt create mode 100644 app/src/test/java/com/bitwarden/authenticator/data/platform/manager/DebugMenuFeatureFlagManagerTest.kt create mode 100644 app/src/test/java/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepositoryTest.kt create mode 100644 app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt create mode 100644 app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt create mode 100644 app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugLaunchManagerTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 131337341..8187d7e6c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/MainActivity.kt b/app/src/main/kotlin/com/bitwarden/authenticator/MainActivity.kt index c27d56d30..7171045da 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/MainActivity.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/MainActivity.kt @@ -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) diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/MainViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/MainViewModel.kt index 7174ee974..d15cf30e4 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/MainViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/MainViewModel.kt @@ -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( 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. */ diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/DebugMenuFeatureFlagManagerImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/DebugMenuFeatureFlagManagerImpl.kt new file mode 100644 index 000000000..f4a6ca483 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/DebugMenuFeatureFlagManagerImpl.kt @@ -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 getFeatureFlagFlow(key: FlagKey): Flow { + return debugMenuRepository.featureFlagOverridesUpdatedFlow.map { _ -> + debugMenuRepository + .getFeatureFlag(key) + ?: defaultFeatureFlagManager.getFeatureFlag(key = key) + } + } + + override suspend fun getFeatureFlag(key: FlagKey, forceRefresh: Boolean): T { + return debugMenuRepository + .getFeatureFlag(key) + ?: defaultFeatureFlagManager.getFeatureFlag(key = key, forceRefresh = forceRefresh) + } + + override fun getFeatureFlag(key: FlagKey): T { + return debugMenuRepository + .getFeatureFlag(key) + ?: defaultFeatureFlagManager.getFeatureFlag(key = key) + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/di/PlatformManagerModule.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/di/PlatformManagerModule.kt index 45599b9fd..4b7f42910 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/di/PlatformManagerModule.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/manager/di/PlatformManagerModule.kt @@ -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, + ) + } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepository.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepository.kt new file mode 100644 index 000000000..280a1193d --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepository.kt @@ -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 + + /** + * Update a feature flag which matches the given [key] to the given [value]. + */ + fun updateFeatureFlag(key: FlagKey, value: T) + + /** + * Get a feature flag value based on the associated [FlagKey]. + */ + fun getFeatureFlag(key: FlagKey): T? + + /** + * Reset all feature flag overrides to their default values or values from the network. + */ + fun resetFeatureFlagOverrides() +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepositoryImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepositoryImpl.kt new file mode 100644 index 000000000..6751b5be8 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepositoryImpl.kt @@ -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(replay = 1) + override val featureFlagOverridesUpdatedFlow: Flow = mutableOverridesUpdatedFlow + .onSubscription { emit(Unit) } + + override val isDebugMenuEnabled: Boolean + get() = BuildConfig.HAS_DEBUG_MENU + + override fun updateFeatureFlag(key: FlagKey, value: T) { + featureFlagOverrideDiskSource.saveFeatureFlag(key = key, value = value) + mutableOverridesUpdatedFlow.tryEmit(Unit) + } + + override fun getFeatureFlag(key: FlagKey): T? = + featureFlagOverrideDiskSource.getFeatureFlag( + key = key, + ) + + override fun resetFeatureFlagOverrides() { + val currentServerConfig = serverConfigRepository.serverConfigStateFlow.value + FlagKey.activeFlags.forEach { flagKey -> + updateFeatureFlag( + flagKey, + currentServerConfig.getFlagValueOrDefault(flagKey), + ) + } + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/di/PlatformRepositoryModule.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/di/PlatformRepositoryModule.kt index f9f028dba..5fad4099a 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/di/PlatformRepositoryModule.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/platform/repository/di/PlatformRepositoryModule.kt @@ -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, + ) } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/ModifierExtensions.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/ModifierExtensions.kt index d5968730f..3bf9de4d9 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/ModifierExtensions.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/base/util/ModifierExtensions.kt @@ -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) +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/divider/BitwardenHorizontalDivider.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/divider/BitwardenHorizontalDivider.kt new file mode 100644 index 000000000..4ba8d807d --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/divider/BitwardenHorizontalDivider.kt @@ -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, + ) +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuNavigation.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuNavigation.kt new file mode 100644 index 000000000..85f3303ae --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuNavigation.kt @@ -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) + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuScreen.kt new file mode 100644 index 000000000..303ade4d1 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuScreen.kt @@ -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, Any>, + onValueChange: (key: FlagKey, 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 = { }, + ) + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuViewModel.kt new file mode 100644 index 000000000..43557dc26 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuViewModel.kt @@ -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( + 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, 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(val flagKey: FlagKey, 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, Any>) : Internal() + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt new file mode 100644 index 000000000..5e61dfa7a --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt @@ -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 FlagKey.ListItemContent( + currentValue: T, + onValueChange: (key: FlagKey, 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, + currentValue = currentValue as Boolean, + onValueChange = onValueChange as (FlagKey, Boolean) -> Unit, + modifier = modifier, + ) +} + +/** + * The UI layout for a boolean backed flag key. + */ +@Composable +private fun BooleanFlagItem( + label: String, + key: FlagKey, + currentValue: Boolean, + onValueChange: (key: FlagKey, value: Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + BitwardenWideSwitch( + label = label, + isChecked = currentValue, + onCheckedChange = { + onValueChange(key, it) + }, + modifier = modifier, + ) +} + +@Composable +private fun FlagKey.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) +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/di/DebugMenuModule.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/di/DebugMenuModule.kt new file mode 100644 index 000000000..e7fc7e008 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/di/DebugMenuModule.kt @@ -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) +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugLaunchManagerImpl.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugLaunchManagerImpl.kt new file mode 100644 index 000000000..a4693bb99 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugLaunchManagerImpl.kt @@ -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 = 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 +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugMenuLaunchManager.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugMenuLaunchManager.kt new file mode 100644 index 000000000..c030f2b12 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugMenuLaunchManager.kt @@ -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 +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt index d0e7232cc..4949a9f0a 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt @@ -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, diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ConfigurationExtensions.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ConfigurationExtensions.kt new file mode 100644 index 000000000..709022e17 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/util/ConfigurationExtensions.kt @@ -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 + } diff --git a/app/src/main/res/values/strings_non_localized.xml b/app/src/main/res/values/strings_non_localized.xml index 358581ae0..491cda250 100644 --- a/app/src/main/res/values/strings_non_localized.xml +++ b/app/src/main/res/values/strings_non_localized.xml @@ -6,4 +6,12 @@ 2FAS (no password) LastPass (.json) Aegis (.json) + + + Feature Flags: + Debug Menu + Reset values + Bitwarden authentication enabled + Password manager sync + diff --git a/app/src/test/java/com/bitwarden/authenticator/MainViewModelTest.kt b/app/src/test/java/com/bitwarden/authenticator/MainViewModelTest.kt index c904992de..6b979590d 100644 --- a/app/src/test/java/com/bitwarden/authenticator/MainViewModelTest.kt +++ b/app/src/test/java/com/bitwarden/authenticator/MainViewModelTest.kt @@ -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()) + } + } } diff --git a/app/src/test/java/com/bitwarden/authenticator/data/platform/manager/DebugMenuFeatureFlagManagerTest.kt b/app/src/test/java/com/bitwarden/authenticator/data/platform/manager/DebugMenuFeatureFlagManagerTest.kt new file mode 100644 index 000000000..8ee1c25e3 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/data/platform/manager/DebugMenuFeatureFlagManagerTest.kt @@ -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(relaxed = true) { + every { getFeatureFlag(any()) } returns true + } + + private val mutableOverridesUpdateFlow = bufferedMutableSharedFlow() + private val mockDebugMenuRepository = mockk(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) } + } +} diff --git a/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepositoryTest.kt b/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepositoryTest.kt new file mode 100644 index 000000000..019ec2e8f --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/data/platform/repository/DebugMenuRepositoryTest.kt @@ -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 { + 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( + null, + ) + private val mockServerConfigRepository = + mockk { + 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(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( + relaxed = true, + ) { + every { featureStates } returns mapOf( + FlagKey.PasswordManagerSync.keyName to JsonPrimitive( + true, + ), + FlagKey.BitwardenAuthenticationEnabled.keyName to JsonPrimitive( + false, + ), + ) + } + val mockServerConfig = + mockk( + 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 diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt new file mode 100644 index 000000000..417b99b17 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt @@ -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() + private val mutableStateFlow = MutableStateFlow(DebugMenuState(featureFlags = emptyMap())) + private val viewModel = mockk(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) } + } +} diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt new file mode 100644 index 000000000..043c5f460 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt @@ -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(relaxed = true) { + every { getFeatureFlagFlow(any()) } returns flowOf(true) + } + + private val mockDebugMenuRepository = mockk(relaxed = true) { + coEvery { resetFeatureFlagOverrides() } just runs + every { updateFeatureFlag(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, Any> = mapOf( + FlagKey.BitwardenAuthenticationEnabled to true, + FlagKey.PasswordManagerSync to true, +) + +private val UPDATED_MAP_VALUE: Map, Any> = mapOf( + FlagKey.BitwardenAuthenticationEnabled to false, + FlagKey.PasswordManagerSync to false, +) + +private val DEFAULT_STATE = DebugMenuState( + featureFlags = DEFAULT_MAP_VALUE, +) diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugLaunchManagerTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugLaunchManagerTest.kt new file mode 100644 index 000000000..6bf7185f8 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/platform/feature/debugmenu/manager/DebugLaunchManagerTest.kt @@ -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(relaxed = true) { + every { isDebugMenuEnabled } returns true + } + + private val mockKeyEvent = mockk(relaxed = true) { + every { action } returns KeyEvent.ACTION_DOWN + every { keyCode } returns KeyEvent.KEYCODE_GRAVE + every { isShiftPressed } returns true + } + + private val mockMotionEvent = mockk(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) + } +}