diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1bc5c5e48..30c814da4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,6 +61,8 @@ android { signingConfig = signingConfigs.getByName("debug") isDebuggable = true isMinifyEnabled = false + + buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "true") } // Beta and Release variants are identical except beta has a different package name @@ -72,6 +74,8 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + + buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false") } release { isDebuggable = false @@ -80,6 +84,8 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + + buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false") } } diff --git a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt index d696bfce1..59c240733 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt @@ -2,6 +2,8 @@ package com.x8bit.bitwarden import android.content.Intent import android.os.Bundle +import android.view.KeyEvent +import android.view.MotionEvent import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -11,17 +13,18 @@ import androidx.compose.runtime.getValue import androidx.core.os.LocaleListCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.lifecycleScope +import androidx.navigation.compose.rememberNavController import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManager import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage import com.x8bit.bitwarden.data.platform.repository.SettingsRepository +import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.composition.LocalManagerProvider +import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager +import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScreen import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavScreen import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import javax.inject.Inject /** @@ -42,13 +45,14 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var settingsRepository: SettingsRepository + @Inject + lateinit var debugLaunchManager: DebugMenuLaunchManager + override fun onCreate(savedInstanceState: Bundle?) { var shouldShowSplashScreen = true installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen } super.onCreate(savedInstanceState) - observeViewModelEvents() - if (savedInstanceState == null) { mainViewModel.trySendAction( MainAction.ReceiveFirstIntent( @@ -66,11 +70,20 @@ class MainActivity : AppCompatActivity() { } setContent { val state by mainViewModel.stateFlow.collectAsStateWithLifecycle() + val navController = rememberNavController() + EventsEffect(viewModel = mainViewModel) { event -> + when (event) { + is MainEvent.CompleteAutofill -> handleCompleteAutofill(event) + MainEvent.Recreate -> handleRecreate() + MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen() + } + } updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed) LocalManagerProvider { BitwardenTheme(theme = state.theme) { RootNavScreen( onSplashScreenRemoved = { shouldShowSplashScreen = false }, + navController = navController, ) } } @@ -93,16 +106,18 @@ class MainActivity : AppCompatActivity() { currentFocus?.clearFocus() } - private fun observeViewModelEvents() { - mainViewModel - .eventFlow - .onEach { event -> - when (event) { - is MainEvent.CompleteAutofill -> handleCompleteAutofill(event) - MainEvent.Recreate -> handleRecreate() - } - } - .launchIn(lifecycleScope) + override fun dispatchTouchEvent(event: MotionEvent): Boolean = debugLaunchManager + .actionOnInputEvent(event = event, action = ::sendOpenDebugMenuEvent) + .takeIf { it } + ?: super.dispatchTouchEvent(event) + + override fun dispatchKeyEvent(event: KeyEvent): Boolean = debugLaunchManager + .actionOnInputEvent(event = event, action = ::sendOpenDebugMenuEvent) + .takeIf { it } + ?: super.dispatchKeyEvent(event) + + private fun sendOpenDebugMenuEvent() { + mainViewModel.trySendAction(MainAction.OpenDebugMenu) } private fun handleCompleteAutofill(event: MainEvent.CompleteAutofill) { diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt index d3816a28f..435058f14 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt @@ -140,9 +140,14 @@ class MainViewModel @Inject constructor( is MainAction.Internal.VaultUnlockStateChange -> handleVaultUnlockStateChange() is MainAction.ReceiveFirstIntent -> handleFirstIntentReceived(action) is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action) + MainAction.OpenDebugMenu -> handleOpenDebugMenu() } } + private fun handleOpenDebugMenu() { + sendEvent(MainEvent.NavigateToDebugMenu) + } + private fun handleAutofillSelectionReceive( action: MainAction.Internal.AutofillSelectionReceive, ) { @@ -315,6 +320,11 @@ sealed class MainAction { */ data class ReceiveNewIntent(val intent: Intent) : MainAction() + /** + * Receive event to open the debug menu. + */ + data object OpenDebugMenu : MainAction() + /** * Actions for internal use by the ViewModel. */ @@ -366,4 +376,9 @@ sealed class MainEvent { * Event indicating that the UI should recreate itself. */ data object Recreate : MainEvent() + + /** + * Navigate to the debug menu. + */ + data object NavigateToDebugMenu : MainEvent() } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/FeatureFlagOverrideDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/FeatureFlagOverrideDiskSource.kt new file mode 100644 index 000000000..2a966f575 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/FeatureFlagOverrideDiskSource.kt @@ -0,0 +1,19 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk + +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey + +/** + * Disk data source for saved feature flag overrides. + */ +interface FeatureFlagOverrideDiskSource { + + /** + * Save a feature flag [FlagKey] to disk. + */ + fun saveFeatureFlag(key: FlagKey, value: T) + + /** + * Get a feature flag value based on the associated [FlagKey] from disk. + */ + fun getFeatureFlag(key: FlagKey): T? +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/FeatureFlagOverrideDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/FeatureFlagOverrideDiskSourceImpl.kt new file mode 100644 index 000000000..ea3105dba --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/FeatureFlagOverrideDiskSourceImpl.kt @@ -0,0 +1,37 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk + +import android.content.SharedPreferences +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey + +/** + * Default implementation of the [FeatureFlagOverrideDiskSource] + */ +class FeatureFlagOverrideDiskSourceImpl( + sharedPreferences: SharedPreferences, +) : FeatureFlagOverrideDiskSource, BaseDiskSource(sharedPreferences) { + + override fun saveFeatureFlag(key: FlagKey, value: T) { + when (key.defaultValue) { + is Boolean -> putBoolean(key.keyName, value as Boolean) + is String -> putString(key.keyName, value as String) + is Int -> putInt(key.keyName, value as Int) + else -> Unit + } + } + + @Suppress("UNCHECKED_CAST") + override fun getFeatureFlag(key: FlagKey): T? { + return try { + when (key.defaultValue) { + is Boolean -> getBoolean(key.keyName) as? T + is String -> getString(key.keyName) as? T + is Int -> getInt(key.keyName) as? T + else -> null + } + } catch (castException: ClassCastException) { + null + } catch (numberFormatException: NumberFormatException) { + null + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt index eadbf20f6..d40947168 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt @@ -12,6 +12,8 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSourceImpl import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSourceImpl +import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSourceImpl import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSourceImpl import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource @@ -149,4 +151,12 @@ object PlatformDiskModule { sharedPreferences = sharedPreferences, json = json, ) + + @Provides + @Singleton + fun provideFeatureFlagOverrideDiskSource( + @UnencryptedPreferences sharedPreferences: SharedPreferences, + ): FeatureFlagOverrideDiskSource = FeatureFlagOverrideDiskSourceImpl( + sharedPreferences = sharedPreferences, + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/DebugMenuFeatureFlagManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/DebugMenuFeatureFlagManagerImpl.kt new file mode 100644 index 000000000..0a9a12c8d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/DebugMenuFeatureFlagManagerImpl.kt @@ -0,0 +1,37 @@ +package com.x8bit.bitwarden.data.platform.manager + +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey +import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * The [FeatureFlagManager] implementation for the debug menu. This manager uses the + * values returned from the [debugMenuRepository] if they are available. otherwise it will use + * the default [FeatureFlagManager]. + */ +class DebugMenuFeatureFlagManagerImpl( + private val defaultFeatureFlagManager: FeatureFlagManager, + private val debugMenuRepository: DebugMenuRepository, +) : FeatureFlagManager by defaultFeatureFlagManager { + + override fun 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/java/com/x8bit/bitwarden/data/platform/manager/FeatureFlagManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/FeatureFlagManagerImpl.kt index 1bf58091b..d27c95781 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/FeatureFlagManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/FeatureFlagManagerImpl.kt @@ -40,7 +40,11 @@ class FeatureFlagManagerImpl( .getFlagValueOrDefault(key = key) } -private fun ServerConfig?.getFlagValueOrDefault(key: FlagKey): T { +/** + * Extract the value of a [FlagKey] from the [ServerConfig]. If there is an issue with retrieving + * or if the value is null, the default value will be returned. + */ +fun ServerConfig?.getFlagValueOrDefault(key: FlagKey): T { val defaultValue = key.defaultValue if (!key.isRemotelyConfigured) return key.defaultValue return this diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt index 5346ba18d..5e5a3e133 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt @@ -22,6 +22,7 @@ import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManagerImpl import com.x8bit.bitwarden.data.platform.manager.CrashLogsManager import com.x8bit.bitwarden.data.platform.manager.CrashLogsManagerImpl +import com.x8bit.bitwarden.data.platform.manager.DebugMenuFeatureFlagManagerImpl import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManagerImpl import com.x8bit.bitwarden.data.platform.manager.NetworkConfigManager @@ -48,6 +49,7 @@ import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManage import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManagerImpl import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManagerImpl +import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -141,11 +143,20 @@ object PlatformManagerModule { @Provides @Singleton fun providesFeatureFlagManager( + debugMenuRepository: DebugMenuRepository, serverConfigRepository: ServerConfigRepository, - ): FeatureFlagManager = + ): FeatureFlagManager = if (debugMenuRepository.isDebugMenuEnabled) { + DebugMenuFeatureFlagManagerImpl( + debugMenuRepository = debugMenuRepository, + defaultFeatureFlagManager = FeatureFlagManagerImpl( + serverConfigRepository = serverConfigRepository, + ), + ) + } else { FeatureFlagManagerImpl( serverConfigRepository = serverConfigRepository, ) + } @Provides @Singleton diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt index 31488aae9..7048b320b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt @@ -19,6 +19,19 @@ sealed class FlagKey { */ abstract val isRemotelyConfigured: Boolean + companion object { + /** + * List of all flag keys to consider + */ + val activeFlags: List> by lazy { + listOf( + EmailVerification, + OnboardingFlow, + OnboardingCarousel, + ) + } + } + /** * Data object holding the key for Email Verification feature. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepository.kt new file mode 100644 index 000000000..512db4e83 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepository.kt @@ -0,0 +1,35 @@ +package com.x8bit.bitwarden.data.platform.repository + +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey +import kotlinx.coroutines.flow.Flow + +/** + * Repository for accessing data required or associated with the debug menu. + */ +interface DebugMenuRepository { + + /** + * Value to determine if the debug menu is enabled. + */ + val isDebugMenuEnabled: Boolean + + /** + * Observable flow for when any of the feature flag overrides have been updated. + */ + val featureFlagOverridesUpdatedFlow: Flow + + /** + * 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/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryImpl.kt new file mode 100644 index 000000000..0141f52a4 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryImpl.kt @@ -0,0 +1,45 @@ +package com.x8bit.bitwarden.data.platform.repository + +import com.x8bit.bitwarden.BuildConfig +import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource +import com.x8bit.bitwarden.data.platform.manager.getFlagValueOrDefault +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onSubscription + +/** + * Default implementation of the [DebugMenuRepository] + */ +class DebugMenuRepositoryImpl( + private val featureFlagOverrideDiskSource: FeatureFlagOverrideDiskSource, + private val serverConfigRepository: ServerConfigRepository, +) : DebugMenuRepository { + + private val mutableOverridesUpdatedFlow = bufferedMutableSharedFlow(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/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt index 143689f26..4d8787457 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt @@ -5,11 +5,14 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.datasource.network.service.ConfigService import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository +import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepositoryImpl import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepositoryImpl import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository @@ -81,4 +84,14 @@ object PlatformRepositoryModule { dispatcherManager = dispatcherManager, policyManager = policyManager, ) + + @Provides + @Singleton + fun provideDebugMenuRepository( + featureFlagOverrideDiskSource: FeatureFlagOverrideDiskSource, + serverConfigRepository: ServerConfigRepository, + ): DebugMenuRepository = DebugMenuRepositoryImpl( + featureFlagOverrideDiskSource = featureFlagOverrideDiskSource, + serverConfigRepository = serverConfigRepository, + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuNavigation.kt new file mode 100644 index 000000000..84dfd0845 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuNavigation.kt @@ -0,0 +1,29 @@ +package com.x8bit.bitwarden.ui.platform.feature.debugmenu + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import com.x8bit.bitwarden.ui.platform.base.util.composableWithPushTransitions + +private const val DEBUG_MENU = "debug_menu" + +/** + * Navigate to the setup unlock screen. + */ +fun NavController.navigateToDebugMenuScreen() { + this.navigate(DEBUG_MENU) { + launchSingleTop = true + } +} + +/** + * Add the setup unlock screen to the nav graph. + */ +fun NavGraphBuilder.setupDebugMenuDestination( + onNavigateBack: () -> Unit, +) { + composableWithPushTransitions( + route = DEBUG_MENU, + ) { + DebugMenuScreen(onNavigateBack = onNavigateBack) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt new file mode 100644 index 000000000..45a0511c8 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreen.kt @@ -0,0 +1,135 @@ +package com.x8bit.bitwarden.ui.platform.feature.debugmenu + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey +import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin +import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.appbar.NavigationIcon +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton +import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText +import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.feature.debugmenu.components.ListItemContent +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme + +/** + * Top level screen for the debug menu. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DebugMenuScreen( + onNavigateBack: () -> Unit, + viewModel: DebugMenuViewModel = hiltViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + DebugMenuEvent.NavigateBack -> onNavigateBack() + } + } + + BitwardenScaffold( + topBar = { + BitwardenTopAppBar( + title = stringResource(R.string.debug_menu), + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), + navigationIcon = NavigationIcon( + navigationIcon = rememberVectorPainter(R.drawable.ic_back), + navigationIconContentDescription = stringResource(id = R.string.back), + onNavigationIconClick = remember(viewModel) { + { + viewModel.trySendAction(DebugMenuAction.NavigateBack) + } + }, + ), + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding), + ) { + Spacer(modifier = Modifier.height(16.dp)) + FeatureFlagContent( + featureFlagMap = state.featureFlags, + onValueChange = { key, value -> + viewModel.trySendAction(DebugMenuAction.UpdateFeatureFlag(key, value)) + }, + onResetValues = { + viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues) + }, + ) + } + } +} + +@Composable +private fun FeatureFlagContent( + featureFlagMap: Map, 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)) + HorizontalDivider() + featureFlagMap.forEach { featureFlag -> + featureFlag.key.ListItemContent( + currentValue = featureFlag.value, + onValueChange = onValueChange, + modifier = Modifier.standardHorizontalMargin(), + ) + HorizontalDivider() + } + Spacer(modifier = Modifier.height(16.dp)) + BitwardenFilledButton( + label = stringResource(R.string.reset_values), + onClick = onResetValues, + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Preview(showBackground = true) +@Composable +private fun FeatureFlagContent_preview() { + BitwardenTheme { + FeatureFlagContent( + featureFlagMap = mapOf( + FlagKey.EmailVerification to true, + FlagKey.OnboardingCarousel to true, + FlagKey.OnboardingFlow to false, + ), + onValueChange = { _, _ -> }, + onResetValues = { }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt new file mode 100644 index 000000000..9ac839b79 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModel.kt @@ -0,0 +1,126 @@ +package com.x8bit.bitwarden.ui.platform.feature.debugmenu + +import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey +import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * ViewModel for the [DebugMenuScreen] + */ +@HiltViewModel +class DebugMenuViewModel @Inject constructor( + featureFlagManager: FeatureFlagManager, + private val debugMenuRepository: DebugMenuRepository, +) : BaseViewModel( + initialState = DebugMenuState(featureFlags = emptyMap()), +) { + + private var featureFlagResetJob: Job? = null + + init { + combine( + featureFlagManager.getFeatureFlagFlow(FlagKey.EmailVerification), + featureFlagManager.getFeatureFlagFlow(FlagKey.OnboardingCarousel), + featureFlagManager.getFeatureFlagFlow(FlagKey.OnboardingFlow), + ) { (emailVerification, onboardingCarousel, onboardingFlow) -> + sendAction( + DebugMenuAction.Internal.UpdateFeatureFlagMap( + mapOf( + FlagKey.EmailVerification to emailVerification, + FlagKey.OnboardingCarousel to onboardingCarousel, + FlagKey.OnboardingFlow to onboardingFlow, + ), + ), + ) + } + .launchIn(viewModelScope) + } + + override fun handleAction(action: DebugMenuAction) { + when (action) { + is DebugMenuAction.UpdateFeatureFlag<*> -> handleUpdateFeatureFlag(action) + is DebugMenuAction.Internal.UpdateFeatureFlagMap -> handleUpdateFeatureFlagMap(action) + DebugMenuAction.NavigateBack -> handleNavigateBack() + DebugMenuAction.ResetFeatureFlagValues -> handleResetFeatureFlagValues() + } + } + + private fun handleResetFeatureFlagValues() { + featureFlagResetJob?.cancel() + featureFlagResetJob = viewModelScope.launch { + debugMenuRepository.resetFeatureFlagOverrides() + } + } + + private fun handleNavigateBack() { + sendEvent(DebugMenuEvent.NavigateBack) + } + + private fun handleUpdateFeatureFlagMap(action: DebugMenuAction.Internal.UpdateFeatureFlagMap) { + mutableStateFlow.update { + it.copy(featureFlags = action.newMap) + } + } + + private fun handleUpdateFeatureFlag(action: DebugMenuAction.UpdateFeatureFlag<*>) { + debugMenuRepository.updateFeatureFlag(action.flagKey, action.newValue) + } +} + +/** + * State for the [DebugMenuViewModel] + */ +data class DebugMenuState( + val featureFlags: Map, 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. + */ + 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/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt new file mode 100644 index 000000000..74c484893 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/components/FeatureFlagListItems.kt @@ -0,0 +1,68 @@ +package com.x8bit.bitwarden.ui.platform.feature.debugmenu.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey +import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenWideSwitch + +/** + * Creates a list item for a [FlagKey]. + */ +@Suppress("UNCHECKED_CAST") +@Composable +fun 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.EmailVerification, + FlagKey.OnboardingCarousel, + FlagKey.OnboardingFlow, + -> 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.EmailVerification -> stringResource(R.string.email_verification) + FlagKey.OnboardingCarousel -> stringResource(R.string.onboarding_carousel) + FlagKey.OnboardingFlow -> stringResource(R.string.onboarding_flow) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/di/DebugMenuModule.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/di/DebugMenuModule.kt new file mode 100644 index 000000000..f89e329f5 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/di/DebugMenuModule.kt @@ -0,0 +1,22 @@ +package com.x8bit.bitwarden.ui.platform.feature.debugmenu.di + +import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository +import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugLaunchManagerImpl +import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +/** + * Provides dependencies for the debug menu. + */ +@Module +@InstallIn(SingletonComponent::class) +class DebugMenuModule { + + @Provides + fun provideDebugMenuLaunchManager( + debugMenuRepository: DebugMenuRepository, + ): DebugMenuLaunchManager = DebugLaunchManagerImpl(debugMenuRepository = debugMenuRepository) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/manager/DebugLaunchManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/manager/DebugLaunchManagerImpl.kt new file mode 100644 index 000000000..de20d7d08 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/manager/DebugLaunchManagerImpl.kt @@ -0,0 +1,67 @@ +package com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager + +import android.view.InputEvent +import android.view.KeyEvent +import android.view.MotionEvent +import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository + +private const val TAP_TIME_THRESHOLD_MILLIS = 500 +private const val POINTERS_REQUIRED = 3 + +/** + * Default implementation of the [DebugMenuLaunchManager] + */ +class DebugLaunchManagerImpl( + private val debugMenuRepository: DebugMenuRepository, +) : DebugMenuLaunchManager { + + private val tapEventQueue: ArrayDeque = 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/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/manager/DebugMenuLaunchManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/manager/DebugMenuLaunchManager.kt new file mode 100644 index 000000000..96d8a4d76 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/manager/DebugMenuLaunchManager.kt @@ -0,0 +1,18 @@ +package com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager + +import android.view.InputEvent + +/** + * Manager for abstracting the logic of launching debug menu. + */ +interface DebugMenuLaunchManager { + + /** + * Defines an interface to action on specific input events. + * @param event the input event to evaluate + * @param action the action to perform if the event matches + * + * @return true if the action was performed, false otherwise. + */ + fun actionOnInputEvent(event: InputEvent, action: () -> Unit): Boolean +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 1f270c109..305cc791e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -34,6 +34,7 @@ import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.VAULT_UNLOCK_ROUTE import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.navigateToVaultUnlock import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.vaultUnlockDestination import com.x8bit.bitwarden.ui.auth.feature.welcome.navigateToWelcome +import com.x8bit.bitwarden.ui.platform.feature.debugmenu.setupDebugMenuDestination import com.x8bit.bitwarden.ui.platform.feature.rootnav.util.toVaultItemListingType import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.navigateToLoginApproval import com.x8bit.bitwarden.ui.platform.feature.splash.SPLASH_ROUTE @@ -87,6 +88,7 @@ fun RootNavScreen( trustedDeviceGraph(navController) vaultUnlockDestination() vaultUnlockedGraph(navController) + setupDebugMenuDestination(onNavigateBack = { navController.popBackStack() }) } val targetRoute = when (state) { diff --git a/app/src/main/res/values/strings_non_localized.xml b/app/src/main/res/values/strings_non_localized.xml index 4cbf7374c..29aef02df 100644 --- a/app/src/main/res/values/strings_non_localized.xml +++ b/app/src/main/res/values/strings_non_localized.xml @@ -6,4 +6,13 @@ Duo (%1$s) .json .json (%1$s) + + + Email Verification + Onboarding Carousel + Onboarding Flow + Feature Flags: + Debug Menu + Reset values + diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt index 638070943..d4413b510 100644 --- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -746,6 +746,16 @@ class MainViewModelTest : BaseViewModelTest() { ) } + @Test + fun `send NavigateToDebugMenu action when OpenDebugMenu action is sent`() = runTest { + val viewModel = createViewModel() + viewModel.trySendAction(MainAction.OpenDebugMenu) + + viewModel.eventFlow.test { + assertEquals(MainEvent.NavigateToDebugMenu, awaitItem()) + } + } + private fun createViewModel( initialSpecialCircumstance: SpecialCircumstance? = null, ) = MainViewModel( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/FeatureFlagOverrideDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/FeatureFlagOverrideDiskSourceTest.kt new file mode 100644 index 000000000..94ae24b63 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/FeatureFlagOverrideDiskSourceTest.kt @@ -0,0 +1,117 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk + +import androidx.core.content.edit +import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class FeatureFlagOverrideDiskSourceTest { + private val fakeSharedPreferences = FakeSharedPreferences() + + private val featureFlagOverrideDiskSource = FeatureFlagOverrideDiskSourceImpl( + sharedPreferences = fakeSharedPreferences, + ) + + @Test + fun `call to save feature flag should update SharedPreferences for booleans`() { + val key = FlagKey.DummyBoolean + assertFalse( + fakeSharedPreferences.getBoolean( + "$BASE_STORAGE_PREFIX${key.keyName}", + false, + ), + ) + val value = true + featureFlagOverrideDiskSource.saveFeatureFlag(key, value) + assertTrue( + fakeSharedPreferences.getBoolean( + "$BASE_STORAGE_PREFIX${key.keyName}", + false, + ), + ) + } + + @Test + fun `call to get feature flag should return correct value for booleans`() { + val key = FlagKey.DummyBoolean + fakeSharedPreferences.edit { + putBoolean("$BASE_STORAGE_PREFIX${key.keyName}", true) + } + + val actual = featureFlagOverrideDiskSource.getFeatureFlag(key) + assertTrue(actual!!) + } + + @Test + fun `call to save feature flag should update SharedPreferences for strings`() { + val key = FlagKey.DummyString + assertNull( + fakeSharedPreferences.getString( + "$BASE_STORAGE_PREFIX${key.keyName}", + null, + ), + ) + val expectedValue = "string" + featureFlagOverrideDiskSource.saveFeatureFlag(key, expectedValue) + assertEquals( + fakeSharedPreferences.getString( + "$BASE_STORAGE_PREFIX${key.keyName}", + null, + ), + expectedValue, + ) + } + + @Test + fun `call to get feature flag should return correct value for strings`() { + val key = FlagKey.DummyString + assertNull(featureFlagOverrideDiskSource.getFeatureFlag(key)) + val expectedValue = "string" + fakeSharedPreferences.edit { + putString("$BASE_STORAGE_PREFIX${key.keyName}", expectedValue) + } + + val actual = featureFlagOverrideDiskSource.getFeatureFlag(key) + assertEquals(actual, expectedValue) + } + + @Test + fun `call to save feature flag should update SharedPreferences for ints`() { + val key = FlagKey.DummyInt() + assertEquals( + fakeSharedPreferences.getInt( + "$BASE_STORAGE_PREFIX${key.keyName}", + 0, + ), + 0, + ) + val expectedValue = 1 + featureFlagOverrideDiskSource.saveFeatureFlag(key, expectedValue) + assertEquals( + fakeSharedPreferences.getInt( + "$BASE_STORAGE_PREFIX${key.keyName}", + 0, + ), + expectedValue, + ) + } + + @Test + fun `call to get feature flag should return correct value for ints`() { + val key = FlagKey.DummyInt() + assertNull(featureFlagOverrideDiskSource.getFeatureFlag(key)) + val expectedValue = 1 + fakeSharedPreferences.edit { + putInt("$BASE_STORAGE_PREFIX${key.keyName}", expectedValue) + } + + val actual = featureFlagOverrideDiskSource.getFeatureFlag(key) + assertEquals(actual, expectedValue) + } +} + +private const val BASE_STORAGE_PREFIX = "bwPreferencesStorage:" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/DebugMenuFeatureFlagManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/DebugMenuFeatureFlagManagerTest.kt new file mode 100644 index 000000000..396562ea2 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/DebugMenuFeatureFlagManagerTest.kt @@ -0,0 +1,112 @@ +package com.x8bit.bitwarden.data.platform.manager + +import app.cash.turbine.test +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey +import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DebugMenuFeatureFlagManagerTest { + + private val mockFeatureFlagManager = mockk(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/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryTest.kt new file mode 100644 index 000000000..ce6d3cf44 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/DebugMenuRepositoryTest.kt @@ -0,0 +1,151 @@ +package com.x8bit.bitwarden.data.platform.repository + +import app.cash.turbine.test +import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig +import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponseJson +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DebugMenuRepositoryTest { + private val mockFeatureFlagOverrideDiskSource = mockk() { + every { getFeatureFlag(FlagKey.DummyBoolean) } returns true + every { getFeatureFlag(FlagKey.DummyString) } returns TEST_STRING_VALUE + every { getFeatureFlag(FlagKey.DummyInt()) } returns TEST_INT_VALUE + every { saveFeatureFlag(any(), any()) } just runs + } + private val mutableServerConfigStateFlow = MutableStateFlow(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`() { + assertTrue(debugMenuRepository.getFeatureFlag(FlagKey.DummyBoolean)!!) + } + + @Test + fun `getFeatureFlag should return the feature flag string value from disk`() { + assertEquals(TEST_STRING_VALUE, debugMenuRepository.getFeatureFlag(FlagKey.DummyString)!!) + } + + @Test + fun `getFeatureFlag should return the feature flag int value from disk`() { + assertEquals(TEST_INT_VALUE, debugMenuRepository.getFeatureFlag(FlagKey.DummyInt())!!) + } + + @Test + fun `getFeatureFlag should return null if the feature flag does not exist in disk`() { + every { mockFeatureFlagOverrideDiskSource.getFeatureFlag(any()) } returns null + assertNull(debugMenuRepository.getFeatureFlag(FlagKey.DummyBoolean)) + } + + @Suppress("MaxLineLength") + @Test + fun `resetFeatureFlagOverrides should reset flags to default values if they don't exist in server config`() = + runTest { + debugMenuRepository.resetFeatureFlagOverrides() + verify(exactly = 1) { + mockFeatureFlagOverrideDiskSource.saveFeatureFlag( + FlagKey.EmailVerification, + FlagKey.EmailVerification.defaultValue, + ) + mockFeatureFlagOverrideDiskSource.saveFeatureFlag( + FlagKey.OnboardingCarousel, + FlagKey.OnboardingCarousel.defaultValue, + ) + mockFeatureFlagOverrideDiskSource.saveFeatureFlag( + FlagKey.OnboardingFlow, + FlagKey.OnboardingFlow.defaultValue, + ) + } + debugMenuRepository.featureFlagOverridesUpdatedFlow.test { + awaitItem() // initial value on subscription + awaitItem() + expectNoEvents() + } + } + + @Suppress("MaxLineLength") + @Test + fun `resetFeatureFlagOverrides should save all feature flags to values from the server config if remote configured is on`() = + runTest { + val mockServerData = mockk(relaxed = true) { + every { featureStates } returns mapOf( + FlagKey.EmailVerification.keyName to JsonPrimitive(true), + FlagKey.OnboardingCarousel.keyName to JsonPrimitive(false), + FlagKey.OnboardingFlow.keyName to JsonPrimitive(true), + ) + } + val mockServerConfig = mockk(relaxed = true) { + every { serverData } returns mockServerData + } + mutableServerConfigStateFlow.value = mockServerConfig + + debugMenuRepository.resetFeatureFlagOverrides() + + assertTrue(FlagKey.EmailVerification.isRemotelyConfigured) + assertFalse(FlagKey.OnboardingCarousel.isRemotelyConfigured) + verify(exactly = 1) { + mockFeatureFlagOverrideDiskSource.saveFeatureFlag(FlagKey.EmailVerification, true) + mockFeatureFlagOverrideDiskSource.saveFeatureFlag( + FlagKey.OnboardingCarousel, + false, + ) + mockFeatureFlagOverrideDiskSource.saveFeatureFlag( + FlagKey.OnboardingFlow, + false, + ) + } + + debugMenuRepository.featureFlagOverridesUpdatedFlow.test { + awaitItem() // initial value on subscription + awaitItem() + cancel() + } + unmockkStatic(FlagKey.OnboardingFlow::class) + } +} + +private const val TEST_STRING_VALUE = "test" +private const val TEST_INT_VALUE = 100 diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt new file mode 100644 index 000000000..d94550008 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuScreenTest.kt @@ -0,0 +1,107 @@ +package com.x8bit.bitwarden.ui.platform.feature.debugmenu + +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.printToLog +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class DebugMenuScreenTest : BaseComposeTest() { + private var onNavigateBackCalled = false + private val mutableEventFlow = bufferedMutableSharedFlow() + 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("Email Verification", ignoreCase = true) + .assertDoesNotExist() + } + + @Test + fun `feature flag content should display if the state is not empty`() { + mutableStateFlow.tryEmit( + DebugMenuState( + featureFlags = mapOf( + FlagKey.EmailVerification to true, + ), + ), + ) + + composeTestRule + .onNodeWithText("Email Verification", ignoreCase = true) + .assertExists() + } + + @Test + fun `boolean feature flag content should send action when clicked`() { + mutableStateFlow.tryEmit( + DebugMenuState( + featureFlags = mapOf( + FlagKey.EmailVerification to true, + ), + ), + ) + composeTestRule + .onNodeWithText("Email Verification", ignoreCase = true) + .performClick() + + verify { + viewModel.trySendAction( + DebugMenuAction.UpdateFeatureFlag( + FlagKey.EmailVerification, + false, + ), + ) + } + } + + @Test + fun `reset feature flag values should send action when clicked`() { + composeTestRule + .onNodeWithText("Reset Values", ignoreCase = true) + .performClick() + + verify { viewModel.trySendAction(DebugMenuAction.ResetFeatureFlagValues) } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt new file mode 100644 index 000000000..4556f93f8 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/DebugMenuViewModelTest.kt @@ -0,0 +1,92 @@ +package com.x8bit.bitwarden.ui.platform.feature.debugmenu + +import app.cash.turbine.test +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey +import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class DebugMenuViewModelTest : BaseViewModelTest() { + + private val mockFeatureFlagManager = mockk(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.EmailVerification, false), + ) + verify { mockDebugMenuRepository.updateFeatureFlag(FlagKey.EmailVerification, false) } + } + + private fun createViewModel(): DebugMenuViewModel = DebugMenuViewModel( + featureFlagManager = mockFeatureFlagManager, + debugMenuRepository = mockDebugMenuRepository, + ) +} + +private val DEFAULT_MAP_VALUE: Map, Any> = mapOf( + FlagKey.EmailVerification to true, + FlagKey.OnboardingCarousel to true, + FlagKey.OnboardingFlow to true, +) + +private val UPDATED_MAP_VALUE: Map, Any> = mapOf( + FlagKey.EmailVerification to false, + FlagKey.OnboardingCarousel to true, + FlagKey.OnboardingFlow to false, +) + +private val DEFAULT_STATE = DebugMenuState( + featureFlags = DEFAULT_MAP_VALUE, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/manager/DebugLaunchManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/manager/DebugLaunchManagerTest.kt new file mode 100644 index 000000000..4ff7c72ec --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/debugmenu/manager/DebugLaunchManagerTest.kt @@ -0,0 +1,109 @@ +package com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager + +import android.view.KeyEvent +import android.view.MotionEvent +import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DebugLaunchManagerTest { + + private val mockDebugMenuRepository = mockk(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) + } +}