1
0
Fork 0
mirror of https://github.com/bitwarden/android.git synced 2025-01-30 19:53:47 +03:00

[PM-8217] New device two factor notice ()

Co-authored-by: Federico Maccaroni <fedemkr@gmail.com>
This commit is contained in:
André Bispo 2024-12-27 15:03:33 +00:00 committed by GitHub
parent ae8db9256c
commit a35ec8cf3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1343 additions and 51 deletions

View file

@ -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?)
}

View file

@ -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()

View file

@ -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
}

View file

@ -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?)
}

View file

@ -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,

View file

@ -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.

View file

@ -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,
)
}

View file

@ -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()
}
}

View file

@ -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()
}
/**

View file

@ -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,
)
}
}

View file

@ -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,
),
)
}
}

View file

@ -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(),
)
}

View file

@ -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)
}

View file

@ -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,
)
}
}
}
}

View file

@ -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()
}
/**

View file

@ -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() },
)
}
}

View file

@ -1111,4 +1111,5 @@ Do you want to switch to this account?</string>
<string name="we_couldnt_verify_the_servers_certificate">We couldnt verify the servers 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>

View file

@ -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>

View file

@ -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 = """

View file

@ -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].
*/

View file

@ -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(

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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,
)
}

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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(

View file

@ -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,
)
}
}
}

View file

@ -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,