From 80b3a7e6758bce76ff81c0bfd38acdf573ab9f63 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Mon, 15 Sep 2025 15:44:03 -0400 Subject: [PATCH] [PM-25663] Introduce CredentialExchangeImporter (#5868) --- cxf/build.gradle.kts | 1 + .../importer/CredentialExchangeImporter.kt | 17 +++ .../CredentialExchangeImporterImpl.kt | 57 ++++++++++ .../dsl/CredentialExchangeImporterBuilder.kt | 47 ++++++++ .../model/ImportCredentialsSelectionResult.kt | 36 ++++++ ...LocalCredentialExchangeImporterProvider.kt | 16 +++ .../CredentialExchangeImporterTest.kt | 106 ++++++++++++++++++ 7 files changed, 280 insertions(+) create mode 100644 cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporter.kt create mode 100644 cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterImpl.kt create mode 100644 cxf/src/main/kotlin/com/bitwarden/cxf/importer/dsl/CredentialExchangeImporterBuilder.kt create mode 100644 cxf/src/main/kotlin/com/bitwarden/cxf/importer/model/ImportCredentialsSelectionResult.kt create mode 100644 cxf/src/main/kotlin/com/bitwarden/cxf/ui/composition/LocalCredentialExchangeImporterProvider.kt create mode 100644 cxf/src/test/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterTest.kt diff --git a/cxf/build.gradle.kts b/cxf/build.gradle.kts index 5fe3a900e8..75acd2faa7 100644 --- a/cxf/build.gradle.kts +++ b/cxf/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { implementation(libs.androidx.credentials.providerevents) implementation(libs.androidx.credentials.providerevents.play.services) implementation(libs.kotlinx.coroutines.android) + implementation(libs.timber) testImplementation(platform(libs.junit.bom)) testRuntimeOnly(libs.junit.platform.launcher) diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporter.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporter.kt new file mode 100644 index 0000000000..ee3890b67a --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporter.kt @@ -0,0 +1,17 @@ +package com.bitwarden.cxf.importer + +import com.bitwarden.cxf.importer.model.ImportCredentialsSelectionResult + +/** + * Responsible for importing credentials from other apps. + */ +interface CredentialExchangeImporter { + /** + * Starts the import process by requesting selection of a source credential provider. + * + * @param credentialTypes The types of credentials to import. + */ + suspend fun importCredentials( + credentialTypes: List, + ): ImportCredentialsSelectionResult +} diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterImpl.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterImpl.kt new file mode 100644 index 0000000000..879445128b --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterImpl.kt @@ -0,0 +1,57 @@ +package com.bitwarden.cxf.importer + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.credentials.providerevents.ProviderEventsManager +import androidx.credentials.providerevents.exception.ImportCredentialsCancellationException +import androidx.credentials.providerevents.exception.ImportCredentialsException +import androidx.credentials.providerevents.transfer.ImportCredentialsRequest +import com.bitwarden.cxf.importer.model.ImportCredentialsSelectionResult +import timber.log.Timber + +/** + * Default implementation of [CredentialExchangeImporter]. + * + * @param activity The context of the activity that is importing credentials. + * @param providerEventsManager The [ProviderEventsManager] instance used for managing provider + * events. If not provided, a default instance will be created using the provided [activity]. + * It is only meant to be used for testing purposes. + */ +internal class CredentialExchangeImporterImpl( + private val activity: Context, + @param:VisibleForTesting + private val providerEventsManager: ProviderEventsManager = + ProviderEventsManager.create(activity), +) : CredentialExchangeImporter { + + override suspend fun importCredentials( + credentialTypes: List, + ): ImportCredentialsSelectionResult { + return try { + val response = providerEventsManager.importCredentials( + context = activity, + request = ImportCredentialsRequest( + // TODO: [PM-25663] Link to the correct documentation once it's available. + requestJson = """ + { + "importer": "${activity.packageName}", + "credentialTypes": [ + ${credentialTypes.joinToString { "\"$it\"" }} + ] + } + """ + .trimIndent(), + ), + ) + ImportCredentialsSelectionResult.Success( + response = response.response.responseJson, + callingAppInfo = response.callingAppInfo, + ) + } catch (_: ImportCredentialsCancellationException) { + ImportCredentialsSelectionResult.Cancelled + } catch (e: ImportCredentialsException) { + Timber.e(e, "Failed to import items from selected credential manager.") + ImportCredentialsSelectionResult.Failure(error = e) + } + } +} diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/importer/dsl/CredentialExchangeImporterBuilder.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/importer/dsl/CredentialExchangeImporterBuilder.kt new file mode 100644 index 0000000000..7ab991079c --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/importer/dsl/CredentialExchangeImporterBuilder.kt @@ -0,0 +1,47 @@ +@file:OmitFromCoverage + +package com.bitwarden.cxf.importer.dsl + +import android.app.Activity +import com.bitwarden.annotation.OmitFromCoverage +import com.bitwarden.cxf.importer.CredentialExchangeImporter +import com.bitwarden.cxf.importer.CredentialExchangeImporterImpl + +/** + * A builder class for constructing an instance of [CredentialExchangeImporter]. + * + * This builder is invoked within the [credentialExchangeImporter] function to configure and create + * the importer. It is not intended to be instantiated directly. + */ +@OmitFromCoverage +class CredentialExchangeImporterBuilder internal constructor() { + internal fun build(activity: Activity): CredentialExchangeImporter = + CredentialExchangeImporterImpl(activity = activity) +} + +/** + * Creates an instance of [CredentialExchangeImporter] using the provided [activity] + * and an optional [config] lambda to configure the importer. + * + * This function acts as a DSL entry point for building a [CredentialExchangeImporter]. + * + * Example usage: + * ```kotlin + * val importer = credentialExchangeImporter(activity) { + * // Configuration options for the builder can be set here if any are added in the future. + * logsManager = myLogsManager // Optional: pass your LogsManager instance + * } + * importer.importCredentials() + * ``` + * + * @param activity The Android [Activity] triggering the import process. + * @param config A lambda with [CredentialExchangeImporterBuilder] as its receiver, + * allowing for declarative configuration of the importer. + * @return A fully configured instance of [CredentialExchangeImporter]. + */ +fun credentialExchangeImporter( + activity: Activity, + config: CredentialExchangeImporterBuilder.() -> Unit = {}, +): CredentialExchangeImporter = CredentialExchangeImporterBuilder() + .apply(config) + .build(activity = activity) diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/importer/model/ImportCredentialsSelectionResult.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/importer/model/ImportCredentialsSelectionResult.kt new file mode 100644 index 0000000000..6c8668f41a --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/importer/model/ImportCredentialsSelectionResult.kt @@ -0,0 +1,36 @@ +package com.bitwarden.cxf.importer.model + +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.providerevents.exception.ImportCredentialsException + +/** + * Represents the result of the credential selection step of the import process. + */ +sealed class ImportCredentialsSelectionResult { + + /** + * Represents a successful response from the selected credential provider. + * + * @property response The response from the import. This is a JSON string containing + * credentials to import and is compliant with the FIDO2 CXF standard. + * @property callingAppInfo The calling app information. + */ + data class Success( + val response: String, + val callingAppInfo: CallingAppInfo, + ) : ImportCredentialsSelectionResult() + + /** + * Represents a cancellation of the import process. + */ + data object Cancelled : ImportCredentialsSelectionResult() + + /** + * Represents a failure during the credential selection step of the import process. + * + * @property error The exception that caused the failure. + */ + data class Failure( + val error: ImportCredentialsException, + ) : ImportCredentialsSelectionResult() +} diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/ui/composition/LocalCredentialExchangeImporterProvider.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/ui/composition/LocalCredentialExchangeImporterProvider.kt new file mode 100644 index 0000000000..471e53b7cf --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/ui/composition/LocalCredentialExchangeImporterProvider.kt @@ -0,0 +1,16 @@ +@file:OmitFromCoverage + +package com.bitwarden.cxf.ui.composition + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import com.bitwarden.annotation.OmitFromCoverage +import com.bitwarden.cxf.importer.CredentialExchangeImporter + +/** + * Provides access to the Credential Exchange importer throughout the app. + */ +val LocalCredentialExchangeImporter: ProvidableCompositionLocal = + compositionLocalOf { + error("CompositionLocal LocalPermissionsManager not present") + } diff --git a/cxf/src/test/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterTest.kt b/cxf/src/test/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterTest.kt new file mode 100644 index 0000000000..6cf9ab6665 --- /dev/null +++ b/cxf/src/test/kotlin/com/bitwarden/cxf/importer/CredentialExchangeImporterTest.kt @@ -0,0 +1,106 @@ +package com.bitwarden.cxf.importer + +import android.app.Activity +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.providerevents.ProviderEventsManager +import androidx.credentials.providerevents.exception.ImportCredentialsCancellationException +import androidx.credentials.providerevents.exception.ImportCredentialsException +import androidx.credentials.providerevents.transfer.ImportCredentialsRequest +import androidx.credentials.providerevents.transfer.ImportCredentialsResponse +import androidx.credentials.providerevents.transfer.ProviderImportCredentialsResponse +import com.bitwarden.cxf.importer.model.ImportCredentialsSelectionResult +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CredentialExchangeImporterTest { + + private val mockActivity = mockk(relaxed = true) { + every { packageName } returns "mockPackageName" + } + private val mockProviderEventsManager = mockk() + private val importer = CredentialExchangeImporterImpl( + activity = mockActivity, + providerEventsManager = mockProviderEventsManager, + ) + + @Test + fun `importCredentials should construct request correctly and return a success result`() = + runTest { + val mockCallingAppInfo = mockk() + val capturedRequestJson = mutableListOf() + val expectedRequestJson = """ + { + "importer": "mockPackageName", + "credentialTypes": [ + "basic-auth" + ] + } + """ + .trimIndent() + coEvery { + mockProviderEventsManager.importCredentials( + context = mockActivity, + request = capture(capturedRequestJson), + ) + } returns ProviderImportCredentialsResponse( + response = ImportCredentialsResponse( + responseJson = "mockResponse", + ), + callingAppInfo = mockCallingAppInfo, + ) + + val result = importer.importCredentials(listOf("basic-auth")) + assertEquals( + expectedRequestJson, + capturedRequestJson.firstOrNull()?.requestJson, + ) + assertEquals( + ImportCredentialsSelectionResult.Success( + response = "mockResponse", + callingAppInfo = mockCallingAppInfo, + ), + result, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `importCredentials should return ImportCredentialsSelectionResult Cancelled when ImportCredentialsCancellationException is thrown`() = + runTest { + coEvery { + mockProviderEventsManager.importCredentials( + context = mockActivity, + request = any(), + ) + } throws ImportCredentialsCancellationException() + + assertEquals( + ImportCredentialsSelectionResult.Cancelled, + importer.importCredentials(listOf("basic-auth")), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `importCredentials should return ImportCredentialsSelectionResult Failure when ImportCredentialsException is thrown`() = + runTest { + val importException = mockk() + coEvery { + mockProviderEventsManager.importCredentials( + context = mockActivity, + request = any(), + ) + } throws importException + + val result = importer.importCredentials(listOf("basic-auth")) + + assertEquals( + ImportCredentialsSelectionResult.Failure(error = importException), + result, + ) + } +}