From 481a8c8fbc2f723c0e4c6f124f90bb1f1a9fa412 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Mon, 15 Sep 2025 14:36:48 -0400 Subject: [PATCH] [PM-25662] Add CredentialExchangeCompletionManager (#5867) --- .../CredentialExchangeCompletionManager.kt | 16 ++++ ...CredentialExchangeCompletionManagerImpl.kt | 41 ++++++++++ ...dentialExchangeCompletionManagerBuilder.kt | 54 ++++++++++++ .../manager/model/ExportCredentialsResult.kt | 20 +++++ ...entialExchangeCompletionManagerProvider.kt | 17 ++++ ...CredentialExchangeCompletionManagerTest.kt | 82 +++++++++++++++++++ 6 files changed, 230 insertions(+) create mode 100644 cxf/src/main/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManager.kt create mode 100644 cxf/src/main/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerImpl.kt create mode 100644 cxf/src/main/kotlin/com/bitwarden/cxf/manager/dsl/CredentialExchangeCompletionManagerBuilder.kt create mode 100644 cxf/src/main/kotlin/com/bitwarden/cxf/manager/model/ExportCredentialsResult.kt create mode 100644 cxf/src/main/kotlin/com/bitwarden/cxf/ui/composition/LocalCredentialExchangeCompletionManagerProvider.kt create mode 100644 cxf/src/test/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerTest.kt diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManager.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManager.kt new file mode 100644 index 0000000000..2adcd82c95 --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManager.kt @@ -0,0 +1,16 @@ +package com.bitwarden.cxf.manager + +import com.bitwarden.cxf.manager.model.ExportCredentialsResult + +/** + * A manager for completing the Credential Exchange processes. + */ +interface CredentialExchangeCompletionManager { + + /** + * Complete the Credential Exchange export process with the provided [exportResult]. + * + * @param exportResult The result of the export operation. + */ + fun completeCredentialExport(exportResult: ExportCredentialsResult) +} diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerImpl.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerImpl.kt new file mode 100644 index 0000000000..2154bbaf8f --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerImpl.kt @@ -0,0 +1,41 @@ +package com.bitwarden.cxf.manager + +import android.app.Activity +import android.content.Intent +import androidx.credentials.providerevents.playservices.IntentHandler +import androidx.credentials.providerevents.transfer.ImportCredentialsResponse +import com.bitwarden.cxf.manager.model.ExportCredentialsResult + +/** + * Primary implementation of [CredentialExchangeCompletionManager]. + */ +internal class CredentialExchangeCompletionManagerImpl( + private val activity: Activity, +) : CredentialExchangeCompletionManager { + + override fun completeCredentialExport(exportResult: ExportCredentialsResult) { + val intent = Intent() + when (exportResult) { + is ExportCredentialsResult.Failure -> { + IntentHandler.setImportCredentialsException( + intent = intent, + exception = exportResult.error, + ) + } + + is ExportCredentialsResult.Success -> { + IntentHandler.setImportCredentialsResponse( + context = activity, + uri = exportResult.uri, + response = ImportCredentialsResponse( + responseJson = exportResult.payload, + ), + ) + } + } + activity.apply { + setResult(Activity.RESULT_OK, intent) + finish() + } + } +} diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/manager/dsl/CredentialExchangeCompletionManagerBuilder.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/manager/dsl/CredentialExchangeCompletionManagerBuilder.kt new file mode 100644 index 0000000000..aaafe917f3 --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/manager/dsl/CredentialExchangeCompletionManagerBuilder.kt @@ -0,0 +1,54 @@ +@file:OmitFromCoverage + +package com.bitwarden.cxf.manager.dsl + +import android.app.Activity +import com.bitwarden.annotation.OmitFromCoverage +import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager +import com.bitwarden.cxf.manager.CredentialExchangeCompletionManagerImpl + +/** + * A DSL for building a [CredentialExchangeCompletionManager]. + * + * This class provides a structured way to configure and create an instance of + * [CredentialExchangeCompletionManager], which is used to finalize the credential + * exchange process by returning a result to the calling application. It is primarily + * used within the [credentialExchangeCompletionManager] builder function. + * + */ +@OmitFromCoverage +class CredentialExchangeCompletionManagerBuilder internal constructor() { + internal fun build(activity: Activity): CredentialExchangeCompletionManager = + CredentialExchangeCompletionManagerImpl(activity = activity) +} + +/** + * Creates an instance of [CredentialExchangeCompletionManager] using a DSL-style builder. + * + * This function is the entry point for handling the completion of a credential exchange flow, + * such as after a user has successfully created or selected a passkey. + * + * Example usage: + * ``` + * val completionManager = credentialExchangeCompletionManager(activity) { + * // Configuration options can be added here if the DSL is extended in the future. + * } + * + * // Use the completionManager to finish the credential exchange. + * completionManager.completeCredentialExport(...) + * ``` + * + * @param activity The [Activity] that initiated the credential exchange operation. This is + * used to send back the result to the calling application (e.g., the browser). + * @param config A lambda with [CredentialExchangeCompletionManagerBuilder] as its receiver, + * allowing for declarative configuration of the manager. + * + * @return A configured [CredentialExchangeCompletionManager] instance. + */ +fun credentialExchangeCompletionManager( + activity: Activity, + config: CredentialExchangeCompletionManagerBuilder.() -> Unit = {}, +): CredentialExchangeCompletionManager = + CredentialExchangeCompletionManagerBuilder() + .apply(config) + .build(activity = activity) diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/manager/model/ExportCredentialsResult.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/manager/model/ExportCredentialsResult.kt new file mode 100644 index 0000000000..c90e3b721b --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/manager/model/ExportCredentialsResult.kt @@ -0,0 +1,20 @@ +package com.bitwarden.cxf.manager.model + +import android.net.Uri +import androidx.credentials.providerevents.exception.ImportCredentialsException + +/** + * Represents the result of exporting credentials. + */ +sealed class ExportCredentialsResult { + + /** + * Represents a successful export. + */ + data class Success(val payload: String, val uri: Uri) : ExportCredentialsResult() + + /** + * Represents a failure to export. + */ + data class Failure(val error: ImportCredentialsException) : ExportCredentialsResult() +} diff --git a/cxf/src/main/kotlin/com/bitwarden/cxf/ui/composition/LocalCredentialExchangeCompletionManagerProvider.kt b/cxf/src/main/kotlin/com/bitwarden/cxf/ui/composition/LocalCredentialExchangeCompletionManagerProvider.kt new file mode 100644 index 0000000000..a230301e9b --- /dev/null +++ b/cxf/src/main/kotlin/com/bitwarden/cxf/ui/composition/LocalCredentialExchangeCompletionManagerProvider.kt @@ -0,0 +1,17 @@ +@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.manager.CredentialExchangeCompletionManager + +/** + * Provides access to the Credential Exchange completion manager throughout the app. + */ +@Suppress("MaxLineLength") +val LocalCredentialExchangeCompletionManager: ProvidableCompositionLocal = + compositionLocalOf { + error("CompositionLocal LocalPermissionsManager not present") + } diff --git a/cxf/src/test/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerTest.kt b/cxf/src/test/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerTest.kt new file mode 100644 index 0000000000..da3e7a61f2 --- /dev/null +++ b/cxf/src/test/kotlin/com/bitwarden/cxf/manager/CredentialExchangeCompletionManagerTest.kt @@ -0,0 +1,82 @@ +package com.bitwarden.cxf.manager + +import android.app.Activity +import android.net.Uri +import androidx.credentials.providerevents.exception.ImportCredentialsException +import androidx.credentials.providerevents.playservices.IntentHandler +import com.bitwarden.cxf.manager.model.ExportCredentialsResult +import io.mockk.Ordering +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.runs +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class CredentialExchangeCompletionManagerTest { + + private val mockActivity = mockk() + private val completionManager = CredentialExchangeCompletionManagerImpl(mockActivity) + + @BeforeEach + fun setUp() { + mockkObject(IntentHandler) + every { + IntentHandler.setImportCredentialsResponse( + context = any(), + uri = any(), + response = any(), + ) + } just runs + + every { + IntentHandler.setImportCredentialsException( + intent = any(), + exception = any(), + ) + } just runs + } + + @Test + fun `completeCredentialExport sets Success result and finishes the activity`() { + val mockUri = mockk() + val exportResult = ExportCredentialsResult.Success("payload", mockUri) + + every { mockActivity.setResult(Activity.RESULT_OK, any()) } just runs + every { mockActivity.finish() } just runs + + completionManager.completeCredentialExport(exportResult) + + verify(ordering = Ordering.ORDERED) { + IntentHandler.setImportCredentialsResponse( + context = mockActivity, + uri = mockUri, + response = any(), + ) + mockActivity.setResult(Activity.RESULT_OK, any()) + mockActivity.finish() + } + } + + @Test + fun `completeCredentialExport sets Failure result and finishes the activity`() { + val importException = mockk() + val exportResult = ExportCredentialsResult.Failure(error = importException) + + every { mockActivity.setResult(Activity.RESULT_OK, any()) } just runs + every { mockActivity.finish() } just runs + + completionManager.completeCredentialExport(exportResult) + + verify(ordering = Ordering.ORDERED) { + IntentHandler.setImportCredentialsException( + intent = any(), + exception = importException, + ) + mockActivity.setResult(Activity.RESULT_OK, any()) + mockActivity.finish() + } + } +}