diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt index c12b686fc2..368f23adb2 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt @@ -51,6 +51,7 @@ import androidx.hilt.navigation.compose.hiltViewModel 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.platform.base.util.EventsEffect import com.bitwarden.authenticator.ui.platform.base.util.asText import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenMediumTopAppBar @@ -65,6 +66,7 @@ import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenTwoBut import com.bitwarden.authenticator.ui.platform.components.dialog.LoadingDialogState import com.bitwarden.authenticator.ui.platform.components.fab.ExpandableFabIcon import com.bitwarden.authenticator.ui.platform.components.fab.ExpandableFloatingActionButton +import com.bitwarden.authenticator.ui.platform.components.header.BitwardenListHeaderText import com.bitwarden.authenticator.ui.platform.components.header.BitwardenListHeaderTextWithSupportLabel import com.bitwarden.authenticator.ui.platform.components.model.IconResource import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold @@ -418,6 +420,7 @@ private fun ItemListingContent( onItemClick = { onItemClick(it.authCode) }, onEditItemClick = { onEditItemClick(it.id) }, onDeleteItemClick = { onDeleteItemClick(it.id) }, + allowLongPress = it.allowLongPressActions, modifier = Modifier.fillMaxWidth(), ) } @@ -445,10 +448,57 @@ private fun ItemListingContent( onItemClick = { onItemClick(it.authCode) }, onEditItemClick = { onEditItemClick(it.id) }, onDeleteItemClick = { onDeleteItemClick(it.id) }, + allowLongPress = it.allowLongPressActions, modifier = Modifier.fillMaxWidth(), ) } + // If there are any items in the local lists, add a spacer between + // local codes and shared codes: + if (state.itemList.isNotEmpty() || state.favoriteItems.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + } + } + + when (state.sharedItems) { + is SharedCodesDisplayState.Codes -> { + items(state.sharedItems.sections) { section -> + BitwardenListHeaderText( + label = section.label(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + section.codes.forEach { + VaultVerificationCodeItem( + authCode = it.authCode, + primaryLabel = it.issuer, + secondaryLabel = it.label, + periodSeconds = it.periodSeconds, + timeLeftSeconds = it.timeLeftSeconds, + alertThresholdSeconds = it.alertThresholdSeconds, + startIcon = it.startIcon, + onItemClick = { onItemClick(it.authCode) }, + onEditItemClick = { }, + onDeleteItemClick = { }, + allowLongPress = it.allowLongPressActions, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + SharedCodesDisplayState.Error -> item { + Text( + text = stringResource(R.string.shared_codes_error), + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } + } + // Add a spacer item to prevent the FAB from hiding verification codes at the // bottom of the list item { diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt index ce8a441b18..45646b988b 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt @@ -20,8 +20,10 @@ import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClip import com.bitwarden.authenticator.data.platform.manager.imports.model.GoogleAuthenticatorProtos 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.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.BaseViewModel import com.bitwarden.authenticator.ui.platform.base.util.Text import com.bitwarden.authenticator.ui.platform.base.util.asText @@ -420,8 +422,21 @@ class ItemListingViewModel @Inject constructor( } return } - if (localItems.isEmpty()) { - // If there are no local items, show empty state: + val sharedItemsState: SharedCodesDisplayState = when (action.sharedCodesState) { + SharedVerificationCodesState.Error -> SharedCodesDisplayState.Error + SharedVerificationCodesState.AppNotInstalled, + SharedVerificationCodesState.FeatureNotEnabled, + SharedVerificationCodesState.Loading, + SharedVerificationCodesState.OsVersionNotSupported, + SharedVerificationCodesState.SyncNotEnabled, + -> SharedCodesDisplayState.Codes(emptyList()) + + is SharedVerificationCodesState.Success -> + action.sharedCodesState.toSharedCodesDisplayState(state.alertThresholdSeconds) + } + + if (localItems.isEmpty() && sharedItemsState.isEmpty()) { + // If there are no items, show empty state: mutableStateFlow.update { it.copy( viewState = ItemListingState.ViewState.NoItems( @@ -441,6 +456,7 @@ class ItemListingViewModel @Inject constructor( .map { it.toDisplayItem(alertThresholdSeconds = state.alertThresholdSeconds) }, + sharedItems = sharedItemsState, actionCard = action.sharedCodesState.toActionCard(), ) mutableStateFlow.update { it.copy(viewState = viewState) } @@ -590,6 +606,7 @@ data class ItemListingState( val actionCard: ActionCardState, val favoriteItems: List, val itemList: List, + val sharedItems: SharedCodesDisplayState, ) : ViewState() } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt index 41e8cd55ec..897be3476a 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/VaultVerificationCodeItem.kt @@ -1,6 +1,7 @@ package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -61,18 +62,27 @@ fun VaultVerificationCodeItem( onItemClick: () -> Unit, onEditItemClick: () -> Unit, onDeleteItemClick: () -> Unit, + allowLongPress: Boolean, modifier: Modifier = Modifier, ) { var shouldShowDropdownMenu by remember { mutableStateOf(value = false) } Box(modifier = modifier) { Row( modifier = Modifier - .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(color = MaterialTheme.colorScheme.primary), - onClick = onItemClick, - onLongClick = { - shouldShowDropdownMenu = true + .then( + if (allowLongPress) { + Modifier.combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(color = MaterialTheme.colorScheme.primary), + onClick = onItemClick, + onLongClick = { shouldShowDropdownMenu = true }, + ) + } else { + Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(color = MaterialTheme.colorScheme.primary), + onClick = onItemClick, + ) }, ) .defaultMinSize(minHeight = 72.dp) @@ -185,6 +195,7 @@ private fun VerificationCodeItem_preview() { onItemClick = {}, onEditItemClick = {}, onDeleteItemClick = {}, + allowLongPress = true, modifier = Modifier.padding(horizontal = 16.dp), ) } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/SharedCodesDisplayState.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/SharedCodesDisplayState.kt new file mode 100644 index 0000000000..8fa5374fa9 --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/SharedCodesDisplayState.kt @@ -0,0 +1,40 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model + +import android.os.Parcelable +import com.bitwarden.authenticator.ui.platform.base.util.Text +import kotlinx.parcelize.Parcelize + +/** + * Models how shared codes should be displayed. + */ +sealed class SharedCodesDisplayState : Parcelable { + + /** + * There was an error syncing codes. + */ + @Parcelize + data object Error : SharedCodesDisplayState() + + /** + * Display the given [sections] of verification codes. + */ + @Parcelize + data class Codes(val sections: List) : SharedCodesDisplayState() + + /** + * Models a section of shared authenticator codes to be displayed. + */ + @Parcelize + data class SharedCodesAccountSection( + val label: Text, + val codes: List, + ) : Parcelable + + /** + * Utility function to determine if there are any codes synced. + */ + fun isEmpty() = when (this) { + is Codes -> this.sections.isEmpty() + Error -> true + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VerificationCodeDisplayItem.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VerificationCodeDisplayItem.kt index aecdae2dd9..3b312c8b54 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VerificationCodeDisplayItem.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/model/VerificationCodeDisplayItem.kt @@ -19,4 +19,5 @@ data class VerificationCodeDisplayItem( val authCode: String, val startIcon: IconData = IconData.Local(R.drawable.ic_login_item), val favorite: Boolean, + val allowLongPressActions: Boolean, ) : Parcelable diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/SharedVerificationCodesStateExtensions.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/SharedVerificationCodesStateExtensions.kt new file mode 100644 index 0000000000..d27478149e --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/SharedVerificationCodesStateExtensions.kt @@ -0,0 +1,36 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.util + +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.SharedCodesDisplayState +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem +import com.bitwarden.authenticator.ui.platform.base.util.asText + +/** + * Convert [SharedVerificationCodesState.Success] into [SharedCodesDisplayState.Codes]. + */ +fun SharedVerificationCodesState.Success.toSharedCodesDisplayState( + alertThresholdSeconds: Int, +): SharedCodesDisplayState.Codes { + val codesMap = + mutableMapOf>() + // Make a map where each key is a Bitwarden account and each value is a list of verification + // codes for that account: + this.items.forEach { + codesMap.putIfAbsent(it.source as AuthenticatorItem.Source.Shared, mutableListOf()) + codesMap[it.source]?.add(it.toDisplayItem(alertThresholdSeconds)) + } + // Flatten that map down to a list of accounts that each has a list of codes: + return codesMap + .map { + SharedCodesDisplayState.SharedCodesAccountSection( + label = R.string.shared_accounts_header.asText( + it.key.email, + it.key.environmentLabel, + ), + codes = it.value, + ) + } + .let { SharedCodesDisplayState.Codes(it) } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt index 66b6bf9722..e4896cd877 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensions.kt @@ -16,5 +16,9 @@ fun VerificationCodeItem.toDisplayItem(alertThresholdSeconds: Int) = periodSeconds = periodSeconds, alertThresholdSeconds = alertThresholdSeconds, authCode = code, + allowLongPressActions = when (source) { + is AuthenticatorItem.Source.Local -> true + is AuthenticatorItem.Source.Shared -> false + }, favorite = (source as? AuthenticatorItem.Source.Local)?.isFavorite ?: false, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cdf9d6754c..1f14c87ee7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -125,4 +125,6 @@ Store all of your logins and sync verification codes directly with the Authenticator app. Download Sync with Bitwarden app + Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. + %1$s | %2$s diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListViewModelTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListViewModelTest.kt index 7c31f351a8..564b7358df 100644 --- a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListViewModelTest.kt +++ b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListViewModelTest.kt @@ -9,7 +9,9 @@ import com.bitwarden.authenticator.data.platform.manager.BitwardenEncodingManage import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager 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.util.toDisplayItem +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.util.toSharedCodesDisplayState import com.bitwarden.authenticator.ui.platform.base.BaseViewModelTest import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme import io.mockk.every @@ -95,6 +97,7 @@ class ItemListViewModelTest : BaseViewModelTest() { actionCard = ItemListingState.ActionCardState.DownloadBitwardenApp, favoriteItems = LOCAL_FAVORITE_ITEMS, itemList = LOCAL_NON_FAVORITE_ITEMS, + sharedItems = SharedCodesDisplayState.Codes(emptyList()), ), ) every { settingsRepository.hasUserDismissedDownloadBitwardenCard } returns false @@ -112,6 +115,7 @@ class ItemListViewModelTest : BaseViewModelTest() { actionCard = ItemListingState.ActionCardState.None, favoriteItems = LOCAL_FAVORITE_ITEMS, itemList = LOCAL_NON_FAVORITE_ITEMS, + sharedItems = SharedCodesDisplayState.Codes(emptyList()), ), ) every { settingsRepository.hasUserDismissedDownloadBitwardenCard } returns true @@ -121,6 +125,74 @@ class ItemListViewModelTest : BaseViewModelTest() { assertEquals(expectedState, viewModel.stateFlow.value) } + @Test + @Suppress("MaxLineLength") + fun `stateFlow sharedItems value should be Error when shared state is Error `() { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = LOCAL_FAVORITE_ITEMS, + itemList = LOCAL_NON_FAVORITE_ITEMS, + sharedItems = SharedCodesDisplayState.Error, + ), + ) + mutableVerificationCodesFlow.value = DataState.Loaded(LOCAL_VERIFICATION_ITEMS) + mutableSharedCodesFlow.value = SharedVerificationCodesState.Error + val viewModel = createViewModel() + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `stateFlow sharedItems value should be Codes with empty list when shared state is Error `() { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = LOCAL_FAVORITE_ITEMS, + itemList = LOCAL_NON_FAVORITE_ITEMS, + sharedItems = SHARED_DISPLAY_ITEMS, + ), + ) + mutableVerificationCodesFlow.value = DataState.Loaded(LOCAL_VERIFICATION_ITEMS) + mutableSharedCodesFlow.value = + SharedVerificationCodesState.Success(SHARED_VERIFICATION_ITEMS) + val viewModel = createViewModel() + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `stateFlow sharedItems value should show items even when local items are empty`() { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.NoItems( + actionCard = ItemListingState.ActionCardState.None, + ), + ) + mutableVerificationCodesFlow.value = DataState.Loaded(emptyList()) + mutableSharedCodesFlow.value = + SharedVerificationCodesState.Success(emptyList()) + val viewModel = createViewModel() + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + @Suppress("MaxLineLength") + fun `stateFlow viewState value should be NoItems when both local and shared codes are empty`() { + val expectedState = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = emptyList(), + itemList = emptyList(), + sharedItems = SHARED_DISPLAY_ITEMS, + ), + ) + mutableVerificationCodesFlow.value = DataState.Loaded(emptyList()) + mutableSharedCodesFlow.value = + SharedVerificationCodesState.Success(SHARED_VERIFICATION_ITEMS) + val viewModel = createViewModel() + assertEquals(expectedState, viewModel.stateFlow.value) + } + @Test fun `on DownloadBitwardenClick receive should emit NavigateToBitwardenListing`() = runTest { val viewModel = createViewModel() @@ -139,6 +211,7 @@ class ItemListViewModelTest : BaseViewModelTest() { actionCard = ItemListingState.ActionCardState.None, favoriteItems = LOCAL_FAVORITE_ITEMS, itemList = LOCAL_NON_FAVORITE_ITEMS, + sharedItems = SharedCodesDisplayState.Codes(emptyList()), ), ) every { settingsRepository.hasUserDismissedDownloadBitwardenCard = true } just runs @@ -208,9 +281,30 @@ private val LOCAL_VERIFICATION_ITEMS = listOf( ), ) +private val SHARED_VERIFICATION_ITEMS = listOf( + VerificationCodeItem( + code = "987654", + periodSeconds = 60, + timeLeftSeconds = 430, + issueTime = 35L, + id = "1", + issuer = "sharedIssue", + accountName = "sharedAccountName", + source = AuthenticatorItem.Source.Shared( + userId = "1", + nameOfUser = null, + email = "email", + environmentLabel = "environmentLabel", + ), + ), +) + private val LOCAL_DISPLAY_ITEMS = LOCAL_VERIFICATION_ITEMS.map { it.toDisplayItem(AUTHENTICATOR_ALERT_SECONDS) } +private val SHARED_DISPLAY_ITEMS = SharedVerificationCodesState.Success(SHARED_VERIFICATION_ITEMS) + .toSharedCodesDisplayState(AUTHENTICATOR_ALERT_SECONDS) + private val LOCAL_FAVORITE_ITEMS = LOCAL_DISPLAY_ITEMS.filter { it.favorite } private val LOCAL_NON_FAVORITE_ITEMS = LOCAL_DISPLAY_ITEMS.filterNot { it.favorite } diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt new file mode 100644 index 0000000000..9ed08a939a --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt @@ -0,0 +1,159 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +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.VerificationCodeDisplayItem +import com.bitwarden.authenticator.ui.platform.base.BaseComposeTest +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager +import com.bitwarden.authenticator.ui.platform.manager.permissions.FakePermissionManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Test + +class ItemListingScreenTest : BaseComposeTest() { + + private var onNavigateBackCalled = false + private var onNavigateToSearchCalled = false + private var onNavigateToQrCodeScannerCalled = false + private var onNavigateToManualKeyEntryCalled = false + private var onNavigateToEditItemScreenCalled = false + + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mutableEventFlow = bufferedMutableSharedFlow() + + private val viewModel: ItemListingViewModel = mockk { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns mutableEventFlow + every { trySendAction(any()) } just runs + } + + private val intentManager: IntentManager = mockk() + private val permissionsManager = FakePermissionManager() + + @Before + fun setup() { + composeTestRule.setContent { + ItemListingScreen( + viewModel = viewModel, + intentManager = intentManager, + permissionsManager = permissionsManager, + onNavigateBack = { onNavigateBackCalled = true }, + onNavigateToSearch = { onNavigateToSearchCalled = true }, + onNavigateToQrCodeScanner = { onNavigateToQrCodeScannerCalled = true }, + onNavigateToManualKeyEntry = { onNavigateToManualKeyEntryCalled = true }, + onNavigateToEditItemScreen = { onNavigateToEditItemScreenCalled = true }, + ) + } + } + + @Test + @Suppress("MaxLineLength") + fun `shared accounts error message should show when view is Content with SharedCodesDisplayState Error`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = emptyList(), + itemList = emptyList(), + sharedItems = SharedCodesDisplayState.Error, + ), + ) + + composeTestRule + .onNodeWithText("Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app.") + .assertIsDisplayed() + + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = emptyList(), + itemList = emptyList(), + sharedItems = SharedCodesDisplayState.Codes(emptyList()), + ), + ) + + composeTestRule + .onNodeWithText("Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app.") + .assertDoesNotExist() + } + + @Test + fun `clicking shared accounts verification code item should send ItemClick action`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + actionCard = ItemListingState.ActionCardState.None, + favoriteItems = emptyList(), + itemList = emptyList(), + sharedItems = SharedCodesDisplayState.Codes( + sections = listOf( + SHARED_ACCOUNTS_SECTION, + ), + ), + ), + ) + + composeTestRule + .onNodeWithText("joe+shared_code_1@test.com") + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + ItemListingAction.ItemClick(SHARED_ACCOUNTS_SECTION.codes[0].authCode), + ) + } + + // Make sure long press sends action as well, since local items have long press options + // but shared items do not: + composeTestRule + .onNodeWithText("joe+shared_code_1@test.com") + .performTouchInput { longClick() } + + verify { + viewModel.trySendAction( + ItemListingAction.ItemClick(SHARED_ACCOUNTS_SECTION.codes[0].authCode), + ) + } + } +} + +private val APP_THEME = AppTheme.DEFAULT +private const val ALERT_THRESHOLD = 7 + +private val SHARED_ACCOUNTS_SECTION = SharedCodesDisplayState.SharedCodesAccountSection( + label = "test@test.com".asText(), + codes = listOf( + VerificationCodeDisplayItem( + id = "1", + issuer = "bitwarden.com", + label = "joe+shared_code_1@test.com", + timeLeftSeconds = 10, + periodSeconds = 30, + alertThresholdSeconds = ALERT_THRESHOLD, + authCode = "123456", + favorite = false, + allowLongPressActions = false, + ), + ), +) + +private val DEFAULT_STATE = ItemListingState( + appTheme = APP_THEME, + alertThresholdSeconds = ALERT_THRESHOLD, + viewState = ItemListingState.ViewState.NoItems( + actionCard = ItemListingState.ActionCardState.None, + ), + dialog = null, +) diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/SharedVerificationCodesStateTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/SharedVerificationCodesStateTest.kt new file mode 100644 index 0000000000..0223ef1563 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/SharedVerificationCodesStateTest.kt @@ -0,0 +1,110 @@ +package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.util + +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.manager.model.VerificationCodeItem +import com.bitwarden.authenticator.data.authenticator.repository.model.AuthenticatorItem +import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.SharedCodesDisplayState +import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem +import com.bitwarden.authenticator.ui.platform.base.util.asText +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SharedVerificationCodesStateTest { + + @Test + fun `toSharedCodesDisplayState on empty list should return empty list`() { + val state = SharedVerificationCodesState.Success(emptyList()) + val expected = SharedCodesDisplayState.Codes(emptyList()) + assertEquals( + expected, + state.toSharedCodesDisplayState(ALERT_THRESHOLD), + ) + } + + @Test + fun `toSharedCodesDisplayState should return list of sections grouped by account`() { + val state = SharedVerificationCodesState.Success( + items = listOf( + VerificationCodeItem( + code = "123456", + periodSeconds = 30, + timeLeftSeconds = 10, + issueTime = 100L, + id = "123", + issuer = null, + accountName = null, + source = AuthenticatorItem.Source.Shared( + userId = "user1", + nameOfUser = "John Appleseed", + email = "John@test.com", + environmentLabel = "bitwarden.com", + ), + ), + VerificationCodeItem( + code = "987654", + periodSeconds = 30, + timeLeftSeconds = 10, + issueTime = 100L, + id = "987", + issuer = "issuer", + accountName = "accountName", + source = AuthenticatorItem.Source.Shared( + userId = "user1", + nameOfUser = "Jane Doe", + email = "Jane@test.com", + environmentLabel = "bitwarden.eu", + ), + ), + ), + ) + val expected = SharedCodesDisplayState.Codes( + sections = listOf( + SharedCodesDisplayState.SharedCodesAccountSection( + label = R.string.shared_accounts_header.asText( + "John@test.com", + "bitwarden.com", + ), + codes = listOf( + VerificationCodeDisplayItem( + authCode = "123456", + periodSeconds = 30, + timeLeftSeconds = 10, + id = "123", + issuer = null, + label = null, + favorite = false, + allowLongPressActions = false, + alertThresholdSeconds = ALERT_THRESHOLD, + ), + ), + ), + SharedCodesDisplayState.SharedCodesAccountSection( + label = R.string.shared_accounts_header.asText( + "Jane@test.com", + "bitwarden.eu", + ), + codes = listOf( + VerificationCodeDisplayItem( + authCode = "987654", + periodSeconds = 30, + timeLeftSeconds = 10, + id = "987", + issuer = "issuer", + label = "accountName", + favorite = false, + allowLongPressActions = false, + alertThresholdSeconds = ALERT_THRESHOLD, + ), + ), + ), + ), + ) + assertEquals( + expected, + state.toSharedCodesDisplayState(ALERT_THRESHOLD), + ) + } +} + +private const val ALERT_THRESHOLD = 7 diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensionsTest.kt b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensionsTest.kt index c4b9fde0c0..51e4896c71 100644 --- a/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensionsTest.kt +++ b/app/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/util/VerificationCodeItemExtensionsTest.kt @@ -9,7 +9,7 @@ import org.junit.jupiter.api.Test class VerificationCodeItemExtensionsTest { @Test - fun `toDisplayItem should map items correctly`() { + fun `toDisplayItem should map Local items correctly`() { val alertThresholdSeconds = 7 val favoriteItem = createMockVerificationCodeItem(number = 1, favorite = true) val nonFavoriteItem = createMockVerificationCodeItem(number = 2) @@ -23,6 +23,7 @@ class VerificationCodeItemExtensionsTest { alertThresholdSeconds = alertThresholdSeconds, authCode = favoriteItem.code, favorite = (favoriteItem.source as AuthenticatorItem.Source.Local).isFavorite, + allowLongPressActions = true, ) val expectedNonFavoriteItem = VerificationCodeDisplayItem( @@ -34,9 +35,38 @@ class VerificationCodeItemExtensionsTest { alertThresholdSeconds = alertThresholdSeconds, authCode = nonFavoriteItem.code, favorite = (nonFavoriteItem.source as AuthenticatorItem.Source.Local).isFavorite, + allowLongPressActions = true, ) assertEquals(expectedFavoriteItem, favoriteItem.toDisplayItem(alertThresholdSeconds)) assertEquals(expectedNonFavoriteItem, nonFavoriteItem.toDisplayItem(alertThresholdSeconds)) } + + @Test + fun `toDisplayItem should map Shared items correctly`() { + val alertThresholdSeconds = 7 + val favoriteItem = createMockVerificationCodeItem(number = 1, favorite = true) + .copy( + source = AuthenticatorItem.Source.Shared( + userId = "1", + nameOfUser = "John Doe", + email = "test@bitwarden.com", + environmentLabel = "bitwarden.com", + ), + ) + + val expectedFavoriteItem = VerificationCodeDisplayItem( + id = favoriteItem.id, + issuer = favoriteItem.issuer, + label = favoriteItem.accountName, + timeLeftSeconds = favoriteItem.timeLeftSeconds, + periodSeconds = favoriteItem.periodSeconds, + alertThresholdSeconds = alertThresholdSeconds, + authCode = favoriteItem.code, + favorite = false, + allowLongPressActions = false, + ) + + assertEquals(expectedFavoriteItem, favoriteItem.toDisplayItem(alertThresholdSeconds)) + } } diff --git a/app/src/test/java/com/bitwarden/authenticator/ui/platform/manager/permissions/FakePermissionManager.kt b/app/src/test/java/com/bitwarden/authenticator/ui/platform/manager/permissions/FakePermissionManager.kt new file mode 100644 index 0000000000..6e25a367d0 --- /dev/null +++ b/app/src/test/java/com/bitwarden/authenticator/ui/platform/manager/permissions/FakePermissionManager.kt @@ -0,0 +1,68 @@ +package com.bitwarden.authenticator.ui.platform.manager.permissions + +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.compose.runtime.Composable +import io.mockk.every +import io.mockk.mockk + +/** + * A helper class used to test permissions + */ +class FakePermissionManager : PermissionsManager { + + /** + * The value returned when we check if we have the permission. + */ + var checkPermissionResult: Boolean = false + + /** + * The value returned when the user is asked for permission. + */ + var getPermissionsResult: Boolean = false + + /** + * The value returned when the user is asked for permission. + */ + var getMultiplePermissionsResult: Map = emptyMap() + + /** + * The value for whether a rationale should be shown to the user. + */ + var shouldShowRequestRationale: Boolean = false + + /** + * Indicates that the [getLauncher] function has been called. + */ + var hasGetLauncherBeenCalled: Boolean = false + + @Composable + override fun getLauncher( + onResult: (Boolean) -> Unit, + ): ManagedActivityResultLauncher { + hasGetLauncherBeenCalled = true + return mockk { + every { launch(any()) } answers { onResult.invoke(getPermissionsResult) } + } + } + + @Composable + override fun getPermissionsLauncher( + onResult: (Map) -> Unit, + ): ManagedActivityResultLauncher, Map> { + return mockk { + every { launch(any()) } answers { onResult.invoke(getMultiplePermissionsResult) } + } + } + + override fun checkPermission(permission: String): Boolean { + return checkPermissionResult + } + + override fun checkPermissions(permissions: Array): Boolean { + return checkPermissionResult + } + + override fun shouldShouldRequestPermissionRationale(permission: String): Boolean { + return shouldShowRequestRationale + } +}