1
0
Fork 0
mirror of https://github.com/bitwarden/android.git synced 2025-02-22 16:49:13 +03:00

PM-16630 add logins action card

This commit is contained in:
Dave Severns 2025-01-20 10:34:17 -05:00
parent d573ce6e10
commit a95fe46e73
14 changed files with 465 additions and 3 deletions

View file

@ -361,4 +361,19 @@ 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 getHasSeenAddLoginCoachMark(): Boolean?
/**
* Stores a value for if the Add Login CoachMark tour has been interacted with
*/
fun storeHasSeenAddLoginCoachMark(hasSeen: Boolean?)
/**
* Returns an [Flow] to observe updates to the "HasSeenAddLoginCoachMark" value.
*/
fun getHasSeenAddLoginCoachMarkFlow(): Flow<Boolean?>
}

View file

@ -39,6 +39,7 @@ 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 HAS_SEEN_ADD_LOGIN_COACH_MARK = "hasSeenAddLoginCoachMark"
/**
* Primary implementation of [SettingsDiskSource].
@ -78,6 +79,8 @@ class SettingsDiskSourceImpl(
private val mutableHasUserLoggedInOrCreatedAccountFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableHasSeenAddLoginCoachMarkFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableScreenCaptureAllowedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
@ -185,6 +188,7 @@ class SettingsDiskSourceImpl(
// - screen capture allowed
// - show autofill setting badge
// - show unlock setting badge
// - has seen add login coach mark
}
override fun getAccountBiometricIntegrityValidity(
@ -486,6 +490,22 @@ class SettingsDiskSourceImpl(
)
}
override fun getHasSeenAddLoginCoachMark(): Boolean? =
getBoolean(key = HAS_SEEN_ADD_LOGIN_COACH_MARK)
override fun storeHasSeenAddLoginCoachMark(hasSeen: Boolean?) {
putBoolean(
key = HAS_SEEN_ADD_LOGIN_COACH_MARK,
value = hasSeen,
)
mutableHasSeenAddLoginCoachMarkFlow.tryEmit(hasSeen)
}
override fun getHasSeenAddLoginCoachMarkFlow(): Flow<Boolean?> =
mutableHasSeenAddLoginCoachMarkFlow.onSubscription {
emit(getBoolean(key = HAS_SEEN_ADD_LOGIN_COACH_MARK))
}
private fun getMutableLastSyncFlow(
userId: String,
): MutableSharedFlow<Instant?> =

View file

@ -39,6 +39,11 @@ 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 hasSeenAddLoginCoachMarkFlow: Flow<Boolean>
/**
* Get the current [FirstTimeState] of the active user if available, otherwise return
* a default configuration.
@ -66,4 +71,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 hasSeenAddLoginCoachMarkTour()
}

View file

@ -154,6 +154,13 @@ class FirstTimeActionManagerImpl @Inject constructor(
}
.distinctUntilChanged()
override val hasSeenAddLoginCoachMarkFlow: Flow<Boolean>
get() = settingsDiskSource
.getHasSeenAddLoginCoachMarkFlow()
// default value of false.
.map { it ?: false }
.distinctUntilChanged()
/**
* Get the current [FirstTimeState] of the active user if available, otherwise return
* a default configuration.
@ -211,6 +218,10 @@ class FirstTimeActionManagerImpl @Inject constructor(
)
}
override fun hasSeenAddLoginCoachMarkTour() {
settingsDiskSource.storeHasSeenAddLoginCoachMark(hasSeen = true)
}
/**
* Internal implementation to get a flow of the showImportLogins value which takes
* into account if the vault is empty.

View file

@ -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,21 @@ 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.standardHorizontalMargin(),
)
}
}
item {
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenListHeaderText(

View file

@ -384,6 +384,7 @@ fun VaultAddEditScreen(
coachMarkState.coachingComplete(onComplete = scrollBackToTop)
},
onCoachMarkDismissed = scrollBackToTop,
shouldShowLearnAboutLoginsCard = state.shouldShowLearnAboutNewLogins,
modifier = Modifier
.imePadding()
.fillMaxSize(),

View file

@ -15,10 +15,13 @@ 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.FeatureFlagManager
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.FlagKey
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
@ -63,6 +66,7 @@ import com.x8bit.bitwarden.ui.vault.model.VaultCollection
import com.x8bit.bitwarden.ui.vault.model.VaultIdentityTitle
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@ -103,6 +107,8 @@ class VaultAddEditViewModel @Inject constructor(
private val clock: Clock,
private val organizationEventManager: OrganizationEventManager,
private val networkConnectionManager: NetworkConnectionManager,
private val firstTimeActionManager: FirstTimeActionManager,
private val featureFlagManager: FeatureFlagManager,
) : BaseViewModel<VaultAddEditState, VaultAddEditEvent, VaultAddEditAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE]
@ -170,6 +176,7 @@ class VaultAddEditViewModel @Inject constructor(
shouldShowCloseButton = autofillSaveItem == null && fido2AttestationOptions == null,
shouldExitOnSave = shouldExitOnSave,
supportedItemTypes = getSupportedItemTypeOptions(),
hasSeenLearnAboutLoginsCard = false,
)
},
) {
@ -211,6 +218,20 @@ class VaultAddEditViewModel @Inject constructor(
}
.onEach(::sendAction)
.launchIn(viewModelScope)
firstTimeActionManager
.hasSeenAddLoginCoachMarkFlow
.combine(
featureFlagManager.getFeatureFlagFlow(key = FlagKey.OnboardingFlow),
) { hasSeenTour, onboardFeatureFlag ->
// The result of this will hide the card if the tour has been shown or if the
// onboarding flow feature is off.
VaultAddEditAction.Internal.HasSeenAddLoginCoachMarkValueChangeReceive(
newValue = hasSeenTour || !onboardFeatureFlag,
)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: VaultAddEditAction) {
@ -967,9 +988,26 @@ class VaultAddEditViewModel @Inject constructor(
VaultAddEditAction.ItemType.LoginType.ClearFido2CredentialClick -> {
handleLoginClearFido2Credential()
}
VaultAddEditAction.ItemType.LoginType.LearnAboutLoginsDismissed -> {
handleLearnAboutLoginsDismissed()
}
VaultAddEditAction.ItemType.LoginType.StartLearnAboutLogins -> {
handleStartLearnAboutLogins()
}
}
}
private fun handleStartLearnAboutLogins() {
firstTimeActionManager.hasSeenAddLoginCoachMarkTour()
sendEvent(VaultAddEditEvent.StartAddLoginItemCoachMarkTour)
}
private fun handleLearnAboutLoginsDismissed() {
firstTimeActionManager.hasSeenAddLoginCoachMarkTour()
}
private fun handleLoginUsernameTextInputChange(
action: VaultAddEditAction.ItemType.LoginType.UsernameTextChange,
) {
@ -1445,6 +1483,20 @@ class VaultAddEditViewModel @Inject constructor(
is VaultAddEditAction.Internal.ValidateFido2PinResultReceive -> {
handleValidateFido2PinResultReceive(action)
}
is VaultAddEditAction.Internal.HasSeenAddLoginCoachMarkValueChangeReceive -> {
handleHasSeenAddLoginCoachMarkValueChange(action)
}
}
}
private fun handleHasSeenAddLoginCoachMarkValueChange(
action: VaultAddEditAction.Internal.HasSeenAddLoginCoachMarkValueChangeReceive,
) {
mutableStateFlow.update {
it.copy(
hasSeenLearnAboutLoginsCard = action.newValue,
)
}
}
@ -1992,6 +2044,7 @@ data class VaultAddEditState(
// Internal
val shouldExitOnSave: Boolean = false,
val totpData: TotpData? = null,
private val hasSeenLearnAboutLoginsCard: Boolean,
) : Parcelable {
/**
@ -2040,6 +2093,11 @@ data class VaultAddEditState(
?.canAssignToCollections
?: false
val shouldShowLearnAboutNewLogins: Boolean
get() = !hasSeenLearnAboutLoginsCard &&
((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 +2908,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 +3206,13 @@ sealed class VaultAddEditAction {
data class ValidateFido2PinResultReceive(
val result: ValidatePinResult,
) : Internal()
/**
* The value for the hasSeenAddLoginCoachMark has changed.
*/
data class HasSeenAddLoginCoachMarkValueChangeReceive(
val newValue: Boolean,
) : Internal()
}
}

