mirror of
https://github.com/bitwarden/android.git
synced 2026-10-23 03:23:53 -05:00
[PM-32121] Add CookieAcquisition screen and ViewModel (#6523)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
bd0640e5b4
commit
f6f630ff8c
8 files changed
+1036
No files matched your search
+38
@@ -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,
|
||||
)
|
||||
}
|
||||
+238
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+192
@@ -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()
|
||||
}
|
||||
}
|
||||
+59
@@ -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)
|
||||
}
|
||||
+180
@@ -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,
|
||||
)
|
||||
+217
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user