From f6f630ff8c79d70a91845ef76b6c433fa43b288d Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:44:44 -0500 Subject: [PATCH] [PM-32121] Add CookieAcquisition screen and ViewModel (#6523) Co-authored-by: Claude --- .../CookieAcquisitionNavigation.kt | 38 +++ .../CookieAcquisitionScreen.kt | 238 ++++++++++++++++++ .../CookieAcquisitionViewModel.kt | 192 ++++++++++++++ .../handlers/CookieAcquisitionHandler.kt | 59 +++++ .../CookieAcquisitionScreenTest.kt | 180 +++++++++++++ .../CookieAcquisitionViewModelTest.kt | 217 ++++++++++++++++ .../main/res/drawable/ill_sso_cookie_sync.xml | 108 ++++++++ ui/src/main/res/values/strings.xml | 4 + 8 files changed, 1036 insertions(+) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionNavigation.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionScreen.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionViewModel.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/handlers/CookieAcquisitionHandler.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionScreenTest.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionViewModelTest.kt create mode 100644 ui/src/main/res/drawable/ill_sso_cookie_sync.xml diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionNavigation.kt new file mode 100644 index 0000000000..3aff034a81 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionNavigation.kt @@ -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 { + CookieAcquisitionScreen() + } +} + +/** + * Navigate to the cookie acquisition screen. + */ +fun NavController.navigateToCookieAcquisition( + navOptions: NavOptions? = null, +) { + this.navigate( + route = CookieAcquisitionRoute, + navOptions = navOptions, + ) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionScreen.kt new file mode 100644 index 0000000000..871a7f0cb3 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionScreen.kt @@ -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(), + ) + } + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionViewModel.kt new file mode 100644 index 0000000000..67959a1fef --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionViewModel.kt @@ -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( + 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() + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/handlers/CookieAcquisitionHandler.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/handlers/CookieAcquisitionHandler.kt new file mode 100644 index 0000000000..14e88d9455 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/handlers/CookieAcquisitionHandler.kt @@ -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) +} diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionScreenTest.kt new file mode 100644 index 0000000000..d01d71b70f --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionScreenTest.kt @@ -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() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + } + private val intentManager = mockk { + 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, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionViewModelTest.kt new file mode 100644 index 0000000000..6396b8e4ce --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/cookieacquisition/CookieAcquisitionViewModelTest.kt @@ -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() + + private val mutableCookieAcquisitionRequestFlow = + MutableStateFlow(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, +) diff --git a/ui/src/main/res/drawable/ill_sso_cookie_sync.xml b/ui/src/main/res/drawable/ill_sso_cookie_sync.xml new file mode 100644 index 0000000000..426baa8106 --- /dev/null +++ b/ui/src/main/res/drawable/ill_sso_cookie_sync.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 17eb9e6c4e..511c01d467 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1206,4 +1206,8 @@ Do you want to switch to this account? Restart Premium Items transferred Updated master password + Sync with browser + 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. + Launch browser + Continue without syncing