diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index 2d4e1a5eb2..f128653a30 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -20,6 +20,18 @@ + + + + + + + diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt index 6a59074f2c..e4d2551020 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt @@ -5,6 +5,8 @@ import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.core.data.manager.toast.ToastManager +import com.bitwarden.cxf.model.ImportCredentialsRequestData +import com.bitwarden.cxf.util.getProviderImportCredentialsRequest import com.bitwarden.ui.platform.base.BaseViewModel import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.bitwarden.ui.platform.manager.IntentManager @@ -295,6 +297,7 @@ class MainViewModel @Inject constructor( val getCredentialsRequest = intent.getGetCredentialsRequestOrNull() val fido2AssertCredentialRequest = intent.getFido2AssertionRequestOrNull() val providerGetPasswordRequest = intent.getProviderGetPasswordRequestOrNull() + val importCredentialsRequest = intent.getProviderImportCredentialsRequest() when { passwordlessRequestData != null -> { authRepository.activeUserId?.let { @@ -418,6 +421,16 @@ class MainViewModel @Inject constructor( specialCircumstanceManager.specialCircumstance = SpecialCircumstance.AccountSecurityShortcut } + + importCredentialsRequest != null -> { + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.CredentialExchangeExport( + data = ImportCredentialsRequestData( + uri = importCredentialsRequest.uri, + requestJson = importCredentialsRequest.request.requestJson, + ), + ) + } } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt index 6435a7c417..d025990f72 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.manager.model import android.os.Parcelable import androidx.credentials.CredentialManager +import com.bitwarden.cxf.model.ImportCredentialsRequestData import com.bitwarden.ui.platform.manager.IntentManager import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData @@ -133,6 +134,14 @@ sealed class SpecialCircumstance : Parcelable { @Parcelize data object VerificationCodeShortcut : SpecialCircumstance() + /** + * The app was launched to select an account to export credentials from. + */ + @Parcelize + data class CredentialExchangeExport( + val data: ImportCredentialsRequestData, + ) : SpecialCircumstance() + /** * A subset of [SpecialCircumstance] that are only relevant in a pre-login state and should be * cleared after a successful login. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt index 919cebea5a..01451f3ac9 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.data.platform.manager.util +import com.bitwarden.cxf.model.ImportCredentialsRequestData import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest @@ -71,3 +72,12 @@ fun SpecialCircumstance.toTotpDataOrNull(): TotpData? = is SpecialCircumstance.AddTotpLoginItem -> this.data else -> null } + +/** + * Returns [ImportCredentialsRequestData] when contained in the given [SpecialCircumstance]. + */ +fun SpecialCircumstance.toImportCredentialsRequestDataOrNull(): ImportCredentialsRequestData? = + when (this) { + is SpecialCircumstance.CredentialExchangeExport -> this.data + else -> null + } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 2fee64a0ca..54be454f6e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -62,6 +62,8 @@ import com.x8bit.bitwarden.ui.tools.feature.send.addedit.navigateToAddEditSend import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs import com.x8bit.bitwarden.ui.vault.feature.addedit.navigateToVaultAddEdit import com.x8bit.bitwarden.ui.vault.feature.addedit.util.toVaultItemCipherType +import com.x8bit.bitwarden.ui.vault.feature.exportitems.exportItemsGraph +import com.x8bit.bitwarden.ui.vault.feature.exportitems.navigateToExportItemsGraph import com.x8bit.bitwarden.ui.vault.feature.itemlisting.navigateToVaultItemListingAsRoot import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType @@ -107,6 +109,7 @@ fun RootNavScreen( setupUnlockDestinationAsRoot() setupAutoFillDestinationAsRoot() setupCompleteDestination() + exportItemsGraph() } val targetRoute = when (state) { @@ -132,6 +135,7 @@ fun RootNavScreen( is RootNavState.VaultUnlockedForFido2Assertion, is RootNavState.VaultUnlockedForPasswordGet, is RootNavState.VaultUnlockedForProviderGetCredentials, + is RootNavState.CredentialExchangeExport, -> VaultUnlockedGraphRoute RootNavState.OnboardingAccountLockSetup -> SetupUnlockRoute.AsRoot @@ -270,6 +274,10 @@ fun RootNavScreen( RootNavState.OnboardingStepsComplete -> { navController.navigateToSetupCompleteScreen(rootNavOptions) } + + is RootNavState.CredentialExchangeExport -> { + navController.navigateToExportItemsGraph(rootNavOptions) + } } } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index 56392cd67a..b741255088 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -88,6 +88,10 @@ class RootNavViewModel @Inject constructor( } } + specialCircumstance is SpecialCircumstance.CredentialExchangeExport -> { + RootNavState.CredentialExchangeExport + } + userState.activeAccount.isVaultUnlocked && userState.shouldShowRemovePassword(authState = action.authState) -> { RootNavState.RemovePassword @@ -181,7 +185,9 @@ class RootNavViewModel @Inject constructor( null, -> RootNavState.VaultUnlocked(activeUserId = userState.activeAccount.userId) - is SpecialCircumstance.RegistrationEvent -> { + is SpecialCircumstance.CredentialExchangeExport, + is SpecialCircumstance.RegistrationEvent, + -> { throw IllegalStateException( "Special circumstance should have been already handled.", ) @@ -401,6 +407,12 @@ sealed class RootNavState : Parcelable { */ @Parcelize data object OnboardingStepsComplete : RootNavState() + + /** + * App should begin the export items flow. + */ + @Parcelize + data object CredentialExchangeExport : RootNavState() } /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/ExportItemsNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/ExportItemsNavigation.kt new file mode 100644 index 0000000000..1374975428 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/ExportItemsNavigation.kt @@ -0,0 +1,43 @@ +@file:OmitFromCoverage + +package com.x8bit.bitwarden.ui.vault.feature.exportitems + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.navigation +import com.bitwarden.annotation.OmitFromCoverage +import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountRoute +import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.selectAccountDestination +import kotlinx.serialization.Serializable + +/** + * The type-safe route for the export items graph. + */ +@OmitFromCoverage +@Serializable +data object ExportItemsRoute + +/** + * Add export items destinations to the nav graph. + */ +fun NavGraphBuilder.exportItemsGraph() { + navigation( + startDestination = SelectAccountRoute, + ) { + selectAccountDestination( + onAccountSelected = { + // TODO: [PM-26110] Navigate to verify password screen. + }, + ) + } +} + +/** + * Navigate to the export items graph. + */ +fun NavController.navigateToExportItemsGraph( + navOptions: NavOptions? = null, +) { + navigate(route = ExportItemsRoute, navOptions = navOptions) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountNavigation.kt new file mode 100644 index 0000000000..06adfb1c3d --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountNavigation.kt @@ -0,0 +1,42 @@ +@file:OmitFromCoverage + +package com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.bitwarden.annotation.OmitFromCoverage +import com.bitwarden.ui.platform.base.util.composableWithRootPushTransitions +import kotlinx.serialization.Serializable + +/** + * The type-safe route for the select account screen. + */ +@OmitFromCoverage +@Serializable +data object SelectAccountRoute + +/** + * Add the [SelectAccountScreen] to the nav graph. + */ +fun NavGraphBuilder.selectAccountDestination( + onAccountSelected: (id: String) -> Unit, +) { + composableWithRootPushTransitions { + SelectAccountScreen( + onAccountSelected = onAccountSelected, + ) + } +} + +/** + * Navigate to the [SelectAccountScreen]. + */ +fun NavController.navigateToSelectAccountScreen( + navOptions: NavOptions? = null, +) { + navigate( + route = SelectAccountRoute, + navOptions = navOptions, + ) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountScreen.kt new file mode 100644 index 0000000000..9703706ec5 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/selectaccount/SelectAccountScreen.kt @@ -0,0 +1,15 @@ +package com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable + +/** + * Top level screen for selecting an account to export items from. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SelectAccountScreen( + onAccountSelected: (id: String) -> Unit, +) { + // TODO: [PM-26095] Implement select account screen. +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt index dce91a3f0b..fd416151ab 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt @@ -6,10 +6,14 @@ import androidx.credentials.GetPublicKeyCredentialOption import androidx.credentials.provider.BiometricPromptResult import androidx.credentials.provider.ProviderCreateCredentialRequest import androidx.credentials.provider.ProviderGetCredentialRequest +import androidx.credentials.providerevents.transfer.ImportCredentialsRequest +import androidx.credentials.providerevents.transfer.ProviderImportCredentialsRequest import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.core.data.manager.toast.ToastManager import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import com.bitwarden.cxf.model.ImportCredentialsRequestData +import com.bitwarden.cxf.util.getProviderImportCredentialsRequest import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager import com.bitwarden.data.repository.model.Environment import com.bitwarden.ui.platform.base.BaseViewModelTest @@ -178,6 +182,7 @@ class MainViewModelTest : BaseViewModelTest() { Intent::getCreateCredentialRequestOrNull, Intent::getGetCredentialsRequestOrNull, Intent::isAddTotpLoginItemFromAuthenticator, + Intent::getProviderImportCredentialsRequest, ) mockkStatic( Intent::isMyVaultShortcut, @@ -209,6 +214,7 @@ class MainViewModelTest : BaseViewModelTest() { Intent::getCreateCredentialRequestOrNull, Intent::getGetCredentialsRequestOrNull, Intent::isAddTotpLoginItemFromAuthenticator, + Intent::getProviderImportCredentialsRequest, ) unmockkStatic( Intent::isMyVaultShortcut, @@ -1098,6 +1104,37 @@ class MainViewModelTest : BaseViewModelTest() { verify { authRepository.switchAccount(userId) } } + @Suppress("MaxLineLength") + @Test + fun `on ReceiveNewIntent with import credentials request data should set the special circumstance to CredentialExchangeExport`() { + val viewModel = createViewModel() + val importCredentialsRequestData = ProviderImportCredentialsRequest( + request = ImportCredentialsRequest("mockRequestJson"), + callingAppInfo = mockk(), + uri = mockk(), + credId = "mockCredId", + ) + val mockIntent = createMockIntent( + mockProviderImportCredentialsRequest = importCredentialsRequestData, + ) + + viewModel.trySendAction( + MainAction.ReceiveNewIntent( + intent = mockIntent, + ), + ) + + assertEquals( + SpecialCircumstance.CredentialExchangeExport( + data = ImportCredentialsRequestData( + uri = importCredentialsRequestData.uri, + requestJson = importCredentialsRequestData.request.requestJson, + ), + ), + specialCircumstanceManager.specialCircumstance, + ) + } + @Suppress("MaxLineLength") @Test fun `on ResumeScreenDataReceived with null value, should call AppResumeManager clearResumeScreen`() { @@ -1209,6 +1246,7 @@ private fun createMockIntent( mockIsPasswordGeneratorShortcut: Boolean = false, mockIsAccountSecurityShortcut: Boolean = false, mockIsAddTotpLoginItemFromAuthenticator: Boolean = false, + mockProviderImportCredentialsRequest: ProviderImportCredentialsRequest? = null, ): Intent = mockk { every { getTotpDataOrNull() } returns mockTotpData every { getPasswordlessRequestDataIntentOrNull() } returns mockPasswordlessRequestData @@ -1223,6 +1261,7 @@ private fun createMockIntent( every { isPasswordGeneratorShortcut } returns mockIsPasswordGeneratorShortcut every { isAccountSecurityShortcut } returns mockIsAccountSecurityShortcut every { isAddTotpLoginItemFromAuthenticator() } returns mockIsAddTotpLoginItemFromAuthenticator + every { getProviderImportCredentialsRequest() } returns mockProviderImportCredentialsRequest } private val FIXED_CLOCK: Clock = Clock.fixed( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt index 6d53bc9b5b..da7247de8b 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensionsTest.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.platform.manager.util import androidx.core.os.bundleOf +import com.bitwarden.cxf.model.ImportCredentialsRequestData import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest @@ -347,4 +348,56 @@ class SpecialCircumstanceExtensionsTest { assertNull(specialCircumstance.toTotpDataOrNull()) } } + + @Suppress("MaxLineLength") + @Test + fun `toImportCredentialsRequestDataOrNull should return a non-null value for ImportCredentials`() { + val importCredentialsRequest = ImportCredentialsRequestData( + uri = mockk(), + requestJson = "", + ) + assertEquals( + importCredentialsRequest, + SpecialCircumstance + .CredentialExchangeExport( + data = importCredentialsRequest, + ) + .toImportCredentialsRequestDataOrNull(), + ) + } + + @Test + fun `toImportCredentialsRequestDataOrNull should return a null value for other types`() { + listOf( + SpecialCircumstance.AutofillSelection( + autofillSelectionData = mockk(), + shouldFinishWhenComplete = true, + ), + SpecialCircumstance.AutofillSave( + autofillSaveItem = mockk(), + ), + SpecialCircumstance.ShareNewSend( + data = mockk(), + shouldFinishWhenComplete = true, + ), + mockk(), + SpecialCircumstance.PasswordlessRequest( + passwordlessRequestData = mockk(), + shouldFinishWhenComplete = true, + ), + SpecialCircumstance.ProviderCreateCredential( + createCredentialRequest = mockk(), + ), + SpecialCircumstance.ProviderGetCredentials( + getCredentialsRequest = mockk(), + ), + SpecialCircumstance.Fido2Assertion( + fido2AssertionRequest = mockk(), + ), + SpecialCircumstance.GeneratorShortcut, + SpecialCircumstance.VaultShortcut, + ).forEach { specialCircumstance -> + assertNull(specialCircumstance.toImportCredentialsRequestDataOrNull()) + } + } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt index 6c1e7480da..2560a73509 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -23,6 +23,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.addedit.ModeType import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditMode import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditRoute +import com.x8bit.bitwarden.ui.vault.feature.exportitems.ExportItemsRoute import com.x8bit.bitwarden.ui.vault.feature.itemlisting.ItemListingType import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingRoute import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType @@ -426,6 +427,17 @@ class RootNavScreenTest : BitwardenComposeTest() { ) } } + + // Make sure navigating to export items graph works as expected: + rootNavStateFlow.value = RootNavState.CredentialExchangeExport + composeTestRule.runOnIdle { + verify { + mockNavHostController.navigate( + route = ExportItemsRoute, + navOptions = expectedNavOptions, + ) + } + } } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index 012927d9df..bde6d03529 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav import androidx.core.os.bundleOf +import com.bitwarden.cxf.model.ImportCredentialsRequestData import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager import com.bitwarden.data.repository.model.Environment import com.bitwarden.network.model.JwtTokenDataJson @@ -1392,6 +1393,24 @@ class RootNavViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `when SpecialCircumstance is CredentialExchangeExport the nav state should be CredentialExchangeExport`() { + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.CredentialExchangeExport( + data = ImportCredentialsRequestData( + uri = mockk(), + requestJson = "mockRequestJson", + ), + ) + mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE) + val viewModel = createViewModel() + assertEquals( + RootNavState.CredentialExchangeExport, + viewModel.stateFlow.value, + ) + } + private fun createViewModel(): RootNavViewModel = RootNavViewModel( authRepository = authRepository,