View file

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

View file

@ -1121,4 +1121,6 @@ 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>
</resources>

View file

@ -1253,4 +1253,34 @@ class SettingsDiskSourceTest {
settingsDiskSource.storeCreateSendActionCount(count = 1)
assertEquals(1, fakeSharedPreferences.getInt(createActionCountKey, 0))
}
@Test
fun `getHasSeenAddLoginCoachMark should pull value from SharedPreferences`() {
val hasSeenAddLoginCoachMarkKey = "bwPreferencesStorage:hasSeenAddLoginCoachMark"
fakeSharedPreferences.edit { putBoolean(hasSeenAddLoginCoachMarkKey, true) }
assertTrue(settingsDiskSource.getHasSeenAddLoginCoachMark() == true)
}
@Test
fun `storeHasSeenAddLoginCoachMark should update SharedPreferences`() {
val hasSeenAddLoginCoachMarkKey = "bwPreferencesStorage:hasSeenAddLoginCoachMark"
settingsDiskSource.storeHasSeenAddLoginCoachMark(hasSeen = true)
assertTrue(
fakeSharedPreferences.getBoolean(
key = hasSeenAddLoginCoachMarkKey,
defaultValue = false,
),
)
}
@Test
fun `getHasSeenAddLoginCoachMarkFlow emits changes to stored value`() = runTest {
settingsDiskSource.getHasSeenAddLoginCoachMarkFlow().test {
assertNull(awaitItem())
settingsDiskSource.storeHasSeenAddLoginCoachMark(hasSeen = false)
assertFalse(awaitItem() ?: true)
settingsDiskSource.storeHasSeenAddLoginCoachMark(hasSeen = true)
assertTrue(awaitItem() ?: false)
}
}
}

