[PM-25663] Introduce CredentialExchangeImporter (#5868)

This commit is contained in:
Patrick Honkonen 2025-09-15 15:44:03 -04:00 committed by GitHub
parent 8235045dad
commit 80b3a7e675
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 280 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<CredentialExchangeImporter> =
compositionLocalOf {
error("CompositionLocal LocalPermissionsManager not present")
}

View File

@ -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<Activity>(relaxed = true) {
every { packageName } returns "mockPackageName"
}
private val mockProviderEventsManager = mockk<ProviderEventsManager>()
private val importer = CredentialExchangeImporterImpl(
activity = mockActivity,
providerEventsManager = mockProviderEventsManager,
)
@Test
fun `importCredentials should construct request correctly and return a success result`() =
runTest {
val mockCallingAppInfo = mockk<CallingAppInfo>()
val capturedRequestJson = mutableListOf<ImportCredentialsRequest>()
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<ImportCredentialsException>()
coEvery {
mockProviderEventsManager.importCredentials(
context = mockActivity,
request = any(),
)
} throws importException
val result = importer.importCredentials(listOf("basic-auth"))
assertEquals(
ImportCredentialsSelectionResult.Failure(error = importException),
result,
)
}
}