BIT-526: Clone vault item (#713)

This commit is contained in:
Ramsey Smith 2024-01-22 15:03:20 -07:00 committed by Álison Fernandes
parent 3ec95b0ffd
commit a760127711
13 changed files with 235 additions and 30 deletions

View file

@ -92,8 +92,15 @@ fun NavGraphBuilder.vaultUnlockedGraph(
)
vaultItemDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToVaultEditItem = {
navController.navigateToVaultAddEdit(VaultAddEditType.EditItem(it))
onNavigateToVaultEditItem = { vaultItemId, isClone ->
navController.navigateToVaultAddEdit(
if (isClone) {
VaultAddEditType.CloneItem(vaultItemId)
} else {
VaultAddEditType.EditItem(vaultItemId)
},
)
},
onNavigateToMoveToOrganization = {
navController.navigateToVaultMoveToOrganization(it)

View file

@ -3,11 +3,14 @@ package com.x8bit.bitwarden.ui.platform.manager.di
import android.content.Context
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManagerImpl
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManagerImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Provides UI-based managers in the platform package.
@ -15,11 +18,18 @@ import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
class PlatformUiManagerModule {
@Provides
@Singleton
fun provideIntentManager(
@ApplicationContext context: Context,
): IntentManager =
IntentManagerImpl(
context = context,
)
@Provides
@Singleton
fun provideResourceManager(@ApplicationContext context: Context): ResourceManager =
ResourceManagerImpl(context = context)
}

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.ui.platform.manager.resource
import androidx.annotation.StringRes
/**
* Interface for managing resources.
*/
interface ResourceManager {
/**
* Method for returning a permission string from a [resId].
*/
fun getString(@StringRes resId: Int): String
/**
* Method for returning a permission string from a [resId] with [formatArgs].
*/
fun getString(@StringRes resId: Int, vararg formatArgs: Any): String
}

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.ui.platform.manager.resource
import android.content.Context
import androidx.annotation.StringRes
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
* Primary implementation of [ResourceManager].
*/
@OmitFromCoverage
class ResourceManagerImpl(private val context: Context) : ResourceManager {
override fun getString(@StringRes resId: Int): String =
context.getString(resId)
override fun getString(@StringRes resId: Int, vararg formatArgs: Any): String =
context.getString(resId, *formatArgs)
}

View file

@ -13,6 +13,7 @@ import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
private const val ADD_TYPE: String = "add"
private const val EDIT_TYPE: String = "edit"
private const val CLONE_TYPE: String = "clone"
private const val EDIT_ITEM_ID: String = "vault_edit_id"
private const val ADD_EDIT_ITEM_PREFIX: String = "vault_add_edit_item"
@ -32,6 +33,7 @@ data class VaultAddEditArgs(
vaultAddEditType = when (requireNotNull(savedStateHandle[ADD_EDIT_ITEM_TYPE])) {
ADD_TYPE -> VaultAddEditType.AddItem
EDIT_TYPE -> VaultAddEditType.EditItem(requireNotNull(savedStateHandle[EDIT_ITEM_ID]))
CLONE_TYPE -> VaultAddEditType.CloneItem(requireNotNull(savedStateHandle[EDIT_ITEM_ID]))
else -> throw IllegalStateException("Unknown VaultAddEditType.")
},
)
@ -79,7 +81,12 @@ private fun VaultAddEditType.toTypeString(): String =
when (this) {
is VaultAddEditType.AddItem -> ADD_TYPE
is VaultAddEditType.EditItem -> EDIT_TYPE
is VaultAddEditType.CloneItem -> CLONE_TYPE
}
private fun VaultAddEditType.toIdOrNull(): String? =
(this as? VaultAddEditType.EditItem)?.vaultItemId
when (this) {
is VaultAddEditType.AddItem -> null
is VaultAddEditType.CloneItem -> vaultItemId
is VaultAddEditType.EditItem -> vaultItemId
}

View file

@ -18,6 +18,7 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode
import com.x8bit.bitwarden.ui.vault.feature.addedit.model.CustomFieldType
import com.x8bit.bitwarden.ui.vault.feature.addedit.model.toCustomField
@ -56,6 +57,7 @@ class VaultAddEditViewModel @Inject constructor(
private val clipboardManager: BitwardenClipboardManager,
private val vaultRepository: VaultRepository,
private val generatorRepository: GeneratorRepository,
private val resourceManager: ResourceManager,
) : BaseViewModel<VaultAddEditState, VaultAddEditEvent, VaultAddEditAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE]
@ -70,6 +72,7 @@ class VaultAddEditViewModel @Inject constructor(
)
is VaultAddEditType.EditItem -> VaultAddEditState.ViewState.Loading
is VaultAddEditType.CloneItem -> VaultAddEditState.ViewState.Loading
},
dialog = null,
)
@ -79,18 +82,18 @@ class VaultAddEditViewModel @Inject constructor(
//region Initialization and Overrides
init {
when (val vaultAddEditType = state.vaultAddEditType) {
VaultAddEditType.AddItem -> Unit
is VaultAddEditType.EditItem -> {
state
.vaultAddEditType
.vaultItemId
?.let { itemId ->
vaultRepository
.getVaultItemStateFlow(vaultAddEditType.vaultItemId)
.getVaultItemStateFlow(itemId)
// We'll stop getting updates as soon as we get some loaded data.
.takeUntilLoaded()
.map { VaultAddEditAction.Internal.VaultDataReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
}
vaultRepository
.totpCodeFlow
@ -240,6 +243,11 @@ class VaultAddEditViewModel @Inject constructor(
)
sendAction(VaultAddEditAction.Internal.UpdateCipherResultReceive(result))
}
is VaultAddEditType.CloneItem -> {
val result = vaultRepository.createCipher(cipherView = content.toCipherView())
sendAction(VaultAddEditAction.Internal.CreateCipherResultReceive(result))
}
}
}
}
@ -810,6 +818,7 @@ class VaultAddEditViewModel @Inject constructor(
}
}
@Suppress("LongMethod")
private fun handleVaultDataReceive(action: VaultAddEditAction.Internal.VaultDataReceive) {
when (val vaultDataState = action.vaultDataState) {
is DataState.Error -> {
@ -827,7 +836,10 @@ class VaultAddEditViewModel @Inject constructor(
it.copy(
viewState = vaultDataState
.data
?.toViewState()
?.toViewState(
isClone = it.isCloneMode,
resourceManager = resourceManager,
)
?: VaultAddEditState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
@ -858,7 +870,10 @@ class VaultAddEditViewModel @Inject constructor(
it.copy(
viewState = vaultDataState
.data
?.toViewState()
?.toViewState(
isClone = it.isCloneMode,
resourceManager = resourceManager,
)
?: VaultAddEditState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
@ -1041,6 +1056,7 @@ data class VaultAddEditState(
get() = when (vaultAddEditType) {
VaultAddEditType.AddItem -> R.string.add_item.asText()
is VaultAddEditType.EditItem -> R.string.edit_item.asText()
is VaultAddEditType.CloneItem -> R.string.add_item.asText()
}
/**
@ -1048,6 +1064,11 @@ data class VaultAddEditState(
*/
val isAddItemMode: Boolean get() = vaultAddEditType == VaultAddEditType.AddItem
/**
* Helper to determine if the UI should display the content in clone mode.
*/
val isCloneMode: Boolean get() = vaultAddEditType is VaultAddEditType.CloneItem
/**
* Enum representing the main type options for the vault, such as LOGIN, CARD, etc.
*

View file

@ -7,6 +7,7 @@ import com.bitwarden.core.FieldType
import com.bitwarden.core.FieldView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultCardExpirationMonth
@ -18,7 +19,10 @@ import java.util.UUID
/**
* Transforms [CipherView] into [VaultAddEditState.ViewState].
*/
fun CipherView.toViewState(): VaultAddEditState.ViewState =
fun CipherView.toViewState(
isClone: Boolean,
resourceManager: ResourceManager,
): VaultAddEditState.ViewState =
VaultAddEditState.ViewState.Content(
type = when (type) {
CipherType.LOGIN -> {
@ -63,7 +67,10 @@ fun CipherView.toViewState(): VaultAddEditState.ViewState =
},
common = VaultAddEditState.ViewState.Content.Common(
originalCipher = this,
name = this.name,
name = name.appendCloneTextIfRequired(
isClone = isClone,
resourceManager = resourceManager,
),
favorite = this.favorite,
masterPasswordReprompt = this.reprompt == CipherRepromptType.PASSWORD,
notes = this.notes.orEmpty(),
@ -121,3 +128,13 @@ private fun String?.toExpirationMonthOrDefault(): VaultCardExpirationMonth =
.entries
.find { it.number == this }
?: VaultCardExpirationMonth.SELECT
private fun String.appendCloneTextIfRequired(
isClone: Boolean,
resourceManager: ResourceManager,
): String =
if (isClone) {
plus(" - ${resourceManager.getString(R.string.clone)}")
} else {
this
}

View file

@ -28,7 +28,7 @@ data class VaultItemArgs(val vaultItemId: String) {
*/
fun NavGraphBuilder.vaultItemDestination(
onNavigateBack: () -> Unit,
onNavigateToVaultEditItem: (vaultItemId: String) -> Unit,
onNavigateToVaultEditItem: (vaultItemId: String, isClone: Boolean) -> Unit,
onNavigateToMoveToOrganization: (vaultItemId: String) -> Unit,
) {
composableWithSlideTransitions(

View file

@ -59,7 +59,7 @@ fun VaultItemScreen(
viewModel: VaultItemViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
onNavigateBack: () -> Unit,
onNavigateToVaultAddEditItem: (vaultItemId: String) -> Unit,
onNavigateToVaultAddEditItem: (vaultItemId: String, isClone: Boolean) -> Unit,
onNavigateToMoveToOrganization: (vaultItemId: String) -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@ -75,8 +75,7 @@ fun VaultItemScreen(
VaultItemEvent.NavigateBack -> onNavigateBack()
is VaultItemEvent.NavigateToAddEdit -> {
// TODO Implement cloning in BIT-526
onNavigateToVaultAddEditItem(event.itemId)
onNavigateToVaultAddEditItem(event.itemId, event.isClone)
}
is VaultItemEvent.NavigateToPasswordHistory -> {

View file

@ -7,19 +7,34 @@ import kotlinx.parcelize.Parcelize
* Represents the difference between create a completely new cipher and editing an existing one.
*/
sealed class VaultAddEditType : Parcelable {
/**
* The ID of the vault item (nullable).
*/
abstract val vaultItemId: String?
/**
* Indicates that we want to create a completely new vault item.
*/
@Parcelize
data object AddItem : VaultAddEditType()
data object AddItem : VaultAddEditType() {
override val vaultItemId: String?
get() = null
}
/**
* Indicates that we want to edit an existing item.
*
* @param vaultItemId The ID of the vault item to edit.
*/
@Parcelize
data class EditItem(
val vaultItemId: String,
override val vaultItemId: String,
) : VaultAddEditType()
/**
* Indicates that we want to clone an existing item.
*/
@Parcelize
data class CloneItem(
override val vaultItemId: String,
) : VaultAddEditType()
}

View file

@ -16,6 +16,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode
import com.x8bit.bitwarden.ui.vault.feature.addedit.model.CustomFieldType
import com.x8bit.bitwarden.ui.vault.feature.addedit.model.toCustomField
@ -58,7 +59,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
private val totpTestCodeFlow: MutableSharedFlow<TotpCodeResult> = bufferedMutableSharedFlow()
private val mutableVaultItemFlow = MutableStateFlow<DataState<CipherView?>>(DataState.Loading)
private val resourceManager: ResourceManager = mockk()
private val clipboardManager: BitwardenClipboardManager = mockk()
private val vaultRepository: VaultRepository = mockk {
every { getVaultItemStateFlow(DEFAULT_EDIT_ITEM_ID) } returns mutableVaultItemFlow
@ -131,6 +132,25 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `initial clone state should be correct`() = runTest {
val vaultAddEditType = VaultAddEditType.CloneItem(DEFAULT_EDIT_ITEM_ID)
val initState = createVaultAddItemState(vaultAddEditType = vaultAddEditType)
val viewModel = createAddVaultItemViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = initState,
vaultAddEditType = vaultAddEditType,
),
)
assertEquals(
initState.copy(viewState = VaultAddEditState.ViewState.Loading),
viewModel.stateFlow.value,
)
verify(exactly = 1) {
vaultRepository.getVaultItemStateFlow(DEFAULT_EDIT_ITEM_ID)
}
}
@Test
fun `CloseClick should emit NavigateBack`() = runTest {
val viewModel = createAddVaultItemViewModel()
@ -254,7 +274,12 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
name = "mockName",
),
)
every { cipherView.toViewState() } returns stateWithName.viewState
every {
cipherView.toViewState(
isClone = false,
resourceManager = resourceManager,
)
} returns stateWithName.viewState
mutableVaultItemFlow.value = DataState.Loaded(cipherView)
val viewModel = createAddVaultItemViewModel(
@ -276,7 +301,10 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
}
coVerify(exactly = 1) {
cipherView.toViewState()
cipherView.toViewState(
isClone = false,
resourceManager = resourceManager,
)
vaultRepository.updateCipher(DEFAULT_EDIT_ITEM_ID, any())
}
}
@ -294,7 +322,12 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
),
)
every { cipherView.toViewState() } returns stateWithName.viewState
every {
cipherView.toViewState(
isClone = false,
resourceManager = resourceManager,
)
} returns stateWithName.viewState
coEvery {
vaultRepository.updateCipher(DEFAULT_EDIT_ITEM_ID, any())
} returns UpdateCipherResult.Error(errorMessage = null)
@ -336,7 +369,12 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
)
val errorMessage = "You do not have permission to edit this."
every { cipherView.toViewState() } returns stateWithName.viewState
every {
cipherView.toViewState(
isClone = false,
resourceManager = resourceManager,
)
} returns stateWithName.viewState
coEvery {
vaultRepository.updateCipher(DEFAULT_EDIT_ITEM_ID, any())
} returns UpdateCipherResult.Error(errorMessage = errorMessage)
@ -1192,6 +1230,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
clipboardManager = clipboardManager,
vaultRepository = vaultRepository,
generatorRepository = generatorRepository,
resourceManager = resourceManager,
)
}
@ -1484,6 +1523,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
when (vaultAddEditType) {
VaultAddEditType.AddItem -> "add"
is VaultAddEditType.EditItem -> "edit"
is VaultAddEditType.CloneItem -> "clone"
},
)
set("vault_edit_id", (vaultAddEditType as? VaultAddEditType.EditItem)?.vaultItemId)
@ -1494,12 +1534,14 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
bitwardenClipboardManager: BitwardenClipboardManager = clipboardManager,
vaultRepo: VaultRepository = vaultRepository,
generatorRepo: GeneratorRepository = generatorRepository,
bitwardenResourceManager: ResourceManager = resourceManager,
): VaultAddEditViewModel =
VaultAddEditViewModel(
savedStateHandle = savedStateHandle,
clipboardManager = bitwardenClipboardManager,
vaultRepository = vaultRepo,
generatorRepository = generatorRepo,
resourceManager = bitwardenResourceManager,
)
/**

View file

@ -14,10 +14,12 @@ import com.bitwarden.core.SecureNoteType
import com.bitwarden.core.SecureNoteView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditState
import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand
import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.junit.jupiter.api.AfterEach
@ -29,6 +31,10 @@ import java.util.UUID
class CipherViewExtensionsTest {
private val resourceManager: ResourceManager = mockk {
every { getString(R.string.clone) } returns "Clone"
}
@BeforeEach
fun setup() {
mockkStatic(UUID::randomUUID)
@ -44,7 +50,10 @@ class CipherViewExtensionsTest {
fun `toViewState should create a Card ViewState`() {
val cipherView = DEFAULT_CARD_CIPHER_VIEW
val result = cipherView.toViewState()
val result = cipherView.toViewState(
isClone = false,
resourceManager = resourceManager,
)
assertEquals(
VaultAddEditState.ViewState.Content(
@ -85,7 +94,10 @@ class CipherViewExtensionsTest {
fun `toViewState should create a Identity ViewState`() {
val cipherView = DEFAULT_IDENTITY_CIPHER_VIEW
val result = cipherView.toViewState()
val result = cipherView.toViewState(
isClone = false,
resourceManager = resourceManager,
)
assertEquals(
VaultAddEditState.ViewState.Content(
@ -131,7 +143,10 @@ class CipherViewExtensionsTest {
fun `toViewState should create a Login ViewState`() {
val cipherView = DEFAULT_LOGIN_CIPHER_VIEW
val result = cipherView.toViewState()
val result = cipherView.toViewState(
isClone = false,
resourceManager = resourceManager,
)
assertEquals(
VaultAddEditState.ViewState.Content(
@ -172,7 +187,10 @@ class CipherViewExtensionsTest {
fun `toViewState should create a Secure Notes ViewState`() {
val cipherView = DEFAULT_SECURE_NOTES_CIPHER_VIEW
val result = cipherView.toViewState()
val result = cipherView.toViewState(
isClone = false,
resourceManager = resourceManager,
)
assertEquals(
VaultAddEditState.ViewState.Content(
@ -197,6 +215,39 @@ class CipherViewExtensionsTest {
result,
)
}
@Test
fun `toViewState with isClone true should append clone text to the cipher name`() {
val cipherView = DEFAULT_SECURE_NOTES_CIPHER_VIEW
val result = cipherView.toViewState(
isClone = true,
resourceManager = resourceManager,
)
assertEquals(
VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(
originalCipher = cipherView,
name = "cipher - Clone",
folderName = R.string.folder_none.asText(),
favorite = false,
masterPasswordReprompt = true,
notes = "Lots of notes",
ownership = "",
customFieldData = listOf(
VaultAddEditState.Custom.BooleanField(TEST_ID, "TestBoolean", false),
VaultAddEditState.Custom.TextField(TEST_ID, "TestText", "TestText"),
VaultAddEditState.Custom.HiddenField(TEST_ID, "TestHidden", "TestHidden"),
),
availableFolders = emptyList(),
availableOwners = emptyList(),
),
type = VaultAddEditState.ViewState.Content.ItemType.SecureNotes,
),
result,
)
}
}
private val DEFAULT_BASE_CIPHER_VIEW: CipherView = CipherView(

View file

@ -70,7 +70,7 @@ class VaultItemScreenTest : BaseComposeTest() {
VaultItemScreen(
viewModel = viewModel,
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToVaultAddEditItem = { onNavigateToVaultEditItemId = it },
onNavigateToVaultAddEditItem = { id, _ -> onNavigateToVaultEditItemId = id },
onNavigateToMoveToOrganization = { onNavigateToMoveToOrganizationItemId = it },
intentManager = intentManager,
)