mirror of
https://github.com/bitwarden/android.git
synced 2026-08-30 17:33:42 -05:00
PM-16630 PM-16621 Add logins action card and add explore generator card to be able to trigger coach marks (#4616)
This commit is contained in:
+30
@@ -361,4 +361,34 @@ interface SettingsDiskSource {
|
||||
* Stores the given [count] completed create send actions for the device.
|
||||
*/
|
||||
fun storeCreateSendActionCount(count: Int?)
|
||||
|
||||
/**
|
||||
* Gets the Boolean value of if the Add Login CoachMark tour has been interacted with.
|
||||
*/
|
||||
fun getShouldShowAddLoginCoachMark(): Boolean?
|
||||
|
||||
/**
|
||||
* Stores a value for if the Add Login CoachMark tour has been interacted with
|
||||
*/
|
||||
fun storeShouldShowAddLoginCoachMark(shouldShow: Boolean?)
|
||||
|
||||
/**
|
||||
* Returns an [Flow] to observe updates to the "ShouldShowAddLoginCoachMark" value.
|
||||
*/
|
||||
fun getShouldShowAddLoginCoachMarkFlow(): Flow<Boolean?>
|
||||
|
||||
/**
|
||||
* Gets the Boolean value of if the Generator CoachMark tour has been interacted with.
|
||||
*/
|
||||
fun getShouldShowGeneratorCoachMark(): Boolean?
|
||||
|
||||
/**
|
||||
* Stores a value for if the Generator CoachMark tour has been interacted with
|
||||
*/
|
||||
fun storeShouldShowGeneratorCoachMark(shouldShow: Boolean?)
|
||||
|
||||
/**
|
||||
* Returns an [Flow] to observe updates to the "ShouldShowGeneratorCoachMark" value.
|
||||
*/
|
||||
fun getShouldShowGeneratorCoachMarkFlow(): Flow<Boolean?>
|
||||
}
|
||||
|
||||
+40
@@ -39,6 +39,8 @@ private const val IS_VAULT_REGISTERED_FOR_EXPORT = "isVaultRegisteredForExport"
|
||||
private const val ADD_ACTION_COUNT = "addActionCount"
|
||||
private const val COPY_ACTION_COUNT = "copyActionCount"
|
||||
private const val CREATE_ACTION_COUNT = "createActionCount"
|
||||
private const val SHOULD_SHOW_ADD_LOGIN_COACH_MARK = "shouldShowAddLoginCoachMark"
|
||||
private const val SHOULD_SHOW_GENERATOR_COACH_MARK = "shouldShowGeneratorCoachMark"
|
||||
|
||||
/**
|
||||
* Primary implementation of [SettingsDiskSource].
|
||||
@@ -78,6 +80,10 @@ class SettingsDiskSourceImpl(
|
||||
|
||||
private val mutableHasUserLoggedInOrCreatedAccountFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableHasSeenAddLoginCoachMarkFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableHasSeenGeneratorCoachMarkFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableScreenCaptureAllowedFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
|
||||
|
||||
@@ -185,6 +191,8 @@ class SettingsDiskSourceImpl(
|
||||
// - screen capture allowed
|
||||
// - show autofill setting badge
|
||||
// - show unlock setting badge
|
||||
// - should show add login coach mark
|
||||
// - should show generator coach mark
|
||||
}
|
||||
|
||||
override fun getAccountBiometricIntegrityValidity(
|
||||
@@ -486,6 +494,38 @@ class SettingsDiskSourceImpl(
|
||||
)
|
||||
}
|
||||
|
||||
override fun getShouldShowAddLoginCoachMark(): Boolean? =
|
||||
getBoolean(key = SHOULD_SHOW_ADD_LOGIN_COACH_MARK)
|
||||
|
||||
override fun storeShouldShowAddLoginCoachMark(shouldShow: Boolean?) {
|
||||
putBoolean(
|
||||
key = SHOULD_SHOW_ADD_LOGIN_COACH_MARK,
|
||||
value = shouldShow,
|
||||
)
|
||||
mutableHasSeenAddLoginCoachMarkFlow.tryEmit(shouldShow)
|
||||
}
|
||||
|
||||
override fun getShouldShowAddLoginCoachMarkFlow(): Flow<Boolean?> =
|
||||
mutableHasSeenAddLoginCoachMarkFlow.onSubscription {
|
||||
emit(getBoolean(key = SHOULD_SHOW_ADD_LOGIN_COACH_MARK))
|
||||
}
|
||||
|
||||
override fun getShouldShowGeneratorCoachMark(): Boolean? =
|
||||
getBoolean(key = SHOULD_SHOW_GENERATOR_COACH_MARK)
|
||||
|
||||
override fun storeShouldShowGeneratorCoachMark(shouldShow: Boolean?) {
|
||||
putBoolean(
|
||||
key = SHOULD_SHOW_GENERATOR_COACH_MARK,
|
||||
value = shouldShow,
|
||||
)
|
||||
mutableHasSeenGeneratorCoachMarkFlow.tryEmit(shouldShow)
|
||||
}
|
||||
|
||||
override fun getShouldShowGeneratorCoachMarkFlow(): Flow<Boolean?> =
|
||||
mutableHasSeenGeneratorCoachMarkFlow.onSubscription {
|
||||
emit(getShouldShowGeneratorCoachMark())
|
||||
}
|
||||
|
||||
private fun getMutableLastSyncFlow(
|
||||
userId: String,
|
||||
): MutableSharedFlow<Instant?> =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.CoachMarkTourType
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -39,6 +40,17 @@ interface FirstTimeActionManager {
|
||||
*/
|
||||
val firstTimeStateFlow: Flow<FirstTimeState>
|
||||
|
||||
/**
|
||||
* Returns observable flow of if a user on the device has seen the Add Login coach mark tour.
|
||||
*/
|
||||
val shouldShowAddLoginCoachMarkFlow: Flow<Boolean>
|
||||
|
||||
/**
|
||||
* Returns observable flow of if a user on the device has seen the Generator screen
|
||||
* coach mark tour.
|
||||
*/
|
||||
val shouldShowGeneratorCoachMarkFlow: Flow<Boolean>
|
||||
|
||||
/**
|
||||
* Get the current [FirstTimeState] of the active user if available, otherwise return
|
||||
* a default configuration.
|
||||
@@ -66,4 +78,9 @@ interface FirstTimeActionManager {
|
||||
* Update the value of the showImportLoginsSettingsBadge status for the active user.
|
||||
*/
|
||||
fun storeShowImportLoginsSettingsBadge(showBadge: Boolean)
|
||||
|
||||
/**
|
||||
* Can be called to indicate that a user has seen the AddLogin coach mark tour.
|
||||
*/
|
||||
fun markCoachMarkTourCompleted(tourCompleted: CoachMarkTourType)
|
||||
}
|
||||
|
||||
+37
@@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.CoachMarkTourType
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||
@@ -154,6 +155,30 @@ class FirstTimeActionManagerImpl @Inject constructor(
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
override val shouldShowAddLoginCoachMarkFlow: Flow<Boolean>
|
||||
get() = settingsDiskSource
|
||||
.getShouldShowAddLoginCoachMarkFlow()
|
||||
.map { it ?: true }
|
||||
.combine(
|
||||
featureFlagManager.getFeatureFlagFlow(FlagKey.OnboardingFlow),
|
||||
) { shouldShow, featureIsEnabled ->
|
||||
// If the feature flag is off always return true so observers know
|
||||
// the card has not been shown.
|
||||
shouldShow && featureIsEnabled
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
override val shouldShowGeneratorCoachMarkFlow: Flow<Boolean>
|
||||
get() = settingsDiskSource
|
||||
.getShouldShowGeneratorCoachMarkFlow()
|
||||
.map { it ?: true }
|
||||
.combine(
|
||||
featureFlagManager.getFeatureFlagFlow(FlagKey.OnboardingFlow),
|
||||
) { shouldShow, featureFlagEnabled ->
|
||||
shouldShow && featureFlagEnabled
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
|
||||
/**
|
||||
* Get the current [FirstTimeState] of the active user if available, otherwise return
|
||||
* a default configuration.
|
||||
@@ -211,6 +236,18 @@ class FirstTimeActionManagerImpl @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
override fun markCoachMarkTourCompleted(tourCompleted: CoachMarkTourType) {
|
||||
when (tourCompleted) {
|
||||
CoachMarkTourType.ADD_LOGIN -> {
|
||||
settingsDiskSource.storeShouldShowAddLoginCoachMark(shouldShow = false)
|
||||
}
|
||||
|
||||
CoachMarkTourType.GENERATOR -> {
|
||||
settingsDiskSource.storeShouldShowGeneratorCoachMark(shouldShow = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation to get a flow of the showImportLogins value which takes
|
||||
* into account if the vault is empty.
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.model
|
||||
|
||||
/**
|
||||
* Enumerated values to represent all the possible coach mark tours that can be
|
||||
* completed.
|
||||
*
|
||||
* @see com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
*/
|
||||
enum class CoachMarkTourType {
|
||||
ADD_LOGIN,
|
||||
GENERATOR,
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import com.x8bit.bitwarden.ui.platform.components.appbar.action.OverflowMenuItem
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard
|
||||
import com.x8bit.bitwarden.ui.platform.components.card.BitwardenInfoCalloutCard
|
||||
import com.x8bit.bitwarden.ui.platform.components.dropdown.BitwardenMultiSelectButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.field.BitwardenPasswordField
|
||||
@@ -319,6 +320,22 @@ private fun ScrollContent(
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
if (state.shouldShowExploreGeneratorCard) {
|
||||
BitwardenActionCard(
|
||||
cardTitle = stringResource(R.string.explore_the_generator),
|
||||
cardSubtitle = stringResource(
|
||||
R.string.learn_more_about_generating_secure_login_credentials_with_guided_tour,
|
||||
),
|
||||
actionText = stringResource(R.string.get_started),
|
||||
onActionClick = passwordHandlers.onGeneratorActionCardClicked,
|
||||
onDismissClick = passwordHandlers.onGeneratorActionCardDismissed,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
|
||||
GeneratedStringItem(
|
||||
generatedText = state.generatedText,
|
||||
onRegenerateClick = onRegenerateClick,
|
||||
|
||||
+71
-1
@@ -12,9 +12,11 @@ import com.bitwarden.generators.UsernameGeneratorRequest
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
||||
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.CoachMarkTourType
|
||||
import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies
|
||||
import com.x8bit.bitwarden.data.platform.manager.util.getActivePoliciesFlow
|
||||
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
|
||||
@@ -68,7 +70,7 @@ private const val NO_GENERATED_TEXT: String = "-"
|
||||
*
|
||||
* @property savedStateHandle Handles the saved state of this ViewModel.
|
||||
*/
|
||||
@Suppress("LargeClass")
|
||||
@Suppress("LargeClass", "LongParameterList")
|
||||
@HiltViewModel
|
||||
class GeneratorViewModel @Inject constructor(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
@@ -77,6 +79,7 @@ class GeneratorViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val policyManager: PolicyManager,
|
||||
private val reviewPromptManager: ReviewPromptManager,
|
||||
private val firstTimeActionManager: FirstTimeActionManager,
|
||||
) : BaseViewModel<GeneratorState, GeneratorEvent, GeneratorAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: run {
|
||||
val generatorMode = GeneratorArgs(savedStateHandle).type
|
||||
@@ -105,6 +108,7 @@ class GeneratorViewModel @Inject constructor(
|
||||
.getActivePolicies<PolicyInformation.PasswordGenerator>()
|
||||
.any(),
|
||||
website = (generatorMode as? GeneratorMode.Modal.Username)?.website,
|
||||
shouldShowCoachMarkTour = false,
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -121,6 +125,16 @@ class GeneratorViewModel @Inject constructor(
|
||||
.map { GeneratorAction.Internal.PasswordGeneratorPolicyReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
firstTimeActionManager
|
||||
.shouldShowGeneratorCoachMarkFlow
|
||||
.map { shouldShowCoachMarkTour ->
|
||||
GeneratorAction.Internal.ShouldShowGeneratorCoachMarkValueChangeReceive(
|
||||
shouldShowCoachMarkTour = shouldShowCoachMarkTour,
|
||||
)
|
||||
}
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: GeneratorAction) {
|
||||
@@ -134,9 +148,26 @@ class GeneratorViewModel @Inject constructor(
|
||||
is GeneratorAction.MainType -> handleMainTypeAction(action)
|
||||
is GeneratorAction.Internal -> handleInternalAction(action)
|
||||
GeneratorAction.LifecycleResume -> handleOnResumed()
|
||||
GeneratorAction.ExploreGeneratorCardDismissed -> handleExploreCardDismissed()
|
||||
GeneratorAction.StartExploreGeneratorTour -> handleStartExploreGeneratorTour()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleExploreCardDismissed() {
|
||||
coachMarkTourCompleted()
|
||||
}
|
||||
|
||||
private fun handleStartExploreGeneratorTour() {
|
||||
coachMarkTourCompleted()
|
||||
// TODO: PM-16622 send show coach mark event.
|
||||
}
|
||||
|
||||
private fun coachMarkTourCompleted() {
|
||||
firstTimeActionManager.markCoachMarkTourCompleted(
|
||||
tourCompleted = CoachMarkTourType.GENERATOR,
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleOnResumed() {
|
||||
// when the screen resumes we need to refresh the options for the current option from
|
||||
// disk in the event they were changed while the screen was in the foreground.
|
||||
@@ -233,6 +264,10 @@ class GeneratorViewModel @Inject constructor(
|
||||
is GeneratorAction.Internal.PasswordGeneratorPolicyReceive -> {
|
||||
handlePasswordGeneratorPolicyReceive(action)
|
||||
}
|
||||
|
||||
is GeneratorAction.Internal.ShouldShowGeneratorCoachMarkValueChangeReceive -> {
|
||||
handleShouldShowCoachMarkValueChange(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -746,6 +781,14 @@ class GeneratorViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleShouldShowCoachMarkValueChange(
|
||||
action: GeneratorAction.Internal.ShouldShowGeneratorCoachMarkValueChangeReceive,
|
||||
) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(shouldShowCoachMarkTour = action.shouldShowCoachMarkTour)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePasswordGeneratorPolicyReceive(
|
||||
action: GeneratorAction.Internal.PasswordGeneratorPolicyReceive,
|
||||
) {
|
||||
@@ -1726,6 +1769,7 @@ data class GeneratorState(
|
||||
val isUnderPolicy: Boolean = false,
|
||||
val website: String? = null,
|
||||
var passcodePolicyOverride: PasscodePolicyOverride? = null,
|
||||
private val shouldShowCoachMarkTour: Boolean,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
@@ -1741,6 +1785,15 @@ data class GeneratorState(
|
||||
is GeneratorMode.Modal.Username -> emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to show the action card which starts the coach mark tour. Should only show
|
||||
* if has not be interacted with prior and the screen mode is the default.
|
||||
*/
|
||||
val shouldShowExploreGeneratorCard: Boolean
|
||||
get() = shouldShowCoachMarkTour &&
|
||||
generatorMode is GeneratorMode.Default &&
|
||||
selectedType is MainType.Password
|
||||
|
||||
/**
|
||||
* Enum representing the main type options for the generator, such as PASSWORD PASSPHRASE, and
|
||||
* USERNAME.
|
||||
@@ -2173,6 +2226,16 @@ sealed class GeneratorAction {
|
||||
val mainTypeOption: GeneratorState.MainTypeOption,
|
||||
) : GeneratorAction()
|
||||
|
||||
/**
|
||||
* User clicked the close button on the action card for exploring the generator.
|
||||
*/
|
||||
data object ExploreGeneratorCardDismissed : GeneratorAction()
|
||||
|
||||
/**
|
||||
* User has clicked the call to action to start the coach mark tour.
|
||||
*/
|
||||
data object StartExploreGeneratorTour : GeneratorAction()
|
||||
|
||||
/**
|
||||
* Represents actions related to the [GeneratorState.MainType] in the generator feature.
|
||||
*/
|
||||
@@ -2545,6 +2608,13 @@ sealed class GeneratorAction {
|
||||
data class UpdateGeneratedForwardedServiceUsernameResult(
|
||||
val result: GeneratedForwardedServiceUsernameResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* The value for the shouldShowGeneratorCoachMark has changed.
|
||||
*/
|
||||
data class ShouldShowGeneratorCoachMarkValueChangeReceive(
|
||||
val shouldShowCoachMarkTour: Boolean,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
@@ -20,6 +20,8 @@ data class PasswordHandlers(
|
||||
val onPasswordMinNumbersCounterChange: (Int) -> Unit,
|
||||
val onPasswordMinSpecialCharactersChange: (Int) -> Unit,
|
||||
val onPasswordToggleAvoidAmbiguousCharsChange: (Boolean) -> Unit,
|
||||
val onGeneratorActionCardClicked: () -> Unit,
|
||||
val onGeneratorActionCardDismissed: () -> Unit,
|
||||
) {
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
@@ -27,6 +29,7 @@ data class PasswordHandlers(
|
||||
* Creates an instance of [PasswordHandlers] by binding actions to the provided
|
||||
* [GeneratorViewModel].
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
fun create(
|
||||
viewModel: GeneratorViewModel,
|
||||
): PasswordHandlers = PasswordHandlers(
|
||||
@@ -87,6 +90,16 @@ data class PasswordHandlers(
|
||||
),
|
||||
)
|
||||
},
|
||||
onGeneratorActionCardDismissed = {
|
||||
viewModel.trySendAction(
|
||||
GeneratorAction.ExploreGeneratorCardDismissed,
|
||||
)
|
||||
},
|
||||
onGeneratorActionCardClicked = {
|
||||
viewModel.trySendAction(
|
||||
GeneratorAction.StartExploreGeneratorTour,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -15,6 +15,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard
|
||||
import com.x8bit.bitwarden.ui.platform.components.card.BitwardenInfoCalloutCard
|
||||
import com.x8bit.bitwarden.ui.platform.components.coachmark.CoachMarkScope
|
||||
import com.x8bit.bitwarden.ui.platform.components.dropdown.BitwardenMultiSelectButton
|
||||
@@ -50,6 +51,7 @@ fun CoachMarkScope<AddEditItemCoachMark>.VaultAddEditContent(
|
||||
onPreviousCoachMark: () -> Unit,
|
||||
onCoachMarkTourComplete: () -> Unit,
|
||||
onCoachMarkDismissed: () -> Unit,
|
||||
shouldShowLearnAboutLoginsCard: Boolean,
|
||||
) {
|
||||
val launcher = permissionsManager.getLauncher(
|
||||
onResult = { isGranted ->
|
||||
@@ -79,6 +81,23 @@ fun CoachMarkScope<AddEditItemCoachMark>.VaultAddEditContent(
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldShowLearnAboutLoginsCard) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
BitwardenActionCard(
|
||||
cardTitle = stringResource(R.string.learn_about_new_logins),
|
||||
cardSubtitle = stringResource(
|
||||
R.string.we_ll_walk_you_through_the_key_features_to_add_a_new_login,
|
||||
),
|
||||
actionText = stringResource(R.string.get_started),
|
||||
onActionClick = loginItemTypeHandlers.onStartLoginCoachMarkTour,
|
||||
onDismissClick = loginItemTypeHandlers.onDismissLearnAboutLoginsCard,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
BitwardenListHeaderText(
|
||||
|
||||
@@ -384,6 +384,7 @@ fun VaultAddEditScreen(
|
||||
coachMarkState.coachingComplete(onComplete = scrollBackToTop)
|
||||
},
|
||||
onCoachMarkDismissed = scrollBackToTop,
|
||||
shouldShowLearnAboutLoginsCard = state.shouldShowLearnAboutNewLogins,
|
||||
modifier = Modifier
|
||||
.imePadding()
|
||||
.fillMaxSize(),
|
||||
|
||||
+74
@@ -15,10 +15,12 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialReques
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
|
||||
import com.x8bit.bitwarden.data.autofill.fido2.model.UserVerificationRequirement
|
||||
import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials
|
||||
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.CoachMarkTourType
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSaveItemOrNull
|
||||
@@ -103,6 +105,7 @@ class VaultAddEditViewModel @Inject constructor(
|
||||
private val clock: Clock,
|
||||
private val organizationEventManager: OrganizationEventManager,
|
||||
private val networkConnectionManager: NetworkConnectionManager,
|
||||
private val firstTimeActionManager: FirstTimeActionManager,
|
||||
) : BaseViewModel<VaultAddEditState, VaultAddEditEvent, VaultAddEditAction>(
|
||||
// We load the state from the savedStateHandle for testing purposes.
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
@@ -170,6 +173,7 @@ class VaultAddEditViewModel @Inject constructor(
|
||||
shouldShowCloseButton = autofillSaveItem == null && fido2AttestationOptions == null,
|
||||
shouldExitOnSave = shouldExitOnSave,
|
||||
supportedItemTypes = getSupportedItemTypeOptions(),
|
||||
shouldShowCoachMarkTour = false,
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -211,6 +215,16 @@ class VaultAddEditViewModel @Inject constructor(
|
||||
}
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
firstTimeActionManager
|
||||
.shouldShowAddLoginCoachMarkFlow
|
||||
.map { shouldShowTour ->
|
||||
VaultAddEditAction.Internal.ShouldShowAddLoginCoachMarkValueChangeReceive(
|
||||
shouldShowCoachMarkTour = shouldShowTour,
|
||||
)
|
||||
}
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: VaultAddEditAction) {
|
||||
@@ -967,9 +981,32 @@ class VaultAddEditViewModel @Inject constructor(
|
||||
VaultAddEditAction.ItemType.LoginType.ClearFido2CredentialClick -> {
|
||||
handleLoginClearFido2Credential()
|
||||
}
|
||||
|
||||
VaultAddEditAction.ItemType.LoginType.LearnAboutLoginsDismissed -> {
|
||||
handleLearnAboutLoginsDismissed()
|
||||
}
|
||||
|
||||
VaultAddEditAction.ItemType.LoginType.StartLearnAboutLogins -> {
|
||||
handleStartLearnAboutLogins()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStartLearnAboutLogins() {
|
||||
coachMarkTourCompleted()
|
||||
sendEvent(VaultAddEditEvent.StartAddLoginItemCoachMarkTour)
|
||||
}
|
||||
|
||||
private fun handleLearnAboutLoginsDismissed() {
|
||||
coachMarkTourCompleted()
|
||||
}
|
||||
|
||||
private fun coachMarkTourCompleted() {
|
||||
firstTimeActionManager.markCoachMarkTourCompleted(
|
||||
tourCompleted = CoachMarkTourType.ADD_LOGIN,
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleLoginUsernameTextInputChange(
|
||||
action: VaultAddEditAction.ItemType.LoginType.UsernameTextChange,
|
||||
) {
|
||||
@@ -1445,6 +1482,20 @@ class VaultAddEditViewModel @Inject constructor(
|
||||
is VaultAddEditAction.Internal.ValidateFido2PinResultReceive -> {
|
||||
handleValidateFido2PinResultReceive(action)
|
||||
}
|
||||
|
||||
is VaultAddEditAction.Internal.ShouldShowAddLoginCoachMarkValueChangeReceive -> {
|
||||
handleShouldShowAddLoginCoachMarkValueChange(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleShouldShowAddLoginCoachMarkValueChange(
|
||||
action: VaultAddEditAction.Internal.ShouldShowAddLoginCoachMarkValueChangeReceive,
|
||||
) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
shouldShowCoachMarkTour = action.shouldShowCoachMarkTour,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1992,6 +2043,7 @@ data class VaultAddEditState(
|
||||
// Internal
|
||||
val shouldExitOnSave: Boolean = false,
|
||||
val totpData: TotpData? = null,
|
||||
private val shouldShowCoachMarkTour: Boolean,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
@@ -2040,6 +2092,11 @@ data class VaultAddEditState(
|
||||
?.canAssignToCollections
|
||||
?: false
|
||||
|
||||
val shouldShowLearnAboutNewLogins: Boolean
|
||||
get() = shouldShowCoachMarkTour &&
|
||||
((viewState as? ViewState.Content)?.type is ViewState.Content.ItemType.Login) &&
|
||||
isAddItemMode
|
||||
|
||||
/**
|
||||
* Enum representing the main type options for the vault, such as LOGIN, CARD, etc.
|
||||
*
|
||||
@@ -2850,6 +2907,16 @@ sealed class VaultAddEditAction {
|
||||
* Represents the action to clear the fido2 credential.
|
||||
*/
|
||||
data object ClearFido2CredentialClick : LoginType()
|
||||
|
||||
/**
|
||||
* User has clicked the call to action on the learn about logins card.
|
||||
*/
|
||||
data object StartLearnAboutLogins : LoginType()
|
||||
|
||||
/**
|
||||
* User has dismissed the learn about logins card.
|
||||
*/
|
||||
data object LearnAboutLoginsDismissed : LoginType()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3138,6 +3205,13 @@ sealed class VaultAddEditAction {
|
||||
data class ValidateFido2PinResultReceive(
|
||||
val result: ValidatePinResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* The value for the shouldShowAddLoginCoachMark has changed.
|
||||
*/
|
||||
data class ShouldShowAddLoginCoachMarkValueChangeReceive(
|
||||
val shouldShowCoachMarkTour: Boolean,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -42,6 +42,8 @@ data class VaultAddEditLoginTypeHandlers(
|
||||
val onAddNewUriClick: () -> Unit,
|
||||
val onPasswordVisibilityChange: (Boolean) -> Unit,
|
||||
val onClearFido2CredentialClick: () -> Unit,
|
||||
val onStartLoginCoachMarkTour: () -> Unit,
|
||||
val onDismissLearnAboutLoginsCard: () -> Unit,
|
||||
) {
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
@@ -123,6 +125,16 @@ data class VaultAddEditLoginTypeHandlers(
|
||||
VaultAddEditAction.ItemType.LoginType.ClearFido2CredentialClick,
|
||||
)
|
||||
},
|
||||
onStartLoginCoachMarkTour = {
|
||||
viewModel.trySendAction(
|
||||
VaultAddEditAction.ItemType.LoginType.StartLearnAboutLogins,
|
||||
)
|
||||
},
|
||||
onDismissLearnAboutLoginsCard = {
|
||||
viewModel.trySendAction(
|
||||
VaultAddEditAction.ItemType.LoginType.LearnAboutLoginsDismissed,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1121,4 +1121,8 @@ Do you want to switch to this account?</string>
|
||||
<string name="mutual_tls">Mutual TLS</string>
|
||||
<string name="single_tap_passkey_creation">Single tap passkey creation</string>
|
||||
<string name="single_tap_passkey_authentication">Single tap passkey sign-on</string>
|
||||
<string name="learn_about_new_logins">Learn about new logins</string>
|
||||
<string name="we_ll_walk_you_through_the_key_features_to_add_a_new_login">We\'ll walk you through the key features to add a new login.</string>
|
||||
<string name="explore_the_generator">Explore the generator</string>
|
||||
<string name="learn_more_about_generating_secure_login_credentials_with_guided_tour">Learn more about generating secure login credentials with a guided tour.</string>
|
||||
</resources>
|
||||
|
||||
+61
@@ -1253,4 +1253,65 @@ class SettingsDiskSourceTest {
|
||||
settingsDiskSource.storeCreateSendActionCount(count = 1)
|
||||
assertEquals(1, fakeSharedPreferences.getInt(createActionCountKey, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getShouldShowAddLoginCoachMark should pull value from SharedPreferences`() {
|
||||
val hasSeenAddLoginCoachMarkKey = "bwPreferencesStorage:shouldShowAddLoginCoachMark"
|
||||
fakeSharedPreferences.edit { putBoolean(hasSeenAddLoginCoachMarkKey, true) }
|
||||
assertTrue(settingsDiskSource.getShouldShowAddLoginCoachMark() == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `storeShouldShowAddLoginCoachMark should update SharedPreferences`() {
|
||||
val hasSeenAddLoginCoachMarkKey = "bwPreferencesStorage:shouldShowAddLoginCoachMark"
|
||||
settingsDiskSource.storeShouldShowAddLoginCoachMark(shouldShow = true)
|
||||
assertTrue(
|
||||
fakeSharedPreferences.getBoolean(
|
||||
key = hasSeenAddLoginCoachMarkKey,
|
||||
defaultValue = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getShouldShowAddLoginCoachMarkFlow emits changes to stored value`() = runTest {
|
||||
settingsDiskSource.getShouldShowAddLoginCoachMarkFlow().test {
|
||||
assertNull(awaitItem())
|
||||
settingsDiskSource.storeShouldShowAddLoginCoachMark(shouldShow = false)
|
||||
assertFalse(awaitItem() ?: true)
|
||||
settingsDiskSource.storeShouldShowAddLoginCoachMark(shouldShow = true)
|
||||
assertTrue(awaitItem() ?: false)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `getShouldShowGeneratorCoachMarkGeneratorCoachMark should pull value from SharedPreferences`() {
|
||||
val hasSeenGeneratorCoachMarkKey = "bwPreferencesStorage:shouldShowGeneratorCoachMark"
|
||||
fakeSharedPreferences.edit { putBoolean(hasSeenGeneratorCoachMarkKey, true) }
|
||||
assertTrue(settingsDiskSource.getShouldShowGeneratorCoachMark() == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `storeShouldShowGeneratorCoachMarkGeneratorCoachMark should update SharedPreferences`() {
|
||||
val hasSeenGeneratorCoachMarkKey = "bwPreferencesStorage:shouldShowGeneratorCoachMark"
|
||||
settingsDiskSource.storeShouldShowGeneratorCoachMark(shouldShow = true)
|
||||
assertTrue(
|
||||
fakeSharedPreferences.getBoolean(
|
||||
key = hasSeenGeneratorCoachMarkKey,
|
||||
defaultValue = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getShouldShowGeneratorCoachMarkFlow emits changes to stored value`() = runTest {
|
||||
settingsDiskSource.getShouldShowGeneratorCoachMarkFlow().test {
|
||||
assertNull(awaitItem())
|
||||
settingsDiskSource.storeShouldShowGeneratorCoachMark(shouldShow = false)
|
||||
assertFalse(awaitItem() ?: true)
|
||||
settingsDiskSource.storeShouldShowGeneratorCoachMark(shouldShow = true)
|
||||
assertTrue(awaitItem() ?: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -40,9 +40,14 @@ class FakeSettingsDiskSource : SettingsDiskSource {
|
||||
private val mutableHasUserLoggedInOrCreatedAccount =
|
||||
bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableShouldShowAddLoginCoachMarkFlow = bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private val mutableScreenCaptureAllowedFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
|
||||
|
||||
private val mutableShouldShowGeneratorCoachMarkFlow =
|
||||
bufferedMutableSharedFlow<Boolean?>()
|
||||
|
||||
private var storedAppLanguage: AppLanguage? = null
|
||||
private var storedAppTheme: AppTheme = AppTheme.DEFAULT
|
||||
private val storedLastSyncTime = mutableMapOf<String, Instant?>()
|
||||
@@ -70,6 +75,8 @@ class FakeSettingsDiskSource : SettingsDiskSource {
|
||||
private var addCipherActionCount: Int? = null
|
||||
private var generatedActionCount: Int? = null
|
||||
private var createSendActionCount: Int? = null
|
||||
private var hasSeenAddLoginCoachMark: Boolean? = null
|
||||
private var hasSeenGeneratorCoachMark: Boolean? = null
|
||||
|
||||
private val mutableShowAutoFillSettingBadgeFlowMap =
|
||||
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
|
||||
@@ -390,6 +397,33 @@ class FakeSettingsDiskSource : SettingsDiskSource {
|
||||
createSendActionCount = count
|
||||
}
|
||||
|
||||
override fun getShouldShowAddLoginCoachMark(): Boolean? {
|
||||
return hasSeenAddLoginCoachMark
|
||||
}
|
||||
|
||||
override fun storeShouldShowAddLoginCoachMark(shouldShow: Boolean?) {
|
||||
hasSeenAddLoginCoachMark = shouldShow
|
||||
mutableShouldShowAddLoginCoachMarkFlow.tryEmit(shouldShow)
|
||||
}
|
||||
|
||||
override fun getShouldShowAddLoginCoachMarkFlow(): Flow<Boolean?> =
|
||||
mutableShouldShowAddLoginCoachMarkFlow.onSubscription {
|
||||
emit(getShouldShowAddLoginCoachMark())
|
||||
}
|
||||
|
||||
override fun getShouldShowGeneratorCoachMark(): Boolean? =
|
||||
hasSeenGeneratorCoachMark
|
||||
|
||||
override fun storeShouldShowGeneratorCoachMark(shouldShow: Boolean?) {
|
||||
hasSeenGeneratorCoachMark = shouldShow
|
||||
mutableShouldShowGeneratorCoachMarkFlow.tryEmit(shouldShow)
|
||||
}
|
||||
|
||||
override fun getShouldShowGeneratorCoachMarkFlow(): Flow<Boolean?> =
|
||||
mutableShouldShowGeneratorCoachMarkFlow.onSubscription {
|
||||
emit(hasSeenGeneratorCoachMark)
|
||||
}
|
||||
|
||||
//region Private helper functions
|
||||
private fun getMutableScreenCaptureAllowedFlow(userId: String): MutableSharedFlow<Boolean?> {
|
||||
return mutableScreenCaptureAllowedFlowMap.getOrPut(userId) {
|
||||
|
||||
+68
@@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOpti
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
|
||||
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.CoachMarkTourType
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||
@@ -22,6 +23,7 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.ZonedDateTime
|
||||
@@ -35,8 +37,10 @@ class FirstTimeActionManagerTest {
|
||||
}
|
||||
|
||||
private val mutableImportLoginsFlow = MutableStateFlow(false)
|
||||
private val mutableOnboardingFeatureFlow = MutableStateFlow(false)
|
||||
private val featureFlagManager = mockk<FeatureFlagManager> {
|
||||
every { getFeatureFlagFlow(FlagKey.ImportLoginsFlow) } returns mutableImportLoginsFlow
|
||||
every { getFeatureFlagFlow(FlagKey.OnboardingFlow) } returns mutableOnboardingFeatureFlow
|
||||
}
|
||||
|
||||
private val mutableAutofillEnabledFlow = MutableStateFlow(false)
|
||||
@@ -296,6 +300,70 @@ class FirstTimeActionManagerTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldShowAddLoginCoachMarkFlow updates when disk source updates`() = runTest {
|
||||
// Enable the feature for this test.
|
||||
mutableOnboardingFeatureFlow.update { true }
|
||||
firstTimeActionManager.shouldShowAddLoginCoachMarkFlow.test {
|
||||
// Null will be mapped to false.
|
||||
assertTrue(awaitItem())
|
||||
fakeSettingsDiskSource.storeShouldShowAddLoginCoachMark(shouldShow = false)
|
||||
assertFalse(awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldShowAddLoginCoachMarkFlow updates when feature flag for onboarding updates`() =
|
||||
runTest {
|
||||
firstTimeActionManager.shouldShowAddLoginCoachMarkFlow.test {
|
||||
// Null will be mapped to false but feature being "off" will override to true.
|
||||
assertFalse(awaitItem())
|
||||
mutableOnboardingFeatureFlow.update { true }
|
||||
// Will use the value from disk source (null ?: false).
|
||||
assertTrue(awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `markCoachMarkTourCompleted for the ADD_LOGIN type sets the value to true in the disk source for should show add logins coach mark`() {
|
||||
assertNull(fakeSettingsDiskSource.getShouldShowAddLoginCoachMark())
|
||||
firstTimeActionManager.markCoachMarkTourCompleted(CoachMarkTourType.ADD_LOGIN)
|
||||
assertTrue(fakeSettingsDiskSource.getShouldShowAddLoginCoachMark() == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldShowGeneratorCoachMarkFlow updates when disk source updates`() = runTest {
|
||||
// Enable feature flag so flow emits updates from disk.
|
||||
mutableOnboardingFeatureFlow.update { true }
|
||||
firstTimeActionManager.shouldShowGeneratorCoachMarkFlow.test {
|
||||
// Null will be mapped to false.
|
||||
assertTrue(awaitItem())
|
||||
fakeSettingsDiskSource.storeShouldShowGeneratorCoachMark(shouldShow = false)
|
||||
assertFalse(awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shouldShowGeneratorCoachMarkFlow updates when onboarding feature value changes`() =
|
||||
runTest {
|
||||
firstTimeActionManager.shouldShowGeneratorCoachMarkFlow.test {
|
||||
// Null will be mapped to false
|
||||
assertFalse(awaitItem())
|
||||
mutableOnboardingFeatureFlow.update { true }
|
||||
// Take the value from disk.
|
||||
assertTrue(awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `markCoachMarkTourCompleted for the GENERATOR type sets the value to true in the disk source for should show generator coach mark`() {
|
||||
assertNull(fakeSettingsDiskSource.getShouldShowGeneratorCoachMark())
|
||||
firstTimeActionManager.markCoachMarkTourCompleted(CoachMarkTourType.GENERATOR)
|
||||
assertTrue(fakeSettingsDiskSource.getShouldShowGeneratorCoachMark() == false)
|
||||
}
|
||||
}
|
||||
|
||||
private const val USER_ID: String = "userId"
|
||||
|
||||
+86
@@ -10,9 +10,11 @@ import androidx.compose.ui.test.assertIsOn
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasAnySibling
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasContentDescription
|
||||
import androidx.compose.ui.test.hasProgressBarRangeInfo
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onChildren
|
||||
@@ -1521,6 +1523,89 @@ class GeneratorScreenTest : BaseComposeTest() {
|
||||
verify { viewModel.trySendAction(GeneratorAction.LifecycleResume) }
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `Explore generator card shows when default mode and shouldShowCoachMarkTour is true`() {
|
||||
updateState(
|
||||
state = DEFAULT_STATE.copy(shouldShowCoachMarkTour = true),
|
||||
)
|
||||
composeTestRule
|
||||
.onNodeWithText("Explore the generator")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `Explore generator card does not show when default mode and shouldShowCoachMarkTour is false`() {
|
||||
updateState(
|
||||
state = DEFAULT_STATE.copy(shouldShowCoachMarkTour = false),
|
||||
)
|
||||
composeTestRule
|
||||
.onNodeWithText("Explore the generator")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `Explore generator card does not show when modal mode and shouldShowCoachMarkTour is true`() {
|
||||
updateState(
|
||||
state = DEFAULT_STATE.copy(
|
||||
shouldShowCoachMarkTour = true,
|
||||
generatorMode = GeneratorMode.Modal.Password,
|
||||
),
|
||||
)
|
||||
composeTestRule
|
||||
.onNodeWithText("Explore the generator")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `Explore generator card does not when default mode, shouldShowCoachMarkTour is true, and main type is not password`() {
|
||||
updateState(
|
||||
state = DEFAULT_STATE.copy(
|
||||
shouldShowCoachMarkTour = true,
|
||||
generatorMode = GeneratorMode.Modal.Password,
|
||||
selectedType = GeneratorState.MainType.Username(),
|
||||
),
|
||||
)
|
||||
composeTestRule
|
||||
.onNodeWithText("Explore the generator")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Clicking close button on generator card send ExploreGeneratorCardDismissed action`() {
|
||||
updateState(
|
||||
state = DEFAULT_STATE.copy(shouldShowCoachMarkTour = true),
|
||||
)
|
||||
composeTestRule
|
||||
.onNode(
|
||||
hasContentDescription("Close")
|
||||
and hasAnySibling(hasText("Explore the generator")),
|
||||
)
|
||||
.performClick()
|
||||
|
||||
verify(exactly = 1) {
|
||||
viewModel.trySendAction(GeneratorAction.ExploreGeneratorCardDismissed)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `Clicking call to action button on generator card send StartExploreGeneratorTour action`() {
|
||||
updateState(
|
||||
state = DEFAULT_STATE.copy(shouldShowCoachMarkTour = true),
|
||||
)
|
||||
composeTestRule
|
||||
.onNodeWithText("Get started")
|
||||
.performClick()
|
||||
|
||||
verify(exactly = 1) {
|
||||
viewModel.trySendAction(GeneratorAction.StartExploreGeneratorTour)
|
||||
}
|
||||
}
|
||||
|
||||
//endregion Random Word Tests
|
||||
|
||||
private fun updateState(state: GeneratorState) {
|
||||
@@ -1532,4 +1617,5 @@ private val DEFAULT_STATE = GeneratorState(
|
||||
generatedText = "",
|
||||
selectedType = GeneratorState.MainType.Password(),
|
||||
currentEmailAddress = "currentEmail",
|
||||
shouldShowCoachMarkTour = false,
|
||||
)
|
||||
|
||||
+88
@@ -8,9 +8,11 @@ import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.CoachMarkTourType
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
@@ -37,11 +39,14 @@ import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -109,6 +114,12 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
every { registerGeneratedResultAction() } just runs
|
||||
}
|
||||
|
||||
private val mutableShouldShowGeneratorCoachMarkFlow = MutableStateFlow(true)
|
||||
private val firstTimeActionManager: FirstTimeActionManager = mockk {
|
||||
every { markCoachMarkTourCompleted(CoachMarkTourType.GENERATOR) } just runs
|
||||
every { shouldShowGeneratorCoachMarkFlow } returns mutableShouldShowGeneratorCoachMarkFlow
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when there is no saved state`() {
|
||||
val viewModel = createViewModel(state = null)
|
||||
@@ -139,6 +150,7 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
currentEmailAddress = "currentEmail",
|
||||
isUnderPolicy = false,
|
||||
website = "",
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
@@ -174,6 +186,7 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
currentEmailAddress = "currentEmail",
|
||||
isUnderPolicy = false,
|
||||
website = null,
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
@@ -2160,6 +2173,67 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
)
|
||||
assertEquals(10, password.computedMinimumLength)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when first time action manager should show generator tour value updates to false shouldShowExploreGeneratorCard should update to false`() {
|
||||
val viewModel = createViewModel()
|
||||
assertTrue(viewModel.stateFlow.value.shouldShowExploreGeneratorCard)
|
||||
mutableShouldShowGeneratorCoachMarkFlow.update { false }
|
||||
assertFalse(viewModel.stateFlow.value.shouldShowExploreGeneratorCard)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `shouldShowExploreGeneratorCard value should be false if generator screen is in modal mode`() {
|
||||
mutableShouldShowGeneratorCoachMarkFlow.update { false }
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = createPasswordState().copy(
|
||||
shouldShowCoachMarkTour = false,
|
||||
generatorMode = GeneratorMode.Modal.Password,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertFalse(viewModel.stateFlow.value.shouldShowExploreGeneratorCard)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `shouldShowExploreGeneratorCard value should be false if generator screen selected type is not password`() {
|
||||
mutableShouldShowGeneratorCoachMarkFlow.update { false }
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = createUsernameModeState().copy(
|
||||
shouldShowCoachMarkTour = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertFalse(viewModel.stateFlow.value.shouldShowExploreGeneratorCard)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `ExploreGeneratorCardDismissed action calls first time action manager markCoachMarkTourCompleted with GENERATOR value called`() {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(GeneratorAction.ExploreGeneratorCardDismissed)
|
||||
verify(exactly = 1) {
|
||||
firstTimeActionManager.markCoachMarkTourCompleted(CoachMarkTourType.GENERATOR)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `StartExploreGeneratorTour action calls first time action manager hasSeenGeneratorCoachMarkTour called and show coach mark event sent`() {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(GeneratorAction.StartExploreGeneratorTour)
|
||||
verify(exactly = 1) {
|
||||
firstTimeActionManager.markCoachMarkTourCompleted(CoachMarkTourType.GENERATOR)
|
||||
}
|
||||
}
|
||||
//region Helper Functions
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@@ -2187,6 +2261,7 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
avoidAmbiguousChars = avoidAmbiguousChars,
|
||||
),
|
||||
currentEmailAddress = "currentEmail",
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
private fun createPassphraseState(
|
||||
@@ -2205,6 +2280,7 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
includeNumber = includeNumber,
|
||||
),
|
||||
currentEmailAddress = "currentEmail",
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
private fun createUsernameModeState(
|
||||
@@ -2220,6 +2296,7 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
),
|
||||
),
|
||||
currentEmailAddress = "currentEmail",
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
private fun createForwardedEmailAliasState(
|
||||
@@ -2235,6 +2312,7 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
),
|
||||
),
|
||||
currentEmailAddress = "currentEmail",
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
private fun createAddyIoState(
|
||||
@@ -2249,6 +2327,7 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
),
|
||||
),
|
||||
currentEmailAddress = "currentEmail",
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
private fun createDuckDuckGoState(
|
||||
@@ -2263,6 +2342,7 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
),
|
||||
),
|
||||
currentEmailAddress = "currentEmail",
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
private fun createFastMailState(
|
||||
@@ -2277,6 +2357,7 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
),
|
||||
),
|
||||
currentEmailAddress = "currentEmail",
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
private fun createFirefoxRelayState(
|
||||
@@ -2291,6 +2372,7 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
),
|
||||
),
|
||||
currentEmailAddress = "currentEmail",
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
private fun createForwardEmailState(
|
||||
@@ -2305,6 +2387,7 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
),
|
||||
),
|
||||
currentEmailAddress = "currentEmail",
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
private fun createSimpleLoginState(
|
||||
@@ -2319,6 +2402,7 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
),
|
||||
),
|
||||
currentEmailAddress = "currentEmail",
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
private fun createPlusAddressedEmailState(
|
||||
@@ -2333,6 +2417,7 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
),
|
||||
),
|
||||
currentEmailAddress = "currentEmail",
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
private fun createCatchAllEmailState(
|
||||
@@ -2347,6 +2432,7 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
),
|
||||
),
|
||||
currentEmailAddress = "currentEmail",
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
private fun createRandomWordState(
|
||||
@@ -2363,6 +2449,7 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
),
|
||||
),
|
||||
currentEmailAddress = "currentEmail",
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
private fun createSavedStateHandleWithState(state: GeneratorState) =
|
||||
@@ -2379,6 +2466,7 @@ class GeneratorViewModelTest : BaseViewModelTest() {
|
||||
authRepository = authRepository,
|
||||
policyManager = policyManager,
|
||||
reviewPromptManager = reviewPromptManager,
|
||||
firstTimeActionManager = firstTimeActionManager,
|
||||
)
|
||||
|
||||
private fun createViewModel(
|
||||
|
||||
+103
@@ -15,6 +15,7 @@ import androidx.compose.ui.test.click
|
||||
import androidx.compose.ui.test.filter
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.hasAnySibling
|
||||
import androidx.compose.ui.test.hasContentDescription
|
||||
import androidx.compose.ui.test.hasSetTextAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
@@ -3560,6 +3561,101 @@ class VaultAddEditScreenTest : BaseComposeTest() {
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `learn about add logins card should show when state is add mode, login type content, and should show coach mark tour is true`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE_LOGIN.copy(
|
||||
vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN),
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Learn about new logins")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `learn about add logins card should not show when state is add mode, login type content, and should show coach mark tour is false`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE_LOGIN.copy(
|
||||
vaultAddEditType = VaultAddEditType.AddItem(
|
||||
vaultItemCipherType = VaultItemCipherType.LOGIN,
|
||||
),
|
||||
shouldShowCoachMarkTour = false,
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Learn about new logins")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `learn about add logins card should not show when state is edit mode, login type content, and should show coach mark tour is true`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE_LOGIN.copy(
|
||||
vaultAddEditType = VaultAddEditType.EditItem(vaultItemId = "1234"),
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Learn about new logins")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `learn about add logins card should not show when state is add mode, card type content, and should show coach mark tour is true`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE_CARD.copy(
|
||||
vaultAddEditType = VaultAddEditType.EditItem(vaultItemId = "1234"),
|
||||
shouldShowCoachMarkTour = false,
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Learn about new logins")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when learn about logins card is showing, clicking the close button sends LearnAboutLoginsDismissed action`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE_LOGIN.copy(
|
||||
vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN),
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNode(
|
||||
hasContentDescription("Close")
|
||||
and hasAnySibling(hasText("Learn about new logins")),
|
||||
)
|
||||
.performClick()
|
||||
|
||||
verify(exactly = 1) {
|
||||
viewModel.trySendAction(
|
||||
VaultAddEditAction.ItemType.LoginType.LearnAboutLoginsDismissed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when learn about logins card is showing, clicking the call to action sends StartLearnAboutLogins action`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE_LOGIN.copy(
|
||||
vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN),
|
||||
shouldShowCoachMarkTour = true,
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Get started")
|
||||
.performClick()
|
||||
|
||||
verify(exactly = 1) {
|
||||
viewModel.trySendAction(
|
||||
VaultAddEditAction.ItemType.LoginType.StartLearnAboutLogins,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//region Helper functions
|
||||
|
||||
private fun updateLoginType(
|
||||
@@ -3685,6 +3781,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
|
||||
dialog = VaultAddEditState.DialogState.Generic(message = "test".asText()),
|
||||
vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN),
|
||||
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries,
|
||||
shouldShowCoachMarkTour = false,
|
||||
)
|
||||
|
||||
private val DEFAULT_STATE_LOGIN = VaultAddEditState(
|
||||
@@ -3696,6 +3793,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
|
||||
),
|
||||
dialog = null,
|
||||
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries,
|
||||
shouldShowCoachMarkTour = false,
|
||||
)
|
||||
|
||||
private val DEFAULT_STATE_IDENTITY = VaultAddEditState(
|
||||
@@ -3707,6 +3805,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
|
||||
),
|
||||
dialog = null,
|
||||
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries,
|
||||
shouldShowCoachMarkTour = false,
|
||||
)
|
||||
|
||||
private val DEFAULT_STATE_CARD = VaultAddEditState(
|
||||
@@ -3718,6 +3817,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
|
||||
),
|
||||
dialog = null,
|
||||
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries,
|
||||
shouldShowCoachMarkTour = false,
|
||||
)
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@@ -3740,6 +3840,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
|
||||
dialog = null,
|
||||
vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.SECURE_NOTE),
|
||||
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries,
|
||||
shouldShowCoachMarkTour = false,
|
||||
)
|
||||
|
||||
private val DEFAULT_STATE_SECURE_NOTES = VaultAddEditState(
|
||||
@@ -3751,6 +3852,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
|
||||
),
|
||||
dialog = null,
|
||||
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries,
|
||||
shouldShowCoachMarkTour = false,
|
||||
)
|
||||
|
||||
private val DEFAULT_STATE_SSH_KEYS = VaultAddEditState(
|
||||
@@ -3762,6 +3864,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
|
||||
),
|
||||
dialog = null,
|
||||
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries,
|
||||
shouldShowCoachMarkTour = false,
|
||||
)
|
||||
|
||||
private val ALTERED_COLLECTIONS = listOf(
|
||||
|
||||
+100
@@ -25,11 +25,13 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CreateCreden
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
|
||||
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.CoachMarkTourType
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
|
||||
@@ -87,10 +89,13 @@ import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -163,6 +168,12 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
|
||||
every { isNetworkConnected } returns true
|
||||
}
|
||||
|
||||
private val mutableShouldShowAddLoginCoachMarkFlow = MutableStateFlow(false)
|
||||
private val firstTimeActionManager = mockk<FirstTimeActionManager> {
|
||||
every { markCoachMarkTourCompleted(CoachMarkTourType.ADD_LOGIN) } just runs
|
||||
every { shouldShowAddLoginCoachMarkFlow } returns mutableShouldShowAddLoginCoachMarkFlow
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic(CipherView::toViewState)
|
||||
@@ -191,6 +202,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
|
||||
shouldExitOnSave = false,
|
||||
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries
|
||||
.filter { it != VaultAddEditState.ItemTypeOption.SSH_KEYS },
|
||||
shouldShowCoachMarkTour = false,
|
||||
)
|
||||
val viewModel = createAddVaultItemViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
@@ -273,6 +285,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
|
||||
dialog = null,
|
||||
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries
|
||||
.filter { it != VaultAddEditState.ItemTypeOption.SSH_KEYS },
|
||||
shouldShowCoachMarkTour = false,
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
@@ -2601,6 +2614,90 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when first time action manager should show logins tour value updates to false shouldShowLearnAboutNewLogins should update to false`() {
|
||||
mutableShouldShowAddLoginCoachMarkFlow.update { true }
|
||||
val viewModel = createAddVaultItemViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = createVaultAddItemState(
|
||||
typeContentViewState = createLoginTypeContentViewState(),
|
||||
),
|
||||
vaultAddEditType = VaultAddEditType.AddItem(
|
||||
vaultItemCipherType = VaultItemCipherType.LOGIN,
|
||||
),
|
||||
),
|
||||
)
|
||||
assertTrue(viewModel.stateFlow.value.shouldShowLearnAboutNewLogins)
|
||||
mutableShouldShowAddLoginCoachMarkFlow.update { false }
|
||||
assertFalse(viewModel.stateFlow.value.shouldShowLearnAboutNewLogins)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when first time action manager value is true, but type content is not login shouldShowLearnAboutNewLogins should be false`() {
|
||||
mutableShouldShowAddLoginCoachMarkFlow.update { true }
|
||||
val viewModel = createAddVaultItemViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = createVaultAddItemState(
|
||||
typeContentViewState = createLoginTypeContentViewState(),
|
||||
),
|
||||
vaultAddEditType = VaultAddEditType.AddItem(
|
||||
vaultItemCipherType = VaultItemCipherType.LOGIN,
|
||||
),
|
||||
),
|
||||
)
|
||||
assertTrue(viewModel.stateFlow.value.shouldShowLearnAboutNewLogins)
|
||||
viewModel.trySendAction(
|
||||
VaultAddEditAction.Common.TypeOptionSelect(
|
||||
VaultAddEditState.ItemTypeOption.SSH_KEYS,
|
||||
),
|
||||
)
|
||||
assertFalse(viewModel.stateFlow.value.shouldShowLearnAboutNewLogins)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `when first time action manager value is false, but edit type is EditItem shouldShowLearnAboutNewLogins should be false`() {
|
||||
val viewModel = createAddVaultItemViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = createVaultAddItemState(
|
||||
vaultAddEditType = VaultAddEditType.EditItem(vaultItemId = "1234"),
|
||||
typeContentViewState = createLoginTypeContentViewState(),
|
||||
),
|
||||
vaultAddEditType = VaultAddEditType.EditItem(
|
||||
vaultItemId = "1234",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertFalse(viewModel.stateFlow.value.shouldShowLearnAboutNewLogins)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `LearnAboutLoginsDismissed action calls first time action manager hasSeenAddLoginCoachMarkTour called`() {
|
||||
val viewModel = createAddVaultItemViewModel()
|
||||
|
||||
viewModel.trySendAction(VaultAddEditAction.ItemType.LoginType.LearnAboutLoginsDismissed)
|
||||
verify(exactly = 1) {
|
||||
firstTimeActionManager.markCoachMarkTourCompleted(CoachMarkTourType.ADD_LOGIN)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `StartLearnAboutLogins action calls first time action manager hasSeenAddLoginCoachMarkTour called and show coach mark event sent`() =
|
||||
runTest {
|
||||
val viewModel = createAddVaultItemViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultAddEditAction.ItemType.LoginType.StartLearnAboutLogins)
|
||||
assertEquals(VaultAddEditEvent.StartAddLoginItemCoachMarkTour, awaitItem())
|
||||
}
|
||||
verify(exactly = 1) {
|
||||
firstTimeActionManager.markCoachMarkTourCompleted(CoachMarkTourType.ADD_LOGIN)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class VaultAddEditIdentityTypeItemActions {
|
||||
private lateinit var viewModel: VaultAddEditViewModel
|
||||
@@ -3126,6 +3223,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
|
||||
clock = fixedClock,
|
||||
organizationEventManager = organizationEventManager,
|
||||
networkConnectionManager = networkConnectionManager,
|
||||
firstTimeActionManager = firstTimeActionManager,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4276,6 +4374,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
|
||||
shouldExitOnSave = shouldExitOnSave,
|
||||
totpData = totpData,
|
||||
supportedItemTypes = supportedItemTypes,
|
||||
shouldShowCoachMarkTour = false,
|
||||
)
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@@ -4385,6 +4484,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
|
||||
clock = clock,
|
||||
organizationEventManager = organizationEventManager,
|
||||
networkConnectionManager = networkConnectionManager,
|
||||
firstTimeActionManager = firstTimeActionManager,
|
||||
)
|
||||
|
||||
private fun createVaultData(
|
||||
|
||||
Reference in New Issue
Block a user