[PM-32122] Add cookie acquisition navigation (#6529)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
Patrick Honkonen
2026-02-18 13:11:43 -05:00
committed by GitHub
parent 6f19ae534f
commit 1a6936262c
14 changed files with 272 additions and 18 deletions

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden
import android.content.Intent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.getCookieCallbackResultOrNull
import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResultOrNull
@@ -28,6 +29,7 @@ class AuthCallbackViewModel @Inject constructor(
val webAuthResult = action.intent.getWebAuthResultOrNull()
val duoCallbackTokenResult = action.intent.getDuoCallbackTokenResult()
val ssoCallbackResult = action.intent.getSsoCallbackResult()
val cookieCallbackResult = action.intent.getCookieCallbackResultOrNull()
when {
yubiKeyResult != null -> {
authRepository.setYubiKeyResult(yubiKeyResult = yubiKeyResult)
@@ -45,6 +47,12 @@ class AuthCallbackViewModel @Inject constructor(
)
}
cookieCallbackResult != null -> {
authRepository.setCookieCallbackResult(
result = cookieCallbackResult,
)
}
webAuthResult != null -> {
authRepository.setWebAuthResult(webAuthResult = webAuthResult)
}

View File

@@ -35,6 +35,8 @@ import com.x8bit.bitwarden.data.platform.manager.util.ObserveScreenDataEffect
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.components.util.rememberBitwardenNavController
import com.x8bit.bitwarden.ui.platform.composition.LocalManagerProvider
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.cookieAcquisitionDestination
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.navigateToCookieAcquisition
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.debugMenuDestination
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
@@ -125,14 +127,19 @@ class MainActivity : AppCompatActivity() {
modifier = Modifier
.background(color = BitwardenTheme.colorScheme.background.primary),
) {
// Both root navigation and debug menu exist at this top level.
// The debug menu can appear on top of the rest of the app without
// interacting with the state-based navigation used by RootNavScreen.
// Root navigation, debug menu, and cookie acquisition exist at
// this top level. They can appear on top of the rest of the app
// without interacting with the state-based navigation used by
// RootNavScreen.
rootNavDestination { shouldShowSplashScreen = false }
debugMenuDestination(
onNavigateBack = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
cookieAcquisitionDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
}
}
}
@@ -206,6 +213,8 @@ class MainActivity : AppCompatActivity() {
is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
MainEvent.Recreate -> handleRecreate()
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
MainEvent.NavigateToCookieAcquisition -> navController.navigateToCookieAcquisition()
is MainEvent.UpdateAppLocale -> {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(event.localeName),

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.cxf.model.ImportCredentialsRequestData
import com.bitwarden.cxf.util.getProviderImportCredentialsRequest
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.manager.share.ShareManager
@@ -29,6 +30,7 @@ import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
@@ -39,7 +41,6 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.util.isAddTotpLoginItemFromAuthenticator
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavViewModel
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
@@ -48,6 +49,7 @@ import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
import com.x8bit.bitwarden.ui.vault.util.getTotpDataOrNull
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
@@ -75,6 +77,7 @@ private const val ANIMATION_DEBOUNCE_DELAY_MS = 500L
class MainViewModel @Inject constructor(
accessibilitySelectionManager: AccessibilitySelectionManager,
autofillSelectionManager: AutofillSelectionManager,
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
@@ -162,6 +165,23 @@ class MainViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
combine(
authRepository.userStateFlow,
cookieAcquisitionRequestManager.cookieAcquisitionRequestFlow,
) { userState, request ->
userState != null &&
userState.activeAccount.isVaultUnlocked &&
request != null &&
request.hostname ==
userState.activeAccount.environment.environmentUrlData
.baseWebVaultUrlOrDefault
}
.distinctUntilChanged()
.filter { it }
.map { MainAction.Internal.CookieAcquisitionReady }
.onEach(::sendAction)
.launchIn(viewModelScope)
// On app launch, mark all active users as having previously logged in.
// This covers any users who are active prior to this value being recorded.
viewModelScope.launch {
@@ -207,6 +227,7 @@ class MainViewModel @Inject constructor(
is MainAction.Internal.ScreenCaptureUpdate -> handleScreenCaptureUpdate(action)
is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
is MainAction.Internal.DynamicColorsUpdate -> handleDynamicColorsUpdate(action)
is MainAction.Internal.CookieAcquisitionReady -> handleCookieAcquisitionReady()
}
}
@@ -271,6 +292,10 @@ class MainViewModel @Inject constructor(
mutableStateFlow.update { it.copy(isDynamicColorsEnabled = action.isDynamicColorsEnabled) }
}
private fun handleCookieAcquisitionReady() {
sendEvent(MainEvent.NavigateToCookieAcquisition)
}
private fun handleFirstIntentReceived(action: MainAction.ReceiveFirstIntent) {
handleIntent(
intent = action.intent,
@@ -589,6 +614,12 @@ sealed class MainAction {
data class DynamicColorsUpdate(
val isDynamicColorsEnabled: Boolean,
) : Internal()
/**
* Indicates that the cookie acquisition conditions are met and navigation
* should proceed.
*/
data object CookieAcquisitionReady : Internal()
}
}
@@ -618,6 +649,11 @@ sealed class MainEvent {
*/
data object NavigateToDebugMenu : MainEvent()
/**
* Navigate to the cookie acquisition screen.
*/
data object NavigateToCookieAcquisition : MainEvent()
/**
* Indicates that the app language has been updated.
*/

View File

@@ -4,7 +4,6 @@ package com.x8bit.bitwarden.ui.platform.feature.cookieacquisition
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
@@ -19,20 +18,23 @@ data object CookieAcquisitionRoute
/**
* Add the cookie acquisition screen to the nav graph.
*/
fun NavGraphBuilder.cookieAcquisitionDestination() {
fun NavGraphBuilder.cookieAcquisitionDestination(
onDismiss: () -> Unit,
onSplashScreenRemoved: () -> Unit,
) {
composableWithSlideTransitions<CookieAcquisitionRoute> {
CookieAcquisitionScreen()
CookieAcquisitionScreen(onDismiss = onDismiss)
// If we are displaying the cookie acquisition screen, then we can just hide
// the splash screen.
onSplashScreenRemoved()
}
}
/**
* Navigate to the cookie acquisition screen.
*/
fun NavController.navigateToCookieAcquisition(
navOptions: NavOptions? = null,
) {
this.navigate(
route = CookieAcquisitionRoute,
navOptions = navOptions,
)
fun NavController.navigateToCookieAcquisition() {
this.navigate(route = CookieAcquisitionRoute) {
launchSingleTop = true
}
}

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.platform.feature.cookieacquisition
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -51,6 +52,7 @@ import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.handlers.rememb
*/
@Composable
fun CookieAcquisitionScreen(
onDismiss: () -> Unit,
viewModel: CookieAcquisitionViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
) {
@@ -65,11 +67,20 @@ fun CookieAcquisitionScreen(
is CookieAcquisitionEvent.NavigateToHelp -> {
intentManager.launchUri(event.uri.toUri())
}
CookieAcquisitionEvent.NavigateBack -> onDismiss()
}
}
val handler = rememberCookieAcquisitionHandler(viewModel = viewModel)
// Route back through the ViewModel so the pending cookie request is cleared
// before dismissing. A normal back-pop would leave the request active and
// MainViewModel would immediately re-navigate to this screen.
BackHandler {
viewModel.trySendAction(CookieAcquisitionAction.ContinueWithoutSyncingClick)
}
CookieAcquisitionDialogs(
dialogState = state.dialogState,
onDismissRequest = handler.onDismissDialogClick,

View File

@@ -4,6 +4,7 @@ import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
@@ -84,6 +85,7 @@ class CookieAcquisitionViewModel @Inject constructor(
private fun handleContinueWithoutSyncingClick() {
cookieAcquisitionRequestManager.setPendingCookieAcquisition(data = null)
sendEvent(CookieAcquisitionEvent.NavigateBack)
}
private fun handleWhyAmISeeingThisClick() {
@@ -100,6 +102,7 @@ class CookieAcquisitionViewModel @Inject constructor(
when (action.result) {
is CookieCallbackResult.Success -> {
cookieAcquisitionRequestManager.setPendingCookieAcquisition(data = null)
sendEvent(CookieAcquisitionEvent.NavigateBack)
}
is CookieCallbackResult.MissingCookie -> {
@@ -152,6 +155,14 @@ sealed class CookieAcquisitionEvent {
* Navigate to the help page.
*/
data class NavigateToHelp(val uri: String) : CookieAcquisitionEvent()
/**
* Navigate back, dismissing the cookie acquisition screen.
*
* Implements [BackgroundEvent] because the cookie callback result may arrive while
* the screen is not resumed (e.g. returning from a Custom Tab browser session).
*/
data object NavigateBack : CookieAcquisitionEvent(), BackgroundEvent
}
/**

View File

@@ -125,6 +125,21 @@ fun DebugMenuScreen(
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.trigger_cookie_acquisition),
onClick = remember(viewModel) {
{
viewModel.trySendAction(
DebugMenuAction.TriggerCookieAcquisition,
)
}
},
isEnabled = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
Spacer(Modifier.height(height = 16.dp))

View File

@@ -2,11 +2,15 @@ package com.x8bit.bitwarden.ui.platform.feature.debugmenu
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.model.CookieAcquisitionRequest
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
@@ -23,12 +27,15 @@ import javax.inject.Inject
/**
* ViewModel for the [DebugMenuScreen]
*/
@Suppress("TooManyFunctions")
@HiltViewModel
class DebugMenuViewModel @Inject constructor(
featureFlagManager: FeatureFlagManager,
private val debugMenuRepository: DebugMenuRepository,
private val authRepository: AuthRepository,
private val logsManager: LogsManager,
private val cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
private val environmentRepository: EnvironmentRepository,
) : BaseViewModel<DebugMenuState, DebugMenuEvent, DebugMenuAction>(
initialState = DebugMenuState(featureFlags = persistentMapOf()),
) {
@@ -56,6 +63,7 @@ class DebugMenuViewModel @Inject constructor(
DebugMenuAction.ResetCoachMarkTourStatuses -> handleResetCoachMarkTourStatuses()
DebugMenuAction.GenerateCrashClick -> handleCrashClick()
DebugMenuAction.GenerateErrorReportClick -> handleErrorReportClick()
DebugMenuAction.TriggerCookieAcquisition -> handleTriggerCookieAcquisition()
}
}
@@ -92,6 +100,17 @@ class DebugMenuViewModel @Inject constructor(
}
}
private fun handleTriggerCookieAcquisition() {
cookieAcquisitionRequestManager.setPendingCookieAcquisition(
data = CookieAcquisitionRequest(
hostname = environmentRepository
.environment
.environmentUrlData
.baseWebVaultUrlOrDefault,
),
)
}
private fun handleNavigateBack() {
sendEvent(DebugMenuEvent.NavigateBack)
}
@@ -172,6 +191,11 @@ sealed class DebugMenuAction {
*/
data object GenerateErrorReportClick : DebugMenuAction()
/**
* The user has clicked trigger cookie acquisition button.
*/
data object TriggerCookieAcquisition : DebugMenuAction()
/**
* Internal actions not triggered from the UI.
*/

View File

@@ -3,9 +3,11 @@ package com.x8bit.bitwarden
import android.content.Intent
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.getCookieCallbackResultOrNull
import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResultOrNull
@@ -24,6 +26,7 @@ import org.junit.jupiter.api.Test
class AuthCallbackViewModelTest : BaseViewModelTest() {
private val authRepository = mockk<AuthRepository> {
every { setCookieCallbackResult(any()) } just runs
every { setSsoCallbackResult(any()) } just runs
every { setDuoCallbackTokenResult(any()) } just runs
every { setYubiKeyResult(any()) } just runs
@@ -33,6 +36,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
@BeforeEach
fun setUp() {
mockkStatic(
Intent::getCookieCallbackResultOrNull,
Intent::getYubiKeyResultOrNull,
Intent::getWebAuthResultOrNull,
Intent::getDuoCallbackTokenResult,
@@ -43,6 +47,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
@AfterEach
fun tearDown() {
unmockkStatic(
Intent::getCookieCallbackResultOrNull,
Intent::getYubiKeyResultOrNull,
Intent::getWebAuthResultOrNull,
Intent::getDuoCallbackTokenResult,
@@ -59,6 +64,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
every { mockIntent.getYubiKeyResultOrNull() } returns null
every { mockIntent.getWebAuthResultOrNull() } returns null
every { mockIntent.getSsoCallbackResult() } returns null
every { mockIntent.getCookieCallbackResultOrNull() } returns null
viewModel.trySendAction(AuthCallbackAction.IntentReceive(intent = mockIntent))
verify(exactly = 1) {
@@ -78,6 +84,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
every { mockIntent.getYubiKeyResultOrNull() } returns null
every { mockIntent.getWebAuthResultOrNull() } returns null
every { mockIntent.getDuoCallbackTokenResult() } returns null
every { mockIntent.getCookieCallbackResultOrNull() } returns null
viewModel.trySendAction(AuthCallbackAction.IntentReceive(intent = mockIntent))
verify(exactly = 1) {
@@ -94,6 +101,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
every { mockIntent.getWebAuthResultOrNull() } returns null
every { mockIntent.getDuoCallbackTokenResult() } returns null
every { mockIntent.getSsoCallbackResult() } returns null
every { mockIntent.getCookieCallbackResultOrNull() } returns null
viewModel.trySendAction(AuthCallbackAction.IntentReceive(intent = mockIntent))
verify(exactly = 1) {
@@ -110,6 +118,7 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
every { getYubiKeyResultOrNull() } returns null
every { getDuoCallbackTokenResult() } returns null
every { getSsoCallbackResult() } returns null
every { getCookieCallbackResultOrNull() } returns null
}
viewModel.trySendAction(AuthCallbackAction.IntentReceive(intent = mockIntent))
@@ -118,6 +127,25 @@ class AuthCallbackViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on IntentReceive with cookie callback should call setCookieCallbackResult`() {
val viewModel = createViewModel()
val mockIntent = mockk<Intent>()
val cookieCallbackResult = CookieCallbackResult.Success(
cookies = mapOf("cookie" to "value"),
)
every { mockIntent.getCookieCallbackResultOrNull() } returns cookieCallbackResult
every { mockIntent.getYubiKeyResultOrNull() } returns null
every { mockIntent.getWebAuthResultOrNull() } returns null
every { mockIntent.getDuoCallbackTokenResult() } returns null
every { mockIntent.getSsoCallbackResult() } returns null
viewModel.trySendAction(AuthCallbackAction.IntentReceive(intent = mockIntent))
verify(exactly = 1) {
authRepository.setCookieCallbackResult(result = cookieCallbackResult)
}
}
private fun createViewModel() = AuthCallbackViewModel(
authRepository = authRepository,
)

View File

@@ -52,11 +52,13 @@ import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionReques
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.CookieAcquisitionRequest
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
@@ -171,6 +173,11 @@ class MainViewModelTest : BaseViewModelTest() {
every { show(message = any(), duration = any()) } just runs
every { show(messageId = any(), duration = any()) } just runs
}
private val mutableCookieAcquisitionRequestFlow =
MutableStateFlow<CookieAcquisitionRequest?>(null)
private val cookieAcquisitionRequestManager: CookieAcquisitionRequestManager = mockk {
every { cookieAcquisitionRequestFlow } returns mutableCookieAcquisitionRequestFlow
}
private val credentialProviderRequestManager: CredentialProviderRequestManager = mockk {
every { getPendingCredentialRequest() } returns null
}
@@ -256,14 +263,14 @@ class MainViewModelTest : BaseViewModelTest() {
viewModel.eventFlow.test {
// We skip the first 2 events because they are the default appTheme and appLanguage
awaitItem()
awaitItem()
skipItems(2)
mutableUserStateFlow.value = UserState(
activeUserId = userId1,
accounts = listOf(
mockk<UserState.Account> {
every { userId } returns userId1
every { isVaultUnlocked } returns false
},
),
hasPendingAccountAddition = false,
@@ -275,6 +282,7 @@ class MainViewModelTest : BaseViewModelTest() {
accounts = listOf(
mockk<UserState.Account> {
every { userId } returns userId1
every { isVaultUnlocked } returns false
},
),
hasPendingAccountAddition = true,
@@ -286,9 +294,11 @@ class MainViewModelTest : BaseViewModelTest() {
accounts = listOf(
mockk<UserState.Account> {
every { userId } returns userId1
every { isVaultUnlocked } returns false
},
mockk<UserState.Account> {
every { userId } returns userId2
every { isVaultUnlocked } returns false
},
),
hasPendingAccountAddition = true,
@@ -1142,12 +1152,46 @@ class MainViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `cookie acquisition should emit NavigateToCookieAcquisition when vault unlocked with matching hostname`() =
runTest {
mutableCookieAcquisitionRequestFlow.value = CookieAcquisitionRequest(
hostname = DEFAULT_US_WEB_VAULT_URL,
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
// Skip init events (appLanguage + appTheme)
skipItems(2)
mutableUserStateFlow.value = DEFAULT_USER_STATE
assertEquals(
MainEvent.NavigateToCookieAcquisition,
awaitItem(),
)
}
}
@Test
fun `cookie acquisition should not emit event when conditions are false`() =
runTest {
mutableCookieAcquisitionRequestFlow.value = null
val viewModel = createViewModel()
viewModel.eventFlow.test {
// Skip init events (appLanguage + appTheme)
skipItems(2)
mutableUserStateFlow.value = DEFAULT_USER_STATE
expectNoEvents()
}
}
private fun createViewModel(
initialSpecialCircumstance: SpecialCircumstance? = null,
) = MainViewModel(
accessibilitySelectionManager = accessibilitySelectionManager,
addTotpItemFromAuthenticatorManager = addTotpItemAuthenticatorManager,
autofillSelectionManager = autofillSelectionManager,
cookieAcquisitionRequestManager = cookieAcquisitionRequestManager,
specialCircumstanceManager = specialCircumstanceManager,
garbageCollectionManager = garbageCollectionManager,
credentialProviderRequestManager = credentialProviderRequestManager,
@@ -1177,6 +1221,7 @@ private val DEFAULT_FIRST_TIME_STATE = FirstTimeState(
private const val SPECIAL_CIRCUMSTANCE_KEY: String = "special-circumstance"
private const val ACTIVE_USER_ID: String = "activeUserId"
private const val DEFAULT_US_WEB_VAULT_URL: String = "https://vault.bitwarden.com"
private val DEFAULT_ACCOUNT = UserState.Account(
userId = ACTIVE_USER_ID,
name = "Active User",

View File

@@ -21,11 +21,13 @@ import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class CookieAcquisitionScreenTest : BitwardenComposeTest() {
private var onDismissCalled = false
private val mutableEventFlow =
bufferedMutableSharedFlow<CookieAcquisitionEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@@ -44,6 +46,7 @@ class CookieAcquisitionScreenTest : BitwardenComposeTest() {
intentManager = intentManager,
) {
CookieAcquisitionScreen(
onDismiss = { onDismissCalled = true },
viewModel = viewModel,
)
}
@@ -148,6 +151,16 @@ class CookieAcquisitionScreenTest : BitwardenComposeTest() {
.assertExists()
}
@Test
fun `on system back should send ContinueWithoutSyncingClick action`() {
backDispatcher?.onBackPressed()
verify {
viewModel.trySendAction(
CookieAcquisitionAction.ContinueWithoutSyncingClick,
)
}
}
@Test
fun `error dialog dismiss should send DismissDialogClick action`() {
mutableStateFlow.update {
@@ -170,6 +183,12 @@ class CookieAcquisitionScreenTest : BitwardenComposeTest() {
viewModel.trySendAction(CookieAcquisitionAction.DismissDialogClick)
}
}
@Test
fun `NavigateBack event should call onDismiss`() {
mutableEventFlow.tryEmit(CookieAcquisitionEvent.NavigateBack)
assertTrue(onDismissCalled)
}
}
private const val DEFAULT_ENVIRONMENT_URL = "https://vault.bitwarden.com"

View File

@@ -88,6 +88,21 @@ class CookieAcquisitionViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `ContinueWithoutSyncingClick should emit NavigateBack event`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
CookieAcquisitionAction.ContinueWithoutSyncingClick,
)
assertEquals(
CookieAcquisitionEvent.NavigateBack,
awaitItem(),
)
}
}
@Test
fun `WhyAmISeeingThisClick should emit NavigateToHelp event`() = runTest {
val viewModel = createViewModel()
@@ -103,14 +118,17 @@ class CookieAcquisitionViewModelTest : BaseViewModelTest() {
}
@Test
fun `CookieCallbackResult Success should clear pending request`() =
fun `CookieCallbackResult Success should clear pending request and emit NavigateBack`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableCookieCallbackResultFlow.emit(
CookieCallbackResult.Success(cookies = mapOf("cookie" to "value")),
)
expectNoEvents()
assertEquals(
CookieAcquisitionEvent.NavigateBack,
awaitItem(),
)
}
verify {
mockCookieAcquisitionRequestManager.setPendingCookieAcquisition(data = null)

View File

@@ -5,9 +5,12 @@ import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.util.assertCoroutineThrows
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.model.CookieAcquisitionRequest
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@@ -48,6 +51,13 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
every { trackNonFatalException(throwable = any()) } just runs
}
private val mockCookieAcquisitionRequestManager =
mockk<CookieAcquisitionRequestManager> {
every { setPendingCookieAcquisition(data = any()) } just runs
}
private val fakeEnvironmentRepository = FakeEnvironmentRepository()
@Test
fun `initial state should be correct`() {
val viewModel = createViewModel()
@@ -135,11 +145,28 @@ class DebugMenuViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `TriggerCookieAcquisition should set pending cookie acquisition`() =
runTest {
val viewModel = createViewModel()
viewModel.trySendAction(DebugMenuAction.TriggerCookieAcquisition)
verify(exactly = 1) {
mockCookieAcquisitionRequestManager.setPendingCookieAcquisition(
data = CookieAcquisitionRequest(
hostname = "https://vault.bitwarden.com",
),
)
}
viewModel.eventFlow.test { expectNoEvents() }
}
private fun createViewModel(): DebugMenuViewModel = DebugMenuViewModel(
featureFlagManager = mockFeatureFlagManager,
debugMenuRepository = mockDebugMenuRepository,
authRepository = mockAuthRepository,
logsManager = logsManager,
cookieAcquisitionRequestManager = mockCookieAcquisitionRequestManager,
environmentRepository = fakeEnvironmentRepository,
)
}

View File

@@ -41,6 +41,7 @@
<string name="migrate_my_vault_to_my_items">Migrate My Vault to My Items</string>
<string name="archive_items">Archive Items</string>
<string name="send_email_verification">Send Email Verification</string>
<string name="trigger_cookie_acquisition">Trigger cookie acquisition</string>
<!-- endregion Debug Menu -->
</resources>