BIT-1648: Add the copy totp code overflow option (#850)

This commit is contained in:
Oleg Semenenko 2024-01-29 16:40:54 -06:00 committed by Álison Fernandes
parent 0c6ea8d18d
commit 20dd839923
13 changed files with 283 additions and 3 deletions

View file

@ -59,6 +59,7 @@ fun SearchContent(
is ListingItemOverflowAction.VaultAction.CopyNoteClick,
is ListingItemOverflowAction.VaultAction.CopyNumberClick,
is ListingItemOverflowAction.VaultAction.CopyPasswordClick,
is ListingItemOverflowAction.VaultAction.CopyTotpClick,
is ListingItemOverflowAction.VaultAction.CopySecurityCodeClick,
is ListingItemOverflowAction.VaultAction.CopyUsernameClick,
is ListingItemOverflowAction.VaultAction.EditClick,

View file

@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
@ -190,6 +191,10 @@ class SearchViewModel @Inject constructor(
is ListingItemOverflowAction.VaultAction.ViewClick -> {
handleViewCipherClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.CopyTotpClick -> {
handleCopyTotpClick(overflowAction)
}
}
}
@ -211,6 +216,15 @@ class SearchViewModel @Inject constructor(
sendEvent(SearchEvent.NavigateToEditSend(action.sendId))
}
private fun handleCopyTotpClick(
action: ListingItemOverflowAction.VaultAction.CopyTotpClick,
) {
viewModelScope.launch {
val result = vaultRepo.generateTotp(action.totpCode, clock.instant())
sendAction(SearchAction.Internal.GenerateTotpResultReceive(result))
}
}
private fun handleRemovePasswordClick(
action: ListingItemOverflowAction.SendAction.RemovePasswordClick,
) {
@ -283,6 +297,10 @@ class SearchViewModel @Inject constructor(
handleDeleteSendResultReceive(action)
}
is SearchAction.Internal.GenerateTotpResultReceive -> {
handleGenerateTotpResultReceive(action)
}
is SearchAction.Internal.RemovePasswordSendResultReceive -> {
handleRemovePasswordSendResultReceive(action)
}
@ -320,6 +338,17 @@ class SearchViewModel @Inject constructor(
}
}
private fun handleGenerateTotpResultReceive(
action: SearchAction.Internal.GenerateTotpResultReceive,
) {
when (val result = action.result) {
is GenerateTotpResult.Error -> Unit
is GenerateTotpResult.Success -> {
clipboardManager.setText(result.code)
}
}
}
private fun handleRemovePasswordSendResultReceive(
action: SearchAction.Internal.RemovePasswordSendResultReceive,
) {
@ -758,6 +787,13 @@ sealed class SearchAction {
val result: DeleteSendResult,
) : Internal()
/**
* Indicates a result for generating a verification code has been received.
*/
data class GenerateTotpResultReceive(
val result: GenerateTotpResult,
) : Internal()
/**
* Indicates a result for removing the password protection from a send has been received.
*/

View file

@ -66,6 +66,7 @@ fun VaultItemListingContent(
is ListingItemOverflowAction.VaultAction.EditClick,
is ListingItemOverflowAction.VaultAction.LaunchClick,
is ListingItemOverflowAction.VaultAction.ViewClick,
is ListingItemOverflowAction.VaultAction.CopyTotpClick,
null,
-> Unit
}

View file

@ -19,6 +19,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl
import com.x8bit.bitwarden.data.platform.repository.util.map
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
@ -256,6 +257,15 @@ class VaultItemListingViewModel @Inject constructor(
clipboardManager.setText(action.securityCode)
}
private fun handleCopyTotpClick(
action: ListingItemOverflowAction.VaultAction.CopyTotpClick,
) {
viewModelScope.launch {
val result = vaultRepository.generateTotp(action.totpCode, clock.instant())
sendAction(VaultItemListingsAction.Internal.GenerateTotpResultReceive(result))
}
}
private fun handleCopyUsernameClick(
action: ListingItemOverflowAction.VaultAction.CopyUsernameClick,
) {
@ -347,6 +357,10 @@ class VaultItemListingViewModel @Inject constructor(
handleCopySecurityCodeClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.CopyTotpClick -> {
handleCopyTotpClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.CopyUsernameClick -> {
handleCopyUsernameClick(overflowAction)
}
@ -383,6 +397,10 @@ class VaultItemListingViewModel @Inject constructor(
is VaultItemListingsAction.Internal.IconLoadingSettingReceive -> {
handleIconsSettingReceived(action)
}
is VaultItemListingsAction.Internal.GenerateTotpResultReceive -> {
handleGenerateTotpResultReceive(action)
}
}
}
@ -445,6 +463,17 @@ class VaultItemListingViewModel @Inject constructor(
}
}
private fun handleGenerateTotpResultReceive(
action: VaultItemListingsAction.Internal.GenerateTotpResultReceive,
) {
when (val result = action.result) {
is GenerateTotpResult.Error -> Unit
is GenerateTotpResult.Success -> {
clipboardManager.setText(result.code)
}
}
}
private fun handleVaultDataReceive(
action: VaultItemListingsAction.Internal.VaultDataReceive,
) {
@ -1033,6 +1062,13 @@ sealed class VaultItemListingsAction {
*/
data class DeleteSendResultReceive(val result: DeleteSendResult) : Internal()
/**
* Indicates a result for generating a verification code has been received.
*/
data class GenerateTotpResultReceive(
val result: GenerateTotpResult,
) : Internal()
/**
* Indicates a result for removing the password protection from a send has been received.
*/

View file

@ -97,6 +97,14 @@ sealed class ListingItemOverflowAction : Parcelable {
override val title: Text get() = R.string.copy_password.asText()
}
/**
* Click on the copy TOTP code overflow option.
*/
@Parcelize
data class CopyTotpClick(val totpCode: String) : VaultAction() {
override val title: Text get() = R.string.copy_totp.asText()
}
/**
* Click on the copy number overflow option.
*/

View file

@ -23,6 +23,9 @@ fun CipherView.toOverflowActions(): List<ListingItemOverflowAction.VaultAction>
this.login?.password?.let {
ListingItemOverflowAction.VaultAction.CopyPasswordClick(password = it)
},
this.login?.totp
?.let { ListingItemOverflowAction.VaultAction.CopyTotpClick(totpCode = it) }
.takeIf { this.type == CipherType.LOGIN },
this.card?.number?.let {
ListingItemOverflowAction.VaultAction.CopyNumberClick(number = it)
},

View file

@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
@ -37,7 +38,9 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.time.Clock
import javax.inject.Inject
/**
@ -46,10 +49,11 @@ import javax.inject.Inject
@Suppress("TooManyFunctions")
@HiltViewModel
class VaultViewModel @Inject constructor(
private val clipboardManager: BitwardenClipboardManager,
private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository,
private val clipboardManager: BitwardenClipboardManager,
private val clock: Clock,
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
initialState = run {
val userState = requireNotNull(authRepository.userStateFlow.value)
@ -293,6 +297,10 @@ class VaultViewModel @Inject constructor(
handleCopySecurityCodeClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.CopyTotpClick -> {
handleCopyTotpClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.CopyUsernameClick -> {
handleCopyUsernameClick(overflowAction)
}
@ -333,6 +341,15 @@ class VaultViewModel @Inject constructor(
clipboardManager.setText(action.securityCode)
}
private fun handleCopyTotpClick(
action: ListingItemOverflowAction.VaultAction.CopyTotpClick,
) {
viewModelScope.launch {
val result = vaultRepository.generateTotp(action.totpCode, clock.instant())
sendAction(VaultAction.Internal.GenerateTotpResultReceive(result))
}
}
private fun handleCopyUsernameClick(
action: ListingItemOverflowAction.VaultAction.CopyUsernameClick,
) {
@ -353,6 +370,10 @@ class VaultViewModel @Inject constructor(
private fun handleInternalAction(action: VaultAction.Internal) {
when (action) {
is VaultAction.Internal.GenerateTotpResultReceive -> {
handleGenerateTotpResultReceive(action)
}
is VaultAction.Internal.PullToRefreshEnableReceive -> {
handlePullToRefreshEnableReceive(action)
}
@ -365,6 +386,17 @@ class VaultViewModel @Inject constructor(
}
}
private fun handleGenerateTotpResultReceive(
action: VaultAction.Internal.GenerateTotpResultReceive,
) {
when (val result = action.result) {
is GenerateTotpResult.Error -> Unit
is GenerateTotpResult.Success -> {
clipboardManager.setText(result.code)
}
}
}
private fun handlePullToRefreshEnableReceive(
action: VaultAction.Internal.PullToRefreshEnableReceive,
) {
@ -1002,6 +1034,13 @@ sealed class VaultAction {
val isIconLoadingDisabled: Boolean,
) : Internal()
/**
* Indicates a result for generating a verification code has been received.
*/
data class GenerateTotpResultReceive(
val result: GenerateTotpResult,
) : Internal()
/**
* Indicates that the pull to refresh feature toggle has changed.
*/

View file

@ -18,6 +18,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
@ -331,6 +332,51 @@ class SearchViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `OverflowOptionClick Vault CopyTotpClick with GenerateTotpCode success should call setText on the ClipboardManager`() =
runTest {
val totpCode = "totpCode"
val code = "Code"
coEvery {
vaultRepository.generateTotp(totpCode, clock.instant())
} returns GenerateTotpResult.Success(code, 30)
val viewModel = createViewModel()
viewModel.trySendAction(
SearchAction.OverflowOptionClick(
ListingItemOverflowAction.VaultAction.CopyTotpClick(totpCode),
),
)
verify(exactly = 1) {
clipboardManager.setText(code)
}
}
@Suppress("MaxLineLength")
@Test
fun `OverflowOptionClick Vault CopyTotpClick with GenerateTotpCode failure should not call setText on the ClipboardManager`() =
runTest {
val totpCode = "totpCode"
coEvery {
vaultRepository.generateTotp(totpCode, clock.instant())
} returns GenerateTotpResult.Error
val viewModel = createViewModel()
viewModel.trySendAction(
SearchAction.OverflowOptionClick(
ListingItemOverflowAction.VaultAction.CopyTotpClick(totpCode),
),
)
verify(exactly = 0) {
clipboardManager.setText(text = any<String>())
}
}
@Suppress("MaxLineLength")
@Test
fun `OverflowOptionClick Vault CopyPasswordClick should call setText on the ClipboardManager`() =

View file

@ -45,6 +45,9 @@ fun createMockDisplayItemForCipher(
ListingItemOverflowAction.VaultAction.CopyPasswordClick(
password = "mockPassword-$number",
),
ListingItemOverflowAction.VaultAction.CopyTotpClick(
totpCode = "mockTotp-$number",
),
ListingItemOverflowAction.VaultAction.LaunchClick(
url = "www.mockuri$number.com",
),

View file

@ -25,6 +25,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
@ -511,6 +512,51 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `OverflowOptionClick Vault CopyTotpClick with GenerateTotpCode success should call setText on the ClipboardManager`() =
runTest {
val totpCode = "totpCode"
val code = "Code"
coEvery {
vaultRepository.generateTotp(totpCode, clock.instant())
} returns GenerateTotpResult.Success(code, 30)
val viewModel = createVaultItemListingViewModel()
viewModel.trySendAction(
VaultItemListingsAction.OverflowOptionClick(
ListingItemOverflowAction.VaultAction.CopyTotpClick(totpCode),
),
)
verify(exactly = 1) {
clipboardManager.setText(code)
}
}
@Suppress("MaxLineLength")
@Test
fun `OverflowOptionClick Vault CopyTotpClick with GenerateTotpCode failure should not call setText on the ClipboardManager`() =
runTest {
val totpCode = "totpCode"
coEvery {
vaultRepository.generateTotp(totpCode, clock.instant())
} returns GenerateTotpResult.Error
val viewModel = createVaultItemListingViewModel()
viewModel.trySendAction(
VaultItemListingsAction.OverflowOptionClick(
ListingItemOverflowAction.VaultAction.CopyTotpClick(totpCode),
),
)
verify(exactly = 0) {
clipboardManager.setText(text = any<String>())
}
}
@Suppress("MaxLineLength")
@Test
fun `OverflowOptionClick Vault CopyUsernameClick should call setText on the ClipboardManager`() =

View file

@ -46,6 +46,9 @@ fun createMockDisplayItemForCipher(
ListingItemOverflowAction.VaultAction.CopyPasswordClick(
password = "mockPassword-$number",
),
ListingItemOverflowAction.VaultAction.CopyTotpClick(
totpCode = "mockTotp-$number",
),
ListingItemOverflowAction.VaultAction.LaunchClick(
url = "www.mockuri$number.com",
),

View file

@ -20,6 +20,7 @@ class CipherViewExtensionsTest {
val id = "mockId-1"
val username = "Bitwarden"
val password = "password"
val totpCode = "mockTotp-1"
val uri = "www.test.com"
val cipher = createMockCipherView(number = 1, cipherType = CipherType.LOGIN).copy(
id = id,
@ -38,6 +39,7 @@ class CipherViewExtensionsTest {
ListingItemOverflowAction.VaultAction.EditClick(cipherId = id),
ListingItemOverflowAction.VaultAction.CopyUsernameClick(username = username),
ListingItemOverflowAction.VaultAction.CopyPasswordClick(password = password),
ListingItemOverflowAction.VaultAction.CopyTotpClick(totpCode = totpCode),
ListingItemOverflowAction.VaultAction.LaunchClick(url = uri),
),
result,
@ -58,6 +60,7 @@ class CipherViewExtensionsTest {
username = null,
password = null,
uris = null,
totp = null,
),
)

View file

@ -16,6 +16,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionV
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -25,6 +26,7 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toViewState
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@ -36,9 +38,16 @@ 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.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
@Suppress("LargeClass")
class VaultViewModelTest : BaseViewModelTest() {
private val clock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val clipboardManager: BitwardenClipboardManager = mockk {
every { setText(any<String>()) } just runs
@ -1108,6 +1117,51 @@ class VaultViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `OverflowOptionClick Vault CopyTotpClick with GenerateTotpCode success should call setText on the ClipboardManager`() =
runTest {
val totpCode = "totpCode"
val code = "Code"
coEvery {
vaultRepository.generateTotp(totpCode, clock.instant())
} returns GenerateTotpResult.Success(code, 30)
val viewModel = createViewModel()
viewModel.trySendAction(
VaultAction.OverflowOptionClick(
ListingItemOverflowAction.VaultAction.CopyTotpClick(totpCode),
),
)
verify(exactly = 1) {
clipboardManager.setText(code)
}
}
@Suppress("MaxLineLength")
@Test
fun `OverflowOptionClick Vault CopyTotpClick with GenerateTotpCode failure should not call setText on the ClipboardManager`() =
runTest {
val totpCode = "totpCode"
coEvery {
vaultRepository.generateTotp(totpCode, clock.instant())
} returns GenerateTotpResult.Error
val viewModel = createViewModel()
viewModel.trySendAction(
VaultAction.OverflowOptionClick(
ListingItemOverflowAction.VaultAction.CopyTotpClick(totpCode),
),
)
verify(exactly = 0) {
clipboardManager.setText(text = any<String>())
}
}
@Suppress("MaxLineLength")
@Test
fun `OverflowOptionClick Vault CopySecurityCodeClick should call setText on the ClipboardManager`() =
@ -1188,8 +1242,9 @@ class VaultViewModelTest : BaseViewModelTest() {
private fun createViewModel(): VaultViewModel =
VaultViewModel(
clipboardManager = clipboardManager,
authRepository = authRepository,
clipboardManager = clipboardManager,
clock = clock,
settingsRepository = settingsRepository,
vaultRepository = vaultRepository,
)