BWA-124 - 'Copy' Option Missing from Long-Press Menu (#300)

This commit is contained in:
Phil Cappelli
2024-12-12 00:28:59 -05:00
committed by GitHub
parent ea1a4e4710
commit ad90785c39
7 changed files with 191 additions and 96 deletions

View File

@@ -53,6 +53,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.authenticator.R
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.ItemListingExpandableFabAction
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.SharedCodesDisplayState
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VaultDropdownMenuAction
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem
import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect
import com.bitwarden.authenticator.ui.platform.base.util.asText
import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenMediumTopAppBar
@@ -211,17 +213,13 @@ fun ItemListingScreen(
)
}
},
onEditItemClick = remember(viewModel) {
{
onDropdownMenuClick = remember(viewModel) {
{ action, item ->
viewModel.trySendAction(
ItemListingAction.EditItemClick(it),
)
}
},
onDeleteItemClick = remember(viewModel) {
{
viewModel.trySendAction(
ItemListingAction.DeleteItemClick(it),
ItemListingAction.DropdownMenuClick(
menuAction = action,
item = item,
),
)
}
},
@@ -245,11 +243,6 @@ fun ItemListingScreen(
viewModel.trySendAction(ItemListingAction.SyncWithBitwardenDismiss)
}
},
onMoveToBitwardenClick = remember(viewModel) {
{
viewModel.trySendAction(ItemListingAction.MoveToBitwardenClick(it))
}
},
)
}
@@ -354,9 +347,7 @@ private fun ItemListingContent(
onScanQrCodeClick: () -> Unit,
onEnterSetupKeyClick: () -> Unit,
onItemClick: (String) -> Unit,
onEditItemClick: (String) -> Unit,
onDeleteItemClick: (String) -> Unit,
onMoveToBitwardenClick: (String) -> Unit,
onDropdownMenuClick: (VaultDropdownMenuAction, VerificationCodeDisplayItem) -> Unit,
onDownloadBitwardenClick: () -> Unit,
onDismissDownloadBitwardenClick: () -> Unit,
onSyncWithBitwardenClick: () -> Unit,
@@ -467,9 +458,9 @@ private fun ItemListingContent(
alertThresholdSeconds = it.alertThresholdSeconds,
startIcon = it.startIcon,
onItemClick = { onItemClick(it.authCode) },
onEditItemClick = { onEditItemClick(it.id) },
onDeleteItemClick = { onDeleteItemClick(it.id) },
onMoveToBitwardenClick = { onMoveToBitwardenClick(it.id) },
onDropdownMenuClick = { action ->
onDropdownMenuClick(action, it)
},
showMoveToBitwarden = it.showMoveToBitwarden,
allowLongPress = it.allowLongPressActions,
modifier = Modifier.fillMaxWidth(),
@@ -508,9 +499,9 @@ private fun ItemListingContent(
alertThresholdSeconds = it.alertThresholdSeconds,
startIcon = it.startIcon,
onItemClick = { onItemClick(it.authCode) },
onEditItemClick = { onEditItemClick(it.id) },
onDeleteItemClick = { onDeleteItemClick(it.id) },
onMoveToBitwardenClick = { onMoveToBitwardenClick(it.id) },
onDropdownMenuClick = { action ->
onDropdownMenuClick(action, it)
},
showMoveToBitwarden = it.showMoveToBitwarden,
allowLongPress = it.allowLongPressActions,
modifier = Modifier.fillMaxWidth(),
@@ -544,9 +535,9 @@ private fun ItemListingContent(
alertThresholdSeconds = it.alertThresholdSeconds,
startIcon = it.startIcon,
onItemClick = { onItemClick(it.authCode) },
onEditItemClick = { },
onDeleteItemClick = { },
onMoveToBitwardenClick = { },
onDropdownMenuClick = { action ->
onDropdownMenuClick(action, it)
},
showMoveToBitwarden = it.showMoveToBitwarden,
allowLongPress = it.allowLongPressActions,
modifier = Modifier.fillMaxWidth(),

View File

@@ -21,6 +21,7 @@ import com.bitwarden.authenticator.data.platform.manager.imports.model.GoogleAut
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticator.data.platform.repository.model.DataState
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.SharedCodesDisplayState
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VaultDropdownMenuAction
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.util.toDisplayItem
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.util.toSharedCodesDisplayState
@@ -62,7 +63,6 @@ class ItemListingViewModel @Inject constructor(
) {
init {
settingsRepository
.authenticatorAlertThresholdSecondsFlow
.map { ItemListingAction.Internal.AlertThresholdSecondsReceive(it) }
@@ -110,10 +110,6 @@ class ItemListingViewModel @Inject constructor(
sendEvent(ItemListingEvent.NavigateBack)
}
is ItemListingAction.DeleteItemClick -> {
handleDeleteItemClick(action)
}
is ItemListingAction.ConfirmDeleteClick -> {
handleConfirmDeleteClick(action)
}
@@ -123,11 +119,7 @@ class ItemListingViewModel @Inject constructor(
}
is ItemListingAction.ItemClick -> {
handleItemClick(action)
}
is ItemListingAction.EditItemClick -> {
handleEditItemClick(action)
handleCopyItemClick(action.authCode)
}
is ItemListingAction.DialogDismiss -> {
@@ -142,6 +134,10 @@ class ItemListingViewModel @Inject constructor(
handleInternalAction(action)
}
is ItemListingAction.DropdownMenuClick -> {
handleDropdownMenuClick(action)
}
ItemListingAction.DownloadBitwardenClick -> {
handleDownloadBitwardenClick()
}
@@ -157,10 +153,6 @@ class ItemListingViewModel @Inject constructor(
ItemListingAction.SyncWithBitwardenDismiss -> {
handleSyncWithBitwardenDismiss()
}
is ItemListingAction.MoveToBitwardenClick -> {
handleMoveToBitwardenClick(action)
}
}
}
@@ -168,27 +160,22 @@ class ItemListingViewModel @Inject constructor(
sendEvent(ItemListingEvent.NavigateToAppSettings)
}
private fun handleItemClick(action: ItemListingAction.ItemClick) {
clipboardManager.setText(action.authCode)
sendEvent(
ItemListingEvent.ShowToast(
message = R.string.value_has_been_copied.asText(action.authCode),
),
)
private fun handleCopyItemClick(authCode: String) {
clipboardManager.setText(authCode)
}
private fun handleEditItemClick(action: ItemListingAction.EditItemClick) {
sendEvent(ItemListingEvent.NavigateToEditItem(action.itemId))
private fun handleEditItemClick(itemId: String) {
sendEvent(ItemListingEvent.NavigateToEditItem(itemId))
}
private fun handleMoveToBitwardenClick(action: ItemListingAction.MoveToBitwardenClick) {
private fun handleMoveToBitwardenClick(itemId: String) {
viewModelScope.launch {
val item = authenticatorRepository
.getItemStateFlow(action.entityId)
.getItemStateFlow(itemId)
.first { it.data != null }
val didLaunchAddTotpFlow = authenticatorBridgeManager.startAddTotpLoginItemFlow(
totpUri = item.data!!.toOtpAuthUriString(),
totpUri = item.data?.toOtpAuthUriString().orEmpty(),
)
if (!didLaunchAddTotpFlow) {
mutableStateFlow.update {
@@ -203,12 +190,12 @@ class ItemListingViewModel @Inject constructor(
}
}
private fun handleDeleteItemClick(action: ItemListingAction.DeleteItemClick) {
private fun handleDeleteItemClick(itemId: String) {
mutableStateFlow.update {
it.copy(
dialog = ItemListingState.DialogState.DeleteConfirmationPrompt(
message = R.string.do_you_really_want_to_permanently_delete_cipher.asText(),
itemId = action.itemId,
itemId = itemId,
),
)
}
@@ -528,6 +515,15 @@ class ItemListingViewModel @Inject constructor(
sendEvent(ItemListingEvent.NavigateToBitwardenListing)
}
private fun handleDropdownMenuClick(action: ItemListingAction.DropdownMenuClick) {
when (action.menuAction) {
VaultDropdownMenuAction.COPY -> handleCopyItemClick(action.item.authCode)
VaultDropdownMenuAction.EDIT -> handleEditItemClick(action.item.id)
VaultDropdownMenuAction.MOVE -> handleMoveToBitwardenClick(action.item.id)
VaultDropdownMenuAction.DELETE -> handleDeleteItemClick(action.item.id)
}
}
private fun handleDownloadBitwardenDismiss() {
settingsRepository.hasUserDismissedDownloadBitwardenCard = true
mutableStateFlow.update {
@@ -663,7 +659,6 @@ data class ItemListingState(
val viewState: ViewState,
val dialog: DialogState?,
) : Parcelable {
/**
* Represents the different view states of the [ItemListingScreen].
*/
@@ -714,7 +709,6 @@ data class ItemListingState(
* Display an action card on the item [ItemListingScreen].
*/
sealed class ActionCardState : Parcelable {
/**
* Display no action card.
*/
@@ -738,7 +732,6 @@ data class ItemListingState(
* Display a dialog on the [ItemListingScreen].
*/
sealed class DialogState : Parcelable {
/**
* Displays the loading dialog to the user.
*/
@@ -769,7 +762,6 @@ data class ItemListingState(
* Represents a set of events related to viewing the item listing.
*/
sealed class ItemListingEvent {
/**
* Navigates to the Create Account screen.
*/
@@ -830,7 +822,6 @@ sealed class ItemListingEvent {
* Each subclass of this sealed class denotes a distinct action that can be taken.
*/
sealed class ItemListingAction {
/**
* The user clicked the back button.
*/
@@ -856,11 +847,6 @@ sealed class ItemListingAction {
*/
data class ItemClick(val authCode: String) : ItemListingAction()
/**
* The user clicked edit item.
*/
data class EditItemClick(val itemId: String) : ItemListingAction()
/**
* The user dismissed the dialog.
*/
@@ -892,15 +878,25 @@ sealed class ItemListingAction {
data object SyncWithBitwardenDismiss : ItemListingAction()
/**
* The user clicked the "Move to Bitwarden" action on a local verification item.
* The user clicked confirm when prompted to delete an item.
*/
data class MoveToBitwardenClick(val entityId: String) : ItemListingAction()
data class ConfirmDeleteClick(val itemId: String) : ItemListingAction()
/**
* Represents an action triggered when the user clicks an item in the dropdown menu.
*
* @param menuAction The action selected from the dropdown menu.
* @param id The identifier of the item on which the action is being performed.
*/
data class DropdownMenuClick(
val menuAction: VaultDropdownMenuAction,
val item: VerificationCodeDisplayItem,
) : ItemListingAction()
/**
* Models actions that [ItemListingScreen] itself may send.
*/
sealed class Internal : ItemListingAction() {
/**
* Indicates verification items have been received.
*/
@@ -941,14 +937,4 @@ sealed class ItemListingAction {
*/
data object FirstTimeUserSyncReceive : Internal()
}
/**
* The user clicked Delete.
*/
data class DeleteItemClick(val itemId: String) : ItemListingAction()
/**
* The user clicked confirm when prompted to delete an item.
*/
data class ConfirmDeleteClick(val itemId: String) : ItemListingAction()
}

View File

@@ -33,6 +33,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bitwarden.authenticator.R
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VaultDropdownMenuAction
import com.bitwarden.authenticator.ui.platform.components.icon.BitwardenIcon
import com.bitwarden.authenticator.ui.platform.components.indicator.BitwardenCircularCountdownIndicator
import com.bitwarden.authenticator.ui.platform.components.model.IconData
@@ -46,8 +47,12 @@ import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme
* @param secondaryLabel The supporting label for the item. Represents the OTP account name.
* @param periodSeconds The times span where the code is valid.
* @param timeLeftSeconds The seconds remaining until a new code is needed.
* @param alertThresholdSeconds The time threshold in seconds to display an expiration warning.
* @param startIcon The leading icon for the item.
* @param onItemClick The lambda function to be invoked when the item is clicked.
* @param onDropdownMenuClick A lambda function invoked when a dropdown menu action is clicked.
* @param allowLongPress Whether long-press interactions are enabled for the item.
* @param showMoveToBitwarden Whether the option to move the item to Bitwarden is displayed.
* @param modifier The modifier for the item.
*/
@OptIn(ExperimentalFoundationApi::class)
@@ -62,9 +67,7 @@ fun VaultVerificationCodeItem(
alertThresholdSeconds: Int,
startIcon: IconData,
onItemClick: () -> Unit,
onEditItemClick: () -> Unit,
onDeleteItemClick: () -> Unit,
onMoveToBitwardenClick: () -> Unit,
onDropdownMenuClick: (VaultDropdownMenuAction) -> Unit,
allowLongPress: Boolean,
showMoveToBitwarden: Boolean,
modifier: Modifier = Modifier,
@@ -155,13 +158,29 @@ fun VaultVerificationCodeItem(
expanded = shouldShowDropdownMenu,
onDismissRequest = { shouldShowDropdownMenu = false },
) {
DropdownMenuItem(
text = {
Text(text = stringResource(id = R.string.copy))
},
onClick = {
shouldShowDropdownMenu = false
onDropdownMenuClick(VaultDropdownMenuAction.COPY)
},
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.copy),
)
},
)
HorizontalDivider()
DropdownMenuItem(
text = {
Text(text = stringResource(id = R.string.edit_item))
},
onClick = {
shouldShowDropdownMenu = false
onEditItemClick()
onDropdownMenuClick(VaultDropdownMenuAction.EDIT)
},
leadingIcon = {
Icon(
@@ -174,16 +193,16 @@ fun VaultVerificationCodeItem(
HorizontalDivider()
DropdownMenuItem(
text = {
Text(text = stringResource(id = R.string.copy_to_bitwarden))
Text(text = stringResource(id = R.string.move_to_bitwarden))
},
onClick = {
shouldShowDropdownMenu = false
onMoveToBitwardenClick()
onDropdownMenuClick(VaultDropdownMenuAction.MOVE)
},
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.ic_arrow_right),
contentDescription = stringResource(id = R.string.copy_to_bitwarden),
contentDescription = stringResource(id = R.string.move_to_bitwarden),
)
},
)
@@ -195,7 +214,7 @@ fun VaultVerificationCodeItem(
},
onClick = {
shouldShowDropdownMenu = false
onDeleteItemClick()
onDropdownMenuClick(VaultDropdownMenuAction.DELETE)
},
leadingIcon = {
Icon(
@@ -222,9 +241,7 @@ private fun VerificationCodeItem_preview() {
alertThresholdSeconds = 7,
startIcon = IconData.Local(R.drawable.ic_login_item),
onItemClick = {},
onEditItemClick = {},
onDeleteItemClick = {},
onMoveToBitwardenClick = {},
onDropdownMenuClick = {},
allowLongPress = true,
modifier = Modifier.padding(horizontal = 16.dp),
showMoveToBitwarden = true,

View File

@@ -0,0 +1,11 @@
package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model
/**
* Enum representing the available actions in the Vault dropdown menu.
*/
enum class VaultDropdownMenuAction {
COPY,
EDIT,
MOVE,
DELETE,
}

View File

@@ -132,7 +132,7 @@
<string name="sync_with_bitwarden_action_card_message">Allow Authenticator app syncing in settings to view all of your verification codes here.</string>
<string name="something_went_wrong">Something went wrong</string>
<string name="please_try_again">Please try again</string>
<string name="copy_to_bitwarden">Copy to Bitwarden</string>
<string name="move_to_bitwarden">Move to Bitwarden</string>
<string name="default_save_option">Default save option</string>
<string name="save_to_bitwarden">Save to Bitwarden</string>
<string name="save_here">Save here</string>

View File

@@ -10,6 +10,7 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTouchInput
import com.bitwarden.authenticator.data.platform.repository.util.bufferedMutableSharedFlow
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.SharedCodesDisplayState
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VaultDropdownMenuAction
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem
import com.bitwarden.authenticator.ui.platform.base.BaseComposeTest
import com.bitwarden.authenticator.ui.platform.base.util.asText
@@ -202,7 +203,7 @@ class ItemListingScreenTest : BaseComposeTest() {
}
@Test
fun `clicking Copy to Bitwarden should send MoveToBitwardenClick`() {
fun `clicking Move to Bitwarden should send MoveToBitwardenClick`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
viewState = ItemListingState.ViewState.Content(
actionCard = ItemListingState.ActionCardState.None,
@@ -216,10 +217,17 @@ class ItemListingScreenTest : BaseComposeTest() {
.performTouchInput { longClick() }
composeTestRule
.onNodeWithText("Copy to Bitwarden")
.onNodeWithText("Move to Bitwarden")
.performClick()
verify { viewModel.trySendAction(ItemListingAction.MoveToBitwardenClick("1")) }
verify {
viewModel.trySendAction(
ItemListingAction.DropdownMenuClick(
menuAction = VaultDropdownMenuAction.MOVE,
item = LOCAL_CODE,
),
)
}
}
@Test

View File

@@ -12,6 +12,8 @@ import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClip
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticator.data.platform.repository.model.DataState
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.SharedCodesDisplayState
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VaultDropdownMenuAction
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.util.toDisplayItem
import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.util.toSharedCodesDisplayState
import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest
@@ -31,7 +33,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ItemListViewModelTest : BaseViewModelTest() {
class ItemListingViewModelTest : BaseViewModelTest() {
private val mutableAuthenticatorAlertThresholdFlow =
MutableStateFlow(AUTHENTICATOR_ALERT_SECONDS)
@@ -381,7 +383,12 @@ class ItemListViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel()
viewModel.trySendAction(ItemListingAction.MoveToBitwardenClick(entityId = "1"))
viewModel.trySendAction(
ItemListingAction.DropdownMenuClick(
menuAction = VaultDropdownMenuAction.MOVE,
item = LOCAL_CODE,
),
)
verify { authenticatorBridgeManager.startAddTotpLoginItemFlow(expectedUriString) }
}
@@ -406,7 +413,9 @@ class ItemListViewModelTest : BaseViewModelTest() {
} returns false
val viewModel = createViewModel()
viewModel.trySendAction(ItemListingAction.MoveToBitwardenClick(entityId = "1"))
viewModel.trySendAction(
ItemListingAction.DropdownMenuClick(VaultDropdownMenuAction.MOVE, LOCAL_CODE),
)
assertEquals(
expectedState,
viewModel.stateFlow.value,
@@ -423,6 +432,66 @@ class ItemListViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `should copy text to clipboard when DropdownMenuClick COPY is triggered`() = runTest {
val viewModel = createViewModel()
every { clipboardManager.setText(text = LOCAL_CODE.authCode) } just runs
viewModel.eventFlow.test {
viewModel.trySendAction(
ItemListingAction.DropdownMenuClick(
menuAction = VaultDropdownMenuAction.COPY,
item = LOCAL_CODE,
),
)
verify(exactly = 1) {
clipboardManager.setText(text = LOCAL_CODE.authCode)
}
}
}
@Test
fun `should trigger edit action when DropdownMenuClick EDIT is triggered`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
ItemListingAction.DropdownMenuClick(VaultDropdownMenuAction.EDIT, LOCAL_CODE),
)
assertEquals(
ItemListingEvent.NavigateToEditItem(LOCAL_CODE.id),
awaitItem(),
)
}
}
@Test
fun `should trigger delete prompt when DropdownMenuClick DELETE is triggered`() = runTest {
val viewModel = createViewModel()
val expectedState = DEFAULT_STATE.copy(
dialog = ItemListingState.DialogState.DeleteConfirmationPrompt(
message = R.string.do_you_really_want_to_permanently_delete_cipher.asText(),
itemId = LOCAL_CODE.id,
),
)
viewModel.trySendAction(
ItemListingAction.DropdownMenuClick(
menuAction = VaultDropdownMenuAction.DELETE,
item = LOCAL_CODE,
),
)
assertEquals(
expectedState,
viewModel.stateFlow.value,
)
}
private fun createViewModel() = ItemListingViewModel(
authenticatorRepository = authenticatorRepository,
authenticatorBridgeManager = authenticatorBridgeManager,
@@ -441,6 +510,19 @@ private val DEFAULT_STATE = ItemListingState(
dialog = null,
)
private val LOCAL_CODE = VerificationCodeDisplayItem(
id = "1",
title = "issuer",
subtitle = null,
timeLeftSeconds = 10,
periodSeconds = 30,
alertThresholdSeconds = 7,
authCode = "123456",
favorite = false,
allowLongPressActions = true,
showMoveToBitwarden = true,
)
private val LOCAL_VERIFICATION_ITEMS = listOf(
VerificationCodeItem(
code = "123456",