View file

@ -40,6 +40,8 @@ class FakeSettingsDiskSource : SettingsDiskSource {
private val mutableHasUserLoggedInOrCreatedAccount =
bufferedMutableSharedFlow<Boolean?>()
private val mutableHasSeenAddLoginCoachMarkFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableScreenCaptureAllowedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
@ -70,6 +72,7 @@ class FakeSettingsDiskSource : SettingsDiskSource {
private var addCipherActionCount: Int? = null
private var generatedActionCount: Int? = null
private var createSendActionCount: Int? = null
private var hasSeenAddLoginCoachMark: Boolean? = null
private val mutableShowAutoFillSettingBadgeFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
@ -390,6 +393,20 @@ class FakeSettingsDiskSource : SettingsDiskSource {
createSendActionCount = count
}
override fun getHasSeenAddLoginCoachMark(): Boolean? {
return hasSeenAddLoginCoachMark
}
override fun storeHasSeenAddLoginCoachMark(hasSeen: Boolean?) {
hasSeenAddLoginCoachMark = hasSeen
mutableHasSeenAddLoginCoachMarkFlow.tryEmit(hasSeen)
}
override fun getHasSeenAddLoginCoachMarkFlow(): Flow<Boolean?> =
mutableHasSeenAddLoginCoachMarkFlow.onSubscription {
emit(getHasSeenAddLoginCoachMark())
}
//region Private helper functions
private fun getMutableScreenCaptureAllowedFlow(userId: String): MutableSharedFlow<Boolean?> {
return mutableScreenCaptureAllowedFlowMap.getOrPut(userId) {

View file

@ -22,6 +22,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
@ -296,6 +297,23 @@ class FirstTimeActionManagerTest {
)
}
}
@Test
fun `hasSeenAddLoginCoachMarkFlow updates when disk source updates`() = runTest {
firstTimeActionManager.hasSeenAddLoginCoachMarkFlow.test {
// null will be mapped to false
assertFalse(awaitItem())
fakeSettingsDiskSource.storeHasSeenAddLoginCoachMark(hasSeen = true)
assertTrue(awaitItem())
}
}
@Test
fun `hasSeenAddLoginCoachMarkTour sets the value to true in the disk source`() {
assertNull(fakeSettingsDiskSource.getHasSeenAddLoginCoachMark())
firstTimeActionManager.hasSeenAddLoginCoachMarkTour()
assertTrue(fakeSettingsDiskSource.getHasSeenAddLoginCoachMark() == true)
}
}
private const val USER_ID: String = "userId"

View file

@ -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
@ -448,19 +449,26 @@ class VaultAddEditScreenTest : BaseComposeTest() {
@Test
fun `close button should update according to state`() {
composeTestRule.onNodeWithContentDescription("Close").assertIsDisplayed()
composeTestRule
.onAllNodesWithContentDescription("Close")
.assertCountEquals(2)
mutableStateFlow.update {
it.copy(shouldShowCloseButton = false)
}
composeTestRule.onNodeWithContentDescription("Close").assertDoesNotExist()
composeTestRule
.onAllNodesWithContentDescription("Close")
.assertCountEquals(1)
}
@Test
fun `clicking close button should send CloseClick action`() {
composeTestRule
.onNodeWithContentDescription(label = "Close")
.onNode(
hasContentDescription("Close")
and !hasAnySibling(hasText("Learn about new logins")),
)
.performClick()
verify {
@ -3560,6 +3568,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 hasSeenTour is false`() {
mutableStateFlow.value = DEFAULT_STATE_LOGIN.copy(
vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN),
hasSeenLearnAboutLoginsCard = false,
)
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 hasSeenTour is true`() {
mutableStateFlow.value = DEFAULT_STATE_LOGIN.copy(
vaultAddEditType = VaultAddEditType.AddItem(
vaultItemCipherType = VaultItemCipherType.LOGIN,
),
hasSeenLearnAboutLoginsCard = true,
)
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 hasSeenTour is false`() {
mutableStateFlow.value = DEFAULT_STATE_LOGIN.copy(
vaultAddEditType = VaultAddEditType.EditItem(vaultItemId = "1234"),
hasSeenLearnAboutLoginsCard = false,
)
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 hasSeenTour is false`() {
mutableStateFlow.value = DEFAULT_STATE_CARD.copy(
vaultAddEditType = VaultAddEditType.EditItem(vaultItemId = "1234"),
hasSeenLearnAboutLoginsCard = 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),
hasSeenLearnAboutLoginsCard = false,
)
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),
hasSeenLearnAboutLoginsCard = false,
)
composeTestRule
.onNodeWithText("Get started")
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
VaultAddEditAction.ItemType.LoginType.StartLearnAboutLogins,
)
}
}
//region Helper functions
private fun updateLoginType(
@ -3685,6 +3788,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
dialog = VaultAddEditState.DialogState.Generic(message = "test".asText()),
vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.LOGIN),
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries,
hasSeenLearnAboutLoginsCard = false,
)
private val DEFAULT_STATE_LOGIN = VaultAddEditState(
@ -3696,6 +3800,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
),
dialog = null,
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries,
hasSeenLearnAboutLoginsCard = false,
)
private val DEFAULT_STATE_IDENTITY = VaultAddEditState(
@ -3707,6 +3812,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
),
dialog = null,
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries,
hasSeenLearnAboutLoginsCard = false,
)
private val DEFAULT_STATE_CARD = VaultAddEditState(
@ -3718,6 +3824,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
),
dialog = null,
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries,
hasSeenLearnAboutLoginsCard = false,
)
@Suppress("MaxLineLength")
@ -3740,6 +3847,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
dialog = null,
vaultAddEditType = VaultAddEditType.AddItem(VaultItemCipherType.SECURE_NOTE),
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries,
hasSeenLearnAboutLoginsCard = false,
)
private val DEFAULT_STATE_SECURE_NOTES = VaultAddEditState(
@ -3751,6 +3859,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
),
dialog = null,
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries,
hasSeenLearnAboutLoginsCard = false,
)
private val DEFAULT_STATE_SSH_KEYS = VaultAddEditState(
@ -3762,6 +3871,7 @@ class VaultAddEditScreenTest : BaseComposeTest() {
),
dialog = null,
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries,
hasSeenLearnAboutLoginsCard = false,
)
private val ALTERED_COLLECTIONS = listOf(

View file

@ -25,12 +25,15 @@ 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.FeatureFlagManager
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.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
@ -87,10 +90,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 +169,20 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
every { isNetworkConnected } returns true
}
private val mutableHasSeenAddLoginCoachMarkFlow = MutableStateFlow(false)
private val firstTimeActionManager = mockk<FirstTimeActionManager> {
every { hasSeenAddLoginCoachMarkTour() } just runs
every { hasSeenAddLoginCoachMarkFlow } returns mutableHasSeenAddLoginCoachMarkFlow
}
// Feature flag default to be enabled.
private val mutableOnboardingFeatureFlagFlow = MutableStateFlow(true)
private val featureFlagManager = mockk<FeatureFlagManager> {
every {
getFeatureFlagFlow(FlagKey.OnboardingFlow)
} returns mutableOnboardingFeatureFlagFlow
}
@BeforeEach
fun setup() {
mockkStatic(CipherView::toViewState)
@ -191,6 +211,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
shouldExitOnSave = false,
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries
.filter { it != VaultAddEditState.ItemTypeOption.SSH_KEYS },
hasSeenLearnAboutLoginsCard = false,
)
val viewModel = createAddVaultItemViewModel(
savedStateHandle = createSavedStateHandleWithState(
@ -273,6 +294,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
dialog = null,
supportedItemTypes = VaultAddEditState.ItemTypeOption.entries
.filter { it != VaultAddEditState.ItemTypeOption.SSH_KEYS },
hasSeenLearnAboutLoginsCard = false,
),
viewModel.stateFlow.value,
)
@ -2601,6 +2623,103 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `when OnboardFlow feature flag is off, shouldShowLearnAboutNewLogins should be false`() {
mutableOnboardingFeatureFlagFlow.update { true }
val viewModel = createAddVaultItemViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = createVaultAddItemState(
typeContentViewState = createLoginTypeContentViewState(),
),
vaultAddEditType = VaultAddEditType.AddItem(
vaultItemCipherType = VaultItemCipherType.LOGIN,
),
),
)
assertTrue(viewModel.stateFlow.value.shouldShowLearnAboutNewLogins)
mutableOnboardingFeatureFlagFlow.update { false }
assertFalse(viewModel.stateFlow.value.shouldShowLearnAboutNewLogins)
}
@Suppress("MaxLineLength")
@Test
fun `when first time action manager has seen logins tour value updates to true shouldShowLearnAboutNewLogins should update to false`() {
val viewModel = createAddVaultItemViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = createVaultAddItemState(
typeContentViewState = createLoginTypeContentViewState(),
),
vaultAddEditType = VaultAddEditType.AddItem(
vaultItemCipherType = VaultItemCipherType.LOGIN,
),
),
)
assertTrue(viewModel.stateFlow.value.shouldShowLearnAboutNewLogins)
mutableHasSeenAddLoginCoachMarkFlow.update { true }
assertFalse(viewModel.stateFlow.value.shouldShowLearnAboutNewLogins)
}
@Suppress("MaxLineLength")
@Test
fun `when first time action manager value is false, but type content is not login shouldShowLearnAboutNewLogins should be false`() {
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.hasSeenAddLoginCoachMarkTour() }
}
@Suppress("MaxLineLength")
@Test
fun `LearnAboutLoginsDismissed 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.hasSeenAddLoginCoachMarkTour() }
}
@Nested
inner class VaultAddEditIdentityTypeItemActions {
private lateinit var viewModel: VaultAddEditViewModel
@ -3126,6 +3245,8 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
clock = fixedClock,
organizationEventManager = organizationEventManager,
networkConnectionManager = networkConnectionManager,
firstTimeActionManager = firstTimeActionManager,
featureFlagManager = featureFlagManager,
)
}
@ -4276,6 +4397,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
shouldExitOnSave = shouldExitOnSave,
totpData = totpData,
supportedItemTypes = supportedItemTypes,
hasSeenLearnAboutLoginsCard = false,
)
@Suppress("LongParameterList")
@ -4385,6 +4507,8 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
clock = clock,
organizationEventManager = organizationEventManager,
networkConnectionManager = networkConnectionManager,
firstTimeActionManager = firstTimeActionManager,
featureFlagManager = featureFlagManager,
)
private fun createVaultData(