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 7f207df505..ed5a90cfa5 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 + + /** + * 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 a702974e84..302a2ed574 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 0000000000..2f9a1f8cae --- /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 b9102b1460..cf96efda0c 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 0902f24370..5c305c099f 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 353f955e9f..1b2bf1ea7b 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 { CredentialExchangeProtocolImport, CredentialExchangeProtocolExport, AppReviewPrompt, + NewDevicePermanentDismiss, + NewDeviceTemporaryDismiss, ) } } @@ -141,6 +143,24 @@ sealed class FlagKey { override val isRemotelyConfigured: Boolean = true } + /** + * Data object holding the feature flag key for the New Device Temporary Dismiss feature. + */ + data object NewDeviceTemporaryDismiss : FlagKey() { + 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() { + 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 dca5b9ec38..2be6f4a473 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 4313b461df..5967772744 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 32a20f9cbc..78b2d256f5 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 1e60f58fb2..6c2896900a 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 7190232e73..3cd9c30b3e 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 b342f8c8fa..5ab6802904 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( - 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 d85c87b046..3783148fe2 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 FlagKey.ListItemContent( FlagKey.CredentialExchangeProtocolExport, FlagKey.AppReviewPrompt, FlagKey.CipherKeyEncryption, + FlagKey.NewDevicePermanentDismiss, + FlagKey.NewDeviceTemporaryDismiss, -> BooleanFlagItem( label = flagKey.getDisplayLabel(), key = flagKey as FlagKey, @@ -81,4 +83,6 @@ private fun FlagKey.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 4bb22e3fa8..bde02656ce 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 e4c03861a6..b9a4e62cd6 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 1c96b4a948..56f1ac7648 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 c225460a5a..f3b88d3b21 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? 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. Review flow launched! Copy private key + You can change your account email on the Bitwarden web app. diff --git a/app/src/main/res/values/strings_non_localized.xml b/app/src/main/res/values/strings_non_localized.xml index 263a42e3b6..221fd54720 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 @@ 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. App Review Prompt Cipher Key Encryption"> + New device notice permanent dismiss"> + New device notice temporary dismiss"> 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 03ccab4271..40e51ecb5b 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 f920cb5c69..479a3809bb 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?>() private val storedOnboardingStatus = mutableMapOf() private val storedShowImportLogins = mutableMapOf() + private val storedNewDeviceNoticeState = mutableMapOf() 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 934c3095ec..e2165e4015 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 a0deccade5..587ecd95de 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 2b6e123327..2418d96958 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() + private var onNavigateBackToVaultCalled = false private var onNavigateToTwoFactorOptionsCalled = false private val viewModel = mockk(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 6c401286cc..6e8b722714 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 { + every { getNewDeviceNoticeState() } returns NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN, + lastSeenDate = null, + ) + every { setNewDeviceNoticeState(any()) } just runs + every { checkUserNeedsNewDeviceTwoFactorNotice() } returns true + } + + private val featureFlagManager = mockk(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 37f7055b14..00fd152926 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() private val viewModel = mockk(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 11d26f317c..1130125188 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 { + every { getNewDeviceNoticeState() } returns NewDeviceNoticeState( + displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN, + lastSeenDate = null, + ) + every { setNewDeviceNoticeState(any()) } just runs + } + + private val featureFlagManager = mockk(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 07b34f1186..9d8dd79b81 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, 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, Any> = mapOf( @@ -130,6 +132,8 @@ private val UPDATED_MAP_VALUE: Map, 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 16e327f6fc..f5520654c0 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 c82a14d7ec..730f8da397 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(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,