mirror of
https://github.com/bitwarden/android.git
synced 2026-03-12 05:04:17 -05:00
[PM-26112] Handle Credential Exchange export requests (#5928)
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user