mirror of
https://github.com/bitwarden/android.git
synced 2025-12-11 04:39:19 -06:00
[PM-25663] Introduce CredentialExchangeImporter (#5868)
This commit is contained in:
parent
8235045dad
commit
80b3a7e675
@ -52,6 +52,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.credentials.providerevents)
|
implementation(libs.androidx.credentials.providerevents)
|
||||||
implementation(libs.androidx.credentials.providerevents.play.services)
|
implementation(libs.androidx.credentials.providerevents.play.services)
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
implementation(libs.timber)
|
||||||
|
|
||||||
testImplementation(platform(libs.junit.bom))
|
testImplementation(platform(libs.junit.bom))
|
||||||
testRuntimeOnly(libs.junit.platform.launcher)
|
testRuntimeOnly(libs.junit.platform.launcher)
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
@ -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()
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user