[PM-26315] Register/unregister for CXP export based on feature flag (#5948)

This commit is contained in:
Patrick Honkonen
2025-10-08 14:00:50 -04:00
committed by GitHub
parent bebf94796c
commit 3a4f1d719f
21 changed files with 460 additions and 110 deletions

View File

@@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.auth.manager.model.LogoutEvent
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
@@ -34,10 +35,12 @@ class UserLogoutManagerImpl(
private val toastManager: ToastManager,
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val credentialExchangeRegistryManager: CredentialExchangeRegistryManager,
dispatcherManager: DispatcherManager,
) : UserLogoutManager {
private val scope = CoroutineScope(dispatcherManager.unconfined)
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val mainScope = CoroutineScope(dispatcherManager.main)
private val ioScope = CoroutineScope(dispatcherManager.io)
private val mutableLogoutEventFlow: MutableSharedFlow<LogoutEvent> =
bufferedMutableSharedFlow()
@@ -58,8 +61,10 @@ class UserLogoutManagerImpl(
)
if (!ableToSwitchToNewAccount) {
// Update the user information and log out
// Update the user information and log out.
authDiskSource.userState = null
// Unregister the application from CXP Export since there are no other accounts.
ioScope.launch { credentialExchangeRegistryManager.unregister() }
}
clearData(userId = userId)
@@ -114,7 +119,7 @@ class UserLogoutManagerImpl(
generatorDiskSource.clearData(userId = userId)
pushDiskSource.clearData(userId = userId)
settingsDiskSource.clearData(userId = userId)
scope.launch {
unconfinedScope.launch {
passwordHistoryDiskSource.clearPasswordHistories(userId = userId)
vaultDiskSource.deleteVaultData(userId = userId)
}

View File

@@ -25,6 +25,7 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManagerImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource
@@ -117,6 +118,7 @@ object AuthManagerModule {
vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource,
dispatcherManager: DispatcherManager,
credentialExchangeRegistryManager: CredentialExchangeRegistryManager,
): UserLogoutManager =
UserLogoutManagerImpl(
authDiskSource = authDiskSource,
@@ -128,6 +130,7 @@ object AuthManagerModule {
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
dispatcherManager = dispatcherManager,
credentialExchangeRegistryManager = credentialExchangeRegistryManager,
)
@Provides

View File

@@ -354,21 +354,21 @@ interface SettingsDiskSource {
fun getShowImportLoginsSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether or not the given [userId] has registered for export via the credential exchange
* Gets whether or not the application has registered for export via the credential exchange
* protocol.
*/
fun getVaultRegisteredForExport(userId: String): Boolean?
fun getAppRegisteredForExport(): Boolean?
/**
* Stores the given value for whether or not the given [userId] has registered for export via
* Stores the given value for whether or not the application has registered for export via
* the credential exchange protocol.
*/
fun storeVaultRegisteredForExport(userId: String, isRegistered: Boolean?)
fun storeAppRegisteredForExport(isRegistered: Boolean?)
/**
* Emits updates that track [getVaultRegisteredForExport] for the given [userId].
* Emits updates that track [getAppRegisteredForExport].
*/
fun getVaultRegisteredForExportFlow(userId: String): Flow<Boolean?>
fun getAppRegisteredForExportFlow(userId: String): Flow<Boolean?>
/**
* Gets the number of qualifying add cipher actions for the device.

View File

@@ -100,8 +100,7 @@ class SettingsDiskSourceImpl(
private val mutableScreenCaptureAllowedFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableVaultRegisteredForExportFlow =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableVaultRegisteredForExportFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableIsDynamicColorsEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
@@ -247,7 +246,6 @@ class SettingsDiskSourceImpl(
storeLastSyncTime(userId = userId, lastSyncTime = null)
storeClearClipboardFrequencySeconds(userId = userId, frequency = null)
removeWithPrefix(prefix = ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY.appendIdentifier(userId))
storeVaultRegisteredForExport(userId = userId, isRegistered = null)
storeAppResumeScreen(userId = userId, screenData = null)
// The following are intentionally not cleared so they can be
@@ -509,17 +507,17 @@ class SettingsDiskSourceImpl(
getMutableShowImportLoginsSettingBadgeFlow(userId)
.onSubscription { emit(getShowImportLoginsSettingBadge(userId)) }
override fun getVaultRegisteredForExport(userId: String): Boolean? =
getBoolean(IS_VAULT_REGISTERED_FOR_EXPORT.appendIdentifier(userId))
override fun getAppRegisteredForExport(): Boolean? =
getBoolean(IS_VAULT_REGISTERED_FOR_EXPORT)
override fun storeVaultRegisteredForExport(userId: String, isRegistered: Boolean?) {
putBoolean(IS_VAULT_REGISTERED_FOR_EXPORT.appendIdentifier(userId), isRegistered)
getMutableVaultRegisteredForExportFlow(userId).tryEmit(isRegistered)
override fun storeAppRegisteredForExport(isRegistered: Boolean?) {
putBoolean(IS_VAULT_REGISTERED_FOR_EXPORT, isRegistered)
mutableVaultRegisteredForExportFlow.tryEmit(isRegistered)
}
override fun getVaultRegisteredForExportFlow(userId: String): Flow<Boolean?> =
getMutableVaultRegisteredForExportFlow(userId)
.onSubscription { emit(getVaultRegisteredForExport(userId)) }
override fun getAppRegisteredForExportFlow(userId: String): Flow<Boolean?> =
mutableVaultRegisteredForExportFlow
.onSubscription { emit(getAppRegisteredForExport()) }
override fun getAddCipherActionCount(): Int? = getInt(
key = ADD_ACTION_COUNT,
@@ -649,12 +647,6 @@ class SettingsDiskSourceImpl(
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableVaultRegisteredForExportFlow(
userId: String,
): MutableSharedFlow<Boolean?> = mutableVaultRegisteredForExportFlow.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
/**
* Migrates the user-scoped screen capture state to an app-wide state.
*

View File

@@ -0,0 +1,20 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.manager.model.RegisterExportResult
import com.x8bit.bitwarden.data.platform.manager.model.UnregisterExportResult
/**
* Manager for registering for Credential Exchange Protocol export.
*/
interface CredentialExchangeRegistryManager {
/**
* Registers the application for Credential Exchange Protocol export.
*/
suspend fun register(): RegisterExportResult
/**
* Unregisters the application for Credential Exchange Protocol export.
*/
suspend fun unregister(): UnregisterExportResult
}

View File

@@ -0,0 +1,60 @@
package com.x8bit.bitwarden.data.platform.manager
import androidx.credentials.providerevents.transfer.CredentialTypes
import com.bitwarden.cxf.registry.CredentialExchangeRegistry
import com.bitwarden.cxf.registry.model.RegistrationRequest
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.model.RegisterExportResult
import com.x8bit.bitwarden.data.platform.manager.model.UnregisterExportResult
import timber.log.Timber
/**
* Default implementation of [CredentialExchangeRegistryManager].
*/
class CredentialExchangeRegistryManagerImpl(
private val credentialExchangeRegistry: CredentialExchangeRegistry,
private val settingsDiskSource: SettingsDiskSource,
) : CredentialExchangeRegistryManager {
override suspend fun register(): RegisterExportResult = credentialExchangeRegistry
.register(
registrationRequest = RegistrationRequest(
appNameRes = R.string.app_name,
credentialTypes = setOf(
CredentialTypes.CREDENTIAL_TYPE_BASIC_AUTH,
CredentialTypes.CREDENTIAL_TYPE_PUBLIC_KEY,
CredentialTypes.CREDENTIAL_TYPE_TOTP,
CredentialTypes.CREDENTIAL_TYPE_CREDIT_CARD,
CredentialTypes.CREDENTIAL_TYPE_SSH_KEY,
CredentialTypes.CREDENTIAL_TYPE_ADDRESS,
),
iconResId = BitwardenDrawable.icon,
),
)
.fold(
onSuccess = {
Timber.d("Successfully registered for CXP export")
settingsDiskSource.storeAppRegisteredForExport(isRegistered = true)
RegisterExportResult.Success
},
onFailure = {
Timber.e(it, "Failed to register for CXP export")
RegisterExportResult.Failure(it)
},
)
override suspend fun unregister(): UnregisterExportResult = credentialExchangeRegistry
.unregister()
.fold(
onSuccess = {
Timber.d("Successfully unregistered for CXP export")
settingsDiskSource.storeAppRegisteredForExport(isRegistered = false)
UnregisterExportResult.Success
},
onFailure = {
Timber.e(it, "Failed to unregister for CXP export")
UnregisterExportResult.Failure(it)
},
)
}

View File

@@ -7,6 +7,8 @@ import com.bitwarden.core.data.manager.realtime.RealtimeManager
import com.bitwarden.core.data.manager.realtime.RealtimeManagerImpl
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.core.data.manager.toast.ToastManagerImpl
import com.bitwarden.cxf.registry.CredentialExchangeRegistry
import com.bitwarden.cxf.registry.dsl.credentialExchangeRegistry
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.data.manager.DispatcherManagerImpl
import com.bitwarden.data.manager.NativeLibraryManager
@@ -34,6 +36,8 @@ import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.CertificateManager
import com.x8bit.bitwarden.data.platform.manager.CertificateManagerImpl
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManagerImpl
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManagerImpl
import com.x8bit.bitwarden.data.platform.manager.DebugMenuFeatureFlagManagerImpl
@@ -422,4 +426,22 @@ object PlatformManagerModule {
clock = clock,
)
}
@Provides
@Singleton
fun provideCredentialExchangeRegistry(
application: Application,
): CredentialExchangeRegistry = credentialExchangeRegistry(
application = application,
)
@Provides
@Singleton
fun provideCredentialExchangeRegistryManager(
credentialExchangeRegistry: CredentialExchangeRegistry,
settingsDiskSource: SettingsDiskSource,
): CredentialExchangeRegistryManager = CredentialExchangeRegistryManagerImpl(
credentialExchangeRegistry = credentialExchangeRegistry,
settingsDiskSource = settingsDiskSource,
)
}

View File

@@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.platform.manager.model
/**
* Represents the result of registering for export.
*/
sealed class RegisterExportResult {
/**
* Registration was successful.
*/
data object Success : RegisterExportResult()
/**
* Registration failed.
*/
data class Failure(val throwable: Throwable?) : RegisterExportResult()
}

View File

@@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.platform.manager.model
/**
* Represents the result of unregistering for Credential Exchange Protocol export.
*/
sealed class UnregisterExportResult {
/**
* Represents a successful unregistering for Credential Exchange Protocol export.
*/
data object Success : UnregisterExportResult()
/**
* Represents a failure to unregister for Credential Exchange Protocol export.
*/
data class Failure(val throwable: Throwable?) : UnregisterExportResult()
}

View File

@@ -278,19 +278,19 @@ interface SettingsRepository : FlightRecorderManager {
fun storeUserHasLoggedInValue(userId: String)
/**
* Returns true if the given [userId] has previously registered for export via the credential
* Returns true if the application has previously registered for export via the credential
* exchange protocol.
*/
fun isVaultRegisteredForExport(userId: String): Boolean
fun isAppRegisteredForExport(): Boolean
/**
* Stores that the given [userId] has previously registered for export via the credential
* Stores that the application has previously registered for export via the credential
* exchange protocol.
*/
fun storeVaultRegisteredForExport(userId: String, isRegistered: Boolean)
fun storeAppRegisteredForExport(isRegistered: Boolean)
/**
* Gets updates for the [isVaultRegisteredForExport] value for the given [userId].
* Gets updates for the [isAppRegisteredForExport] value for the given [userId].
*/
fun getVaultRegisteredForExportFlow(userId: String): StateFlow<Boolean>
fun getAppRegisteredForExportFlow(userId: String): StateFlow<Boolean>
}

View File

@@ -575,23 +575,23 @@ class SettingsRepositoryImpl(
settingsDiskSource.storeUseHasLoggedInPreviously(userId)
}
override fun isVaultRegisteredForExport(userId: String): Boolean {
return settingsDiskSource.getVaultRegisteredForExport(userId) == true
override fun isAppRegisteredForExport(): Boolean {
return settingsDiskSource.getAppRegisteredForExport() == true
}
override fun storeVaultRegisteredForExport(userId: String, isRegistered: Boolean) {
settingsDiskSource.storeVaultRegisteredForExport(userId, isRegistered)
override fun storeAppRegisteredForExport(isRegistered: Boolean) {
settingsDiskSource.storeAppRegisteredForExport(isRegistered)
}
override fun getVaultRegisteredForExportFlow(userId: String): StateFlow<Boolean> {
override fun getAppRegisteredForExportFlow(userId: String): StateFlow<Boolean> {
return settingsDiskSource
.getVaultRegisteredForExportFlow(userId)
.getAppRegisteredForExportFlow(userId)
.map { it ?: false }
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = settingsDiskSource
.getVaultRegisteredForExport(userId)
.getAppRegisteredForExport()
?: false,
)
}

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.vault.feature.vault
import android.os.Parcelable
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.util.persistentListOfNotNull
import com.bitwarden.data.repository.util.baseIconUrl
@@ -28,6 +29,8 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManager
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
@@ -100,6 +103,8 @@ class VaultViewModel @Inject constructor(
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val networkConnectionManager: NetworkConnectionManager,
private val browserAutofillDialogManager: BrowserAutofillDialogManager,
private val credentialExchangeRegistryManager: CredentialExchangeRegistryManager,
featureFlagManager: FeatureFlagManager,
snackbarRelayManager: SnackbarRelayManager,
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
initialState = run {
@@ -210,6 +215,11 @@ class VaultViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
featureFlagManager.getFeatureFlagFlow(FlagKey.CredentialExchangeProtocolExport)
.map { VaultAction.Internal.CredentialExchangeProtocolExportFlagUpdateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
viewModelScope.launch {
delay(timeMillis = BROWSER_AUTOFILL_DIALOG_DELAY)
mutableStateFlow.update { vaultState ->
@@ -785,6 +795,22 @@ class VaultViewModel @Inject constructor(
is VaultAction.Internal.DecryptionErrorReceive -> {
handleDecryptionErrorReceive(action)
}
is VaultAction.Internal.CredentialExchangeProtocolExportFlagUpdateReceive -> {
handleCredentialExchangeProtocolExportFlagUpdateReceive(action)
}
}
}
private fun handleCredentialExchangeProtocolExportFlagUpdateReceive(
action: VaultAction.Internal.CredentialExchangeProtocolExportFlagUpdateReceive,
) {
viewModelScope.launch {
if (action.isCredentialExchangeProtocolExportEnabled) {
credentialExchangeRegistryManager.register()
} else {
credentialExchangeRegistryManager.unregister()
}
}
}
@@ -1916,6 +1942,13 @@ sealed class VaultAction {
val message: Text,
val error: Throwable?,
) : Internal()
/**
* Indicates that the Credential Exchange Protocol export flag has been updated.
*/
data class CredentialExchangeProtocolExportFlagUpdateReceive(
val isCredentialExchangeProtocolExportEnabled: Boolean,
) : Internal()
}
}

View File

@@ -12,6 +12,8 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.platform.manager.model.UnregisterExportResult
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource
@@ -43,6 +45,7 @@ class UserLogoutManagerTest {
every { clearData(any()) } just runs
every { storeVaultTimeoutInMinutes(any(), any()) } just runs
every { storeVaultTimeoutAction(any(), any()) } just runs
every { storeAppRegisteredForExport(any()) } just runs
}
private val pushDiskSource: PushDiskSource = mockk {
coEvery { clearData(any()) } just runs
@@ -59,6 +62,9 @@ class UserLogoutManagerTest {
private val toastManager: ToastManager = mockk {
every { show(messageId = any()) } just runs
}
private val credentialExchangeRegistryManager: CredentialExchangeRegistryManager = mockk {
coEvery { unregister() } returns UnregisterExportResult.Success
}
private val userLogoutManager: UserLogoutManager =
UserLogoutManagerImpl(
@@ -71,17 +77,21 @@ class UserLogoutManagerTest {
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
dispatcherManager = FakeDispatcherManager(),
credentialExchangeRegistryManager = credentialExchangeRegistryManager,
)
@Suppress("MaxLineLength")
@Test
fun `logout for single account should clear data associated with the given user and null out the user state`() {
fun `logout for single account should clear data associated with the given user, null out the user state, and unregister app for export`() {
val userId = USER_ID_1
every { authDiskSource.userState } returns SINGLE_USER_STATE_1
userLogoutManager.logout(userId = USER_ID_1, reason = LogoutReason.Timeout)
verify { authDiskSource.userState = null }
coVerify {
authDiskSource.userState = null
credentialExchangeRegistryManager.unregister()
}
assertDataCleared(userId = userId)
}

View File

@@ -1342,35 +1342,31 @@ class SettingsDiskSourceTest {
}
@Test
fun `getVaultRegisteredForExport should pull from SharedPreferences`() {
val mockUserId = "mockUserId"
val vaultRegisteredForExportKey =
"bwPreferencesStorage:isVaultRegisteredForExport_$mockUserId"
fun `getAppRegisteredForExport should pull from SharedPreferences`() {
val vaultRegisteredForExportKey = "bwPreferencesStorage:isVaultRegisteredForExport"
fakeSharedPreferences.edit {
putBoolean(vaultRegisteredForExportKey, true)
}
assertTrue(settingsDiskSource.getVaultRegisteredForExport(userId = mockUserId)!!)
assertTrue(settingsDiskSource.getAppRegisteredForExport()!!)
}
@Test
fun `storeVaultRegisteredForExport should update SharedPreferences`() {
val mockUserId = "mockUserId"
val vaultRegisteredForExportKey =
"bwPreferencesStorage:isVaultRegisteredForExport_$mockUserId"
settingsDiskSource.storeVaultRegisteredForExport(mockUserId, true)
fun `storeAppRegisteredForExport should update SharedPreferences`() {
val vaultRegisteredForExportKey = "bwPreferencesStorage:isVaultRegisteredForExport"
settingsDiskSource.storeAppRegisteredForExport(true)
assertTrue(fakeSharedPreferences.getBoolean(vaultRegisteredForExportKey, false))
}
@Test
fun `storeVaultRegisteredForExport should update the flow value`() = runTest {
fun `storeAppRegisteredForExport should update the flow value`() = runTest {
val mockUserId = "mockUserId"
settingsDiskSource.getVaultRegisteredForExportFlow(mockUserId).test {
settingsDiskSource.getAppRegisteredForExportFlow(mockUserId).test {
// The initial values of the Flow are in sync
assertFalse(awaitItem() ?: false)
settingsDiskSource.storeVaultRegisteredForExport(mockUserId, true)
settingsDiskSource.storeAppRegisteredForExport(true)
assertTrue(awaitItem() ?: false)
// Update the value to false
settingsDiskSource.storeVaultRegisteredForExport(mockUserId, false)
settingsDiskSource.storeAppRegisteredForExport(false)
assertFalse(awaitItem() ?: true)
}
}

View File

@@ -80,7 +80,7 @@ class FakeSettingsDiskSource : SettingsDiskSource {
private val userShowBrowserAutofillBadge = mutableMapOf<String, Boolean?>()
private val userShowUnlockBadge = mutableMapOf<String, Boolean?>()
private val userShowImportLoginsBadge = mutableMapOf<String, Boolean?>()
private val vaultRegisteredForExport = mutableMapOf<String, Boolean?>()
private var vaultRegisteredForExport: Boolean? = null
private var addCipherActionCount: Int? = null
private var generatedActionCount: Int? = null
private var createSendActionCount: Int? = null
@@ -102,12 +102,12 @@ class FakeSettingsDiskSource : SettingsDiskSource {
private val mutableShowImportLoginsSettingBadgeFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableVaultRegisteredForExportFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableIsDynamicColorsEnabled =
bufferedMutableSharedFlow<Boolean?>()
private val mutableVaultRegisteredForExportFlow =
bufferedMutableSharedFlow<Boolean?>()
override var appLanguage: AppLanguage?
get() = storedAppLanguage
set(value) {
@@ -244,7 +244,6 @@ class FakeSettingsDiskSource : SettingsDiskSource {
mutableVaultTimeoutActionsFlowMap.remove(userId)
mutableVaultTimeoutInMinutesFlowMap.remove(userId)
mutableLastSyncCallFlowMap.remove(userId)
mutableVaultRegisteredForExportFlowMap.remove(userId)
}
override fun getLastSyncTime(userId: String): Instant? = storedLastSyncTime[userId]
@@ -415,17 +414,17 @@ class FakeSettingsDiskSource : SettingsDiskSource {
emit(getShowImportLoginsSettingBadge(userId = userId))
}
override fun getVaultRegisteredForExport(userId: String): Boolean =
vaultRegisteredForExport[userId] ?: false
override fun getAppRegisteredForExport(): Boolean =
vaultRegisteredForExport ?: false
override fun storeVaultRegisteredForExport(userId: String, isRegistered: Boolean?) {
vaultRegisteredForExport[userId] = isRegistered
getMutableVaultRegisteredForExportFlow(userId = userId).tryEmit(isRegistered)
override fun storeAppRegisteredForExport(isRegistered: Boolean?) {
vaultRegisteredForExport = isRegistered
mutableVaultRegisteredForExportFlow.tryEmit(isRegistered)
}
override fun getVaultRegisteredForExportFlow(userId: String): Flow<Boolean?> =
getMutableVaultRegisteredForExportFlow(userId = userId).onSubscription {
emit(getVaultRegisteredForExport(userId = userId))
override fun getAppRegisteredForExportFlow(userId: String): Flow<Boolean?> =
mutableVaultRegisteredForExportFlow.onSubscription {
emit(getAppRegisteredForExport())
}
override fun getAddCipherActionCount(): Int? {
@@ -560,13 +559,5 @@ class FakeSettingsDiskSource : SettingsDiskSource {
): MutableSharedFlow<Boolean?> = mutableShowImportLoginsSettingBadgeFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableVaultRegisteredForExportFlow(
userId: String,
): MutableSharedFlow<Boolean?> =
mutableVaultRegisteredForExportFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
//endregion Private helper functions
}

View File

@@ -0,0 +1,113 @@
package com.x8bit.bitwarden.data.platform.manager
import androidx.credentials.providerevents.exception.RegisterExportUnknownErrorException
import androidx.credentials.providerevents.transfer.CredentialTypes
import androidx.credentials.providerevents.transfer.RegisterExportResponse
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.cxf.registry.CredentialExchangeRegistry
import com.bitwarden.cxf.registry.model.RegistrationRequest
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.model.RegisterExportResult
import com.x8bit.bitwarden.data.platform.manager.model.UnregisterExportResult
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class CredentialExchangeRegistryManagerImplTest {
private val credentialExchangeRegistry: CredentialExchangeRegistry = mockk {
coEvery { register(any()) } returns RegisterExportResponse().asSuccess()
coEvery { unregister() } returns RegisterExportResponse().asSuccess()
}
private val settingsDiskSource: SettingsDiskSource = mockk {
every { getAppRegisteredForExport() } returns false
every { storeAppRegisteredForExport(any()) } just runs
}
private val registryManager: CredentialExchangeRegistryManager =
CredentialExchangeRegistryManagerImpl(
credentialExchangeRegistry = credentialExchangeRegistry,
settingsDiskSource = settingsDiskSource,
)
@Suppress("MaxLineLength")
@Test
fun `register should store app registered for export and return Success when registration is successful`() =
runTest {
val result = registryManager.register()
coVerify {
credentialExchangeRegistry.register(
registrationRequest = RegistrationRequest(
appNameRes = R.string.app_name,
credentialTypes = setOf(
CredentialTypes.CREDENTIAL_TYPE_BASIC_AUTH,
CredentialTypes.CREDENTIAL_TYPE_PUBLIC_KEY,
CredentialTypes.CREDENTIAL_TYPE_TOTP,
CredentialTypes.CREDENTIAL_TYPE_CREDIT_CARD,
CredentialTypes.CREDENTIAL_TYPE_SSH_KEY,
CredentialTypes.CREDENTIAL_TYPE_ADDRESS,
),
iconResId = BitwardenDrawable.icon,
),
)
settingsDiskSource.storeAppRegisteredForExport(true)
}
assertEquals(RegisterExportResult.Success, result)
}
@Test
fun `register should return Failure when registration fails`() =
runTest {
coEvery {
credentialExchangeRegistry.register(any())
} returns RegisterExportUnknownErrorException().asFailure()
val result = registryManager.register()
verify(exactly = 0) {
settingsDiskSource.storeAppRegisteredForExport(any())
}
assertTrue(result is RegisterExportResult.Failure)
}
@Suppress("MaxLineLength")
@Test
fun `unregister should store app registered for export and return Success when unregistration is successful`() =
runTest {
val result = registryManager.unregister()
coVerify {
credentialExchangeRegistry.unregister()
settingsDiskSource.storeAppRegisteredForExport(false)
}
assertEquals(UnregisterExportResult.Success, result)
}
@Test
fun `unregister should return Failure when unregistration fails`() = runTest {
coEvery {
credentialExchangeRegistry.unregister()
} returns RegisterExportUnknownErrorException().asFailure()
val result = registryManager.unregister()
verify(exactly = 0) {
settingsDiskSource.storeAppRegisteredForExport(any())
}
assertTrue(result is UnregisterExportResult.Failure)
}
}

View File

@@ -1236,39 +1236,35 @@ class SettingsRepositoryTest {
}
@Test
fun `isVaultRegisteredForExport should return false if no value exists`() {
assertFalse(settingsRepository.isVaultRegisteredForExport(userId = "userId"))
fun `isAppRegisteredForExport should return false if no value exists`() {
assertFalse(settingsRepository.isAppRegisteredForExport())
}
@Test
fun `isVaultRegisteredForExport should return true if it exists`() {
val userId = "userId"
fakeSettingsDiskSource.storeVaultRegisteredForExport(userId = userId, isRegistered = true)
assertTrue(settingsRepository.isVaultRegisteredForExport(userId = userId))
fun `isAppRegisteredForExport should return true if it exists`() {
fakeSettingsDiskSource.storeAppRegisteredForExport(isRegistered = true)
assertTrue(settingsRepository.isAppRegisteredForExport())
}
@Test
fun `storeVaultRegisteredForExport should store value of true to disk`() {
val userId = "userId"
settingsRepository.storeVaultRegisteredForExport(userId = userId, isRegistered = true)
assertTrue(fakeSettingsDiskSource.getVaultRegisteredForExport(userId = userId))
fun `storeAppRegisteredForExport should store value of true to disk`() {
settingsRepository.storeAppRegisteredForExport(isRegistered = true)
assertTrue(fakeSettingsDiskSource.getAppRegisteredForExport())
}
@Test
fun `getVaultRegisteredForExportFlow should react to changes in SettingsDiskSource`() =
fun `getAppRegisteredForExportFlow should react to changes in SettingsDiskSource`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
settingsRepository
.getVaultRegisteredForExportFlow(userId = USER_ID)
.getAppRegisteredForExportFlow(userId = USER_ID)
.test {
assertFalse(awaitItem())
fakeSettingsDiskSource.storeVaultRegisteredForExport(
userId = USER_ID,
fakeSettingsDiskSource.storeAppRegisteredForExport(
isRegistered = true,
)
assertTrue(awaitItem())
fakeSettingsDiskSource.storeVaultRegisteredForExport(
userId = USER_ID,
fakeSettingsDiskSource.storeAppRegisteredForExport(
isRegistered = false,
)
assertFalse(awaitItem())

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import app.cash.turbine.test
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.repository.model.Environment
@@ -25,6 +26,8 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManager
import com.x8bit.bitwarden.data.platform.datasource.disk.model.FlightRecorderDataSet
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
@@ -33,7 +36,9 @@ import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardMan
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.manager.model.RegisterExportResult
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.manager.model.UnregisterExportResult
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCardListView
@@ -61,7 +66,9 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.util.toSnackbarData
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toViewState
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
import io.mockk.awaits
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@@ -187,6 +194,17 @@ class VaultViewModelTest : BaseViewModelTest() {
every { delayDialog() } just runs
}
private val credentialExchangeRegistryManager: CredentialExchangeRegistryManager = mockk {
coEvery { register() } returns RegisterExportResult.Success
coEvery { unregister() } returns UnregisterExportResult.Success
}
private val mutableCxpExportFeatureFlagFlow = MutableStateFlow(false)
private val featureFlagManager: FeatureFlagManager = mockk {
every {
getFeatureFlagFlow(FlagKey.CredentialExchangeProtocolExport)
} returns mutableCxpExportFeatureFlagFlow
}
@AfterEach
fun tearDown() {
unmockkStatic(FlightRecorderDataSet::toSnackbarData)
@@ -2842,6 +2860,47 @@ class VaultViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `CredentialExchangeProtocolExportFlagUpdateReceive should register for export when flag is enabled`() =
runTest {
mutableCxpExportFeatureFlagFlow.value = false
coEvery { credentialExchangeRegistryManager.register() } just awaits
val viewModel = createViewModel()
viewModel.trySendAction(
VaultAction.Internal.CredentialExchangeProtocolExportFlagUpdateReceive(
isCredentialExchangeProtocolExportEnabled = true,
),
)
coVerify {
credentialExchangeRegistryManager.register()
}
}
@Suppress("MaxLineLength")
@Test
fun `CredentialExchangeProtocolExportFlagUpdateReceive should unregister when flag is disabled`() =
runTest {
mutableCxpExportFeatureFlagFlow.value = true
every { settingsRepository.isAppRegisteredForExport() } returns true
coEvery { credentialExchangeRegistryManager.unregister() } just awaits
val viewModel = createViewModel()
viewModel.trySendAction(
VaultAction.Internal.CredentialExchangeProtocolExportFlagUpdateReceive(
isCredentialExchangeProtocolExportEnabled = false,
),
)
coVerify {
credentialExchangeRegistryManager.unregister()
}
}
private fun createViewModel(): VaultViewModel =
VaultViewModel(
authRepository = authRepository,
@@ -2857,6 +2916,8 @@ class VaultViewModelTest : BaseViewModelTest() {
specialCircumstanceManager = specialCircumstanceManager,
networkConnectionManager = networkConnectionManager,
browserAutofillDialogManager = browserAutofillDialogManager,
credentialExchangeRegistryManager = credentialExchangeRegistryManager,
featureFlagManager = featureFlagManager,
)
}

View File

@@ -29,5 +29,5 @@ interface CredentialExchangeRegistry {
*
* @return True if the unregistration was successful, false otherwise.
*/
suspend fun unregister(): RegisterExportResponse
suspend fun unregister(): Result<RegisterExportResponse>
}

View File

@@ -4,6 +4,7 @@ import android.app.Application
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmapOrNull
import androidx.credentials.providerevents.ProviderEventsManager
import androidx.credentials.providerevents.exception.RegisterExportException
import androidx.credentials.providerevents.transfer.ExportEntry
import androidx.credentials.providerevents.transfer.RegisterExportRequest
import androidx.credentials.providerevents.transfer.RegisterExportResponse
@@ -11,6 +12,7 @@ import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.cxf.registry.model.RegistrationRequest
import timber.log.Timber
import java.util.UUID
/**
@@ -40,23 +42,34 @@ internal class CredentialExchangeRegistryImpl(
ExportEntry(
id = UUID.randomUUID().toString(),
accountDisplayName = null,
userDisplayName = registrationRequest.appName,
userDisplayName = application.getString(registrationRequest.appNameRes),
icon = icon,
supportedCredentialTypes = registrationRequest.credentialTypes,
),
),
)
return providerEventsManager
.registerExport(request = request)
.asSuccess()
return try {
providerEventsManager
.registerExport(request = request)
.asSuccess()
} catch (e: RegisterExportException) {
Timber.e(e, "Failed to register application for export.")
e.asFailure()
}
}
override suspend fun unregister(): RegisterExportResponse =
providerEventsManager.registerExport(
// This is a workaround for unregistering an account since an explicit "unregister" API
// is not currently available.
request = RegisterExportRequest(
entries = emptyList(),
),
)
override suspend fun unregister(): Result<RegisterExportResponse> =
try {
providerEventsManager.registerExport(
// This is a workaround for unregistering an account since an explicit "unregister"
// API is not currently available.
request = RegisterExportRequest(
entries = emptyList(),
),
)
.asSuccess()
} catch (e: RegisterExportException) {
Timber.e(e, "Failed to unregister application for export.")
e.asFailure()
}
}

View File

@@ -1,18 +1,20 @@
package com.bitwarden.cxf.registry.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
/**
* Represents a request to register as a credential provider that allows exporting credentials.
*
* @property appName The name of the application as it will be displayed to the user.
* @property appNameRes The name of the application as it will be displayed to the user.
* @property credentialTypes The types of credentials that can be exported.
* @property iconResId Resource ID of a 36x36 pixel drawable to be displayed alongside the [appName]
* when credential import is requested.
* @property iconResId Resource ID of a 36x36 pixel drawable to be displayed alongside the
* [appNameRes] when credential import is requested.
*/
data class RegistrationRequest(
val appName: String,
val credentialTypes: Set<String>,
@field:StringRes
val appNameRes: Int,
@field:DrawableRes
val iconResId: Int,
val credentialTypes: Set<String>,
)