From a35ec8cf3c19ecc83791c9b89825ff6008e47a2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bispo?= <abispo@bitwarden.com> Date: Fri, 27 Dec 2024 15:03:33 +0000 Subject: [PATCH] [PM-8217] New device two factor notice (#4508) Co-authored-by: Federico Maccaroni <fedemkr@gmail.com> --- .../auth/datasource/disk/AuthDiskSource.kt | 13 +- .../datasource/disk/AuthDiskSourceImpl.kt | 19 + .../model/NewDeviceNoticeDisplayStatus.kt | 60 +++ .../data/auth/repository/AuthRepository.kt | 16 + .../auth/repository/AuthRepositoryImpl.kt | 87 ++++ .../data/platform/manager/model/FlagKey.kt | 20 + .../NewDeviceNoticeEmailAccessNavigation.kt | 2 + .../NewDeviceNoticeEmailAccessScreen.kt | 2 + .../NewDeviceNoticeEmailAccessViewModel.kt | 34 +- .../NewDeviceNoticeTwoFactorNavigation.kt | 4 +- .../NewDeviceNoticeTwoFactorScreen.kt | 53 ++- .../NewDeviceNoticeTwoFactorViewModel.kt | 130 +++++- .../components/FeatureFlagListItems.kt | 4 + .../platform/feature/rootnav/RootNavScreen.kt | 9 + .../feature/rootnav/RootNavViewModel.kt | 11 + .../vaultunlocked/VaultUnlockedNavigation.kt | 10 + app/src/main/res/values/strings.xml | 1 + .../main/res/values/strings_non_localized.xml | 2 + .../datasource/disk/AuthDiskSourceTest.kt | 60 +++ .../disk/util/FakeAuthDiskSource.kt | 14 + .../auth/repository/AuthRepositoryTest.kt | 418 +++++++++++++++++- .../data/platform/manager/FlagKeyTest.kt | 20 + .../NewDeviceNoticeEmailAccessScreenTest.kt | 21 +- ...NewDeviceNoticeEmailAccessViewModelTest.kt | 56 ++- .../NewDeviceNoticeTwoFactorScreenTest.kt | 123 +++++- .../NewDeviceNoticeTwoFactorViewModelTest.kt | 151 ++++++- .../debugmenu/DebugMenuViewModelTest.kt | 4 + .../feature/rootnav/RootNavScreenTest.kt | 10 + .../feature/rootnav/RootNavViewModelTest.kt | 40 ++ 29 files changed, 1343 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/model/NewDeviceNoticeDisplayStatus.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt index 7f207df50..ed5a90cfa 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.auth.datasource.disk import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson @@ -328,7 +329,17 @@ interface AuthDiskSource { fun storeShowImportLogins(userId: String, showImportLogins: Boolean?) /** - * Emits updates that track [getShowImportLogins]. This will replay the last known value, + * Emits updates that track [getShowImportLogins]. This will replay the last known value. */ fun getShowImportLoginsFlow(userId: String): Flow<Boolean?> + + /** + * Gets the new device notice state for the given [userId]. + */ + fun getNewDeviceNoticeState(userId: String): NewDeviceNoticeState + + /** + * Stores the new device notice state for the given [userId]. + */ + fun storeNewDeviceNoticeState(userId: String, newState: NewDeviceNoticeState?) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt index a702974e8..302a2ed57 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.auth.datasource.disk import android.content.SharedPreferences import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson @@ -46,6 +48,7 @@ private const val TDE_LOGIN_COMPLETE = "tdeLoginComplete" private const val USES_KEY_CONNECTOR = "usesKeyConnector" private const val ONBOARDING_STATUS_KEY = "onboardingStatus" private const val SHOW_IMPORT_LOGINS_KEY = "showImportLogins" +private const val NEW_DEVICE_NOTICE_STATE = "newDeviceNoticeState" /** * Primary implementation of [AuthDiskSource]. @@ -471,6 +474,22 @@ class AuthDiskSourceImpl( getMutableShowImportLoginsFlow(userId) .onSubscription { emit(getShowImportLogins(userId)) } + override fun getNewDeviceNoticeState(userId: String): NewDeviceNoticeState { + return getString(key = NEW_DEVICE_NOTICE_STATE.appendIdentifier(userId))?.let { + json.decodeFromStringOrNull(it) + } ?: NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN, + lastSeenDate = null, + ) + } + + override fun storeNewDeviceNoticeState(userId: String, newState: NewDeviceNoticeState?) { + putString( + key = NEW_DEVICE_NOTICE_STATE.appendIdentifier(userId), + value = newState?.let { json.encodeToString(it) }, + ) + } + private fun generateAndStoreUniqueAppId(): String = UUID .randomUUID() diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/model/NewDeviceNoticeDisplayStatus.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/model/NewDeviceNoticeDisplayStatus.kt new file mode 100644 index 000000000..2f9a1f8ca --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/model/NewDeviceNoticeDisplayStatus.kt @@ -0,0 +1,60 @@ +package com.x8bit.bitwarden.data.auth.datasource.disk.model + +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.time.ZonedDateTime + +/** + * Describes the current display status of the new device notice screen. + */ +@Serializable +enum class NewDeviceNoticeDisplayStatus { + /** + * The user has seen the screen and indicated they can access their email. + */ + @SerialName("canAccessEmail") + CAN_ACCESS_EMAIL, + + /** + * The user has indicated they can access their email + * as specified by the Permanent mode of the notice. + */ + @SerialName("canAccessEmailPermanent") + CAN_ACCESS_EMAIL_PERMANENT, + + /** + * The user has not seen the screen. + */ + @SerialName("hasNotSeen") + HAS_NOT_SEEN, + + /** + * The user has seen the screen and selected "remind me later". + */ + @SerialName("hasSeen") + HAS_SEEN, +} + +/** + * The state of the new device notice screen. + */ +@Suppress("MagicNumber") +@Serializable +data class NewDeviceNoticeState( + @SerialName("displayStatus") + val displayStatus: NewDeviceNoticeDisplayStatus, + + @SerialName("lastSeenDate") + @Contextual + val lastSeenDate: ZonedDateTime?, +) { + /** + * Whether the [lastSeenDate] is at least 7 days old. + */ + val shouldDisplayNoticeIfSeen = lastSeenDate + ?.isBefore( + ZonedDateTime.now().minusDays(7), + ) + ?: false +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index b9102b146..cf96efda0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.auth.repository import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel @@ -401,4 +402,19 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { * Update the value of the onboarding status for the user. */ fun setOnboardingStatus(userId: String, status: OnboardingStatus?) + + /** + * Checks if a new device notice should be displayed. + */ + fun checkUserNeedsNewDeviceTwoFactorNotice(): Boolean + + /** + * Gets the new device notice state of active user. + */ + fun getNewDeviceNoticeState(): NewDeviceNoticeState? + + /** + * Stores the new device notice state for active user. + */ + fun setNewDeviceNoticeState(newState: NewDeviceNoticeState?) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 0902f2437..5c305c099 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -8,6 +8,8 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountResponseJson @@ -106,6 +108,7 @@ import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository +import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.platform.repository.util.toEnvironmentUrls import com.x8bit.bitwarden.data.platform.util.asFailure @@ -141,6 +144,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import java.time.ZonedDateTime import javax.inject.Singleton /** @@ -1333,6 +1337,89 @@ class AuthRepositoryImpl( authDiskSource.storeOnboardingStatus(userId = userId, onboardingStatus = status) } + override fun getNewDeviceNoticeState(): NewDeviceNoticeState? { + return activeUserId?.let { userId -> + authDiskSource.getNewDeviceNoticeState(userId = userId) + } + } + + override fun setNewDeviceNoticeState(newState: NewDeviceNoticeState?) { + activeUserId?.let { userId -> + authDiskSource.storeNewDeviceNoticeState(userId = userId, newState = newState) + } + } + + override fun checkUserNeedsNewDeviceTwoFactorNotice(): Boolean { + return activeUserId?.let { userId -> + val temporaryFlag = featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) + val permanentFlag = featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) + + // check if feature flags are disabled + if (!temporaryFlag && !permanentFlag) { + return false + } + + if (!newDeviceNoticePreConditionsValid()) { + return false + } + + val newDeviceNoticeState = authDiskSource.getNewDeviceNoticeState(userId = userId) + return when (newDeviceNoticeState.displayStatus) { + // if the user has already attested email access but permanent flag is enabled, + // the notice needs to appear again + NewDeviceNoticeDisplayStatus.CAN_ACCESS_EMAIL -> permanentFlag + // if the user has already seen but 7 days have already passed, + // the notice needs to appear again + NewDeviceNoticeDisplayStatus.HAS_SEEN -> + newDeviceNoticeState.shouldDisplayNoticeIfSeen + NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN -> true + // the user never needs to see the notice again + NewDeviceNoticeDisplayStatus.CAN_ACCESS_EMAIL_PERMANENT -> false + } + } + ?: false + } + + /** + * Checks if the preconditions are met for a user to see a new device notice: + * - Must be a Bitwarden cloud user. + * - The account must be at least one week old. + * - Cannot have an active policy requiring SSO to be enabled. + * - Cannot have two-factor authentication enabled. + */ + private fun newDeviceNoticePreConditionsValid(): Boolean { + if (environmentRepository.environment.type == Environment.Type.SELF_HOSTED) { + return false + } + + val userProfile = authDiskSource.userState?.activeAccount?.profile + val isProfileAtLeastWeekOld = userProfile + ?.let { + it.creationDate + ?.plusWeeks(1) + ?.isBefore( + ZonedDateTime.now(), + ) + } + ?: false + if (!isProfileAtLeastWeekOld) { + return false + } + + val hasTwoFactorEnabled = userProfile + ?.isTwoFactorEnabled + ?: false + if (hasTwoFactorEnabled) { + return false + } + + val hasSSOPolicy = + policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO) + .any { p -> p.isEnabled } + + return !hasSSOPolicy + } + @Suppress("CyclomaticComplexMethod") private suspend fun validatePasswordAgainstPolicy( password: String, 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 353f955e9..1b2bf1ea7 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 @@ -36,6 +36,8 @@ sealed class FlagKey<out T : Any> { CredentialExchangeProtocolImport, CredentialExchangeProtocolExport, AppReviewPrompt, + NewDevicePermanentDismiss, + NewDeviceTemporaryDismiss, ) } } @@ -141,6 +143,24 @@ sealed class FlagKey<out T : Any> { override val isRemotelyConfigured: Boolean = true } + /** + * Data object holding the feature flag key for the New Device Temporary Dismiss feature. + */ + data object NewDeviceTemporaryDismiss : FlagKey<Boolean>() { + override val keyName: String = "new-device-temporary-dismiss" + override val defaultValue: Boolean = false + override val isRemotelyConfigured: Boolean = true + } + + /** + * Data object holding the feature flag key for the New Device Permanent Dismiss feature. + */ + data object NewDevicePermanentDismiss : FlagKey<Boolean>() { + override val keyName: String = "new-device-permanent-dismiss" + override val defaultValue: Boolean = false + override val isRemotelyConfigured: Boolean = true + } + //region Dummy keys for testing /** * Data object holding the key for a [Boolean] flag to be used in tests. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessNavigation.kt index dca5b9ec3..2be6f4a47 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessNavigation.kt @@ -41,6 +41,7 @@ fun NavController.navigateToNewDeviceNoticeEmailAccess( * Add the new device notice email access screen to the nav graph. */ fun NavGraphBuilder.newDeviceNoticeEmailAccessDestination( + onNavigateBackToVault: () -> Unit, onNavigateToTwoFactorOptions: () -> Unit, ) { composableWithSlideTransitions( @@ -50,6 +51,7 @@ fun NavGraphBuilder.newDeviceNoticeEmailAccessDestination( ), ) { NewDeviceNoticeEmailAccessScreen( + onNavigateBackToVault = onNavigateBackToVault, onNavigateToTwoFactorOptions = onNavigateToTwoFactorOptions, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreen.kt index 4313b461d..596777274 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreen.kt @@ -44,6 +44,7 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme */ @Composable fun NewDeviceNoticeEmailAccessScreen( + onNavigateBackToVault: () -> Unit, onNavigateToTwoFactorOptions: () -> Unit, viewModel: NewDeviceNoticeEmailAccessViewModel = hiltViewModel(), ) { @@ -51,6 +52,7 @@ fun NewDeviceNoticeEmailAccessScreen( EventsEffect(viewModel = viewModel) { event -> when (event) { NavigateToTwoFactorOptions -> onNavigateToTwoFactorOptions() + NewDeviceNoticeEmailAccessEvent.NavigateBackToVault -> onNavigateBackToVault() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModel.kt index 32a20f9cb..78b2d256f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModel.kt @@ -2,6 +2,12 @@ package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice import android.os.Parcelable import androidx.lifecycle.SavedStateHandle +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus.CAN_ACCESS_EMAIL +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus.CAN_ACCESS_EMAIL_PERMANENT +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessAction.ContinueClick import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessAction.EmailAccessToggle import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessEvent.NavigateToTwoFactorOptions @@ -9,6 +15,7 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.update import kotlinx.parcelize.Parcelize +import java.time.ZonedDateTime import javax.inject.Inject private const val KEY_STATE = "state" @@ -18,6 +25,8 @@ private const val KEY_STATE = "state" */ @HiltViewModel class NewDeviceNoticeEmailAccessViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val featureFlagManager: FeatureFlagManager, savedStateHandle: SavedStateHandle, ) : BaseViewModel< NewDeviceNoticeEmailAccessState, @@ -38,8 +47,24 @@ class NewDeviceNoticeEmailAccessViewModel @Inject constructor( } private fun handleContinueClick() { - // TODO PM-8217: update new device notice status and navigate accordingly - sendEvent(NavigateToTwoFactorOptions) + if (state.isEmailAccessEnabled) { + val displayStatus = + if (featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss)) { + CAN_ACCESS_EMAIL_PERMANENT + } else { + CAN_ACCESS_EMAIL + } + + authRepository.setNewDeviceNoticeState( + NewDeviceNoticeState( + displayStatus = displayStatus, + lastSeenDate = ZonedDateTime.now(), + ), + ) + sendEvent(NewDeviceNoticeEmailAccessEvent.NavigateBackToVault) + } else { + sendEvent(NavigateToTwoFactorOptions) + } } private fun handleEmailAccessToggle(action: EmailAccessToggle) { @@ -66,6 +91,11 @@ sealed class NewDeviceNoticeEmailAccessEvent { * Navigates to the Two Factor Options screen. */ data object NavigateToTwoFactorOptions : NewDeviceNoticeEmailAccessEvent() + + /** + * Navigates back. + */ + data object NavigateBackToVault : NewDeviceNoticeEmailAccessEvent() } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorNavigation.kt index 1e60f58fb..6c2896900 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorNavigation.kt @@ -23,13 +23,13 @@ fun NavController.navigateToNewDeviceNoticeTwoFactor( * Add the new device notice two factor screen to the nav graph. */ fun NavGraphBuilder.newDeviceNoticeTwoFactorDestination( - onNavigateBack: () -> Unit, + onNavigateBackToVault: () -> Unit, ) { composableWithSlideTransitions( route = NEW_DEVICE_NOTICE_TWO_FACTOR_ROUTE, ) { NewDeviceNoticeTwoFactorScreen( - onNavigateBack = onNavigateBack, + onNavigateBackToVault = onNavigateBackToVault, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorScreen.kt index 7190232e7..3cd9c30b3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -21,17 +22,21 @@ import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.ChangeAccountEmailClick +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.ContinueDialogClick +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.DismissDialogClick import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.RemindMeLaterClick import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.TurnOnTwoFactorClick -import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorEvent.NavigateBack +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorEvent.NavigateBackToVault import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorEvent.NavigateToChangeAccountEmail import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorEvent.NavigateToTurnOnTwoFactor 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.button.BitwardenFilledButton import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager @@ -43,10 +48,11 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme */ @Composable fun NewDeviceNoticeTwoFactorScreen( - onNavigateBack: () -> Unit, + onNavigateBackToVault: () -> Unit, intentManager: IntentManager = LocalIntentManager.current, viewModel: NewDeviceNoticeTwoFactorViewModel = hiltViewModel(), ) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() EventsEffect(viewModel = viewModel) { event -> when (event) { is NavigateToTurnOnTwoFactor -> { @@ -57,10 +63,28 @@ fun NewDeviceNoticeTwoFactorScreen( intentManager.launchUri(event.url.toUri()) } - NavigateBack -> onNavigateBack() + NavigateBackToVault -> onNavigateBackToVault() } } + // Show dialog if needed: + when (val dialogState = state.dialogState) { + is NewDeviceNoticeTwoFactorDialogState.TurnOnTwoFactorDialog, + is NewDeviceNoticeTwoFactorDialogState.ChangeAccountEmailDialog, + -> + BitwardenTwoButtonDialog( + title = stringResource(R.string.continue_to_web_app), + message = dialogState.message(), + confirmButtonText = stringResource(id = R.string.confirm), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = { viewModel.trySendAction(ContinueDialogClick) }, + onDismissClick = { viewModel.trySendAction(DismissDialogClick) }, + onDismissRequest = { viewModel.trySendAction(DismissDialogClick) }, + ) + + null -> Unit + } + BitwardenScaffold { NewDeviceNoticeTwoFactorContent( onTurnOnTwoFactorClick = { @@ -72,6 +96,7 @@ fun NewDeviceNoticeTwoFactorScreen( onRemindMeLaterClick = { viewModel.trySendAction(RemindMeLaterClick) }, + state = state, ) } } @@ -84,6 +109,7 @@ private fun NewDeviceNoticeTwoFactorContent( onTurnOnTwoFactorClick: () -> Unit, onChangeAccountEmailClick: () -> Unit, onRemindMeLaterClick: () -> Unit, + state: NewDeviceNoticeTwoFactorState, modifier: Modifier = Modifier, ) { Column( @@ -100,6 +126,7 @@ private fun NewDeviceNoticeTwoFactorContent( onTurnOnTwoFactorClick = onTurnOnTwoFactorClick, onChangeAccountEmailClick = onChangeAccountEmailClick, onRemindMeLaterClick = onRemindMeLaterClick, + state = state, ) Spacer(modifier = Modifier.navigationBarsPadding()) } @@ -142,6 +169,7 @@ private fun ColumnScope.MainContent( onTurnOnTwoFactorClick: () -> Unit, onChangeAccountEmailClick: () -> Unit, onRemindMeLaterClick: () -> Unit, + state: NewDeviceNoticeTwoFactorState, ) { BitwardenFilledButton( label = stringResource(R.string.turn_on_two_step_login), @@ -158,13 +186,15 @@ private fun ColumnScope.MainContent( modifier = Modifier .fillMaxWidth(), ) - Spacer(modifier = Modifier.height(12.dp)) - BitwardenOutlinedButton( - label = stringResource(R.string.remind_me_later), - onClick = onRemindMeLaterClick, - modifier = Modifier - .fillMaxWidth(), - ) + if (state.shouldShowRemindMeLater) { + Spacer(modifier = Modifier.height(12.dp)) + BitwardenOutlinedButton( + label = stringResource(R.string.remind_me_later), + onClick = onRemindMeLaterClick, + modifier = Modifier + .fillMaxWidth(), + ) + } } @PreviewScreenSizes @@ -175,6 +205,9 @@ private fun NewDeviceNoticeTwoFactorScreen_preview() { onTurnOnTwoFactorClick = {}, onChangeAccountEmailClick = {}, onRemindMeLaterClick = {}, + state = NewDeviceNoticeTwoFactorState( + shouldShowRemindMeLater = true, + ), ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorViewModel.kt index b342f8c8f..5ab680290 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorViewModel.kt @@ -1,12 +1,27 @@ package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice +import android.os.Parcelable +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.ChangeAccountEmailClick +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.ContinueDialogClick +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.DismissDialogClick import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.RemindMeLaterClick import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.TurnOnTwoFactorClick +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorDialogState.ChangeAccountEmailDialog +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorDialogState.TurnOnTwoFactorDialog import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize import javax.inject.Inject /** @@ -14,9 +29,19 @@ import javax.inject.Inject */ @HiltViewModel class NewDeviceNoticeTwoFactorViewModel @Inject constructor( + val authRepository: AuthRepository, val environmentRepository: EnvironmentRepository, -) : BaseViewModel<Unit, NewDeviceNoticeTwoFactorEvent, NewDeviceNoticeTwoFactorAction>( - initialState = Unit, + val featureFlagManager: FeatureFlagManager, +) : BaseViewModel< + NewDeviceNoticeTwoFactorState, + NewDeviceNoticeTwoFactorEvent, + NewDeviceNoticeTwoFactorAction, + >( + initialState = NewDeviceNoticeTwoFactorState( + shouldShowRemindMeLater = !featureFlagManager.getFeatureFlag( + FlagKey.NewDevicePermanentDismiss, + ), + ), ) { private val webTwoFactorUrl: String get() { @@ -38,22 +63,51 @@ class NewDeviceNoticeTwoFactorViewModel @Inject constructor( override fun handleAction(action: NewDeviceNoticeTwoFactorAction) { when (action) { - ChangeAccountEmailClick -> sendEvent( - NewDeviceNoticeTwoFactorEvent.NavigateToChangeAccountEmail( - url = webAccountUrl, - ), - ) + ChangeAccountEmailClick -> updateDialogState(newState = ChangeAccountEmailDialog) - TurnOnTwoFactorClick -> sendEvent( - NewDeviceNoticeTwoFactorEvent.NavigateToTurnOnTwoFactor( - url = webTwoFactorUrl, - ), - ) + TurnOnTwoFactorClick -> updateDialogState(newState = TurnOnTwoFactorDialog) - RemindMeLaterClick -> { - // TODO PM-8217: Add logic to remind me later - sendEvent(NewDeviceNoticeTwoFactorEvent.NavigateBack) + RemindMeLaterClick -> handleRemindMeLater() + + DismissDialogClick -> updateDialogState(newState = null) + + ContinueDialogClick -> handleContinueDialog() + } + } + + private fun handleRemindMeLater() { + authRepository.setNewDeviceNoticeState( + NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_SEEN, + lastSeenDate = null, + ), + ) + sendEvent(NewDeviceNoticeTwoFactorEvent.NavigateBackToVault) + } + + private fun handleContinueDialog() { + when (state.dialogState) { + is ChangeAccountEmailDialog -> { + sendEvent( + NewDeviceNoticeTwoFactorEvent.NavigateToChangeAccountEmail(url = webAccountUrl), + ) + updateDialogState(newState = null) } + + is TurnOnTwoFactorDialog -> { + sendEvent( + NewDeviceNoticeTwoFactorEvent.NavigateToTurnOnTwoFactor(url = webTwoFactorUrl), + ) + updateDialogState(newState = null) + } + + null -> return + } + } + + private fun updateDialogState(newState: NewDeviceNoticeTwoFactorDialogState?) { + mutableStateFlow.update { + it.copy(dialogState = newState) } } } @@ -75,9 +129,9 @@ sealed class NewDeviceNoticeTwoFactorEvent { data class NavigateToChangeAccountEmail(val url: String) : NewDeviceNoticeTwoFactorEvent() /** - * Navigates back. + * Navigates back to vault. */ - data object NavigateBack : NewDeviceNoticeTwoFactorEvent() + data object NavigateBackToVault : NewDeviceNoticeTwoFactorEvent() } /** @@ -98,4 +152,46 @@ sealed class NewDeviceNoticeTwoFactorAction { * User tapped the remind me later button. */ data object RemindMeLaterClick : NewDeviceNoticeTwoFactorAction() + + /** + * User tapped the dismiss dialog button. + */ + data object DismissDialogClick : NewDeviceNoticeTwoFactorAction() + + /** + * User tapped the continue dialog button. + */ + data object ContinueDialogClick : NewDeviceNoticeTwoFactorAction() +} + +/** + * Models state of the new device notice two factor screen. + */ +@Parcelize +data class NewDeviceNoticeTwoFactorState( + val dialogState: NewDeviceNoticeTwoFactorDialogState? = null, + val shouldShowRemindMeLater: Boolean, +) : Parcelable + +/** + * Dialog states for the new device notice two factor screen. + */ +sealed class NewDeviceNoticeTwoFactorDialogState( + val message: Text, +) : Parcelable { + /** + * Represents the turn on two factor dialog. + */ + @Parcelize + data object TurnOnTwoFactorDialog : NewDeviceNoticeTwoFactorDialogState( + message = R.string.two_step_login_description_long.asText(), + ) + + /** + * Represents the change account email dialog. + */ + @Parcelize + data object ChangeAccountEmailDialog : NewDeviceNoticeTwoFactorDialogState( + R.string.you_can_change_your_account_email_on_the_bitwarden_web_app.asText(), + ) } 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 index d85c87b04..3783148fe 100644 --- 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 @@ -33,6 +33,8 @@ fun <T : Any> FlagKey<T>.ListItemContent( FlagKey.CredentialExchangeProtocolExport, FlagKey.AppReviewPrompt, FlagKey.CipherKeyEncryption, + FlagKey.NewDevicePermanentDismiss, + FlagKey.NewDeviceTemporaryDismiss, -> BooleanFlagItem( label = flagKey.getDisplayLabel(), key = flagKey as FlagKey<Boolean>, @@ -81,4 +83,6 @@ private fun <T : Any> FlagKey<T>.getDisplayLabel(): String = when (this) { FlagKey.CredentialExchangeProtocolExport -> stringResource(R.string.cxp_export) FlagKey.AppReviewPrompt -> stringResource(R.string.app_review_prompt) FlagKey.CipherKeyEncryption -> stringResource(R.string.cipher_key_encryption) + FlagKey.NewDevicePermanentDismiss -> stringResource(R.string.new_device_permanent_dismiss) + FlagKey.NewDeviceTemporaryDismiss -> stringResource(R.string.new_device_temporary_dismiss) } 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 4bb22e3fa..bde02656c 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 @@ -29,6 +29,7 @@ import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration import com.x8bit.bitwarden.ui.auth.feature.expiredregistrationlink.navigateToExpiredRegistrationLinkScreen +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.navigateToNewDeviceNoticeEmailAccess import com.x8bit.bitwarden.ui.auth.feature.removepassword.REMOVE_PASSWORD_ROUTE import com.x8bit.bitwarden.ui.auth.feature.removepassword.navigateToRemovePassword import com.x8bit.bitwarden.ui.auth.feature.removepassword.removePasswordDestination @@ -126,6 +127,7 @@ fun RootNavScreen( is RootNavState.VaultUnlockedForFido2Save, is RootNavState.VaultUnlockedForFido2Assertion, is RootNavState.VaultUnlockedForFido2GetCredentials, + is RootNavState.NewDeviceTwoFactorNotice, -> VAULT_UNLOCKED_GRAPH_ROUTE RootNavState.OnboardingAccountLockSetup -> SETUP_UNLOCK_AS_ROOT_ROUTE @@ -254,6 +256,13 @@ fun RootNavScreen( RootNavState.OnboardingStepsComplete -> { navController.navigateToSetupCompleteScreen(rootNavOptions) } + + is RootNavState.NewDeviceTwoFactorNotice -> { + navController.navigateToNewDeviceNoticeEmailAccess( + emailAddress = currentState.email, + navOptions = rootNavOptions, + ) + } } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index e4c03861a..b9a4e62cd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -103,6 +103,9 @@ class RootNavViewModel @Inject constructor( } } + userState.activeAccount.isVaultUnlocked && + authRepository.checkUserNeedsNewDeviceTwoFactorNotice() -> RootNavState.NewDeviceTwoFactorNotice(userState.activeAccount.email) + userState.activeAccount.isVaultUnlocked -> { when (specialCircumstance) { is SpecialCircumstance.AutofillSave -> { @@ -367,6 +370,14 @@ sealed class RootNavState : Parcelable { */ @Parcelize data object OnboardingStepsComplete : RootNavState() + + /** + * App should show the new device two factor notice screen. + */ + @Parcelize + data class NewDeviceTwoFactorNotice( + val email: String, + ) : RootNavState() } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index 1c96b4a94..56f1ac764 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -8,6 +8,9 @@ import com.x8bit.bitwarden.ui.auth.feature.accountsetup.navigateToSetupAutoFillS import com.x8bit.bitwarden.ui.auth.feature.accountsetup.navigateToSetupUnlockScreen import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupAutoFillDestination import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupUnlockDestination +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.navigateToNewDeviceNoticeTwoFactor +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.newDeviceNoticeEmailAccessDestination +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.newDeviceNoticeTwoFactorDestination import com.x8bit.bitwarden.ui.platform.feature.search.navigateToSearch import com.x8bit.bitwarden.ui.platform.feature.search.searchDestination import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.deleteAccountDestination @@ -232,5 +235,12 @@ fun NavGraphBuilder.vaultUnlockedGraph( importLoginsScreenDestination( onNavigateBack = { navController.popBackStack() }, ) + newDeviceNoticeEmailAccessDestination( + onNavigateBackToVault = { navController.navigateToVaultUnlockedGraph() }, + onNavigateToTwoFactorOptions = { navController.navigateToNewDeviceNoticeTwoFactor() }, + ) + newDeviceNoticeTwoFactorDestination( + onNavigateBackToVault = { navController.navigateToVaultUnlockedGraph() }, + ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c225460a5..f3b88d3b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1111,4 +1111,5 @@ Do you want to switch to this account?</string> <string name="we_couldnt_verify_the_servers_certificate">We couldn’t verify the server’s certificate. The certificate chain or proxy settings on your device or your Bitwarden server may not be set up correctly.</string> <string name="review_flow_launched">Review flow launched!</string> <string name="copy_private_key">Copy private key</string> + <string name="you_can_change_your_account_email_on_the_bitwarden_web_app">You can change your account email on the Bitwarden web app.</string> </resources> diff --git a/app/src/main/res/values/strings_non_localized.xml b/app/src/main/res/values/strings_non_localized.xml index 263a42e3b..221fd5472 100644 --- a/app/src/main/res/values/strings_non_localized.xml +++ b/app/src/main/res/values/strings_non_localized.xml @@ -22,5 +22,7 @@ <string name="restart_onboarding_carousel_details">This will force the change to app state which will cause the first time carousel to show. The carousel will continue to show for any \"new\" account until a login is completed. May need to exit debug menu manually.</string> <string name="app_review_prompt">App Review Prompt</string> <string name="cipher_key_encryption">Cipher Key Encryption</string>"> + <string name="new_device_permanent_dismiss">New device notice permanent dismiss</string>"> + <string name="new_device_temporary_dismiss">New device notice temporary dismiss</string>"> <!-- /Debug Menu --> </resources> diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt index 03ccab427..40e51ecb5 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt @@ -7,6 +7,8 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson @@ -1246,6 +1248,64 @@ class AuthDiskSourceTest { assertTrue(awaitItem() ?: false) } } + + @Test + fun `getNewDeviceNoticeState should pull from SharedPreferences`() { + val storeKey = "bwPreferencesStorage:newDeviceNoticeState" + val mockUserId = "mockUserId" + val expectedState = NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_SEEN, + lastSeenDate = ZonedDateTime.parse("2024-12-25T01:00:00.00Z"), + ) + fakeSharedPreferences.edit { + putString( + "${storeKey}_$mockUserId", + json.encodeToString(expectedState), + ) + } + val actual = authDiskSource.getNewDeviceNoticeState(userId = mockUserId) + assertEquals( + expectedState, + actual, + ) + } + + @Test + fun `getNewDeviceNoticeState should pull default from SharedPreferences if no user is found`() { + val mockUserId = "mockUserId" + val defaultState = NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN, + lastSeenDate = null, + ) + val actual = authDiskSource.getNewDeviceNoticeState(userId = mockUserId) + assertEquals( + defaultState, + actual, + ) + } + + @Test + fun `setNewDeviceNoticeState should update SharedPreferences`() { + val storeKey = "bwPreferencesStorage:newDeviceNoticeState" + val mockUserId = "mockUserId" + val mockStatus = NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_SEEN, + lastSeenDate = ZonedDateTime.parse("2024-12-25T01:00:00.00Z"), + ) + authDiskSource.storeNewDeviceNoticeState( + userId = mockUserId, + mockStatus, + ) + + val actual = fakeSharedPreferences.getString( + "${storeKey}_$mockUserId", + null, + ) + assertEquals( + json.encodeToString(mockStatus), + actual, + ) + } } private const val USER_STATE_JSON = """ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index f920cb5c6..479a3809b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.auth.datasource.disk.util import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson @@ -60,6 +62,7 @@ class FakeAuthDiskSource : AuthDiskSource { private val storedPolicies = mutableMapOf<String, List<SyncResponseJson.Policy>?>() private val storedOnboardingStatus = mutableMapOf<String, OnboardingStatus?>() private val storedShowImportLogins = mutableMapOf<String, Boolean?>() + private val storedNewDeviceNoticeState = mutableMapOf<String, NewDeviceNoticeState?>() override var userState: UserStateJson? = null set(value) { @@ -298,6 +301,17 @@ class FakeAuthDiskSource : AuthDiskSource { getMutableShowImportLoginsFlow(userId) .onSubscription { emit(getShowImportLogins(userId)) } + override fun getNewDeviceNoticeState(userId: String): NewDeviceNoticeState { + return storedNewDeviceNoticeState[userId] ?: NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN, + lastSeenDate = null, + ) + } + + override fun storeNewDeviceNoticeState(userId: String, newState: NewDeviceNoticeState?) { + storedNewDeviceNoticeState[userId] = newState + } + /** * Assert the the [isTdeLoginComplete] was stored successfully using the [userId]. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 934c3095e..e2165e401 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -16,6 +16,8 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson @@ -147,6 +149,7 @@ import kotlinx.serialization.json.put import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach @@ -6468,6 +6471,408 @@ class AuthRepositoryTest { assertNull(fakeAuthDiskSource.getOnboardingStatus(USER_ID_1)) } + @Test + fun `getNewDeviceNoticeState should return device notice state if an account is active`() = + runTest { + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + val deviceNoticeState = repository.getNewDeviceNoticeState() + assertNotNull(deviceNoticeState) + } + + @Test + fun `getNewDeviceNoticeState should return null if no account is active`() = + runTest { + val deviceNoticeState = repository.getNewDeviceNoticeState() + assertNull(deviceNoticeState) + } + + @Test + fun `setNewDeviceNoticeState should update disk source`() = + runTest { + val userId = "2a135b23-e1fb-42c9-bec3-573857bc8181" + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + repository.setNewDeviceNoticeState( + NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_SEEN, + lastSeenDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"), + ), + ) + assertEquals( + NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_SEEN, + lastSeenDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"), + ), + fakeAuthDiskSource.getNewDeviceNoticeState(userId), + ) + } + + @Test + @Suppress("MaxLineLength") + fun `setNewDeviceNoticeState without an active account should not update disk source and return default`() = + runTest { + val userId = "2a135b23-e1fb-42c9-bec3-573857bc8181" + repository.setNewDeviceNoticeState( + NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_SEEN, + lastSeenDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"), + ), + ) + assertEquals( + NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN, + lastSeenDate = null, + ), + fakeAuthDiskSource.getNewDeviceNoticeState(userId), + ) + } + + @Test + @Suppress("MaxLineLength") + fun `checkUserNeedsNewDeviceTwoFactorNotice flags on, is cloud user, profile at least week old, no required sso policy, no two factor enable returns true`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) + } returns true + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) + } returns true + every { + policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO) + } returns listOf() + fakeEnvironmentRepository.environment = Environment.Us + + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + + val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice() + + assertTrue(shouldShowNewDeviceNotice) + } + + @Test + @Suppress("MaxLineLength") + fun `checkUserNeedsNewDeviceTwoFactorNotice NewDeviceTemporaryDismiss and NewDevicePermanentDismiss flags are off returns false`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) + } returns false + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) + } returns false + every { + policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO) + } returns listOf() + fakeEnvironmentRepository.environment = Environment.Us + + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + + val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice() + + assertFalse(shouldShowNewDeviceNotice) + } + + @Test + fun `checkUserNeedsNewDeviceTwoFactorNotice has required SSO policy returns false`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) + } returns true + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) + } returns true + every { + policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO) + } returns listOf( + createMockPolicy( + type = PolicyTypeJson.REQUIRE_SSO, + isEnabled = true, + ), + ) + fakeEnvironmentRepository.environment = Environment.Us + + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + + val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice() + + assertFalse(shouldShowNewDeviceNotice) + } + + @Test + fun `checkUserNeedsNewDeviceTwoFactorNotice with two factor enable returns false`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) + } returns true + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) + } returns true + every { + policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO) + } returns listOf() + fakeEnvironmentRepository.environment = Environment.Us + + fakeAuthDiskSource.userState = SINGLE_USER_STATE_2 + + val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice() + + assertFalse(shouldShowNewDeviceNotice) + } + + @Test + fun `checkUserNeedsNewDeviceTwoFactorNotice account less than a week old returns false`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) + } returns true + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) + } returns true + every { + policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO) + } returns listOf() + + fakeEnvironmentRepository.environment = Environment.Us + + fakeAuthDiskSource.userState = UserStateJson( + activeUserId = USER_ID_1, + accounts = mapOf( + USER_ID_1 to ACCOUNT_1.copy( + profile = ACCOUNT_1.profile.copy( + creationDate = ZonedDateTime.now().minusDays(2), + ), + ), + ), + ) + + val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice() + + assertFalse(shouldShowNewDeviceNotice) + } + + @Test + @Suppress("MaxLineLength") + fun `checkUserNeedsNewDeviceTwoFactorNotice with NewDeviceNoticeDisplayStatus CAN_ACCESS_EMAIL_PERMANENT return false`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) + } returns true + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) + } returns true + every { + policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO) + } returns listOf() + fakeEnvironmentRepository.environment = Environment.Us + + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + fakeAuthDiskSource.storeNewDeviceNoticeState( + userId = USER_ID_1, + newState = NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.CAN_ACCESS_EMAIL_PERMANENT, + lastSeenDate = null, + ), + ) + + val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice() + + assertFalse(shouldShowNewDeviceNotice) + } + + @Test + @Suppress("MaxLineLength") + fun `checkUserNeedsNewDeviceTwoFactorNotice with NewDeviceNoticeDisplayStatus HAS_NOT_SEEN return true`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) + } returns true + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) + } returns true + every { + policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO) + } returns listOf() + fakeEnvironmentRepository.environment = Environment.Us + + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + fakeAuthDiskSource.storeNewDeviceNoticeState( + userId = USER_ID_1, + newState = NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN, + lastSeenDate = null, + ), + ) + + val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice() + + assertTrue(shouldShowNewDeviceNotice) + } + + @Test + @Suppress("MaxLineLength") + fun `checkUserNeedsNewDeviceTwoFactorNotice with NewDeviceNoticeDisplayStatus HAS_SEEN return true if date is older than 7 days`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) + } returns true + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) + } returns true + every { + policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO) + } returns listOf() + fakeEnvironmentRepository.environment = Environment.Us + + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + fakeAuthDiskSource.storeNewDeviceNoticeState( + userId = USER_ID_1, + newState = NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_SEEN, + lastSeenDate = ZonedDateTime.now().minusDays(10), + ), + ) + + val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice() + + assertTrue(shouldShowNewDeviceNotice) + } + + @Test + @Suppress("MaxLineLength") + fun `checkUserNeedsNewDeviceTwoFactorNotice with NewDeviceNoticeDisplayStatus HAS_SEEN return false if date is not older than 7 days`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) + } returns true + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) + } returns true + every { + policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO) + } returns listOf() + fakeEnvironmentRepository.environment = Environment.Us + + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + fakeAuthDiskSource.storeNewDeviceNoticeState( + userId = USER_ID_1, + newState = NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_SEEN, + lastSeenDate = ZonedDateTime.now().minusDays(2), + ), + ) + + val shouldShowNewDeviceNotice = repository.checkUserNeedsNewDeviceTwoFactorNotice() + + assertFalse(shouldShowNewDeviceNotice) + } + + @Test + @Suppress("MaxLineLength") + fun `checkUserNeedsNewDeviceTwoFactorNotice with NewDeviceNoticeDisplayStatus CAN_ACCESS_EMAIL return permanent flag value`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) + } returns true + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) + } returns true + every { + policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO) + } returns listOf() + fakeEnvironmentRepository.environment = Environment.Us + + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + fakeAuthDiskSource.storeNewDeviceNoticeState( + userId = USER_ID_1, + newState = NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.CAN_ACCESS_EMAIL, + lastSeenDate = ZonedDateTime.now().minusDays(2), + ), + ) + + assertTrue(repository.checkUserNeedsNewDeviceTwoFactorNotice()) + + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) + } returns false + + assertFalse(repository.checkUserNeedsNewDeviceTwoFactorNotice()) + } + + @Test + fun `checkUserNeedsNewDeviceTwoFactorNotice with no active user returns false`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) + } returns true + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) + } returns true + every { + policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO) + } returns listOf() + fakeEnvironmentRepository.environment = Environment.Us + + fakeAuthDiskSource.userState = null + + assertFalse(repository.checkUserNeedsNewDeviceTwoFactorNotice()) + } + + @Test + fun `checkUserNeedsNewDeviceTwoFactorNotice account with null creationDate returns false`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) + } returns true + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) + } returns true + every { + policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO) + } returns listOf() + fakeEnvironmentRepository.environment = Environment.Us + + fakeAuthDiskSource.userState = UserStateJson( + activeUserId = USER_ID_1, + accounts = mapOf( + USER_ID_1 to ACCOUNT_1.copy( + profile = ACCOUNT_1.profile.copy( + creationDate = null, + ), + ), + ), + ) + + assertFalse(repository.checkUserNeedsNewDeviceTwoFactorNotice()) + } + + @Test + @Suppress("MaxLineLength") + fun `checkUserNeedsNewDeviceTwoFactorNotice account with null isTwoFactorEnabled returns true`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) + } returns true + every { + featureFlagManager.getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) + } returns true + every { + policyManager.getActivePolicies(type = PolicyTypeJson.REQUIRE_SSO) + } returns listOf() + fakeEnvironmentRepository.environment = Environment.Us + + fakeAuthDiskSource.userState = UserStateJson( + activeUserId = USER_ID_1, + accounts = mapOf( + USER_ID_1 to ACCOUNT_1.copy( + profile = ACCOUNT_1.profile.copy( + isTwoFactorEnabled = null, + ), + ), + ), + ) + + assertTrue(repository.checkUserNeedsNewDeviceTwoFactorNotice()) + } + companion object { private const val UNIQUE_APP_ID = "testUniqueAppId" private const val NAME = "Example Name" @@ -6596,7 +7001,7 @@ class AuthRepositoryTest { kdfMemory = null, kdfParallelism = null, userDecryptionOptions = null, - isTwoFactorEnabled = false, + isTwoFactorEnabled = true, creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"), ), settings = AccountJson.Settings( @@ -6623,6 +7028,17 @@ class AuthRepositoryTest { ), ), ) + + private val SINGLE_USER_STATE_1_NEW_ACCOUNT = UserStateJson( + activeUserId = USER_ID_1, + accounts = mapOf( + USER_ID_1 to ACCOUNT_1.copy( + profile = ACCOUNT_1.profile.copy( + creationDate = ZonedDateTime.parse("2024-09-14T01:00:00.00Z"), + ), + ), + ), + ) private val SINGLE_USER_STATE_2 = UserStateJson( activeUserId = USER_ID_2, accounts = mapOf( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/FlagKeyTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/FlagKeyTest.kt index a0deccade..587ecd95d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/FlagKeyTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/FlagKeyTest.kt @@ -46,4 +46,24 @@ class FlagKeyTest { fun `AppReviewPrompt default value should be false`() { assertFalse(FlagKey.AppReviewPrompt.defaultValue) } + + @Test + fun `NewDevicePermanentDismiss default value should be false`() { + assertFalse(FlagKey.NewDevicePermanentDismiss.defaultValue) + } + + @Test + fun `NewDevicePermanentDismiss is remotely configured value should be true`() { + assertTrue(FlagKey.NewDevicePermanentDismiss.isRemotelyConfigured) + } + + @Test + fun `NewDeviceTemporaryDismiss default value should be false`() { + assertFalse(FlagKey.NewDeviceTemporaryDismiss.defaultValue) + } + + @Test + fun `NewDeviceTemporaryDismiss is remotely configured value should be true`() { + assertTrue(FlagKey.NewDeviceTemporaryDismiss.isRemotelyConfigured) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreenTest.kt index 2b6e12332..2418d9695 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessScreenTest.kt @@ -13,12 +13,14 @@ import io.mockk.verify import junit.framework.TestCase.assertTrue import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import org.junit.After import org.junit.Before import org.junit.Test class NewDeviceNoticeEmailAccessScreenTest : BaseComposeTest() { private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val mutableEventFlow = bufferedMutableSharedFlow<NewDeviceNoticeEmailAccessEvent>() + private var onNavigateBackToVaultCalled = false private var onNavigateToTwoFactorOptionsCalled = false private val viewModel = mockk<NewDeviceNoticeEmailAccessViewModel>(relaxed = true) { every { stateFlow } returns mutableStateFlow @@ -29,14 +31,21 @@ class NewDeviceNoticeEmailAccessScreenTest : BaseComposeTest() { fun setUp() { composeTestRule.setContent { NewDeviceNoticeEmailAccessScreen( + onNavigateBackToVault = { onNavigateBackToVaultCalled = true }, onNavigateToTwoFactorOptions = { onNavigateToTwoFactorOptionsCalled = true }, viewModel = viewModel, ) } } - @Suppress("MaxLineLength") + @After + fun tearDown() { + onNavigateBackToVaultCalled = false + onNavigateToTwoFactorOptionsCalled = false + } + @Test + @Suppress("MaxLineLength") fun `Do you have reliable access to your email should be toggled on or off according to the state`() { composeTestRule .onNodeWithText("Yes, I can reliably access my email", substring = true) @@ -70,7 +79,15 @@ class NewDeviceNoticeEmailAccessScreenTest : BaseComposeTest() { } @Test - fun `ContinueClick should call onNavigateToTwoFactorOptions`() { + fun `ContinueClick should call onNavigateBackToVault if isEmailAccessEnabled is false`() { + mutableStateFlow.update { it.copy(isEmailAccessEnabled = false) } + mutableEventFlow.tryEmit(NewDeviceNoticeEmailAccessEvent.NavigateBackToVault) + assertTrue(onNavigateBackToVaultCalled) + } + + @Test + fun `ContinueClick should call onNavigateToTwoFactorOptions if isEmailAccessEnabled is true`() { + mutableStateFlow.update { it.copy(isEmailAccessEnabled = true) } mutableEventFlow.tryEmit(NewDeviceNoticeEmailAccessEvent.NavigateToTwoFactorOptions) assertTrue(onNavigateToTwoFactorOptionsCalled) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModelTest.kt index 6c401286c..6e8b72271 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeEmailAccessViewModelTest.kt @@ -2,12 +2,34 @@ package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class NewDeviceNoticeEmailAccessViewModelTest : BaseViewModelTest() { + private val authRepository = mockk<AuthRepository> { + every { getNewDeviceNoticeState() } returns NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN, + lastSeenDate = null, + ) + every { setNewDeviceNoticeState(any()) } just runs + every { checkUserNeedsNewDeviceTwoFactorNotice() } returns true + } + + private val featureFlagManager = mockk<FeatureFlagManager>(relaxed = true) { + every { getFeatureFlag(FlagKey.NewDevicePermanentDismiss) } returns true + every { getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) } returns true + } @Test fun `initial state should be correct with email from state handle`() = runTest { @@ -30,7 +52,37 @@ class NewDeviceNoticeEmailAccessViewModelTest : BaseViewModelTest() { } @Test - fun `ContinueClick should emit NavigateToTwoFactorOptions`() = runTest { + fun `ContinueClick should emit NavigateBackToVault if isEmailAccessEnabled`() = runTest { + val viewModel = createViewModel() + viewModel.trySendAction(NewDeviceNoticeEmailAccessAction.EmailAccessToggle(true)) + viewModel.eventFlow.test { + viewModel.trySendAction(NewDeviceNoticeEmailAccessAction.ContinueClick) + assertEquals( + NewDeviceNoticeEmailAccessEvent.NavigateBackToVault, + awaitItem(), + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `ContinueClick should emit NavigateBackToVault if isEmailAccessEnabled and NewDevicePermanentDismiss flag is off`() = runTest { + every { featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) } returns false + + val viewModel = createViewModel() + viewModel.trySendAction(NewDeviceNoticeEmailAccessAction.EmailAccessToggle(true)) + viewModel.eventFlow.test { + viewModel.trySendAction(NewDeviceNoticeEmailAccessAction.ContinueClick) + assertEquals( + NewDeviceNoticeEmailAccessEvent.NavigateBackToVault, + awaitItem(), + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `ContinueClick should emit NavigateToTwoFactorOptions if isEmailAccessEnabled is false`() = runTest { val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.trySendAction(NewDeviceNoticeEmailAccessAction.ContinueClick) @@ -46,6 +98,8 @@ class NewDeviceNoticeEmailAccessViewModelTest : BaseViewModelTest() { it["email_address"] = EMAIL }, ): NewDeviceNoticeEmailAccessViewModel = NewDeviceNoticeEmailAccessViewModel( + authRepository = authRepository, + featureFlagManager = featureFlagManager, savedStateHandle = savedStateHandle, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorScreenTest.kt index 37f7055b1..00fd15292 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorScreenTest.kt @@ -1,5 +1,9 @@ package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo @@ -13,6 +17,8 @@ import io.mockk.mockk import io.mockk.runs import io.mockk.verify import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import org.junit.After import org.junit.Before import org.junit.Test @@ -22,8 +28,10 @@ class NewDeviceNoticeTwoFactorScreenTest : BaseComposeTest() { every { startCustomTabsActivity(any()) } just runs } private var onNavigateBackCalled = false + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val mutableEventFlow = bufferedMutableSharedFlow<NewDeviceNoticeTwoFactorEvent>() private val viewModel = mockk<NewDeviceNoticeTwoFactorViewModel>(relaxed = true) { + every { stateFlow } returns mutableStateFlow every { eventFlow } returns mutableEventFlow } @@ -31,7 +39,7 @@ class NewDeviceNoticeTwoFactorScreenTest : BaseComposeTest() { fun setUp() { composeTestRule.setContent { NewDeviceNoticeTwoFactorScreen( - onNavigateBack = { onNavigateBackCalled = true }, + onNavigateBackToVault = { onNavigateBackCalled = true }, intentManager, viewModel = viewModel, ) @@ -108,7 +116,118 @@ class NewDeviceNoticeTwoFactorScreenTest : BaseComposeTest() { @Test fun `RemindMeLaterClick should call OnNavigateBack`() { - mutableEventFlow.tryEmit(NewDeviceNoticeTwoFactorEvent.NavigateBack) + mutableEventFlow.tryEmit(NewDeviceNoticeTwoFactorEvent.NavigateBackToVault) assertTrue(onNavigateBackCalled) } + + @Test + fun `remind me later button visibility should update according to state`() { + composeTestRule + .onNodeWithText("Remind me later") + .performScrollTo() + .assertIsDisplayed() + + mutableStateFlow.update { + it.copy(shouldShowRemindMeLater = false) + } + composeTestRule + .onNodeWithText("Remind me later") + .assertDoesNotExist() + } + + @Test + @Suppress("MaxLineLength") + fun `turn on two factor dialog should be shown or hidden according to the state`() { + composeTestRule.onNode(isDialog()).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = NewDeviceNoticeTwoFactorDialogState.TurnOnTwoFactorDialog, + ) + } + + composeTestRule.onNode(isDialog()).assertIsDisplayed() + + composeTestRule + .onNodeWithText("Continue to web app", substring = true, ignoreCase = true) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText( + "Make your account more secure by setting up two-step login in the Bitwarden web app.", + substring = true, + ignoreCase = true, + ) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("Continue", substring = true, ignoreCase = true) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("Cancel", substring = true, ignoreCase = true) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `change account email dialog should be shown or hidden according to the state`() { + composeTestRule.onNode(isDialog()).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = NewDeviceNoticeTwoFactorDialogState.ChangeAccountEmailDialog, + ) + } + + composeTestRule.onNode(isDialog()).assertIsDisplayed() + + composeTestRule + .onNodeWithText("Continue to web app", substring = true, ignoreCase = true) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText( + "You can change your account email on the Bitwarden web app.", + substring = true, + ignoreCase = true, + ) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("Continue", substring = true, ignoreCase = true) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("Cancel", substring = true, ignoreCase = true) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `dialog should be hidden according to the state`() { + composeTestRule.onNode(isDialog()).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = NewDeviceNoticeTwoFactorDialogState.ChangeAccountEmailDialog, + ) + } + + composeTestRule.onNode(isDialog()).assertIsDisplayed() + + mutableStateFlow.update { + it.copy( + dialogState = null, + ) + } + + composeTestRule.onNode(isDialog()).assertDoesNotExist() + } } + +private val DEFAULT_STATE = + NewDeviceNoticeTwoFactorState( + shouldShowRemindMeLater = true, + dialogState = null, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorViewModelTest.kt index 11d26f317..113012518 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/newdevicenotice/NewDeviceNoticeTwoFactorViewModelTest.kt @@ -1,39 +1,101 @@ package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice import app.cash.turbine.test +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeDisplayStatus +import com.x8bit.bitwarden.data.auth.datasource.disk.model.NewDeviceNoticeState +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorDialogState.ChangeAccountEmailDialog +import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorDialogState.TurnOnTwoFactorDialog import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class NewDeviceNoticeTwoFactorViewModelTest : BaseViewModelTest() { private val environmentRepository = FakeEnvironmentRepository() + private val authRepository = mockk<AuthRepository> { + every { getNewDeviceNoticeState() } returns NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN, + lastSeenDate = null, + ) + every { setNewDeviceNoticeState(any()) } just runs + } + + private val featureFlagManager = mockk<FeatureFlagManager>(relaxed = true) { + every { getFeatureFlag(FlagKey.NewDevicePermanentDismiss) } returns false + every { getFeatureFlag(FlagKey.NewDeviceTemporaryDismiss) } returns true + } @Test - fun `ChangeAccountEmailClick should emit NavigateToChangeAccountEmail`() = runTest { + fun `initial state should be correct with NewDevicePermanentDismiss flag false`() = runTest { val viewModel = createViewModel() - viewModel.eventFlow.test { - viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.ChangeAccountEmailClick) + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + } + } + + @Test + fun `initial state should be correct with NewDevicePermanentDismiss flag true`() = runTest { + every { featureFlagManager.getFeatureFlag(FlagKey.NewDevicePermanentDismiss) } returns true + val viewModel = createViewModel() + viewModel.stateFlow.test { assertEquals( - NewDeviceNoticeTwoFactorEvent.NavigateToChangeAccountEmail( - url = "https://vault.bitwarden.com/#/settings/account", - ), + DEFAULT_STATE.copy(shouldShowRemindMeLater = false), awaitItem(), ) } } @Test - fun `TurnOnTwoFactorClick should emit NavigateToTurnOnTwoFactor`() = runTest { + fun `initial state should be correct with email from state handle`() = runTest { val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + } + } + + @Test + fun `ChangeAccountEmailClick should should change dialog state to ChangeAccountEmailDialog`() = + runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.ChangeAccountEmailClick) + assertEquals( + DEFAULT_STATE.copy(dialogState = ChangeAccountEmailDialog), + viewModel.stateFlow.value, + ) + } + } + + @Test + fun `TurnOnTwoFactorClick should should change dialog state to TurnOnTwoFactorDialog`() = + runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.TurnOnTwoFactorClick) + assertEquals( + DEFAULT_STATE.copy(dialogState = TurnOnTwoFactorDialog), + viewModel.stateFlow.value, + ) + } + } + + @Test + fun `DismissDialogClick should should change dialog state to null`() = runTest { + val viewModel = createViewModel() + viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.TurnOnTwoFactorClick) viewModel.eventFlow.test { - viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.TurnOnTwoFactorClick) + viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.DismissDialogClick) assertEquals( - NewDeviceNoticeTwoFactorEvent.NavigateToTurnOnTwoFactor( - url = "https://vault.bitwarden.com/#/settings/security/two-factor", - ), - awaitItem(), + DEFAULT_STATE, + viewModel.stateFlow.value, ) } } @@ -44,14 +106,77 @@ class NewDeviceNoticeTwoFactorViewModelTest : BaseViewModelTest() { viewModel.eventFlow.test { viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.RemindMeLaterClick) assertEquals( - NewDeviceNoticeTwoFactorEvent.NavigateBack, + NewDeviceNoticeTwoFactorEvent.NavigateBackToVault, awaitItem(), ) } } + @Test + @Suppress("MaxLineLength") + fun `ContinueDialogClick should emit NavigateToTurnOnTwoFactor if dialog state is TurnOnTwoFactorDialog`() = + runTest { + val viewModel = createViewModel() + viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.TurnOnTwoFactorClick) + viewModel.eventFlow.test { + viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.ContinueDialogClick) + assertEquals( + NewDeviceNoticeTwoFactorEvent.NavigateToTurnOnTwoFactor( + url = "https://vault.bitwarden.com/#/settings/security/two-factor", + ), + awaitItem(), + ) + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `ContinueDialogClick should emit NavigateToChangeAccountEmail if dialog state is ChangeAccountEmailClick`() = + runTest { + val viewModel = createViewModel() + viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.ChangeAccountEmailClick) + viewModel.eventFlow.test { + viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.ContinueDialogClick) + assertEquals( + NewDeviceNoticeTwoFactorEvent.NavigateToChangeAccountEmail( + url = "https://vault.bitwarden.com/#/settings/account", + ), + awaitItem(), + ) + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `ContinueDialogClick should return if dialog state is null`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.ContinueDialogClick) + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + } + } + private fun createViewModel(): NewDeviceNoticeTwoFactorViewModel = NewDeviceNoticeTwoFactorViewModel( + authRepository = authRepository, environmentRepository = environmentRepository, + featureFlagManager = featureFlagManager, ) } + +private val DEFAULT_STATE = + NewDeviceNoticeTwoFactorState( + shouldShowRemindMeLater = true, + dialogState = null, + ) 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 index 07b34f118..9d8dd79b8 100644 --- 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 @@ -117,6 +117,8 @@ private val DEFAULT_MAP_VALUE: Map<FlagKey<Any>, Any> = mapOf( FlagKey.CredentialExchangeProtocolImport to true, FlagKey.CredentialExchangeProtocolExport to true, FlagKey.AppReviewPrompt to true, + FlagKey.NewDeviceTemporaryDismiss to true, + FlagKey.NewDevicePermanentDismiss to true, ) private val UPDATED_MAP_VALUE: Map<FlagKey<Any>, Any> = mapOf( @@ -130,6 +132,8 @@ private val UPDATED_MAP_VALUE: Map<FlagKey<Any>, Any> = mapOf( FlagKey.CredentialExchangeProtocolImport to false, FlagKey.CredentialExchangeProtocolExport to false, FlagKey.AppReviewPrompt to false, + FlagKey.NewDeviceTemporaryDismiss to false, + FlagKey.NewDevicePermanentDismiss to false, ) private val DEFAULT_STATE = DebugMenuState( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt index 16e327f6f..f5520654c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -264,6 +264,16 @@ class RootNavScreenTest : BaseComposeTest() { navOptions = expectedNavOptions, ) } + + // Make sure navigating to new device two factor works as expected: + rootNavStateFlow.value = + RootNavState.NewDeviceTwoFactorNotice(email = "example@bitwarden.com") + composeTestRule.runOnIdle { + fakeNavHostController.assertLastNavigation( + route = "new_device_notice/example@bitwarden.com", + navOptions = expectedNavOptions, + ) + } } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index c82a14d7e..730f8da39 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -43,6 +43,7 @@ class RootNavViewModelTest : BaseViewModelTest() { every { userStateFlow } returns mutableUserStateFlow every { authStateFlow } returns mutableAuthStateFlow every { showWelcomeCarousel } returns false + every { checkUserNeedsNewDeviceTwoFactorNotice() } returns false } private val mockAuthRepository = mockk<AuthRepository>(relaxed = true) @@ -1333,6 +1334,45 @@ class RootNavViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `when the active user has an unlocked vault and they need to be shown the new device notice the nav state should be NewDeviceTwoFactorNotice`() { + every { authRepository.checkUserNeedsNewDeviceTwoFactorNotice() } returns true + mutableUserStateFlow.tryEmit( + UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + environment = Environment.Us, + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = emptyList(), + needsMasterPassword = false, + trustedDevice = null, + hasMasterPassword = true, + isUsingKeyConnector = false, + onboardingStatus = OnboardingStatus.COMPLETE, + firstTimeState = FirstTimeState( + showImportLoginsCard = true, + ), + ), + ), + ), + ) + val viewModel = createViewModel() + assertEquals( + RootNavState.NewDeviceTwoFactorNotice("email"), + viewModel.stateFlow.value, + ) + } + private fun createViewModel(): RootNavViewModel = RootNavViewModel( authRepository = authRepository,