mirror of
https://github.com/bitwarden/android.git
synced 2025-02-22 16:49:13 +03:00
[PM-8217] New device two factor notice (#4508)
Co-authored-by: Federico Maccaroni <fedemkr@gmail.com>
This commit is contained in:
parent
ae8db9256c
commit
a35ec8cf3c
29 changed files with 1343 additions and 51 deletions
app/src
main
java/com/x8bit/bitwarden
data
auth
datasource/disk
repository
platform/manager/model
ui
auth/feature/newdevicenotice
NewDeviceNoticeEmailAccessNavigation.ktNewDeviceNoticeEmailAccessScreen.ktNewDeviceNoticeEmailAccessViewModel.ktNewDeviceNoticeTwoFactorNavigation.ktNewDeviceNoticeTwoFactorScreen.ktNewDeviceNoticeTwoFactorViewModel.kt
platform/feature
debugmenu/components
rootnav
vaultunlocked
res/values
test/java/com/x8bit/bitwarden
data
auth
platform/manager
ui
auth/feature/newdevicenotice
NewDeviceNoticeEmailAccessScreenTest.ktNewDeviceNoticeEmailAccessViewModelTest.ktNewDeviceNoticeTwoFactorScreenTest.ktNewDeviceNoticeTwoFactorViewModelTest.kt
platform/feature
|
@ -1,6 +1,7 @@
|
||||||
package com.x8bit.bitwarden.data.auth.datasource.disk
|
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.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.OnboardingStatus
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
|
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
||||||
|
@ -328,7 +329,17 @@ interface AuthDiskSource {
|
||||||
fun storeShowImportLogins(userId: String, showImportLogins: Boolean?)
|
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?>
|
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?)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,8 @@ package com.x8bit.bitwarden.data.auth.datasource.disk
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
|
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.OnboardingStatus
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
|
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
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 USES_KEY_CONNECTOR = "usesKeyConnector"
|
||||||
private const val ONBOARDING_STATUS_KEY = "onboardingStatus"
|
private const val ONBOARDING_STATUS_KEY = "onboardingStatus"
|
||||||
private const val SHOW_IMPORT_LOGINS_KEY = "showImportLogins"
|
private const val SHOW_IMPORT_LOGINS_KEY = "showImportLogins"
|
||||||
|
private const val NEW_DEVICE_NOTICE_STATE = "newDeviceNoticeState"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Primary implementation of [AuthDiskSource].
|
* Primary implementation of [AuthDiskSource].
|
||||||
|
@ -471,6 +474,22 @@ class AuthDiskSourceImpl(
|
||||||
getMutableShowImportLoginsFlow(userId)
|
getMutableShowImportLoginsFlow(userId)
|
||||||
.onSubscription { emit(getShowImportLogins(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 =
|
private fun generateAndStoreUniqueAppId(): String =
|
||||||
UUID
|
UUID
|
||||||
.randomUUID()
|
.randomUUID()
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
package com.x8bit.bitwarden.data.auth.repository
|
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.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.disk.model.OnboardingStatus
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
|
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
|
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.
|
* Update the value of the onboarding status for the user.
|
||||||
*/
|
*/
|
||||||
fun setOnboardingStatus(userId: String, status: OnboardingStatus?)
|
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?)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.AccountJson
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
|
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.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.OnboardingStatus
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountResponseJson
|
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.manager.util.getActivePolicies
|
||||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
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.bufferedMutableSharedFlow
|
||||||
import com.x8bit.bitwarden.data.platform.repository.util.toEnvironmentUrls
|
import com.x8bit.bitwarden.data.platform.repository.util.toEnvironmentUrls
|
||||||
import com.x8bit.bitwarden.data.platform.util.asFailure
|
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.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import java.time.ZonedDateTime
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1333,6 +1337,89 @@ class AuthRepositoryImpl(
|
||||||
authDiskSource.storeOnboardingStatus(userId = userId, onboardingStatus = status)
|
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")
|
@Suppress("CyclomaticComplexMethod")
|
||||||
private suspend fun validatePasswordAgainstPolicy(
|
private suspend fun validatePasswordAgainstPolicy(
|
||||||
password: String,
|
password: String,
|
||||||
|
|
|
@ -36,6 +36,8 @@ sealed class FlagKey<out T : Any> {
|
||||||
CredentialExchangeProtocolImport,
|
CredentialExchangeProtocolImport,
|
||||||
CredentialExchangeProtocolExport,
|
CredentialExchangeProtocolExport,
|
||||||
AppReviewPrompt,
|
AppReviewPrompt,
|
||||||
|
NewDevicePermanentDismiss,
|
||||||
|
NewDeviceTemporaryDismiss,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -141,6 +143,24 @@ sealed class FlagKey<out T : Any> {
|
||||||
override val isRemotelyConfigured: Boolean = true
|
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
|
//region Dummy keys for testing
|
||||||
/**
|
/**
|
||||||
* Data object holding the key for a [Boolean] flag to be used in tests.
|
* Data object holding the key for a [Boolean] flag to be used in tests.
|
||||||
|
|
|
@ -41,6 +41,7 @@ fun NavController.navigateToNewDeviceNoticeEmailAccess(
|
||||||
* Add the new device notice email access screen to the nav graph.
|
* Add the new device notice email access screen to the nav graph.
|
||||||
*/
|
*/
|
||||||
fun NavGraphBuilder.newDeviceNoticeEmailAccessDestination(
|
fun NavGraphBuilder.newDeviceNoticeEmailAccessDestination(
|
||||||
|
onNavigateBackToVault: () -> Unit,
|
||||||
onNavigateToTwoFactorOptions: () -> Unit,
|
onNavigateToTwoFactorOptions: () -> Unit,
|
||||||
) {
|
) {
|
||||||
composableWithSlideTransitions(
|
composableWithSlideTransitions(
|
||||||
|
@ -50,6 +51,7 @@ fun NavGraphBuilder.newDeviceNoticeEmailAccessDestination(
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
NewDeviceNoticeEmailAccessScreen(
|
NewDeviceNoticeEmailAccessScreen(
|
||||||
|
onNavigateBackToVault = onNavigateBackToVault,
|
||||||
onNavigateToTwoFactorOptions = onNavigateToTwoFactorOptions,
|
onNavigateToTwoFactorOptions = onNavigateToTwoFactorOptions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,6 +44,7 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun NewDeviceNoticeEmailAccessScreen(
|
fun NewDeviceNoticeEmailAccessScreen(
|
||||||
|
onNavigateBackToVault: () -> Unit,
|
||||||
onNavigateToTwoFactorOptions: () -> Unit,
|
onNavigateToTwoFactorOptions: () -> Unit,
|
||||||
viewModel: NewDeviceNoticeEmailAccessViewModel = hiltViewModel(),
|
viewModel: NewDeviceNoticeEmailAccessViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
@ -51,6 +52,7 @@ fun NewDeviceNoticeEmailAccessScreen(
|
||||||
EventsEffect(viewModel = viewModel) { event ->
|
EventsEffect(viewModel = viewModel) { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
NavigateToTwoFactorOptions -> onNavigateToTwoFactorOptions()
|
NavigateToTwoFactorOptions -> onNavigateToTwoFactorOptions()
|
||||||
|
NewDeviceNoticeEmailAccessEvent.NavigateBackToVault -> onNavigateBackToVault()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,12 @@ package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.lifecycle.SavedStateHandle
|
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.ContinueClick
|
||||||
import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessAction.EmailAccessToggle
|
import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessAction.EmailAccessToggle
|
||||||
import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessEvent.NavigateToTwoFactorOptions
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import java.time.ZonedDateTime
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val KEY_STATE = "state"
|
private const val KEY_STATE = "state"
|
||||||
|
@ -18,6 +25,8 @@ private const val KEY_STATE = "state"
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class NewDeviceNoticeEmailAccessViewModel @Inject constructor(
|
class NewDeviceNoticeEmailAccessViewModel @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val featureFlagManager: FeatureFlagManager,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : BaseViewModel<
|
) : BaseViewModel<
|
||||||
NewDeviceNoticeEmailAccessState,
|
NewDeviceNoticeEmailAccessState,
|
||||||
|
@ -38,8 +47,24 @@ class NewDeviceNoticeEmailAccessViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleContinueClick() {
|
private fun handleContinueClick() {
|
||||||
// TODO PM-8217: update new device notice status and navigate accordingly
|
if (state.isEmailAccessEnabled) {
|
||||||
sendEvent(NavigateToTwoFactorOptions)
|
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) {
|
private fun handleEmailAccessToggle(action: EmailAccessToggle) {
|
||||||
|
@ -66,6 +91,11 @@ sealed class NewDeviceNoticeEmailAccessEvent {
|
||||||
* Navigates to the Two Factor Options screen.
|
* Navigates to the Two Factor Options screen.
|
||||||
*/
|
*/
|
||||||
data object NavigateToTwoFactorOptions : NewDeviceNoticeEmailAccessEvent()
|
data object NavigateToTwoFactorOptions : NewDeviceNoticeEmailAccessEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigates back.
|
||||||
|
*/
|
||||||
|
data object NavigateBackToVault : NewDeviceNoticeEmailAccessEvent()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -23,13 +23,13 @@ fun NavController.navigateToNewDeviceNoticeTwoFactor(
|
||||||
* Add the new device notice two factor screen to the nav graph.
|
* Add the new device notice two factor screen to the nav graph.
|
||||||
*/
|
*/
|
||||||
fun NavGraphBuilder.newDeviceNoticeTwoFactorDestination(
|
fun NavGraphBuilder.newDeviceNoticeTwoFactorDestination(
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBackToVault: () -> Unit,
|
||||||
) {
|
) {
|
||||||
composableWithSlideTransitions(
|
composableWithSlideTransitions(
|
||||||
route = NEW_DEVICE_NOTICE_TWO_FACTOR_ROUTE,
|
route = NEW_DEVICE_NOTICE_TWO_FACTOR_ROUTE,
|
||||||
) {
|
) {
|
||||||
NewDeviceNoticeTwoFactorScreen(
|
NewDeviceNoticeTwoFactorScreen(
|
||||||
onNavigateBack = onNavigateBack,
|
onNavigateBackToVault = onNavigateBackToVault,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
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.compose.ui.unit.dp
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.x8bit.bitwarden.R
|
import com.x8bit.bitwarden.R
|
||||||
import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.ChangeAccountEmailClick
|
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.RemindMeLaterClick
|
||||||
import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.TurnOnTwoFactorClick
|
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.NavigateToChangeAccountEmail
|
||||||
import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorEvent.NavigateToTurnOnTwoFactor
|
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.EventsEffect
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
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.BitwardenFilledButton
|
||||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
|
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.scaffold.BitwardenScaffold
|
||||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||||
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
|
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
|
||||||
|
@ -43,10 +48,11 @@ import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun NewDeviceNoticeTwoFactorScreen(
|
fun NewDeviceNoticeTwoFactorScreen(
|
||||||
onNavigateBack: () -> Unit,
|
onNavigateBackToVault: () -> Unit,
|
||||||
intentManager: IntentManager = LocalIntentManager.current,
|
intentManager: IntentManager = LocalIntentManager.current,
|
||||||
viewModel: NewDeviceNoticeTwoFactorViewModel = hiltViewModel(),
|
viewModel: NewDeviceNoticeTwoFactorViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||||
EventsEffect(viewModel = viewModel) { event ->
|
EventsEffect(viewModel = viewModel) { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
is NavigateToTurnOnTwoFactor -> {
|
is NavigateToTurnOnTwoFactor -> {
|
||||||
|
@ -57,10 +63,28 @@ fun NewDeviceNoticeTwoFactorScreen(
|
||||||
intentManager.launchUri(event.url.toUri())
|
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 {
|
BitwardenScaffold {
|
||||||
NewDeviceNoticeTwoFactorContent(
|
NewDeviceNoticeTwoFactorContent(
|
||||||
onTurnOnTwoFactorClick = {
|
onTurnOnTwoFactorClick = {
|
||||||
|
@ -72,6 +96,7 @@ fun NewDeviceNoticeTwoFactorScreen(
|
||||||
onRemindMeLaterClick = {
|
onRemindMeLaterClick = {
|
||||||
viewModel.trySendAction(RemindMeLaterClick)
|
viewModel.trySendAction(RemindMeLaterClick)
|
||||||
},
|
},
|
||||||
|
state = state,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -84,6 +109,7 @@ private fun NewDeviceNoticeTwoFactorContent(
|
||||||
onTurnOnTwoFactorClick: () -> Unit,
|
onTurnOnTwoFactorClick: () -> Unit,
|
||||||
onChangeAccountEmailClick: () -> Unit,
|
onChangeAccountEmailClick: () -> Unit,
|
||||||
onRemindMeLaterClick: () -> Unit,
|
onRemindMeLaterClick: () -> Unit,
|
||||||
|
state: NewDeviceNoticeTwoFactorState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
@ -100,6 +126,7 @@ private fun NewDeviceNoticeTwoFactorContent(
|
||||||
onTurnOnTwoFactorClick = onTurnOnTwoFactorClick,
|
onTurnOnTwoFactorClick = onTurnOnTwoFactorClick,
|
||||||
onChangeAccountEmailClick = onChangeAccountEmailClick,
|
onChangeAccountEmailClick = onChangeAccountEmailClick,
|
||||||
onRemindMeLaterClick = onRemindMeLaterClick,
|
onRemindMeLaterClick = onRemindMeLaterClick,
|
||||||
|
state = state,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||||
}
|
}
|
||||||
|
@ -142,6 +169,7 @@ private fun ColumnScope.MainContent(
|
||||||
onTurnOnTwoFactorClick: () -> Unit,
|
onTurnOnTwoFactorClick: () -> Unit,
|
||||||
onChangeAccountEmailClick: () -> Unit,
|
onChangeAccountEmailClick: () -> Unit,
|
||||||
onRemindMeLaterClick: () -> Unit,
|
onRemindMeLaterClick: () -> Unit,
|
||||||
|
state: NewDeviceNoticeTwoFactorState,
|
||||||
) {
|
) {
|
||||||
BitwardenFilledButton(
|
BitwardenFilledButton(
|
||||||
label = stringResource(R.string.turn_on_two_step_login),
|
label = stringResource(R.string.turn_on_two_step_login),
|
||||||
|
@ -158,13 +186,15 @@ private fun ColumnScope.MainContent(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
if (state.shouldShowRemindMeLater) {
|
||||||
BitwardenOutlinedButton(
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
label = stringResource(R.string.remind_me_later),
|
BitwardenOutlinedButton(
|
||||||
onClick = onRemindMeLaterClick,
|
label = stringResource(R.string.remind_me_later),
|
||||||
modifier = Modifier
|
onClick = onRemindMeLaterClick,
|
||||||
.fillMaxWidth(),
|
modifier = Modifier
|
||||||
)
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewScreenSizes
|
@PreviewScreenSizes
|
||||||
|
@ -175,6 +205,9 @@ private fun NewDeviceNoticeTwoFactorScreen_preview() {
|
||||||
onTurnOnTwoFactorClick = {},
|
onTurnOnTwoFactorClick = {},
|
||||||
onChangeAccountEmailClick = {},
|
onChangeAccountEmailClick = {},
|
||||||
onRemindMeLaterClick = {},
|
onRemindMeLaterClick = {},
|
||||||
|
state = NewDeviceNoticeTwoFactorState(
|
||||||
|
shouldShowRemindMeLater = true,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,27 @@
|
||||||
package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice
|
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.EnvironmentRepository
|
||||||
import com.x8bit.bitwarden.data.platform.repository.util.baseWebVaultUrlOrDefault
|
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.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.RemindMeLaterClick
|
||||||
import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeTwoFactorAction.TurnOnTwoFactorClick
|
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.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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -14,9 +29,19 @@ import javax.inject.Inject
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class NewDeviceNoticeTwoFactorViewModel @Inject constructor(
|
class NewDeviceNoticeTwoFactorViewModel @Inject constructor(
|
||||||
|
val authRepository: AuthRepository,
|
||||||
val environmentRepository: EnvironmentRepository,
|
val environmentRepository: EnvironmentRepository,
|
||||||
) : BaseViewModel<Unit, NewDeviceNoticeTwoFactorEvent, NewDeviceNoticeTwoFactorAction>(
|
val featureFlagManager: FeatureFlagManager,
|
||||||
initialState = Unit,
|
) : BaseViewModel<
|
||||||
|
NewDeviceNoticeTwoFactorState,
|
||||||
|
NewDeviceNoticeTwoFactorEvent,
|
||||||
|
NewDeviceNoticeTwoFactorAction,
|
||||||
|
>(
|
||||||
|
initialState = NewDeviceNoticeTwoFactorState(
|
||||||
|
shouldShowRemindMeLater = !featureFlagManager.getFeatureFlag(
|
||||||
|
FlagKey.NewDevicePermanentDismiss,
|
||||||
|
),
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
private val webTwoFactorUrl: String
|
private val webTwoFactorUrl: String
|
||||||
get() {
|
get() {
|
||||||
|
@ -38,22 +63,51 @@ class NewDeviceNoticeTwoFactorViewModel @Inject constructor(
|
||||||
|
|
||||||
override fun handleAction(action: NewDeviceNoticeTwoFactorAction) {
|
override fun handleAction(action: NewDeviceNoticeTwoFactorAction) {
|
||||||
when (action) {
|
when (action) {
|
||||||
ChangeAccountEmailClick -> sendEvent(
|
ChangeAccountEmailClick -> updateDialogState(newState = ChangeAccountEmailDialog)
|
||||||
NewDeviceNoticeTwoFactorEvent.NavigateToChangeAccountEmail(
|
|
||||||
url = webAccountUrl,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
TurnOnTwoFactorClick -> sendEvent(
|
TurnOnTwoFactorClick -> updateDialogState(newState = TurnOnTwoFactorDialog)
|
||||||
NewDeviceNoticeTwoFactorEvent.NavigateToTurnOnTwoFactor(
|
|
||||||
url = webTwoFactorUrl,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
RemindMeLaterClick -> {
|
RemindMeLaterClick -> handleRemindMeLater()
|
||||||
// TODO PM-8217: Add logic to remind me later
|
|
||||||
sendEvent(NewDeviceNoticeTwoFactorEvent.NavigateBack)
|
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()
|
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.
|
* User tapped the remind me later button.
|
||||||
*/
|
*/
|
||||||
data object RemindMeLaterClick : NewDeviceNoticeTwoFactorAction()
|
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(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,8 @@ fun <T : Any> FlagKey<T>.ListItemContent(
|
||||||
FlagKey.CredentialExchangeProtocolExport,
|
FlagKey.CredentialExchangeProtocolExport,
|
||||||
FlagKey.AppReviewPrompt,
|
FlagKey.AppReviewPrompt,
|
||||||
FlagKey.CipherKeyEncryption,
|
FlagKey.CipherKeyEncryption,
|
||||||
|
FlagKey.NewDevicePermanentDismiss,
|
||||||
|
FlagKey.NewDeviceTemporaryDismiss,
|
||||||
-> BooleanFlagItem(
|
-> BooleanFlagItem(
|
||||||
label = flagKey.getDisplayLabel(),
|
label = flagKey.getDisplayLabel(),
|
||||||
key = flagKey as FlagKey<Boolean>,
|
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.CredentialExchangeProtocolExport -> stringResource(R.string.cxp_export)
|
||||||
FlagKey.AppReviewPrompt -> stringResource(R.string.app_review_prompt)
|
FlagKey.AppReviewPrompt -> stringResource(R.string.app_review_prompt)
|
||||||
FlagKey.CipherKeyEncryption -> stringResource(R.string.cipher_key_encryption)
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.auth.navigateToAuthGraph
|
||||||
import com.x8bit.bitwarden.ui.auth.feature.completeregistration.navigateToCompleteRegistration
|
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.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.REMOVE_PASSWORD_ROUTE
|
||||||
import com.x8bit.bitwarden.ui.auth.feature.removepassword.navigateToRemovePassword
|
import com.x8bit.bitwarden.ui.auth.feature.removepassword.navigateToRemovePassword
|
||||||
import com.x8bit.bitwarden.ui.auth.feature.removepassword.removePasswordDestination
|
import com.x8bit.bitwarden.ui.auth.feature.removepassword.removePasswordDestination
|
||||||
|
@ -126,6 +127,7 @@ fun RootNavScreen(
|
||||||
is RootNavState.VaultUnlockedForFido2Save,
|
is RootNavState.VaultUnlockedForFido2Save,
|
||||||
is RootNavState.VaultUnlockedForFido2Assertion,
|
is RootNavState.VaultUnlockedForFido2Assertion,
|
||||||
is RootNavState.VaultUnlockedForFido2GetCredentials,
|
is RootNavState.VaultUnlockedForFido2GetCredentials,
|
||||||
|
is RootNavState.NewDeviceTwoFactorNotice,
|
||||||
-> VAULT_UNLOCKED_GRAPH_ROUTE
|
-> VAULT_UNLOCKED_GRAPH_ROUTE
|
||||||
|
|
||||||
RootNavState.OnboardingAccountLockSetup -> SETUP_UNLOCK_AS_ROOT_ROUTE
|
RootNavState.OnboardingAccountLockSetup -> SETUP_UNLOCK_AS_ROOT_ROUTE
|
||||||
|
@ -254,6 +256,13 @@ fun RootNavScreen(
|
||||||
RootNavState.OnboardingStepsComplete -> {
|
RootNavState.OnboardingStepsComplete -> {
|
||||||
navController.navigateToSetupCompleteScreen(rootNavOptions)
|
navController.navigateToSetupCompleteScreen(rootNavOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is RootNavState.NewDeviceTwoFactorNotice -> {
|
||||||
|
navController.navigateToNewDeviceNoticeEmailAccess(
|
||||||
|
emailAddress = currentState.email,
|
||||||
|
navOptions = rootNavOptions,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -103,6 +103,9 @@ class RootNavViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userState.activeAccount.isVaultUnlocked &&
|
||||||
|
authRepository.checkUserNeedsNewDeviceTwoFactorNotice() -> RootNavState.NewDeviceTwoFactorNotice(userState.activeAccount.email)
|
||||||
|
|
||||||
userState.activeAccount.isVaultUnlocked -> {
|
userState.activeAccount.isVaultUnlocked -> {
|
||||||
when (specialCircumstance) {
|
when (specialCircumstance) {
|
||||||
is SpecialCircumstance.AutofillSave -> {
|
is SpecialCircumstance.AutofillSave -> {
|
||||||
|
@ -367,6 +370,14 @@ sealed class RootNavState : Parcelable {
|
||||||
*/
|
*/
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object OnboardingStepsComplete : RootNavState()
|
data object OnboardingStepsComplete : RootNavState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App should show the new device two factor notice screen.
|
||||||
|
*/
|
||||||
|
@Parcelize
|
||||||
|
data class NewDeviceTwoFactorNotice(
|
||||||
|
val email: String,
|
||||||
|
) : RootNavState()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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.navigateToSetupUnlockScreen
|
||||||
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupAutoFillDestination
|
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.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.navigateToSearch
|
||||||
import com.x8bit.bitwarden.ui.platform.feature.search.searchDestination
|
import com.x8bit.bitwarden.ui.platform.feature.search.searchDestination
|
||||||
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.deleteAccountDestination
|
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.deleteAccountDestination
|
||||||
|
@ -232,5 +235,12 @@ fun NavGraphBuilder.vaultUnlockedGraph(
|
||||||
importLoginsScreenDestination(
|
importLoginsScreenDestination(
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
|
newDeviceNoticeEmailAccessDestination(
|
||||||
|
onNavigateBackToVault = { navController.navigateToVaultUnlockedGraph() },
|
||||||
|
onNavigateToTwoFactorOptions = { navController.navigateToNewDeviceNoticeTwoFactor() },
|
||||||
|
)
|
||||||
|
newDeviceNoticeTwoFactorDestination(
|
||||||
|
onNavigateBackToVault = { navController.navigateToVaultUnlockedGraph() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1111,4 +1111,5 @@ Do you want to switch to this account?</string>
|
||||||
<string name="we_couldnt_verify_the_servers_certificate">We couldn’t verify the server’s certificate. The certificate chain or proxy settings on your device or your Bitwarden server may not be set up correctly.</string>
|
<string name="we_couldnt_verify_the_servers_certificate">We couldn’t verify the server’s certificate. The certificate chain or proxy settings on your device or your Bitwarden server may not be set up correctly.</string>
|
||||||
<string name="review_flow_launched">Review flow launched!</string>
|
<string name="review_flow_launched">Review flow launched!</string>
|
||||||
<string name="copy_private_key">Copy private key</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>
|
</resources>
|
||||||
|
|
|
@ -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="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="app_review_prompt">App Review Prompt</string>
|
||||||
<string name="cipher_key_encryption">Cipher Key Encryption</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 -->
|
<!-- /Debug Menu -->
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
@ -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.AccountTokensJson
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
|
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.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.OnboardingStatus
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
|
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
||||||
|
@ -1246,6 +1248,64 @@ class AuthDiskSourceTest {
|
||||||
assertTrue(awaitItem() ?: false)
|
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 = """
|
private const val USER_STATE_JSON = """
|
||||||
|
|
|
@ -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.AuthDiskSource
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
|
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.OnboardingStatus
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
|
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
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 storedPolicies = mutableMapOf<String, List<SyncResponseJson.Policy>?>()
|
||||||
private val storedOnboardingStatus = mutableMapOf<String, OnboardingStatus?>()
|
private val storedOnboardingStatus = mutableMapOf<String, OnboardingStatus?>()
|
||||||
private val storedShowImportLogins = mutableMapOf<String, Boolean?>()
|
private val storedShowImportLogins = mutableMapOf<String, Boolean?>()
|
||||||
|
private val storedNewDeviceNoticeState = mutableMapOf<String, NewDeviceNoticeState?>()
|
||||||
|
|
||||||
override var userState: UserStateJson? = null
|
override var userState: UserStateJson? = null
|
||||||
set(value) {
|
set(value) {
|
||||||
|
@ -298,6 +301,17 @@ class FakeAuthDiskSource : AuthDiskSource {
|
||||||
getMutableShowImportLoginsFlow(userId)
|
getMutableShowImportLoginsFlow(userId)
|
||||||
.onSubscription { emit(getShowImportLogins(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].
|
* Assert the the [isTdeLoginComplete] was stored successfully using the [userId].
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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.AccountTokensJson
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
|
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.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.OnboardingStatus
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
|
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
|
||||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
|
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.AfterEach
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
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.assertNull
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.BeforeEach
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
@ -6468,6 +6471,408 @@ class AuthRepositoryTest {
|
||||||
assertNull(fakeAuthDiskSource.getOnboardingStatus(USER_ID_1))
|
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 {
|
companion object {
|
||||||
private const val UNIQUE_APP_ID = "testUniqueAppId"
|
private const val UNIQUE_APP_ID = "testUniqueAppId"
|
||||||
private const val NAME = "Example Name"
|
private const val NAME = "Example Name"
|
||||||
|
@ -6596,7 +7001,7 @@ class AuthRepositoryTest {
|
||||||
kdfMemory = null,
|
kdfMemory = null,
|
||||||
kdfParallelism = null,
|
kdfParallelism = null,
|
||||||
userDecryptionOptions = null,
|
userDecryptionOptions = null,
|
||||||
isTwoFactorEnabled = false,
|
isTwoFactorEnabled = true,
|
||||||
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
|
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
|
||||||
),
|
),
|
||||||
settings = AccountJson.Settings(
|
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(
|
private val SINGLE_USER_STATE_2 = UserStateJson(
|
||||||
activeUserId = USER_ID_2,
|
activeUserId = USER_ID_2,
|
||||||
accounts = mapOf(
|
accounts = mapOf(
|
||||||
|
|
|
@ -46,4 +46,24 @@ class FlagKeyTest {
|
||||||
fun `AppReviewPrompt default value should be false`() {
|
fun `AppReviewPrompt default value should be false`() {
|
||||||
assertFalse(FlagKey.AppReviewPrompt.defaultValue)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,12 +13,14 @@ import io.mockk.verify
|
||||||
import junit.framework.TestCase.assertTrue
|
import junit.framework.TestCase.assertTrue
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class NewDeviceNoticeEmailAccessScreenTest : BaseComposeTest() {
|
class NewDeviceNoticeEmailAccessScreenTest : BaseComposeTest() {
|
||||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||||
private val mutableEventFlow = bufferedMutableSharedFlow<NewDeviceNoticeEmailAccessEvent>()
|
private val mutableEventFlow = bufferedMutableSharedFlow<NewDeviceNoticeEmailAccessEvent>()
|
||||||
|
private var onNavigateBackToVaultCalled = false
|
||||||
private var onNavigateToTwoFactorOptionsCalled = false
|
private var onNavigateToTwoFactorOptionsCalled = false
|
||||||
private val viewModel = mockk<NewDeviceNoticeEmailAccessViewModel>(relaxed = true) {
|
private val viewModel = mockk<NewDeviceNoticeEmailAccessViewModel>(relaxed = true) {
|
||||||
every { stateFlow } returns mutableStateFlow
|
every { stateFlow } returns mutableStateFlow
|
||||||
|
@ -29,14 +31,21 @@ class NewDeviceNoticeEmailAccessScreenTest : BaseComposeTest() {
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
NewDeviceNoticeEmailAccessScreen(
|
NewDeviceNoticeEmailAccessScreen(
|
||||||
|
onNavigateBackToVault = { onNavigateBackToVaultCalled = true },
|
||||||
onNavigateToTwoFactorOptions = { onNavigateToTwoFactorOptionsCalled = true },
|
onNavigateToTwoFactorOptions = { onNavigateToTwoFactorOptionsCalled = true },
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("MaxLineLength")
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
onNavigateBackToVaultCalled = false
|
||||||
|
onNavigateToTwoFactorOptionsCalled = false
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
fun `Do you have reliable access to your email should be toggled on or off according to the state`() {
|
fun `Do you have reliable access to your email should be toggled on or off according to the state`() {
|
||||||
composeTestRule
|
composeTestRule
|
||||||
.onNodeWithText("Yes, I can reliably access my email", substring = true)
|
.onNodeWithText("Yes, I can reliably access my email", substring = true)
|
||||||
|
@ -70,7 +79,15 @@ class NewDeviceNoticeEmailAccessScreenTest : BaseComposeTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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)
|
mutableEventFlow.tryEmit(NewDeviceNoticeEmailAccessEvent.NavigateToTwoFactorOptions)
|
||||||
assertTrue(onNavigateToTwoFactorOptionsCalled)
|
assertTrue(onNavigateToTwoFactorOptionsCalled)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,12 +2,34 @@ package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import app.cash.turbine.test
|
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 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 kotlinx.coroutines.test.runTest
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
class NewDeviceNoticeEmailAccessViewModelTest : BaseViewModelTest() {
|
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
|
@Test
|
||||||
fun `initial state should be correct with email from state handle`() = runTest {
|
fun `initial state should be correct with email from state handle`() = runTest {
|
||||||
|
@ -30,7 +52,37 @@ class NewDeviceNoticeEmailAccessViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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()
|
val viewModel = createViewModel()
|
||||||
viewModel.eventFlow.test {
|
viewModel.eventFlow.test {
|
||||||
viewModel.trySendAction(NewDeviceNoticeEmailAccessAction.ContinueClick)
|
viewModel.trySendAction(NewDeviceNoticeEmailAccessAction.ContinueClick)
|
||||||
|
@ -46,6 +98,8 @@ class NewDeviceNoticeEmailAccessViewModelTest : BaseViewModelTest() {
|
||||||
it["email_address"] = EMAIL
|
it["email_address"] = EMAIL
|
||||||
},
|
},
|
||||||
): NewDeviceNoticeEmailAccessViewModel = NewDeviceNoticeEmailAccessViewModel(
|
): NewDeviceNoticeEmailAccessViewModel = NewDeviceNoticeEmailAccessViewModel(
|
||||||
|
authRepository = authRepository,
|
||||||
|
featureFlagManager = featureFlagManager,
|
||||||
savedStateHandle = savedStateHandle,
|
savedStateHandle = savedStateHandle,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice
|
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.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performScrollTo
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
@ -13,6 +17,8 @@ import io.mockk.mockk
|
||||||
import io.mockk.runs
|
import io.mockk.runs
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import junit.framework.TestCase.assertTrue
|
import junit.framework.TestCase.assertTrue
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
@ -22,8 +28,10 @@ class NewDeviceNoticeTwoFactorScreenTest : BaseComposeTest() {
|
||||||
every { startCustomTabsActivity(any()) } just runs
|
every { startCustomTabsActivity(any()) } just runs
|
||||||
}
|
}
|
||||||
private var onNavigateBackCalled = false
|
private var onNavigateBackCalled = false
|
||||||
|
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||||
private val mutableEventFlow = bufferedMutableSharedFlow<NewDeviceNoticeTwoFactorEvent>()
|
private val mutableEventFlow = bufferedMutableSharedFlow<NewDeviceNoticeTwoFactorEvent>()
|
||||||
private val viewModel = mockk<NewDeviceNoticeTwoFactorViewModel>(relaxed = true) {
|
private val viewModel = mockk<NewDeviceNoticeTwoFactorViewModel>(relaxed = true) {
|
||||||
|
every { stateFlow } returns mutableStateFlow
|
||||||
every { eventFlow } returns mutableEventFlow
|
every { eventFlow } returns mutableEventFlow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,7 +39,7 @@ class NewDeviceNoticeTwoFactorScreenTest : BaseComposeTest() {
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
NewDeviceNoticeTwoFactorScreen(
|
NewDeviceNoticeTwoFactorScreen(
|
||||||
onNavigateBack = { onNavigateBackCalled = true },
|
onNavigateBackToVault = { onNavigateBackCalled = true },
|
||||||
intentManager,
|
intentManager,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
)
|
)
|
||||||
|
@ -108,7 +116,118 @@ class NewDeviceNoticeTwoFactorScreenTest : BaseComposeTest() {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `RemindMeLaterClick should call OnNavigateBack`() {
|
fun `RemindMeLaterClick should call OnNavigateBack`() {
|
||||||
mutableEventFlow.tryEmit(NewDeviceNoticeTwoFactorEvent.NavigateBack)
|
mutableEventFlow.tryEmit(NewDeviceNoticeTwoFactorEvent.NavigateBackToVault)
|
||||||
assertTrue(onNavigateBackCalled)
|
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,
|
||||||
|
)
|
||||||
|
|
|
@ -1,39 +1,101 @@
|
||||||
package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice
|
package com.x8bit.bitwarden.ui.auth.feature.newdevicenotice
|
||||||
|
|
||||||
import app.cash.turbine.test
|
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.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 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 kotlinx.coroutines.test.runTest
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
class NewDeviceNoticeTwoFactorViewModelTest : BaseViewModelTest() {
|
class NewDeviceNoticeTwoFactorViewModelTest : BaseViewModelTest() {
|
||||||
private val environmentRepository = FakeEnvironmentRepository()
|
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
|
@Test
|
||||||
fun `ChangeAccountEmailClick should emit NavigateToChangeAccountEmail`() = runTest {
|
fun `initial state should be correct with NewDevicePermanentDismiss flag false`() = runTest {
|
||||||
val viewModel = createViewModel()
|
val viewModel = createViewModel()
|
||||||
viewModel.eventFlow.test {
|
viewModel.stateFlow.test {
|
||||||
viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.ChangeAccountEmailClick)
|
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(
|
assertEquals(
|
||||||
NewDeviceNoticeTwoFactorEvent.NavigateToChangeAccountEmail(
|
DEFAULT_STATE.copy(shouldShowRemindMeLater = false),
|
||||||
url = "https://vault.bitwarden.com/#/settings/account",
|
|
||||||
),
|
|
||||||
awaitItem(),
|
awaitItem(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `TurnOnTwoFactorClick should emit NavigateToTurnOnTwoFactor`() = runTest {
|
fun `initial state should be correct with email from state handle`() = runTest {
|
||||||
val viewModel = createViewModel()
|
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.eventFlow.test {
|
||||||
viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.TurnOnTwoFactorClick)
|
viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.DismissDialogClick)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
NewDeviceNoticeTwoFactorEvent.NavigateToTurnOnTwoFactor(
|
DEFAULT_STATE,
|
||||||
url = "https://vault.bitwarden.com/#/settings/security/two-factor",
|
viewModel.stateFlow.value,
|
||||||
),
|
|
||||||
awaitItem(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -44,14 +106,77 @@ class NewDeviceNoticeTwoFactorViewModelTest : BaseViewModelTest() {
|
||||||
viewModel.eventFlow.test {
|
viewModel.eventFlow.test {
|
||||||
viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.RemindMeLaterClick)
|
viewModel.trySendAction(NewDeviceNoticeTwoFactorAction.RemindMeLaterClick)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
NewDeviceNoticeTwoFactorEvent.NavigateBack,
|
NewDeviceNoticeTwoFactorEvent.NavigateBackToVault,
|
||||||
awaitItem(),
|
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 =
|
private fun createViewModel(): NewDeviceNoticeTwoFactorViewModel =
|
||||||
NewDeviceNoticeTwoFactorViewModel(
|
NewDeviceNoticeTwoFactorViewModel(
|
||||||
|
authRepository = authRepository,
|
||||||
environmentRepository = environmentRepository,
|
environmentRepository = environmentRepository,
|
||||||
|
featureFlagManager = featureFlagManager,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val DEFAULT_STATE =
|
||||||
|
NewDeviceNoticeTwoFactorState(
|
||||||
|
shouldShowRemindMeLater = true,
|
||||||
|
dialogState = null,
|
||||||
|
)
|
||||||
|
|
|
@ -117,6 +117,8 @@ private val DEFAULT_MAP_VALUE: Map<FlagKey<Any>, Any> = mapOf(
|
||||||
FlagKey.CredentialExchangeProtocolImport to true,
|
FlagKey.CredentialExchangeProtocolImport to true,
|
||||||
FlagKey.CredentialExchangeProtocolExport to true,
|
FlagKey.CredentialExchangeProtocolExport to true,
|
||||||
FlagKey.AppReviewPrompt to true,
|
FlagKey.AppReviewPrompt to true,
|
||||||
|
FlagKey.NewDeviceTemporaryDismiss to true,
|
||||||
|
FlagKey.NewDevicePermanentDismiss to true,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val UPDATED_MAP_VALUE: Map<FlagKey<Any>, Any> = mapOf(
|
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.CredentialExchangeProtocolImport to false,
|
||||||
FlagKey.CredentialExchangeProtocolExport to false,
|
FlagKey.CredentialExchangeProtocolExport to false,
|
||||||
FlagKey.AppReviewPrompt to false,
|
FlagKey.AppReviewPrompt to false,
|
||||||
|
FlagKey.NewDeviceTemporaryDismiss to false,
|
||||||
|
FlagKey.NewDevicePermanentDismiss to false,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val DEFAULT_STATE = DebugMenuState(
|
private val DEFAULT_STATE = DebugMenuState(
|
||||||
|
|
|
@ -264,6 +264,16 @@ class RootNavScreenTest : BaseComposeTest() {
|
||||||
navOptions = expectedNavOptions,
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -43,6 +43,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
|
||||||
every { userStateFlow } returns mutableUserStateFlow
|
every { userStateFlow } returns mutableUserStateFlow
|
||||||
every { authStateFlow } returns mutableAuthStateFlow
|
every { authStateFlow } returns mutableAuthStateFlow
|
||||||
every { showWelcomeCarousel } returns false
|
every { showWelcomeCarousel } returns false
|
||||||
|
every { checkUserNeedsNewDeviceTwoFactorNotice() } returns false
|
||||||
}
|
}
|
||||||
|
|
||||||
private val mockAuthRepository = mockk<AuthRepository>(relaxed = true)
|
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 =
|
private fun createViewModel(): RootNavViewModel =
|
||||||
RootNavViewModel(
|
RootNavViewModel(
|
||||||
authRepository = authRepository,
|
authRepository = authRepository,
|
||||||
|
|
Loading…
Add table
Reference in a new issue