From ca84284f37a8b637579f1831f00c5075c0e96b6d Mon Sep 17 00:00:00 2001 From: David Perez Date: Fri, 24 Apr 2026 11:22:50 -0500 Subject: [PATCH 01/16] Update the Androidx Credentials dependency (#6831) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5549b981cd..a6a287134d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ androidxBrowser = "1.10.0" androidxCamera = "1.6.0" androidxComposeBom = "2026.03.01" androidxCore = "1.18.0" -androidxCredentials = "1.6.0-rc02" +androidxCredentials = "1.6.0" androidxCredentialsProviderEvents = "1.0.0-alpha06" androidxHiltNavigationCompose = "1.3.0" androidxLifecycle = "2.10.0" From 1a3679fb43b5e027e083c93095d51f2faa8494fe Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:11:12 -0500 Subject: [PATCH 02/16] [deps]: Update com.google.guava:guava to v33.6.0-jre (#6838) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a6a287134d..faa909b1fb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,7 +38,7 @@ glide = "5.0.7" glideCompose = "1.0.0-beta01" googleBilling = "8.3.0" googleMlkitTextRecognition = "16.0.1" -googleGuava = "33.5.0-jre" +googleGuava = "33.6.0-jre" googleProtoBufJava = "4.34.1" googleProtoBufPlugin = "0.10.0" googleServices = "4.4.4" From 1aba32fa3d6c03fd135127ebb1c36906564aa1da Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:25:10 -0400 Subject: [PATCH 03/16] [PM-33519] feat: Rewire upgrade CTAs to use conditional routing (#6796) --- .../feature/search/SearchNavigation.kt | 3 ++ .../platform/feature/search/SearchScreen.kt | 2 + .../feature/search/SearchViewModel.kt | 20 ++++++-- .../vaultunlocked/VaultUnlockedNavigation.kt | 5 ++ .../VaultUnlockedNavBarScreen.kt | 1 + .../tools/feature/send/SendGraphNavigation.kt | 2 + .../send/addedit/AddEditSendNavigation.kt | 2 + .../feature/send/addedit/AddEditSendScreen.kt | 3 ++ .../send/addedit/AddEditSendViewModel.kt | 29 +++++++---- .../feature/addedit/VaultAddEditNavigation.kt | 2 + .../feature/addedit/VaultAddEditScreen.kt | 3 ++ .../feature/addedit/VaultAddEditViewModel.kt | 26 +++++++--- .../vault/feature/item/VaultItemNavigation.kt | 2 + .../ui/vault/feature/item/VaultItemScreen.kt | 3 ++ .../vault/feature/item/VaultItemViewModel.kt | 20 ++++++-- .../itemlisting/VaultItemListingNavigation.kt | 8 +++ .../itemlisting/VaultItemListingScreen.kt | 3 ++ .../itemlisting/VaultItemListingViewModel.kt | 20 ++++++-- .../feature/vault/VaultGraphNavigation.kt | 1 + .../ui/vault/feature/vault/VaultViewModel.kt | 13 +++-- .../feature/search/SearchScreenTest.kt | 8 +++ .../feature/search/SearchViewModelTest.kt | 46 ++++++++++++----- .../send/addedit/AddEditSendScreenTest.kt | 8 +++ .../send/addedit/AddEditSendViewModelTest.kt | 44 +++++++++++----- .../feature/addedit/VaultAddEditScreenTest.kt | 8 +++ .../addedit/VaultAddEditViewModelTest.kt | 46 ++++++++++++----- .../vault/feature/item/VaultItemScreenTest.kt | 8 +++ .../feature/item/VaultItemViewModelTest.kt | 51 ++++++++++++++----- .../itemlisting/VaultItemListingScreenTest.kt | 9 ++++ .../VaultItemListingViewModelTest.kt | 46 ++++++++++++----- .../vault/feature/vault/VaultViewModelTest.kt | 43 +++++++++++----- 31 files changed, 380 insertions(+), 105 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchNavigation.kt index 07cf9e9f56..a36c45f7f3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchNavigation.kt @@ -86,12 +86,14 @@ fun SavedStateHandle.toSearchArgs(): SearchArgs { /** * Add search destinations to the nav graph. */ +@Suppress("LongParameterList") fun NavGraphBuilder.searchDestination( onNavigateBack: () -> Unit, onNavigateToAddEditSend: (route: AddEditSendRoute) -> Unit, onNavigateToViewSend: (route: ViewSendRoute) -> Unit, onNavigateToEditCipher: (args: VaultAddEditArgs) -> Unit, onNavigateToViewCipher: (args: VaultItemArgs) -> Unit, + onNavigateToPlan: () -> Unit, ) { composableWithSlideTransitions { SearchScreen( @@ -100,6 +102,7 @@ fun NavGraphBuilder.searchDestination( onNavigateToViewSend = onNavigateToViewSend, onNavigateToEditCipher = onNavigateToEditCipher, onNavigateToViewCipher = onNavigateToViewCipher, + onNavigateToPlan = onNavigateToPlan, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreen.kt index 8a392c1c92..26e45ed946 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreen.kt @@ -58,6 +58,7 @@ fun SearchScreen( onNavigateToViewSend: (route: ViewSendRoute) -> Unit, onNavigateToEditCipher: (args: VaultAddEditArgs) -> Unit, onNavigateToViewCipher: (args: VaultItemArgs) -> Unit, + onNavigateToPlan: () -> Unit, intentManager: IntentManager = LocalIntentManager.current, viewModel: SearchViewModel = hiltViewModel(), appResumeStateManager: AppResumeStateManager = LocalAppResumeStateManager.current, @@ -111,6 +112,7 @@ fun SearchScreen( } is SearchEvent.NavigateToUrl -> intentManager.launchUri(event.url.toUri()) + SearchEvent.NavigateToPlanModal -> onNavigateToPlan() is SearchEvent.ShowShareSheet -> intentManager.shareText(event.content) is SearchEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt index daa8648141..db3c826f51 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt @@ -25,6 +25,7 @@ import com.bitwarden.vault.CipherType import com.bitwarden.vault.CipherView import com.bitwarden.vault.LoginUriView import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager @@ -101,6 +102,7 @@ class SearchViewModel @Inject constructor( private val vaultRepo: VaultRepository, private val authRepo: AuthRepository, private val environmentRepo: EnvironmentRepository, + private val premiumStateManager: PremiumStateManager, settingsRepo: SettingsRepository, snackbarRelayManager: SnackbarRelayManager, specialCircumstanceManager: SpecialCircumstanceManager, @@ -318,9 +320,16 @@ class SearchViewModel @Inject constructor( private fun handleUpgradeToPremiumClick() { mutableStateFlow.update { it.copy(dialogState = null) } - val baseUrl = environmentRepo.environment.environmentUrlData.baseWebVaultUrlOrDefault - val url = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium" - sendEvent(SearchEvent.NavigateToUrl(url = url)) + if (premiumStateManager.isInAppUpgradeAvailable()) { + sendEvent(SearchEvent.NavigateToPlanModal) + } else { + val baseUrl = environmentRepo + .environment + .environmentUrlData + .baseWebVaultUrlOrDefault + val url = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium" + sendEvent(SearchEvent.NavigateToUrl(url = url)) + } } private fun handleOverflowItemClick(action: SearchAction.OverflowOptionClick) { @@ -1575,6 +1584,11 @@ sealed class SearchEvent { val url: String, ) : SearchEvent() + /** + * Navigates to the in-app plan modal for premium upgrade. + */ + data object NavigateToPlanModal : SearchEvent() + /** * Shares the [content] with share sheet. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index a410cec8ab..4fe530acd5 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -106,6 +106,7 @@ fun NavGraphBuilder.vaultUnlockedGraph( parentFolderName = it, ) }, + onNavigateToPlan = { navController.navigateToPlanModal() }, ) vaultUnlockedNavBarDestination( onNavigateToExportVault = { navController.navigateToExportVault() }, @@ -189,6 +190,7 @@ fun NavGraphBuilder.vaultUnlockedGraph( showOnlyCollections = showOnlyCollections, ) }, + onNavigateToPlan = { navController.navigateToPlanModal() }, ) vaultMoveToOrganizationDestination( onNavigateBack = { navController.popBackStack() }, @@ -209,6 +211,7 @@ fun NavGraphBuilder.vaultUnlockedGraph( ) }, onNavigateToPreviewAttachment = { navController.navigateToPreviewAttachment(it) }, + onNavigateToPlan = { navController.navigateToPlanModal() }, ) cardScanDestination( onNavigateBack = { navController.popBackStack() }, @@ -232,6 +235,7 @@ fun NavGraphBuilder.vaultUnlockedGraph( onNavigateBack = { navController.popBackStack() }, onNavigateUpToSearchOrRoot = { navController.navigateUpToSearchOrVaultUnlockedRoot() }, onNavigateToGeneratorModal = { navController.navigateToGeneratorModal(mode = it) }, + onNavigateToPlan = { navController.navigateToPlanModal() }, ) viewSendDestination( onNavigateBack = { navController.popBackStack() }, @@ -259,6 +263,7 @@ fun NavGraphBuilder.vaultUnlockedGraph( onNavigateToViewSend = { navController.navigateToViewSend(it) }, onNavigateToEditCipher = { navController.navigateToVaultAddEdit(it) }, onNavigateToViewCipher = { navController.navigateToVaultItem(it) }, + onNavigateToPlan = { navController.navigateToPlanModal() }, ) attachmentDestination( onNavigateBack = { navController.popBackStack() }, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index 2f30fa8042..2847732a2a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -212,6 +212,7 @@ private fun VaultUnlockedNavBarScaffold( onNavigateToAddEditSend = onNavigateToAddEditSend, onNavigateToViewSend = onNavigateToViewSend, onNavigateToSearchSend = onNavigateToSearchSend, + onNavigateToPlan = onNavigateToPlan, ) generatorGraph( onNavigateToPasswordHistory = { navigateToPasswordHistory() }, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendGraphNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendGraphNavigation.kt index 3a22f776eb..10dc18f9c4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendGraphNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendGraphNavigation.kt @@ -26,6 +26,7 @@ fun NavGraphBuilder.sendGraph( onNavigateToAddEditSend: (route: AddEditSendRoute) -> Unit, onNavigateToViewSend: (ViewSendRoute) -> Unit, onNavigateToSearchSend: (searchType: SearchType.Sends) -> Unit, + onNavigateToPlan: () -> Unit, ) { navigation( startDestination = SendRoute, @@ -46,6 +47,7 @@ fun NavGraphBuilder.sendGraph( onNavigateToAddEditSendItem = onNavigateToAddEditSend, onNavigateToViewSendItem = onNavigateToViewSend, onNavigateToSearchSend = onNavigateToSearchSend, + onNavigateToPlan = onNavigateToPlan, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendNavigation.kt index 86ff810f43..969f373b41 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendNavigation.kt @@ -59,12 +59,14 @@ fun NavGraphBuilder.addEditSendDestination( onNavigateBack: () -> Unit, onNavigateUpToSearchOrRoot: () -> Unit, onNavigateToGeneratorModal: (GeneratorMode.Modal) -> Unit, + onNavigateToPlan: () -> Unit, ) { composableWithSlideTransitions { AddEditSendScreen( onNavigateBack = onNavigateBack, onNavigateUpToSearchOrRoot = onNavigateUpToSearchOrRoot, onNavigateToGeneratorModal = onNavigateToGeneratorModal, + onNavigateToPlan = onNavigateToPlan, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt index 8b89563184..8dc93cbab2 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt @@ -57,6 +57,7 @@ fun AddEditSendScreen( onNavigateBack: () -> Unit, onNavigateUpToSearchOrRoot: () -> Unit, onNavigateToGeneratorModal: (GeneratorMode.Modal) -> Unit, + onNavigateToPlan: () -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val addSendHandlers = remember(viewModel) { AddEditSendHandlers.create(viewModel) } @@ -98,6 +99,8 @@ fun AddEditSendScreen( is AddEditSendEvent.NavigateToPremium -> { intentManager.launchUri(uri = event.uri.toUri()) } + + AddEditSendEvent.NavigateToPlanModal -> onNavigateToPlan() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt index d6e6fc527e..dbb1f84005 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt @@ -22,6 +22,7 @@ import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText import com.bitwarden.ui.util.concat import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager @@ -90,6 +91,7 @@ class AddEditSendViewModel @Inject constructor( private val networkConnectionManager: NetworkConnectionManager, private val snackbarRelayManager: SnackbarRelayManager, private val featureFlagManager: FeatureFlagManager, + private val premiumStateManager: PremiumStateManager, ) : BaseViewModel( // We load the state from the savedStateHandle for testing purposes. initialState = savedStateHandle[KEY_STATE] ?: run { @@ -648,15 +650,19 @@ class AddEditSendViewModel @Inject constructor( } private fun handleUpgradeToPremiumClick() { - val baseUrl = environmentRepo - .environment - .environmentUrlData - .baseWebVaultUrlOrDefault - sendEvent( - AddEditSendEvent.NavigateToPremium( - uri = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium", - ), - ) + if (premiumStateManager.isInAppUpgradeAvailable()) { + sendEvent(AddEditSendEvent.NavigateToPlanModal) + } else { + val baseUrl = environmentRepo + .environment + .environmentUrlData + .baseWebVaultUrlOrDefault + sendEvent( + AddEditSendEvent.NavigateToPremium( + uri = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium", + ), + ) + } } @Suppress("LongMethod") @@ -1113,6 +1119,11 @@ sealed class AddEditSendEvent { * Navigate to the Premium upgrade page. */ data class NavigateToPremium(val uri: String) : AddEditSendEvent() + + /** + * Navigates to the in-app plan modal for premium upgrade. + */ + data object NavigateToPlanModal : AddEditSendEvent() } /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditNavigation.kt index 540bbae3c1..e865b3e8be 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditNavigation.kt @@ -77,6 +77,7 @@ fun NavGraphBuilder.vaultAddEditDestination( onNavigateToGeneratorModal: (GeneratorMode.Modal) -> Unit, onNavigateToAttachments: (cipherId: String) -> Unit, onNavigateToMoveToOrganization: (cipherId: String, showOnlyCollections: Boolean) -> Unit, + onNavigateToPlan: () -> Unit, ) { composableWithSlideTransitions { VaultAddEditScreen( @@ -87,6 +88,7 @@ fun NavGraphBuilder.vaultAddEditDestination( onNavigateToGeneratorModal = onNavigateToGeneratorModal, onNavigateToAttachments = onNavigateToAttachments, onNavigateToMoveToOrganization = onNavigateToMoveToOrganization, + onNavigateToPlan = onNavigateToPlan, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt index 4a2044a0ed..b626850f0f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt @@ -113,6 +113,7 @@ fun VaultAddEditScreen( onNavigateToGeneratorModal: (GeneratorMode.Modal) -> Unit, onNavigateToAttachments: (cipherId: String) -> Unit, onNavigateToMoveToOrganization: (cipherId: String, showOnlyCollections: Boolean) -> Unit, + onNavigateToPlan: () -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val userVerificationHandlers = remember(viewModel) { @@ -210,6 +211,8 @@ fun VaultAddEditScreen( VaultAddEditEvent.NavigateToAppSettings -> { intentManager.startAppSettingsActivity() } + + VaultAddEditEvent.NavigateToPlanModal -> onNavigateToPlan() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index 65ec60f9bc..fbd762c26d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -33,6 +33,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials +import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest import com.x8bit.bitwarden.data.credentials.model.Fido2RegisterCredentialResult @@ -141,6 +142,7 @@ class VaultAddEditViewModel @Inject constructor( private val networkConnectionManager: NetworkConnectionManager, private val firstTimeActionManager: FirstTimeActionManager, private val environmentRepository: EnvironmentRepository, + private val premiumStateManager: PremiumStateManager, ) : BaseViewModel( // We load the state from the savedStateHandle for testing purposes. initialState = savedStateHandle[KEY_STATE] @@ -652,12 +654,19 @@ class VaultAddEditViewModel @Inject constructor( } private fun handleUpgradeToPremiumClick() { - val baseUrl = environmentRepository.environment.environmentUrlData.baseWebVaultUrlOrDefault - sendEvent( - VaultAddEditEvent.NavigateToPremium( - uri = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium", - ), - ) + if (premiumStateManager.isInAppUpgradeAvailable()) { + sendEvent(VaultAddEditEvent.NavigateToPlanModal) + } else { + val baseUrl = environmentRepository + .environment + .environmentUrlData + .baseWebVaultUrlOrDefault + sendEvent( + VaultAddEditEvent.NavigateToPremium( + uri = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium", + ), + ) + } } private fun handleConfirmDeleteClick() { @@ -3197,6 +3206,11 @@ sealed class VaultAddEditEvent { val uri: String, ) : VaultAddEditEvent() + /** + * Navigates to the in-app plan modal for premium upgrade. + */ + data object NavigateToPlanModal : VaultAddEditEvent() + /** * Navigates to the collections screen. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemNavigation.kt index 3f7aa2b534..53c5f97863 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemNavigation.kt @@ -47,6 +47,7 @@ fun NavGraphBuilder.vaultItemDestination( onNavigateToAttachments: (vaultItemId: String) -> Unit, onNavigateToPasswordHistory: (vaultItemId: String) -> Unit, onNavigateToPreviewAttachment: (route: PreviewAttachmentRoute) -> Unit, + onNavigateToPlan: () -> Unit, ) { composableWithSlideTransitions { VaultItemScreen( @@ -56,6 +57,7 @@ fun NavGraphBuilder.vaultItemDestination( onNavigateToAttachments = onNavigateToAttachments, onNavigateToPasswordHistory = onNavigateToPasswordHistory, onNavigateToPreviewAttachment = onNavigateToPreviewAttachment, + onNavigateToPlan = onNavigateToPlan, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index d078674a53..2c90e12c72 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -65,6 +65,7 @@ fun VaultItemScreen( onNavigateToAttachments: (vaultItemId: String) -> Unit, onNavigateToPasswordHistory: (vaultItemId: String) -> Unit, onNavigateToPreviewAttachment: (route: PreviewAttachmentRoute) -> Unit, + onNavigateToPlan: () -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val fileChooserLauncher = intentManager.getActivityResultLauncher { activityResult -> @@ -100,6 +101,8 @@ fun VaultItemScreen( is VaultItemEvent.NavigateToUri -> intentManager.launchUri(event.uri.toUri()) + VaultItemEvent.NavigateToPlanModal -> onNavigateToPlan() + is VaultItemEvent.NavigateToAttachments -> onNavigateToAttachments(event.itemId) is VaultItemEvent.NavigateToMoveToOrganization -> { diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index a0bb049de2..71f055a319 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -25,6 +25,7 @@ import com.bitwarden.ui.util.asText import com.bitwarden.ui.util.concat import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager @@ -82,6 +83,7 @@ class VaultItemViewModel @Inject constructor( private val environmentRepository: EnvironmentRepository, private val settingsRepository: SettingsRepository, private val snackbarRelayManager: SnackbarRelayManager, + private val premiumStateManager: PremiumStateManager, featureFlagManager: FeatureFlagManager, ) : BaseViewModel( // We load the state from the savedStateHandle for testing purposes. @@ -744,9 +746,16 @@ class VaultItemViewModel @Inject constructor( private fun handleUpgradeToPremiumClick() { updateDialogState(dialog = null) - val baseUrl = environmentRepository.environment.environmentUrlData.baseWebVaultUrlOrDefault - val uri = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium" - sendEvent(VaultItemEvent.NavigateToUri(uri = uri)) + if (premiumStateManager.isInAppUpgradeAvailable()) { + sendEvent(VaultItemEvent.NavigateToPlanModal) + } else { + val baseUrl = environmentRepository + .environment + .environmentUrlData + .baseWebVaultUrlOrDefault + val uri = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium" + sendEvent(VaultItemEvent.NavigateToUri(uri = uri)) + } } private fun handlePasswordVisibilityClicked( @@ -1976,6 +1985,11 @@ sealed class VaultItemEvent { val uri: String, ) : VaultItemEvent() + /** + * Navigates to the in-app plan modal for premium upgrade. + */ + data object NavigateToPlanModal : VaultItemEvent() + /** * Navigates to the attachments screen. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingNavigation.kt index 2363b29b4b..1431f904a9 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingNavigation.kt @@ -148,6 +148,7 @@ fun NavGraphBuilder.vaultItemListingDestination( onNavigateToVaultAddItemScreen: (args: VaultAddEditArgs) -> Unit, onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit, + onNavigateToPlan: () -> Unit, ) { internalVaultItemListingDestination( onNavigateBack = onNavigateBack, @@ -159,6 +160,7 @@ fun NavGraphBuilder.vaultItemListingDestination( onNavigateToVaultEditItemScreen = onNavigateToVaultEditItemScreen, onNavigateToSearch = { onNavigateToSearchVault(it as SearchType.Vault) }, onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, + onNavigateToPlan = onNavigateToPlan, ) } @@ -173,6 +175,7 @@ fun NavGraphBuilder.vaultItemListingDestinationAsRoot( onNavigateToVaultAddItemScreen: (args: VaultAddEditArgs) -> Unit, onNavigateToAddFolderScreen: (selectedFolderId: String?) -> Unit, onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit, + onNavigateToPlan: () -> Unit, ) { composableWithStayTransitions { VaultItemListingScreen( @@ -185,6 +188,7 @@ fun NavGraphBuilder.vaultItemListingDestinationAsRoot( onNavigateToVaultItemListing = {}, onNavigateToAddEditSendItem = {}, onNavigateToViewSendItem = {}, + onNavigateToPlan = onNavigateToPlan, ) } } @@ -197,6 +201,7 @@ fun NavGraphBuilder.sendItemListingDestination( onNavigateToAddEditSendItem: (route: AddEditSendRoute) -> Unit, onNavigateToViewSendItem: (route: ViewSendRoute) -> Unit, onNavigateToSearchSend: (searchType: SearchType.Sends) -> Unit, + onNavigateToPlan: () -> Unit, ) { internalVaultItemListingDestination( onNavigateBack = onNavigateBack, @@ -208,6 +213,7 @@ fun NavGraphBuilder.sendItemListingDestination( onNavigateToVaultEditItemScreen = { }, onNavigateToVaultItemListing = { }, onNavigateToSearch = { onNavigateToSearchSend(it as SearchType.Sends) }, + onNavigateToPlan = onNavigateToPlan, ) } @@ -225,6 +231,7 @@ private inline fun NavGraphBuilder.internalV noinline onNavigateToAddEditSendItem: (route: AddEditSendRoute) -> Unit, noinline onNavigateToViewSendItem: (route: ViewSendRoute) -> Unit, noinline onNavigateToSearch: (searchType: SearchType) -> Unit, + noinline onNavigateToPlan: () -> Unit, ) { composableWithPushTransitions { VaultItemListingScreen( @@ -237,6 +244,7 @@ private inline fun NavGraphBuilder.internalV onNavigateToVaultItemListing = onNavigateToVaultItemListing, onNavigateToSearch = onNavigateToSearch, onNavigateToAddFolder = onNavigateToAddFolderScreen, + onNavigateToPlan = onNavigateToPlan, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt index 7fe39da8f3..02a70bc14e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt @@ -85,6 +85,7 @@ fun VaultItemListingScreen( onNavigateToAddEditSendItem: (route: AddEditSendRoute) -> Unit, onNavigateToViewSendItem: (route: ViewSendRoute) -> Unit, onNavigateToSearch: (searchType: SearchType) -> Unit, + onNavigateToPlan: () -> Unit, intentManager: IntentManager = LocalIntentManager.current, exitManager: ExitManager = LocalExitManager.current, credentialProviderCompletionManager: CredentialProviderCompletionManager = @@ -145,6 +146,8 @@ fun VaultItemListingScreen( intentManager.launchUri(event.url.toUri()) } + VaultItemListingEvent.NavigateToPlanModal -> onNavigateToPlan() + is VaultItemListingEvent.NavigateToAddSendItem -> { onNavigateToAddEditSendItem( AddEditSendRoute( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index 96ee861e89..f823bd7c72 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -45,6 +45,7 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySele import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials +import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager import com.x8bit.bitwarden.data.credentials.manager.OriginManager import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest @@ -151,6 +152,7 @@ class VaultItemListingViewModel @Inject constructor( private val networkConnectionManager: NetworkConnectionManager, private val relyingPartyParser: RelyingPartyParser, private val toastManager: ToastManager, + private val premiumStateManager: PremiumStateManager, snackbarRelayManager: SnackbarRelayManager, featureFlagManager: FeatureFlagManager, ) : BaseViewModel( @@ -671,9 +673,16 @@ class VaultItemListingViewModel @Inject constructor( private fun handleUpgradeToPremiumClick() { clearDialogState() - val baseUrl = environmentRepository.environment.environmentUrlData.baseWebVaultUrlOrDefault - val url = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium" - sendEvent(VaultItemListingEvent.NavigateToUrl(url = url)) + if (premiumStateManager.isInAppUpgradeAvailable()) { + sendEvent(VaultItemListingEvent.NavigateToPlanModal) + } else { + val baseUrl = environmentRepository + .environment + .environmentUrlData + .baseWebVaultUrlOrDefault + val url = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium" + sendEvent(VaultItemListingEvent.NavigateToUrl(url = url)) + } } private fun handleRemoveSendPasswordClick( @@ -3501,6 +3510,11 @@ sealed class VaultItemListingEvent { val url: String, ) : VaultItemListingEvent() + /** + * Navigates to the in-app plan modal for premium upgrade. + */ + data object NavigateToPlanModal : VaultItemListingEvent() + /** * Navigates to the SearchScreen with the given type filter. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt index 793f83ed69..91a5563bcc 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultGraphNavigation.kt @@ -63,6 +63,7 @@ fun NavGraphBuilder.vaultGraph( onNavigateToVaultEditItemScreen = onNavigateToVaultEditItemScreen, onNavigateToVaultItemListing = { navController.navigateToVaultItemListing(it) }, onNavigateToAddFolderScreen = onNavigateToAddFolderScreen, + onNavigateToPlan = onNavigateToPlan, ) vaultVerificationCodeDestination( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 11c2d5360b..3bcf386caa 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -400,9 +400,16 @@ class VaultViewModel @Inject constructor( private fun handleUpgradeToPremiumClick() { mutableStateFlow.update { it.copy(dialog = null) } - val baseUrl = environmentRepository.environment.environmentUrlData.baseWebVaultUrlOrDefault - val url = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium" - sendEvent(VaultEvent.NavigateToUrl(url = url)) + if (premiumStateManager.isInAppUpgradeAvailable()) { + sendEvent(VaultEvent.NavigateToUpgradePremium) + } else { + val baseUrl = environmentRepository + .environment + .environmentUrlData + .baseWebVaultUrlOrDefault + val url = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium" + sendEvent(VaultEvent.NavigateToUrl(url = url)) + } } private fun handleDismissActionCardClick(action: VaultAction.DismissActionCardClick) { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt index fdcc11be90..dc69959c61 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt @@ -73,6 +73,7 @@ class SearchScreenTest : BitwardenComposeTest() { private var onNavigateToViewSendRoute: ViewSendRoute? = null private var onNavigateToEditCipherArgs: VaultAddEditArgs? = null private var onNavigateToViewCipherArgs: VaultItemArgs? = null + private var onNavigateToPlanCalled = false @Before fun setup() { @@ -87,6 +88,7 @@ class SearchScreenTest : BitwardenComposeTest() { onNavigateToViewSend = { onNavigateToViewSendRoute = it }, onNavigateToEditCipher = { onNavigateToEditCipherArgs = it }, onNavigateToViewCipher = { onNavigateToViewCipherArgs = it }, + onNavigateToPlan = { onNavigateToPlanCalled = true }, ) } } @@ -153,6 +155,12 @@ class SearchScreenTest : BitwardenComposeTest() { } } + @Test + fun `NavigateToPlanModal should call onNavigateToPlan`() { + mutableEventFlow.tryEmit(SearchEvent.NavigateToPlanModal) + assertTrue(onNavigateToPlanCalled) + } + @Test fun `on ShowSnackbar event should display the snackbar`() { val message = "message" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt index 20a59f45f8..e66e3f2b0a 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt @@ -26,6 +26,7 @@ import com.bitwarden.vault.CipherView import com.bitwarden.vault.LoginUriView import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager @@ -162,6 +163,9 @@ class SearchViewModelTest : BaseViewModelTest() { getSnackbarDataFlow(relay = any(), relays = anyVararg()) } returns mutableSnackbarDataFlow } + private val premiumStateManager: PremiumStateManager = mockk { + every { isInAppUpgradeAvailable() } returns false + } private val mutableArchiveItemsFlow = MutableStateFlow(true) private val featureFlagManager: FeatureFlagManager = mockk { every { getFeatureFlag(FlagKey.ArchiveItems) } answers { mutableArchiveItemsFlow.value } @@ -301,20 +305,35 @@ class SearchViewModelTest : BaseViewModelTest() { } @Test - fun `UpgradeToPremiumClick should emit NavigateToUrl`() = runTest { - val viewModel = createViewModel(initialState = null) - viewModel.eventFlow.test { - viewModel.trySendAction(SearchAction.UpgradeToPremiumClick) - assertEquals( - SearchEvent.NavigateToUrl( - url = "https://vault.bitwarden.com/#/" + - "settings/subscription/premium" + - "?callToAction=upgradeToPremium", - ), - awaitItem(), - ) + fun `UpgradeToPremiumClick should emit NavigateToUrl when in-app upgrade not available`() = + runTest { + val viewModel = createViewModel(initialState = null) + viewModel.eventFlow.test { + viewModel.trySendAction(SearchAction.UpgradeToPremiumClick) + assertEquals( + SearchEvent.NavigateToUrl( + url = "https://vault.bitwarden.com/#/" + + "settings/subscription/premium" + + "?callToAction=upgradeToPremium", + ), + awaitItem(), + ) + } + } + + @Test + fun `UpgradeToPremiumClick should emit NavigateToPlanModal when in-app upgrade available`() = + runTest { + every { premiumStateManager.isInAppUpgradeAvailable() } returns true + val viewModel = createViewModel(initialState = null) + viewModel.eventFlow.test { + viewModel.trySendAction(SearchAction.UpgradeToPremiumClick) + assertEquals( + SearchEvent.NavigateToPlanModal, + awaitItem(), + ) + } } - } @Test fun `ArchiveClick without Premium should show ArchiveRequiresPremium dialog`() = runTest { @@ -2013,6 +2032,7 @@ class SearchViewModelTest : BaseViewModelTest() { accessibilitySelectionManager = accessibilitySelectionManager, autofillSelectionManager = autofillSelectionManager, organizationEventManager = organizationEventManager, + premiumStateManager = premiumStateManager, snackbarRelayManager = snackbarRelayManager, featureFlagManager = featureFlagManager, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt index 5ab1bb2c0b..3bd37cd898 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt @@ -58,6 +58,7 @@ class AddEditSendScreenTest : BitwardenComposeTest() { private var navgatedGeneratorMode: GeneratorMode.Modal? = null private var onNavigateBackCalled = false private var onNavigateUpToSearchOrRootCalled = false + private var onNavigateToPlanCalled = false private val exitManager: ExitManager = mockk(relaxed = true) { every { exitApplication() } just runs @@ -87,6 +88,7 @@ class AddEditSendScreenTest : BitwardenComposeTest() { onNavigateToGeneratorModal = { mode -> navgatedGeneratorMode = mode }, + onNavigateToPlan = { onNavigateToPlanCalled = true }, ) } } @@ -114,6 +116,12 @@ class AddEditSendScreenTest : BitwardenComposeTest() { assertEquals(mode, navgatedGeneratorMode) } + @Test + fun `on NavigateToPlanModal event should call onNavigateToPlan`() { + mutableEventFlow.tryEmit(AddEditSendEvent.NavigateToPlanModal) + assertTrue(onNavigateToPlanCalled) + } + @Test fun `ExitApp should call exitApplication on ExitManager`() { mutableEventFlow.tryEmit(AddEditSendEvent.ExitApp) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt index 1a52243fcd..b56c333db6 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt @@ -19,6 +19,7 @@ import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager @@ -109,6 +110,9 @@ class AddEditSendViewModelTest : BaseViewModelTest() { every { sendSnackbarData(data = any(), relay = any()) } just runs } + private val premiumStateManager: PremiumStateManager = mockk { + every { isInAppUpgradeAvailable() } returns false + } private val mutableSendEmailVerificationFeatureFlagFlow = MutableStateFlow(false) private val featureFlagManager: FeatureFlagManager = mockk { every { @@ -1458,20 +1462,33 @@ class AddEditSendViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `UpgradeToPremiumClick should send NavigateToPremium event`() = runTest { - val viewModel = createViewModel() - - viewModel.eventFlow.test { - viewModel.trySendAction(AddEditSendAction.UpgradeToPremiumClick) - val event = awaitItem() - assertEquals( - AddEditSendEvent.NavigateToPremium( - uri = "https://vault.bitwarden.com/#/settings/subscription/premium?callToAction=upgradeToPremium", - ), - event, - ) + fun `UpgradeToPremiumClick should send NavigateToPremium when in-app upgrade not available`() = + runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AddEditSendAction.UpgradeToPremiumClick) + assertEquals( + AddEditSendEvent.NavigateToPremium( + uri = "https://vault.bitwarden.com/#/settings/subscription/premium?callToAction=upgradeToPremium", + ), + awaitItem(), + ) + } + } + + @Test + fun `UpgradeToPremiumClick should send NavigateToPlanModal when in-app upgrade available`() = + runTest { + every { premiumStateManager.isInAppUpgradeAvailable() } returns true + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AddEditSendAction.UpgradeToPremiumClick) + assertEquals( + AddEditSendEvent.NavigateToPlanModal, + awaitItem(), + ) + } } - } //endregion Authentication Tests @@ -1499,6 +1516,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() { snackbarRelayManager = snackbarRelayManager, generatorRepository = generatorRepository, featureFlagManager = featureFlagManager, + premiumStateManager = premiumStateManager, ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt index d3be05b1a6..f92196f14c 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt @@ -98,6 +98,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { private var onNavigateToAttachmentsId: String? = null private var onNavigateToCardScanScreenCalled = false private var onNavigateToMoveToOrganizationId: String? = null + private var onNavigateToPlanCalled = false private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE_LOGIN) @@ -140,6 +141,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { onNavigateToAttachments = { onNavigateToAttachmentsId = it }, onNavigateToMoveToOrganization = { id, _ -> onNavigateToMoveToOrganizationId = id }, onNavigateToCardScanScreen = { onNavigateToCardScanScreenCalled = true }, + onNavigateToPlan = { onNavigateToPlanCalled = true }, viewModel = viewModel, ) } @@ -275,6 +277,12 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { assertEquals(GeneratorMode.Modal.Username(website), onNavigateToGeneratorModalType) } + @Test + fun `on NavigateToPlanModal event should invoke onNavigateToPlan`() { + mutableEventFlow.tryEmit(VaultAddEditEvent.NavigateToPlanModal) + assertTrue(onNavigateToPlanCalled) + } + @Test fun `on CompleteCredentialCreate event should invoke CredentialProviderCompletionManager`() { val result = CreateCredentialResult.Success.Fido2CredentialRegistered( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index d4c428005b..1afc450ec1 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -34,6 +34,7 @@ import com.bitwarden.vault.FolderView import com.bitwarden.vault.UriMatchType import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult @@ -226,6 +227,9 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { every { show(message = any(), duration = any()) } just runs } private val environmentRepository = FakeEnvironmentRepository() + private val premiumStateManager: PremiumStateManager = mockk { + every { isInAppUpgradeAvailable() } returns false + } private val mutableArchiveItemsFlow = MutableStateFlow(true) private val mutableCardScannerFlow = MutableStateFlow(false) private val mutableCardScanResultFlow = @@ -552,20 +556,35 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { } @Test - fun `UpgradeToPremiumClick should emit NavigateToPremium`() = runTest { - val viewModel = createAddVaultItemViewModel() - viewModel.eventFlow.test { - viewModel.trySendAction(VaultAddEditAction.Common.UpgradeToPremiumClick) - assertEquals( - VaultAddEditEvent.NavigateToPremium( - uri = "https://vault.bitwarden.com/#/" + - "settings/subscription/premium" + - "?callToAction=upgradeToPremium", - ), - awaitItem(), - ) + fun `UpgradeToPremiumClick should emit NavigateToPremium when in-app upgrade not available`() = + runTest { + val viewModel = createAddVaultItemViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(VaultAddEditAction.Common.UpgradeToPremiumClick) + assertEquals( + VaultAddEditEvent.NavigateToPremium( + uri = "https://vault.bitwarden.com/#/" + + "settings/subscription/premium" + + "?callToAction=upgradeToPremium", + ), + awaitItem(), + ) + } + } + + @Test + fun `UpgradeToPremiumClick should emit NavigateToPlanModal when in-app upgrade available`() = + runTest { + every { premiumStateManager.isInAppUpgradeAvailable() } returns true + val viewModel = createAddVaultItemViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(VaultAddEditAction.Common.UpgradeToPremiumClick) + assertEquals( + VaultAddEditEvent.NavigateToPlanModal, + awaitItem(), + ) + } } - } @Test fun `snackbar relay emission should send ShowSnackbar`() = runTest { @@ -5539,6 +5558,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { networkConnectionManager = networkConnectionManager, firstTimeActionManager = firstTimeActionManager, environmentRepository = environmentRepository, + premiumStateManager = premiumStateManager, ) private fun createVaultData( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index e091bfe9a4..056c237203 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -71,6 +71,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { private var onNavigateToAttachmentsId: String? = null private var onNavigateToPasswordHistoryId: String? = null private var onNavigateToPreviewAttachment: PreviewAttachmentRoute? = null + private var onNavigateToPlanCalled = false private val intentManager = mockk(relaxed = true) @@ -96,6 +97,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { onNavigateToAttachments = { onNavigateToAttachmentsId = it }, onNavigateToPasswordHistory = { onNavigateToPasswordHistoryId = it }, onNavigateToPreviewAttachment = { onNavigateToPreviewAttachment = it }, + onNavigateToPlan = { onNavigateToPlanCalled = true }, ) } } @@ -199,6 +201,12 @@ class VaultItemScreenTest : BitwardenComposeTest() { } } + @Test + fun `NavigateToPlanModal event should invoke onNavigateToPlan`() { + mutableEventFlow.tryEmit(VaultItemEvent.NavigateToPlanModal) + assertTrue(onNavigateToPlanCalled) + } + @Test fun `NavigateToSelectAttachmentSaveLocation should invoke createDocumentIntent`() { mutableEventFlow.tryEmit(VaultItemEvent.NavigateToSelectAttachmentSaveLocation("test.mp4")) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index 0f723f15e0..840cbe8e04 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -26,6 +26,7 @@ import com.bitwarden.vault.CipherView import com.bitwarden.vault.FolderView import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.createMockOrganization @@ -132,6 +133,9 @@ class VaultItemViewModelTest : BaseViewModelTest() { } returns mutableSnackbarDataFlow every { sendSnackbarData(data = any(), relay = any()) } just runs } + private val premiumStateManager: PremiumStateManager = mockk { + every { isInAppUpgradeAvailable() } returns false + } private val mutableArchiveItemsFlow = MutableStateFlow(true) private val featureFlagManager: FeatureFlagManager = mockk { every { getFeatureFlag(FlagKey.ArchiveItems) } answers { mutableArchiveItemsFlow.value } @@ -232,20 +236,40 @@ class VaultItemViewModelTest : BaseViewModelTest() { } @Test - fun `UpgradeToPremiumClick should emit NavigateToPremium`() = runTest { - val viewModel = createViewModel(state = null) - viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemAction.Common.UpgradeToPremiumClick) - assertEquals( - VaultItemEvent.NavigateToUri( - uri = "https://vault.bitwarden.com/#/" + - "settings/subscription/premium" + - "?callToAction=upgradeToPremium", - ), - awaitItem(), - ) + fun `UpgradeToPremiumClick should emit NavigateToUri when in-app upgrade not available`() = + runTest { + val viewModel = createViewModel(state = null) + viewModel.eventFlow.test { + viewModel.trySendAction( + VaultItemAction.Common.UpgradeToPremiumClick, + ) + assertEquals( + VaultItemEvent.NavigateToUri( + uri = "https://vault.bitwarden.com/#/" + + "settings/subscription/premium" + + "?callToAction=upgradeToPremium", + ), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `UpgradeToPremiumClick should emit NavigateToPlanModal when in-app upgrade available`() = + runTest { + every { premiumStateManager.isInAppUpgradeAvailable() } returns true + val viewModel = createViewModel(state = null) + viewModel.eventFlow.test { + viewModel.trySendAction( + VaultItemAction.Common.UpgradeToPremiumClick, + ) + assertEquals( + VaultItemEvent.NavigateToPlanModal, + awaitItem(), + ) + } } - } @Test fun `ArchiveClick without Premium should show ArchiveRequiresPremium dialog`() = runTest { @@ -2963,6 +2987,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { environmentRepository = environmentRepository, settingsRepository = settingsRepository, snackbarRelayManager = snackbarRelayManager, + premiumStateManager = premiumStateManager, featureFlagManager = featureFlagManager, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index 05ec604426..84f4044742 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -100,6 +100,7 @@ class VaultItemListingScreenTest : BitwardenComposeTest() { private var onNavigateToVaultItemListingScreenType: VaultItemListingType? = null private var onNavigateToAddFolderCalled = false private var onNavigateToAddFolderParentFolderName: String? = null + private var onNavigateToPlanCalled: Boolean = false private val exitManager: ExitManager = mockk { every { exitApplication() } just runs @@ -146,6 +147,7 @@ class VaultItemListingScreenTest : BitwardenComposeTest() { onNavigateToAddFolderCalled = true onNavigateToAddFolderParentFolderName = folderName }, + onNavigateToPlan = { onNavigateToPlanCalled = true }, ) } } @@ -635,6 +637,12 @@ class VaultItemListingScreenTest : BitwardenComposeTest() { } } + @Test + fun `NavigateToPlanModal event should invoke onNavigateToPlan`() { + mutableEventFlow.tryEmit(VaultItemListingEvent.NavigateToPlanModal) + assertTrue(onNavigateToPlanCalled) + } + @Test fun `progressbar should be displayed according to state`() { mutableStateFlow.update { DEFAULT_STATE } @@ -1588,6 +1596,7 @@ class VaultItemListingScreenTest : BitwardenComposeTest() { .assertIsDisplayed() } + @Suppress("LongMethod") @Test fun `on send item overflow option click should emit the appropriate action`() { val number = 1 diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 24fca0854b..dffa03d681 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -46,6 +46,7 @@ import com.bitwarden.vault.CipherRepromptType import com.bitwarden.vault.CipherType import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState @@ -298,6 +299,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { every { parse(any()) } returns DEFAULT_RELYING_PARTY_ID every { parse(any()) } returns DEFAULT_RELYING_PARTY_ID } + private val premiumStateManager: PremiumStateManager = mockk { + every { isInAppUpgradeAvailable() } returns false + } private val mutableArchiveItemsFlow = MutableStateFlow(true) private val featureFlagManager: FeatureFlagManager = mockk { every { getFeatureFlag(FlagKey.ArchiveItems) } answers { mutableArchiveItemsFlow.value } @@ -1057,20 +1061,35 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } @Test - fun `UpgradeToPremiumClick should emit NavigateToUrl`() = runTest { - val viewModel = createVaultItemListingViewModel() - viewModel.eventFlow.test { - viewModel.trySendAction(VaultItemListingsAction.UpgradeToPremiumClick) - assertEquals( - VaultItemListingEvent.NavigateToUrl( - url = "https://vault.bitwarden.com/#/" + - "settings/subscription/premium" + - "?callToAction=upgradeToPremium", - ), - awaitItem(), - ) + fun `UpgradeToPremiumClick should emit NavigateToUrl when in-app upgrade not available`() = + runTest { + val viewModel = createVaultItemListingViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemListingsAction.UpgradeToPremiumClick) + assertEquals( + VaultItemListingEvent.NavigateToUrl( + url = "https://vault.bitwarden.com/#/" + + "settings/subscription/premium" + + "?callToAction=upgradeToPremium", + ), + awaitItem(), + ) + } + } + + @Test + fun `UpgradeToPremiumClick should emit NavigateToPlanModal when in-app upgrade available`() = + runTest { + every { premiumStateManager.isInAppUpgradeAvailable() } returns true + val viewModel = createVaultItemListingViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(VaultItemListingsAction.UpgradeToPremiumClick) + assertEquals( + VaultItemListingEvent.NavigateToPlanModal, + awaitItem(), + ) + } } - } @Test fun `ArchiveClick without Premium should show ArchiveRequiresPremium dialog`() = runTest { @@ -6178,6 +6197,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { privilegedAppRepository = privilegedAppRepository, snackbarRelayManager = snackbarRelayManager, toastManager = toastManager, + premiumStateManager = premiumStateManager, relyingPartyParser = relyingPartyParser, featureFlagManager = featureFlagManager, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 618d217799..19d8e63efb 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -240,6 +240,7 @@ class VaultViewModelTest : BaseViewModelTest() { every { isPremiumUpgradeBannerEligibleFlow } returns mutablePremiumUpgradeBannerEligibleFlow + every { isInAppUpgradeAvailable() } returns false every { dismissPremiumUpgradeBanner() } just runs } @@ -832,20 +833,36 @@ class VaultViewModelTest : BaseViewModelTest() { } @Test - fun `UpgradeToPremiumClick should emit NavigateToUrl`() = runTest { - val viewModel = createViewModel() - viewModel.eventFlow.test { - viewModel.trySendAction(VaultAction.UpgradeToPremiumClick) - assertEquals( - VaultEvent.NavigateToUrl( - url = "https://vault.bitwarden.com/#/" + - "settings/subscription/premium" + - "?callToAction=upgradeToPremium", - ), - awaitItem(), - ) + fun `UpgradeToPremiumClick should emit NavigateToUrl when in-app upgrade not available`() = + runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(VaultAction.UpgradeToPremiumClick) + assertEquals( + VaultEvent.NavigateToUrl( + url = "https://vault.bitwarden.com/#/" + + "settings/subscription/premium" + + "?callToAction=upgradeToPremium", + ), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `UpgradeToPremiumClick should emit NavigateToUpgradePremium when in-app upgrade available`() = + runTest { + every { premiumStateManager.isInAppUpgradeAvailable() } returns true + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(VaultAction.UpgradeToPremiumClick) + assertEquals( + VaultEvent.NavigateToUpgradePremium, + awaitItem(), + ) + } } - } @Test fun `ArchiveClick without Premium should show ArchiveRequiresPremium dialog`() = runTest { From bd6be6b851c6e60df1b686885df47eec5aa8fd32 Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:45:12 -0500 Subject: [PATCH 04/16] Update SDK to 2.0.0-6370-96753eef (#6780) Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com> --- .../platform/util/CipherViewExtensions.kt | 2 ++ .../util/VaultSdkCipherExtensions.kt | 4 ++++ .../search/util/SearchTypeDataExtensions.kt | 1 + .../addedit/util/CipherViewExtensions.kt | 2 ++ .../feature/item/util/CipherViewExtensions.kt | 3 +++ .../itemlisting/VaultItemListingViewModel.kt | 2 ++ .../util/VaultItemListingDataExtensions.kt | 2 ++ .../vault/util/VaultAddItemStateExtensions.kt | 2 ++ .../feature/vault/util/VaultDataExtensions.kt | 2 ++ .../util/CipherListViewTypeExtensions.kt | 1 + .../ui/vault/util/CipherTypeExtensions.kt | 1 + .../datasource/sdk/model/CipherViewUtil.kt | 20 ++++++++++++++++ .../sdk/model/VaultSdkCipherUtil.kt | 20 ++++++++++++++++ .../feature/search/util/SearchUtil.kt | 2 ++ .../addedit/util/CipherViewExtensionsTest.kt | 1 + .../feature/item/util/VaultItemTestUtil.kt | 1 + .../util/VaultItemListingDataUtil.kt | 2 ++ .../vault/feature/vault/VaultViewModelTest.kt | 20 ++++++++-------- .../util/VaultAddItemStateExtensionsTest.kt | 7 ++++++ .../vault/util/VaultDataExtensionsTest.kt | 24 +++++++++---------- gradle/libs.versions.toml | 2 +- 21 files changed, 98 insertions(+), 23 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/CipherViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/CipherViewExtensions.kt index 90cef6b85a..b764e7b6d6 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/CipherViewExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/util/CipherViewExtensions.kt @@ -54,6 +54,8 @@ val CipherView.subtitle: String? CipherType.SECURE_NOTE, CipherType.SSH_KEY, -> null + + CipherType.BANK_ACCOUNT -> TODO("PM-32810: Add Bank Account Type") } /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt index e787dcf5ce..8ec70ecb0a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/util/VaultSdkCipherExtensions.kt @@ -373,6 +373,7 @@ private fun CipherType.toNetworkCipherType(): CipherTypeJson = CipherType.CARD -> CipherTypeJson.CARD CipherType.IDENTITY -> CipherTypeJson.IDENTITY CipherType.SSH_KEY -> CipherTypeJson.SSH_KEY + CipherType.BANK_ACCOUNT -> TODO("PM-32810: Add Bank Account Type") } /** @@ -401,6 +402,8 @@ fun SyncResponseJson.Cipher.toEncryptedSdkCipher(): Cipher = sshKey = sshKey?.toSdkSshKey(), card = card?.toSdkCard(), secureNote = secureNote?.toSdkSecureNote(), + // TODO: PM-32810: Add Bank Account Type + bankAccount = null, favorite = isFavorite, reprompt = reprompt.toSdkRepromptType(), organizationUseTotp = shouldOrganizationUseTotp, @@ -717,6 +720,7 @@ fun Cipher.toFailureCipherListView(): CipherListView = CipherType.IDENTITY -> CipherListViewType.Identity CipherType.SSH_KEY -> CipherListViewType.SshKey + CipherType.BANK_ACCOUNT -> CipherListViewType.BankAccount }, favorite = favorite, reprompt = reprompt, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt index 9dd7955d87..f6b6463ff4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt @@ -274,6 +274,7 @@ private val CipherListViewType.iconRes: Int is CipherListViewType.Card -> BitwardenDrawable.ic_payment_card CipherListViewType.Identity -> BitwardenDrawable.ic_id_card CipherListViewType.SshKey -> BitwardenDrawable.ic_ssh_key + CipherListViewType.BankAccount -> TODO("PM-32810: Add Bank Account Type") } /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt index c324c5e1d4..c449b2deb1 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensions.kt @@ -98,6 +98,8 @@ fun CipherView.toViewState( privateKey = sshKey?.privateKey.orEmpty(), fingerprint = sshKey?.fingerprint.orEmpty(), ) + + CipherType.BANK_ACCOUNT -> TODO("PM-32810: Add Bank Account Type") }, common = VaultAddEditState.ViewState.Content.Common( originalCipher = this, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt index 811ff61a60..0e56612dc8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt @@ -212,6 +212,8 @@ fun CipherView.toViewState( ?.showPrivateKey == true, ) } + + CipherType.BANK_ACCOUNT -> TODO("PM-32810: Add Bank Account Type") }, ) @@ -302,6 +304,7 @@ private val CipherType.iconRes: Int CipherType.IDENTITY -> BitwardenDrawable.ic_id_card CipherType.SSH_KEY -> BitwardenDrawable.ic_ssh_key CipherType.LOGIN -> BitwardenDrawable.ic_globe + CipherType.BANK_ACCOUNT -> TODO("PM-32810: Add Bank Account Type") } @get:DrawableRes diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index f823bd7c72..8bcf1e5cfd 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -1349,6 +1349,7 @@ class VaultItemListingViewModel @Inject constructor( CipherType.CARD -> VaultItemCipherType.CARD CipherType.IDENTITY -> VaultItemCipherType.IDENTITY CipherType.SSH_KEY -> VaultItemCipherType.SSH_KEY + CipherType.BANK_ACCOUNT -> TODO("PM-32810: Add Bank Account Type") }, ), ) @@ -1370,6 +1371,7 @@ class VaultItemListingViewModel @Inject constructor( CipherType.CARD -> VaultItemCipherType.CARD CipherType.IDENTITY -> VaultItemCipherType.IDENTITY CipherType.SSH_KEY -> VaultItemCipherType.SSH_KEY + CipherType.BANK_ACCOUNT -> TODO("PM-32810: Add Bank Account Type") }, ), ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt index 2256ac61f5..ad33d2cec9 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt @@ -531,6 +531,7 @@ private fun CipherListView.toIconTestTag(): String = is CipherListViewType.Card -> "CardCipherIcon" CipherListViewType.Identity -> "IdentityCipherIcon" CipherListViewType.SshKey -> "SshKeyCipherIcon" + CipherListViewType.BankAccount -> "BankAccountCipherIcon" } private fun CipherListView.toIconData( @@ -593,6 +594,7 @@ private val CipherListViewType.iconRes: Int is CipherListViewType.Card -> BitwardenDrawable.ic_payment_card CipherListViewType.Identity -> BitwardenDrawable.ic_id_card CipherListViewType.SshKey -> BitwardenDrawable.ic_ssh_key + CipherListViewType.BankAccount -> TODO("PM-32810: Add Bank Account Type") } private fun List.applyFilters( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt index c18c4edda8..908d7267e1 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensions.kt @@ -62,6 +62,8 @@ fun VaultAddEditState.ViewState.Content.toCipherView( login = type.toLoginView(common = common, clock = clock), card = type.toCardView(), sshKey = type.toSshKeyView(), + // TODO PM-32810: Add Bank Account Type + bankAccount = null, // Fields we always grab from the UI name = common.name, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt index a109fcd417..39362f92e5 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt @@ -376,6 +376,8 @@ private fun CipherListView.toVaultItemOrNull( reprompt == CipherRepromptType.PASSWORD, hasDecryptionError = hasDecryptionError, ) + + CipherListViewType.BankAccount -> TODO("PM-32810: Add Bank Account Type") } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/util/CipherListViewTypeExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/util/CipherListViewTypeExtensions.kt index c258163eb0..d88135700f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/util/CipherListViewTypeExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/util/CipherListViewTypeExtensions.kt @@ -13,4 +13,5 @@ fun CipherListViewType.toSdkCipherType(): CipherType = is CipherListViewType.Login -> CipherType.LOGIN CipherListViewType.SecureNote -> CipherType.SECURE_NOTE CipherListViewType.SshKey -> CipherType.SSH_KEY + CipherListViewType.BankAccount -> CipherType.BANK_ACCOUNT } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/util/CipherTypeExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/util/CipherTypeExtensions.kt index a87664b6cd..0f6ff953b0 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/util/CipherTypeExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/util/CipherTypeExtensions.kt @@ -13,4 +13,5 @@ fun CipherType.toVaultItemCipherType(): VaultItemCipherType = CipherType.CARD -> VaultItemCipherType.CARD CipherType.IDENTITY -> VaultItemCipherType.IDENTITY CipherType.SSH_KEY -> VaultItemCipherType.SSH_KEY + CipherType.BANK_ACCOUNT -> TODO("PM-32810: Add Bank Account Type") } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt index b3ce9c9012..24f14ac17b 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.vault.datasource.sdk.model import com.bitwarden.fido.Fido2CredentialAutofillView import com.bitwarden.vault.AttachmentView +import com.bitwarden.vault.BankAccountView import com.bitwarden.vault.CardView import com.bitwarden.vault.CipherRepromptType import com.bitwarden.vault.CipherType @@ -51,6 +52,7 @@ fun createMockCipherView( clock: Clock = FIXED_CLOCK, fido2Credentials: List? = null, sshKey: SshKeyView? = createMockSshKeyView(number = number), + bankAccount: BankAccountView = createMockBankAccountView(number = number), login: LoginView? = createMockLoginView( number = number, password = password, @@ -94,6 +96,7 @@ fun createMockCipherView( cipherType == CipherType.IDENTITY }, sshKey = sshKey.takeIf { cipherType == CipherType.SSH_KEY }, + bankAccount = bankAccount.takeIf { cipherType == CipherType.BANK_ACCOUNT }, favorite = false, passwordHistory = passwordHistory, permissions = createMockSdkCipherPermissions(), @@ -269,6 +272,23 @@ fun createMockSshKeyView(number: Int): SshKeyView = fingerprint = "mockKeyFingerprint-$number", ) +/** + * Create a mock [BankAccountView] with a given [number]. + */ +fun createMockBankAccountView(number: Int): BankAccountView = + BankAccountView( + bankName = "mockBankName-$number", + nameOnAccount = "mockNameOnAccount-$number", + accountType = "mockAccountType-$number", + accountNumber = "mockAccountNumber-$number", + routingNumber = "mockRoutingNumber-$number", + branchNumber = "mockBranchNumber-$number", + pin = "mockPin-$number", + swiftCode = "mokSwiftCode-$number", + iban = "mockIban-$number", + bankContactPhone = "mockBankContractPhone-$number", + ) + /** * Create a mock [PasswordHistoryView] with a given [number]. */ diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/VaultSdkCipherUtil.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/VaultSdkCipherUtil.kt index 19a90a9427..41e59c2c6d 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/VaultSdkCipherUtil.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/model/VaultSdkCipherUtil.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.vault.datasource.sdk.model import com.bitwarden.vault.Attachment +import com.bitwarden.vault.BankAccount import com.bitwarden.vault.Card import com.bitwarden.vault.Cipher import com.bitwarden.vault.CipherPermissions @@ -52,6 +53,8 @@ fun createMockSdkCipher(number: Int, clock: Clock = FIXED_CLOCK): Cipher = fields = listOf(createMockSdkField(number = number)), identity = createMockSdkIdentity(number = number), sshKey = createMockSdkSshKey(number = number), + // TODO: PM-32810: Add Bank Account Type + bankAccount = null, favorite = false, passwordHistory = listOf(createMockSdkPasswordHistory(number = number, clock = clock)), permissions = createMockSdkCipherPermissions(), @@ -128,6 +131,23 @@ fun createMockSdkSshKey(number: Int): SshKey = fingerprint = "mockKeyFingerprint-$number", ) +/** + * Create a mock [BankAccount] with a given [number]. + */ +fun createMockBankAccount(number: Int): BankAccount = + BankAccount( + bankName = "mockBankName-$number", + nameOnAccount = "mockNameOnAccount-$number", + accountType = "mockAccountType-$number", + accountNumber = "mockAccountNumber-$number", + routingNumber = "mockRoutingNumber-$number", + branchNumber = "mockBranchNumber-$number", + pin = "mockPin-$number", + swiftCode = "mokSwiftCode-$number", + iban = "mockIban-$number", + bankContactPhone = "mockBankContractPhone-$number", + ) + /** * Create a mock [Field] with a given [number]. */ diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchUtil.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchUtil.kt index 3492213487..a1d6be4acf 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchUtil.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchUtil.kt @@ -245,6 +245,8 @@ fun createMockDisplayItemForCipher( itemType = SearchState.DisplayItem.ItemType.Vault(type = cipherType), ) } + + CipherType.BANK_ACCOUNT -> TODO("PM-32810: Add Bank Account Type") } /** diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt index 1f95c51b47..25512dca11 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/util/CipherViewExtensionsTest.kt @@ -739,6 +739,7 @@ private val DEFAULT_BASE_CIPHER_VIEW: CipherView = CipherView( identity = null, card = null, secureNote = null, + bankAccount = null, favorite = false, reprompt = CipherRepromptType.PASSWORD, organizationUseTotp = false, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt index 018b6f4dde..24789e0238 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/VaultItemTestUtil.kt @@ -98,6 +98,7 @@ fun createCipherView(type: CipherType, isEmpty: Boolean): CipherView = identity = createIdentityView(isEmpty = isEmpty), card = null, secureNote = null, + bankAccount = null, favorite = false, reprompt = CipherRepromptType.PASSWORD, organizationUseTotp = false, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt index 351b29dac7..1b9f88aff8 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt @@ -276,6 +276,8 @@ fun createMockDisplayItemForCipher( itemType = VaultItemListingState.DisplayItem.ItemType.Vault(type = cipherType), ) } + + CipherType.BANK_ACCOUNT -> TODO("PM-32810: Add Bank Account Type") } /** diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 19d8e63efb..30f4a66634 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -1478,7 +1478,7 @@ class VaultViewModelTest : BaseViewModelTest() { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, archiveEnabled = true, @@ -1617,7 +1617,7 @@ class VaultViewModelTest : BaseViewModelTest() { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, archiveEnabled = true, @@ -1758,7 +1758,7 @@ class VaultViewModelTest : BaseViewModelTest() { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, archiveEnabled = true, @@ -1826,7 +1826,7 @@ class VaultViewModelTest : BaseViewModelTest() { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, archiveEnabled = true, @@ -1942,7 +1942,7 @@ class VaultViewModelTest : BaseViewModelTest() { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, archiveEnabled = true, @@ -2268,7 +2268,7 @@ class VaultViewModelTest : BaseViewModelTest() { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 0, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = null, archiveEnabled = true, @@ -2375,7 +2375,7 @@ class VaultViewModelTest : BaseViewModelTest() { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 0, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 1, archiveEnabled = true, @@ -2448,7 +2448,7 @@ class VaultViewModelTest : BaseViewModelTest() { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, archiveEnabled = true, @@ -3748,7 +3748,7 @@ class VaultViewModelTest : BaseViewModelTest() { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 2, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, archiveEnabled = true, @@ -3810,7 +3810,7 @@ class VaultViewModelTest : BaseViewModelTest() { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 2, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, archiveEnabled = true, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt index 675f8e1677..b9bdcfc8c6 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultAddItemStateExtensionsTest.kt @@ -91,6 +91,7 @@ class VaultAddItemStateExtensionsTest { identity = null, card = null, secureNote = null, + bankAccount = null, favorite = false, reprompt = CipherRepromptType.NONE, organizationUseTotp = false, @@ -254,6 +255,7 @@ class VaultAddItemStateExtensionsTest { login = null, identity = null, card = null, + bankAccount = null, secureNote = SecureNoteView(SecureNoteType.GENERIC), favorite = false, reprompt = CipherRepromptType.NONE, @@ -383,6 +385,7 @@ class VaultAddItemStateExtensionsTest { notes = "mockNotes-1", type = CipherType.IDENTITY, login = null, + bankAccount = null, identity = IdentityView( title = "MR", firstName = "mockFirstName", @@ -590,6 +593,7 @@ class VaultAddItemStateExtensionsTest { number = "1234567", ), secureNote = null, + bankAccount = null, favorite = false, reprompt = CipherRepromptType.NONE, organizationUseTotp = false, @@ -731,6 +735,7 @@ class VaultAddItemStateExtensionsTest { identity = null, card = null, secureNote = null, + bankAccount = null, favorite = false, reprompt = CipherRepromptType.NONE, organizationUseTotp = false, @@ -1029,6 +1034,7 @@ class VaultAddItemStateExtensionsTest { identity = null, card = null, secureNote = null, + bankAccount = null, favorite = false, reprompt = CipherRepromptType.NONE, organizationUseTotp = false, @@ -1140,6 +1146,7 @@ private val DEFAULT_BASE_CIPHER_VIEW: CipherView = CipherView( identity = null, card = null, secureNote = null, + bankAccount = null, favorite = false, reprompt = CipherRepromptType.PASSWORD, organizationUseTotp = false, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt index 7e37270d01..0372314ce2 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt @@ -141,7 +141,7 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 1, archiveEnabled = true, @@ -197,7 +197,7 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, archiveEnabled = true, @@ -273,7 +273,7 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, archiveEnabled = true, @@ -376,7 +376,7 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, archiveEnabled = true, @@ -423,7 +423,7 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 0, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = null, archiveEnabled = true, @@ -472,7 +472,7 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = null, archiveEnabled = true, @@ -521,7 +521,7 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = null, archiveEnabled = true, @@ -754,7 +754,7 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 2, totpItemsCount = 1, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, archiveEnabled = true, @@ -803,7 +803,7 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 2, totpItemsCount = 0, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, archiveEnabled = true, @@ -861,7 +861,7 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 100, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, archiveEnabled = true, @@ -933,7 +933,7 @@ class VaultDataExtensionsTest { ), trashItemsCount = 0, totpItemsCount = 1, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, archiveEnabled = true, @@ -1024,7 +1024,7 @@ class VaultDataExtensionsTest { noFolderItems = listOf(), trashItemsCount = 0, totpItemsCount = 1, - itemTypesCount = 5, + itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, archiveEnabled = true, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index faa909b1fb..ea51277eb0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,7 @@ androidxRoom = "2.8.4" androidxSecurityCrypto = "1.1.0" androidxSplash = "1.2.0" androidxWork = "2.11.2" -bitwardenSdk = "2.0.0-6340-00b609f9" +bitwardenSdk = "2.0.0-6370-96753eef" crashlytics = "3.0.7" detekt = "1.23.8" firebaseBom = "34.12.0" From 2b4ca430f15cafa5046f60968a5790b87ffe901e Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:42:18 -0400 Subject: [PATCH 05/16] [PM-35454] feat: Add subscription API, domain models, and status badge component (#6818) --- .../billing/repository/BillingRepository.kt | 6 + .../repository/BillingRepositoryImpl.kt | 14 + .../billing/repository/model/PlanCadence.kt | 9 + .../model/PremiumSubscriptionStatus.kt | 12 + .../repository/model/SubscriptionInfo.kt | 36 ++ .../repository/model/SubscriptionResult.kt | 20 + ...ardenSubscriptionResponseJsonExtensions.kt | 85 ++++ .../repository/BillingRepositoryTest.kt | 81 ++++ ...nSubscriptionResponseJsonExtensionsTest.kt | 219 +++++++++++ .../data/serializer/BigDecimalSerializer.kt | 44 +++ .../com/bitwarden/core/di/CoreModule.kt | 2 + .../serializer/BigDecimalSerializerTest.kt | 93 +++++ .../network/BitwardenServiceClientImpl.kt | 2 + .../network/api/AuthenticatedBillingApi.kt | 7 + .../BitwardenSubscriptionResponseJson.kt | 222 +++++++++++ .../network/service/BillingService.kt | 6 + .../network/service/BillingServiceImpl.kt | 6 + .../network/service/BillingServiceTest.kt | 369 ++++++++++++++++++ .../components/badge/BitwardenStatusBadge.kt | 99 +++++ .../theme/color/BitwardenColorScheme.kt | 21 + .../ui/platform/theme/color/ColorScheme.kt | 53 +++ .../badge/BitwardenStatusBadgeTest.kt | 49 +++ 22 files changed, 1455 insertions(+) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PlanCadence.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PremiumSubscriptionStatus.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/SubscriptionInfo.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/SubscriptionResult.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensions.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensionsTest.kt create mode 100644 core/src/main/kotlin/com/bitwarden/core/data/serializer/BigDecimalSerializer.kt create mode 100644 core/src/test/kotlin/com/bitwarden/core/data/serializer/BigDecimalSerializerTest.kt create mode 100644 network/src/main/kotlin/com/bitwarden/network/model/BitwardenSubscriptionResponseJson.kt create mode 100644 ui/src/main/kotlin/com/bitwarden/ui/platform/components/badge/BitwardenStatusBadge.kt create mode 100644 ui/src/test/kotlin/com/bitwarden/ui/platform/components/badge/BitwardenStatusBadgeTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepository.kt index 398d787ff4..e1f422b549 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepository.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.billing.repository import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult import com.x8bit.bitwarden.data.billing.repository.model.PremiumPlanPricingResult +import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult import kotlinx.coroutines.flow.StateFlow /** @@ -29,4 +30,9 @@ interface BillingRepository { * Retrieves the premium plan pricing information. */ suspend fun getPremiumPlanPricing(): PremiumPlanPricingResult + + /** + * Fetches the current user's premium subscription details. + */ + suspend fun getSubscription(): SubscriptionResult } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryImpl.kt index a13efbde8e..87bf2a4f06 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryImpl.kt @@ -5,6 +5,8 @@ import com.x8bit.bitwarden.data.billing.manager.PlayBillingManager import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult import com.x8bit.bitwarden.data.billing.repository.model.PremiumPlanPricingResult +import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult +import com.x8bit.bitwarden.data.billing.repository.util.toSubscriptionInfo import kotlinx.coroutines.flow.StateFlow /** @@ -47,4 +49,16 @@ class BillingRepositoryImpl( PremiumPlanPricingResult.Error(error = it) }, ) + + override suspend fun getSubscription(): SubscriptionResult = + billingService + .getSubscription() + .fold( + onSuccess = { + SubscriptionResult.Success( + subscription = it.toSubscriptionInfo(), + ) + }, + onFailure = { SubscriptionResult.Error(error = it) }, + ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PlanCadence.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PlanCadence.kt new file mode 100644 index 0000000000..eb862d293e --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PlanCadence.kt @@ -0,0 +1,9 @@ +package com.x8bit.bitwarden.data.billing.repository.model + +/** + * The billing cadence of a premium subscription. + */ +enum class PlanCadence { + ANNUALLY, + MONTHLY, +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PremiumSubscriptionStatus.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PremiumSubscriptionStatus.kt new file mode 100644 index 0000000000..3f288a4b85 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PremiumSubscriptionStatus.kt @@ -0,0 +1,12 @@ +package com.x8bit.bitwarden.data.billing.repository.model + +/** + * Represents the UI-facing subscription status for premium users. + */ +enum class PremiumSubscriptionStatus { + ACTIVE, + CANCELED, + OVERDUE_PAYMENT, + PAST_DUE, + PAUSED, +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/SubscriptionInfo.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/SubscriptionInfo.kt new file mode 100644 index 0000000000..2fbac43010 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/SubscriptionInfo.kt @@ -0,0 +1,36 @@ +package com.x8bit.bitwarden.data.billing.repository.model + +import java.math.BigDecimal +import java.time.Instant + +/** + * Domain model containing a premium subscription's billing and lifecycle details. + * + * @property status The UI-facing subscription status. + * @property cadence The billing cadence (annual or monthly). + * @property seatsCost The cost of the seat line item for the current cadence. + * @property storageCost The cost of additional storage, or null if none. + * @property discountAmount The money value of any applied discount, or null if no discount is + * present. Percent-off discounts are resolved against the password manager subtotal at mapping + * time. + * @property estimatedTax The estimated tax charged on the next invoice. + * @property nextChargeTotal The total of the next invoice: + * `seatsCost + (storageCost ?: 0) - (discountAmount ?: 0) + estimatedTax`. + * @property nextCharge The date of the next charge, or null if not applicable. + * @property canceledDate The date the subscription was canceled, or null. + * @property suspensionDate The date the subscription will be suspended, or null. + * @property gracePeriodDays The grace period in days, or null. + */ +data class SubscriptionInfo( + val status: PremiumSubscriptionStatus, + val cadence: PlanCadence, + val seatsCost: BigDecimal, + val storageCost: BigDecimal?, + val discountAmount: BigDecimal?, + val estimatedTax: BigDecimal, + val nextChargeTotal: BigDecimal, + val nextCharge: Instant?, + val canceledDate: Instant?, + val suspensionDate: Instant?, + val gracePeriodDays: Int?, +) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/SubscriptionResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/SubscriptionResult.kt new file mode 100644 index 0000000000..2fee5fddc1 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/SubscriptionResult.kt @@ -0,0 +1,20 @@ +package com.x8bit.bitwarden.data.billing.repository.model + +/** + * Models the result of fetching the user's premium subscription details. + */ +sealed class SubscriptionResult { + /** + * Subscription details were fetched successfully. + */ + data class Success( + val subscription: SubscriptionInfo, + ) : SubscriptionResult() + + /** + * An error occurred while fetching subscription details. + */ + data class Error( + val error: Throwable, + ) : SubscriptionResult() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensions.kt new file mode 100644 index 0000000000..db26b5aec4 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensions.kt @@ -0,0 +1,85 @@ +package com.x8bit.bitwarden.data.billing.repository.util + +import com.bitwarden.network.model.BitwardenDiscountJson +import com.bitwarden.network.model.BitwardenSubscriptionResponseJson +import com.bitwarden.network.model.CadenceTypeJson +import com.bitwarden.network.model.DiscountTypeJson +import com.bitwarden.network.model.SubscriptionStatusJson +import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence +import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus +import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo +import java.math.BigDecimal +import java.math.RoundingMode + +private val PERCENT_DIVISOR: BigDecimal = BigDecimal("100") +private const val MONEY_SCALE: Int = 2 + +/** + * Maps a [BitwardenSubscriptionResponseJson] into a [SubscriptionInfo] domain + * model. + * + * `discountAmount` is resolved at mapping time: fixed-amount discounts pass + * through as-is; percent-off discounts apply to the password manager subtotal + * (`seatsCost + storageCost`). `nextChargeTotal` is computed client-side as + * `seatsCost + storageCost - discountAmount + estimatedTax` because the server + * does not expose a precomputed total. + */ +fun BitwardenSubscriptionResponseJson.toSubscriptionInfo(): SubscriptionInfo { + val seatsCost = cart.passwordManager.seats.cost + val storageCost = cart.passwordManager.additionalStorage?.cost + val discountAmount = cart.discount?.toMoneyAmount( + subtotal = seatsCost + (storageCost ?: BigDecimal.ZERO), + ) + val estimatedTax = cart.estimatedTax + val nextChargeTotal = seatsCost + + (storageCost ?: BigDecimal.ZERO) - + (discountAmount ?: BigDecimal.ZERO) + + estimatedTax + + return SubscriptionInfo( + status = status.toPremiumSubscriptionStatus(), + cadence = cart.cadence.toPlanCadence(), + seatsCost = seatsCost, + storageCost = storageCost, + discountAmount = discountAmount, + estimatedTax = estimatedTax, + nextChargeTotal = nextChargeTotal, + nextCharge = nextCharge, + canceledDate = canceled, + suspensionDate = suspension, + gracePeriodDays = gracePeriod, + ) +} + +private fun SubscriptionStatusJson.toPremiumSubscriptionStatus(): PremiumSubscriptionStatus = + when (this) { + SubscriptionStatusJson.ACTIVE, + SubscriptionStatusJson.TRIALING, + -> PremiumSubscriptionStatus.ACTIVE + + SubscriptionStatusJson.CANCELED, + SubscriptionStatusJson.INCOMPLETE_EXPIRED, + -> PremiumSubscriptionStatus.CANCELED + + SubscriptionStatusJson.INCOMPLETE, + SubscriptionStatusJson.UNPAID, + -> PremiumSubscriptionStatus.OVERDUE_PAYMENT + + SubscriptionStatusJson.PAST_DUE -> PremiumSubscriptionStatus.PAST_DUE + + SubscriptionStatusJson.PAUSED -> PremiumSubscriptionStatus.PAUSED + } + +private fun CadenceTypeJson.toPlanCadence(): PlanCadence = when (this) { + CadenceTypeJson.ANNUALLY -> PlanCadence.ANNUALLY + CadenceTypeJson.MONTHLY -> PlanCadence.MONTHLY +} + +private fun BitwardenDiscountJson.toMoneyAmount(subtotal: BigDecimal): BigDecimal = + when (type) { + DiscountTypeJson.AMOUNT_OFF -> value + DiscountTypeJson.PERCENT_OFF -> + subtotal + .multiply(value) + .divide(PERCENT_DIVISOR, MONEY_SCALE, RoundingMode.HALF_EVEN) + } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryTest.kt index f480891e43..6b8a81e51e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryTest.kt @@ -2,14 +2,24 @@ package com.x8bit.bitwarden.data.billing.repository import com.bitwarden.core.data.util.asFailure import com.bitwarden.core.data.util.asSuccess +import com.bitwarden.network.model.BitwardenSubscriptionResponseJson +import com.bitwarden.network.model.CadenceTypeJson +import com.bitwarden.network.model.CartItemJson +import com.bitwarden.network.model.CartJson import com.bitwarden.network.model.CheckoutSessionResponseJson +import com.bitwarden.network.model.PasswordManagerCartItemsJson import com.bitwarden.network.model.PortalUrlResponseJson import com.bitwarden.network.model.PremiumPlanResponseJson +import com.bitwarden.network.model.SubscriptionStatusJson import com.bitwarden.network.service.BillingService import com.x8bit.bitwarden.data.billing.manager.PlayBillingManager import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult +import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence import com.x8bit.bitwarden.data.billing.repository.model.PremiumPlanPricingResult +import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus +import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo +import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -19,6 +29,7 @@ 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.math.BigDecimal class BillingRepositoryTest { @@ -150,6 +161,76 @@ class BillingRepositoryTest { result, ) } + + @Test + fun `getSubscription when service returns success should return Success`() = + runTest { + coEvery { + billingService.getSubscription() + } returns ACTIVE_SUBSCRIPTION_RESPONSE.asSuccess() + + val result = repository.getSubscription() + + assertEquals( + SubscriptionResult.Success( + subscription = SubscriptionInfo( + status = PremiumSubscriptionStatus.ACTIVE, + cadence = PlanCadence.ANNUALLY, + seatsCost = BigDecimal("19.80"), + storageCost = null, + discountAmount = null, + estimatedTax = BigDecimal.ZERO, + nextChargeTotal = BigDecimal("19.80"), + nextCharge = null, + canceledDate = null, + suspensionDate = null, + gracePeriodDays = null, + ), + ), + result, + ) + } + + @Test + fun `getSubscription when service returns failure should return Error`() = + runTest { + val exception = RuntimeException("Network error") + coEvery { + billingService.getSubscription() + } returns exception.asFailure() + + val result = repository.getSubscription() + + assertEquals( + SubscriptionResult.Error(error = exception), + result, + ) + } } private const val ANNUAL_PRICE = 19.99 + +private val ACTIVE_SUBSCRIPTION_RESPONSE = BitwardenSubscriptionResponseJson( + status = SubscriptionStatusJson.ACTIVE, + cart = CartJson( + passwordManager = PasswordManagerCartItemsJson( + seats = CartItemJson( + translationKey = "premiumMembership", + quantity = 1, + cost = BigDecimal("19.80"), + discount = null, + ), + additionalStorage = null, + ), + secretsManager = null, + cadence = CadenceTypeJson.ANNUALLY, + discount = null, + estimatedTax = BigDecimal.ZERO, + ), + storage = null, + cancelAt = null, + canceled = null, + nextCharge = null, + suspension = null, + gracePeriod = null, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensionsTest.kt new file mode 100644 index 0000000000..ca70ae5db5 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensionsTest.kt @@ -0,0 +1,219 @@ +package com.x8bit.bitwarden.data.billing.repository.util + +import com.bitwarden.network.model.BitwardenDiscountJson +import com.bitwarden.network.model.BitwardenSubscriptionResponseJson +import com.bitwarden.network.model.CadenceTypeJson +import com.bitwarden.network.model.CartItemJson +import com.bitwarden.network.model.CartJson +import com.bitwarden.network.model.DiscountTypeJson +import com.bitwarden.network.model.PasswordManagerCartItemsJson +import com.bitwarden.network.model.StorageJson +import com.bitwarden.network.model.SubscriptionStatusJson +import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence +import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.time.Instant + +class BitwardenSubscriptionResponseJsonExtensionsTest { + + @Test + fun `toSubscriptionInfo maps ACTIVE and TRIALING to ACTIVE`() { + listOf(SubscriptionStatusJson.ACTIVE, SubscriptionStatusJson.TRIALING).forEach { + val info = buildResponse(status = it).toSubscriptionInfo() + assertEquals(PremiumSubscriptionStatus.ACTIVE, info.status) + } + } + + @Test + fun `toSubscriptionInfo maps CANCELED and INCOMPLETE_EXPIRED to CANCELED`() { + listOf( + SubscriptionStatusJson.CANCELED, + SubscriptionStatusJson.INCOMPLETE_EXPIRED, + ).forEach { + val info = buildResponse(status = it).toSubscriptionInfo() + assertEquals(PremiumSubscriptionStatus.CANCELED, info.status) + } + } + + @Test + fun `toSubscriptionInfo maps INCOMPLETE and UNPAID to OVERDUE_PAYMENT`() { + listOf(SubscriptionStatusJson.INCOMPLETE, SubscriptionStatusJson.UNPAID).forEach { + val info = buildResponse(status = it).toSubscriptionInfo() + assertEquals(PremiumSubscriptionStatus.OVERDUE_PAYMENT, info.status) + } + } + + @Test + fun `toSubscriptionInfo maps PAST_DUE to PAST_DUE`() { + val info = buildResponse( + status = SubscriptionStatusJson.PAST_DUE, + ).toSubscriptionInfo() + assertEquals(PremiumSubscriptionStatus.PAST_DUE, info.status) + } + + @Test + fun `toSubscriptionInfo maps PAUSED to PAUSED`() { + val info = buildResponse( + status = SubscriptionStatusJson.PAUSED, + ).toSubscriptionInfo() + assertEquals(PremiumSubscriptionStatus.PAUSED, info.status) + } + + @Test + fun `toSubscriptionInfo maps cadence to PlanCadence`() { + val annually = buildResponse(cadence = CadenceTypeJson.ANNUALLY).toSubscriptionInfo() + assertEquals(PlanCadence.ANNUALLY, annually.cadence) + + val monthly = buildResponse(cadence = CadenceTypeJson.MONTHLY).toSubscriptionInfo() + assertEquals(PlanCadence.MONTHLY, monthly.cadence) + } + + @Test + fun `toSubscriptionInfo maps seatsCost and null storageCost when not present`() { + val info = buildResponse(seatsCost = BigDecimal("19.80")).toSubscriptionInfo() + assertEquals(BigDecimal("19.80"), info.seatsCost) + assertNull(info.storageCost) + } + + @Test + fun `toSubscriptionInfo maps storageCost from additionalStorage when present`() { + val info = buildResponse( + seatsCost = BigDecimal("19.80"), + storageCost = BigDecimal("24.00"), + ).toSubscriptionInfo() + assertEquals(BigDecimal("24.00"), info.storageCost) + } + + @Test + fun `toSubscriptionInfo discountAmount is null when no discount`() { + val info = buildResponse(discount = null).toSubscriptionInfo() + assertNull(info.discountAmount) + } + + @Test + fun `toSubscriptionInfo discountAmount for AMOUNT_OFF passes value through`() { + val info = buildResponse( + discount = BitwardenDiscountJson( + type = DiscountTypeJson.AMOUNT_OFF, + value = BigDecimal("2.10"), + ), + ).toSubscriptionInfo() + assertEquals(BigDecimal("2.10"), info.discountAmount) + } + + @Test + fun `toSubscriptionInfo discountAmount for PERCENT_OFF applies to PM subtotal`() { + // seats 20 + storage 10 = 30 subtotal, 15% = 4.50 + val info = buildResponse( + seatsCost = BigDecimal("20.00"), + storageCost = BigDecimal("10.00"), + discount = BitwardenDiscountJson( + type = DiscountTypeJson.PERCENT_OFF, + value = BigDecimal("15.00"), + ), + ).toSubscriptionInfo() + assertEquals(BigDecimal("4.50"), info.discountAmount) + } + + @Test + fun `toSubscriptionInfo passes estimatedTax through`() { + val info = buildResponse(estimatedTax = BigDecimal("3.85")).toSubscriptionInfo() + assertEquals(BigDecimal("3.85"), info.estimatedTax) + } + + @Test + fun `toSubscriptionInfo nextChargeTotal sums line items, subtracts discount, adds tax`() { + // Matches the design example: 19.80 + 24.00 - 2.10 + 3.85 = 45.55 + val info = buildResponse( + seatsCost = BigDecimal("19.80"), + storageCost = BigDecimal("24.00"), + discount = BitwardenDiscountJson( + type = DiscountTypeJson.AMOUNT_OFF, + value = BigDecimal("2.10"), + ), + estimatedTax = BigDecimal("3.85"), + ).toSubscriptionInfo() + assertEquals(BigDecimal("45.55"), info.nextChargeTotal) + } + + @Test + fun `toSubscriptionInfo nextChargeTotal with minimal cart equals seatsCost`() { + // User-provided JSON: 19.80 + 0 - 0 + 0 = 19.80 + val info = buildResponse(seatsCost = BigDecimal("19.80")).toSubscriptionInfo() + assertEquals(BigDecimal("19.80"), info.nextChargeTotal) + } + + @Test + fun `toSubscriptionInfo maps timestamps and gracePeriod`() { + val canceled = Instant.parse("2026-01-01T00:00:00Z") + val next = Instant.parse("2027-04-21T17:35:42Z") + val suspension = Instant.parse("2026-05-02T00:00:00Z") + val info = buildResponse( + canceled = canceled, + nextCharge = next, + suspension = suspension, + gracePeriod = 14, + ).toSubscriptionInfo() + assertEquals(canceled, info.canceledDate) + assertEquals(next, info.nextCharge) + assertEquals(suspension, info.suspensionDate) + assertEquals(14, info.gracePeriodDays) + } + + @Test + fun `toSubscriptionInfo has null timestamps and gracePeriod when not provided`() { + val info = buildResponse().toSubscriptionInfo() + assertNull(info.canceledDate) + assertNull(info.nextCharge) + assertNull(info.suspensionDate) + assertNull(info.gracePeriodDays) + } + + @Suppress("LongParameterList") + private fun buildResponse( + status: SubscriptionStatusJson = SubscriptionStatusJson.ACTIVE, + cadence: CadenceTypeJson = CadenceTypeJson.ANNUALLY, + seatsCost: BigDecimal = BigDecimal("19.80"), + storageCost: BigDecimal? = null, + discount: BitwardenDiscountJson? = null, + estimatedTax: BigDecimal = BigDecimal.ZERO, + storage: StorageJson? = null, + canceled: Instant? = null, + nextCharge: Instant? = null, + suspension: Instant? = null, + gracePeriod: Int? = null, + ): BitwardenSubscriptionResponseJson = BitwardenSubscriptionResponseJson( + status = status, + cart = CartJson( + passwordManager = PasswordManagerCartItemsJson( + seats = CartItemJson( + translationKey = "premiumMembership", + quantity = 1, + cost = seatsCost, + discount = null, + ), + additionalStorage = storageCost?.let { + CartItemJson( + translationKey = "additionalStorage", + quantity = 1, + cost = it, + discount = null, + ) + }, + ), + secretsManager = null, + cadence = cadence, + discount = discount, + estimatedTax = estimatedTax, + ), + storage = storage, + cancelAt = null, + canceled = canceled, + nextCharge = nextCharge, + suspension = suspension, + gracePeriod = gracePeriod, + ) +} diff --git a/core/src/main/kotlin/com/bitwarden/core/data/serializer/BigDecimalSerializer.kt b/core/src/main/kotlin/com/bitwarden/core/data/serializer/BigDecimalSerializer.kt new file mode 100644 index 0000000000..5c122c06dc --- /dev/null +++ b/core/src/main/kotlin/com/bitwarden/core/data/serializer/BigDecimalSerializer.kt @@ -0,0 +1,44 @@ +package com.bitwarden.core.data.serializer + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonUnquotedLiteral +import java.math.BigDecimal + +/** + * Used to serialize and deserialize [BigDecimal] as a JSON number literal without + * round-tripping through [Double]. Preserving the raw numeric string guarantees no + * precision loss for currency values. + */ +class BigDecimalSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor(serialName = "BigDecimal", kind = PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): BigDecimal { + val jsonDecoder = decoder as? JsonDecoder + ?: throw SerializationException( + "BigDecimalSerializer only supports JSON formats.", + ) + val primitive = jsonDecoder.decodeJsonElement() as? JsonPrimitive + ?: throw SerializationException( + "Expected a JSON number literal for BigDecimal.", + ) + return primitive.content.toBigDecimal() + } + + override fun serialize(encoder: Encoder, value: BigDecimal) { + val jsonEncoder = encoder as? JsonEncoder + ?: throw SerializationException( + "BigDecimalSerializer only supports JSON formats.", + ) + jsonEncoder.encodeJsonElement(JsonUnquotedLiteral(value.toPlainString())) + } +} diff --git a/core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt b/core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt index 1d641360b0..3351f7c8fe 100644 --- a/core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt +++ b/core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt @@ -1,6 +1,7 @@ package com.bitwarden.core.di import com.bitwarden.core.data.manager.BuildInfoManager +import com.bitwarden.core.data.serializer.BigDecimalSerializer import com.bitwarden.core.data.serializer.InstantSerializer import dagger.Module import dagger.Provides @@ -34,6 +35,7 @@ object CoreModule { explicitNulls = false serializersModule = SerializersModule { contextual(InstantSerializer()) + contextual(BigDecimalSerializer()) } // Respect model default property values. diff --git a/core/src/test/kotlin/com/bitwarden/core/data/serializer/BigDecimalSerializerTest.kt b/core/src/test/kotlin/com/bitwarden/core/data/serializer/BigDecimalSerializerTest.kt new file mode 100644 index 0000000000..0f45adc395 --- /dev/null +++ b/core/src/test/kotlin/com/bitwarden/core/data/serializer/BigDecimalSerializerTest.kt @@ -0,0 +1,93 @@ +package com.bitwarden.core.data.serializer + +import com.bitwarden.core.di.CoreModule +import io.mockk.mockk +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.encodeToJsonElement +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +class BigDecimalSerializerTest { + private val json = CoreModule.providesJson(buildInfoManager = mockk(relaxed = true)) + + @Test + fun `deserializes JSON number with decimals without precision loss`() { + assertEquals( + BigDecimalData(amount = BigDecimal("3.85")), + json.decodeFromString( + """ + { + "amount": 3.85 + } + """, + ), + ) + } + + @Test + fun `deserializes JSON integer zero`() { + assertEquals( + BigDecimalData(amount = BigDecimal("0")), + json.decodeFromString( + """ + { + "amount": 0 + } + """, + ), + ) + } + + @Test + fun `deserializes high-precision JSON number without coercing through Double`() { + assertEquals( + BigDecimalData(amount = BigDecimal("0.123456789012345")), + json.decodeFromString( + """ + { + "amount": 0.123456789012345 + } + """, + ), + ) + } + + @Test + fun `deserializes negative JSON number`() { + assertEquals( + BigDecimalData(amount = BigDecimal("-4.20")), + json.decodeFromString( + """ + { + "amount": -4.20 + } + """, + ), + ) + } + + @Test + fun `serializes BigDecimal as unquoted JSON number literal`() { + assertEquals( + json.parseToJsonElement( + """ + { + "amount": 19.80 + } + """, + ), + json.encodeToJsonElement( + BigDecimalData(amount = BigDecimal("19.80")), + ), + ) + } +} + +@Serializable +private data class BigDecimalData( + @Serializable(BigDecimalSerializer::class) + @SerialName("amount") + val amount: BigDecimal, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt index 229cfcd6e8..342726cb1f 100644 --- a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt @@ -1,6 +1,7 @@ package com.bitwarden.network import com.bitwarden.annotation.OmitFromCoverage +import com.bitwarden.core.data.serializer.BigDecimalSerializer import com.bitwarden.core.data.serializer.InstantSerializer import com.bitwarden.network.interceptor.AuthTokenManager import com.bitwarden.network.interceptor.BaseUrlInterceptors @@ -74,6 +75,7 @@ internal class BitwardenServiceClientImpl( explicitNulls = false serializersModule = SerializersModule { contextual(InstantSerializer()) + contextual(BigDecimalSerializer()) } // Respect model default property values. diff --git a/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedBillingApi.kt b/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedBillingApi.kt index f236b95c76..e1220c68d5 100644 --- a/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedBillingApi.kt +++ b/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedBillingApi.kt @@ -1,5 +1,6 @@ package com.bitwarden.network.api +import com.bitwarden.network.model.BitwardenSubscriptionResponseJson import com.bitwarden.network.model.CheckoutSessionRequestJson import com.bitwarden.network.model.CheckoutSessionResponseJson import com.bitwarden.network.model.NetworkResult @@ -33,4 +34,10 @@ internal interface AuthenticatedBillingApi { */ @GET("/plans/premium") suspend fun getPremiumPlan(): NetworkResult + + /** + * Retrieves the user's premium subscription details. + */ + @GET("/account/billing/vnext/subscription") + suspend fun getSubscription(): NetworkResult } diff --git a/network/src/main/kotlin/com/bitwarden/network/model/BitwardenSubscriptionResponseJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/BitwardenSubscriptionResponseJson.kt new file mode 100644 index 0000000000..7288df28e0 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/BitwardenSubscriptionResponseJson.kt @@ -0,0 +1,222 @@ +package com.bitwarden.network.model + +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.math.BigDecimal +import java.time.Instant + +/** + * Response object returned when retrieving the user's premium subscription details. + * + * @property status The current status of the subscription. + * @property cart The cart details of the subscription. + * @property storage The storage usage details, if available. + * @property cancelAt The date the subscription is scheduled to cancel, if applicable. + * @property canceled The date the subscription was canceled, if applicable. + * @property nextCharge The date of the next charge, if applicable. + * @property suspension The date the subscription was suspended, if applicable. + * @property gracePeriod The grace period in days, if applicable. + */ +@Serializable +data class BitwardenSubscriptionResponseJson( + @SerialName("status") + val status: SubscriptionStatusJson, + + @SerialName("cart") + val cart: CartJson, + + @SerialName("storage") + val storage: StorageJson?, + + @Contextual + @SerialName("cancelAt") + val cancelAt: Instant?, + + @Contextual + @SerialName("canceled") + val canceled: Instant?, + + @Contextual + @SerialName("nextCharge") + val nextCharge: Instant?, + + @Contextual + @SerialName("suspension") + val suspension: Instant?, + + @SerialName("gracePeriod") + val gracePeriod: Int?, +) + +/** + * Represents the status of a subscription. + */ +@Serializable +enum class SubscriptionStatusJson { + @SerialName("active") + ACTIVE, + + @SerialName("canceled") + CANCELED, + + @SerialName("past_due") + PAST_DUE, + + @SerialName("incomplete") + INCOMPLETE, + + @SerialName("incomplete_expired") + INCOMPLETE_EXPIRED, + + @SerialName("unpaid") + UNPAID, + + @SerialName("trialing") + TRIALING, + + @SerialName("paused") + PAUSED, +} + +/** + * Represents the cart details of a subscription. + * + * @property passwordManager The password manager cart items. + * @property secretsManager The secrets manager cart items, if applicable. + * @property cadence The billing cadence of the subscription. + * @property discount The discount applied to the cart, if applicable. + * @property estimatedTax The estimated tax amount. + */ +@Serializable +data class CartJson( + @SerialName("passwordManager") + val passwordManager: PasswordManagerCartItemsJson, + + @SerialName("secretsManager") + val secretsManager: SecretsManagerCartItemsJson?, + + @SerialName("cadence") + val cadence: CadenceTypeJson, + + @SerialName("discount") + val discount: BitwardenDiscountJson?, + + @Contextual + @SerialName("estimatedTax") + val estimatedTax: BigDecimal, +) + +/** + * Represents the password manager cart items within a subscription. + * + * @property seats The seat pricing details. + * @property additionalStorage The additional storage pricing details, if applicable. + */ +@Serializable +data class PasswordManagerCartItemsJson( + @SerialName("seats") + val seats: CartItemJson, + + @SerialName("additionalStorage") + val additionalStorage: CartItemJson?, +) + +/** + * Represents the secrets manager cart items within a subscription. + * + * @property seats The seat pricing details. + * @property additionalServiceAccounts The additional service accounts pricing details, + * if applicable. + */ +@Serializable +data class SecretsManagerCartItemsJson( + @SerialName("seats") + val seats: CartItemJson, + + @SerialName("additionalServiceAccounts") + val additionalServiceAccounts: CartItemJson?, +) + +/** + * Represents a single cart item within a subscription. + * + * @property translationKey The translation key for display purposes. + * @property quantity The quantity of this item. + * @property cost The cost of this item. + * @property discount The discount applied to this item, if applicable. + */ +@Serializable +data class CartItemJson( + @SerialName("translationKey") + val translationKey: String, + + @SerialName("quantity") + val quantity: Long, + + @Contextual + @SerialName("cost") + val cost: BigDecimal, + + @SerialName("discount") + val discount: BitwardenDiscountJson?, +) + +/** + * Represents a discount applied to a subscription or cart item. + * + * @property type The type of discount. + * @property value The discount value. + */ +@Serializable +data class BitwardenDiscountJson( + @SerialName("type") + val type: DiscountTypeJson, + + @Contextual + @SerialName("value") + val value: BigDecimal, +) + +/** + * Represents the type of discount applied to a subscription. + */ +@Serializable +enum class DiscountTypeJson { + @SerialName("amount-off") + AMOUNT_OFF, + + @SerialName("percent-off") + PERCENT_OFF, +} + +/** + * Represents the billing cadence of a subscription. + */ +@Serializable +enum class CadenceTypeJson { + @SerialName("annually") + ANNUALLY, + + @SerialName("monthly") + MONTHLY, +} + +/** + * Represents storage usage details for a subscription. + * + * @property available The available storage in bytes. + * @property used The used storage amount. + * @property readableUsed A human-readable representation of the used storage. + */ +@Serializable +data class StorageJson( + @SerialName("available") + val available: Int, + + @SerialName("used") + val used: Double, + + @SerialName("readableUsed") + val readableUsed: String, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/service/BillingService.kt b/network/src/main/kotlin/com/bitwarden/network/service/BillingService.kt index 5fd224699f..5daf228cad 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/BillingService.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/BillingService.kt @@ -1,5 +1,6 @@ package com.bitwarden.network.service +import com.bitwarden.network.model.BitwardenSubscriptionResponseJson import com.bitwarden.network.model.CheckoutSessionResponseJson import com.bitwarden.network.model.PortalUrlResponseJson import com.bitwarden.network.model.PremiumPlanResponseJson @@ -23,4 +24,9 @@ interface BillingService { * Retrieves the premium plan pricing information. */ suspend fun getPremiumPlan(): Result + + /** + * Retrieves the user's premium subscription details. + */ + suspend fun getSubscription(): Result } diff --git a/network/src/main/kotlin/com/bitwarden/network/service/BillingServiceImpl.kt b/network/src/main/kotlin/com/bitwarden/network/service/BillingServiceImpl.kt index 9a79c98da0..13536c3268 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/BillingServiceImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/BillingServiceImpl.kt @@ -1,6 +1,7 @@ package com.bitwarden.network.service import com.bitwarden.network.api.AuthenticatedBillingApi +import com.bitwarden.network.model.BitwardenSubscriptionResponseJson import com.bitwarden.network.model.CheckoutSessionRequestJson import com.bitwarden.network.model.CheckoutSessionResponseJson import com.bitwarden.network.model.PortalUrlResponseJson @@ -32,4 +33,9 @@ internal class BillingServiceImpl( authenticatedBillingApi .getPremiumPlan() .toResult() + + override suspend fun getSubscription(): Result = + authenticatedBillingApi + .getSubscription() + .toResult() } diff --git a/network/src/test/kotlin/com/bitwarden/network/service/BillingServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/BillingServiceTest.kt index cf9a043913..aa1213a45b 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/BillingServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/BillingServiceTest.kt @@ -3,15 +3,27 @@ package com.bitwarden.network.service import com.bitwarden.core.data.util.asSuccess import com.bitwarden.network.api.AuthenticatedBillingApi import com.bitwarden.network.base.BaseServiceTest +import com.bitwarden.network.model.BitwardenDiscountJson +import com.bitwarden.network.model.BitwardenSubscriptionResponseJson +import com.bitwarden.network.model.CadenceTypeJson +import com.bitwarden.network.model.CartItemJson +import com.bitwarden.network.model.CartJson import com.bitwarden.network.model.CheckoutSessionResponseJson +import com.bitwarden.network.model.DiscountTypeJson +import com.bitwarden.network.model.PasswordManagerCartItemsJson import com.bitwarden.network.model.PortalUrlResponseJson import com.bitwarden.network.model.PremiumPlanResponseJson +import com.bitwarden.network.model.StorageJson +import com.bitwarden.network.model.SubscriptionStatusJson import kotlinx.coroutines.test.runTest +import kotlinx.serialization.SerializationException import okhttp3.mockwebserver.MockResponse import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import retrofit2.create +import java.math.BigDecimal +import java.time.Instant class BillingServiceTest : BaseServiceTest() { @@ -77,6 +89,105 @@ class BillingServiceTest : BaseServiceTest() { val actual = service.getPremiumPlan() assertEquals(PREMIUM_PLAN_RESPONSE.asSuccess(), actual) } + + @Test + fun `getSubscription when response is Failure should return Failure`() = + runTest { + val response = MockResponse().setResponseCode(400) + server.enqueue(response) + val actual = service.getSubscription() + assertTrue(actual.isFailure) + } + + @Test + fun `getSubscription when response is Success should return Success`() = + runTest { + val response = MockResponse() + .setBody(SUBSCRIPTION_RESPONSE_JSON) + .setResponseCode(200) + server.enqueue(response) + val actual = service.getSubscription() + assertEquals(SUBSCRIPTION_RESPONSE.asSuccess(), actual) + } + + @Test + fun `getSubscription with monthly cadence should parse correctly`() = + runTest { + val response = MockResponse() + .setBody(SUBSCRIPTION_RESPONSE_MONTHLY_JSON) + .setResponseCode(200) + server.enqueue(response) + val actual = service.getSubscription() + assertEquals( + CadenceTypeJson.MONTHLY, + actual.getOrNull()?.cart?.cadence, + ) + } + + @Test + fun `getSubscription should parse every SubscriptionStatusJson value`() = + runTest { + SubscriptionStatusJson.entries.forEach { status -> + val body = subscriptionResponseJsonForStatus(status) + val response = MockResponse() + .setBody(body) + .setResponseCode(200) + server.enqueue(response) + val actual = service.getSubscription() + assertEquals(status, actual.getOrNull()?.status) + } + } + + @Test + fun `getSubscription with null storage and discount should parse`() = + runTest { + val response = MockResponse() + .setBody(SUBSCRIPTION_RESPONSE_MINIMAL_JSON) + .setResponseCode(200) + server.enqueue(response) + val actual = service.getSubscription() + assertTrue(actual.isSuccess) + } + + @Test + fun `getSubscription with AMOUNT_OFF discount should parse`() = + runTest { + val response = MockResponse() + .setBody(SUBSCRIPTION_RESPONSE_AMOUNT_OFF_JSON) + .setResponseCode(200) + server.enqueue(response) + val actual = service.getSubscription() + assertEquals( + DiscountTypeJson.AMOUNT_OFF, + actual.getOrNull()?.cart?.discount?.type, + ) + } + + @Test + fun `getSubscription with PERCENT_OFF discount should parse`() = + runTest { + val response = MockResponse() + .setBody(SUBSCRIPTION_RESPONSE_PERCENT_OFF_JSON) + .setResponseCode(200) + server.enqueue(response) + val actual = service.getSubscription() + assertEquals( + DiscountTypeJson.PERCENT_OFF, + actual.getOrNull()?.cart?.discount?.type, + ) + } + + @Test + fun `getSubscription with unknown cadence should fail deserialization`() = + runTest { + val response = MockResponse() + .setBody(SUBSCRIPTION_RESPONSE_UNKNOWN_CADENCE_JSON) + .setResponseCode(200) + server.enqueue(response) + val actual = service.getSubscription() + assertTrue(actual.isFailure) + assertTrue(actual.exceptionOrNull() is SerializationException) + } } private const val CHECKOUT_SESSION_RESPONSE_JSON = """ @@ -132,3 +243,261 @@ private val PREMIUM_PLAN_RESPONSE = PremiumPlanResponseJson( provided = 5, ), ) + +private const val SUBSCRIPTION_RESPONSE_JSON = """ +{ + "status": "active", + "cart": { + "passwordManager": { + "seats": { + "translationKey": "premiumMembership", + "quantity": 1, + "cost": 19.80, + "discount": null + }, + "additionalStorage": { + "translationKey": "additionalStorage", + "quantity": 24, + "cost": 24.00, + "discount": null + } + }, + "secretsManager": null, + "cadence": "annually", + "discount": { + "type": "amount-off", + "value": 2.10 + }, + "estimatedTax": 3.85 + }, + "storage": { + "available": 5, + "used": 0, + "readableUsed": "0 Bytes" + }, + "cancelAt": null, + "canceled": null, + "nextCharge": "2026-04-02T00:00:00Z", + "suspension": null, + "gracePeriod": null +} +""" + +private val SUBSCRIPTION_RESPONSE = BitwardenSubscriptionResponseJson( + status = SubscriptionStatusJson.ACTIVE, + cart = CartJson( + passwordManager = PasswordManagerCartItemsJson( + seats = CartItemJson( + translationKey = "premiumMembership", + quantity = 1, + cost = BigDecimal("19.80"), + discount = null, + ), + additionalStorage = CartItemJson( + translationKey = "additionalStorage", + quantity = 24, + cost = BigDecimal("24.00"), + discount = null, + ), + ), + secretsManager = null, + cadence = CadenceTypeJson.ANNUALLY, + discount = BitwardenDiscountJson( + type = DiscountTypeJson.AMOUNT_OFF, + value = BigDecimal("2.10"), + ), + estimatedTax = BigDecimal("3.85"), + ), + storage = StorageJson( + available = 5, + used = 0.0, + readableUsed = "0 Bytes", + ), + cancelAt = null, + canceled = null, + nextCharge = Instant.parse("2026-04-02T00:00:00Z"), + suspension = null, + gracePeriod = null, +) + +private const val SUBSCRIPTION_RESPONSE_MONTHLY_JSON = """ +{ + "status": "active", + "cart": { + "passwordManager": { + "seats": { + "translationKey": "premiumMembership", + "quantity": 1, + "cost": 1.67, + "discount": null + }, + "additionalStorage": null + }, + "secretsManager": null, + "cadence": "monthly", + "discount": null, + "estimatedTax": 0 + }, + "storage": null, + "cancelAt": null, + "canceled": null, + "nextCharge": null, + "suspension": null, + "gracePeriod": null +} +""" + +private const val SUBSCRIPTION_RESPONSE_MINIMAL_JSON = """ +{ + "status": "active", + "cart": { + "passwordManager": { + "seats": { + "translationKey": "premiumMembership", + "quantity": 1, + "cost": 19.80, + "discount": null + }, + "additionalStorage": null + }, + "secretsManager": null, + "cadence": "annually", + "discount": null, + "estimatedTax": 0 + }, + "storage": null, + "cancelAt": null, + "canceled": null, + "nextCharge": null, + "suspension": null, + "gracePeriod": null +} +""" + +private const val SUBSCRIPTION_RESPONSE_AMOUNT_OFF_JSON = """ +{ + "status": "active", + "cart": { + "passwordManager": { + "seats": { + "translationKey": "premiumMembership", + "quantity": 1, + "cost": 19.80, + "discount": null + }, + "additionalStorage": null + }, + "secretsManager": null, + "cadence": "annually", + "discount": { + "type": "amount-off", + "value": 5.00 + }, + "estimatedTax": 0 + }, + "storage": null, + "cancelAt": null, + "canceled": null, + "nextCharge": null, + "suspension": null, + "gracePeriod": null +} +""" + +private const val SUBSCRIPTION_RESPONSE_PERCENT_OFF_JSON = """ +{ + "status": "active", + "cart": { + "passwordManager": { + "seats": { + "translationKey": "premiumMembership", + "quantity": 1, + "cost": 19.80, + "discount": null + }, + "additionalStorage": null + }, + "secretsManager": null, + "cadence": "annually", + "discount": { + "type": "percent-off", + "value": 15.00 + }, + "estimatedTax": 0 + }, + "storage": null, + "cancelAt": null, + "canceled": null, + "nextCharge": null, + "suspension": null, + "gracePeriod": null +} +""" + +private const val SUBSCRIPTION_RESPONSE_UNKNOWN_CADENCE_JSON = """ +{ + "status": "active", + "cart": { + "passwordManager": { + "seats": { + "translationKey": "premiumMembership", + "quantity": 1, + "cost": 19.80, + "discount": null + }, + "additionalStorage": null + }, + "secretsManager": null, + "cadence": "weekly", + "discount": null, + "estimatedTax": 0 + }, + "storage": null, + "cancelAt": null, + "canceled": null, + "nextCharge": null, + "suspension": null, + "gracePeriod": null +} +""" + +private fun subscriptionResponseJsonForStatus( + status: SubscriptionStatusJson, +): String { + val wireValue = when (status) { + SubscriptionStatusJson.ACTIVE -> "active" + SubscriptionStatusJson.CANCELED -> "canceled" + SubscriptionStatusJson.PAST_DUE -> "past_due" + SubscriptionStatusJson.INCOMPLETE -> "incomplete" + SubscriptionStatusJson.INCOMPLETE_EXPIRED -> "incomplete_expired" + SubscriptionStatusJson.UNPAID -> "unpaid" + SubscriptionStatusJson.TRIALING -> "trialing" + SubscriptionStatusJson.PAUSED -> "paused" + } + return """ + { + "status": "$wireValue", + "cart": { + "passwordManager": { + "seats": { + "translationKey": "premiumMembership", + "quantity": 1, + "cost": 19.80, + "discount": null + }, + "additionalStorage": null + }, + "secretsManager": null, + "cadence": "annually", + "discount": null, + "estimatedTax": 0 + }, + "storage": null, + "cancelAt": null, + "canceled": null, + "nextCharge": null, + "suspension": null, + "gracePeriod": null + } + """.trimIndent() +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/badge/BitwardenStatusBadge.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/badge/BitwardenStatusBadge.kt new file mode 100644 index 0000000000..e54a4a28a2 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/badge/BitwardenStatusBadge.kt @@ -0,0 +1,99 @@ +package com.bitwarden.ui.platform.components.badge + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bitwarden.annotation.OmitFromCoverage +import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.ui.platform.theme.BitwardenTheme +import com.bitwarden.ui.platform.theme.color.BitwardenColorScheme + +/** + * A reusable status badge composable that displays a colored pill with a label. + * + * @param label The text to display in the badge. + * @param colors The border, background, and text colors for the badge. + * @param modifier The [Modifier] to apply to this badge. + */ +@Composable +fun BitwardenStatusBadge( + label: String, + colors: BitwardenColorScheme.StatusBadgeVariantColors, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(size = 12.dp) + Surface( + shape = shape, + color = colors.background, + modifier = modifier + .height(24.dp) + .border( + width = 1.dp, + color = colors.border, + shape = shape, + ), + ) { + Text( + text = label, + style = BitwardenTheme.typography.labelSmall, + color = colors.text, + modifier = Modifier.padding( + horizontal = 8.dp, + vertical = 4.dp, + ), + ) + } +} + +@OmitFromCoverage +@Preview +@Composable +private fun BitwardenStatusBadge_Preview() { + BitwardenTheme { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + BitwardenStatusBadge( + label = "Active", + colors = BitwardenTheme.colorScheme.statusBadge.success, + ) + BitwardenStatusBadge( + label = "Canceled", + colors = BitwardenTheme.colorScheme.statusBadge.error, + ) + BitwardenStatusBadge( + label = "Update payment", + colors = BitwardenTheme.colorScheme.statusBadge.warning, + ) + } + } +} + +@OmitFromCoverage +@Preview +@Composable +private fun BitwardenStatusBadge_PreviewDark() { + BitwardenTheme(theme = AppTheme.DARK) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + BitwardenStatusBadge( + label = "Active", + colors = BitwardenTheme.colorScheme.statusBadge.success, + ) + BitwardenStatusBadge( + label = "Canceled", + colors = BitwardenTheme.colorScheme.statusBadge.error, + ) + BitwardenStatusBadge( + label = "Update payment", + colors = BitwardenTheme.colorScheme.statusBadge.warning, + ) + } + } +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/BitwardenColorScheme.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/BitwardenColorScheme.kt index 7cf0cd342d..6b5f1e0c2c 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/BitwardenColorScheme.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/BitwardenColorScheme.kt @@ -18,6 +18,7 @@ data class BitwardenColorScheme( val toggleButton: ToggleButtonColors, val sliderButton: SliderButtonColors, val status: StatusColors, + val statusBadge: StatusBadgeColors, val illustration: IllustrationColors, ) { /** @@ -128,6 +129,26 @@ data class BitwardenColorScheme( val error: Color, ) + /** + * Defines all the status badge colors for the app. + */ + @Immutable + data class StatusBadgeColors( + val success: StatusBadgeVariantColors, + val error: StatusBadgeVariantColors, + val warning: StatusBadgeVariantColors, + ) + + /** + * Defines the border, background, and text colors for a status badge variant. + */ + @Immutable + data class StatusBadgeVariantColors( + val border: Color, + val background: Color, + val text: Color, + ) + /** * Defines all the illustration colors for the app. */ diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/ColorScheme.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/ColorScheme.kt index 1a5b713936..a5deea3e6f 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/ColorScheme.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/ColorScheme.kt @@ -71,6 +71,23 @@ val darkBitwardenColorScheme: BitwardenColorScheme = BitwardenColorScheme( weak2 = PrimitiveColors.yellow200, error = PrimitiveColors.red200, ), + statusBadge = BitwardenColorScheme.StatusBadgeColors( + success = BitwardenColorScheme.StatusBadgeVariantColors( + border = PrimitiveColors.green800, + background = PrimitiveColors.green950, + text = PrimitiveColors.green150, + ), + error = BitwardenColorScheme.StatusBadgeVariantColors( + border = PrimitiveColors.red800, + background = PrimitiveColors.red950, + text = PrimitiveColors.red150, + ), + warning = BitwardenColorScheme.StatusBadgeVariantColors( + border = PrimitiveColors.orange800, + background = PrimitiveColors.orange950, + text = PrimitiveColors.orange200, + ), + ), illustration = BitwardenColorScheme.IllustrationColors( outline = PrimitiveColors.blue500, backgroundPrimary = PrimitiveColors.blue200, @@ -149,6 +166,23 @@ val lightBitwardenColorScheme: BitwardenColorScheme = BitwardenColorScheme( weak2 = PrimitiveColors.yellow300, error = PrimitiveColors.red300, ), + statusBadge = BitwardenColorScheme.StatusBadgeColors( + success = BitwardenColorScheme.StatusBadgeVariantColors( + border = PrimitiveColors.green150, + background = PrimitiveColors.green050, + text = PrimitiveColors.green400, + ), + error = BitwardenColorScheme.StatusBadgeVariantColors( + border = PrimitiveColors.red150, + background = PrimitiveColors.red050, + text = PrimitiveColors.red400, + ), + warning = BitwardenColorScheme.StatusBadgeVariantColors( + border = PrimitiveColors.orange200, + background = PrimitiveColors.orange050, + text = PrimitiveColors.orange700, + ), + ), illustration = BitwardenColorScheme.IllustrationColors( outline = PrimitiveColors.blue700, backgroundPrimary = PrimitiveColors.blue100, @@ -233,6 +267,11 @@ fun dynamicBitwardenColorScheme( weak2 = defaultTheme.status.weak2, error = defaultTheme.status.error, ), + statusBadge = BitwardenColorScheme.StatusBadgeColors( + success = defaultTheme.statusBadge.success, + error = defaultTheme.statusBadge.error, + warning = defaultTheme.statusBadge.warning, + ), illustration = BitwardenColorScheme.IllustrationColors( outline = materialColorScheme.onSurface, backgroundPrimary = if (isDarkTheme) { @@ -290,20 +329,34 @@ private data object PrimitiveColors { val blue600: Color = Color(color = 0xFF1A41AC) val blue700: Color = Color(color = 0xFF020F66) + val green050: Color = Color(color = 0xFFF0FDF4) val green100: Color = Color(color = 0xFFBFECC3) + val green150: Color = Color(color = 0xFFB9F8CF) val green200: Color = Color(color = 0xFF6BF178) val green300: Color = Color(color = 0xFF0C8018) val green400: Color = Color(color = 0xFF08540F) + val green800: Color = Color(color = 0xFF016630) + val green950: Color = Color(color = 0xFF032E15) + val red050: Color = Color(color = 0xFFFEF2F2) val red100: Color = Color(color = 0xFFFFECEF) + val red150: Color = Color(color = 0xFFFFC9C9) val red200: Color = Color(color = 0xFFFF4E63) val red300: Color = Color(color = 0xFFCB263A) val red400: Color = Color(color = 0xFF951B2A) + val red800: Color = Color(color = 0xFF9F0712) + val red950: Color = Color(color = 0xFF460809) val yellow100: Color = Color(color = 0xFFFFF8E4) val yellow200: Color = Color(color = 0xFFFFBF00) val yellow300: Color = Color(color = 0xFFAC5800) + val orange050: Color = Color(color = 0xFFFFF8F1) + val orange200: Color = Color(color = 0xFFFCD9BD) + val orange700: Color = Color(color = 0xFFB23300) + val orange800: Color = Color(color = 0xFF8A2203) + val orange950: Color = Color(color = 0xFF441600) + val pink100: Color = Color(color = 0xFFC01176) val pink200: Color = Color(color = 0xFFFF8FD0) } diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/components/badge/BitwardenStatusBadgeTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/badge/BitwardenStatusBadgeTest.kt new file mode 100644 index 0000000000..78b4519081 --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/badge/BitwardenStatusBadgeTest.kt @@ -0,0 +1,49 @@ +package com.bitwarden.ui.platform.components.badge + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import com.bitwarden.ui.platform.base.BaseComposeTest +import com.bitwarden.ui.platform.theme.BitwardenTheme +import org.junit.Test + +class BitwardenStatusBadgeTest : BaseComposeTest() { + + @Test + fun `success variant renders with label`() { + setTestContent { + BitwardenTheme { + BitwardenStatusBadge( + label = "Active", + colors = BitwardenTheme.colorScheme.statusBadge.success, + ) + } + } + composeTestRule.onNodeWithText("Active").assertIsDisplayed() + } + + @Test + fun `error variant renders with label`() { + setTestContent { + BitwardenTheme { + BitwardenStatusBadge( + label = "Canceled", + colors = BitwardenTheme.colorScheme.statusBadge.error, + ) + } + } + composeTestRule.onNodeWithText("Canceled").assertIsDisplayed() + } + + @Test + fun `warning variant renders with label`() { + setTestContent { + BitwardenTheme { + BitwardenStatusBadge( + label = "Overdue payment", + colors = BitwardenTheme.colorScheme.statusBadge.warning, + ) + } + } + composeTestRule.onNodeWithText("Overdue payment").assertIsDisplayed() + } +} From a3f32e31cd6998ba9edcafa4958f9c4fb1589d83 Mon Sep 17 00:00:00 2001 From: David Perez Date: Mon, 27 Apr 2026 15:45:44 -0500 Subject: [PATCH 06/16] deps: Update Androidx Compose BOM and Navigation libraries (#6832) --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ea51277eb0..2b4b35af1a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,13 +19,13 @@ androdixAutofill = "1.3.0" androidxBiometrics = "1.2.0-alpha05" androidxBrowser = "1.10.0" androidxCamera = "1.6.0" -androidxComposeBom = "2026.03.01" +androidxComposeBom = "2026.04.01" androidxCore = "1.18.0" androidxCredentials = "1.6.0" androidxCredentialsProviderEvents = "1.0.0-alpha06" androidxHiltNavigationCompose = "1.3.0" androidxLifecycle = "2.10.0" -androidxNavigation = "2.9.7" +androidxNavigation = "2.9.8" androidxRoom = "2.8.4" androidxSecurityCrypto = "1.1.0" androidxSplash = "1.2.0" From e3b26be1bf0432f86ac9bff5e1bdd7b40255b588 Mon Sep 17 00:00:00 2001 From: David Perez Date: Mon, 27 Apr 2026 16:58:21 -0500 Subject: [PATCH 07/16] deps: Update to Kotlin v2.3.21 (#6843) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2b4b35af1a..b7a84d8d6a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,7 +46,7 @@ googleReview = "2.0.2" hilt = "2.59.2" junit = "6.0.3" jvmTarget = "21" -kotlin = "2.3.20" +kotlin = "2.3.21" kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutines = "1.10.2" kotlinxSerialization = "1.11.0" From 909d999186b68f4abe9574302bf042087faa1afb Mon Sep 17 00:00:00 2001 From: David Perez Date: Tue, 28 Apr 2026 09:30:03 -0500 Subject: [PATCH 08/16] Deps: Update to AGP v9.2.0 (#6845) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b7a84d8d6a..cb85f6d389 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ minSdk = "29" minSdkBwa = "28" # Dependency Versions -androidGradlePlugin = "9.1.1" +androidGradlePlugin = "9.2.0" androidxActivity = "1.13.0" androidxAppCompat = "1.7.1" androdixAutofill = "1.3.0" From 0586edb592f69e14b1d4f77ddcee5fe170a4bc91 Mon Sep 17 00:00:00 2001 From: David Perez Date: Tue, 28 Apr 2026 13:45:03 -0500 Subject: [PATCH 09/16] PM-35925: bug: Update 'hexToColor' function to handle default names (#6841) --- .../ui/platform/base/util/StringExtensions.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/base/util/StringExtensions.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/base/util/StringExtensions.kt index 6e697196ad..c151366649 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/base/util/StringExtensions.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/base/util/StringExtensions.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.rememberTextMeasurer import androidx.core.graphics.toColorInt +import timber.log.Timber import java.net.URI import java.net.URISyntaxException import java.text.Normalizer @@ -169,11 +170,16 @@ fun String.toAnnotatedString(): AnnotatedString = AnnotatedString(text = this) * Supported formats: * - "rrggbb" / "#rrggbb" * - "aarrggbb" / "#aarrggbb" + * Support for some default color names per the [toColorInt] function. */ -fun String.hexToColor(): Color = if (startsWith("#")) { - Color(toColorInt()) -} else { - Color("#$this".toColorInt()) +fun String.hexToColor(): Color { + val colorString = if (Regex("^[0-9A-Fa-f]+$").matches(this)) "#$this" else this + return try { + Color(colorString.toColorInt()) + } catch (e: IllegalArgumentException) { + Timber.e(e, "Failed to parse color: $this") + Color.Black + } } /** From 41142a3d4d3a003e1d43e44e7f209e5c896dc8bb Mon Sep 17 00:00:00 2001 From: Colin Rinke Date: Tue, 28 Apr 2026 20:55:13 +0200 Subject: [PATCH 10/16] [PM-35352] [PM-21264] Group card numbers in vault item display (#6810) --- .../vault/feature/item/VaultItemViewModel.kt | 3 +- .../feature/item/util/CipherViewExtensions.kt | 3 +- .../ui/vault/util/CardNumberUtils.kt | 61 +++++++++++++++ .../feature/item/VaultItemViewModelTest.kt | 15 +++- .../item/util/CipherViewExtensionsTest.kt | 33 ++++++++ .../ui/vault/util/CardNumberUtilsTest.kt | 75 +++++++++++++++++++ 6 files changed, 187 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index 71f055a319..e89707a5bc 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -828,8 +828,9 @@ class VaultItemViewModel @Inject constructor( private fun handleCopyNumberClick() { onCardContent { _, card -> + val cardNumber = requireNotNull(card.number).number clipboardManager.setText( - text = requireNotNull(card.number).number, + text = cardNumber.filter { it.isDigit() }, toastDescriptorOverride = BitwardenString.number.asText(), ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt index 0e56612dc8..043e5a9644 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt @@ -27,6 +27,7 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand import com.x8bit.bitwarden.ui.vault.model.VaultLinkedFieldType import com.x8bit.bitwarden.ui.vault.model.findVaultCardBrandWithNameOrNull +import com.x8bit.bitwarden.ui.vault.util.formatCardNumber import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import java.time.Clock @@ -162,7 +163,7 @@ fun CipherView.toViewState( cardholderName = card?.cardholderName, number = card?.number?.let { VaultItemState.ViewState.Content.ItemType.Card.NumberData( - number = it, + number = it.formatCardNumber(), isVisible = (previousState?.type as? VaultItemState.ViewState.Content.ItemType.Card) ?.number diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtils.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtils.kt index f61997f33e..860eb2a412 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtils.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtils.kt @@ -1,8 +1,69 @@ +@file:Suppress("TooManyFunctions") + package com.x8bit.bitwarden.ui.vault.util import com.bitwarden.ui.platform.feature.cardscanner.util.sanitizeCardNumber import com.x8bit.bitwarden.ui.vault.model.VaultCardBrand +/** + * Formats a card number using brand-specific spacing rules. + * + * The input string is first sanitized to remove non-digit characters, then the card brand is + * detected based on the digit patterns. Finally, the digits are grouped into blocks according to + * the brand's formatting rules, and spaces are inserted between the blocks for improved + * readability. + * + * @return The formatted card number. + */ +fun String.formatCardNumber(): String { + val digits = sanitizeCardNumber() + if (digits.isEmpty()) return this + val blocks = digits.detectCardBrand().formattingBlocks(digitCount = digits.length) + return digits.chunkByBlocks(blocks).joinToString(separator = " ") +} + +/** + * Returns the digit group sizes used to format a card number for a specific brand. + * + * @param digitCount The total number of sanitized digits available for formatting. + * @return A list of block sizes that defines how the card number should be grouped. + */ +@Suppress("MagicNumber") +private fun VaultCardBrand.formattingBlocks(digitCount: Int): List { + val default = listOf(4, 4, 4, 4) + return when (this) { + VaultCardBrand.AMEX -> listOf(4, 6, 5) + VaultCardBrand.DINERS_CLUB -> if (digitCount == 14) listOf(4, 6, 4) else default + VaultCardBrand.MAESTRO -> when (digitCount) { + 13 -> listOf(4, 4, 5) + 15 -> listOf(4, 6, 5) + 19 -> listOf(4, 4, 4, 4, 3) + else -> default + } + VaultCardBrand.UNIONPAY -> if (digitCount == 19) listOf(6, 13) else default + else -> default + } +} + +/** + * Splits the string into blocks of specified sizes. + * + * If the total of the block sizes is less than the string length, the remaining characters are + * included as an additional block at the end of the list. + * + * @param blocks A list of integers specifying the size of each block. + * @return A list of string blocks based on the specified sizes. + */ +private fun String.chunkByBlocks(blocks: List): List = buildList { + var remaining = this@chunkByBlocks + for (size in blocks) { + if (remaining.isEmpty()) return@buildList + add(remaining.take(size)) + remaining = remaining.drop(size) + } + if (remaining.isNotEmpty()) add(remaining) +} + /** * Detects the card brand based on the card number prefix. * diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index 840cbe8e04..fe2c456ecd 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -2102,6 +2102,17 @@ class VaultItemViewModelTest : BaseViewModelTest() { @Test fun `on CopyNumberClick should call setText on the ClipboardManager`() = runTest { + val cardTypeWithFormattedNumber = DEFAULT_CARD_TYPE.copy( + number = VaultItemState.ViewState.Content.ItemType.Card.NumberData( + number = "1234 5436", + isVisible = false, + ), + ) + viewModel = createViewModel( + state = DEFAULT_STATE.copy( + viewState = createViewState(type = cardTypeWithFormattedNumber), + ), + ) every { mockCipherView.toViewState( previousState = null, @@ -2116,7 +2127,7 @@ class VaultItemViewModelTest : BaseViewModelTest() { relatedLocations = persistentListOf(), hasOrganizations = true, ) - } returns createViewState(type = DEFAULT_CARD_TYPE) + } returns createViewState(type = cardTypeWithFormattedNumber) mutableVaultItemFlow.value = DataState.Loaded(data = mockCipherView) mutableAuthCodeItemFlow.value = DataState.Loaded(data = null) mutableCollectionsStateFlow.value = DataState.Loaded(emptyList()) @@ -2129,6 +2140,8 @@ class VaultItemViewModelTest : BaseViewModelTest() { text = "12345436", toastDescriptorOverride = BitwardenString.number.asText(), ) + } + verify(atLeast = 1) { mockCipherView.toViewState( previousState = null, isPremiumUser = true, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt index a88c420003..98bc9872e0 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensionsTest.kt @@ -513,6 +513,39 @@ class CipherViewExtensionsTest { } } + @Test + fun `toViewState should format card number when transforming card content`() { + val cipherView = createCipherView(type = CipherType.CARD, isEmpty = false) + .copy( + card = createMockCardView( + number = 1, + cardNumber = "4111111111111111", + brand = VaultCardBrand.VISA.name, + ), + ) + val viewState = cipherView.toViewState( + previousState = null, + isPremiumUser = true, + totpCodeItemData = null, + clock = fixedClock, + canDelete = true, + canRestore = true, + canAssignToCollections = true, + canEdit = true, + baseIconUrl = "https://example.com/", + isIconLoadingDisabled = true, + relatedLocations = persistentListOf(), + hasOrganizations = true, + ) + + assertEquals( + "4111 1111 1111 1111", + (viewState.asContentOrNull()?.type as? VaultItemState.ViewState.Content.ItemType.Card) + ?.number + ?.number, + ) + } + private fun setupMockUri() { mockkStatic(Uri::class) val uriMock = mockk() diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtilsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtilsTest.kt index b828338f32..299bdd7958 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtilsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/util/CardNumberUtilsTest.kt @@ -104,4 +104,79 @@ class CardNumberUtilsTest { ) assertEquals(VaultCardBrand.OTHER, "".detectCardBrand()) } + + @Test + fun `formatCardNumber should format Amex correctly`() { + assertEquals("3782 822463 10005", "378282246310005".formatCardNumber()) + assertEquals("3411 111111 11111", "341111111111111".formatCardNumber()) + } + + @Test + fun `formatCardNumber should format Diners Club 14 digits correctly`() { + assertEquals("3056 930902 5904", "30569309025904".formatCardNumber()) + } + + @Test + fun `formatCardNumber should format Diners Club with non 14 digits as default`() { + assertEquals("3600 0000 0000 0084", "3600000000000084".formatCardNumber()) + } + + @Test + fun `formatCardNumber should format Maestro 13 digits correctly`() { + assertEquals("5018 5753 94858", "5018575394858".formatCardNumber()) + } + + @Test + fun `formatCardNumber should format Maestro 15 digits correctly`() { + assertEquals("5018 575394 85843", "501857539485843".formatCardNumber()) + } + + @Test + fun `formatCardNumber should format Maestro 19 digits correctly`() { + assertEquals("5018 5753 9485 8437 306", "5018575394858437306".formatCardNumber()) + } + + @Test + fun `formatCardNumber should format Maestro other digits as default`() { + assertEquals("5018 5753 9485 8437", "5018575394858437".formatCardNumber()) + } + + @Test + fun `formatCardNumber should format UnionPay 19 digits correctly`() { + assertEquals("622795 5237950556428", "6227955237950556428".formatCardNumber()) + } + + @Test + fun `formatCardNumber should format UnionPay with non 19 digits as default`() { + assertEquals("6227 9552 3795 0556", "6227955237950556".formatCardNumber()) + } + + @Test + fun `formatCardNumber should format standard card numbers in groups of 4`() { + assertEquals("4111 1111 1111 1111", "4111111111111111".formatCardNumber()) + assertEquals("5500 0000 0000 0004", "5500000000000004".formatCardNumber()) + assertEquals("6011 1111 1111 1117", "6011111111111117".formatCardNumber()) + } + + @Test + fun `formatCardNumber should format card numbers in 4 groups of 4 and append remainder`() { + assertEquals("4111 1111 1111 1111 1", "41111111111111111".formatCardNumber()) + assertEquals("5500 0000 0000 0004 0000", "55000000000000040000".formatCardNumber()) + assertEquals("6011 1111 1111 1117 11111", "601111111111111711111".formatCardNumber()) + } + + @Test + fun `formatCardNumber should keep short standard card numbers unchanged`() { + assertEquals("123", "123".formatCardNumber()) + } + + @Test + fun `formatCardNumber should return empty string for empty input`() { + assertEquals("", "".formatCardNumber()) + } + + @Test + fun `formatCardNumber should sanitize and return empty when input has no digits`() { + assertEquals("----", "----".formatCardNumber()) + } } From be1dabb9dc430ba2f7e2010dca54d03e5d3479cb Mon Sep 17 00:00:00 2001 From: David Perez Date: Tue, 28 Apr 2026 14:10:40 -0500 Subject: [PATCH 11/16] PM-30130: feat: Remove the Archive Items feature flag (#6667) --- .../feature/search/SearchViewModel.kt | 29 ----------- .../search/util/SearchTypeDataExtensions.kt | 8 ---- .../feature/addedit/VaultAddEditViewModel.kt | 31 +----------- .../vault/feature/item/VaultItemViewModel.kt | 48 ++++--------------- .../itemlisting/VaultItemListingViewModel.kt | 29 ----------- .../util/VaultItemListingDataExtensions.kt | 6 --- .../feature/util/CipherListViewExtensions.kt | 7 +-- .../ui/vault/feature/vault/VaultContent.kt | 41 +++++++--------- .../ui/vault/feature/vault/VaultViewModel.kt | 35 +------------- .../feature/vault/util/VaultDataExtensions.kt | 14 +----- .../feature/search/SearchScreenTest.kt | 1 - .../feature/search/SearchViewModelTest.kt | 16 +------ .../util/SearchTypeDataExtensionsTest.kt | 5 -- .../feature/addedit/VaultAddEditScreenTest.kt | 7 --- .../addedit/VaultAddEditViewModelTest.kt | 11 +---- .../vault/feature/item/VaultItemScreenTest.kt | 1 - .../feature/item/VaultItemViewModelTest.kt | 11 +---- .../itemlisting/VaultItemListingScreenTest.kt | 1 - .../VaultItemListingViewModelTest.kt | 11 +---- .../VaultItemListingDataExtensionsTest.kt | 18 ------- .../util/CipherListViewExtensionsTest.kt | 40 ---------------- .../ui/vault/feature/vault/VaultScreenTest.kt | 3 -- .../vault/feature/vault/VaultViewModelTest.kt | 21 -------- .../vault/util/VaultDataExtensionsTest.kt | 36 -------------- .../vault/util/VaultStateExtensionsTest.kt | 1 - .../core/data/manager/model/FlagKey.kt | 9 ---- .../core/data/manager/model/FlagKeyTest.kt | 5 -- .../components/debug/FeatureFlagListItems.kt | 2 - .../main/res/values/strings_non_localized.xml | 1 - 29 files changed, 37 insertions(+), 411 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt index db3c826f51..2e676e8a2a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt @@ -4,7 +4,6 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.annotation.OmitFromCoverage -import com.bitwarden.core.data.manager.model.FlagKey import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.data.repository.util.baseIconUrl import com.bitwarden.data.repository.util.baseWebSendUrl @@ -31,7 +30,6 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySele import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.autofill.util.login -import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager @@ -106,7 +104,6 @@ class SearchViewModel @Inject constructor( settingsRepo: SettingsRepository, snackbarRelayManager: SnackbarRelayManager, specialCircumstanceManager: SpecialCircumstanceManager, - featureFlagManager: FeatureFlagManager, ) : BaseViewModel( // We load the state from the savedStateHandle for testing purposes. initialState = savedStateHandle[KEY_STATE] @@ -142,7 +139,6 @@ class SearchViewModel @Inject constructor( hasMasterPassword = userState.activeAccount.hasMasterPassword, isPremium = userState.activeAccount.isPremium, restrictItemTypesPolicyOrgIds = persistentListOf(), - isArchiveEnabled = featureFlagManager.getFeatureFlag(FlagKey.ArchiveItems), ) }, ) { @@ -182,12 +178,6 @@ class SearchViewModel @Inject constructor( .map { SearchAction.Internal.SnackbarDataReceived(it) } .onEach(::sendAction) .launchIn(viewModelScope) - - featureFlagManager - .getFeatureFlagFlow(FlagKey.ArchiveItems) - .map { SearchAction.Internal.ArchiveItemsFlagUpdateReceive(it) } - .onEach(::sendAction) - .launchIn(viewModelScope) } override fun handleAction(action: SearchAction) { @@ -649,10 +639,6 @@ class SearchViewModel @Inject constructor( handleDecryptCipherErrorReceive(action) } - is SearchAction.Internal.ArchiveItemsFlagUpdateReceive -> { - handleArchiveItemsFlagUpdateReceive(action) - } - is SearchAction.Internal.ArchiveCipherReceive -> handleArchiveCipherReceive(action) is SearchAction.Internal.UnarchiveCipherReceive -> handleUnarchiveCipherReceive(action) } @@ -672,12 +658,6 @@ class SearchViewModel @Inject constructor( } } - private fun handleArchiveItemsFlagUpdateReceive( - action: SearchAction.Internal.ArchiveItemsFlagUpdateReceive, - ) { - mutableStateFlow.update { it.copy(isArchiveEnabled = action.isEnabled) } - } - private fun handleArchiveCipherReceive(action: SearchAction.Internal.ArchiveCipherReceive) { when (val result = action.result) { is ArchiveCipherResult.Error -> { @@ -1026,7 +1006,6 @@ class SearchViewModel @Inject constructor( isIconLoadingDisabled = state.isIconLoadingDisabled, isAutofill = state.isAutofill, isPremiumUser = state.isPremium, - isArchiveEnabled = state.isArchiveEnabled, ) } @@ -1093,7 +1072,6 @@ data class SearchState( val hasMasterPassword: Boolean, val isPremium: Boolean, val restrictItemTypesPolicyOrgIds: ImmutableList, - val isArchiveEnabled: Boolean, ) : Parcelable { /** @@ -1526,13 +1504,6 @@ sealed class SearchAction { data class DecryptCipherErrorReceive( val error: Throwable?, ) : Internal() - - /** - * Indicates that the Archive Items flag has been updated. - */ - data class ArchiveItemsFlagUpdateReceive( - val isEnabled: Boolean, - ) : Internal() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt index f6b6463ff4..cfe9da91c2 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt @@ -164,7 +164,6 @@ fun List.toViewState( isIconLoadingDisabled: Boolean, isAutofill: Boolean, isPremiumUser: Boolean, - isArchiveEnabled: Boolean, ): SearchState.ViewState = when { searchTerm.isEmpty() -> SearchState.ViewState.Empty(message = null) @@ -176,7 +175,6 @@ fun List.toViewState( isIconLoadingDisabled = isIconLoadingDisabled, isAutofill = isAutofill, isPremiumUser = isPremiumUser, - isArchiveEnabled = isArchiveEnabled, ), ) } @@ -188,14 +186,12 @@ fun List.toViewState( } } -@Suppress("LongParameterList") private fun List.toDisplayItemList( baseIconUrl: String, hasMasterPassword: Boolean, isIconLoadingDisabled: Boolean, isAutofill: Boolean, isPremiumUser: Boolean, - isArchiveEnabled: Boolean, ): ImmutableList = this .map { @@ -205,20 +201,17 @@ private fun List.toDisplayItemList( isIconLoadingDisabled = isIconLoadingDisabled, isAutofill = isAutofill, isPremiumUser = isPremiumUser, - isArchiveEnabled = isArchiveEnabled, ) } .sortAlphabetically() .toImmutableList() -@Suppress("LongParameterList") private fun CipherListView.toDisplayItem( baseIconUrl: String, hasMasterPassword: Boolean, isIconLoadingDisabled: Boolean, isAutofill: Boolean, isPremiumUser: Boolean, - isArchiveEnabled: Boolean, ): SearchState.DisplayItem = SearchState.DisplayItem( id = id.orEmpty(), @@ -234,7 +227,6 @@ private fun CipherListView.toDisplayItem( overflowOptions = toOverflowActions( hasMasterPassword = hasMasterPassword, isPremiumUser = isPremiumUser, - isArchiveEnabled = isArchiveEnabled, ), overflowTestTag = "CipherOptionsButton", totpCode = login?.totp, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index fbd762c26d..33ab5c62c0 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -184,7 +184,6 @@ class VaultAddEditViewModel @Inject constructor( } VaultAddEditState( - isArchiveEnabled = featureFlagManager.getFeatureFlag(FlagKey.ArchiveItems), isCardScannerEnabled = featureFlagManager.getFeatureFlag(FlagKey.CardScanner), vaultAddEditType = vaultAddEditType, cipherType = vaultCipherType, @@ -281,12 +280,6 @@ class VaultAddEditViewModel @Inject constructor( .onEach(::sendAction) .launchIn(viewModelScope) - featureFlagManager - .getFeatureFlagFlow(FlagKey.ArchiveItems) - .map { VaultAddEditAction.Internal.ArchiveItemsFlagUpdateReceive(it) } - .onEach(::sendAction) - .launchIn(viewModelScope) - featureFlagManager .getFeatureFlagFlow(FlagKey.CardScanner) .map { VaultAddEditAction.Internal.CardScannerFlagUpdateReceive(it) } @@ -1706,10 +1699,6 @@ class VaultAddEditViewModel @Inject constructor( handleUnarchiveCipherReceive(action) } - is VaultAddEditAction.Internal.ArchiveItemsFlagUpdateReceive -> { - handleArchiveItemsFlagUpdateReceive(action) - } - is VaultAddEditAction.Internal.CardScannerFlagUpdateReceive -> { handleCardScannerFlagUpdateReceive(action) } @@ -1929,12 +1918,6 @@ class VaultAddEditViewModel @Inject constructor( } } - private fun handleArchiveItemsFlagUpdateReceive( - action: VaultAddEditAction.Internal.ArchiveItemsFlagUpdateReceive, - ) { - mutableStateFlow.update { it.copy(isArchiveEnabled = action.isEnabled) } - } - private fun handleCardScannerFlagUpdateReceive( action: VaultAddEditAction.Internal.CardScannerFlagUpdateReceive, ) { @@ -2544,7 +2527,6 @@ data class VaultAddEditState( val createCredentialRequest: CreateCredentialRequest? = null, val defaultUriMatchType: UriMatchType, private val shouldShowCoachMarkTour: Boolean, - private val isArchiveEnabled: Boolean, val isCardScannerEnabled: Boolean, ) : Parcelable { @@ -2602,8 +2584,7 @@ data class VaultAddEditState( * Helper to determine if the UI should display the archive button. */ val displayArchiveButton: Boolean - get() = isArchiveEnabled && - isEditItemMode && + get() = isEditItemMode && (viewState as? ViewState.Content) ?.common ?.originalCipher @@ -2613,8 +2594,7 @@ data class VaultAddEditState( * Helper to determine if the UI should display the unarchive button. */ val displayUnarchiveButton: Boolean - get() = isArchiveEnabled && - isEditItemMode && + get() = isEditItemMode && (viewState as? ViewState.Content) ?.common ?.originalCipher @@ -3992,13 +3972,6 @@ sealed class VaultAddEditAction { val folderData: DataState>, ) : Internal() - /** - * Indicates that the Archive Items flag has been updated. - */ - data class ArchiveItemsFlagUpdateReceive( - val isEnabled: Boolean, - ) : Internal() - /** * Indicates that the Card Scanner flag has been updated. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index e89707a5bc..1c8aade4a8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -4,7 +4,6 @@ import android.net.Uri import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.bitwarden.core.data.manager.model.FlagKey import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.core.data.repository.util.combineDataStates import com.bitwarden.core.data.repository.util.mapNullable @@ -25,10 +24,9 @@ import com.bitwarden.ui.util.asText import com.bitwarden.ui.util.concat import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.data.auth.repository.AuthRepository -import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState -import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent @@ -84,7 +82,6 @@ class VaultItemViewModel @Inject constructor( private val settingsRepository: SettingsRepository, private val snackbarRelayManager: SnackbarRelayManager, private val premiumStateManager: PremiumStateManager, - featureFlagManager: FeatureFlagManager, ) : BaseViewModel( // We load the state from the savedStateHandle for testing purposes. initialState = savedStateHandle[KEY_STATE] ?: run { @@ -97,7 +94,6 @@ class VaultItemViewModel @Inject constructor( baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl, isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled, hasPremium = authRepository.userStateFlow.value?.activeAccount?.isPremium == true, - isArchiveEnabled = featureFlagManager.getFeatureFlag(FlagKey.ArchiveItems), ) }, ) { @@ -234,12 +230,6 @@ class VaultItemViewModel @Inject constructor( .map { VaultItemAction.Internal.SnackbarDataReceived(it) } .onEach(::sendAction) .launchIn(viewModelScope) - - featureFlagManager - .getFeatureFlagFlow(FlagKey.ArchiveItems) - .map { VaultItemAction.Internal.ArchiveItemsFlagUpdateReceive(it) } - .onEach(::sendAction) - .launchIn(viewModelScope) } override fun handleAction(action: VaultItemAction) { @@ -1073,10 +1063,6 @@ class VaultItemViewModel @Inject constructor( handleIsIconLoadingDisabledUpdateReceive(action) } - is VaultItemAction.Internal.ArchiveItemsFlagUpdateReceive -> { - handleArchiveItemsFlagUpdateReceive(action) - } - is VaultItemAction.Internal.ArchiveCipherReceive -> handleArchiveCipherReceive(action) is VaultItemAction.Internal.UnarchiveCipherReceive -> { handleUnarchiveCipherReceive(action) @@ -1312,12 +1298,6 @@ class VaultItemViewModel @Inject constructor( mutableStateFlow.update { it.copy(isIconLoadingDisabled = action.isDisabled) } } - private fun handleArchiveItemsFlagUpdateReceive( - action: VaultItemAction.Internal.ArchiveItemsFlagUpdateReceive, - ) { - mutableStateFlow.update { it.copy(isArchiveEnabled = action.isEnabled) } - } - private fun handleArchiveCipherReceive(action: VaultItemAction.Internal.ArchiveCipherReceive) { when (val result = action.result) { is ArchiveCipherResult.Error -> { @@ -1456,7 +1436,6 @@ data class VaultItemState( val dialog: DialogState?, val baseIconUrl: String, val isIconLoadingDisabled: Boolean, - val isArchiveEnabled: Boolean, val hasPremium: Boolean, ) : Parcelable { @@ -1523,21 +1502,19 @@ data class VaultItemState( * Helper to determine if the UI should display the archive button. */ val displayArchiveButton: Boolean - get() = isArchiveEnabled && - viewState.asContentOrNull() - ?.common - ?.currentCipher - ?.isActive == true + get() = viewState.asContentOrNull() + ?.common + ?.currentCipher + ?.isActive == true /** * Helper to determine if the UI should display the unarchive button. */ val displayUnarchiveButton: Boolean - get() = isArchiveEnabled && - viewState.asContentOrNull() - ?.common - ?.currentCipher - ?.let { it.archivedDate != null && it.deletedDate == null } == true + get() = viewState.asContentOrNull() + ?.common + ?.currentCipher + ?.let { it.archivedDate != null && it.deletedDate == null } == true val canAssignToCollections: Boolean get() = viewState.asContentOrNull() @@ -2387,13 +2364,6 @@ sealed class VaultItemAction { val data: BitwardenSnackbarData, ) : Internal() - /** - * Indicates that the Archive Items flag has been updated. - */ - data class ArchiveItemsFlagUpdateReceive( - val isEnabled: Boolean, - ) : Internal() - /** * Indicates that the archive cipher result has been received. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index 8bcf1e5cfd..d480251aeb 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -11,7 +11,6 @@ import androidx.credentials.provider.ProviderCreateCredentialRequest import androidx.credentials.provider.ProviderGetCredentialRequest import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import com.bitwarden.core.data.manager.model.FlagKey import com.bitwarden.core.data.manager.toast.ToastManager import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.core.data.repository.util.map @@ -59,7 +58,6 @@ import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult import com.x8bit.bitwarden.data.credentials.parser.RelyingPartyParser import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository import com.x8bit.bitwarden.data.credentials.util.getCreatePasskeyCredentialRequestOrNull -import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager @@ -154,7 +152,6 @@ class VaultItemListingViewModel @Inject constructor( private val toastManager: ToastManager, private val premiumStateManager: PremiumStateManager, snackbarRelayManager: SnackbarRelayManager, - featureFlagManager: FeatureFlagManager, ) : BaseViewModel( initialState = run { val userState = requireNotNull(authRepository.userStateFlow.value) @@ -196,7 +193,6 @@ class VaultItemListingViewModel @Inject constructor( getCredentialsRequest = providerGetCredentialsRequest, isPremium = userState.activeAccount.isPremium, isRefreshing = false, - isArchiveEnabled = featureFlagManager.getFeatureFlag(FlagKey.ArchiveItems), ) }, ) { @@ -244,12 +240,6 @@ class VaultItemListingViewModel @Inject constructor( .onEach(::sendAction) .launchIn(viewModelScope) - featureFlagManager - .getFeatureFlagFlow(FlagKey.ArchiveItems) - .map { VaultItemListingsAction.Internal.ArchiveItemsFlagUpdateReceive(it) } - .onEach(::sendAction) - .launchIn(viewModelScope) - specialCircumstanceManager.specialCircumstance ?.toCreateCredentialRequestOrNull() ?.let { request -> @@ -1695,10 +1685,6 @@ class VaultItemListingViewModel @Inject constructor( handleCredentialOperationFailureReceive(action) } - is VaultItemListingsAction.Internal.ArchiveItemsFlagUpdateReceive -> { - handleArchiveItemsFlagUpdateReceive(action) - } - is VaultItemListingsAction.Internal.ArchiveCipherReceive -> { handleArchiveCipherReceive(action) } @@ -1719,12 +1705,6 @@ class VaultItemListingViewModel @Inject constructor( ) } - private fun handleArchiveItemsFlagUpdateReceive( - action: VaultItemListingsAction.Internal.ArchiveItemsFlagUpdateReceive, - ) { - mutableStateFlow.update { it.copy(isArchiveEnabled = action.isEnabled) } - } - private fun handleArchiveCipherReceive( action: VaultItemListingsAction.Internal.ArchiveCipherReceive, ) { @@ -2674,7 +2654,6 @@ class VaultItemListingViewModel @Inject constructor( totpData = state.totpData, isPremiumUser = state.isPremium, restrictItemTypesPolicyOrgIds = state.restrictItemTypesPolicyOrgIds, - isArchiveEnabled = state.isArchiveEnabled, ) } @@ -2882,7 +2861,6 @@ data class VaultItemListingState( val hasMasterPassword: Boolean, val isPremium: Boolean, val isRefreshing: Boolean, - val isArchiveEnabled: Boolean, ) { /** * Indicates what action card to display. @@ -3977,13 +3955,6 @@ sealed class VaultItemListingsAction { val data: BitwardenSnackbarData, ) : Internal(), BackgroundEvent - /** - * Indicates that the Archive Items flag has been updated. - */ - data class ArchiveItemsFlagUpdateReceive( - val isEnabled: Boolean, - ) : Internal() - /** * Indicates that an error occurred while decrypting a cipher. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt index ad33d2cec9..7fad0ccf9b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt @@ -119,7 +119,6 @@ fun VaultData.toViewState( totpData: TotpData?, isPremiumUser: Boolean, restrictItemTypesPolicyOrgIds: List, - isArchiveEnabled: Boolean, ): VaultItemListingState.ViewState { val filteredCipherViewList = decryptCipherListResult .successes @@ -168,7 +167,6 @@ fun VaultData.toViewState( isAutofill = autofillSelectionData != null, isFido2Creation = createCredentialRequestData != null, isPremiumUser = isPremiumUser, - isArchiveEnabled = isArchiveEnabled, ), ), displayFolderList = folderList.map { folderView -> @@ -410,7 +408,6 @@ private fun List.toDisplayItemList( isAutofill: Boolean, isFido2Creation: Boolean, isPremiumUser: Boolean, - isArchiveEnabled: Boolean, ): List = this.map { it.toDisplayItem( @@ -420,7 +417,6 @@ private fun List.toDisplayItemList( isAutofill = isAutofill, isFido2Creation = isFido2Creation, isPremiumUser = isPremiumUser, - isArchiveEnabled = isArchiveEnabled, ) } @@ -443,7 +439,6 @@ private fun CipherListView.toDisplayItem( isAutofill: Boolean, isFido2Creation: Boolean, isPremiumUser: Boolean, - isArchiveEnabled: Boolean, ): VaultItemListingState.DisplayItem = VaultItemListingState.DisplayItem( id = id.orEmpty(), @@ -469,7 +464,6 @@ private fun CipherListView.toDisplayItem( overflowOptions = this.toOverflowActions( hasMasterPassword = hasMasterPassword, isPremiumUser = isPremiumUser, - isArchiveEnabled = isArchiveEnabled, ), optionsTestTag = "CipherOptionsButton", isAutofill = isAutofill, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/util/CipherListViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/util/CipherListViewExtensions.kt index 7f3974a33d..1325781f96 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/util/CipherListViewExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/util/CipherListViewExtensions.kt @@ -20,7 +20,6 @@ import kotlinx.collections.immutable.toImmutableList fun CipherListView.toOverflowActions( hasMasterPassword: Boolean, isPremiumUser: Boolean, - isArchiveEnabled: Boolean, ): ImmutableList = this .id @@ -89,11 +88,9 @@ fun CipherListView.toOverflowActions( ListingItemOverflowAction.VaultAction.LaunchClick(url = it) }, ListingItemOverflowAction.VaultAction.ArchiveClick(cipherId = cipherId) - .takeIf { this.isActive && isArchiveEnabled }, + .takeIf { this.isActive }, ListingItemOverflowAction.VaultAction.UnarchiveClick(cipherId = cipherId) - .takeIf { - this.archivedDate != null && deletedDate == null && isArchiveEnabled - }, + .takeIf { this.archivedDate != null && deletedDate == null }, ) } .orEmpty() diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt index 3689fc4637..aa460b0ad7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt @@ -35,7 +35,6 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers private const val TOTP_TYPES_COUNT: Int = 1 private const val HIDDEN_TYPES_COUNT: Int = 2 -private const val TRASH_TYPES_COUNT: Int = 1 /** * Content view for the [VaultScreen]. @@ -454,11 +453,7 @@ fun VaultContent( item(key = "hidden_items_header") { BitwardenListHeaderText( label = stringResource(id = BitwardenString.hidden_items), - supportingLabel = if (state.archiveEnabled) { - HIDDEN_TYPES_COUNT.toString() - } else { - TRASH_TYPES_COUNT.toString() - }, + supportingLabel = HIDDEN_TYPES_COUNT.toString(), modifier = Modifier .animateItem() .fillMaxWidth() @@ -468,23 +463,21 @@ fun VaultContent( Spacer(modifier = Modifier.height(height = 8.dp)) } - if (state.archiveEnabled) { - item(key = "archive_group") { - BitwardenGroupItem( - startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_archive), - endIcon = state.archiveEndIcon?.let { IconData.Local(iconRes = it) }, - label = stringResource(id = BitwardenString.archive_noun), - subLabel = state.archiveSubText?.invoke(), - supportingLabel = state.archivedItemsCount?.toString().orEmpty(), - onClick = vaultHandlers.archiveClick, - cardStyle = CardStyle.Top(dividerPadding = 56.dp), - modifier = Modifier - .animateItem() - .fillMaxWidth() - .testTag(tag = "ArchiveFilter") - .standardHorizontalMargin(), - ) - } + item(key = "archive_group") { + BitwardenGroupItem( + startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_archive), + endIcon = state.archiveEndIcon?.let { IconData.Local(iconRes = it) }, + label = stringResource(id = BitwardenString.archive_noun), + subLabel = state.archiveSubText?.invoke(), + supportingLabel = state.archivedItemsCount?.toString().orEmpty(), + onClick = vaultHandlers.archiveClick, + cardStyle = CardStyle.Top(dividerPadding = 56.dp), + modifier = Modifier + .animateItem() + .fillMaxWidth() + .testTag(tag = "ArchiveFilter") + .standardHorizontalMargin(), + ) } item(key = "trash_group") { @@ -493,7 +486,7 @@ fun VaultContent( label = stringResource(id = BitwardenString.trash), supportingLabel = state.trashItemsCount.toString(), onClick = vaultHandlers.trashClick, - cardStyle = if (state.archiveEnabled) CardStyle.Bottom else CardStyle.Full, + cardStyle = CardStyle.Bottom, modifier = Modifier .animateItem() .fillMaxWidth() diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 3bcf386caa..19bedf5f9d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -145,7 +145,6 @@ class VaultViewModel @Inject constructor( viewState = VaultState.ViewState.Loading, isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled, isPremium = activeAccount.isPremium, - isArchiveEnabled = featureFlagManager.getFeatureFlag(FlagKey.ArchiveItems), isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value, baseIconUrl = activeAccount.environment.environmentUrlData.baseIconUrl, hasMasterPassword = activeAccount.hasMasterPassword, @@ -273,11 +272,6 @@ class VaultViewModel @Inject constructor( .map { VaultAction.Internal.CredentialExchangeProtocolExportFlagUpdateReceive(it) } .onEach(::sendAction) .launchIn(viewModelScope) - featureFlagManager - .getFeatureFlagFlow(FlagKey.ArchiveItems) - .map { VaultAction.Internal.ArchiveItemsFlagUpdateReceive(it) } - .onEach(::sendAction) - .launchIn(viewModelScope) viewModelScope.launch { delay(timeMillis = BROWSER_AUTOFILL_DIALOG_DELAY) @@ -990,10 +984,6 @@ class VaultViewModel @Inject constructor( handleCredentialExchangeProtocolExportFlagUpdateReceive(action) } - is VaultAction.Internal.ArchiveItemsFlagUpdateReceive -> { - handleArchiveItemsFlagUpdateReceive(action) - } - is VaultAction.Internal.ArchiveCipherReceive -> handleArchiveCipherReceive(action) is VaultAction.Internal.UnarchiveCipherReceive -> handleUnarchiveCipherReceive(action) is VaultAction.Internal.IntroducingArchiveActionCardDismissedFlowReceive -> { @@ -1074,12 +1064,6 @@ class VaultViewModel @Inject constructor( } } - private fun handleArchiveItemsFlagUpdateReceive( - action: VaultAction.Internal.ArchiveItemsFlagUpdateReceive, - ) { - mutableStateFlow.update { it.copy(isArchiveEnabled = action.isEnabled) } - } - private fun handleArchiveCipherReceive(action: VaultAction.Internal.ArchiveCipherReceive) { when (val result = action.result) { is ArchiveCipherResult.Error -> { @@ -1282,7 +1266,6 @@ class VaultViewModel @Inject constructor( ?: BitwardenString.generic_error_message.asText(), isRefreshing = false, restrictItemTypesPolicyOrgIds = state.restrictItemTypesPolicyOrgIds, - isArchiveEnabled = state.isArchiveEnabled, ) } @@ -1347,7 +1330,6 @@ class VaultViewModel @Inject constructor( hasMasterPassword = state.hasMasterPassword, vaultFilterType = vaultFilterTypeOrDefault, restrictItemTypesPolicyOrgIds = state.restrictItemTypesPolicyOrgIds, - isArchiveEnabled = state.isArchiveEnabled, ), dialog = dialog, isRefreshing = false, @@ -1392,7 +1374,6 @@ class VaultViewModel @Inject constructor( hasMasterPassword = state.hasMasterPassword, vaultFilterType = vaultFilterTypeOrDefault, restrictItemTypesPolicyOrgIds = state.restrictItemTypesPolicyOrgIds, - isArchiveEnabled = state.isArchiveEnabled, ), ) } @@ -1544,7 +1525,6 @@ data class VaultState( val flightRecorderSnackBar: BitwardenSnackbarData?, // Internal-use properties val isSwitchingAccounts: Boolean = false, - val isArchiveEnabled: Boolean, val isPremium: Boolean, val hasMasterPassword: Boolean, private val isPullToRefreshSettingEnabled: Boolean, @@ -1566,9 +1546,7 @@ data class VaultState( ActionCardState.UpgradePremium .takeIf { isPremiumUpgradeBannerEligible } ?: ActionCardState.IntroducingArchive.takeIf { - isPremium && - !isIntroducingArchiveActionCardDismissed && - isArchiveEnabled + isPremium && !isIntroducingArchiveActionCardDismissed } } @@ -1655,7 +1633,6 @@ data class VaultState( * @property collectionItems The list of collections to be displayed. * @property trashItemsCount The number of items present in the trash. * @property archivedItemsCount The number of items present in archive. - * @property archiveEnabled Is the archive feature enabled. * @property archiveSubText The subtext to be displayed on the archive item. * @property archiveEndIcon The end icon to be displayed on the archive item. * @property showCardGroup Is the card group available for display. @@ -1675,7 +1652,6 @@ data class VaultState( val collectionItems: List, val trashItemsCount: Int, val archivedItemsCount: Int?, - val archiveEnabled: Boolean, val archiveSubText: Text?, @field:DrawableRes val archiveEndIcon: Int?, val showCardGroup: Boolean, @@ -2437,13 +2413,6 @@ sealed class VaultAction { val isCredentialExchangeProtocolExportEnabled: Boolean, ) : Internal() - /** - * Indicates that the Archive Items flag has been updated. - */ - data class ArchiveItemsFlagUpdateReceive( - val isEnabled: Boolean, - ) : Internal() - /** * Indicates that the archive cipher result has been received. */ @@ -2487,7 +2456,6 @@ private fun MutableStateFlow.updateToErrorStateOrDialog( errorMessage: Text, isRefreshing: Boolean, restrictItemTypesPolicyOrgIds: List, - isArchiveEnabled: Boolean, ) { this.update { if (vaultData != null) { @@ -2499,7 +2467,6 @@ private fun MutableStateFlow.updateToErrorStateOrDialog( vaultFilterType = vaultFilterType, isIconLoadingDisabled = isIconLoadingDisabled, restrictItemTypesPolicyOrgIds = restrictItemTypesPolicyOrgIds, - isArchiveEnabled = isArchiveEnabled, ), dialog = VaultState.DialogState.Error( title = errorTitle, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt index 39362f92e5..128f1cc99c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt @@ -46,7 +46,6 @@ fun VaultData.toViewState( baseIconUrl: String, vaultFilterType: VaultFilterType, restrictItemTypesPolicyOrgIds: List, - isArchiveEnabled: Boolean, ): VaultState.ViewState { val allCipherViews = decryptCipherListResult @@ -111,7 +110,6 @@ fun VaultData.toViewState( baseIconUrl = baseIconUrl, isPremiumUser = isPremium, hasDecryptionError = false, - isArchiveEnabled = isArchiveEnabled, ) } .plus( @@ -124,7 +122,6 @@ fun VaultData.toViewState( baseIconUrl = baseIconUrl, isPremiumUser = isPremium, hasDecryptionError = true, - isArchiveEnabled = isArchiveEnabled, ) }, ) @@ -159,7 +156,6 @@ fun VaultData.toViewState( baseIconUrl = baseIconUrl, isPremiumUser = isPremium, hasDecryptionError = false, - isArchiveEnabled = isArchiveEnabled, ) } .plus( @@ -172,7 +168,6 @@ fun VaultData.toViewState( baseIconUrl = baseIconUrl, isPremiumUser = isPremium, hasDecryptionError = true, - isArchiveEnabled = isArchiveEnabled, ) }, ), @@ -219,7 +214,6 @@ fun VaultData.toViewState( }, trashItemsCount = allCipherViews.count { it.deletedDate != null }, archivedItemsCount = archiveCount.takeIf { isPremium || archiveCount > 0 }, - archiveEnabled = isArchiveEnabled, archiveEndIcon = BitwardenDrawable.ic_locked.takeIf { !isPremium && archiveCount == 0 }, archiveSubText = BitwardenString .premium_subscription_required @@ -279,14 +273,13 @@ fun List?.toLoginIconData( /** * Transforms a [CipherListView] into a [VaultState.ViewState.VaultItem]. */ -@Suppress("MagicNumber", "LongMethod", "CyclomaticComplexMethod", "LongParameterList") +@Suppress("MagicNumber", "LongMethod", "CyclomaticComplexMethod") private fun CipherListView.toVaultItemOrNull( hasMasterPassword: Boolean, isIconLoadingDisabled: Boolean, baseIconUrl: String, isPremiumUser: Boolean, hasDecryptionError: Boolean, - isArchiveEnabled: Boolean, ): VaultState.ViewState.VaultItem? { val id = this.id ?: return null return when (type) { @@ -309,7 +302,6 @@ private fun CipherListView.toVaultItemOrNull( toOverflowActions( hasMasterPassword = hasMasterPassword, isPremiumUser = isPremiumUser, - isArchiveEnabled = isArchiveEnabled, ) }, extraIconList = toLabelIcons(), @@ -324,7 +316,6 @@ private fun CipherListView.toVaultItemOrNull( overflowOptions = toOverflowActions( hasMasterPassword = hasMasterPassword, isPremiumUser = isPremiumUser, - isArchiveEnabled = isArchiveEnabled, ), extraIconList = toLabelIcons(), shouldShowMasterPasswordReprompt = hasMasterPassword && @@ -340,7 +331,6 @@ private fun CipherListView.toVaultItemOrNull( overflowOptions = toOverflowActions( hasMasterPassword = hasMasterPassword, isPremiumUser = isPremiumUser, - isArchiveEnabled = isArchiveEnabled, ), extraIconList = toLabelIcons(), shouldShowMasterPasswordReprompt = hasMasterPassword && @@ -355,7 +345,6 @@ private fun CipherListView.toVaultItemOrNull( overflowOptions = toOverflowActions( hasMasterPassword = hasMasterPassword, isPremiumUser = isPremiumUser, - isArchiveEnabled = isArchiveEnabled, ), extraIconList = toLabelIcons(), shouldShowMasterPasswordReprompt = hasMasterPassword && @@ -370,7 +359,6 @@ private fun CipherListView.toVaultItemOrNull( overflowOptions = toOverflowActions( hasMasterPassword = hasMasterPassword, isPremiumUser = isPremiumUser, - isArchiveEnabled = isArchiveEnabled, ), shouldShowMasterPasswordReprompt = hasMasterPassword && reprompt == CipherRepromptType.PASSWORD, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt index dc69959c61..26f2a35ebe 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt @@ -1107,7 +1107,6 @@ private val DEFAULT_STATE: SearchState = SearchState( autofillSelectionData = null, isPremium = true, restrictItemTypesPolicyOrgIds = persistentListOf(), - isArchiveEnabled = true, ) private fun createStateForAutofill( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt index e66e3f2b0a..40399509a6 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import app.cash.turbine.turbineScope import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager -import com.bitwarden.core.data.manager.model.FlagKey import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.data.repository.model.Environment @@ -26,7 +25,6 @@ import com.bitwarden.vault.CipherView import com.bitwarden.vault.LoginUriView import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.AuthRepository -import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySelectionManager @@ -34,7 +32,7 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySele import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManagerImpl import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData -import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl @@ -166,11 +164,6 @@ class SearchViewModelTest : BaseViewModelTest() { private val premiumStateManager: PremiumStateManager = mockk { every { isInAppUpgradeAvailable() } returns false } - private val mutableArchiveItemsFlow = MutableStateFlow(true) - private val featureFlagManager: FeatureFlagManager = mockk { - every { getFeatureFlag(FlagKey.ArchiveItems) } answers { mutableArchiveItemsFlow.value } - every { getFeatureFlagFlow(FlagKey.ArchiveItems) } returns mutableArchiveItemsFlow - } @BeforeEach fun setup() { @@ -1482,7 +1475,6 @@ class SearchViewModelTest : BaseViewModelTest() { isAutofill = false, hasMasterPassword = true, isPremiumUser = true, - isArchiveEnabled = true, ) } returns expectedViewState val dataState = DataState.Loaded( @@ -1594,7 +1586,6 @@ class SearchViewModelTest : BaseViewModelTest() { isAutofill = false, hasMasterPassword = true, isPremiumUser = true, - isArchiveEnabled = true, ) } returns expectedViewState mutableVaultDataStateFlow.tryEmit( @@ -1713,7 +1704,6 @@ class SearchViewModelTest : BaseViewModelTest() { isAutofill = false, hasMasterPassword = true, isPremiumUser = true, - isArchiveEnabled = true, ) } returns expectedViewState val dataState = DataState.Error( @@ -1835,7 +1825,6 @@ class SearchViewModelTest : BaseViewModelTest() { isAutofill = false, hasMasterPassword = true, isPremiumUser = true, - isArchiveEnabled = true, ) } returns expectedViewState val dataState = DataState.NoNetwork( @@ -2034,7 +2023,6 @@ class SearchViewModelTest : BaseViewModelTest() { organizationEventManager = organizationEventManager, premiumStateManager = premiumStateManager, snackbarRelayManager = snackbarRelayManager, - featureFlagManager = featureFlagManager, ) /** @@ -2070,7 +2058,6 @@ class SearchViewModelTest : BaseViewModelTest() { isAutofill = true, hasMasterPassword = true, isPremiumUser = true, - isArchiveEnabled = true, ) } returns expectedViewState val dataState = DataState.Loaded( @@ -2115,7 +2102,6 @@ private val DEFAULT_STATE: SearchState = SearchState( autofillSelectionData = null, isPremium = true, restrictItemTypesPolicyOrgIds = persistentListOf(), - isArchiveEnabled = true, ) private val DEFAULT_ACCOUNT = UserState.Account( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensionsTest.kt index ef5c5f414f..27b97d9bf4 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensionsTest.kt @@ -376,7 +376,6 @@ class SearchTypeDataExtensionsTest { isAutofill = false, hasMasterPassword = true, isPremiumUser = true, - isArchiveEnabled = true, ) assertEquals(SearchState.ViewState.Empty(message = null), result) @@ -402,7 +401,6 @@ class SearchTypeDataExtensionsTest { isAutofill = false, hasMasterPassword = true, isPremiumUser = true, - isArchiveEnabled = true, ) assertEquals( @@ -445,7 +443,6 @@ class SearchTypeDataExtensionsTest { isAutofill = true, hasMasterPassword = true, isPremiumUser = true, - isArchiveEnabled = true, ) assertEquals( @@ -496,7 +493,6 @@ class SearchTypeDataExtensionsTest { isAutofill = false, hasMasterPassword = true, isPremiumUser = true, - isArchiveEnabled = true, ) assertEquals( @@ -531,7 +527,6 @@ class SearchTypeDataExtensionsTest { hasMasterPassword = true, isAutofill = false, isPremiumUser = true, - isArchiveEnabled = true, ) assertEquals( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt index f92196f14c..733a903f3d 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt @@ -4651,7 +4651,6 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { shouldShowCoachMarkTour = false, defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = false, - isArchiveEnabled = true, isCardScannerEnabled = false, ) @@ -4668,7 +4667,6 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { shouldShowCoachMarkTour = false, defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = false, - isArchiveEnabled = true, isCardScannerEnabled = false, ) @@ -4685,7 +4683,6 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { shouldShowCoachMarkTour = false, defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = false, - isArchiveEnabled = true, isCardScannerEnabled = false, ) @@ -4702,7 +4699,6 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { shouldShowCoachMarkTour = false, defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = false, - isArchiveEnabled = true, isCardScannerEnabled = false, ) @@ -4729,7 +4725,6 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { shouldShowCoachMarkTour = false, defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = false, - isArchiveEnabled = true, isCardScannerEnabled = false, ) @@ -4746,7 +4741,6 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { shouldShowCoachMarkTour = false, defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = false, - isArchiveEnabled = true, isCardScannerEnabled = false, ) @@ -4763,7 +4757,6 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { shouldShowCoachMarkTour = false, defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = false, - isArchiveEnabled = true, isCardScannerEnabled = false, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index 1afc450ec1..17e62d60cd 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -34,7 +34,6 @@ import com.bitwarden.vault.FolderView import com.bitwarden.vault.UriMatchType import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.AuthRepository -import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult @@ -43,6 +42,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.auth.repository.model.createMockOrganization import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData +import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest import com.x8bit.bitwarden.data.credentials.model.Fido2RegisterCredentialResult @@ -230,16 +230,12 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { private val premiumStateManager: PremiumStateManager = mockk { every { isInAppUpgradeAvailable() } returns false } - private val mutableArchiveItemsFlow = MutableStateFlow(true) private val mutableCardScannerFlow = MutableStateFlow(false) - private val mutableCardScanResultFlow = - bufferedMutableSharedFlow() + private val mutableCardScanResultFlow = bufferedMutableSharedFlow() private val cardScanManager: CardScanManager = mockk { every { cardScanResultFlow } returns mutableCardScanResultFlow } private val featureFlagManager: FeatureFlagManager = mockk { - every { getFeatureFlag(FlagKey.ArchiveItems) } answers { mutableArchiveItemsFlow.value } - every { getFeatureFlagFlow(FlagKey.ArchiveItems) } returns mutableArchiveItemsFlow every { getFeatureFlag(FlagKey.CardScanner) } answers { mutableCardScannerFlow.value } every { getFeatureFlagFlow(FlagKey.CardScanner) } returns mutableCardScannerFlow } @@ -283,7 +279,6 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { shouldShowCoachMarkTour = false, defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = true, - isArchiveEnabled = true, isCardScannerEnabled = false, ) val viewModel = createAddVaultItemViewModel( @@ -373,7 +368,6 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { shouldShowCoachMarkTour = false, defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = true, - isArchiveEnabled = true, isCardScannerEnabled = false, ), viewModel.stateFlow.value, @@ -5456,7 +5450,6 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { createCredentialRequest = createCredentialRequest, defaultUriMatchType = UriMatchTypeModel.EXACT, hasPremium = hasPremium, - isArchiveEnabled = true, isCardScannerEnabled = false, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index 056c237203..15cc936121 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -3351,7 +3351,6 @@ private val DEFAULT_STATE: VaultItemState = VaultItemState( baseIconUrl = "https://example.com/", isIconLoadingDisabled = true, hasPremium = false, - isArchiveEnabled = true, ) private val DEFAULT_COMMON: VaultItemState.ViewState.Content.Common = diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index fe2c456ecd..2435d23e16 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -4,7 +4,6 @@ import android.net.Uri import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.collections.CollectionView -import com.bitwarden.core.data.manager.model.FlagKey import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.data.manager.file.FileManager @@ -26,11 +25,10 @@ import com.bitwarden.vault.CipherView import com.bitwarden.vault.FolderView import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.AuthRepository -import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.createMockOrganization -import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState @@ -136,11 +134,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { private val premiumStateManager: PremiumStateManager = mockk { every { isInAppUpgradeAvailable() } returns false } - private val mutableArchiveItemsFlow = MutableStateFlow(true) - private val featureFlagManager: FeatureFlagManager = mockk { - every { getFeatureFlag(FlagKey.ArchiveItems) } answers { mutableArchiveItemsFlow.value } - every { getFeatureFlagFlow(FlagKey.ArchiveItems) } returns mutableArchiveItemsFlow - } @BeforeEach fun setup() { @@ -3001,7 +2994,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { settingsRepository = settingsRepository, snackbarRelayManager = snackbarRelayManager, premiumStateManager = premiumStateManager, - featureFlagManager = featureFlagManager, ) private fun createViewState( @@ -3040,7 +3032,6 @@ class VaultItemViewModelTest : BaseViewModelTest() { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, isIconLoadingDisabled = false, hasPremium = true, - isArchiveEnabled = true, ) private val DEFAULT_USER_ACCOUNT = UserState.Account( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index 84f4044742..48f0d5cbde 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -2643,7 +2643,6 @@ private val DEFAULT_STATE = VaultItemListingState( isPremium = false, isRefreshing = false, restrictItemTypesPolicyOrgIds = persistentListOf(), - isArchiveEnabled = true, ) private val STATE_FOR_AUTOFILL = DEFAULT_STATE.copy( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index dffa03d681..3448d109f0 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -16,7 +16,6 @@ import androidx.credentials.provider.PublicKeyCredentialEntry import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager -import com.bitwarden.core.data.manager.model.FlagKey import com.bitwarden.core.data.manager.toast.ToastManager import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow @@ -46,7 +45,6 @@ import com.bitwarden.vault.CipherRepromptType import com.bitwarden.vault.CipherType import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.repository.AuthRepository -import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState @@ -59,6 +57,7 @@ import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManagerImpl import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData +import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager import com.x8bit.bitwarden.data.credentials.manager.OriginManager import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest @@ -72,7 +71,6 @@ import com.x8bit.bitwarden.data.credentials.model.createMockGetCredentialsReques import com.x8bit.bitwarden.data.credentials.model.createMockProviderGetPasswordCredentialRequest import com.x8bit.bitwarden.data.credentials.parser.RelyingPartyParser import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository -import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl @@ -302,11 +300,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { private val premiumStateManager: PremiumStateManager = mockk { every { isInAppUpgradeAvailable() } returns false } - private val mutableArchiveItemsFlow = MutableStateFlow(true) - private val featureFlagManager: FeatureFlagManager = mockk { - every { getFeatureFlag(FlagKey.ArchiveItems) } answers { mutableArchiveItemsFlow.value } - every { getFeatureFlagFlow(FlagKey.ArchiveItems) } returns mutableArchiveItemsFlow - } @BeforeEach fun setUp() { @@ -6199,7 +6192,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { toastManager = toastManager, premiumStateManager = premiumStateManager, relyingPartyParser = relyingPartyParser, - featureFlagManager = featureFlagManager, ) @Suppress("MaxLineLength") @@ -6228,7 +6220,6 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { isPremium = isPremium, isRefreshing = false, restrictItemTypesPolicyOrgIds = persistentListOf(), - isArchiveEnabled = true, ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt index a3e9820b2f..6981eca1c3 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt @@ -579,7 +579,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -676,7 +675,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -763,7 +761,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -833,7 +830,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ), ) @@ -857,7 +853,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ), ) @@ -879,7 +874,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ), ) @@ -902,7 +896,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ), ) @@ -924,7 +917,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ), ) @@ -946,7 +938,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ), ) @@ -968,7 +959,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ), ) @@ -992,7 +982,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ), ) @@ -1018,7 +1007,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ), ) @@ -1044,7 +1032,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ), ) @@ -1071,7 +1058,6 @@ class VaultItemListingDataExtensionsTest { }, isPremiumUser = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ), ) } @@ -1326,7 +1312,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -1374,7 +1359,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -1454,7 +1438,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = listOf("restrict_item_type_policy_id"), - isArchiveEnabled = true, ) assertEquals( @@ -1504,7 +1487,6 @@ class VaultItemListingDataExtensionsTest { totpData = null, isPremiumUser = true, restrictItemTypesPolicyOrgIds = listOf("restrict_item_type_policy_id"), - isArchiveEnabled = true, ) // Card type diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/util/CipherListViewExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/util/CipherListViewExtensionsTest.kt index 5afd725b55..e160eeca88 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/util/CipherListViewExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/util/CipherListViewExtensionsTest.kt @@ -12,7 +12,6 @@ import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflo import com.x8bit.bitwarden.ui.vault.model.VaultTrailingIcon import com.x8bit.bitwarden.ui.vault.util.toSdkCipherType 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 @@ -40,7 +39,6 @@ class CipherListViewExtensionsTest { val result = cipher.toOverflowActions( hasMasterPassword = false, isPremiumUser = true, - isArchiveEnabled = true, ) assertEquals( @@ -89,7 +87,6 @@ class CipherListViewExtensionsTest { val result = cipher.toOverflowActions( hasMasterPassword = false, isPremiumUser = false, - isArchiveEnabled = true, ) assertTrue( @@ -114,7 +111,6 @@ class CipherListViewExtensionsTest { val result = cipher.toOverflowActions( hasMasterPassword = true, isPremiumUser = false, - isArchiveEnabled = true, ) assertEquals( @@ -157,7 +153,6 @@ class CipherListViewExtensionsTest { val result = cipher.toOverflowActions( hasMasterPassword = true, isPremiumUser = false, - isArchiveEnabled = true, ) assertEquals( @@ -189,7 +184,6 @@ class CipherListViewExtensionsTest { val result = cipher.toOverflowActions( hasMasterPassword = true, isPremiumUser = false, - isArchiveEnabled = true, ) assertEquals( @@ -231,7 +225,6 @@ class CipherListViewExtensionsTest { val result = cipher.toOverflowActions( hasMasterPassword = false, isPremiumUser = false, - isArchiveEnabled = true, ) assertEquals( @@ -257,7 +250,6 @@ class CipherListViewExtensionsTest { val result = cipher.toOverflowActions( hasMasterPassword = false, isPremiumUser = false, - isArchiveEnabled = true, ) assertEquals( @@ -291,7 +283,6 @@ class CipherListViewExtensionsTest { val result = cipher.toOverflowActions( hasMasterPassword = true, isPremiumUser = false, - isArchiveEnabled = true, ) assertEquals( @@ -318,7 +309,6 @@ class CipherListViewExtensionsTest { val result = cipher.toOverflowActions( hasMasterPassword = true, isPremiumUser = false, - isArchiveEnabled = true, ) assertEquals( @@ -356,7 +346,6 @@ class CipherListViewExtensionsTest { val result = cipher.toOverflowActions( hasMasterPassword = false, isPremiumUser = false, - isArchiveEnabled = true, ) assertEquals( @@ -384,7 +373,6 @@ class CipherListViewExtensionsTest { val result = cipher.toOverflowActions( hasMasterPassword = true, isPremiumUser = false, - isArchiveEnabled = true, ) assertTrue( @@ -411,7 +399,6 @@ class CipherListViewExtensionsTest { val result = cipher.toOverflowActions( hasMasterPassword = true, isPremiumUser = false, - isArchiveEnabled = true, ) assertTrue( @@ -446,38 +433,11 @@ class CipherListViewExtensionsTest { val result = cipher.toOverflowActions( hasMasterPassword = true, isPremiumUser = false, - isArchiveEnabled = true, ) assertTrue(result.contains(ListingItemOverflowAction.VaultAction.UnarchiveClick(id))) } - @Test - fun `toOverflowActions should not return Archive action when Archive is disabled`() { - val loginListView = createMockLoginListView( - number = 1, - username = "", - uris = emptyList(), - totp = null, - ) - val cipher = createMockCipherListView( - number = 1, - id = id, - isArchived = false, - isDeleted = false, - edit = true, - type = CipherListViewType.Login(loginListView), - ) - - val result = cipher.toOverflowActions( - hasMasterPassword = true, - isPremiumUser = false, - isArchiveEnabled = false, - ) - - assertFalse(result.contains(ListingItemOverflowAction.VaultAction.ArchiveClick(id))) - } - @Test fun `toTrailingIcons should return collection icon if collectionId is not empty`() { val cipher = createMockCipherListView( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index 37e72a4b68..f690be18dc 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -1003,7 +1003,6 @@ class VaultScreenTest : BitwardenComposeTest() { collectionItems = emptyList(), trashItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = false, archiveSubText = null, archiveEndIcon = null, showCardGroup = false, @@ -2651,7 +2650,6 @@ private val DEFAULT_STATE: VaultState = VaultState( cipherDecryptionFailureIds = persistentListOf(), hasShownDecryptionFailureAlert = false, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, isIntroducingArchiveActionCardDismissed = false, ) @@ -2669,7 +2667,6 @@ private val DEFAULT_CONTENT_VIEW_STATE: VaultState.ViewState.Content = VaultStat itemTypesCount = 4, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 30f4a66634..160644b197 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -226,13 +226,10 @@ class VaultViewModelTest : BaseViewModelTest() { coEvery { unregister() } returns UnregisterExportResult.Success } private val mutableCxpExportFeatureFlagFlow = MutableStateFlow(false) - private val mutableArchiveItemsFlagFlow = MutableStateFlow(true) private val featureFlagManager: FeatureFlagManager = mockk { every { getFeatureFlagFlow(FlagKey.CredentialExchangeProtocolExport) } returns mutableCxpExportFeatureFlagFlow - every { getFeatureFlagFlow(FlagKey.ArchiveItems) } returns mutableArchiveItemsFlagFlow - every { getFeatureFlag(FlagKey.ArchiveItems) } returns mutableArchiveItemsFlagFlow.value } private val mutablePremiumUpgradeBannerEligibleFlow = MutableStateFlow(false) @@ -465,7 +462,6 @@ class VaultViewModelTest : BaseViewModelTest() { val state = createMockVaultState(viewState = contentViewState).copy( isPremiumUpgradeBannerEligible = false, isPremium = true, - isArchiveEnabled = true, isIntroducingArchiveActionCardDismissed = false, ) @@ -481,7 +477,6 @@ class VaultViewModelTest : BaseViewModelTest() { val state = createMockVaultState(viewState = contentViewState).copy( isPremiumUpgradeBannerEligible = false, isPremium = false, - isArchiveEnabled = true, isIntroducingArchiveActionCardDismissed = false, ) @@ -1289,7 +1284,6 @@ class VaultViewModelTest : BaseViewModelTest() { baseIconUrl = viewModel.stateFlow.value.baseIconUrl, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ), ) .copy( @@ -1315,7 +1309,6 @@ class VaultViewModelTest : BaseViewModelTest() { baseIconUrl = viewModel.stateFlow.value.baseIconUrl, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ), ), viewModel.stateFlow.value, @@ -1451,7 +1444,6 @@ class VaultViewModelTest : BaseViewModelTest() { itemTypesCount = CipherType.entries.size, sshKeyItemsCount = 1, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -1481,7 +1473,6 @@ class VaultViewModelTest : BaseViewModelTest() { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -1620,7 +1611,6 @@ class VaultViewModelTest : BaseViewModelTest() { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -1761,7 +1751,6 @@ class VaultViewModelTest : BaseViewModelTest() { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -1829,7 +1818,6 @@ class VaultViewModelTest : BaseViewModelTest() { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -1945,7 +1933,6 @@ class VaultViewModelTest : BaseViewModelTest() { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -2035,7 +2022,6 @@ class VaultViewModelTest : BaseViewModelTest() { itemTypesCount = CipherType.entries.size, sshKeyItemsCount = 1, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -2271,7 +2257,6 @@ class VaultViewModelTest : BaseViewModelTest() { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = null, - archiveEnabled = true, archiveSubText = BitwardenString.premium_subscription_required.asText(), archiveEndIcon = BitwardenDrawable.ic_locked, showCardGroup = true, @@ -2378,7 +2363,6 @@ class VaultViewModelTest : BaseViewModelTest() { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 1, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -2451,7 +2435,6 @@ class VaultViewModelTest : BaseViewModelTest() { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -3751,7 +3734,6 @@ class VaultViewModelTest : BaseViewModelTest() { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -3813,7 +3795,6 @@ class VaultViewModelTest : BaseViewModelTest() { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -4047,7 +4028,6 @@ private fun createMockVaultState( cipherDecryptionFailureIds = persistentListOf(), hasShownDecryptionFailureAlert = false, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, isIntroducingArchiveActionCardDismissed = false, isPremiumUpgradeBannerEligible = false, ) @@ -4066,7 +4046,6 @@ private val DEFAULT_CONTENT_VIEW_STATE = VaultState.ViewState.Content( collectionItems = emptyList(), trashItemsCount = 0, archivedItemsCount = null, - archiveEnabled = false, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt index 0372314ce2..373144b8a4 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt @@ -87,7 +87,6 @@ class VaultDataExtensionsTest { vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -144,7 +143,6 @@ class VaultDataExtensionsTest { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 1, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -176,7 +174,6 @@ class VaultDataExtensionsTest { vaultFilterType = VaultFilterType.MyVault, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -200,7 +197,6 @@ class VaultDataExtensionsTest { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -241,7 +237,6 @@ class VaultDataExtensionsTest { ), hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -276,7 +271,6 @@ class VaultDataExtensionsTest { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -304,7 +298,6 @@ class VaultDataExtensionsTest { vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -332,7 +325,6 @@ class VaultDataExtensionsTest { vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -361,7 +353,6 @@ class VaultDataExtensionsTest { vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -379,7 +370,6 @@ class VaultDataExtensionsTest { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -408,7 +398,6 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -426,7 +415,6 @@ class VaultDataExtensionsTest { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = null, - archiveEnabled = true, archiveSubText = BitwardenString.premium_subscription_required.asText(), archiveEndIcon = BitwardenDrawable.ic_locked, showCardGroup = true, @@ -457,7 +445,6 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -475,7 +462,6 @@ class VaultDataExtensionsTest { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = null, - archiveEnabled = true, archiveSubText = BitwardenString.premium_subscription_required.asText(), archiveEndIcon = BitwardenDrawable.ic_locked, showCardGroup = true, @@ -506,7 +492,6 @@ class VaultDataExtensionsTest { baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -524,7 +509,6 @@ class VaultDataExtensionsTest { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = null, - archiveEnabled = true, archiveSubText = BitwardenString.premium_subscription_required.asText(), archiveEndIcon = BitwardenDrawable.ic_locked, showCardGroup = true, @@ -739,7 +723,6 @@ class VaultDataExtensionsTest { vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -757,7 +740,6 @@ class VaultDataExtensionsTest { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -788,7 +770,6 @@ class VaultDataExtensionsTest { vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -806,7 +787,6 @@ class VaultDataExtensionsTest { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -840,7 +820,6 @@ class VaultDataExtensionsTest { vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -864,7 +843,6 @@ class VaultDataExtensionsTest { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -899,7 +877,6 @@ class VaultDataExtensionsTest { vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -924,7 +901,6 @@ class VaultDataExtensionsTest { overflowOptions = mockCipher.toOverflowActions( hasMasterPassword = true, isPremiumUser = true, - isArchiveEnabled = true, ), shouldShowMasterPasswordReprompt = false, username = "mockUsername-1".asText(), @@ -936,7 +912,6 @@ class VaultDataExtensionsTest { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -977,7 +952,6 @@ class VaultDataExtensionsTest { vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -1027,7 +1001,6 @@ class VaultDataExtensionsTest { itemTypesCount = 6, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -1076,7 +1049,6 @@ class VaultDataExtensionsTest { vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = listOf("restrict_item_type_policy_id"), - isArchiveEnabled = true, ) assertEquals( @@ -1094,7 +1066,6 @@ class VaultDataExtensionsTest { totpItemsCount = 0, itemTypesCount = CipherType.entries.size, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -1139,7 +1110,6 @@ class VaultDataExtensionsTest { vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = listOf("restrict_item_type_policy_id"), - isArchiveEnabled = true, ) assertEquals( @@ -1157,7 +1127,6 @@ class VaultDataExtensionsTest { totpItemsCount = 1, itemTypesCount = CipherType.entries.size, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = false, @@ -1188,7 +1157,6 @@ class VaultDataExtensionsTest { vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -1207,9 +1175,7 @@ class VaultDataExtensionsTest { totpItemsCount = 1, // Verify item types count includes all CipherTypes when showSshKeys is true. itemTypesCount = CipherType.entries.size, - archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, @@ -1254,7 +1220,6 @@ class VaultDataExtensionsTest { vaultFilterType = VaultFilterType.AllVaults, hasMasterPassword = true, restrictItemTypesPolicyOrgIds = emptyList(), - isArchiveEnabled = true, ) assertEquals( @@ -1277,7 +1242,6 @@ class VaultDataExtensionsTest { totpItemsCount = 0, itemTypesCount = CipherType.entries.size, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultStateExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultStateExtensionsTest.kt index 6f0a15b3ad..69c901eaa5 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultStateExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultStateExtensionsTest.kt @@ -86,7 +86,6 @@ class VaultStateExtensionsTest { itemTypesCount = 4, sshKeyItemsCount = 0, archivedItemsCount = 0, - archiveEnabled = true, archiveSubText = null, archiveEndIcon = null, showCardGroup = true, diff --git a/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt b/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt index 0f9ca15e81..419008df24 100644 --- a/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt +++ b/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt @@ -35,7 +35,6 @@ sealed class FlagKey { ForceUpdateKdfSettings, NoLogoutOnKdfChange, MigrateMyVaultToMyItems, - ArchiveItems, SendEmailVerification, CardScanner, MobilePremiumUpgrade, @@ -98,14 +97,6 @@ sealed class FlagKey { override val defaultValue: Boolean = false } - /** - * Data object holding the feature flag key for the Archive Items feature. - */ - data object ArchiveItems : FlagKey() { - override val keyName: String = "pm-19148-innovation-archive" - override val defaultValue: Boolean = false - } - /** * Data object holding the feature flag key for the Send Email Verification feature. */ diff --git a/core/src/test/kotlin/com/bitwarden/core/data/manager/model/FlagKeyTest.kt b/core/src/test/kotlin/com/bitwarden/core/data/manager/model/FlagKeyTest.kt index f0f6d50bf0..cc5e3b6d69 100644 --- a/core/src/test/kotlin/com/bitwarden/core/data/manager/model/FlagKeyTest.kt +++ b/core/src/test/kotlin/com/bitwarden/core/data/manager/model/FlagKeyTest.kt @@ -28,10 +28,6 @@ class FlagKeyTest { FlagKey.MigrateMyVaultToMyItems.keyName, "pm-20558-migrate-myvault-to-myitems", ) - assertEquals( - FlagKey.ArchiveItems.keyName, - "pm-19148-innovation-archive", - ) assertEquals( FlagKey.SendEmailVerification.keyName, "pm-19051-send-email-verification", @@ -71,7 +67,6 @@ class FlagKeyTest { FlagKey.BitwardenAuthenticationEnabled, FlagKey.ForceUpdateKdfSettings, FlagKey.MigrateMyVaultToMyItems, - FlagKey.ArchiveItems, FlagKey.SendEmailVerification, FlagKey.MobilePremiumUpgrade, FlagKey.AttachmentUpdates, diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt index 0cbc8f6bca..6dad716fa4 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/debug/FeatureFlagListItems.kt @@ -29,7 +29,6 @@ fun FlagKey.ListItemContent( FlagKey.ForceUpdateKdfSettings, FlagKey.NoLogoutOnKdfChange, FlagKey.MigrateMyVaultToMyItems, - FlagKey.ArchiveItems, FlagKey.CardScanner, FlagKey.SendEmailVerification, FlagKey.MobilePremiumUpgrade, @@ -88,7 +87,6 @@ private fun FlagKey.getDisplayLabel(): String = when (this) { } FlagKey.MigrateMyVaultToMyItems -> stringResource(BitwardenString.migrate_my_vault_to_my_items) - FlagKey.ArchiveItems -> stringResource(BitwardenString.archive_items) FlagKey.CardScanner -> stringResource(BitwardenString.scan_card) FlagKey.SendEmailVerification -> stringResource(BitwardenString.send_email_verification) FlagKey.MobilePremiumUpgrade -> stringResource(BitwardenString.mobile_premium_upgrade) diff --git a/ui/src/main/res/values/strings_non_localized.xml b/ui/src/main/res/values/strings_non_localized.xml index 445ac412ce..dcfc884182 100644 --- a/ui/src/main/res/values/strings_non_localized.xml +++ b/ui/src/main/res/values/strings_non_localized.xml @@ -41,7 +41,6 @@ Force update KDF settings Avoid logout on KDF change Migrate My Vault to My Items - Archive Items Send Email Verification Trigger cookie acquisition Clear SSO cookies From 32b704cfde1605d9836322675f2af4e6b5dbedca Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:04:31 -0400 Subject: [PATCH 12/16] [PM-35455] feat: Wire premium subscription data into Plan screen (#6819) --- .../feature/premium/plan/PlanScreen.kt | 315 +++++++++- .../feature/premium/plan/PlanViewModel.kt | 431 +++++++++++-- .../premium/plan/handlers/PlanHandlers.kt | 23 + .../PremiumSubscriptionStatusExtensions.kt | 40 ++ .../feature/premium/plan/PlanScreenTest.kt | 495 ++++++++++++++- .../feature/premium/plan/PlanViewModelTest.kt | 565 +++++++++++++++++- ui/src/main/res/values/strings.xml | 28 + 7 files changed, 1790 insertions(+), 107 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/PremiumSubscriptionStatusExtensions.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt index 1bd2185c73..8ba69aa8f4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt @@ -1,5 +1,8 @@ +@file:Suppress("TooManyFunctions") + package com.x8bit.bitwarden.ui.platform.feature.premium.plan +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -18,7 +21,9 @@ import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -33,7 +38,9 @@ import com.bitwarden.ui.platform.base.util.EventsEffect import com.bitwarden.ui.platform.base.util.cardStyle import com.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.bitwarden.ui.platform.components.badge.BitwardenStatusBadge import com.bitwarden.ui.platform.components.button.BitwardenFilledButton +import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton import com.bitwarden.ui.platform.components.content.BitwardenContentBlock import com.bitwarden.ui.platform.components.content.model.ContentBlockData import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog @@ -49,13 +56,20 @@ import com.bitwarden.ui.platform.manager.IntentManager import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme +import com.bitwarden.ui.util.Text +import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus import com.x8bit.bitwarden.ui.platform.composition.LocalAuthTabLaunchers import com.x8bit.bitwarden.ui.platform.feature.premium.plan.handlers.PlanHandlers +import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.badgeColors +import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.labelRes import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers /** - * The screen for the plan — shows the upgrade flow for free users. + * The screen for the plan — shows the upgrade flow for free users and the + * subscription-management surface for premium users. */ +@Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable fun PlanScreen( @@ -78,12 +92,13 @@ fun PlanScreen( ) } + is PlanEvent.LaunchPortal -> intentManager.launchUri(event.url.toUri()) PlanEvent.NavigateBack -> onNavigateBack() is PlanEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data) } } - FreeDialogs( + PlanDialogs( dialogState = state.dialogState, handlers = handlers, ) @@ -112,31 +127,23 @@ fun PlanScreen( is PlanState.ViewState.Free -> { FreeContent( viewState = viewState, - isDialogShowing = state.dialogState != null, handlers = handlers, ) } - PlanState.ViewState.Premium -> { - PremiumContent(modifier = Modifier.fillMaxSize()) + is PlanState.ViewState.Premium -> { + PremiumContent( + viewState = viewState, + handlers = handlers, + ) } } } } +@Suppress("LongMethod") @Composable -private fun PremiumContent( - modifier: Modifier = Modifier, -) { - // TODO(PM-35455): Render the premium subscription management UI — - // status badge, next-charge summary, billing / storage / discount / - // tax line items, and manage plan / cancel actions — once the - // subscription fetch path is wired up. - Spacer(modifier = modifier) -} - -@Composable -private fun FreeDialogs( +private fun PlanDialogs( dialogState: PlanState.DialogState?, handlers: PlanHandlers, ) { @@ -191,6 +198,51 @@ private fun FreeDialogs( ) } + is PlanState.DialogState.CancelConfirmation -> { + BitwardenTwoButtonDialog( + title = stringResource(id = BitwardenString.cancel_premium), + message = stringResource( + id = BitwardenString.cancel_premium_confirmation, + dialogState.nextRenewalDate, + ), + confirmButtonText = stringResource(id = BitwardenString.cancel_now), + dismissButtonText = stringResource(id = BitwardenString.close), + onConfirmClick = handlers.onConfirmCancelClick, + onDismissClick = handlers.onDismissCancelConfirmation, + onDismissRequest = handlers.onDismissCancelConfirmation, + ) + } + + is PlanState.DialogState.PortalError -> { + BitwardenTwoButtonDialog( + title = stringResource(id = BitwardenString.portal_error), + message = stringResource(id = BitwardenString.trouble_loading_portal), + confirmButtonText = stringResource(id = BitwardenString.try_again), + dismissButtonText = stringResource(id = BitwardenString.close), + onConfirmClick = handlers.onManagePlanClick, + onDismissClick = handlers.onDismissPortalError, + onDismissRequest = handlers.onDismissPortalError, + ) + } + + is PlanState.DialogState.SubscriptionError -> { + BitwardenTwoButtonDialog( + title = dialogState.title(), + message = dialogState.message(), + confirmButtonText = stringResource(id = BitwardenString.try_again), + dismissButtonText = stringResource(id = BitwardenString.close), + onConfirmClick = handlers.onRetrySubscriptionClick, + onDismissClick = handlers.onBackClick, + onDismissRequest = handlers.onBackClick, + ) + } + + PlanState.DialogState.LoadingPortal -> { + BitwardenLoadingDialog( + text = stringResource(id = BitwardenString.loading_portal), + ) + } + is PlanState.DialogState.Loading -> { BitwardenLoadingDialog(text = dialogState.message()) } @@ -202,7 +254,6 @@ private fun FreeDialogs( @Composable private fun FreeContent( viewState: PlanState.ViewState.Free, - isDialogShowing: Boolean, handlers: PlanHandlers, modifier: Modifier = Modifier, ) { @@ -223,7 +274,6 @@ private fun FreeContent( BitwardenFilledButton( label = stringResource(id = BitwardenString.upgrade_now), onClick = handlers.onUpgradeNowClick, - isEnabled = !isDialogShowing, icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link), modifier = Modifier .standardHorizontalMargin() @@ -329,6 +379,183 @@ private fun PriceRow( } } +@Composable +private fun PremiumContent( + viewState: PlanState.ViewState.Premium, + handlers: PlanHandlers, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.height(12.dp)) + SubscriptionCard( + viewState = viewState, + modifier = Modifier.standardHorizontalMargin(), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + BitwardenFilledButton( + label = stringResource(id = BitwardenString.manage_plan), + onClick = handlers.onManagePlanClick, + icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link), + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth() + .testTag("ManagePlanButton"), + ) + + if (viewState.showCancelButton) { + Spacer(modifier = Modifier.height(12.dp)) + BitwardenOutlinedButton( + label = stringResource(id = BitwardenString.cancel_premium), + onClick = handlers.onCancelPremiumClick, + icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link), + modifier = Modifier + .standardHorizontalMargin() + .fillMaxWidth() + .testTag("CancelPremiumButton"), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} + +@Suppress("LongMethod") +@Composable +private fun SubscriptionCard( + viewState: PlanState.ViewState.Premium, + modifier: Modifier = Modifier, +) { + val rowModifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin() + Column( + modifier = modifier + .fillMaxWidth() + .cardStyle( + cardStyle = CardStyle.Full, + // Override bottom padding; the final row owns its own spacing. + paddingBottom = 0.dp, + ), + ) { + SubscriptionHeader( + status = viewState.status, + descriptionText = viewState.descriptionText, + modifier = Modifier + .padding(bottom = 16.dp) + .standardHorizontalMargin(), + ) + + BitwardenHorizontalDivider() + + SubscriptionLineItem( + label = stringResource(id = BitwardenString.billing_amount), + value = viewState.billingAmountText(), + testTag = "BillingAmountRow", + modifier = rowModifier, + ) + + BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp)) + + SubscriptionLineItem( + label = stringResource(id = BitwardenString.storage_cost), + value = viewState.storageCostText, + testTag = "StorageCostRow", + modifier = rowModifier, + ) + + BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp)) + + SubscriptionLineItem( + label = stringResource(id = BitwardenString.discount), + value = viewState.discountAmountText, + valueColor = if (viewState.discountAmountText == "--") { + BitwardenTheme.colorScheme.text.primary + } else { + BitwardenTheme.colorScheme.statusBadge.success.text + }, + testTag = "DiscountRow", + modifier = rowModifier, + ) + + BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp)) + + SubscriptionLineItem( + label = stringResource(id = BitwardenString.estimated_tax), + value = viewState.estimatedTaxText, + testTag = "EstimatedTaxRow", + modifier = rowModifier, + ) + } +} + +@Composable +private fun SubscriptionHeader( + status: PremiumSubscriptionStatus?, + descriptionText: Text?, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(id = BitwardenString.premium_plan_name), + style = BitwardenTheme.typography.titleLarge, + color = BitwardenTheme.colorScheme.text.primary, + ) + status?.let { + Spacer(modifier = Modifier.width(8.dp)) + BitwardenStatusBadge( + label = stringResource(id = it.labelRes()), + colors = it.badgeColors(), + ) + } + } + + descriptionText?.let { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = it(), + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.secondary, + ) + } + } +} + +@Composable +private fun SubscriptionLineItem( + label: String, + value: String, + testTag: String, + modifier: Modifier = Modifier, + valueColor: Color = BitwardenTheme.colorScheme.text.primary, +) { + Row( + modifier = modifier + .padding(vertical = 16.dp) + .testTag(testTag), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = BitwardenTheme.typography.bodyLarge, + color = BitwardenTheme.colorScheme.text.secondary, + ) + Text( + text = value, + style = BitwardenTheme.typography.bodyLarge, + color = valueColor, + ) + } +} + @Preview @OmitFromCoverage @Composable @@ -341,7 +568,6 @@ private fun PlanScreenFreeAccount_preview() { checkoutUrl = null, isAwaitingPremiumStatus = false, ), - isDialogShowing = false, handlers = PlanHandlers( onBackClick = {}, onUpgradeNowClick = {}, @@ -353,6 +579,55 @@ private fun PlanScreenFreeAccount_preview() { onGoBackClick = {}, onSyncClick = {}, onContinueClick = {}, + onManagePlanClick = {}, + onCancelPremiumClick = {}, + onConfirmCancelClick = {}, + onDismissCancelConfirmation = {}, + onDismissPortalError = {}, + onRetrySubscriptionClick = {}, + ), + ) + } + } +} + +@Preview +@OmitFromCoverage +@Composable +private fun PlanScreenPremiumAccount_preview() { + BitwardenTheme { + BitwardenScaffold { + PremiumContent( + viewState = PlanState.ViewState.Premium( + status = PremiumSubscriptionStatus.ACTIVE, + descriptionText = BitwardenString.premium_next_charge_summary.asText( + "$45.55", + "April 2, 2026", + ), + billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"), + storageCostText = "$24.00", + discountAmountText = "-$2.10", + estimatedTaxText = "$3.85", + nextChargeDateText = "April 2, 2026", + showCancelButton = true, + ), + handlers = PlanHandlers( + onBackClick = {}, + onUpgradeNowClick = {}, + onDismissError = {}, + onRetryClick = {}, + onRetryPricingClick = {}, + onClosePricingErrorClick = {}, + onCancelWaiting = {}, + onGoBackClick = {}, + onSyncClick = {}, + onContinueClick = {}, + onManagePlanClick = {}, + onCancelPremiumClick = {}, + onConfirmCancelClick = {}, + onDismissCancelConfirmation = {}, + onDismissPortalError = {}, + onRetrySubscriptionClick = {}, ), ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt index 0e0b9c9916..3357b5b19e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt @@ -5,6 +5,7 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.bitwarden.core.data.util.toFormattedDateStyle import com.bitwarden.ui.platform.base.BaseViewModel import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData import com.bitwarden.ui.platform.manager.intent.model.AuthTabData @@ -16,7 +17,12 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.billing.repository.BillingRepository import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult +import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult +import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence import com.x8bit.bitwarden.data.billing.repository.model.PremiumPlanPricingResult +import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus +import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo +import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance @@ -29,13 +35,17 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize +import java.math.BigDecimal import java.text.NumberFormat +import java.time.Clock +import java.time.Instant +import java.time.format.FormatStyle import java.util.Locale import javax.inject.Inject private const val KEY_STATE = "state" private const val MONTHS_PER_YEAR = 12 -private const val PLACEHOLDER_RATE = "--" +private const val PLACEHOLDER_TEXT = "--" /** * The callback URL for the premium checkout custom tab. @@ -43,11 +53,10 @@ private const val PLACEHOLDER_RATE = "--" const val PREMIUM_CHECKOUT_CALLBACK_URL = "bitwarden://premium-checkout-result" /** - * View model for the plan screen, driving the upgrade flow for free users and a - * placeholder surface for premium users until PM-35455 wires in subscription - * management. + * View model for the plan screen, driving the upgrade flow for free users and + * the subscription management surface for premium users. */ -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") @HiltViewModel class PlanViewModel @Inject constructor( savedStateHandle: SavedStateHandle, @@ -55,6 +64,7 @@ class PlanViewModel @Inject constructor( private val authRepository: AuthRepository, private val specialCircumstanceManager: SpecialCircumstanceManager, private val vaultRepository: VaultRepository, + private val clock: Clock, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: run { val planMode = savedStateHandle.toPlanArgs().planMode @@ -66,10 +76,10 @@ class PlanViewModel @Inject constructor( PlanState( planMode = planMode, viewState = if (isPremium) { - PlanState.ViewState.Premium + PlanState.ViewState.Premium() } else { PlanState.ViewState.Free( - rate = PLACEHOLDER_RATE, + rate = PLACEHOLDER_TEXT, checkoutUrl = null, isAwaitingPremiumStatus = false, ) @@ -78,6 +88,10 @@ class PlanViewModel @Inject constructor( ) }, ) { + + private val currencyFormatter: NumberFormat = + NumberFormat.getCurrencyInstance(Locale.US) + init { stateFlow .onEach { savedStateHandle[KEY_STATE] = it } @@ -104,6 +118,23 @@ class PlanViewModel @Inject constructor( ) } } + + onPremiumContent { + mutableStateFlow.update { + it.copy( + dialogState = PlanState.DialogState.Loading( + message = BitwardenString.loading_subscription.asText(), + ), + ) + } + viewModelScope.launch { + sendAction( + PlanAction.Internal.SubscriptionResultReceive( + result = billingRepository.getSubscription(), + ), + ) + } + } } override fun handleAction(action: PlanAction) { @@ -111,37 +142,30 @@ class PlanViewModel @Inject constructor( is PlanAction.BackClick -> handleBackClick() is PlanAction.UpgradeNowClick -> handleUpgradeNowClick() is PlanAction.DismissError -> handleDismissError() - is PlanAction.ClosePricingErrorClick -> { - handleClosePricingErrorClick() - } - + is PlanAction.ClosePricingErrorClick -> handleClosePricingErrorClick() is PlanAction.RetryClick -> handleRetryClick() - is PlanAction.RetryPricingClick -> { - handleRetryPricingClick() - } - + is PlanAction.RetryPricingClick -> handleRetryPricingClick() is PlanAction.CancelWaiting -> handleCancelWaiting() is PlanAction.GoBackClick -> handleGoBackClick() is PlanAction.SyncClick -> handleSyncClick() is PlanAction.ContinueClick -> handleContinueClick() - is PlanAction.Internal.CheckoutUrlReceive -> { - handleCheckoutUrlReceive(action) - } - - is PlanAction.Internal.UserStateUpdateReceive -> { - handleUserStateUpdateReceive(action) - } - + is PlanAction.ManagePlanClick -> handleManagePlanClick() + is PlanAction.CancelPremiumClick -> handleCancelPremiumClick() + is PlanAction.ConfirmCancelClick -> handleConfirmCancelClick() + is PlanAction.DismissCancelConfirmation -> handleDismissCancelConfirmation() + is PlanAction.DismissPortalError -> handleDismissPortalError() + is PlanAction.RetrySubscriptionClick -> handleRetrySubscriptionClick() + is PlanAction.Internal.CheckoutUrlReceive -> handleCheckoutUrlReceive(action) + is PlanAction.Internal.UserStateUpdateReceive -> handleUserStateUpdateReceive(action) is PlanAction.Internal.SpecialCircumstanceReceive -> { handleSpecialCircumstanceReceive(action) } - is PlanAction.Internal.SyncCompleteReceive -> { - handleSyncCompleteReceive() - } - - is PlanAction.Internal.PricingResultReceive -> { - handlePricingResultReceive(action) + is PlanAction.Internal.SyncCompleteReceive -> handleSyncCompleteReceive() + is PlanAction.Internal.PricingResultReceive -> handlePricingResultReceive(action) + is PlanAction.Internal.PortalUrlReceive -> handlePortalUrlReceive(action) + is PlanAction.Internal.SubscriptionResultReceive -> { + handleSubscriptionResultReceive(action) } } } @@ -150,6 +174,8 @@ class PlanViewModel @Inject constructor( sendEvent(PlanEvent.NavigateBack) } + // region Free user handlers + private fun handleUpgradeNowClick() { mutableStateFlow.update { it.copy( @@ -248,6 +274,119 @@ class PlanViewModel @Inject constructor( } } + // endregion Free user handlers + + // region Premium user handlers + + private fun handleManagePlanClick() { + launchPortalFetch() + } + + private fun handleCancelPremiumClick() { + onPremiumContent { premiumState -> + mutableStateFlow.update { + it.copy( + dialogState = PlanState.DialogState.CancelConfirmation( + nextRenewalDate = premiumState.nextChargeDateText + ?: PLACEHOLDER_TEXT, + ), + ) + } + } + } + + private fun handleConfirmCancelClick() { + launchPortalFetch() + } + + private fun handleDismissCancelConfirmation() { + mutableStateFlow.update { it.copy(dialogState = null) } + } + + private fun handleDismissPortalError() { + mutableStateFlow.update { it.copy(dialogState = null) } + } + + private fun launchPortalFetch() { + mutableStateFlow.update { + it.copy(dialogState = PlanState.DialogState.LoadingPortal) + } + viewModelScope.launch { + sendAction( + PlanAction.Internal.PortalUrlReceive( + result = billingRepository.getPortalUrl(), + ), + ) + } + } + + private fun handlePortalUrlReceive( + action: PlanAction.Internal.PortalUrlReceive, + ) { + when (val result = action.result) { + is CustomerPortalResult.Success -> { + mutableStateFlow.update { it.copy(dialogState = null) } + sendEvent(PlanEvent.LaunchPortal(url = result.url)) + } + + is CustomerPortalResult.Error -> { + mutableStateFlow.update { + it.copy(dialogState = PlanState.DialogState.PortalError) + } + } + } + } + + private fun handleRetrySubscriptionClick() { + mutableStateFlow.update { + it.copy( + dialogState = PlanState.DialogState.Loading( + message = BitwardenString.loading_subscription.asText(), + ), + ) + } + viewModelScope.launch { + sendAction( + PlanAction.Internal.SubscriptionResultReceive( + result = billingRepository.getSubscription(), + ), + ) + } + } + + private fun handleSubscriptionResultReceive( + action: PlanAction.Internal.SubscriptionResultReceive, + ) { + when (val result = action.result) { + is SubscriptionResult.Success -> { + val info = result.subscription + mutableStateFlow.update { + it.copy( + viewState = info.toPremiumViewState(), + dialogState = null, + ) + } + } + + is SubscriptionResult.Error -> { + mutableStateFlow.update { + it.copy( + dialogState = PlanState.DialogState.SubscriptionError( + title = BitwardenString.subscription_error.asText(), + message = BitwardenString + .trouble_loading_subscription + .asText(), + ), + ) + } + } + } + } + + // endregion Premium user handlers + + // region Shared handlers + private fun handleUserStateUpdateReceive( action: PlanAction.Internal.UserStateUpdateReceive, ) { @@ -269,7 +408,6 @@ class PlanViewModel @Inject constructor( specialCircumstanceManager.specialCircumstance = null if (checkoutResult.callbackResult is PremiumCheckoutCallbackResult.Canceled) { - // User canceled checkout — show "Payment not received yet" dialog. onFreeContent { freeState -> mutableStateFlow.update { it.copy( @@ -283,7 +421,6 @@ class PlanViewModel @Inject constructor( return } - // Success — check if already premium, otherwise trigger background sync. val isPremium = authRepository .userStateFlow .value @@ -327,8 +464,6 @@ class PlanViewModel @Inject constructor( if (isPremium) { onPremiumUpgradeSuccess() } else { - // Sync completed but premium not yet provisioned — - // prompt the user to retry or continue as free. mutableStateFlow.update { it.copy( dialogState = PlanState.DialogState.PendingUpgrade, @@ -358,25 +493,17 @@ class PlanViewModel @Inject constructor( ) } - private inline fun onFreeContent( - block: (PlanState.ViewState.Free) -> Unit, - ) { - (state.viewState as? PlanState.ViewState.Free) - ?.let(block) - } - private fun handlePricingResultReceive( action: PlanAction.Internal.PricingResultReceive, ) { when (val result = action.result) { is PremiumPlanPricingResult.Success -> { - val formattedRate = NumberFormat - .getCurrencyInstance(Locale.US) + val formattedRate = currencyFormatter .format(result.annualPrice / MONTHS_PER_YEAR) mutableStateFlow.update { currentState -> val updatedViewState = when (val vs = currentState.viewState) { is PlanState.ViewState.Free -> vs.copy(rate = formattedRate) - PlanState.ViewState.Premium -> vs + is PlanState.ViewState.Premium -> vs } currentState.copy( viewState = updatedViewState, @@ -415,6 +542,100 @@ class PlanViewModel @Inject constructor( ) } } + + private inline fun onFreeContent( + block: (PlanState.ViewState.Free) -> Unit, + ) { + (state.viewState as? PlanState.ViewState.Free)?.let(block) + } + + private inline fun onPremiumContent( + block: (PlanState.ViewState.Premium) -> Unit, + ) { + (state.viewState as? PlanState.ViewState.Premium)?.let(block) + } + + private fun SubscriptionInfo.toPremiumViewState(): PlanState.ViewState.Premium { + val formattedTotal = currencyFormatter.format(nextChargeTotal) + val formattedDate = nextCharge?.toLocalizedDate() + val formattedCanceled = canceledDate?.toLocalizedDate() + val formattedSuspension = suspensionDate?.toLocalizedDate() + + return PlanState.ViewState.Premium( + status = status, + descriptionText = toDescriptionText( + formattedTotal = formattedTotal, + nextChargeDate = formattedDate, + canceledDate = formattedCanceled, + suspensionDate = formattedSuspension, + ), + billingAmountText = seatsCost.toBillingAmountText(cadence), + storageCostText = storageCost.toMoneyText(), + discountAmountText = discountAmount.toMoneyText(negative = true), + estimatedTaxText = estimatedTax.toMoneyText(), + nextChargeDateText = formattedDate, + showCancelButton = status != PremiumSubscriptionStatus.CANCELED, + ) + } + + private fun BigDecimal.toBillingAmountText(cadence: PlanCadence): Text { + if (this.signum() == 0) return PLACEHOLDER_TEXT.asText() + val formatted = currencyFormatter.format(this) + val cadenceRes = when (cadence) { + PlanCadence.ANNUALLY -> BitwardenString.billing_rate_per_year + PlanCadence.MONTHLY -> BitwardenString.billing_rate_per_month + } + return cadenceRes.asText(formatted) + } + + private fun BigDecimal?.toMoneyText(negative: Boolean = false): String = + when { + this == null || this.signum() == 0 -> PLACEHOLDER_TEXT + negative -> "-${currencyFormatter.format(this)}" + else -> currencyFormatter.format(this) + } + + private fun SubscriptionInfo.toDescriptionText( + formattedTotal: String, + nextChargeDate: String?, + canceledDate: String?, + suspensionDate: String?, + ): Text = + when (status) { + PremiumSubscriptionStatus.ACTIVE -> + BitwardenString.premium_next_charge_summary.asText( + formattedTotal, + nextChargeDate ?: PLACEHOLDER_TEXT, + ) + + PremiumSubscriptionStatus.CANCELED -> + BitwardenString.subscription_canceled_description.asText( + canceledDate ?: PLACEHOLDER_TEXT, + ) + + PremiumSubscriptionStatus.OVERDUE_PAYMENT -> + BitwardenString.subscription_overdue_description.asText( + suspensionDate ?: PLACEHOLDER_TEXT, + ) + + PremiumSubscriptionStatus.PAST_DUE -> + BitwardenString.subscription_past_due_description.asText( + gracePeriodDays ?: 0, + suspensionDate ?: PLACEHOLDER_TEXT, + ) + + PremiumSubscriptionStatus.PAUSED -> + BitwardenString.subscription_paused_description.asText() + } + + private fun Instant.toLocalizedDate(): String = + toFormattedDateStyle( + dateStyle = FormatStyle.LONG, + locale = Locale.US, + clock = clock, + ) + + // endregion Shared handlers } /** @@ -465,7 +686,7 @@ data class PlanState( val title: Int get() = when (viewState) { is ViewState.Free -> BitwardenString.upgrade_to_premium - ViewState.Premium -> BitwardenString.plan + is ViewState.Premium -> BitwardenString.plan } /** @@ -484,12 +705,24 @@ data class PlanState( ) : ViewState() /** - * Premium user view. Empty placeholder until PM-35455 wires - * subscription management (status, billing amount, next charge, - * manage plan / cancel actions). + * Premium user view — shows subscription details and management options. + * + * Line-item text fields are always populated: they default to the + * `"--"` placeholder during the initial load and for any value that + * resolves to null or `0.00` (e.g. no additional storage, no discount, + * no tax). */ @Parcelize - data object Premium : ViewState() + data class Premium( + val status: PremiumSubscriptionStatus? = null, + val descriptionText: Text? = null, + val billingAmountText: Text = PLACEHOLDER_TEXT.asText(), + val storageCostText: String = PLACEHOLDER_TEXT, + val discountAmountText: String = PLACEHOLDER_TEXT, + val estimatedTaxText: String = PLACEHOLDER_TEXT, + val nextChargeDateText: String? = null, + val showCancelButton: Boolean = false, + ) : ViewState() } /** @@ -521,18 +754,47 @@ data class PlanState( ) : DialogState() /** - * Waiting dialog shown when the user returns from checkout - * without completing payment. + * Waiting dialog shown when the user returns from checkout without + * completing payment. */ @Parcelize data object WaitingForPayment : DialogState() /** - * Dialog shown after a successful checkout when premium - * status has not yet been provisioned by the server. + * Dialog shown after a successful checkout when premium status has not + * yet been provisioned by the server. */ @Parcelize data object PendingUpgrade : DialogState() + + /** + * Confirmation dialog shown before cancelling premium. + */ + @Parcelize + data class CancelConfirmation( + val nextRenewalDate: String, + ) : DialogState() + + /** + * Loading overlay while fetching the portal URL. + */ + @Parcelize + data object LoadingPortal : DialogState() + + /** + * Error dialog shown when the portal URL could not be loaded. + */ + @Parcelize + data object PortalError : DialogState() + + /** + * Error dialog shown when subscription details cannot be loaded. + */ + @Parcelize + data class SubscriptionError( + val title: Text, + val message: Text, + ) : DialogState() } } @@ -542,14 +804,20 @@ data class PlanState( sealed class PlanEvent { /** - * Launch the user's browser with the given checkout [url] - * via AuthTab. + * Launch the user's browser with the given checkout [url] via AuthTab. */ data class LaunchBrowser( val url: String, val authTabData: AuthTabData, ) : PlanEvent() + /** + * Launch the user's browser with the given portal [url]. + */ + data class LaunchPortal( + val url: String, + ) : PlanEvent() + /** * Navigate back to the previous screen. */ @@ -573,6 +841,8 @@ sealed class PlanAction { */ data object BackClick : PlanAction() + // region Free user actions + /** * The user clicked the upgrade now button. */ @@ -618,14 +888,49 @@ sealed class PlanAction { */ data object ContinueClick : PlanAction() + // endregion Free user actions + + // region Premium user actions + + /** + * The user clicked manage plan. + */ + data object ManagePlanClick : PlanAction() + + /** + * The user clicked cancel premium. + */ + data object CancelPremiumClick : PlanAction() + + /** + * The user confirmed the cancel premium action. + */ + data object ConfirmCancelClick : PlanAction() + + /** + * The user dismissed the cancel confirmation dialog. + */ + data object DismissCancelConfirmation : PlanAction() + + /** + * The user dismissed the portal error dialog. + */ + data object DismissPortalError : PlanAction() + + /** + * The user clicked retry on the subscription error dialog. + */ + data object RetrySubscriptionClick : PlanAction() + + // endregion Premium user actions + /** * Models actions the view model sends itself. */ sealed class Internal : PlanAction() { /** - * A checkout URL result has been received from the - * repository. + * A checkout URL result has been received from the repository. */ data class CheckoutUrlReceive( val result: CheckoutSessionResult, @@ -658,5 +963,19 @@ sealed class PlanAction { data class PricingResultReceive( val result: PremiumPlanPricingResult, ) : Internal() + + /** + * A portal URL result has been received from the repository. + */ + data class PortalUrlReceive( + val result: CustomerPortalResult, + ) : Internal() + + /** + * A subscription result has been received from the repository. + */ + data class SubscriptionResultReceive( + val result: SubscriptionResult, + ) : Internal() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/handlers/PlanHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/handlers/PlanHandlers.kt index 513f8979ee..efd64d7dac 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/handlers/PlanHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/handlers/PlanHandlers.kt @@ -7,6 +7,7 @@ import com.x8bit.bitwarden.ui.platform.feature.premium.plan.PlanViewModel * A collection of handler functions for managing actions within the context of * the plan screen. */ +@Suppress("LongParameterList") data class PlanHandlers( val onBackClick: () -> Unit, val onUpgradeNowClick: () -> Unit, @@ -18,6 +19,12 @@ data class PlanHandlers( val onGoBackClick: () -> Unit, val onSyncClick: () -> Unit, val onContinueClick: () -> Unit, + val onManagePlanClick: () -> Unit, + val onCancelPremiumClick: () -> Unit, + val onConfirmCancelClick: () -> Unit, + val onDismissCancelConfirmation: () -> Unit, + val onDismissPortalError: () -> Unit, + val onRetrySubscriptionClick: () -> Unit, ) { @Suppress("UndocumentedPublicClass") companion object { @@ -38,6 +45,22 @@ data class PlanHandlers( onGoBackClick = { viewModel.trySendAction(PlanAction.GoBackClick) }, onSyncClick = { viewModel.trySendAction(PlanAction.SyncClick) }, onContinueClick = { viewModel.trySendAction(PlanAction.ContinueClick) }, + onManagePlanClick = { viewModel.trySendAction(PlanAction.ManagePlanClick) }, + onCancelPremiumClick = { + viewModel.trySendAction(PlanAction.CancelPremiumClick) + }, + onConfirmCancelClick = { + viewModel.trySendAction(PlanAction.ConfirmCancelClick) + }, + onDismissCancelConfirmation = { + viewModel.trySendAction(PlanAction.DismissCancelConfirmation) + }, + onDismissPortalError = { + viewModel.trySendAction(PlanAction.DismissPortalError) + }, + onRetrySubscriptionClick = { + viewModel.trySendAction(PlanAction.RetrySubscriptionClick) + }, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/PremiumSubscriptionStatusExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/PremiumSubscriptionStatusExtensions.kt new file mode 100644 index 0000000000..2531e4a0b3 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/PremiumSubscriptionStatusExtensions.kt @@ -0,0 +1,40 @@ +package com.x8bit.bitwarden.ui.platform.feature.premium.plan.util + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import com.bitwarden.ui.platform.resource.BitwardenString +import com.bitwarden.ui.platform.theme.BitwardenTheme +import com.bitwarden.ui.platform.theme.color.BitwardenColorScheme +import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus + +/** + * Returns the localized label string resource for a [PremiumSubscriptionStatus]. + */ +@StringRes +fun PremiumSubscriptionStatus.labelRes(): Int = when (this) { + PremiumSubscriptionStatus.ACTIVE -> BitwardenString.subscription_status_active + PremiumSubscriptionStatus.CANCELED -> BitwardenString.subscription_status_canceled + PremiumSubscriptionStatus.OVERDUE_PAYMENT -> { + BitwardenString.subscription_status_overdue_payment + } + + PremiumSubscriptionStatus.PAST_DUE -> BitwardenString.subscription_status_past_due + PremiumSubscriptionStatus.PAUSED -> BitwardenString.subscription_status_paused +} + +/** + * Returns the [BitwardenColorScheme.StatusBadgeVariantColors] used to render the badge for a + * [PremiumSubscriptionStatus]. + */ +@Composable +fun PremiumSubscriptionStatus.badgeColors(): BitwardenColorScheme.StatusBadgeVariantColors = + when (this) { + PremiumSubscriptionStatus.ACTIVE -> BitwardenTheme.colorScheme.statusBadge.success + PremiumSubscriptionStatus.CANCELED -> BitwardenTheme.colorScheme.statusBadge.error + PremiumSubscriptionStatus.OVERDUE_PAYMENT, + PremiumSubscriptionStatus.PAST_DUE, + PremiumSubscriptionStatus.PAUSED, + -> { + BitwardenTheme.colorScheme.statusBadge.warning + } + } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt index 6f6c2a1971..8d5d9e68ec 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt @@ -2,9 +2,8 @@ package com.x8bit.bitwarden.ui.platform.feature.premium.plan import android.content.Intent import androidx.activity.result.ActivityResultLauncher +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.isDialog @@ -21,6 +20,7 @@ import com.bitwarden.ui.platform.manager.IntentManager import com.bitwarden.ui.platform.manager.intent.model.AuthTabData import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers import io.mockk.every @@ -34,6 +34,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +@Suppress("LargeClass") class PlanScreenTest : BitwardenComposeTest() { private var onNavigateBackCalled = false @@ -181,27 +182,6 @@ class PlanScreenTest : BitwardenComposeTest() { .assertExists() } - @Test - fun `upgrade now button should be enabled when dialogState is null`() { - composeTestRule - .onNodeWithTag("UpgradeNowButton") - .assertIsEnabled() - } - - @Test - fun `upgrade now button should be disabled when dialogState is Loading`() { - mutableStateFlow.update { - it.copy( - dialogState = PlanState.DialogState.Loading( - message = BitwardenString.opening_checkout.asText(), - ), - ) - } - composeTestRule - .onNodeWithTag("UpgradeNowButton") - .assertIsNotEnabled() - } - @Test fun `loading dialog should render when dialogState is Loading`() { composeTestRule @@ -473,6 +453,461 @@ class PlanScreenTest : BitwardenComposeTest() { } // endregion GetPricingError dialog tests + + // region Premium content rendering + + @Test + fun `premium content should render subscription card when viewState is Premium`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) } + composeTestRule + .onNodeWithTag("BillingAmountRow") + .assertExists() + } + + @Test + fun `premium plan name should render`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) } + composeTestRule + .onNodeWithText("Premium") + .assertIsDisplayed() + } + + @Test + fun `status badge should render with Active label for ACTIVE status`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE.copy( + status = PremiumSubscriptionStatus.ACTIVE, + ), + ) + } + composeTestRule + .onNodeWithText("Active") + .assertIsDisplayed() + } + + @Test + fun `status badge should render with Canceled label for CANCELED status`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE.copy( + status = PremiumSubscriptionStatus.CANCELED, + ), + ) + } + composeTestRule + .onNodeWithText("Canceled") + .assertIsDisplayed() + } + + @Test + fun `status badge should render with Overdue payment label for OVERDUE_PAYMENT status`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE.copy( + status = PremiumSubscriptionStatus.OVERDUE_PAYMENT, + ), + ) + } + composeTestRule + .onNodeWithText("Overdue payment") + .assertIsDisplayed() + } + + @Test + fun `status badge should render with Past due label for PAST_DUE status`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE.copy( + status = PremiumSubscriptionStatus.PAST_DUE, + ), + ) + } + composeTestRule + .onNodeWithText("Past due") + .assertIsDisplayed() + } + + @Test + fun `status badge should render with Paused label for PAUSED status`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE.copy( + status = PremiumSubscriptionStatus.PAUSED, + ), + ) + } + composeTestRule + .onNodeWithText("Paused") + .assertIsDisplayed() + } + + @Test + fun `status badge should not render when status is null`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(status = null), + ) + } + composeTestRule.onNodeWithText("Active").assertDoesNotExist() + composeTestRule.onNodeWithText("Canceled").assertDoesNotExist() + composeTestRule.onNodeWithText("Overdue payment").assertDoesNotExist() + composeTestRule.onNodeWithText("Past due").assertDoesNotExist() + composeTestRule.onNodeWithText("Paused").assertDoesNotExist() + } + + @Test + fun `description text should render when descriptionText is present`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) } + composeTestRule + .onNodeWithText("Your next charge is for $45.55 USD due on April 2, 2026.") + .assertIsDisplayed() + } + + @Test + fun `description text should not render when descriptionText is null`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(descriptionText = null), + ) + } + composeTestRule + .onNodeWithText("Your next charge is for $45.55 USD due on April 2, 2026.") + .assertDoesNotExist() + } + + // endregion Premium content rendering + + // region Line items + + @Test + fun `billing amount row should display billingAmountText value`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) } + composeTestRule + .onNodeWithTag("BillingAmountRow") + .assertExists() + composeTestRule + .onNodeWithText("$19.80 / year") + .assertIsDisplayed() + } + + @Test + fun `storage cost row should display storageCostText value`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) } + composeTestRule + .onNodeWithTag("StorageCostRow") + .assertExists() + composeTestRule + .onNodeWithText("$24.00") + .assertIsDisplayed() + } + + @Test + fun `discount row should display discountAmountText value`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) } + composeTestRule + .onNodeWithTag("DiscountRow") + .assertExists() + composeTestRule + .onNodeWithText("-$2.10") + .assertIsDisplayed() + } + + @Test + fun `estimated tax row should display estimatedTaxText value`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) } + composeTestRule + .onNodeWithTag("EstimatedTaxRow") + .assertExists() + composeTestRule + .onNodeWithText("$3.85") + .assertIsDisplayed() + } + + @Test + fun `line items should display -- placeholder when values are defaults`() { + mutableStateFlow.update { + it.copy(viewState = PlanState.ViewState.Premium()) + } + // Four rows, each displaying the default placeholder value "--". + composeTestRule + .onAllNodesWithText("--") + .assertCountEquals(4) + } + + // endregion Line items + + // region Action buttons + + @Test + fun `manage plan button click should send ManagePlanClick action`() { + mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) } + composeTestRule + .onNodeWithTag("ManagePlanButton") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(PlanAction.ManagePlanClick) } + } + + @Test + fun `cancel premium button should render when showCancelButton is true`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(showCancelButton = true), + ) + } + composeTestRule + .onNodeWithTag("CancelPremiumButton") + .assertExists() + } + + @Test + fun `cancel premium button should not render when showCancelButton is false`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(showCancelButton = false), + ) + } + composeTestRule + .onNodeWithTag("CancelPremiumButton") + .assertDoesNotExist() + } + + @Test + fun `cancel premium button click should send CancelPremiumClick action`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(showCancelButton = true), + ) + } + composeTestRule + .onNodeWithTag("CancelPremiumButton") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(PlanAction.CancelPremiumClick) } + } + + // endregion Action buttons + + // region Premium-flow dialogs + + @Test + fun `subscription error dialog should render when dialogState is SubscriptionError`() { + val title = "An error has occurred".asText() + val message = "Unable to load subscription.".asText() + + composeTestRule + .onAllNodesWithText("An error has occurred") + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + viewState = PlanState.ViewState.Premium(), + dialogState = PlanState.DialogState.SubscriptionError( + title = title, + message = message, + ), + ) + } + + composeTestRule + .onAllNodesWithText("An error has occurred") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + composeTestRule + .onAllNodesWithText("Unable to load subscription.") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + composeTestRule + .onAllNodesWithText("Try again") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + composeTestRule + .onAllNodesWithText("Close") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + } + + @Test + fun `subscription error dialog try again click should send RetrySubscriptionClick action`() { + mutableStateFlow.update { + it.copy( + viewState = PlanState.ViewState.Premium(), + dialogState = PlanState.DialogState.SubscriptionError( + title = "An error has occurred".asText(), + message = "Unable to load subscription.".asText(), + ), + ) + } + composeTestRule + .onAllNodesWithText("Try again") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(PlanAction.RetrySubscriptionClick) } + } + + @Test + fun `subscription error dialog close click should send BackClick action`() { + mutableStateFlow.update { + it.copy( + viewState = PlanState.ViewState.Premium(), + dialogState = PlanState.DialogState.SubscriptionError( + title = "An error has occurred".asText(), + message = "Unable to load subscription.".asText(), + ), + ) + } + composeTestRule + .onAllNodesWithText("Close") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(PlanAction.BackClick) } + } + + @Test + fun `loading portal dialog should render when dialogState is LoadingPortal`() { + composeTestRule + .onAllNodesWithText("Loading portal…") + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE, + dialogState = PlanState.DialogState.LoadingPortal, + ) + } + + composeTestRule + .onAllNodesWithText("Loading portal…") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + } + + @Test + fun `portal error dialog should render when dialogState is PortalError`() { + composeTestRule + .onAllNodesWithText("Something went wrong") + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE, + dialogState = PlanState.DialogState.PortalError, + ) + } + + composeTestRule + .onAllNodesWithText("Something went wrong") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + composeTestRule + .onAllNodesWithText( + "We had trouble loading the management portal, so try again.", + ) + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + composeTestRule + .onAllNodesWithText("Try again") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + composeTestRule + .onAllNodesWithText("Close") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + } + + @Test + fun `portal error dialog dismiss click should send DismissPortalError action`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE, + dialogState = PlanState.DialogState.PortalError, + ) + } + composeTestRule + .onAllNodesWithText("Close") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(PlanAction.DismissPortalError) } + } + + @Test + fun `cancel confirmation dialog should render when dialogState is CancelConfirmation`() { + composeTestRule + .onAllNodesWithText("Cancel Premium") + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE, + dialogState = PlanState.DialogState.CancelConfirmation( + nextRenewalDate = "April 2, 2026", + ), + ) + } + + composeTestRule + .onAllNodesWithText("Cancel Premium") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + composeTestRule + .onAllNodesWithText( + "You’ll continue to have Premium access until April 2, 2026.", + ) + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + } + + @Test + fun `cancel confirmation dialog confirm click should send ConfirmCancelClick action`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE, + dialogState = PlanState.DialogState.CancelConfirmation( + nextRenewalDate = "April 2, 2026", + ), + ) + } + composeTestRule + .onAllNodesWithText("Cancel now") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(PlanAction.ConfirmCancelClick) } + } + + @Test + fun `cancel confirmation dialog dismiss click should send DismissCancelConfirmation action`() { + mutableStateFlow.update { + it.copy( + viewState = DEFAULT_PREMIUM_VIEW_STATE, + dialogState = PlanState.DialogState.CancelConfirmation( + nextRenewalDate = "April 2, 2026", + ), + ) + } + composeTestRule + .onAllNodesWithText("Close") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(PlanAction.DismissCancelConfirmation) } + } + + // endregion Premium-flow dialogs + + // region LaunchPortal event + + @Test + fun `LaunchPortal event should call intentManager launchUri`() { + val url = "https://portal" + mutableEventFlow.tryEmit(PlanEvent.LaunchPortal(url = url)) + verify { intentManager.launchUri(url.toUri()) } + } + + // endregion LaunchPortal event } private val DEFAULT_FREE_STATE = PlanState( @@ -484,3 +919,17 @@ private val DEFAULT_FREE_STATE = PlanState( ), dialogState = null, ) + +private val DEFAULT_PREMIUM_VIEW_STATE = PlanState.ViewState.Premium( + status = PremiumSubscriptionStatus.ACTIVE, + descriptionText = BitwardenString.premium_next_charge_summary.asText( + "$45.55", + "April 2, 2026", + ), + billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"), + storageCostText = "$24.00", + discountAmountText = "-$2.10", + estimatedTaxText = "$3.85", + nextChargeDateText = "April 2, 2026", + showCancelButton = true, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt index 73626c43b6..1f33575bab 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt @@ -11,7 +11,12 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.billing.repository.BillingRepository import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult +import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult +import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence import com.x8bit.bitwarden.data.billing.repository.model.PremiumPlanPricingResult +import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus +import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo +import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance @@ -31,6 +36,10 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset @Suppress("LargeClass") class PlanViewModelTest : BaseViewModelTest() { @@ -775,38 +784,524 @@ class PlanViewModelTest : BaseViewModelTest() { // region Premium user path @Test - fun `initial state should be Premium ViewState for premium user`() = + fun `initial state should be Premium ViewState with loading dialog for premium user`() = runTest { - mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( - accounts = listOf(DEFAULT_ACCOUNT.copy(isPremium = true)), - ) + markUserPremium() val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(DEFAULT_PREMIUM_LOADING_STATE, awaitItem()) + } + } + + @Test + fun `init should fetch subscription for Premium viewstate`() = runTest { + markUserPremium() + + createViewModel(subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE) + + coVerify(exactly = 1) { + mockBillingRepository.getSubscription() + } + } + + @Test + fun `init should not fetch subscription for Free viewstate`() = runTest { + createViewModel() + + coVerify(exactly = 0) { + mockBillingRepository.getSubscription() + } + } + + @Test + fun `SubscriptionResultReceive Success should populate Premium state from SubscriptionInfo`() = + runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE, + ) + + viewModel.stateFlow.test { + assertEquals(DEFAULT_PREMIUM_LOADED_STATE, awaitItem()) + } + } + + @Test + fun `SubscriptionResultReceive Success with Canceled status should hide cancel button`() = + runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SubscriptionResult.Success( + subscription = SUBSCRIPTION_INFO_ACTIVE.copy( + status = PremiumSubscriptionStatus.CANCELED, + canceledDate = Instant.parse("2026-04-21T00:00:00Z"), + ), + ), + ) + viewModel.stateFlow.test { assertEquals( - PlanState( - planMode = PlanMode.Modal, - viewState = PlanState.ViewState.Premium, - dialogState = null, + DEFAULT_PREMIUM_LOADED_STATE.copy( + viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy( + status = PremiumSubscriptionStatus.CANCELED, + descriptionText = BitwardenString + .subscription_canceled_description + .asText("April 21, 2026"), + showCancelButton = false, + ), ), awaitItem(), ) } } + @Test + fun `SubscriptionResultReceive Success with OverduePayment status should describe overdue`() = + runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SubscriptionResult.Success( + subscription = SUBSCRIPTION_INFO_ACTIVE.copy( + status = PremiumSubscriptionStatus.OVERDUE_PAYMENT, + suspensionDate = Instant.parse("2026-04-21T00:00:00Z"), + ), + ), + ) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_PREMIUM_LOADED_STATE.copy( + viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy( + status = PremiumSubscriptionStatus.OVERDUE_PAYMENT, + descriptionText = BitwardenString + .subscription_overdue_description + .asText("April 21, 2026"), + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `SubscriptionResultReceive Success with PastDue status should describe grace period`() = + runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SubscriptionResult.Success( + subscription = SUBSCRIPTION_INFO_ACTIVE.copy( + status = PremiumSubscriptionStatus.PAST_DUE, + suspensionDate = Instant.parse("2026-04-21T00:00:00Z"), + gracePeriodDays = 7, + ), + ), + ) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_PREMIUM_LOADED_STATE.copy( + viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy( + status = PremiumSubscriptionStatus.PAST_DUE, + descriptionText = BitwardenString + .subscription_past_due_description + .asText(7, "April 21, 2026"), + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `SubscriptionResultReceive Success with PastDue and null gracePeriodDays uses fallback`() = + runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SubscriptionResult.Success( + subscription = SUBSCRIPTION_INFO_ACTIVE.copy( + status = PremiumSubscriptionStatus.PAST_DUE, + suspensionDate = Instant.parse("2026-04-21T00:00:00Z"), + gracePeriodDays = null, + ), + ), + ) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_PREMIUM_LOADED_STATE.copy( + viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy( + status = PremiumSubscriptionStatus.PAST_DUE, + descriptionText = BitwardenString + .subscription_past_due_description + .asText(0, "April 21, 2026"), + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `SubscriptionResultReceive Success with Paused status should describe paused`() = + runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SubscriptionResult.Success( + subscription = SUBSCRIPTION_INFO_ACTIVE.copy( + status = PremiumSubscriptionStatus.PAUSED, + ), + ), + ) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_PREMIUM_LOADED_STATE.copy( + viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy( + status = PremiumSubscriptionStatus.PAUSED, + descriptionText = BitwardenString + .subscription_paused_description + .asText(), + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `SubscriptionResultReceive Success with Monthly cadence formats per-month rate`() = + runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SubscriptionResult.Success( + subscription = SUBSCRIPTION_INFO_ACTIVE.copy( + cadence = PlanCadence.MONTHLY, + ), + ), + ) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_PREMIUM_LOADED_STATE.copy( + viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy( + billingAmountText = BitwardenString + .billing_rate_per_month + .asText("$19.80"), + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `SubscriptionResultReceive Success with zero seatsCost shows placeholder rate`() = + runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SubscriptionResult.Success( + subscription = SUBSCRIPTION_INFO_ACTIVE.copy( + seatsCost = BigDecimal.ZERO, + ), + ), + ) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_PREMIUM_LOADED_STATE.copy( + viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy( + billingAmountText = PLACEHOLDER.asText(), + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `SubscriptionResultReceive Success with null line items shows placeholder text`() = + runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SubscriptionResult.Success( + subscription = SUBSCRIPTION_INFO_ACTIVE.copy( + storageCost = null, + discountAmount = null, + ), + ), + ) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_PREMIUM_LOADED_STATE.copy( + viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy( + storageCostText = PLACEHOLDER, + discountAmountText = PLACEHOLDER, + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `SubscriptionResultReceive Success with zero line items shows placeholder text`() = + runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SubscriptionResult.Success( + subscription = SUBSCRIPTION_INFO_ACTIVE.copy( + storageCost = BigDecimal.ZERO, + discountAmount = BigDecimal.ZERO, + estimatedTax = BigDecimal.ZERO, + ), + ), + ) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_PREMIUM_LOADED_STATE.copy( + viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy( + storageCostText = PLACEHOLDER, + discountAmountText = PLACEHOLDER, + estimatedTaxText = PLACEHOLDER, + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `SubscriptionResultReceive Success with null nextCharge shows placeholder date`() = + runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SubscriptionResult.Success( + subscription = SUBSCRIPTION_INFO_ACTIVE.copy( + nextCharge = null, + ), + ), + ) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_PREMIUM_LOADED_STATE.copy( + viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy( + descriptionText = BitwardenString + .premium_next_charge_summary + .asText("$45.55", PLACEHOLDER), + nextChargeDateText = null, + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `SubscriptionResultReceive Error should show SubscriptionError dialog`() = runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SubscriptionResult.Error(error = RuntimeException("boom")), + ) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_PREMIUM_LOADING_STATE.copy( + dialogState = PlanState.DialogState.SubscriptionError( + title = BitwardenString.subscription_error.asText(), + message = BitwardenString + .trouble_loading_subscription + .asText(), + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `RetrySubscriptionClick should transition to Loading then refetch subscription`() = + runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE, + ) + + viewModel.stateFlow.test { + assertEquals(DEFAULT_PREMIUM_LOADED_STATE, awaitItem()) + + viewModel.trySendAction(PlanAction.RetrySubscriptionClick) + + assertEquals( + DEFAULT_PREMIUM_LOADED_STATE.copy( + dialogState = PlanState.DialogState.Loading( + message = BitwardenString.loading_subscription.asText(), + ), + ), + awaitItem(), + ) + assertEquals(DEFAULT_PREMIUM_LOADED_STATE, awaitItem()) + } + coVerify(exactly = 2) { mockBillingRepository.getSubscription() } + } + + @Test + fun `ManagePlanClick should show LoadingPortal then emit LaunchPortal on success`() = + runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE, + portalResult = CustomerPortalResult.Success(url = "https://portal"), + ) + + viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow -> + assertEquals(DEFAULT_PREMIUM_LOADED_STATE, stateFlow.awaitItem()) + + viewModel.trySendAction(PlanAction.ManagePlanClick) + + assertEquals( + DEFAULT_PREMIUM_LOADED_STATE.copy( + dialogState = PlanState.DialogState.LoadingPortal, + ), + stateFlow.awaitItem(), + ) + assertEquals( + PlanEvent.LaunchPortal(url = "https://portal"), + eventFlow.awaitItem(), + ) + assertEquals(DEFAULT_PREMIUM_LOADED_STATE, stateFlow.awaitItem()) + } + } + + @Test + fun `ManagePlanClick should show PortalError on failure`() = runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE, + portalResult = CustomerPortalResult.Error(error = RuntimeException("boom")), + ) + + viewModel.stateFlow.test { + assertEquals(DEFAULT_PREMIUM_LOADED_STATE, awaitItem()) + + viewModel.trySendAction(PlanAction.ManagePlanClick) + + assertEquals( + DEFAULT_PREMIUM_LOADED_STATE.copy( + dialogState = PlanState.DialogState.LoadingPortal, + ), + awaitItem(), + ) + assertEquals( + DEFAULT_PREMIUM_LOADED_STATE.copy( + dialogState = PlanState.DialogState.PortalError, + ), + awaitItem(), + ) + } + } + + @Test + fun `CancelPremiumClick should show CancelConfirmation with next renewal date`() = runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE, + ) + + viewModel.stateFlow.test { + assertEquals(DEFAULT_PREMIUM_LOADED_STATE, awaitItem()) + + viewModel.trySendAction(PlanAction.CancelPremiumClick) + + assertEquals( + DEFAULT_PREMIUM_LOADED_STATE.copy( + dialogState = PlanState.DialogState.CancelConfirmation( + nextRenewalDate = "April 2, 2026", + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `DismissCancelConfirmation should clear dialog`() = runTest { + markUserPremium() + + val viewModel = createViewModel( + subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE, + ) + + viewModel.stateFlow.test { + assertEquals(DEFAULT_PREMIUM_LOADED_STATE, awaitItem()) + + viewModel.trySendAction(PlanAction.CancelPremiumClick) + assertEquals( + DEFAULT_PREMIUM_LOADED_STATE.copy( + dialogState = PlanState.DialogState.CancelConfirmation( + nextRenewalDate = "April 2, 2026", + ), + ), + awaitItem(), + ) + + viewModel.trySendAction(PlanAction.DismissCancelConfirmation) + assertEquals(DEFAULT_PREMIUM_LOADED_STATE, awaitItem()) + } + } + + private fun markUserPremium() { + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf(DEFAULT_ACCOUNT.copy(isPremium = true)), + ) + } + // endregion Premium user path + @Suppress("LongParameterList") private fun createViewModel( initialState: PlanState? = null, planMode: PlanMode = PlanMode.Modal, pricingResult: PremiumPlanPricingResult? = DEFAULT_PRICING_SUCCESS, + subscriptionResult: SubscriptionResult? = null, + portalResult: CustomerPortalResult? = null, + clock: Clock = FIXED_CLOCK, ): PlanViewModel { coEvery { mockBillingRepository.getPremiumPlanPricing() } coAnswers { pricingResult ?: awaitCancellation() } + coEvery { + mockBillingRepository.getSubscription() + } coAnswers { + subscriptionResult ?: awaitCancellation() + } + coEvery { + mockBillingRepository.getPortalUrl() + } coAnswers { + portalResult ?: awaitCancellation() + } val savedStateHandle = SavedStateHandle().apply { set("state", initialState) every { toPlanArgs() } returns PlanArgs(planMode = planMode) @@ -817,6 +1312,7 @@ class PlanViewModelTest : BaseViewModelTest() { billingRepository = mockBillingRepository, specialCircumstanceManager = mockSpecialCircumstanceManager, vaultRepository = mockVaultRepository, + clock = clock, ) } } @@ -860,6 +1356,59 @@ private val DEFAULT_FREE_STATE = PlanState( ) private const val ANNUAL_PRICE = 19.99 + +private val FIXED_CLOCK: Clock = Clock.fixed( + Instant.parse("2026-04-21T00:00:00Z"), + ZoneOffset.UTC, +) + +private val SUBSCRIPTION_INFO_ACTIVE = SubscriptionInfo( + status = PremiumSubscriptionStatus.ACTIVE, + cadence = PlanCadence.ANNUALLY, + seatsCost = BigDecimal("19.80"), + storageCost = BigDecimal("24.00"), + discountAmount = BigDecimal("2.10"), + estimatedTax = BigDecimal("3.85"), + nextChargeTotal = BigDecimal("45.55"), + nextCharge = Instant.parse("2026-04-02T00:00:00Z"), + canceledDate = null, + suspensionDate = null, + gracePeriodDays = null, +) + +private val SUBSCRIPTION_SUCCESS_ACTIVE = + SubscriptionResult.Success(subscription = SUBSCRIPTION_INFO_ACTIVE) + private val DEFAULT_PRICING_SUCCESS = PremiumPlanPricingResult.Success( annualPrice = ANNUAL_PRICE, ) + +private const val PLACEHOLDER = "--" + +private val DEFAULT_PREMIUM_ACTIVE_VIEW_STATE = PlanState.ViewState.Premium( + status = PremiumSubscriptionStatus.ACTIVE, + descriptionText = BitwardenString.premium_next_charge_summary.asText( + "$45.55", + "April 2, 2026", + ), + billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"), + storageCostText = "$24.00", + discountAmountText = "-$2.10", + estimatedTaxText = "$3.85", + nextChargeDateText = "April 2, 2026", + showCancelButton = true, +) + +private val DEFAULT_PREMIUM_LOADED_STATE = PlanState( + planMode = PlanMode.Modal, + viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE, + dialogState = null, +) + +private val DEFAULT_PREMIUM_LOADING_STATE = PlanState( + planMode = PlanMode.Modal, + viewState = PlanState.ViewState.Premium(), + dialogState = PlanState.DialogState.Loading( + message = BitwardenString.loading_subscription.asText(), + ), +) diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 8e3bb0b379..df16800469 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1267,4 +1267,32 @@ Do you want to switch to this account? Archive item Once archived, this item will be excluded from search results and autofill suggestions. Pricing unavailable + Current plan + Premium + Billing amount + Storage cost + Discount + Estimated tax + %1$s / year + %1$s / month + Manage plan + Cancel Premium + Cancel now + You’ll continue to have Premium access until %1$s. + Active + Canceled + Overdue payment + Past due + Paused + Your next charge is for %1$s USD due on %2$s. + Your subscription was canceled on %1$s. Resubscribe to continue using premium features. + We couldn’t process your payment. Update your payment before your subscription ends on %1$s. + You have a grace period of %1$d days from your subscription expiration date. Please resolve the past due amount by %2$s. + Your subscription is paused. Resume to continue using premium features. + Loading subscription… + Loading portal… + Something went wrong + We had trouble loading the management portal, so try again. + Subscription error + We couldn’t load your subscription details. Please try again. From 7231e14488354398c4c72603ce4ce6c99bff2a3e Mon Sep 17 00:00:00 2001 From: David Perez Date: Tue, 28 Apr 2026 15:56:41 -0500 Subject: [PATCH 13/16] PM-32814: Chore: Parsing lists safely (#6846) --- .../network/di/PlatformNetworkModule.kt | 3 + .../network/di/PlatformNetworkModule.kt | 3 + .../data/serializer/SafeListSerializer.kt | 35 +++++++++ .../core/data/util/JsonExtensions.kt | 18 +++++ .../com/bitwarden/core/di/CoreModule.kt | 2 + .../data/serializer/SafeListSerializerTest.kt | 77 +++++++++++++++++++ .../core/data/util/JsonExtensionsTest.kt | 25 ++++++ .../network/BitwardenServiceClient.kt | 7 +- .../network/BitwardenServiceClientImpl.kt | 21 +---- .../network/model/SyncResponseJson.kt | 1 + .../network/service/SyncServiceTest.kt | 2 +- 11 files changed, 172 insertions(+), 22 deletions(-) create mode 100644 core/src/main/kotlin/com/bitwarden/core/data/serializer/SafeListSerializer.kt create mode 100644 core/src/test/kotlin/com/bitwarden/core/data/serializer/SafeListSerializerTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt index 66bc11472b..57fb937c8d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt @@ -19,6 +19,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json import java.time.Clock import javax.inject.Singleton @@ -77,7 +78,9 @@ object PlatformNetworkModule { @Singleton fun provideBitwardenServiceClient( serviceClientConfig: BitwardenServiceClientConfig, + json: Json, ): BitwardenServiceClient = bitwardenServiceClient( config = serviceClientConfig, + json = json, ) } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/di/PlatformNetworkModule.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/di/PlatformNetworkModule.kt index c1c093e479..6ced3891e6 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/di/PlatformNetworkModule.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/data/platform/datasource/network/di/PlatformNetworkModule.kt @@ -20,6 +20,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json import java.net.Socket import java.security.Principal import java.security.PrivateKey @@ -86,8 +87,10 @@ object PlatformNetworkModule { @Singleton fun provideBitwardenServiceClient( serviceClientConfig: BitwardenServiceClientConfig, + json: Json, ): BitwardenServiceClient = bitwardenServiceClient( config = serviceClientConfig, + json = json, ) @Provides diff --git a/core/src/main/kotlin/com/bitwarden/core/data/serializer/SafeListSerializer.kt b/core/src/main/kotlin/com/bitwarden/core/data/serializer/SafeListSerializer.kt new file mode 100644 index 0000000000..3671f6e44a --- /dev/null +++ b/core/src/main/kotlin/com/bitwarden/core/data/serializer/SafeListSerializer.kt @@ -0,0 +1,35 @@ +package com.bitwarden.core.data.serializer + +import com.bitwarden.core.data.util.decodeFromJsonElementOrNull +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.jsonArray + +/** + * A [KSerializer] for parsing lists of items and allows for the individual items in the array + * to fail when deserialize without returning an error. If any number of items fail to be parsed, + * they are removed from the list. + */ +class SafeListSerializer( + private val innerSerializer: KSerializer, +) : KSerializer> { + private val listSerializer: KSerializer> = ListSerializer(innerSerializer) + override val descriptor: SerialDescriptor = listSerializer.descriptor + + override fun serialize( + encoder: Encoder, + value: List, + ): Unit = listSerializer.serialize(encoder, value) + + override fun deserialize( + decoder: Decoder, + ): List = with(decoder as JsonDecoder) { + decodeJsonElement() + .jsonArray + .mapNotNull { json.decodeFromJsonElementOrNull(innerSerializer, it) } + } +} diff --git a/core/src/main/kotlin/com/bitwarden/core/data/util/JsonExtensions.kt b/core/src/main/kotlin/com/bitwarden/core/data/util/JsonExtensions.kt index 26045618a2..3bf71e9c5b 100644 --- a/core/src/main/kotlin/com/bitwarden/core/data/util/JsonExtensions.kt +++ b/core/src/main/kotlin/com/bitwarden/core/data/util/JsonExtensions.kt @@ -1,7 +1,25 @@ package com.bitwarden.core.data.util +import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +/** + * Attempts to decode the given JSON [element] into the given type [T]. If there is an error in + * processing the JSON or deserializing it to an instance of [T], `null` will be returned. + */ +fun Json.decodeFromJsonElementOrNull( + deserializer: DeserializationStrategy, + element: JsonElement, +): T? = + try { + decodeFromJsonElement(deserializer = deserializer, element = element) + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } /** * Attempts to decode the given JSON [string] into the given type [T]. If there is an error in diff --git a/core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt b/core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt index 3351f7c8fe..3de6651324 100644 --- a/core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt +++ b/core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt @@ -3,6 +3,7 @@ package com.bitwarden.core.di import com.bitwarden.core.data.manager.BuildInfoManager import com.bitwarden.core.data.serializer.BigDecimalSerializer import com.bitwarden.core.data.serializer.InstantSerializer +import com.bitwarden.core.data.serializer.SafeListSerializer import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -36,6 +37,7 @@ object CoreModule { serializersModule = SerializersModule { contextual(InstantSerializer()) contextual(BigDecimalSerializer()) + contextual(List::class) { args -> SafeListSerializer(args.first()) } } // Respect model default property values. diff --git a/core/src/test/kotlin/com/bitwarden/core/data/serializer/SafeListSerializerTest.kt b/core/src/test/kotlin/com/bitwarden/core/data/serializer/SafeListSerializerTest.kt new file mode 100644 index 0000000000..94e9f75278 --- /dev/null +++ b/core/src/test/kotlin/com/bitwarden/core/data/serializer/SafeListSerializerTest.kt @@ -0,0 +1,77 @@ +package com.bitwarden.core.data.serializer + +import com.bitwarden.core.di.CoreModule +import io.mockk.mockk +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SafeListSerializerTest { + private val json = CoreModule.providesJson(buildInfoManager = mockk(relaxed = true)) + + @Test + fun `deserializes JSON successfully with invalid items`() { + assertEquals( + TestClass(data = listOf(TestClass.InnerTestClass(string = "test"))), + json.decodeFromString( + """ + { + "data": [ + { "string": "test" }, + { "string": null } + ] + } + """, + ), + ) + } + + @Test + fun `deserializes JSON successfully with null list`() { + assertEquals( + TestClass(data = null), + json.decodeFromString( + """ + { + "data": null + } + """, + ), + ) + } + + @Test + fun `serialized JSON successfully`() { + assertEquals( + buildJsonObject { + putJsonArray(key = "data") { + addJsonObject { + put(key = "string", value = "test") + } + } + }, + json.encodeToJsonElement( + serializer = TestClass.serializer(), + value = TestClass(data = listOf(TestClass.InnerTestClass(string = "test"))), + ), + ) + } +} + +@Serializable +private data class TestClass( + @Serializable(with = SafeListSerializer::class) + @SerialName("data") + val data: List?, +) { + @Serializable + data class InnerTestClass( + @SerialName("string") + val string: String, + ) +} diff --git a/core/src/test/kotlin/com/bitwarden/core/data/util/JsonExtensionsTest.kt b/core/src/test/kotlin/com/bitwarden/core/data/util/JsonExtensionsTest.kt index 9ebde4f9de..50d5cbba0b 100644 --- a/core/src/test/kotlin/com/bitwarden/core/data/util/JsonExtensionsTest.kt +++ b/core/src/test/kotlin/com/bitwarden/core/data/util/JsonExtensionsTest.kt @@ -3,6 +3,8 @@ package com.bitwarden.core.data.util import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test @@ -10,6 +12,29 @@ import org.junit.jupiter.api.Test class JsonExtensionsTest { private val json = Json + @Test + fun `decodeFromJsonElementOrNull for valid JSON but an incorrect model should return null`() { + assertNull( + json.decodeFromJsonElementOrNull( + deserializer = TestData.serializer(), + element = buildJsonObject {}, + ), + ) + } + + @Test + fun `decodeFromJsonElementOrNull for valid JSON and a correct model should parse correctly`() { + assertEquals( + TestData(data = "test"), + json.decodeFromJsonElementOrNull( + deserializer = TestData.serializer(), + element = buildJsonObject { + put(key = "data", value = "test") + }, + ), + ) + } + @Test fun `decodeFromStringOrNull for invalid JSON should return null`() { assertNull( diff --git a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt index b5bfab1dd6..1824ea01a2 100644 --- a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt +++ b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt @@ -24,6 +24,7 @@ import com.bitwarden.network.service.OrganizationService import com.bitwarden.network.service.PushService import com.bitwarden.network.service.SendsService import com.bitwarden.network.service.SyncService +import kotlinx.serialization.json.Json /** * Provides access to Bitwarden services. @@ -176,4 +177,8 @@ interface BitwardenServiceClient { */ fun bitwardenServiceClient( config: BitwardenServiceClientConfig, -): BitwardenServiceClient = BitwardenServiceClientImpl(config) + json: Json, +): BitwardenServiceClient = BitwardenServiceClientImpl( + bitwardenServiceClientConfig = config, + clientJson = json, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt index 342726cb1f..021d65001d 100644 --- a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt @@ -1,8 +1,6 @@ package com.bitwarden.network import com.bitwarden.annotation.OmitFromCoverage -import com.bitwarden.core.data.serializer.BigDecimalSerializer -import com.bitwarden.core.data.serializer.InstantSerializer import com.bitwarden.network.interceptor.AuthTokenManager import com.bitwarden.network.interceptor.BaseUrlInterceptors import com.bitwarden.network.interceptor.CookieInterceptor @@ -45,8 +43,6 @@ import com.bitwarden.network.service.PushServiceImpl import com.bitwarden.network.service.SendsServiceImpl import com.bitwarden.network.service.SyncServiceImpl import kotlinx.serialization.json.Json -import kotlinx.serialization.modules.SerializersModule -import kotlinx.serialization.modules.contextual import retrofit2.create /** @@ -55,6 +51,7 @@ import retrofit2.create @OmitFromCoverage internal class BitwardenServiceClientImpl( private val bitwardenServiceClientConfig: BitwardenServiceClientConfig, + private val clientJson: Json, ) : BitwardenServiceClient { private val authTokenManager: AuthTokenManager = AuthTokenManager( @@ -64,23 +61,7 @@ internal class BitwardenServiceClientImpl( override val tokenProvider: TokenProvider = authTokenManager override val cookieProvider: CookieProvider = bitwardenServiceClientConfig.cookieProvider - private val clientJson = Json { - // If there are keys returned by the server not modeled by a serializable class, - // ignore them. - // This makes additive server changes non-breaking. - ignoreUnknownKeys = true - - // We allow for nullable values to have keys missing in the JSON response. - explicitNulls = false - serializersModule = SerializersModule { - contextual(InstantSerializer()) - contextual(BigDecimalSerializer()) - } - - // Respect model default property values. - coerceInputValues = true - } private val retrofits: Retrofits by lazy { RetrofitsImpl( authTokenManager = authTokenManager, diff --git a/network/src/main/kotlin/com/bitwarden/network/model/SyncResponseJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/SyncResponseJson.kt index 1ac96dcce2..2b27bb87dd 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/SyncResponseJson.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/SyncResponseJson.kt @@ -36,6 +36,7 @@ data class SyncResponseJson( @JsonNames("Profile") val profile: Profile, + @Contextual @SerialName("ciphers") val ciphers: List?, diff --git a/network/src/test/kotlin/com/bitwarden/network/service/SyncServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/SyncServiceTest.kt index 13def0829d..0e28169717 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/SyncServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/SyncServiceTest.kt @@ -310,7 +310,7 @@ private const val SYNC_SUCCESS_JSON = """ "mockCollectionId-1" ], "name": "mockName-1", - "id": "mockId-1" + "id": "mockId-1", "fields": [ { "linkedId": 100, From 771090d529f9139e4c48933d5956cf302f05f72e Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Wed, 29 Apr 2026 04:46:08 -0400 Subject: [PATCH 14/16] [PM-35116] llm: Remove local agents and delivery skills, migrate to marketplace plugins (#6799) --- .claude/CLAUDE.md | 35 ++-- .claude/agents/android-architect/AGENT.md | 162 ------------------ .claude/agents/android-implementer/AGENT.md | 58 ------- .claude/commands/work-on-android.md | 6 +- .../committing-android-changes/SKILL.md | 81 --------- .../creating-android-pull-request/SKILL.md | 79 --------- .../skills/labeling-android-changes/SKILL.md | 40 ----- .../SKILL.md | 37 ---- 8 files changed, 23 insertions(+), 475 deletions(-) delete mode 100644 .claude/agents/android-architect/AGENT.md delete mode 100644 .claude/agents/android-implementer/AGENT.md delete mode 100644 .claude/skills/committing-android-changes/SKILL.md delete mode 100644 .claude/skills/creating-android-pull-request/SKILL.md delete mode 100644 .claude/skills/labeling-android-changes/SKILL.md delete mode 100644 .claude/skills/perform-android-preflight-checklist/SKILL.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 3112918762..46004807ac 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -58,22 +58,27 @@ User Request (UI Action) ### Workflow Skills -> **Quick start**: Use the `android-architect` agent (or `/plan-android-work `) to refine requirements and plan, -> then the `android-implementer` agent (or `/work-on-android `) for implementation, +> **Quick start**: Use the `bitwarden-tech-lead:bitwarden-tech-lead` agent (or `/plan-android-work `) to refine +> requirements and plan, +> then the `bitwarden-software-engineer:bitwarden-software-engineer` agent (or `/work-on-android `) for implementation, > then `/review-android ` to review the result. -Planning: 1–2 | Implementation: 3–7 | Review & PR: 8–10 +## Skills & Commands -1. `refining-android-requirements` - Gap analysis and structured spec from any input source -2. `planning-android-implementation` - Architecture design and phased task breakdown -3. `implementing-android-code` - Patterns, gotchas, and templates for writing code -4. `testing-android-code` - Test patterns and templates for verifying code -5. `build-test-verify` - Build, test, lint, and deploy commands -6. `perform-android-preflight-checklist` - Quality gate before committing -7. `committing-android-changes` - Commit message format and pre-commit workflow -8. `reviewing-changes` - Android-specific MVVM/Compose code review checklists (invoked by `/review-android`) -9. `/review-android` - Full review workflow: PR context gathering → Android checklist → output -10. `creating-android-pull-request` - PR creation workflow and templates +| Skill | Triggers | +|-------|---------| +| `build-test-verify` | "build", "run tests", "lint", "format", "verify build" | +| `implementing-android-code` | "implement", "write code", "add screen", "create feature" | +| `planning-android-implementation` | "plan implementation", "architecture design", "phased task breakdown" | +| `refining-android-requirements` | "refine requirements", "analyze ticket", "gap analysis" | +| `reviewing-changes` | "review", "code review", "check PR" | +| `testing-android-code` | "write tests", "add test coverage", "unit test" | + +| Command | Usage | +|---------|-------| +| `/plan-android-work ` | Fetch ticket → refine requirements → design implementation approach | +| `/work-on-android ` | Full workflow: implement → test → verify → preflight → commit → review → PR | +| `/review-android ` | Full review workflow: PR context gathering → Android checklist → output | --- @@ -125,8 +130,8 @@ In addition to the Key Principles above, follow these rules: - **Before writing code**: Use `implementing-android-code` skill for Bitwarden-specific patterns, gotchas, and templates - **Before writing tests**: Use `testing-android-code` skill for test patterns and templates - **Building/testing**: Use `build-test-verify` skill | App tests: `./gradlew app:testStandardDebugUnitTest` -- **Before committing**: Use `perform-android-preflight-checklist` skill, then `committing-android-changes` skill for message format +- **Before committing**: Use `bitwarden-delivery-tools:perform-preflight` skill, then `bitwarden-delivery-tools:committing-changes` skill for message format - **Code review**: Use `/review-android` for the full review workflow; `reviewing-changes` skill for checklist-only -- **Creating PRs**: Use `creating-android-pull-request` skill for PR workflow and templates +- **Creating PRs**: Use `bitwarden-delivery-tools:creating-pull-request` skill for PR workflow and templates - **Troubleshooting**: See `docs/TROUBLESHOOTING.md` - **Architecture**: `docs/ARCHITECTURE.md` | [Bitwarden SDK](https://github.com/bitwarden/sdk) | [Jetpack Compose](https://developer.android.com/jetpack/compose) | [Hilt DI](https://dagger.dev/hilt/) diff --git a/.claude/agents/android-architect/AGENT.md b/.claude/agents/android-architect/AGENT.md deleted file mode 100644 index cdcd34a128..0000000000 --- a/.claude/agents/android-architect/AGENT.md +++ /dev/null @@ -1,162 +0,0 @@ ---- -name: android-architect -description: "Plans, architects, and refines implementation details for Android features in the Bitwarden Android codebase before any code is written. Use at the START of any new feature, significant change, Jira ticket, or when requirements need clarification and gap analysis. Proactively suggest when the user describes a feature, shares a ticket, or asks to plan Android work. Produces a structured, phased implementation plan ready for the android-implementer agent." -model: opus -color: green -tools: Read, Glob, Grep, Write, Edit, Agent, Skill(refining-android-requirements), Skill(planning-android-implementation), Skill(plan-android-work), mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_issue, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_issue_comments, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__search_issues, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__search_confluence, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_confluence_page ---- - -You are the Android Architect — an elite software architect and senior Android engineer with deep mastery of the Bitwarden Android codebase. You operate as a planning and design authority, responsible for transforming vague requirements, tickets, or feature ideas into precise, actionable, phased implementation plans before any code is written. - -Your primary workflow is `Skill(plan-android-work)`, which encompasses two sequential phases: -1. **`Skill(refining-android-requirements)`** — Gap analysis, ambiguity resolution, and structured specification -2. **`Skill(planning-android-implementation)`** — Architecture design, pattern selection, and phased task breakdown - ---- - -## Core Responsibilities - -### Phase 1: Requirements Refinement (`Skill(refining-android-requirements)`) - -Before any planning begins, you must fully understand what is being built. You will: - -1. **Parse and Extract Intent**: Identify the core feature request, affected modules (`:app`, `:authenticator`, shared), and user-facing vs. internal scope. - -2. **Identify Gaps**: Actively look for missing information: - - Ambiguous acceptance criteria - - Undefined edge cases (empty states, error states, loading states, network failure) - - Missing security or zero-knowledge implications - - Unclear UI/UX behavior - - Unspecified API contracts or SDK interactions - - Missing test coverage expectations - -3. **Produce Structured Specification**: Output a refined spec with: - - Feature summary (1-2 sentences) - - Affected modules and components - - Functional requirements (numbered list) - - Non-functional requirements (performance, security, accessibility) - - Open questions that MUST be resolved before implementation (ask the user if needed) - - Assumptions being made (document clearly) - -### Phase 2: Implementation Planning (`Skill(planning-android-implementation)`) - -With a refined spec, produce a comprehensive implementation plan: - -1. **Architecture Design**: - - Identify which ViewModel(s), Repository(ies), and data sources are involved - - Define new interfaces and their `...Impl` counterparts - - Map UDF flow: UI Actions → ViewModel → Repository → SDK/Network/Disk → DataState - - Identify required State, Action, and Event sealed class members - - Note any new Hilt modules or injection changes required - -2. **Pattern Selection**: - - Identify existing patterns in the codebase that apply - - Flag any cases where a new pattern might be needed (rare — prefer established patterns) - - Reference relevant existing files as implementation guides - -3. **Phased Task Breakdown**: Organize work into logical phases: - - Phase 1: Data layer (repositories, data sources, models) - - Phase 2: Domain/business logic (ViewModel, state management) - - Phase 3: UI layer (Compose screens, previews, navigation) - - Phase 4: Tests (unit tests per component, integration where needed) - - Phase 5: Polish (strings, accessibility, edge cases) - -4. **Dependency and Risk Analysis**: - - Identify blocking dependencies between tasks - - Flag high-risk areas (security, crypto, SDK interactions) - - Note areas requiring special care (e.g., DataState streaming, coroutine context) - -5. **File Manifest**: List all files to be created or modified with brief descriptions. - ---- - -## Bitwarden Android Expertise - -You have deep knowledge of this codebase and must apply it in every plan: - -### Architecture Constraints -- **No exceptions from data layer**: All suspending functions must return `Result` or sealed classes -- **State hoisting**: All behavior-affecting state lives in ViewModel's state — never in composables -- **Interface-based DI**: Every implementation has an interface counterpart with Hilt injection -- **UDF strictly enforced**: State flows down, actions flow up — no bidirectional data flow -- **Internal actions for coroutines**: Never update state directly inside `launch` blocks; map results to `Internal` actions first - -### Zero-Knowledge Security Rules (NON-NEGOTIABLE) -- Never transmit unencrypted vault data or master passwords to the server -- All encryption via Bitwarden SDK — never implement custom crypto -- Use scoped SDK sources (`ScopedVaultSdkSource`) to prevent cross-user context leakage -- On logout, all sensitive data cleared via `UserLogoutManager.logout()` -- Store sensitive data only via Android Keystore or SDK-encrypted storage - -### Code Style Requirements -- 100-character line limit -- `camelCase` for vars/functions, `PascalCase` for classes, `SCREAMING_SNAKE_CASE` for constants -- `...Impl` suffix for all implementations -- KDoc required for all public APIs -- Test constants at bottom of file — NO companion objects in tests -- String resources in `:ui` module (`ui/src/main/res/values/strings.xml`) using typographic quotes - ---- - -## Output Format - -Your output must always be a structured planning document with these sections: - -``` -# Implementation Plan: [Feature Name] - -## Refined Requirements -### Summary -### Functional Requirements -### Non-Functional Requirements -### Assumptions -### Open Questions (if any — request answers from user before proceeding) - -## Architecture Design -### Affected Components -### New Interfaces & Implementations -### UDF Flow Diagram (text-based) -### State / Action / Event Definitions - -## Phased Implementation Plan -### Phase 1: [Name] — [Estimated scope] -- Task 1.1: ... -- Task 1.2: ... -### Phase 2: ... -... - -## File Manifest -### New Files -### Modified Files - -## Risk & Dependency Notes - -## Handoff Notes for Implementer -``` - ---- - -## Behavioral Guidelines - -### DO -- Explore the codebase (via sub-agents) to understand existing patterns before designing — never assume file locations or implementations -- Ask clarifying questions BEFORE producing a plan if critical information is missing -- Reference specific existing files and patterns as implementation guides in your plan -- Apply security considerations proactively — flag any zero-knowledge implications -- Produce plans detailed enough that an implementer needs no additional context -- Note when existing patterns should be reused vs. when genuinely new patterns are warranted - -### DON'T -- Write implementation code — your job ends where the implementer's begins -- Assume requirements are complete — always perform gap analysis -- Invent new architectural patterns when established ones exist -- Ignore security implications of any feature touching vault data, credentials, or keys -- Produce vague tasks — every task must be concrete and actionable -- Skip the requirements refinement phase even for seemingly simple requests - -### Codebase Exploration Protocol -Before designing any architecture, deploy exploration sub-agents to: -- Locate relevant existing ViewModels, Repositories, and data sources -- Understand current patterns for similar features -- Identify reusable components and shared infrastructure -- Check for existing test patterns to replicate diff --git a/.claude/agents/android-implementer/AGENT.md b/.claude/agents/android-implementer/AGENT.md deleted file mode 100644 index 0ba22d669c..0000000000 --- a/.claude/agents/android-implementer/AGENT.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -name: android-implementer -description: "Autonomously implements features, fixes bugs, and completes development tasks on the Bitwarden Android project. Drives the full /work-on-android lifecycle (implement, test, build, preflight, commit) with self-review at each phase. Use when the user wants end-to-end implementation without manual phase approvals. Proactively suggest after /plan-android-work completes or when planning output is ready for implementation." -model: opus -color: green -tools: Bash, Read, Edit, Write, Glob, Grep, LSP, Agent, Skill(implementing-android-code), Skill(testing-android-code), Skill(build-test-verify), Skill(perform-android-preflight-checklist), Skill(committing-android-changes), Skill(work-on-android) ---- - -You are an elite Android implementation engineer specialized in the Bitwarden Android codebase. Your role is to autonomously drive implementation from start to finish, acting as both the implementer and the quality reviewer at each phase. - -## First Action: Invoke `/work-on-android` - -**Immediately invoke the `work-on-android` skill using the Skill tool.** This is your primary workflow — it defines the phases, invokes the correct sub-skills, and structures the entire implementation lifecycle. Do not manually orchestrate individual skills; let `/work-on-android` drive the phase sequence. - -Your added value on top of `/work-on-android` is autonomy: where the skill asks for user confirmation between phases, you provide that confirmation yourself by applying the self-review protocol below. Do not wait for human approval between phases — evaluate your own output, refine if necessary, and advance. - -## Self-Review Protocol - -At each phase transition where `/work-on-android` would normally ask the user to confirm, apply this review instead: - -``` ---- Phase Review: [Phase Name] --- -Status: APPROVED / NEEDS REFINEMENT -Findings: [brief assessment] -Action: [Proceeding to next phase / Iterating on: X] ---- -``` - -If status is NEEDS REFINEMENT, iterate up to 3 times before proceeding with the best available output and noting remaining concerns. - -**Review criteria by phase:** -- **Implementation**: Follows skill guidance and CLAUDE.md anti-patterns list? -- **Testing**: Covers happy path, error cases, and edge cases? -- **Build & Verify**: All tests pass? No compilation errors or warnings? -- **Preflight**: Would this pass code review by a senior engineer? -- **Commit**: Message clear, properly formatted, and accurate? - -## Decision-Making Framework - -- **When uncertain about a pattern**: Search the codebase for existing examples. Follow what exists rather than inventing. -- **When finding multiple valid approaches**: Choose the one most consistent with nearby code in the same module. -- **When discovering scope creep**: Note it as a follow-up item and stay focused on the original task. -- **When tests fail**: Diagnose the root cause, fix it, and re-run. Don't skip failing tests. -- **When a phase produces subpar output**: Iterate. Don't advance with known deficiencies unless you've exhausted reasonable refinement attempts. - -## Communication Style - -- Be concise and direct in phase transition summaries -- Provide detailed technical reasoning only when making non-obvious decisions -- Flag any genuine blockers that require human input clearly and specifically -- At completion, provide a summary of what was implemented, what was tested, and any follow-up items - -## Critical Rules - -1. **Minimize user interruptions**: Only escalate for genuine ambiguities that codebase context cannot resolve. -2. **Never skip testing**: Every implementation phase must have corresponding tests. -3. **Never invent new patterns**: Use established codebase patterns. Search for examples first. -4. **Never leave the codebase in a broken state**: If you can't complete a phase cleanly, revert and explain why. diff --git a/.claude/commands/work-on-android.md b/.claude/commands/work-on-android.md index d984037b8a..5167c37e91 100644 --- a/.claude/commands/work-on-android.md +++ b/.claude/commands/work-on-android.md @@ -35,13 +35,13 @@ Invoke `Skill(build-test-verify)` to run tests, lint, and detekt. Ensure everyth ### Phase 4: Self-Review -Invoke `Skill(perform-android-preflight-checklist)` to perform a quality gate check on all changes. Address any issues found. +Invoke `Skill(bitwarden-delivery-tools:perform-preflight)` to perform a quality gate check on all changes. Address any issues found. **Before advancing**: Share the self-review results and confirm readiness to commit. ### Phase 5: Commit -Invoke `Skill(committing-android-changes)` to stage and commit the changes with a properly formatted commit message. +Invoke `Skill(bitwarden-delivery-tools:committing-changes)` to stage and commit the changes with a properly formatted commit message. **Before advancing**: Confirm the commit was successful and ask if the user wants to proceed to review and PR creation, or stop here. @@ -56,7 +56,7 @@ Launch a subagent with the `/bitwarden-code-review:code-review-local` command to ### Phase 7: Pull Request -Prompt the user to invoke `Skill(creating-android-pull-request)` to create the pull request with proper description and formatting. **Create as a draft PR by default** unless the user has explicitly requested a ready-for-review PR. +Prompt the user to invoke `Skill(bitwarden-delivery-tools:creating-pull-request)` to create the pull request with proper description and formatting. **Create as a draft PR by default** unless the user has explicitly requested a ready-for-review PR. ## Guidelines diff --git a/.claude/skills/committing-android-changes/SKILL.md b/.claude/skills/committing-android-changes/SKILL.md deleted file mode 100644 index f2c77fbf39..0000000000 --- a/.claude/skills/committing-android-changes/SKILL.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -name: committing-android-changes -version: 0.1.0 -description: Git commit conventions and workflow for Bitwarden Android. Use when committing code, writing commit messages, or preparing changes for commit. Triggered by "commit", "git commit", "commit message", "prepare commit", "stage changes". ---- - -# Git Commit Conventions - -## Commit Message Format - -``` -[PM-XXXXX] : - - -``` - -### Rules - -1. **Ticket prefix**: Always include `[PM-XXXXX]` matching the Jira ticket -2. **Type keyword**: Include a conventional commit type after the ticket prefix (see table below) -3. **Imperative mood**: "Add feature" not "Added feature" or "Adds feature" -4. **Short summary**: Under 72 characters for the first line -5. **Body**: Explain the "why" not the "what" — the diff shows the what - -### Type Keywords - -Invoke the `labeling-android-changes` skill for the full type keyword table and selection guidance. - -### Example - -``` -[PM-12345] feat: Add biometric unlock timeout configuration - -Users reported confusion about when biometric prompts appear. -This adds a configurable timeout setting to the security preferences. -``` - -### Followup Commits - -Only the first commit on a branch needs the full format (ticket prefix, type keyword, body). Subsequent commits — whether addressing review feedback, making intermediate changes, or iterating locally — can use a short, descriptive summary with no prefix or body required. - -``` -Update error handling in login flow -``` - ---- - -## Pre-Commit Checklist - -Run the `perform-android-preflight-checklist` skill for the full quality gate. At minimum, before staging and committing: - -1. **Run affected module tests** (use `build-test-verify` skill for correct commands) -2. **Check lint**: `./gradlew detekt` on changed modules -3. **Review staged changes**: `git diff --staged` — verify no unintended modifications -4. **Verify no secrets**: No API keys, tokens, passwords, or `.env` files staged -5. **Verify no generated files**: No build outputs, `.idea/` changes, or generated code - ---- - -## What NOT to Commit - -- `.env` files or `user.properties` with real tokens -- Credential files or signing keystores -- Build outputs (`build/`, `*.apk`, `*.aab`) -- IDE-specific files (`.idea/` changes, `*.iml`) -- Large binary files - ---- - -## Staging Best Practices - -- **Stage specific files** by name rather than `git add -A` or `git add .` -- Put each file path on its own line for readability: - ```bash - git add \ - path/to/first/File.kt \ - path/to/second/File.kt \ - path/to/third/File.kt - ``` -- Review each file being staged to avoid accidentally including sensitive data -- Use `git status` (without `-uall` flag) to see the working tree state diff --git a/.claude/skills/creating-android-pull-request/SKILL.md b/.claude/skills/creating-android-pull-request/SKILL.md deleted file mode 100644 index 2d61cec275..0000000000 --- a/.claude/skills/creating-android-pull-request/SKILL.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -name: creating-android-pull-request -version: 0.1.0 -description: Pull request creation workflow for Bitwarden Android. Use when creating PRs, writing PR descriptions, or preparing branches for review. Triggered by "create PR", "pull request", "open PR", "gh pr create", "PR description". ---- - -# Create Pull Request - -## PR Title Format - -``` -[PM-XXXXX] : -``` - -**Examples:** -- `[PM-12345] feat: Add autofill support for passkeys` -- `[PM-12345] fix: Resolve crash during vault sync` -- `[PM-12345] refactor: Simplify authentication flow` - -**Rules:** -- Include Jira ticket prefix -- Keep under 70 characters total -- Use imperative mood in the summary - -**Type keywords** (triggers automatic `t:` label via CI): - -Invoke the `labeling-android-changes` skill for the full type keyword table and selection guidance. - ---- - -## PR Body Template - -**IMPORTANT:** Always follow the repo's PR template at `.github/PULL_REQUEST_TEMPLATE.md`. Delete the Screenshots section entirely if there are no UI changes. - ---- - -## Pre-PR Checklist - -1. **All tests pass**: Run `./gradlew app:testStandardDebugUnitTest` (and other affected modules) -2. **Lint clean**: Run `./gradlew detekt` -3. **Self-review done**: Use `perform-android-preflight-checklist` skill -4. **No unintended changes**: Check `git diff origin/main...HEAD` for unexpected files -5. **Branch up to date**: Rebase on `main` if needed - ---- - -## Creating the PR - -```bash -# Ensure branch is pushed -git push -u origin - -# Create PR as draft by default (body follows .github/PULL_REQUEST_TEMPLATE.md) -gh pr create --draft --title "[PM-XXXXX] feat: Short summary" --body "" -``` - -**Default to draft PRs.** Only create a non-draft (ready for review) PR if the user explicitly requests it. - ---- - -## AI Review Label - -Before running `gh pr create`, **always** use the `AskUserQuestion` tool to ask whether to add an AI review label: - -- **Question**: "Would you like to add an AI review label to this PR?" -- **Options**: `ai-review-vnext`, `ai-review`, `No label` - -If the user selects a label, include it via the `--label` flag: - -```bash -gh pr create --draft --label "ai-review-vnext" --title "..." --body "..." -``` - ---- - -## Base Branch - -- Default target: `main` -- Check with team if targeting a feature branch instead diff --git a/.claude/skills/labeling-android-changes/SKILL.md b/.claude/skills/labeling-android-changes/SKILL.md deleted file mode 100644 index e9fc5f7557..0000000000 --- a/.claude/skills/labeling-android-changes/SKILL.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: labeling-android-changes -version: 0.1.0 -description: Conventional commit type keywords for PR titles and commit messages. Use when determining the change type for commits or PRs. Triggered by "what type", "label", "change type", "conventional commit", "t: label". ---- - -# Labeling Changes - -PR titles and commit messages must include a conventional commit type keyword. This keyword drives automatic `t:` label assignment via CI (`.github/workflows/sdlc-label-pr.yml`). - -## Format - -The type keyword appears after the Jira ticket prefix: - -``` -[PM-XXXXX] : -``` - -## Type Keywords - -| Type | Label | Use for | -|------|-------|---------| -| `feat` | `t:feature` | New features or functionality | -| `fix` | `t:bug` | Bug fixes | -| `refactor` | `t:tech-debt` | Code restructuring without behavior change | -| `chore` | `t:tech-debt` | Maintenance, cleanup, minor tweaks | -| `test` | `t:tech-debt` | Adding or updating tests | -| `perf` | `t:tech-debt` | Performance improvements | -| `docs` | `t:docs` | Documentation changes | -| `ci` / `build` | `t:ci` | CI/CD and build system changes | -| `deps` | `t:deps` | Dependency updates | -| `llm` | `t:llm` | LLM/Claude configuration changes | -| `breaking` | `t:breaking-change` | Breaking changes requiring migration | -| `misc` | `t:misc` | Changes that do not fit other categories | - -## Selecting a Type - -Infer the type from the task description and changes made. **If the type cannot be confidently determined, ask the user.** - -The CI labeling script matches `:` or `(` in the lowercased PR title, so the keyword must be followed by a colon or parenthesis. CI also accepts additional aliases (e.g., `revert`, `bugfix`, `cleanup`). See `.github/label-pr.json` for the full mapping. diff --git a/.claude/skills/perform-android-preflight-checklist/SKILL.md b/.claude/skills/perform-android-preflight-checklist/SKILL.md deleted file mode 100644 index 72adeec4e5..0000000000 --- a/.claude/skills/perform-android-preflight-checklist/SKILL.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -name: perform-android-preflight-checklist -version: 0.1.0 -description: Quality gate checklist to run before committing or creating a PR. Use when finishing implementation, checking work quality, or preparing to commit. Triggered by "self review", "check my work", "ready to commit", "done implementing", "review checklist", "quality check". ---- - -# Self-Review Checklist - -Run through this checklist before committing or opening a PR. - -## Tests -- [ ] Tests pass with correct flavor: `./gradlew app:testStandardDebugUnitTest` -- [ ] New code has corresponding test coverage -- [ ] Tests for affected modules also pass (`:core:test`, `:data:test`, etc.) - -## Code Quality -- [ ] Lint/detekt clean: `./gradlew detekt` -- [ ] No unintended file changes (`git diff` review) -- [ ] KDoc on all new public APIs -- [ ] No TODO comments left behind (or they reference a ticket) - -## Security -- [ ] No plaintext keys, tokens, or secrets in code -- [ ] User input validated before processing -- [ ] Sensitive data uses encrypted storage patterns -- [ ] No logging of sensitive data (passwords, keys, tokens) - -## Bitwarden Patterns -- [ ] String resources in `:ui` module with typographic quotes -- [ ] Navigation route is `@Serializable` and registered in graph -- [ ] New implementations have Hilt `@Binds` or `@Provides` in a module -- [ ] ViewModel extends `BaseViewModel` with proper state persistence -- [ ] Async results mapped through internal actions (not direct state updates) - -## Files -- [ ] No accidental `.idea/`, build output, or generated files staged -- [ ] No credential files or `.env` files included From 796a4dbcbd78623801ed67da760cec0bb95f8e17 Mon Sep 17 00:00:00 2001 From: David Perez Date: Wed, 29 Apr 2026 10:51:05 -0500 Subject: [PATCH 15/16] PM-27234: feat: jit password v2 encryption (#6835) --- .../data/auth/datasource/sdk/AuthSdkSource.kt | 16 + .../auth/datasource/sdk/AuthSdkSourceImpl.kt | 29 + .../auth/repository/AuthRepositoryImpl.kt | 245 +++-- .../repository/di/AuthRepositoryModule.kt | 3 + .../util/UserStateJsonExtensions.kt | 27 +- ...ppedAccountCryptographicStateExtensions.kt | 41 + .../auth/datasource/sdk/AuthSdkSourceTest.kt | 59 ++ .../auth/repository/AuthRepositoryTest.kt | 879 +++++++++++------- .../util/UserStateJsonExtensionsTest.kt | 87 +- ...AccountCryptographicStateExtensionsTest.kt | 54 ++ 10 files changed, 1047 insertions(+), 393 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WrappedAccountCryptographicStateExtensions.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WrappedAccountCryptographicStateExtensionsTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSource.kt index 9789146fb6..2bf77cbb54 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSource.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.data.auth.datasource.sdk +import com.bitwarden.auth.JitMasterPasswordRegistrationResponse import com.bitwarden.auth.KeyConnectorRegistrationResult import com.bitwarden.core.AuthRequestResponse import com.bitwarden.core.KeyConnectorResponse @@ -14,6 +15,21 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength * Source of authentication information and functionality from the Bitwarden SDK. */ interface AuthSdkSource { + /** + * Enrolls the user to master password unlock. + */ + @Suppress("LongParameterList") + suspend fun postKeysForJitPasswordRegistration( + userId: String, + organizationId: String, + organizationPublicKey: String, + organizationSsoIdentifier: String, + salt: String, + masterPassword: String, + masterPasswordHint: String?, + shouldResetPasswordEnroll: Boolean, + ): Result + /** * Enrolls the user to key connector unlock. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceImpl.kt index 399e8fa3be..fa86d569a4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceImpl.kt @@ -1,5 +1,7 @@ package com.x8bit.bitwarden.data.auth.datasource.sdk +import com.bitwarden.auth.JitMasterPasswordRegistrationRequest +import com.bitwarden.auth.JitMasterPasswordRegistrationResponse import com.bitwarden.auth.KeyConnectorRegistrationResult import com.bitwarden.core.AuthRequestResponse import com.bitwarden.core.FingerprintRequest @@ -25,6 +27,33 @@ class AuthSdkSourceImpl( ) : BaseSdkSource(sdkClientManager = sdkClientManager), AuthSdkSource { + override suspend fun postKeysForJitPasswordRegistration( + userId: String, + organizationId: String, + organizationPublicKey: String, + organizationSsoIdentifier: String, + salt: String, + masterPassword: String, + masterPasswordHint: String?, + shouldResetPasswordEnroll: Boolean, + ): Result = runCatchingWithLogs { + getClient(userId = userId) + .auth() + .registration() + .postKeysForJitPasswordRegistration( + request = JitMasterPasswordRegistrationRequest( + orgId = organizationId, + orgPublicKey = organizationPublicKey, + userId = userId, + organizationSsoIdentifier = organizationSsoIdentifier, + salt = salt, + masterPassword = masterPassword, + masterPasswordHint = masterPasswordHint, + resetPasswordEnroll = shouldResetPasswordEnroll, + ), + ) + } + override suspend fun postKeysForKeyConnectorRegistration( userId: String, accessToken: String, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 9ee54cbd77..ecbc312337 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -5,6 +5,7 @@ import com.bitwarden.core.InitUserCryptoMethod import com.bitwarden.core.RegisterTdeKeyResponse import com.bitwarden.core.WrappedAccountCryptographicState import com.bitwarden.core.data.manager.dispatcher.DispatcherManager +import com.bitwarden.core.data.manager.model.FlagKey import com.bitwarden.core.data.manager.toast.ToastManager import com.bitwarden.core.data.repository.error.MissingPropertyException import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow @@ -100,8 +101,10 @@ import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult +import com.x8bit.bitwarden.data.auth.repository.util.accountKeysJson import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow import com.x8bit.bitwarden.data.auth.repository.util.policyInformation +import com.x8bit.bitwarden.data.auth.repository.util.privateKey import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson @@ -115,6 +118,7 @@ import com.x8bit.bitwarden.data.auth.util.toSdkParams import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.error.NoActiveUserException import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.LogsManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.PushManager @@ -145,6 +149,7 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext import timber.log.Timber import java.time.Clock import javax.inject.Singleton @@ -178,9 +183,10 @@ class AuthRepositoryImpl( private val userStateManager: UserStateManager, private val kdfManager: KdfManager, private val toastManager: ToastManager, + private val featureFlagManager: FeatureFlagManager, logsManager: LogsManager, pushManager: PushManager, - dispatcherManager: DispatcherManager, + private val dispatcherManager: DispatcherManager, ) : AuthRepository, AuthRequestManager by authRequestManager, BiometricsEncryptionManager by biometricsEncryptionManager, @@ -1105,85 +1111,73 @@ class AuthRepositoryImpl( ) } - @Suppress("LongMethod") override suspend fun setPassword( organizationIdentifier: String, password: String, passwordHint: String?, ): SetPasswordResult { - val activeAccount = authDiskSource - .userState - ?.activeAccount + val profile = authDiskSource.userState?.activeAccount?.profile ?: return SetPasswordResult.Error(error = NoActiveUserException()) - val userId = activeAccount.profile.userId - - // Update the saved master password hash. - val passwordHash = authSdkSource - .hashPassword( - email = activeAccount.profile.email, - password = password, - kdf = activeAccount.profile.toSdkParams(), - purpose = HashPurpose.SERVER_AUTHORIZATION, - ) - .getOrElse { return@setPassword SetPasswordResult.Error(error = it) } - - return when (activeAccount.profile.forcePasswordResetReason) { + return when (profile.forcePasswordResetReason) { ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION -> { - vaultSdkSource - .updatePassword(userId = userId, newPassword = password) - .map { it.newKey to null } + setUpdatedPassword( + profile = profile, + organizationIdentifier = organizationIdentifier, + password = password, + passwordHint = passwordHint, + ) } ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET, ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN, null, -> { - authSdkSource - .makeRegisterKeys( - email = activeAccount.profile.email, - password = password, - kdf = activeAccount.profile.toSdkParams(), - ) - .map { it.encryptedUserKey to it.keys } + setPasswordForJit( + profile = profile, + organizationIdentifier = organizationIdentifier, + password = password, + passwordHint = passwordHint, + ) } } - .flatMap { (encryptedUserKey, rsaKeys) -> + } + + private suspend fun setUpdatedPassword( + profile: AccountJson.Profile, + organizationIdentifier: String, + password: String, + passwordHint: String?, + ): SetPasswordResult { + val userId = profile.userId + return vaultSdkSource + .updatePassword(userId = userId, newPassword = password) + .flatMap { response -> accountsService .setPassword( body = SetPasswordRequestJson( - passwordHash = passwordHash, + passwordHash = response.passwordHash, passwordHint = passwordHint, organizationIdentifier = organizationIdentifier, - kdfIterations = activeAccount.profile.kdfIterations, - kdfMemory = activeAccount.profile.kdfMemory, - kdfParallelism = activeAccount.profile.kdfParallelism, - kdfType = activeAccount.profile.kdfType, - key = encryptedUserKey, - keys = rsaKeys?.let { - RegisterRequestJson.Keys( - publicKey = it.public, - encryptedPrivateKey = it.private, - ) - }, + kdfIterations = profile.kdfIterations, + kdfMemory = profile.kdfMemory, + kdfParallelism = profile.kdfParallelism, + kdfType = profile.kdfType, + key = response.newKey, + keys = null, ), ) .onSuccess { - rsaKeys?.private?.let { - // This process is used by TDE and Enterprise accounts during initial - // login. We continue to store the locally generated keys - // until TDE and Enterprise accounts support AEAD keys. - authDiskSource.storePrivateKey(userId = userId, privateKey = it) - } - authDiskSource.storeUserKey(userId = userId, userKey = encryptedUserKey) + authDiskSource.storeUserKey(userId = userId, userKey = response.newKey) } + .map { response.passwordHash } } - .flatMap { + .flatMap { masterPasswordHash -> when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) { is VaultUnlockResult.Success -> { enrollUserInPasswordReset( userId = userId, organizationIdentifier = organizationIdentifier, - passwordHash = passwordHash, + passwordHash = masterPasswordHash, ) } @@ -1194,8 +1188,155 @@ class AuthRepositoryImpl( } } .onSuccess { - authDiskSource.storeMasterPasswordHash(userId = userId, passwordHash = passwordHash) - authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword() + authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword( + masterPasswordUnlock = null, + ) + this.organizationIdentifier = null + } + .fold( + onFailure = { SetPasswordResult.Error(error = it) }, + onSuccess = { SetPasswordResult.Success }, + ) + } + + private suspend fun setPasswordForJit( + profile: AccountJson.Profile, + organizationIdentifier: String, + password: String, + passwordHint: String?, + ): SetPasswordResult { + if (!featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionJitPassword)) { + return setPasswordForJitV1( + profile = profile, + organizationIdentifier = organizationIdentifier, + password = password, + passwordHint = passwordHint, + ) + } + val userId = profile.userId + return organizationService + .getOrganizationAutoEnrollStatus(organizationIdentifier = organizationIdentifier) + .flatMap { enrollStatus -> + organizationService + .getOrganizationKeys(organizationId = enrollStatus.organizationId) + .map { orgKeys -> enrollStatus to orgKeys } + } + .flatMap { (enrollStatus, orgKeys) -> + withContext(dispatcherManager.io) { + authSdkSource.postKeysForJitPasswordRegistration( + userId = userId, + organizationId = enrollStatus.organizationId, + organizationPublicKey = orgKeys.publicKey, + organizationSsoIdentifier = organizationIdentifier, + salt = profile.email, + masterPassword = password, + masterPasswordHint = passwordHint, + shouldResetPasswordEnroll = enrollStatus.isResetPasswordEnabled, + ) + } + } + .onSuccess { response -> + authDiskSource.storeAccountKeys( + userId = userId, + accountKeys = response.accountCryptographicState.accountKeysJson, + ) + // TDE and SSO user creation still uses crypto-v1. These users are not + // expected to have the AEAD keys so we only store the private key for now. + // See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332 + // for more details. + authDiskSource.storePrivateKey( + userId = userId, + privateKey = response.accountCryptographicState.privateKey, + ) + authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword( + masterPasswordUnlock = response.masterPasswordUnlock, + ) + this.organizationIdentifier = null + } + .flatMap { response -> + // Logging in with the password instead of the decrypted userKey will store + // the master password hash automatically. + when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) { + VaultUnlockResult.Success -> response.asSuccess() + is VaultUnlockError -> { + (result.error ?: IllegalStateException("Failed to unlock vault")) + .asFailure() + } + } + } + .fold( + onFailure = { SetPasswordResult.Error(error = it) }, + onSuccess = { SetPasswordResult.Success }, + ) + } + + @Suppress("LongMethod") + private suspend fun setPasswordForJitV1( + profile: AccountJson.Profile, + organizationIdentifier: String, + password: String, + passwordHint: String?, + ): SetPasswordResult { + val userId = profile.userId + return authSdkSource + .makeRegisterKeys( + email = profile.email, + password = password, + kdf = profile.toSdkParams(), + ) + .flatMap { response -> + accountsService + .setPassword( + body = SetPasswordRequestJson( + passwordHash = response.masterPasswordHash, + passwordHint = passwordHint, + organizationIdentifier = organizationIdentifier, + kdfIterations = profile.kdfIterations, + kdfMemory = profile.kdfMemory, + kdfParallelism = profile.kdfParallelism, + kdfType = profile.kdfType, + key = response.encryptedUserKey, + keys = RegisterRequestJson.Keys( + publicKey = response.keys.public, + encryptedPrivateKey = response.keys.private, + ), + ), + ) + .onSuccess { + // This process is used by TDE and Enterprise accounts during initial + // login. We continue to store the locally generated keys + // until TDE and Enterprise accounts support AEAD keys. + authDiskSource.storePrivateKey( + userId = userId, + privateKey = response.keys.private, + ) + authDiskSource.storeUserKey( + userId = userId, + userKey = response.encryptedUserKey, + ) + } + .map { response.masterPasswordHash } + } + .flatMap { masterPasswordHash -> + when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) { + is VaultUnlockResult.Success -> { + enrollUserInPasswordReset( + userId = userId, + organizationIdentifier = organizationIdentifier, + passwordHash = masterPasswordHash, + ) + } + + is VaultUnlockError -> { + (result.error ?: IllegalStateException("Failed to unlock vault")) + .asFailure() + } + } + } + .onSuccess { + authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword( + masterPasswordUnlock = null, + ) this.organizationIdentifier = null } .fold( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt index 53c1b3c252..8713b0fc26 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt @@ -21,6 +21,7 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager import com.x8bit.bitwarden.data.platform.manager.LogsManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager @@ -73,6 +74,7 @@ object AuthRepositoryModule { userStateManager: UserStateManager, kdfManager: KdfManager, toastManager: ToastManager, + featureFlagManager: FeatureFlagManager, ): AuthRepository = AuthRepositoryImpl( clock = clock, accountsService = accountsService, @@ -100,6 +102,7 @@ object AuthRepositoryModule { userStateManager = userStateManager, kdfManager = kdfManager, toastManager = toastManager, + featureFlagManager = featureFlagManager, ) @Provides diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt index 4cc1b5939e..76612273de 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt @@ -1,7 +1,9 @@ package com.x8bit.bitwarden.data.auth.repository.util +import com.bitwarden.core.MasterPasswordUnlockData import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault import com.bitwarden.network.model.KdfTypeJson +import com.bitwarden.network.model.MasterPasswordUnlockDataJson import com.bitwarden.network.model.OrganizationType import com.bitwarden.network.model.PolicyTypeJson import com.bitwarden.network.model.SyncResponseJson @@ -9,6 +11,7 @@ import com.bitwarden.network.model.UserDecryptionOptionsJson import com.bitwarden.ui.platform.base.util.toHexColorRepresentation import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson +import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfRequestModel import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations @@ -108,20 +111,34 @@ fun UserStateJson.toUpdatedUserStateJson( * Updates the [UserStateJson] to set the `hasMasterPassword` value to `true` after a user sets * their password. */ -fun UserStateJson.toUserStateJsonWithPassword(): UserStateJson { +fun UserStateJson.toUserStateJsonWithPassword( + masterPasswordUnlock: MasterPasswordUnlockData?, +): UserStateJson { val account = this.activeAccount val profile = account.profile + val userDecryptionOptions = profile.userDecryptionOptions + val masterPasswordUnlockJson = masterPasswordUnlock + ?.let { + MasterPasswordUnlockDataJson( + salt = it.salt, + kdf = it.kdf.toKdfRequestModel(), + masterKeyWrappedUserKey = it.masterKeyWrappedUserKey, + ) + } + ?: userDecryptionOptions?.masterPasswordUnlock val updatedProfile = profile .copy( forcePasswordResetReason = null, - userDecryptionOptions = profile - .userDecryptionOptions - ?.copy(hasMasterPassword = true) + userDecryptionOptions = userDecryptionOptions + ?.copy( + hasMasterPassword = true, + masterPasswordUnlock = masterPasswordUnlockJson, + ) ?: UserDecryptionOptionsJson( hasMasterPassword = true, keyConnectorUserDecryptionOptions = null, trustedDeviceUserDecryptionOptions = null, - masterPasswordUnlock = null, + masterPasswordUnlock = masterPasswordUnlockJson, ), ) val updatedAccount = account.copy(profile = updatedProfile) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WrappedAccountCryptographicStateExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WrappedAccountCryptographicStateExtensions.kt new file mode 100644 index 0000000000..3cde242a87 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WrappedAccountCryptographicStateExtensions.kt @@ -0,0 +1,41 @@ +package com.x8bit.bitwarden.data.auth.repository.util + +import com.bitwarden.core.WrappedAccountCryptographicState +import com.bitwarden.network.model.AccountKeysJson +import com.bitwarden.network.model.AccountKeysJson.PublicKeyEncryptionKeyPair +import com.bitwarden.network.model.AccountKeysJson.SecurityState +import com.bitwarden.network.model.AccountKeysJson.SignatureKeyPair + +/** + * The user's encryption private key, wrapped by the user key. + */ +val WrappedAccountCryptographicState.privateKey: String + get() = when (this) { + is WrappedAccountCryptographicState.V1 -> this.privateKey + is WrappedAccountCryptographicState.V2 -> this.privateKey + } + +/** + * Converts the [WrappedAccountCryptographicState] into a [AccountKeysJson]. + * + * @receiver `WrappedAccountCryptographicState` to convert to `AccountEncryptionKeysJson`. + */ +val WrappedAccountCryptographicState.accountKeysJson: AccountKeysJson? + get() = when (this) { + is WrappedAccountCryptographicState.V1 -> null + is WrappedAccountCryptographicState.V2 -> AccountKeysJson( + publicKeyEncryptionKeyPair = PublicKeyEncryptionKeyPair( + publicKey = "", + signedPublicKey = this.signedPublicKey, + wrappedPrivateKey = this.privateKey, + ), + signatureKeyPair = SignatureKeyPair( + wrappedSigningKey = this.signingKey, + verifyingKey = "", + ), + securityState = SecurityState( + securityState = this.securityState, + securityVersion = 2, + ), + ) + } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceTest.kt index b51c0ce8ed..8440cb8d93 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/sdk/AuthSdkSourceTest.kt @@ -1,5 +1,7 @@ package com.x8bit.bitwarden.data.auth.datasource.sdk +import com.bitwarden.auth.JitMasterPasswordRegistrationRequest +import com.bitwarden.auth.JitMasterPasswordRegistrationResponse import com.bitwarden.auth.KeyConnectorRegistrationResult import com.bitwarden.core.AuthRequestResponse import com.bitwarden.core.FingerprintRequest @@ -47,6 +49,63 @@ class AuthSdkSourceTest { sdkClientManager = sdkClientManager, ) + @Suppress("MaxLineLength") + @Test + fun `postKeysForJitPasswordRegistration should call SDK and return a Result with correct data`() = + runBlocking { + val userId = "userId" + val organizationId = "organizationId" + val organizationPublicKey = "organizationPublicKey" + val organizationSsoIdentifier = "organizationSsoIdentifier" + val salt = "salt" + val masterPassword = "masterPassword" + val masterPasswordHint = "masterPasswordHint" + val shouldResetPasswordEnroll = false + val expectedResult = mockk() + coEvery { sdkClientManager.getOrCreateClient(userId = userId) } returns client + coEvery { + clientRegistration.postKeysForJitPasswordRegistration( + request = JitMasterPasswordRegistrationRequest( + orgId = organizationId, + orgPublicKey = organizationPublicKey, + organizationSsoIdentifier = organizationSsoIdentifier, + userId = userId, + salt = salt, + masterPassword = masterPassword, + masterPasswordHint = masterPasswordHint, + resetPasswordEnroll = shouldResetPasswordEnroll, + ), + ) + } returns expectedResult + + val result = authSkdSource.postKeysForJitPasswordRegistration( + organizationId = organizationId, + organizationPublicKey = organizationPublicKey, + organizationSsoIdentifier = organizationSsoIdentifier, + userId = userId, + salt = salt, + masterPassword = masterPassword, + masterPasswordHint = masterPasswordHint, + shouldResetPasswordEnroll = shouldResetPasswordEnroll, + ) + + assertEquals(expectedResult, result.getOrThrow()) + coVerify(exactly = 1) { + clientRegistration.postKeysForJitPasswordRegistration( + request = JitMasterPasswordRegistrationRequest( + orgId = organizationId, + orgPublicKey = organizationPublicKey, + organizationSsoIdentifier = organizationSsoIdentifier, + userId = userId, + salt = salt, + masterPassword = masterPassword, + masterPasswordHint = masterPasswordHint, + resetPasswordEnroll = shouldResetPasswordEnroll, + ), + ) + } + } + @Suppress("MaxLineLength") @Test fun `postKeysForKeyConnectorRegistration should call SDK and return a Result with correct data`() = diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index dde151dd80..35d49540d3 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.auth.repository import app.cash.turbine.test +import com.bitwarden.auth.JitMasterPasswordRegistrationResponse import com.bitwarden.core.AuthRequestMethod import com.bitwarden.core.AuthRequestResponse import com.bitwarden.core.InitUserCryptoMethod @@ -13,6 +14,7 @@ import com.bitwarden.core.UpdatePasswordResponse import com.bitwarden.core.WrappedAccountCryptographicState import com.bitwarden.core.data.manager.dispatcher.DispatcherManager import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager +import com.bitwarden.core.data.manager.model.FlagKey import com.bitwarden.core.data.manager.toast.ToastManager import com.bitwarden.core.data.repository.error.MissingPropertyException import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow @@ -124,6 +126,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult +import com.x8bit.bitwarden.data.auth.repository.util.accountKeysJson import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams import com.x8bit.bitwarden.data.auth.repository.util.toUserState @@ -131,6 +134,7 @@ import com.x8bit.bitwarden.data.auth.util.YubiKeyResult import com.x8bit.bitwarden.data.auth.util.toSdkParams import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSource import com.x8bit.bitwarden.data.platform.error.NoActiveUserException +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.LogsManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.PushManager @@ -289,6 +293,9 @@ class AuthRepositoryTest { private val toastManager: ToastManager = mockk { every { show(messageId = any(), duration = any()) } just runs } + private val featureFlagManager: FeatureFlagManager = mockk { + every { getFeatureFlag(FlagKey.V2EncryptionJitPassword) } returns true + } private val repository: AuthRepository = AuthRepositoryImpl( clock = FIXED_CLOCK, @@ -317,6 +324,7 @@ class AuthRepositoryTest { userStateManager = userStateManager, kdfManager = kdfManager, toastManager = toastManager, + featureFlagManager = featureFlagManager, ) @BeforeEach @@ -5682,73 +5690,331 @@ class AuthRepositoryTest { } @Test - fun `setPassword with authSdkSource hashPassword failure should return Error`() = runTest { - val password = "password" - val error = Throwable("Fail") - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - coEvery { - authSdkSource.hashPassword( - email = EMAIL, + fun `setPassword with authSdkSource makeRegisterKeys failure should return Error for v1`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionJitPassword) + } returns false + val password = "password" + val error = Throwable("Fail") + val kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams() + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + coEvery { + authSdkSource.makeRegisterKeys( + email = EMAIL, + password = password, + kdf = kdf, + ) + } returns error.asFailure() + + val result = repository.setPassword( + organizationIdentifier = "organizationId", password = password, - kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams(), - purpose = HashPurpose.SERVER_AUTHORIZATION, + passwordHint = "passwordHint", ) - } returns error.asFailure() - val result = repository.setPassword( - organizationIdentifier = "organizationId", - password = password, - passwordHint = "passwordHint", - ) - - assertEquals(SetPasswordResult.Error(error = error), result) - fakeAuthDiskSource.assertMasterPasswordHash(userId = USER_ID_1, passwordHash = null) - fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null) - fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null) - } + assertEquals(SetPasswordResult.Error(error = error), result) + fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null) + fakeAuthDiskSource.assertAccountKeys(userId = USER_ID_1, accountKeys = null) + fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null) + } @Test - fun `setPassword with authSdkSource makeRegisterKeys failure should return Error`() = runTest { - val password = "password" - val passwordHash = "passwordHash" - val error = Throwable("Fail") - val kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams() - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - coEvery { - authSdkSource.hashPassword( - email = EMAIL, - password = password, - kdf = kdf, - purpose = HashPurpose.SERVER_AUTHORIZATION, - ) - } returns passwordHash.asSuccess() - coEvery { - authSdkSource.makeRegisterKeys( - email = EMAIL, - password = password, - kdf = kdf, - ) - } returns error.asFailure() + fun `setPassword with getOrganizationAutoEnrollStatus failure should return Error`() = + runTest { + val error = Throwable("Fail") + val organizationIdentifier = "organizationIdentifier" + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + coEvery { + organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) + } returns error.asFailure() - val result = repository.setPassword( - organizationIdentifier = "organizationId", - password = password, - passwordHint = "passwordHint", - ) + val result = repository.setPassword( + organizationIdentifier = organizationIdentifier, + password = "password", + passwordHint = "passwordHint", + ) - assertEquals(SetPasswordResult.Error(error = error), result) - fakeAuthDiskSource.assertMasterPasswordHash(userId = USER_ID_1, passwordHash = null) - fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null) - fakeAuthDiskSource.assertAccountKeys(userId = USER_ID_1, accountKeys = null) - fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null) - } + assertEquals(SetPasswordResult.Error(error = error), result) + coVerify(exactly = 1) { + organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) + } + fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null) + fakeAuthDiskSource.assertAccountKeys(userId = USER_ID_1, accountKeys = null) + } + + @Test + fun `setPassword with getOrganizationKeys failure should return Error`() = + runTest { + val error = Throwable("Fail") + val organizationIdentifier = "organizationIdentifier" + val organizationId = "organizationId" + val enrollResponse = OrganizationAutoEnrollStatusResponseJson( + organizationId = organizationId, + isResetPasswordEnabled = true, + ) + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + coEvery { + organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) + } returns enrollResponse.asSuccess() + coEvery { + organizationService.getOrganizationKeys(organizationId) + } returns error.asFailure() + + val result = repository.setPassword( + organizationIdentifier = organizationIdentifier, + password = "password", + passwordHint = "passwordHint", + ) + + assertEquals(SetPasswordResult.Error(error = error), result) + coVerify(exactly = 1) { + organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) + organizationService.getOrganizationKeys(organizationId) + } + fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null) + fakeAuthDiskSource.assertAccountKeys(userId = USER_ID_1, accountKeys = null) + } + + @Test + fun `setPassword with postKeysForJitPasswordRegistration failure should return Error`() = + runTest { + val error = Throwable("Fail") + val password = "password" + val passwordHint = "passwordHint" + val organizationIdentifier = "organizationIdentifier" + val organizationId = "organizationId" + val isResetPasswordEnabled = true + val enrollResponse = OrganizationAutoEnrollStatusResponseJson( + organizationId = organizationId, + isResetPasswordEnabled = isResetPasswordEnabled, + ) + val orgPublicKey = "orgPublicKey" + val orgKeysResponse = OrganizationKeysResponseJson( + privateKey = "orgPrivateKey", + publicKey = orgPublicKey, + ) + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + coEvery { + organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) + } returns enrollResponse.asSuccess() + coEvery { + organizationService.getOrganizationKeys(organizationId) + } returns orgKeysResponse.asSuccess() + coEvery { + authSdkSource.postKeysForJitPasswordRegistration( + userId = USER_ID_1, + organizationId = organizationId, + organizationPublicKey = orgPublicKey, + organizationSsoIdentifier = organizationIdentifier, + salt = EMAIL, + masterPassword = password, + masterPasswordHint = passwordHint, + shouldResetPasswordEnroll = isResetPasswordEnabled, + ) + } returns error.asFailure() + + val result = repository.setPassword( + organizationIdentifier = organizationIdentifier, + password = password, + passwordHint = passwordHint, + ) + + assertEquals(SetPasswordResult.Error(error = error), result) + coVerify(exactly = 1) { + organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) + organizationService.getOrganizationKeys(organizationId) + authSdkSource.postKeysForJitPasswordRegistration( + userId = USER_ID_1, + organizationId = organizationId, + organizationPublicKey = orgPublicKey, + organizationSsoIdentifier = organizationIdentifier, + salt = EMAIL, + masterPassword = password, + masterPasswordHint = passwordHint, + shouldResetPasswordEnroll = isResetPasswordEnabled, + ) + } + fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null) + fakeAuthDiskSource.assertAccountKeys(userId = USER_ID_1, accountKeys = null) + } + + @Test + fun `setPassword with unlockVaultWithMasterPassword failure should return Error`() = + runTest { + val error = Throwable("Fail") + val unlockError = VaultUnlockResult.GenericError(error = error) + val password = "password" + val passwordHint = "passwordHint" + val organizationIdentifier = "organizationIdentifier" + val organizationId = "organizationId" + val isResetPasswordEnabled = true + val enrollResponse = OrganizationAutoEnrollStatusResponseJson( + organizationId = organizationId, + isResetPasswordEnabled = isResetPasswordEnabled, + ) + val orgPublicKey = "orgPublicKey" + val orgKeysResponse = OrganizationKeysResponseJson( + privateKey = "orgPrivateKey", + publicKey = orgPublicKey, + ) + val privateKey = "privateKey" + val accountCryptographicState = WrappedAccountCryptographicState.V2( + privateKey = privateKey, + securityState = "securityState", + signedPublicKey = "signedPublicKey", + signingKey = "signingKey", + ) + val jitMasterPasswordResponse = JitMasterPasswordRegistrationResponse( + accountCryptographicState = accountCryptographicState, + masterPasswordUnlock = MasterPasswordUnlockData( + kdf = Kdf.Pbkdf2(iterations = 1u), + masterKeyWrappedUserKey = "masterKeyWrappedUserKey", + salt = EMAIL, + ), + userKey = "userKey", + ) + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + coEvery { + organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) + } returns enrollResponse.asSuccess() + coEvery { + organizationService.getOrganizationKeys(organizationId) + } returns orgKeysResponse.asSuccess() + coEvery { + authSdkSource.postKeysForJitPasswordRegistration( + userId = USER_ID_1, + organizationId = organizationId, + organizationPublicKey = orgPublicKey, + organizationSsoIdentifier = organizationIdentifier, + salt = EMAIL, + masterPassword = password, + masterPasswordHint = passwordHint, + shouldResetPasswordEnroll = isResetPasswordEnabled, + ) + } returns jitMasterPasswordResponse.asSuccess() + coEvery { + vaultRepository.unlockVaultWithMasterPassword(password) + } returns unlockError + + val result = repository.setPassword( + organizationIdentifier = organizationIdentifier, + password = password, + passwordHint = passwordHint, + ) + + assertEquals(SetPasswordResult.Error(error = error), result) + coVerify(exactly = 1) { + organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) + organizationService.getOrganizationKeys(organizationId) + authSdkSource.postKeysForJitPasswordRegistration( + userId = USER_ID_1, + organizationId = organizationId, + organizationPublicKey = orgPublicKey, + organizationSsoIdentifier = organizationIdentifier, + salt = EMAIL, + masterPassword = password, + masterPasswordHint = passwordHint, + shouldResetPasswordEnroll = isResetPasswordEnabled, + ) + vaultRepository.unlockVaultWithMasterPassword(password) + } + fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = privateKey) + fakeAuthDiskSource.assertAccountKeys( + userId = USER_ID_1, + accountKeys = accountCryptographicState.accountKeysJson, + ) + } + + @Test + fun `setPassword with no failures should return Success`() = + runTest { + val password = "password" + val passwordHint = "passwordHint" + val organizationIdentifier = "organizationIdentifier" + val organizationId = "organizationId" + val isResetPasswordEnabled = true + val enrollResponse = OrganizationAutoEnrollStatusResponseJson( + organizationId = organizationId, + isResetPasswordEnabled = isResetPasswordEnabled, + ) + val orgPublicKey = "orgPublicKey" + val orgKeysResponse = OrganizationKeysResponseJson( + privateKey = "orgPrivateKey", + publicKey = orgPublicKey, + ) + val privateKey = "privateKey" + val accountCryptographicState = WrappedAccountCryptographicState.V2( + privateKey = privateKey, + securityState = "securityState", + signedPublicKey = "signedPublicKey", + signingKey = "signingKey", + ) + val jitMasterPasswordResponse = JitMasterPasswordRegistrationResponse( + accountCryptographicState = accountCryptographicState, + masterPasswordUnlock = MasterPasswordUnlockData( + kdf = Kdf.Pbkdf2(iterations = 1u), + masterKeyWrappedUserKey = "masterKeyWrappedUserKey", + salt = EMAIL, + ), + userKey = "userKey", + ) + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + coEvery { + organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) + } returns enrollResponse.asSuccess() + coEvery { + organizationService.getOrganizationKeys(organizationId) + } returns orgKeysResponse.asSuccess() + coEvery { + authSdkSource.postKeysForJitPasswordRegistration( + userId = USER_ID_1, + organizationId = organizationId, + organizationPublicKey = orgPublicKey, + organizationSsoIdentifier = organizationIdentifier, + salt = EMAIL, + masterPassword = password, + masterPasswordHint = passwordHint, + shouldResetPasswordEnroll = isResetPasswordEnabled, + ) + } returns jitMasterPasswordResponse.asSuccess() + coEvery { + vaultRepository.unlockVaultWithMasterPassword(password) + } returns VaultUnlockResult.Success + + val result = repository.setPassword( + organizationIdentifier = organizationIdentifier, + password = password, + passwordHint = passwordHint, + ) + + assertEquals(SetPasswordResult.Success, result) + coVerify(exactly = 1) { + organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) + organizationService.getOrganizationKeys(organizationId) + authSdkSource.postKeysForJitPasswordRegistration( + userId = USER_ID_1, + organizationId = organizationId, + organizationPublicKey = orgPublicKey, + organizationSsoIdentifier = organizationIdentifier, + salt = EMAIL, + masterPassword = password, + masterPasswordHint = passwordHint, + shouldResetPasswordEnroll = isResetPasswordEnabled, + ) + vaultRepository.unlockVaultWithMasterPassword(password) + } + fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = privateKey) + fakeAuthDiskSource.assertAccountKeys( + userId = USER_ID_1, + accountKeys = accountCryptographicState.accountKeysJson, + ) + } @Test fun `setPassword with vaultSdkSource updatePassword failure should return Error`() = runTest { val password = "password" - val passwordHash = "passwordHash" val error = Throwable("Fail") - val kdf = SINGLE_USER_STATE_1.activeAccount.profile.toSdkParams() fakeAuthDiskSource.userState = SINGLE_USER_STATE_1.copy( accounts = mapOf( USER_ID_1 to ACCOUNT_1.copy( @@ -5759,14 +6025,6 @@ class AuthRepositoryTest { ), ), ) - coEvery { - authSdkSource.hashPassword( - email = EMAIL, - password = password, - kdf = kdf, - purpose = HashPurpose.SERVER_AUTHORIZATION, - ) - } returns passwordHash.asSuccess() coEvery { vaultSdkSource.updatePassword(userId = USER_ID_1, newPassword = password) } returns error.asFailure() @@ -5778,187 +6036,170 @@ class AuthRepositoryTest { ) assertEquals(SetPasswordResult.Error(error = error), result) - fakeAuthDiskSource.assertMasterPasswordHash(userId = USER_ID_1, passwordHash = null) fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null) fakeAuthDiskSource.assertAccountKeys(userId = USER_ID_1, accountKeys = null) fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null) } @Test - fun `setPassword with accountsService setPassword failure should return Error`() = runTest { - val password = "password" - val passwordHash = "passwordHash" - val passwordHint = "passwordHint" - val organizationId = ORGANIZATION_IDENTIFIER - val encryptedUserKey = "encryptedUserKey" - val privateRsaKey = "privateRsaKey" - val publicRsaKey = "publicRsaKey" - val profile = SINGLE_USER_STATE_1.activeAccount.profile - val kdf = profile.toSdkParams() - val error = Throwable("Fail") - val registerKeyResponse = RegisterKeyResponse( - masterPasswordHash = passwordHash, - encryptedUserKey = encryptedUserKey, - keys = RsaKeyPair(public = publicRsaKey, private = privateRsaKey), - ) - val setPasswordRequestJson = SetPasswordRequestJson( - passwordHash = passwordHash, - passwordHint = passwordHint, - organizationIdentifier = organizationId, - kdfIterations = profile.kdfIterations, - kdfMemory = profile.kdfMemory, - kdfParallelism = profile.kdfParallelism, - kdfType = profile.kdfType, - key = encryptedUserKey, - keys = RegisterRequestJson.Keys( - publicKey = publicRsaKey, - encryptedPrivateKey = privateRsaKey, - ), - ) - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - coEvery { - authSdkSource.hashPassword( - email = EMAIL, - password = password, - kdf = kdf, - purpose = HashPurpose.SERVER_AUTHORIZATION, + fun `setPassword with accountsService setPassword failure should return Error for v1`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionJitPassword) + } returns false + val password = "password" + val passwordHash = "passwordHash" + val passwordHint = "passwordHint" + val organizationId = ORGANIZATION_IDENTIFIER + val encryptedUserKey = "encryptedUserKey" + val privateRsaKey = "privateRsaKey" + val publicRsaKey = "publicRsaKey" + val profile = SINGLE_USER_STATE_1.activeAccount.profile + val kdf = profile.toSdkParams() + val error = Throwable("Fail") + val registerKeyResponse = RegisterKeyResponse( + masterPasswordHash = passwordHash, + encryptedUserKey = encryptedUserKey, + keys = RsaKeyPair(public = publicRsaKey, private = privateRsaKey), ) - } returns passwordHash.asSuccess() - coEvery { - authSdkSource.makeRegisterKeys(email = EMAIL, password = password, kdf = kdf) - } returns registerKeyResponse.asSuccess() - coEvery { - accountsService.setPassword(body = setPasswordRequestJson) - } returns error.asFailure() - - val result = repository.setPassword( - organizationIdentifier = organizationId, - password = password, - passwordHint = passwordHint, - ) - - assertEquals(SetPasswordResult.Error(error = error), result) - fakeAuthDiskSource.assertMasterPasswordHash(userId = USER_ID_1, passwordHash = null) - fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null) - fakeAuthDiskSource.assertAccountKeys(userId = USER_ID_1, accountKeys = null) - fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null) - } - - @Test - fun `setPassword with accountsService setPassword success should return Success`() = runTest { - val password = "password" - val passwordHash = "passwordHash" - val passwordHint = "passwordHint" - val organizationIdentifier = ORGANIZATION_IDENTIFIER - val organizationId = "orgId" - val encryptedUserKey = "encryptedUserKey" - val privateRsaKey = "privateRsaKey" - val publicRsaKey = "publicRsaKey" - val publicOrgKey = "publicOrgKey" - val resetPasswordKey = "resetPasswordKey" - val profile = SINGLE_USER_STATE_1.activeAccount.profile - val kdf = profile.toSdkParams() - val registerKeyResponse = RegisterKeyResponse( - masterPasswordHash = passwordHash, - encryptedUserKey = encryptedUserKey, - keys = RsaKeyPair(public = publicRsaKey, private = privateRsaKey), - ) - val setPasswordRequestJson = SetPasswordRequestJson( - passwordHash = passwordHash, - passwordHint = passwordHint, - organizationIdentifier = organizationIdentifier, - kdfIterations = profile.kdfIterations, - kdfMemory = profile.kdfMemory, - kdfParallelism = profile.kdfParallelism, - kdfType = profile.kdfType, - key = encryptedUserKey, - keys = RegisterRequestJson.Keys( - publicKey = publicRsaKey, - encryptedPrivateKey = privateRsaKey, - ), - ) - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - coEvery { - authSdkSource.hashPassword( - email = EMAIL, - password = password, - kdf = kdf, - purpose = HashPurpose.SERVER_AUTHORIZATION, - ) - } returns passwordHash.asSuccess() - coEvery { - authSdkSource.makeRegisterKeys(email = EMAIL, password = password, kdf = kdf) - } returns registerKeyResponse.asSuccess() - coEvery { - accountsService.setPassword(body = setPasswordRequestJson) - } returns Unit.asSuccess() - coEvery { - organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) - } returns OrganizationAutoEnrollStatusResponseJson( - organizationId = organizationId, - isResetPasswordEnabled = true, - ) - .asSuccess() - coEvery { - organizationService.getOrganizationKeys(organizationId) - } returns OrganizationKeysResponseJson( - privateKey = "", - publicKey = publicOrgKey, - ) - .asSuccess() - coEvery { - organizationService.organizationResetPasswordEnroll( - organizationId = organizationId, - userId = profile.userId, + val setPasswordRequestJson = SetPasswordRequestJson( passwordHash = passwordHash, - resetPasswordKey = resetPasswordKey, + passwordHint = passwordHint, + organizationIdentifier = organizationId, + kdfIterations = profile.kdfIterations, + kdfMemory = profile.kdfMemory, + kdfParallelism = profile.kdfParallelism, + kdfType = profile.kdfType, + key = encryptedUserKey, + keys = RegisterRequestJson.Keys( + publicKey = publicRsaKey, + encryptedPrivateKey = privateRsaKey, + ), ) - } returns Unit.asSuccess() - coEvery { - vaultSdkSource.getResetPasswordKey( - orgPublicKey = publicOrgKey, - userId = profile.userId, - ) - } returns resetPasswordKey.asSuccess() - coEvery { - vaultRepository.unlockVaultWithMasterPassword(password) - } returns VaultUnlockResult.Success + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + coEvery { + authSdkSource.makeRegisterKeys(email = EMAIL, password = password, kdf = kdf) + } returns registerKeyResponse.asSuccess() + coEvery { + accountsService.setPassword(body = setPasswordRequestJson) + } returns error.asFailure() - val result = repository.setPassword( - organizationIdentifier = organizationIdentifier, - password = password, - passwordHint = passwordHint, - ) - - assertEquals(SetPasswordResult.Success, result) - fakeAuthDiskSource.assertMasterPasswordHash(userId = USER_ID_1, passwordHash = passwordHash) - fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = privateRsaKey) - fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = encryptedUserKey) - fakeAuthDiskSource.assertUserState(SINGLE_USER_STATE_1_WITH_PASS) - coVerify { - authSdkSource.hashPassword( - email = EMAIL, + val result = repository.setPassword( + organizationIdentifier = organizationId, password = password, - kdf = kdf, - purpose = HashPurpose.SERVER_AUTHORIZATION, - ) - authSdkSource.makeRegisterKeys(email = EMAIL, password = password, kdf = kdf) - accountsService.setPassword(body = setPasswordRequestJson) - organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) - organizationService.getOrganizationKeys(organizationId) - organizationService.organizationResetPasswordEnroll( - organizationId = organizationId, - userId = profile.userId, - passwordHash = passwordHash, - resetPasswordKey = resetPasswordKey, - ) - vaultRepository.unlockVaultWithMasterPassword(password) - vaultSdkSource.getResetPasswordKey( - orgPublicKey = publicOrgKey, - userId = profile.userId, + passwordHint = passwordHint, ) + + assertEquals(SetPasswordResult.Error(error = error), result) + fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null) + fakeAuthDiskSource.assertAccountKeys(userId = USER_ID_1, accountKeys = null) + fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = null) + } + + @Test + fun `setPassword with accountsService setPassword success should return Success for v1`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionJitPassword) + } returns false + val password = "password" + val passwordHash = "passwordHash" + val passwordHint = "passwordHint" + val organizationIdentifier = ORGANIZATION_IDENTIFIER + val organizationId = "orgId" + val encryptedUserKey = "encryptedUserKey" + val privateRsaKey = "privateRsaKey" + val publicRsaKey = "publicRsaKey" + val publicOrgKey = "publicOrgKey" + val resetPasswordKey = "resetPasswordKey" + val profile = SINGLE_USER_STATE_1.activeAccount.profile + val kdf = profile.toSdkParams() + val registerKeyResponse = RegisterKeyResponse( + masterPasswordHash = passwordHash, + encryptedUserKey = encryptedUserKey, + keys = RsaKeyPair(public = publicRsaKey, private = privateRsaKey), + ) + val setPasswordRequestJson = SetPasswordRequestJson( + passwordHash = passwordHash, + passwordHint = passwordHint, + organizationIdentifier = organizationIdentifier, + kdfIterations = profile.kdfIterations, + kdfMemory = profile.kdfMemory, + kdfParallelism = profile.kdfParallelism, + kdfType = profile.kdfType, + key = encryptedUserKey, + keys = RegisterRequestJson.Keys( + publicKey = publicRsaKey, + encryptedPrivateKey = privateRsaKey, + ), + ) + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + coEvery { + authSdkSource.makeRegisterKeys(email = EMAIL, password = password, kdf = kdf) + } returns registerKeyResponse.asSuccess() + coEvery { + accountsService.setPassword(body = setPasswordRequestJson) + } returns Unit.asSuccess() + coEvery { + organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) + } returns OrganizationAutoEnrollStatusResponseJson( + organizationId = organizationId, + isResetPasswordEnabled = true, + ) + .asSuccess() + coEvery { + organizationService.getOrganizationKeys(organizationId) + } returns OrganizationKeysResponseJson( + privateKey = "", + publicKey = publicOrgKey, + ) + .asSuccess() + coEvery { + organizationService.organizationResetPasswordEnroll( + organizationId = organizationId, + userId = profile.userId, + passwordHash = passwordHash, + resetPasswordKey = resetPasswordKey, + ) + } returns Unit.asSuccess() + coEvery { + vaultSdkSource.getResetPasswordKey( + orgPublicKey = publicOrgKey, + userId = profile.userId, + ) + } returns resetPasswordKey.asSuccess() + coEvery { + vaultRepository.unlockVaultWithMasterPassword(password) + } returns VaultUnlockResult.Success + + val result = repository.setPassword( + organizationIdentifier = organizationIdentifier, + password = password, + passwordHint = passwordHint, + ) + + assertEquals(SetPasswordResult.Success, result) + fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = privateRsaKey) + fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = encryptedUserKey) + fakeAuthDiskSource.assertUserState(SINGLE_USER_STATE_1_WITH_PASS) + coVerify(exactly = 1) { + authSdkSource.makeRegisterKeys(email = EMAIL, password = password, kdf = kdf) + accountsService.setPassword(body = setPasswordRequestJson) + organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) + organizationService.getOrganizationKeys(organizationId) + organizationService.organizationResetPasswordEnroll( + organizationId = organizationId, + userId = profile.userId, + passwordHash = passwordHash, + resetPasswordKey = resetPasswordKey, + ) + vaultRepository.unlockVaultWithMasterPassword(password) + vaultSdkSource.getResetPasswordKey( + orgPublicKey = publicOrgKey, + userId = profile.userId, + ) + } } - } @Test fun `setPassword with updatePassword success should return Success`() = runTest { @@ -5981,7 +6222,6 @@ class AuthRepositoryTest { ), ) val profile = userState.activeAccount.profile - val kdf = profile.toSdkParams() val updatePasswordResponse = UpdatePasswordResponse( passwordHash = passwordHash, newKey = encryptedUserKey, @@ -5998,14 +6238,6 @@ class AuthRepositoryTest { keys = null, ) fakeAuthDiskSource.userState = userState - coEvery { - authSdkSource.hashPassword( - email = EMAIL, - password = password, - kdf = kdf, - purpose = HashPurpose.SERVER_AUTHORIZATION, - ) - } returns passwordHash.asSuccess() coEvery { vaultSdkSource.updatePassword(userId = USER_ID_1, newPassword = password) } returns updatePasswordResponse.asSuccess() @@ -6051,21 +6283,11 @@ class AuthRepositoryTest { ) assertEquals(SetPasswordResult.Success, result) - fakeAuthDiskSource.assertMasterPasswordHash( - userId = USER_ID_1, - passwordHash = passwordHash, - ) fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = null) fakeAuthDiskSource.assertAccountKeys(userId = USER_ID_1, accountKeys = null) fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = encryptedUserKey) fakeAuthDiskSource.assertUserState(SINGLE_USER_STATE_1_WITH_PASS) coVerify(exactly = 1) { - authSdkSource.hashPassword( - email = EMAIL, - password = password, - kdf = kdf, - purpose = HashPurpose.SERVER_AUTHORIZATION, - ) vaultSdkSource.updatePassword(userId = USER_ID_1, newPassword = password) accountsService.setPassword(body = setPasswordRequestJson) organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) @@ -6085,95 +6307,84 @@ class AuthRepositoryTest { } @Test - fun `setPassword with unlockVaultWithMasterPassword error should return Failure`() = runTest { - val password = "password" - val passwordHash = "passwordHash" - val passwordHint = "passwordHint" - val organizationIdentifier = ORGANIZATION_IDENTIFIER - val organizationId = "orgId" - val encryptedUserKey = "encryptedUserKey" - val privateRsaKey = "privateRsaKey" - val publicRsaKey = "publicRsaKey" - val publicOrgKey = "publicOrgKey" - val resetPasswordKey = "resetPasswordKey" - val profile = SINGLE_USER_STATE_1.activeAccount.profile - val kdf = profile.toSdkParams() - val registerKeyResponse = RegisterKeyResponse( - masterPasswordHash = passwordHash, - encryptedUserKey = encryptedUserKey, - keys = RsaKeyPair(public = publicRsaKey, private = privateRsaKey), - ) - val setPasswordRequestJson = SetPasswordRequestJson( - passwordHash = passwordHash, - passwordHint = passwordHint, - organizationIdentifier = organizationIdentifier, - kdfIterations = profile.kdfIterations, - kdfMemory = profile.kdfMemory, - kdfParallelism = profile.kdfParallelism, - kdfType = profile.kdfType, - key = encryptedUserKey, - keys = RegisterRequestJson.Keys( - publicKey = publicRsaKey, - encryptedPrivateKey = privateRsaKey, - ), - ) - fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 - coEvery { - authSdkSource.hashPassword( - email = EMAIL, - password = password, - kdf = kdf, - purpose = HashPurpose.SERVER_AUTHORIZATION, + fun `setPassword with unlockVaultWithMasterPassword error should return Failure for v1`() = + runTest { + every { + featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionJitPassword) + } returns false + val password = "password" + val passwordHash = "passwordHash" + val passwordHint = "passwordHint" + val organizationIdentifier = ORGANIZATION_IDENTIFIER + val organizationId = "orgId" + val encryptedUserKey = "encryptedUserKey" + val privateRsaKey = "privateRsaKey" + val publicRsaKey = "publicRsaKey" + val publicOrgKey = "publicOrgKey" + val resetPasswordKey = "resetPasswordKey" + val profile = SINGLE_USER_STATE_1.activeAccount.profile + val kdf = profile.toSdkParams() + val registerKeyResponse = RegisterKeyResponse( + masterPasswordHash = passwordHash, + encryptedUserKey = encryptedUserKey, + keys = RsaKeyPair(public = publicRsaKey, private = privateRsaKey), ) - } returns passwordHash.asSuccess() - coEvery { - authSdkSource.makeRegisterKeys(email = EMAIL, password = password, kdf = kdf) - } returns registerKeyResponse.asSuccess() - coEvery { - accountsService.setPassword(body = setPasswordRequestJson) - } returns Unit.asSuccess() - val error = Throwable("Fail") - coEvery { - vaultRepository.unlockVaultWithMasterPassword(password) - } returns VaultUnlockResult.GenericError(error = error) - - val result = repository.setPassword( - organizationIdentifier = organizationIdentifier, - password = password, - passwordHint = passwordHint, - ) - - assertEquals(SetPasswordResult.Error(error = error), result) - fakeAuthDiskSource.assertMasterPasswordHash(userId = USER_ID_1, passwordHash = null) - fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = privateRsaKey) - fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = encryptedUserKey) - fakeAuthDiskSource.assertUserState(SINGLE_USER_STATE_1) - coVerify { - authSdkSource.hashPassword( - email = EMAIL, - password = password, - kdf = kdf, - purpose = HashPurpose.SERVER_AUTHORIZATION, - ) - authSdkSource.makeRegisterKeys(email = EMAIL, password = password, kdf = kdf) - accountsService.setPassword(body = setPasswordRequestJson) - vaultRepository.unlockVaultWithMasterPassword(password) - } - coVerify(exactly = 0) { - organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) - organizationService.getOrganizationKeys(organizationId) - organizationService.organizationResetPasswordEnroll( - organizationId = organizationId, - userId = profile.userId, + val setPasswordRequestJson = SetPasswordRequestJson( passwordHash = passwordHash, - resetPasswordKey = resetPasswordKey, + passwordHint = passwordHint, + organizationIdentifier = organizationIdentifier, + kdfIterations = profile.kdfIterations, + kdfMemory = profile.kdfMemory, + kdfParallelism = profile.kdfParallelism, + kdfType = profile.kdfType, + key = encryptedUserKey, + keys = RegisterRequestJson.Keys( + publicKey = publicRsaKey, + encryptedPrivateKey = privateRsaKey, + ), ) - vaultSdkSource.getResetPasswordKey( - orgPublicKey = publicOrgKey, - userId = profile.userId, + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + coEvery { + authSdkSource.makeRegisterKeys(email = EMAIL, password = password, kdf = kdf) + } returns registerKeyResponse.asSuccess() + coEvery { + accountsService.setPassword(body = setPasswordRequestJson) + } returns Unit.asSuccess() + val error = Throwable("Fail") + coEvery { + vaultRepository.unlockVaultWithMasterPassword(password) + } returns VaultUnlockResult.GenericError(error = error) + + val result = repository.setPassword( + organizationIdentifier = organizationIdentifier, + password = password, + passwordHint = passwordHint, ) + + assertEquals(SetPasswordResult.Error(error = error), result) + fakeAuthDiskSource.assertPrivateKey(userId = USER_ID_1, privateKey = privateRsaKey) + fakeAuthDiskSource.assertUserKey(userId = USER_ID_1, userKey = encryptedUserKey) + fakeAuthDiskSource.assertUserState(SINGLE_USER_STATE_1) + coVerify(exactly = 1) { + authSdkSource.makeRegisterKeys(email = EMAIL, password = password, kdf = kdf) + accountsService.setPassword(body = setPasswordRequestJson) + vaultRepository.unlockVaultWithMasterPassword(password) + } + coVerify(exactly = 0) { + organizationService.getOrganizationAutoEnrollStatus(organizationIdentifier) + organizationService.getOrganizationKeys(organizationId) + organizationService.organizationResetPasswordEnroll( + organizationId = organizationId, + userId = profile.userId, + passwordHash = passwordHash, + resetPasswordKey = resetPasswordKey, + ) + vaultSdkSource.getResetPasswordKey( + orgPublicKey = publicOrgKey, + userId = profile.userId, + ) + } } - } @Test fun `passwordHintRequest with valid email should return Success`() = runTest { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt index a98ad6e77a..206c53bbd5 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt @@ -1,5 +1,7 @@ package com.x8bit.bitwarden.data.auth.repository.util +import com.bitwarden.core.MasterPasswordUnlockData +import com.bitwarden.crypto.Kdf import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson import com.bitwarden.data.repository.model.Environment import com.bitwarden.network.model.KdfJson @@ -18,6 +20,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson +import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfRequestModel import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations @@ -29,13 +32,27 @@ import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.time.Instant @Suppress("LargeClass") class UserStateJsonExtensionsTest { + @BeforeEach + fun setup() { + mockkStatic(Kdf::toKdfRequestModel) + } + + @AfterEach + fun tearDown() { + unmockkStatic(Kdf::toKdfRequestModel) + } + @Suppress("MaxLineLength") @Test fun `toUpdatedUserStateJson should do nothing for a non-matching account using toRemovedPasswordUserStateJson`() { @@ -288,7 +305,73 @@ class UserStateJsonExtensionsTest { "activeUserId" to originalAccount, ), ) - .toUserStateJsonWithPassword(), + .toUserStateJsonWithPassword(masterPasswordUnlock = null), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toUserStateJsonWithPassword with masterPasswordUnlock should update active account to set hasMasterPassword and masterPasswordUnlock`() { + val originalProfile = AccountJson.Profile( + userId = "activeUserId", + email = "email", + isEmailVerified = true, + name = "name", + stamp = null, + organizationId = null, + avatarColorHex = null, + hasPremium = true, + forcePasswordResetReason = ForcePasswordResetReason + .TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION, + kdfType = KdfTypeJson.ARGON2_ID, + kdfIterations = 600000, + kdfMemory = 16, + kdfParallelism = 4, + userDecryptionOptions = null, + isTwoFactorEnabled = false, + creationDate = Instant.parse("2024-09-13T01:00:00.00Z"), + ) + val originalAccount = AccountJson( + profile = originalProfile, + tokens = mockk(), + settings = mockk(), + ) + val kdf = mockk() + val masterKeyWrappedUserKey = "masterKeyWrappedUserKey" + val salt = "salt" + val masterPasswordUnlock = MasterPasswordUnlockData( + kdf = mockk { every { toKdfRequestModel() } returns kdf }, + masterKeyWrappedUserKey = masterKeyWrappedUserKey, + salt = salt, + ) + assertEquals( + UserStateJson( + activeUserId = "activeUserId", + accounts = mapOf( + "activeUserId" to originalAccount.copy( + profile = originalProfile.copy( + forcePasswordResetReason = null, + userDecryptionOptions = UserDecryptionOptionsJson( + hasMasterPassword = true, + keyConnectorUserDecryptionOptions = null, + trustedDeviceUserDecryptionOptions = null, + masterPasswordUnlock = MasterPasswordUnlockDataJson( + kdf = kdf, + masterKeyWrappedUserKey = masterKeyWrappedUserKey, + salt = salt, + ), + ), + ), + ), + ), + ), + UserStateJson( + activeUserId = "activeUserId", + accounts = mapOf( + "activeUserId" to originalAccount, + ), + ) + .toUserStateJsonWithPassword(masterPasswordUnlock = masterPasswordUnlock), ) } @@ -352,7 +435,7 @@ class UserStateJsonExtensionsTest { "activeUserId" to originalAccount, ), ) - .toUserStateJsonWithPassword(), + .toUserStateJsonWithPassword(masterPasswordUnlock = null), ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WrappedAccountCryptographicStateExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WrappedAccountCryptographicStateExtensionsTest.kt new file mode 100644 index 0000000000..5c65f53a9f --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/util/WrappedAccountCryptographicStateExtensionsTest.kt @@ -0,0 +1,54 @@ +package com.x8bit.bitwarden.data.auth.repository.util + +import com.bitwarden.core.WrappedAccountCryptographicState +import com.bitwarden.network.model.AccountKeysJson +import com.bitwarden.network.model.AccountKeysJson.PublicKeyEncryptionKeyPair +import com.bitwarden.network.model.AccountKeysJson.SecurityState +import com.bitwarden.network.model.AccountKeysJson.SignatureKeyPair +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNull + +class WrappedAccountCryptographicStateExtensionsTest { + @Test + fun `privateKey should return correct value`() { + assertEquals("v1PrivateKey", V1_WRAPPED_ACCOUNT_CRYPTOGRAPHIC_STATE.privateKey) + assertEquals("v2PrivateKey", V2_WRAPPED_ACCOUNT_CRYPTOGRAPHIC_STATE.privateKey) + } + + @Test + fun `accountKeysJson should return correct value`() { + assertNull(V1_WRAPPED_ACCOUNT_CRYPTOGRAPHIC_STATE.accountKeysJson) + assertEquals( + AccountKeysJson( + publicKeyEncryptionKeyPair = PublicKeyEncryptionKeyPair( + publicKey = "", + signedPublicKey = "signedPublicKey", + wrappedPrivateKey = "v2PrivateKey", + ), + signatureKeyPair = SignatureKeyPair( + wrappedSigningKey = "signingKey", + verifyingKey = "", + ), + securityState = SecurityState( + securityState = "securityState", + securityVersion = 2, + ), + ), + V2_WRAPPED_ACCOUNT_CRYPTOGRAPHIC_STATE.accountKeysJson, + ) + } +} + +private val V1_WRAPPED_ACCOUNT_CRYPTOGRAPHIC_STATE: WrappedAccountCryptographicState = + WrappedAccountCryptographicState.V1( + privateKey = "v1PrivateKey", + ) + +private val V2_WRAPPED_ACCOUNT_CRYPTOGRAPHIC_STATE: WrappedAccountCryptographicState = + WrappedAccountCryptographicState.V2( + privateKey = "v2PrivateKey", + securityState = "securityState", + signingKey = "signingKey", + signedPublicKey = "signedPublicKey", + ) From 3845c1fb13a7557d58ade07296803dd85873f2dc Mon Sep 17 00:00:00 2001 From: "bw-ghapp[bot]" <178206702+bw-ghapp[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:04:13 -0500 Subject: [PATCH 16/16] Update SDK to 2.0.0-6484-a19b6544 (#6847) Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cb85f6d389..39685db0d8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,7 @@ androidxRoom = "2.8.4" androidxSecurityCrypto = "1.1.0" androidxSplash = "1.2.0" androidxWork = "2.11.2" -bitwardenSdk = "2.0.0-6370-96753eef" +bitwardenSdk = "2.0.0-6484-a19b6544" crashlytics = "3.0.7" detekt = "1.23.8" firebaseBom = "34.12.0"