[PM-26112] Handle Credential Exchange export requests (#5928)

This commit is contained in:
Patrick Honkonen
2025-09-23 17:41:58 -04:00
committed by GitHub
parent d14fba0c01
commit cc685b2307
13 changed files with 288 additions and 1 deletions

View File

@@ -20,6 +20,18 @@
<data android:host="*.bitwarden.pw" />
<data android:pathPattern="/redirect-connector.*" />
</intent-filter>
<!-- Handle Credential Exchange transfer requests -->
<intent-filter
android:autoVerify="true"
tools:ignore="AppLinkUrlError">
<action android:name="androidx.identitycredentials.action.IMPORT_CREDENTIALS" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:mimeType="application/octet-stream"
android:scheme="content"
tools:ignore="AppLinkUriRelativeFilterGroupError" />
</intent-filter>
</activity>
</application>

View File

@@ -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,
),
)
}
}
}

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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)
}
}
}
}

View File

@@ -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()
}
/**

View File

@@ -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<ExportItemsRoute>(
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)
}

View File

@@ -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<SelectAccountRoute> {
SelectAccountScreen(
onAccountSelected = onAccountSelected,
)
}
}
/**
* Navigate to the [SelectAccountScreen].
*/
fun NavController.navigateToSelectAccountScreen(
navOptions: NavOptions? = null,
) {
navigate(
route = SelectAccountRoute,
navOptions = navOptions,
)
}

View File

@@ -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.
}

View File

@@ -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<Intent> {
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(

View File

@@ -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.AddTotpLoginItem>(),
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())
}
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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,