[PM-32121] Add CookieAcquisition screen and ViewModel (#6523)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Patrick HonkonenandClaude authored and GitHub committed 2026-02-17 20:44:44 +00:00
1 parent bd0640e5b4
commit f6f630ff8c
8 files changed
+1036

No files matched your search

@@ -0,0 +1,38 @@
@file:OmitFromCoverage
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
/**
* The type-safe route for the cookie acquisition screen.
*/
@OmitFromCoverage
@Serializable
data object CookieAcquisitionRoute
/**
* Add the cookie acquisition screen to the nav graph.
*/
fun NavGraphBuilder.cookieAcquisitionDestination() {
composableWithSlideTransitions<CookieAcquisitionRoute> {
CookieAcquisitionScreen()
}
}
/**
* Navigate to the cookie acquisition screen.
*/
fun NavController.navigateToCookieAcquisition(
navOptions: NavOptions? = null,
) {
this.navigate(
route = CookieAcquisitionRoute,
navOptions = navOptions,
)
}
@@ -0,0 +1,238 @@
package com.x8bit.bitwarden.ui.platform.feature.cookieacquisition
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.composition.LocalIntentManager
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.handlers.CookieAcquisitionHandler
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.handlers.rememberCookieAcquisitionHandler
/**
* Top-level composable for the Cookie Acquisition screen.
*/
@Composable
fun CookieAcquisitionScreen(
viewModel: CookieAcquisitionViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is CookieAcquisitionEvent.LaunchBrowser -> {
intentManager.startCustomTabsActivity(event.uri.toUri())
}
is CookieAcquisitionEvent.NavigateToHelp -> {
intentManager.launchUri(event.uri.toUri())
}
}
}
val handler = rememberCookieAcquisitionHandler(viewModel = viewModel)
CookieAcquisitionDialogs(
dialogState = state.dialogState,
onDismissRequest = handler.onDismissDialogClick,
)
BitwardenScaffold(
contentWindowInsets = ScaffoldDefaults
.contentWindowInsets
.union(WindowInsets.displayCutout)
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top),
) {
CookieAcquisitionContent(
state = state,
handler = handler,
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun CookieAcquisitionDialogs(
dialogState: CookieAcquisitionDialogState?,
onDismissRequest: () -> Unit,
) {
when (dialogState) {
is CookieAcquisitionDialogState.Error -> {
BitwardenBasicDialog(
title = dialogState.title(),
message = dialogState.message(),
onDismissRequest = onDismissRequest,
)
}
null -> Unit
}
}
@Suppress("LongMethod")
@Composable
private fun CookieAcquisitionContent(
state: CookieAcquisitionState,
handler: CookieAcquisitionHandler,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(32.dp))
Image(
painter = rememberVectorPainter(id = BitwardenDrawable.ill_sso_cookie_sync),
contentDescription = null,
contentScale = ContentScale.FillHeight,
modifier = Modifier
.standardHorizontalMargin()
.size(100.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(id = BitwardenString.sync_with_browser),
style = BitwardenTheme.typography.titleMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(
id = BitwardenString.sync_with_browser_description,
state.environmentUrl,
),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.launch_browser),
onClick = handler.onLaunchBrowserClick,
icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenOutlinedButton(
label = stringResource(id = BitwardenString.continue_without_syncing),
onClick = handler.onContinueWithoutSyncingClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenTextButton(
label = stringResource(id = BitwardenString.why_am_i_seeing_this),
onClick = handler.onWhyAmISeeingThisClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Preview(showBackground = true)
@Composable
private fun CookieAcquisitionScreen_preview() {
BitwardenTheme {
BitwardenScaffold {
CookieAcquisitionContent(
state = CookieAcquisitionState(
environmentUrl = "vault.bitwarden.com",
dialogState = null,
),
handler = CookieAcquisitionHandler(
onLaunchBrowserClick = {},
onContinueWithoutSyncingClick = {},
onWhyAmISeeingThisClick = {},
onDismissDialogClick = {},
),
modifier = Modifier.fillMaxSize(),
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun CookieAcquisitionScreen_darkPreview() {
BitwardenTheme(theme = AppTheme.DARK) {
BitwardenScaffold {
CookieAcquisitionContent(
state = CookieAcquisitionState(
environmentUrl = "vault.bitwarden.com",
dialogState = null,
),
handler = CookieAcquisitionHandler(
onLaunchBrowserClick = {},
onContinueWithoutSyncingClick = {},
onWhyAmISeeingThisClick = {},
onDismissDialogClick = {},
),
modifier = Modifier.fillMaxSize(),
)
}
}
}
@@ -0,0 +1,192 @@
package com.x8bit.bitwarden.ui.platform.feature.cookieacquisition
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.BaseViewModel
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val HELP_URL = "https://bitwarden.com/help"
/**
* ViewModel for the Cookie Acquisition screen.
*/
@HiltViewModel
class CookieAcquisitionViewModel @Inject constructor(
private val cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
authRepository: AuthRepository,
environmentRepository: EnvironmentRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<CookieAcquisitionState, CookieAcquisitionEvent, CookieAcquisitionAction>(
initialState = savedStateHandle[KEY_STATE] ?: CookieAcquisitionState(
environmentUrl = environmentRepository
.environment
.environmentUrlData
.baseWebVaultUrlOrDefault,
dialogState = null,
),
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
authRepository
.cookieCallbackResultFlow
.onEach {
sendAction(
CookieAcquisitionAction.Internal.CookieAcquisitionResultReceived(
result = it,
),
)
}
.launchIn(viewModelScope)
}
override fun handleAction(action: CookieAcquisitionAction) {
when (action) {
CookieAcquisitionAction.LaunchBrowserClick -> handleLaunchBrowserClick()
CookieAcquisitionAction.ContinueWithoutSyncingClick -> {
handleContinueWithoutSyncingClick()
}
CookieAcquisitionAction.WhyAmISeeingThisClick -> handleWhyAmISeeingThisClick()
CookieAcquisitionAction.DismissDialogClick -> handleDismissDialogClick()
is CookieAcquisitionAction.Internal.CookieAcquisitionResultReceived -> {
handleCookieAcquisitionResultReceived(action)
}
}
}
private fun handleLaunchBrowserClick() {
val hostname = cookieAcquisitionRequestManager
.cookieAcquisitionRequestFlow
.value
?.hostname
?: return
sendEvent(CookieAcquisitionEvent.LaunchBrowser(uri = hostname))
}
private fun handleContinueWithoutSyncingClick() {
cookieAcquisitionRequestManager.setPendingCookieAcquisition(data = null)
}
private fun handleWhyAmISeeingThisClick() {
sendEvent(CookieAcquisitionEvent.NavigateToHelp(uri = HELP_URL))
}
private fun handleDismissDialogClick() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleCookieAcquisitionResultReceived(
action: CookieAcquisitionAction.Internal.CookieAcquisitionResultReceived,
) {
when (action.result) {
is CookieCallbackResult.Success -> {
cookieAcquisitionRequestManager.setPendingCookieAcquisition(data = null)
}
is CookieCallbackResult.MissingCookie -> {
mutableStateFlow.update {
it.copy(
dialogState = CookieAcquisitionDialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
),
)
}
}
}
}
}
/**
* State for the Cookie Acquisition screen.
*/
@Parcelize
data class CookieAcquisitionState(
val environmentUrl: String,
val dialogState: CookieAcquisitionDialogState?,
) : Parcelable
/**
* Dialog states for the Cookie Acquisition screen.
*/
sealed class CookieAcquisitionDialogState : Parcelable {
/**
* Error dialog when cookie acquisition fails.
*/
@Parcelize
data class Error(
val title: Text,
val message: Text,
) : CookieAcquisitionDialogState()
}
/**
* Events for the Cookie Acquisition screen.
*/
sealed class CookieAcquisitionEvent {
/**
* Launch a browser to acquire cookies.
*/
data class LaunchBrowser(val uri: String) : CookieAcquisitionEvent()
/**
* Navigate to the help page.
*/
data class NavigateToHelp(val uri: String) : CookieAcquisitionEvent()
}
/**
* Actions for the Cookie Acquisition screen.
*/
sealed class CookieAcquisitionAction {
/**
* User clicked the "Launch browser" button.
*/
data object LaunchBrowserClick : CookieAcquisitionAction()
/**
* User clicked the "Continue without syncing" button.
*/
data object ContinueWithoutSyncingClick : CookieAcquisitionAction()
/**
* User clicked the "Why am I seeing this?" link.
*/
data object WhyAmISeeingThisClick : CookieAcquisitionAction()
/**
* User dismissed the error dialog.
*/
data object DismissDialogClick : CookieAcquisitionAction()
/**
* Internal actions for ViewModel processing.
*/
sealed class Internal : CookieAcquisitionAction() {
/**
* Cookie acquisition result received from the auth repository.
*/
data class CookieAcquisitionResultReceived(
val result: CookieCallbackResult,
) : Internal()
}
}
@@ -0,0 +1,59 @@
package com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.handlers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.CookieAcquisitionAction
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.CookieAcquisitionViewModel
/**
* A class to handle user interactions for the Cookie Acquisition screen.
*/
data class CookieAcquisitionHandler(
val onLaunchBrowserClick: () -> Unit,
val onContinueWithoutSyncingClick: () -> Unit,
val onWhyAmISeeingThisClick: () -> Unit,
val onDismissDialogClick: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Creates an instance of [CookieAcquisitionHandler] using the provided
* [CookieAcquisitionViewModel].
*/
fun create(
viewModel: CookieAcquisitionViewModel,
): CookieAcquisitionHandler =
CookieAcquisitionHandler(
onLaunchBrowserClick = {
viewModel.trySendAction(
CookieAcquisitionAction.LaunchBrowserClick,
)
},
onContinueWithoutSyncingClick = {
viewModel.trySendAction(
CookieAcquisitionAction.ContinueWithoutSyncingClick,
)
},
onWhyAmISeeingThisClick = {
viewModel.trySendAction(
CookieAcquisitionAction.WhyAmISeeingThisClick,
)
},
onDismissDialogClick = {
viewModel.trySendAction(
CookieAcquisitionAction.DismissDialogClick,
)
},
)
}
}
/**
* Helper function to create and remember a [CookieAcquisitionHandler] instance.
*/
@Composable
fun rememberCookieAcquisitionHandler(
viewModel: CookieAcquisitionViewModel,
): CookieAcquisitionHandler = remember(viewModel) {
CookieAcquisitionHandler.create(viewModel)
}
@@ -0,0 +1,180 @@
package com.x8bit.bitwarden.ui.platform.feature.cookieacquisition
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.core.net.toUri
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Before
import org.junit.Test
class CookieAcquisitionScreenTest : BitwardenComposeTest() {
private val mutableEventFlow =
bufferedMutableSharedFlow<CookieAcquisitionEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<CookieAcquisitionViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
private val intentManager = mockk<IntentManager> {
every { startCustomTabsActivity(any()) } just runs
every { launchUri(any()) } just runs
}
@Before
fun setUp() {
setContent(
intentManager = intentManager,
) {
CookieAcquisitionScreen(
viewModel = viewModel,
)
}
}
@Test
fun `LaunchBrowser event should call startCustomTabsActivity`() {
val uri = "https://example.com"
mutableEventFlow.tryEmit(CookieAcquisitionEvent.LaunchBrowser(uri = uri))
verify {
intentManager.startCustomTabsActivity(uri.toUri())
}
}
@Test
fun `NavigateToHelp event should call launchUri`() {
val uri = "https://bitwarden.com/help"
mutableEventFlow.tryEmit(CookieAcquisitionEvent.NavigateToHelp(uri = uri))
verify {
intentManager.launchUri(uri.toUri())
}
}
@Test
fun `title should be displayed`() {
composeTestRule
.onNodeWithText("Sync with browser")
.assertExists()
}
@Test
fun `body text should display environment URL`() {
composeTestRule
.onNodeWithText(
text = DEFAULT_ENVIRONMENT_URL,
substring = true,
)
.assertExists()
}
@Test
fun `launch browser button click should send LaunchBrowserClick action`() {
composeTestRule
.onNodeWithText("Launch browser")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(CookieAcquisitionAction.LaunchBrowserClick)
}
}
@Suppress("MaxLineLength")
@Test
fun `continue without syncing button click should send ContinueWithoutSyncingClick action`() {
composeTestRule
.onNodeWithText("Continue without syncing")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(
CookieAcquisitionAction.ContinueWithoutSyncingClick,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `why am I seeing this button click should send WhyAmISeeingThisClick action`() {
composeTestRule
.onNodeWithText("Why am I seeing this?")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(
CookieAcquisitionAction.WhyAmISeeingThisClick,
)
}
}
@Test
fun `error dialog should not be displayed by default`() {
composeTestRule
.onAllNodesWithText("An error has occurred")
.filterToOne(hasAnyAncestor(isDialog()))
.assertDoesNotExist()
}
@Test
fun `error dialog should be displayed when dialogState is Error`() {
mutableStateFlow.update {
it.copy(
dialogState = CookieAcquisitionDialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
),
)
}
composeTestRule
.onAllNodesWithText("An error has occurred")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `error dialog dismiss should send DismissDialogClick action`() {
mutableStateFlow.update {
it.copy(
dialogState = CookieAcquisitionDialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
),
)
}
composeTestRule
.onAllNodesWithText("Okay")
.filterToOne(
hasAnyAncestor(isDialog()) and hasClickAction(),
)
.performClick()
verify {
viewModel.trySendAction(CookieAcquisitionAction.DismissDialogClick)
}
}
}
private const val DEFAULT_ENVIRONMENT_URL = "https://vault.bitwarden.com"
private val DEFAULT_STATE = CookieAcquisitionState(
environmentUrl = DEFAULT_ENVIRONMENT_URL,
dialogState = null,
)
@@ -0,0 +1,217 @@
package com.x8bit.bitwarden.ui.platform.feature.cookieacquisition
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.model.CookieAcquisitionRequest
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class CookieAcquisitionViewModelTest : BaseViewModelTest() {
private val mutableCookieCallbackResultFlow =
MutableSharedFlow<CookieCallbackResult>()
private val mutableCookieAcquisitionRequestFlow =
MutableStateFlow<CookieAcquisitionRequest?>(DEFAULT_COOKIE_ACQUISITION_REQUEST)
private val mockAuthRepository: AuthRepository = mockk {
every { cookieCallbackResultFlow } returns mutableCookieCallbackResultFlow
}
private val mockCookieAcquisitionRequestManager: CookieAcquisitionRequestManager =
mockk {
every {
cookieAcquisitionRequestFlow
} returns mutableCookieAcquisitionRequestFlow
every { setPendingCookieAcquisition(data = any()) } just runs
}
private val fakeEnvironmentRepository = FakeEnvironmentRepository()
@Test
fun `initial state should be correct`() {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `LaunchBrowserClick should emit LaunchBrowser event with hostname`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CookieAcquisitionAction.LaunchBrowserClick)
assertEquals(
CookieAcquisitionEvent.LaunchBrowser(
uri = DEFAULT_HOSTNAME,
),
awaitItem(),
)
}
}
@Test
fun `LaunchBrowserClick should do nothing when no pending request`() =
runTest {
mutableCookieAcquisitionRequestFlow.value = null
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CookieAcquisitionAction.LaunchBrowserClick)
expectNoEvents()
}
}
@Test
fun `ContinueWithoutSyncingClick should clear pending acquisition`() {
val viewModel = createViewModel()
viewModel.trySendAction(
CookieAcquisitionAction.ContinueWithoutSyncingClick,
)
verify {
mockCookieAcquisitionRequestManager
.setPendingCookieAcquisition(data = null)
}
}
@Test
fun `WhyAmISeeingThisClick should emit NavigateToHelp event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(CookieAcquisitionAction.WhyAmISeeingThisClick)
assertEquals(
CookieAcquisitionEvent.NavigateToHelp(
uri = "https://bitwarden.com/help",
),
awaitItem(),
)
}
}
@Test
fun `CookieCallbackResult Success should clear pending request`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableCookieCallbackResultFlow.emit(
CookieCallbackResult.Success(cookies = mapOf("cookie" to "value")),
)
expectNoEvents()
}
verify {
mockCookieAcquisitionRequestManager.setPendingCookieAcquisition(data = null)
}
}
@Test
fun `CookieCallbackResult MissingCookie should update state with error dialog`() =
runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableCookieCallbackResultFlow.emit(
CookieCallbackResult.MissingCookie,
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = CookieAcquisitionDialogState.Error(
title = BitwardenString
.an_error_has_occurred
.asText(),
message = BitwardenString
.generic_error_message
.asText(),
),
),
awaitItem(),
)
}
}
@Test
fun `DismissDialogClick should clear dialog state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
// First trigger an error dialog
mutableCookieCallbackResultFlow.emit(CookieCallbackResult.MissingCookie)
assertEquals(
DEFAULT_STATE.copy(
dialogState = CookieAcquisitionDialogState.Error(
title = BitwardenString
.an_error_has_occurred
.asText(),
message = BitwardenString
.generic_error_message
.asText(),
),
),
awaitItem(),
)
viewModel.trySendAction(CookieAcquisitionAction.DismissDialogClick)
assertEquals(DEFAULT_STATE, awaitItem())
}
}
@Test
fun `state should be restored from SavedStateHandle`() {
val savedState = CookieAcquisitionState(
environmentUrl = "https://custom.vault.com",
dialogState = null,
)
val viewModel = createViewModel(state = savedState)
assertEquals(savedState, viewModel.stateFlow.value)
}
@Test
fun `initial state should use environment URL when no saved state`() {
val viewModel = createViewModel(state = null)
assertEquals(
CookieAcquisitionState(
environmentUrl = "https://vault.bitwarden.com",
dialogState = null,
),
viewModel.stateFlow.value,
)
}
private fun createViewModel(
state: CookieAcquisitionState? = DEFAULT_STATE,
): CookieAcquisitionViewModel =
CookieAcquisitionViewModel(
savedStateHandle = SavedStateHandle(
mapOf("state" to state),
),
cookieAcquisitionRequestManager = mockCookieAcquisitionRequestManager,
authRepository = mockAuthRepository,
environmentRepository = fakeEnvironmentRepository,
)
}
private const val DEFAULT_HOSTNAME = "https://example.com"
private val DEFAULT_COOKIE_ACQUISITION_REQUEST = CookieAcquisitionRequest(
hostname = DEFAULT_HOSTNAME,
)
private val DEFAULT_STATE = CookieAcquisitionState(
environmentUrl = "https://vault.bitwarden.us",
dialogState = null,
)
@@ -0,0 +1,108 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="100dp"
android:height="101dp"
android:viewportHeight="101"
android:viewportWidth="100">
<path
android:name="secondary"
android:fillColor="#AAC3EF"
android:fillType="evenOdd"
android:pathData="M67.69,67.25C67.31,65.53 68.08,63.78 69.48,62.71C77.27,56.82 82.29,47.47 82.29,36.96C82.29,19.12 67.83,4.67 50,4.67C32.17,4.67 17.71,19.12 17.71,36.96C17.71,47.47 22.73,56.82 30.52,62.71C31.92,63.78 32.69,65.53 32.31,67.25L26.11,95.44C25.54,98.04 27.52,100.5 30.18,100.5H69.82C72.48,100.5 74.46,98.04 73.89,95.44L67.69,67.25Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M68.23,61.05C75.51,55.53 80.21,46.79 80.21,36.96C80.21,20.27 66.68,6.75 50,6.75C33.32,6.75 19.79,20.27 19.79,36.96C19.79,46.79 24.49,55.53 31.77,61.05C33.74,62.54 34.92,65.08 34.34,67.7L28.15,95.89C27.86,97.19 28.85,98.42 30.18,98.42H69.82C71.15,98.42 72.14,97.19 71.85,95.89L65.66,67.7C65.08,65.08 66.26,62.54 68.23,61.05ZM69.48,62.71C68.08,63.78 67.31,65.53 67.69,67.25L73.89,95.44C74.46,98.04 72.48,100.5 69.82,100.5H30.18C27.52,100.5 25.54,98.04 26.11,95.44L32.31,67.25C32.69,65.53 31.92,63.78 30.52,62.71C22.73,56.82 17.71,47.47 17.71,36.96C17.71,19.12 32.17,4.67 50,4.67C67.83,4.67 82.29,19.12 82.29,36.96C82.29,47.47 77.27,56.82 69.48,62.71Z" />
<path
android:name="primary"
android:fillColor="#DBE5F6"
android:fillType="evenOdd"
android:pathData="M21.73,52.58H47.92C50.22,52.58 52.08,50.72 52.08,48.42V31.75C52.08,29.45 50.22,27.58 47.92,27.58H20.83C20.16,27.58 19.52,27.74 18.96,28.03C18.14,30.86 17.71,33.86 17.71,36.96C17.71,42.63 19.17,47.95 21.73,52.58Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M22.99,50.5H47.92C49.07,50.5 50,49.57 50,48.42V31.75C50,30.6 49.07,29.67 47.92,29.67H20.83C20.78,29.67 20.73,29.67 20.68,29.67C20.1,32 19.79,34.44 19.79,36.96C19.79,41.83 20.94,46.43 22.99,50.5ZM21.73,52.58H47.92C50.22,52.58 52.08,50.72 52.08,48.42V31.75C52.08,29.45 50.22,27.58 47.92,27.58H20.83C20.16,27.58 19.52,27.74 18.96,28.03C18.14,30.86 17.71,33.86 17.71,36.96C17.71,42.63 19.17,47.95 21.73,52.58Z" />
<path
android:name="secondary"
android:fillColor="#AAC3EF"
android:pathData="M18.75,32.79H50V36.96H18.75V32.79Z" />
<path
android:name="secondary"
android:fillColor="#AAC3EF"
android:pathData="M21.88,46.33C21.88,45.76 22.34,45.29 22.92,45.29H27.08C27.66,45.29 28.13,45.76 28.13,46.33V46.33C28.13,46.91 27.66,47.38 27.08,47.38H22.92C22.34,47.38 21.88,46.91 21.88,46.33V46.33Z" />
<path
android:name="secondary"
android:fillColor="#AAC3EF"
android:pathData="M30.21,46.33C30.21,45.76 30.67,45.29 31.25,45.29H40.63C41.2,45.29 41.67,45.76 41.67,46.33V46.33C41.67,46.91 41.2,47.38 40.63,47.38H31.25C30.67,47.38 30.21,46.91 30.21,46.33V46.33Z" />
<path
android:name="primary"
android:fillColor="#DBE5F6"
android:fillType="evenOdd"
android:pathData="M82.24,35.19C81.48,34.36 80.39,33.83 79.17,33.83H52.08C49.78,33.83 47.92,35.7 47.92,38V54.67C47.92,56.97 49.78,58.83 52.08,58.83H73.75C79.05,53.08 82.29,45.4 82.29,36.96C82.29,36.37 82.28,35.78 82.24,35.19Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M80.2,36.19C79.89,36.02 79.54,35.92 79.17,35.92H52.08C50.93,35.92 50,36.85 50,38V54.67C50,55.82 50.93,56.75 52.08,56.75H72.82C77.43,51.45 80.21,44.53 80.21,36.96C80.21,36.7 80.21,36.44 80.2,36.19ZM82.24,35.19C81.48,34.36 80.39,33.83 79.17,33.83H52.08C49.78,33.83 47.92,35.7 47.92,38V54.67C47.92,56.97 49.78,58.83 52.08,58.83H73.75C79.05,53.08 82.29,45.4 82.29,36.96C82.29,36.37 82.28,35.78 82.24,35.19Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:pathData="M68.75,41.13C68.75,40.55 69.22,40.08 69.79,40.08H77.08C77.66,40.08 78.13,40.55 78.13,41.13V41.13C78.13,41.7 77.66,42.17 77.08,42.17H69.79C69.22,42.17 68.75,41.7 68.75,41.13V41.13Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:pathData="M68.75,45.29C68.75,44.72 69.22,44.25 69.79,44.25H72.92C73.49,44.25 73.96,44.72 73.96,45.29V45.29C73.96,45.87 73.49,46.33 72.92,46.33H69.79C69.22,46.33 68.75,45.87 68.75,45.29V45.29Z" />
<path
android:name="tertiary"
android:fillColor="#ffffff"
android:pathData="M64.58,42.17C64.58,44.47 62.72,46.33 60.42,46.33C58.12,46.33 56.25,44.47 56.25,42.17C56.25,39.87 58.12,38 60.42,38C62.72,38 64.58,39.87 64.58,42.17Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M60.42,44.25C61.57,44.25 62.5,43.32 62.5,42.17C62.5,41.02 61.57,40.08 60.42,40.08C59.27,40.08 58.33,41.02 58.33,42.17C58.33,43.32 59.27,44.25 60.42,44.25ZM60.42,46.33C62.72,46.33 64.58,44.47 64.58,42.17C64.58,39.87 62.72,38 60.42,38C58.12,38 56.25,39.87 56.25,42.17C56.25,44.47 58.12,46.33 60.42,46.33Z" />
<path
android:name="tertiary"
android:fillColor="#ffffff"
android:pathData="M68.12,50.78C68.53,51.86 68.75,52.72 68.75,53.89C68.75,54.32 68.4,54.67 67.97,54.67H52.86C52.43,54.67 52.08,54.32 52.08,53.89C52.08,52.72 52.3,51.86 52.72,50.78C53.14,49.7 53.75,48.72 54.52,47.9C55.3,47.07 56.22,46.42 57.23,45.97C58.24,45.52 59.32,45.29 60.42,45.29C61.51,45.29 62.59,45.52 63.61,45.97C64.62,46.42 65.54,47.07 66.31,47.9C67.08,48.72 67.7,49.7 68.12,50.78Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M54.52,47.9C53.75,48.72 53.14,49.7 52.72,50.78C52.3,51.86 52.08,52.72 52.08,53.89C52.08,54.32 52.43,54.67 52.86,54.67H67.97C68.4,54.67 68.75,54.32 68.75,53.89C68.75,52.72 68.53,51.86 68.12,50.78C67.7,49.7 67.08,48.72 66.31,47.9C65.54,47.07 64.62,46.42 63.61,45.97C62.59,45.52 61.51,45.29 60.42,45.29C59.32,45.29 58.24,45.52 57.23,45.97C56.22,46.42 55.3,47.07 54.52,47.9ZM66.52,52.58C66.44,52.26 66.33,51.93 66.17,51.53C65.85,50.7 65.38,49.95 64.79,49.32L66.26,47.94L64.79,49.32C64.2,48.69 63.51,48.2 62.76,47.87C62.02,47.54 61.22,47.38 60.42,47.38C59.62,47.38 58.82,47.54 58.07,47.87C57.32,48.2 56.63,48.69 56.04,49.32C55.46,49.95 54.98,50.7 54.66,51.53C54.51,51.93 54.39,52.26 54.32,52.58H66.52Z" />
<path
android:name="primary"
android:fillColor="#DBE5F6"
android:fillType="evenOdd"
android:pathData="M70.42,79.67H50C45.4,79.67 41.67,83.4 41.67,88C41.67,92.6 45.4,96.33 50,96.33H73.99C73.99,96.04 73.95,95.74 73.89,95.44L70.42,79.67ZM30.04,77.58H52.08C56.69,77.58 60.42,73.85 60.42,69.25C60.42,64.65 56.69,60.92 52.08,60.92H28.35C29.04,61.55 29.77,62.14 30.52,62.71C31.92,63.78 32.69,65.53 32.31,67.25L30.04,77.58Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M30.86,63C32.04,64.08 32.65,65.68 32.31,67.25L30.04,77.58H52.08C56.69,77.58 60.42,73.85 60.42,69.25C60.42,64.65 56.69,60.92 52.08,60.92H28.35C29.04,61.55 29.77,62.14 30.52,62.71C30.64,62.8 30.75,62.9 30.86,63ZM33.53,63C34.36,64.36 34.71,66.01 34.34,67.7L32.63,75.5H52.08C55.54,75.5 58.33,72.7 58.33,69.25C58.33,65.8 55.54,63 52.08,63H33.53ZM68.75,81.75H50C46.55,81.75 43.75,84.55 43.75,88C43.75,91.45 46.55,94.25 50,94.25H71.49L68.75,81.75ZM70.42,79.67H50C45.4,79.67 41.67,83.4 41.67,88C41.67,92.6 45.4,96.33 50,96.33H73.99C73.99,96.04 73.95,95.74 73.89,95.44L70.42,79.67Z" />
<path
android:name="outline"
android:fillColor="#0E3781"
android:fillType="evenOdd"
android:pathData="M71.94,86.57L70.84,86.93V85.47C70.84,84.9 70.37,84.43 69.8,84.43C69.22,84.43 68.75,84.9 68.75,85.47V86.93L67.39,86.48C66.84,86.3 66.25,86.6 66.07,87.15C65.89,87.69 66.19,88.28 66.74,88.46L68.12,88.92L67.26,90.13C66.92,90.6 67.03,91.25 67.5,91.58C67.97,91.92 68.62,91.81 68.95,91.34L69.8,90.16L70.64,91.34C70.97,91.81 71.62,91.92 72.09,91.58C72.56,91.25 72.67,90.6 72.34,90.13L71.47,88.92L72.39,88.61L71.94,86.57ZM31.06,72.92C31.21,72.85 31.35,72.74 31.45,72.59L32.3,71.41L33.14,72.59C33.47,73.06 34.12,73.17 34.59,72.83C35.06,72.5 35.17,71.85 34.84,71.38L33.97,70.17L35.35,69.71C35.9,69.53 36.2,68.94 36.02,68.4C35.84,67.85 35.25,67.55 34.7,67.73L33.34,68.18V66.72C33.34,66.16 32.9,65.71 32.35,65.68C32.43,66.19 32.42,66.73 32.31,67.25L31.06,72.92ZM42.71,66.72C42.71,66.15 42.25,65.68 41.67,65.68C41.1,65.68 40.63,66.15 40.63,66.72V68.18L39.26,67.73C38.72,67.55 38.13,67.85 37.95,68.4C37.77,68.94 38.07,69.53 38.61,69.71L40,70.17L39.13,71.38C38.8,71.85 38.91,72.5 39.37,72.83C39.84,73.17 40.49,73.06 40.83,72.59L41.67,71.41L42.51,72.59C42.85,73.06 43.5,73.17 43.97,72.83C44.44,72.5 44.54,71.85 44.21,71.38L43.34,70.17L44.73,69.71C45.27,69.53 45.57,68.94 45.39,68.4C45.21,67.85 44.63,67.55 44.08,67.73L42.71,68.18V66.72ZM52.09,66.72C52.09,66.15 51.62,65.68 51.05,65.68C50.47,65.68 50,66.15 50,66.72V68.18L48.64,67.73C48.09,67.55 47.5,67.85 47.32,68.4C47.14,68.94 47.44,69.53 47.99,69.71L49.37,70.17L48.51,71.38C48.17,71.85 48.28,72.5 48.75,72.83C49.22,73.17 49.87,73.06 50.2,72.59L51.05,71.41L51.89,72.59C52.22,73.06 52.87,73.17 53.34,72.83C53.81,72.5 53.92,71.85 53.59,71.38L52.72,70.17L54.1,69.71C54.65,69.53 54.95,68.94 54.77,68.4C54.59,67.85 54,67.55 53.45,67.73L52.09,68.18V66.72ZM52.09,85.47C52.09,84.9 51.62,84.43 51.05,84.43C50.47,84.43 50,84.9 50,85.47V86.93L48.64,86.48C48.09,86.3 47.5,86.6 47.32,87.15C47.14,87.69 47.44,88.28 47.99,88.46L49.37,88.92L48.51,90.13C48.17,90.6 48.28,91.25 48.75,91.58C49.22,91.92 49.87,91.81 50.2,91.34L51.05,90.16L51.89,91.34C52.22,91.81 52.87,91.92 53.34,91.58C53.81,91.25 53.92,90.6 53.59,90.13L52.72,88.92L54.1,88.46C54.65,88.28 54.95,87.69 54.77,87.15C54.59,86.6 54,86.3 53.45,86.48L52.09,86.93V85.47ZM61.46,85.47C61.46,84.9 61,84.43 60.42,84.43C59.85,84.43 59.38,84.9 59.38,85.47V86.93L58.01,86.48C57.47,86.3 56.88,86.6 56.7,87.15C56.52,87.69 56.82,88.28 57.36,88.46L58.75,88.92L57.88,90.13C57.55,90.6 57.66,91.25 58.12,91.58C58.59,91.92 59.24,91.81 59.58,91.34L60.42,90.16L61.26,91.34C61.6,91.81 62.25,91.92 62.72,91.58C63.19,91.25 63.29,90.6 62.96,90.13L62.09,88.92L63.48,88.46C64.02,88.28 64.32,87.69 64.14,87.15C63.96,86.6 63.38,86.3 62.83,86.48L61.46,86.93V85.47Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M68.23,61.05C75.51,55.53 80.21,46.79 80.21,36.96C80.21,20.27 66.68,6.75 50,6.75C33.32,6.75 19.79,20.27 19.79,36.96C19.79,46.79 24.49,55.53 31.77,61.05C33.74,62.54 34.92,65.08 34.34,67.7L28.15,95.89C27.86,97.19 28.85,98.42 30.18,98.42H69.82C71.15,98.42 72.14,97.19 71.85,95.89L65.66,67.7C65.08,65.08 66.26,62.54 68.23,61.05ZM69.48,62.71C68.08,63.78 67.31,65.53 67.69,67.25L73.89,95.44C74.46,98.04 72.48,100.5 69.82,100.5H30.18C27.52,100.5 25.54,98.04 26.11,95.44L32.31,67.25C32.69,65.53 31.92,63.78 30.52,62.71C22.73,56.82 17.71,47.47 17.71,36.96C17.71,19.12 32.17,4.67 50,4.67C67.83,4.67 82.29,19.12 82.29,36.96C82.29,47.47 77.27,56.82 69.48,62.71Z" />
<path
android:name="accent"
android:fillColor="#FFBF00"
android:pathData="M62.5,13C62.5,19.9 56.9,25.5 50,25.5C43.1,25.5 37.5,19.9 37.5,13C37.5,6.1 43.1,0.5 50,0.5C56.9,0.5 62.5,6.1 62.5,13Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M50,23.42C55.75,23.42 60.42,18.75 60.42,13C60.42,7.25 55.75,2.58 50,2.58C44.25,2.58 39.58,7.25 39.58,13C39.58,18.75 44.25,23.42 50,23.42ZM50,25.5C56.9,25.5 62.5,19.9 62.5,13C62.5,6.1 56.9,0.5 50,0.5C43.1,0.5 37.5,6.1 37.5,13C37.5,19.9 43.1,25.5 50,25.5Z" />
<path
android:name="outline"
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M55.94,9.14C56.35,9.55 56.35,10.2 55.94,10.61L48.65,17.9C48.25,18.31 47.59,18.31 47.18,17.9L44.06,14.78C43.65,14.37 43.65,13.71 44.06,13.31C44.46,12.9 45.12,12.9 45.53,13.31L47.92,15.69L54.47,9.14C54.88,8.73 55.54,8.73 55.94,9.14Z" />
</vector>
+4
View File
@@ -1206,4 +1206,8 @@ Do you want to switch to this account?</string>
<string name="restart_premium">Restart Premium</string>
<string name="items_transferred">Items transferred</string>
<string name="updated_master_password">Updated master password</string>
<string name="sync_with_browser">Sync with browser</string>
<string name="sync_with_browser_description">To connect to %1$s, sync your default browser with the Bitwarden mobile app. When your browser opens, complete sign-in if prompted.\nOnce syncing is complete, you’ll be returned to this app.</string>
<string name="launch_browser">Launch browser</string>
<string name="continue_without_syncing">Continue without syncing</string>
</resources>