BIT-1661: Pre-populate Add Item screen during autofill save (#913)

This commit is contained in:
Brian Yencho 2024-01-31 16:28:39 -06:00 committed by Álison Fernandes
parent bafebb46f3
commit 4f08d5ddbe
8 changed files with 363 additions and 36 deletions

View file

@ -1,8 +1,19 @@
package com.x8bit.bitwarden.data.platform.manager.util
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
/**
* Returns [AutofillSaveItem] when contained in the given [SpecialCircumstance].
*/
fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? =
when (this) {
is SpecialCircumstance.AutofillSave -> this.autofillSaveItem
is SpecialCircumstance.AutofillSelection -> null
is SpecialCircumstance.ShareNewSend -> null
}
/**
* Returns [AutofillSelectionData] when contained in the given [SpecialCircumstance].
*/

View file

@ -34,9 +34,12 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.NavigationIcon
import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager
import com.x8bit.bitwarden.ui.platform.theme.LocalExitManager
import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.theme.LocalPermissionsManager
import com.x8bit.bitwarden.ui.platform.util.persistentListOfNotNull
@ -58,6 +61,7 @@ fun VaultAddEditScreen(
viewModel: VaultAddEditViewModel = hiltViewModel(),
permissionsManager: PermissionsManager = LocalPermissionsManager.current,
intentManager: IntentManager = LocalIntentManager.current,
exitManager: ExitManager = LocalExitManager.current,
onNavigateToManualCodeEntryScreen: () -> Unit,
onNavigateToGeneratorModal: (GeneratorMode.Modal) -> Unit,
onNavigateToAttachments: (cipherId: String) -> Unit,
@ -94,6 +98,7 @@ fun VaultAddEditScreen(
onNavigateToMoveToOrganization(event.cipherId, true)
}
VaultAddEditEvent.ExitApp -> exitManager.exitApplication()
VaultAddEditEvent.NavigateBack -> onNavigateBack.invoke()
is VaultAddEditEvent.NavigateToTooltipUri -> {
@ -156,11 +161,14 @@ fun VaultAddEditScreen(
topBar = {
BitwardenTopAppBar(
title = state.screenDisplayName(),
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAddEditAction.Common.CloseClick) }
},
navigationIcon = NavigationIcon(
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(VaultAddEditAction.Common.CloseClick) }
},
)
.takeIf { state.shouldShowCloseButton },
scrollBehavior = scrollBehavior,
actions = {
BitwardenTextButton(

View file

@ -8,10 +8,10 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.takeUntilLoaded
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
@ -84,16 +84,16 @@ class VaultAddEditViewModel @Inject constructor(
val vaultAddEditType = VaultAddEditArgs(savedStateHandle).vaultAddEditType
// Check for autofill data to pre-populate
val autofillSelectionData: AutofillSelectionData? =
when (val specialCircumstance = specialCircumstanceManager.specialCircumstance) {
is SpecialCircumstance.AutofillSelection -> {
specialCircumstance.autofillSelectionData
}
else -> null
}
val autofillSaveItem = specialCircumstanceManager
.specialCircumstance
?.toAutofillSaveItemOrNull()
val autofillSelectionData = specialCircumstanceManager
.specialCircumstance
?.toAutofillSelectionDataOrNull()
val defaultAddTypeContent = autofillSelectionData
?.toDefaultAddTypeContent()
?: autofillSaveItem
?.toDefaultAddTypeContent()
?: VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(),
type = VaultAddEditState.ViewState.Content.ItemType.Login(),
@ -107,6 +107,9 @@ class VaultAddEditViewModel @Inject constructor(
is VaultAddEditType.CloneItem -> VaultAddEditState.ViewState.Loading
},
dialog = null,
// Set special conditions for autofill save
shouldShowCloseButton = autofillSaveItem == null,
shouldExitOnSave = autofillSaveItem != null,
)
},
) {
@ -985,9 +988,16 @@ class VaultAddEditViewModel @Inject constructor(
}
is CreateCipherResult.Success -> {
sendEvent(
event = VaultAddEditEvent.NavigateBack,
)
if (state.shouldExitOnSave) {
specialCircumstanceManager.specialCircumstance = null
sendEvent(
event = VaultAddEditEvent.ExitApp,
)
} else {
sendEvent(
event = VaultAddEditEvent.NavigateBack,
)
}
}
}
}
@ -1064,6 +1074,10 @@ class VaultAddEditViewModel @Inject constructor(
}
DataState.Loading -> {
// Skip loading states for add modes, since this will blow away any initial content
// or user-selected content.
if (state.isAddItemMode) return
mutableStateFlow.update {
it.copy(viewState = VaultAddEditState.ViewState.Loading)
}
@ -1354,6 +1368,9 @@ data class VaultAddEditState(
val vaultAddEditType: VaultAddEditType,
val viewState: ViewState,
val dialog: DialogState?,
val shouldShowCloseButton: Boolean = true,
// Internal
val shouldExitOnSave: Boolean = false,
) : Parcelable {
/**
@ -1704,6 +1721,11 @@ sealed class VaultAddEditEvent {
*/
data class ShowToast(val message: Text) : VaultAddEditEvent()
/**
* Leave the application.
*/
data object ExitApp : VaultAddEditEvent()
/**
* Navigate back to previous screen.
*/

View file

@ -0,0 +1,51 @@
package com.x8bit.bitwarden.ui.vault.feature.addedit.util
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState
import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriItem
import com.x8bit.bitwarden.ui.vault.model.VaultCardExpirationMonth
import java.util.UUID
/**
* Returns pre-filled content that may be used for an "add" type
* [VaultAddEditState.ViewState.Content].
*/
fun AutofillSaveItem.toDefaultAddTypeContent(): VaultAddEditState.ViewState.Content =
when (this) {
is AutofillSaveItem.Card -> {
VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(),
type = VaultAddEditState.ViewState.Content.ItemType.Card(
number = this.number.orEmpty(),
expirationMonth = VaultCardExpirationMonth
.entries
.find { it.number == this.expirationMonth }
?: VaultCardExpirationMonth.SELECT,
expirationYear = this.expirationYear.orEmpty(),
securityCode = this.securityCode.orEmpty(),
),
)
}
is AutofillSaveItem.Login -> {
val uri = this.uri
val simpleUri = uri?.toHostOrPathOrNull()
VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(
name = simpleUri.orEmpty(),
),
type = VaultAddEditState.ViewState.Content.ItemType.Login(
username = this.username.orEmpty(),
password = this.password.orEmpty(),
uriList = listOf(
UriItem(
id = UUID.randomUUID().toString(),
uri = uri,
match = null,
),
),
),
)
}
}

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.platform.manager.util
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import io.mockk.mockk
@ -10,7 +11,37 @@ import org.junit.jupiter.api.Test
class SpecialCircumstanceExtensionsTest {
@Test
fun `toAutofillSelectionDataOrNull should a non-null value for AutofillSelection`() {
fun `toAutofillSaveItemOrNull should return a non-null value for AutofillSave`() {
val autofillSaveItem: AutofillSaveItem = mockk()
assertEquals(
autofillSaveItem,
SpecialCircumstance
.AutofillSave(
autofillSaveItem = autofillSaveItem,
)
.toAutofillSaveItemOrNull(),
)
}
@Test
fun `toAutofillSaveItemOrNull should return a null value for other types`() {
listOf(
SpecialCircumstance.AutofillSelection(
autofillSelectionData = mockk(),
shouldFinishWhenComplete = true,
),
SpecialCircumstance.ShareNewSend(
data = mockk(),
shouldFinishWhenComplete = true,
),
)
.forEach { specialCircumstance ->
assertNull(specialCircumstance.toAutofillSaveItemOrNull())
}
}
@Test
fun `toAutofillSelectionDataOrNull should return a non-null value for AutofillSelection`() {
val autofillSelectionData = AutofillSelectionData(
type = AutofillSelectionData.Type.LOGIN,
uri = "uri",
@ -27,7 +58,7 @@ class SpecialCircumstanceExtensionsTest {
}
@Test
fun `toAutofillSelectionDataOrNull should a null value for other types`() {
fun `toAutofillSelectionDataOrNull should return a null value for other types`() {
listOf(
SpecialCircumstance.AutofillSave(
autofillSaveItem = mockk(),

View file

@ -35,6 +35,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode
@ -82,7 +83,9 @@ class VaultAddEditScreenTest : BaseComposeTest() {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
private val exitManager: ExitManager = mockk {
every { exitApplication() } just runs
}
private val intentManager: IntentManager = mockk {
every { launchUri(any()) } just runs
}
@ -101,11 +104,18 @@ class VaultAddEditScreenTest : BaseComposeTest() {
onNavigateToMoveToOrganization = { id, _ -> onNavigateToMoveToOrganizationId = id },
viewModel = viewModel,
permissionsManager = fakePermissionManager,
exitManager = exitManager,
intentManager = intentManager,
)
}
}
@Test
fun `on ExitApp event should call the exitApplication of ExitManager`() {
mutableEventFlow.tryEmit(VaultAddEditEvent.ExitApp)
verify { exitManager.exitApplication() }
}
@Test
fun `on NavigateBack event should invoke onNavigateBack`() {
mutableEventFlow.tryEmit(VaultAddEditEvent.NavigateBack)
@ -173,6 +183,17 @@ class VaultAddEditScreenTest : BaseComposeTest() {
assertEquals(GeneratorMode.Modal.Username, onNavigateToGeneratorModalType)
}
@Test
fun `close button should update according to state`() {
composeTestRule.onNodeWithContentDescription("Close").assertIsDisplayed()
mutableStateFlow.update {
it.copy(shouldShowCloseButton = false)
}
composeTestRule.onNodeWithContentDescription("Close").assertDoesNotExist()
}
@Test
fun `clicking close button should send CloseClick action`() {
composeTestRule

View file

@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.vault.feature.addedit
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import app.cash.turbine.turbineScope
import com.bitwarden.core.CipherView
import com.bitwarden.core.CollectionView
import com.bitwarden.core.FolderView
@ -13,6 +14,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
@ -59,6 +61,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
@ -119,7 +122,10 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
)
viewModel.stateFlow.test {
assertEquals(
loginInitialState.copy(viewState = VaultAddEditState.ViewState.Loading),
createVaultAddItemState(
commonContentViewState = VaultAddEditState.ViewState.Content.Common(),
typeContentViewState = createLoginTypeContentViewState(),
),
awaitItem(),
)
}
@ -136,7 +142,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
),
)
assertEquals(
initState.copy(viewState = VaultAddEditState.ViewState.Loading),
initState,
viewModel.stateFlow.value,
)
verify(exactly = 1) {
@ -145,7 +151,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
}
@Test
fun `initial add state should be correct when autofill`() = runTest {
fun `initial add state should be correct when autofill selection`() = runTest {
val autofillSelectionData = AutofillSelectionData(
type = AutofillSelectionData.Type.LOGIN,
uri = "https://www.test.com",
@ -168,7 +174,39 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
),
)
assertEquals(
initState.copy(viewState = VaultAddEditState.ViewState.Loading),
initState,
viewModel.stateFlow.value,
)
verify(exactly = 1) {
vaultRepository.vaultDataStateFlow
}
}
@Test
fun `initial add state should be correct when autofill save`() = runTest {
val autofillSaveItem = AutofillSaveItem.Login(
username = "username",
password = "password",
uri = "https://www.test.com",
)
specialCircumstanceManager.specialCircumstance = SpecialCircumstance.AutofillSave(
autofillSaveItem = autofillSaveItem,
)
val autofillContentState = autofillSaveItem.toDefaultAddTypeContent()
val vaultAddEditType = VaultAddEditType.AddItem
val initState = createVaultAddItemState(
vaultAddEditType = vaultAddEditType,
commonContentViewState = autofillContentState.common,
typeContentViewState = autofillContentState.type,
)
val viewModel = createAddVaultItemViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = initState,
vaultAddEditType = vaultAddEditType,
),
)
assertEquals(
initState,
viewModel.stateFlow.value,
)
verify(exactly = 1) {
@ -375,8 +413,9 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `in add mode, SaveClick should show dialog, and remove it once an item is saved`() =
fun `in add mode, SaveClick should show dialog, remove it once an item is saved, and emit NavigateBack`() =
runTest {
val stateWithDialog = createVaultAddItemState(
vaultAddEditType = VaultAddEditType.AddItem,
@ -387,36 +426,104 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
name = "mockName-1",
),
)
val stateWithName = createVaultAddItemState(
vaultAddEditType = VaultAddEditType.AddItem,
commonContentViewState = createCommonContentViewState(
name = "mockName-1",
),
)
mutableVaultDataFlow.value = DataState.Loaded(
createVaultData(),
)
val viewModel = createAddVaultItemViewModel(
createSavedStateHandleWithState(
state = stateWithName,
vaultAddEditType = VaultAddEditType.AddItem,
),
)
coEvery {
vaultRepository.createCipherInOrganization(any(), any())
} returns CreateCipherResult.Success
viewModel.stateFlow.test {
viewModel.actionChannel.trySend(VaultAddEditAction.Common.SaveClick)
assertEquals(stateWithName, awaitItem())
assertEquals(stateWithDialog, awaitItem())
assertEquals(stateWithName, awaitItem())
}
turbineScope {
val stateTurbine = viewModel.stateFlow.testIn(backgroundScope)
val eventTurbine = viewModel.eventFlow.testIn(backgroundScope)
viewModel.actionChannel.trySend(VaultAddEditAction.Common.SaveClick)
assertEquals(stateWithName, stateTurbine.awaitItem())
assertEquals(stateWithDialog, stateTurbine.awaitItem())
assertEquals(stateWithName, stateTurbine.awaitItem())
assertEquals(
VaultAddEditEvent.NavigateBack,
eventTurbine.awaitItem(),
)
}
coVerify(exactly = 1) {
vaultRepository.createCipherInOrganization(any(), any())
}
}
@Suppress("MaxLineLength")
@Test
fun `in add mode during autofill, SaveClick should show dialog, remove it once an item is saved, and emit ExitApp`() =
runTest {
val autofillSaveItem = AutofillSaveItem.Login(
username = null,
password = null,
uri = null,
)
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.AutofillSave(
autofillSaveItem = autofillSaveItem,
)
val stateWithDialog = createVaultAddItemState(
vaultAddEditType = VaultAddEditType.AddItem,
dialogState = VaultAddEditState.DialogState.Loading(
R.string.saving.asText(),
),
commonContentViewState = createCommonContentViewState(
name = "mockName-1",
),
)
.copy(shouldExitOnSave = true)
val stateWithName = createVaultAddItemState(
vaultAddEditType = VaultAddEditType.AddItem,
commonContentViewState = createCommonContentViewState(
name = "mockName-1",
),
)
.copy(shouldExitOnSave = true)
mutableVaultDataFlow.value = DataState.Loaded(
createVaultData(),
)
val viewModel = createAddVaultItemViewModel(
createSavedStateHandleWithState(
state = stateWithName,
vaultAddEditType = VaultAddEditType.AddItem,
),
)
coEvery {
vaultRepository.createCipherInOrganization(any(), any())
} returns CreateCipherResult.Success
turbineScope {
val stateTurbine = viewModel.stateFlow.testIn(backgroundScope)
val eventTurbine = viewModel.eventFlow.testIn(backgroundScope)
viewModel.actionChannel.trySend(VaultAddEditAction.Common.SaveClick)
assertEquals(stateWithName, stateTurbine.awaitItem())
assertEquals(stateWithDialog, stateTurbine.awaitItem())
assertEquals(stateWithName, stateTurbine.awaitItem())
assertEquals(
VaultAddEditEvent.ExitApp,
eventTurbine.awaitItem(),
)
}
assertNull(specialCircumstanceManager.specialCircumstance)
coVerify(exactly = 1) {
vaultRepository.createCipherInOrganization(any(), any())
}

View file

@ -0,0 +1,76 @@
package com.x8bit.bitwarden.ui.vault.feature.addedit.util
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState
import com.x8bit.bitwarden.ui.vault.feature.addedit.model.UriItem
import com.x8bit.bitwarden.ui.vault.model.VaultCardExpirationMonth
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.UUID
class AutofillSaveItemExtensionsTest {
@BeforeEach
fun setUp() {
mockkStatic(UUID::randomUUID)
}
@BeforeEach
fun tearDown() {
unmockkStatic(UUID::randomUUID)
}
@Test
fun `toDefaultAddTypeContent for a Card type should return the correct Content`() {
assertEquals(
VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(),
type = VaultAddEditState.ViewState.Content.ItemType.Card(
number = "number",
expirationMonth = VaultCardExpirationMonth.JANUARY,
expirationYear = "2024",
securityCode = "securityCode",
),
),
AutofillSaveItem.Card(
number = "number",
expirationMonth = "1",
expirationYear = "2024",
securityCode = "securityCode",
)
.toDefaultAddTypeContent(),
)
}
@Test
fun `toDefaultAddTypeContent for a Login type should return the correct Content`() {
every { UUID.randomUUID().toString() } returns "uuid"
assertEquals(
VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(
name = "www.test.com",
),
type = VaultAddEditState.ViewState.Content.ItemType.Login(
username = "username",
password = "password",
uriList = listOf(
UriItem(
id = "uuid",
uri = "https://www.test.com",
match = null,
),
),
),
),
AutofillSaveItem.Login(
username = "username",
password = "password",
uri = "https://www.test.com",
)
.toDefaultAddTypeContent(),
)
}
}