Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's up with this error?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not really sure. This is the first time I've seen both of these. I have an pending request for more details about them. According to the integration guide this is how the intent-filter must be declared. It's possible this will change when the API is stable.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, that is pretty odd

<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" />
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one too?

</intent-filter>
</activity>
</application>

Expand Down
13 changes: 13 additions & 0 deletions app/src/main/kotlin/com/x8bit/bitwarden/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
),
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -107,6 +109,7 @@ fun RootNavScreen(
setupUnlockDestinationAsRoot()
setupAutoFillDestinationAsRoot()
setupCompleteDestination()
exportItemsGraph()
}

val targetRoute = when (state) {
Expand All @@ -132,6 +135,7 @@ fun RootNavScreen(
is RootNavState.VaultUnlockedForFido2Assertion,
is RootNavState.VaultUnlockedForPasswordGet,
is RootNavState.VaultUnlockedForProviderGetCredentials,
is RootNavState.CredentialExchangeExport,
-> VaultUnlockedGraphRoute

RootNavState.OnboardingAccountLockSetup -> SetupUnlockRoute.AsRoot
Expand Down Expand Up @@ -270,6 +274,10 @@ fun RootNavScreen(
RootNavState.OnboardingStepsComplete -> {
navController.navigateToSetupCompleteScreen(rootNavOptions)
}

is RootNavState.CredentialExchangeExport -> {
navController.navigateToExportItemsGraph(rootNavOptions)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ class RootNavViewModel @Inject constructor(
}
}

specialCircumstance is SpecialCircumstance.CredentialExchangeExport -> {
RootNavState.CredentialExchangeExport
}

userState.activeAccount.isVaultUnlocked &&
userState.shouldShowRemovePassword(authState = action.authState) -> {
RootNavState.RemovePassword
Expand Down Expand Up @@ -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.",
)
Expand Down Expand Up @@ -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()
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since there is no screen yet, should we block support for these deeplinks?

}
39 changes: 39 additions & 0 deletions app/src/test/kotlin/com/x8bit/bitwarden/MainViewModelTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -178,6 +182,7 @@ class MainViewModelTest : BaseViewModelTest() {
Intent::getCreateCredentialRequestOrNull,
Intent::getGetCredentialsRequestOrNull,
Intent::isAddTotpLoginItemFromAuthenticator,
Intent::getProviderImportCredentialsRequest,
)
mockkStatic(
Intent::isMyVaultShortcut,
Expand Down Expand Up @@ -209,6 +214,7 @@ class MainViewModelTest : BaseViewModelTest() {
Intent::getCreateCredentialRequestOrNull,
Intent::getGetCredentialsRequestOrNull,
Intent::isAddTotpLoginItemFromAuthenticator,
Intent::getProviderImportCredentialsRequest,
)
unmockkStatic(
Intent::isMyVaultShortcut,
Expand Down Expand Up @@ -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`() {
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
Loading