Compare commits

..

7 Commits

Author SHA1 Message Date
Andre Rosado
d6f0a47d77 Merge branch 'main' into PM-37255/fill-assist-network-layer 2026-06-10 11:10:26 +01:00
Andre Rosado
31a9cf7c34 Added FillAssist to BaseUrlInterceptors 2026-06-09 13:59:15 +01:00
Andre Rosado
b25404e112 removed unnecessary null set 2026-06-09 13:16:20 +01:00
Andre Rosado
0b39ad2731 following autofill assist forms schema 2026-06-08 17:39:47 +01:00
Andre Rosado
f66485facd Removed nulls and sets on non nullable fields by schema definition. Removed unnecessary deserialization tests 2026-06-08 17:20:27 +01:00
Andre Rosado
f57a7d09a2 reverted unwanted changes on AuthRepositoryTest 2026-05-29 13:24:13 +01:00
Andre Rosado
c6463722f2 Add fill assist rules network data 2026-05-29 12:03:19 +01:00
50 changed files with 722 additions and 463 deletions

View File

@@ -15,13 +15,13 @@ import androidx.browser.auth.AuthTabIntent
import androidx.compose.foundation.background
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.core.app.ActivityCompat
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.EventsEffect
@@ -36,11 +36,17 @@ import com.x8bit.bitwarden.data.platform.manager.util.ObserveScreenDataEffect
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.components.util.rememberBitwardenNavController
import com.x8bit.bitwarden.ui.platform.composition.LocalManagerProvider
import com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure.accessibilityDisclosureDestination
import com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure.navigateToAccessibilityDisclosure
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.cookieAcquisitionDestination
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.navigateToCookieAcquisition
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.debugMenuDestination
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
import com.x8bit.bitwarden.ui.platform.feature.overlaynav.OverlayNavRoute
import com.x8bit.bitwarden.ui.platform.feature.overlaynav.overlayNavDestination
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.localNetworkAccessDestination
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.navigateToLocalNetworkAccess
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavigationRoute
import com.x8bit.bitwarden.ui.platform.feature.rootnav.rootNavDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers
import com.x8bit.bitwarden.ui.platform.util.appLanguage
@@ -106,6 +112,7 @@ class MainActivity : AppCompatActivity() {
)
}
@Suppress("LongMethod")
override fun onCreate(savedInstanceState: Bundle?) {
intent = intent.validate()
var shouldShowSplashScreen = true
@@ -126,13 +133,49 @@ class MainActivity : AppCompatActivity() {
SetupEventsEffect(navController = navController)
val state by mainViewModel.stateFlow.collectAsStateWithLifecycle()
updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed)
MainActivityContent(
state = state,
LocalManagerProvider(
featureFlagsState = state.featureFlagsState,
authTabLaunchers = authTabLaunchers,
navController = navController,
sendAction = mainViewModel::trySendAction,
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
) {
ObserveScreenDataEffect(
onDataUpdate = remember(mainViewModel) {
{ mainViewModel.trySendAction(MainAction.ResumeScreenDataReceived(it)) }
},
)
BitwardenTheme(
theme = state.theme,
dynamicColor = state.isDynamicColorsEnabled,
) {
NavHost(
navController = navController,
startDestination = RootNavigationRoute,
modifier = Modifier
.background(color = BitwardenTheme.colorScheme.background.primary),
) {
// Root navigation, debug menu, and cookie acquisition exist at
// this top level. They can appear on top of the rest of the app
// without interacting with the state-based navigation used by
// RootNavScreen.
rootNavDestination { shouldShowSplashScreen = false }
debugMenuDestination(
onNavigateBack = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
cookieAcquisitionDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
localNetworkAccessDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
accessibilityDisclosureDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
}
}
}
}
}
@@ -164,7 +207,7 @@ class MainActivity : AppCompatActivity() {
locales.get(0)?.appLanguage
}
} else {
// For older versions, use whatever language is available from the repository.
// For older versions, use what ever language is available from the repository.
settingsRepository.appLanguage
}
@@ -213,6 +256,15 @@ class MainActivity : AppCompatActivity() {
is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
MainEvent.Recreate -> handleRecreate()
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
MainEvent.NavigateToCookieAcquisition -> navController.navigateToCookieAcquisition()
MainEvent.NavigateToLocalNetworkAccess -> {
navController.navigateToLocalNetworkAccess()
}
MainEvent.NavigateToAccessibilityDisclosure -> {
navController.navigateToAccessibilityDisclosure()
}
is MainEvent.UpdateAppLocale -> {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(event.localeName),
@@ -256,38 +308,3 @@ class MainActivity : AppCompatActivity() {
}
}
}
@OmitFromCoverage
@Composable
private fun MainActivityContent(
state: MainState,
authTabLaunchers: AuthTabLaunchers,
navController: NavHostController,
sendAction: (MainAction) -> Unit,
onSplashScreenRemoved: () -> Unit,
) {
LocalManagerProvider(
featureFlagsState = state.featureFlagsState,
authTabLaunchers = authTabLaunchers,
) {
ObserveScreenDataEffect { sendAction(MainAction.ResumeScreenDataReceived(it)) }
BitwardenTheme(
theme = state.theme,
dynamicColor = state.isDynamicColorsEnabled,
) {
NavHost(
navController = navController,
startDestination = OverlayNavRoute,
modifier = Modifier.background(BitwardenTheme.colorScheme.background.primary),
) {
// The OverlayNav and Debug destinations are the only UIs that can be
// displayed here, everything else should be inside the OverlayNav.
overlayNavDestination(onSplashScreenRemoved = onSplashScreenRemoved)
debugMenuDestination(
onNavigateBack = { navController.popBackStack() },
onSplashScreenRemoved = onSplashScreenRemoved,
)
}
}
}
}

View File

@@ -31,11 +31,13 @@ import com.x8bit.bitwarden.data.billing.util.getPremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.util.isAddTotpLoginItemFromAuthenticator
@@ -55,6 +57,7 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -78,6 +81,8 @@ private const val ANIMATION_DEBOUNCE_DELAY_MS = 500L
class MainViewModel @Inject constructor(
accessibilitySelectionManager: AccessibilitySelectionManager,
autofillSelectionManager: AutofillSelectionManager,
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
networkPermissionManager: NetworkPermissionManager,
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
@@ -147,6 +152,12 @@ class MainViewModel @Inject constructor(
.onEach(::trySendAction)
.launchIn(viewModelScope)
settingsRepository
.hasShownAccessibilityDisclaimerFlow
.map { MainAction.Internal.HasShownAccessibilityDisclaimerUpdate(it) }
.onEach(::trySendAction)
.launchIn(viewModelScope)
merge(
authRepository
.userStateFlow
@@ -166,6 +177,20 @@ class MainViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
networkPermissionManager
.isLocalNetworkAccessRequiredStateFlow
.filter { it }
.map { MainAction.Internal.LocalNetworkAccessRequired }
.onEach(::sendAction)
.launchIn(viewModelScope)
cookieAcquisitionRequestManager
.cookieAcquisitionRequestFlow
.filterNotNull()
.map { MainAction.Internal.CookieAcquisitionReady }
.onEach(::sendAction)
.launchIn(viewModelScope)
// On app launch, mark all active users as having previously logged in.
// This covers any users who are active prior to this value being recorded.
viewModelScope.launch {
@@ -214,7 +239,20 @@ class MainViewModel @Inject constructor(
is MainAction.Internal.ScreenCaptureUpdate -> handleScreenCaptureUpdate(action)
is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
is MainAction.Internal.DynamicColorsUpdate -> handleDynamicColorsUpdate(action)
is MainAction.Internal.CookieAcquisitionReady -> handleCookieAcquisitionReady()
is MainAction.Internal.LocalNetworkAccessRequired -> handleLocalNetworkAccessRequired()
is MainAction.Internal.ResizeHasBeenRequested -> handleResizeHasBeenRequested()
is MainAction.Internal.HasShownAccessibilityDisclaimerUpdate -> {
handleHasShownAccessibilityDisclaimerUpdate(action)
}
}
}
private fun handleHasShownAccessibilityDisclaimerUpdate(
action: MainAction.Internal.HasShownAccessibilityDisclaimerUpdate,
) {
if (!action.hasBeenShown) {
sendEvent(MainEvent.NavigateToAccessibilityDisclosure)
}
}
@@ -295,6 +333,14 @@ class MainViewModel @Inject constructor(
mutableStateFlow.update { it.copy(isDynamicColorsEnabled = action.isDynamicColorsEnabled) }
}
private fun handleCookieAcquisitionReady() {
sendEvent(MainEvent.NavigateToCookieAcquisition)
}
private fun handleLocalNetworkAccessRequired() {
sendEvent(MainEvent.NavigateToLocalNetworkAccess)
}
private fun handleResizeHasBeenRequested() {
mutableStateFlow.update { it.copy(hasResizeBeenRequested = true) }
}
@@ -649,10 +695,26 @@ sealed class MainAction {
val isDynamicColorsEnabled: Boolean,
) : Internal()
/**
* Indicates that the cookie acquisition conditions are met and navigation
* should proceed.
*/
data object CookieAcquisitionReady : Internal()
/**
* Indicates that the local network access is required.
*/
data object LocalNetworkAccessRequired : Internal()
/**
* Indicates that resize has been requested on the Activity
*/
data object ResizeHasBeenRequested : Internal()
/**
* Indicates that the accessibility disclaimer has been displayed.
*/
data class HasShownAccessibilityDisclaimerUpdate(val hasBeenShown: Boolean) : Internal()
}
}
@@ -682,6 +744,21 @@ sealed class MainEvent {
*/
data object NavigateToDebugMenu : MainEvent()
/**
* Navigate to the cookie acquisition screen.
*/
data object NavigateToCookieAcquisition : MainEvent()
/**
* Navigate to the local network access screen.
*/
data object NavigateToLocalNetworkAccess : MainEvent()
/**
* Navigate to the accessibility disclosure screen.
*/
data object NavigateToAccessibilityDisclosure : MainEvent()
/**
* Indicates that the app language has been updated.
*/

View File

@@ -27,4 +27,10 @@ interface EnvironmentDiskSource {
* Stores the [urls] for the given [userEmail].
*/
fun storePreAuthEnvironmentUrlDataForEmail(userEmail: String, urls: EnvironmentUrlDataJson)
/**
* The fill-assist URL provided by the server config, or `null` if the server does not
* configure fill-assist targeting rules.
*/
var fillAssistRulesUrl: String?
}

View File

@@ -11,6 +11,7 @@ import kotlinx.serialization.json.Json
private const val PRE_AUTH_URLS_KEY = "preAuthEnvironmentUrls"
private const val EMAIL_VERIFICATION_URLS = "emailVerificationUrls"
private const val FILL_ASSIST_RULES_URL_KEY = "fillAssistRulesUrl"
/**
* Primary implementation of [EnvironmentDiskSource].
@@ -54,4 +55,8 @@ class EnvironmentDiskSourceImpl(
value = json.encodeToString(urls),
)
}
override var fillAssistRulesUrl: String?
get() = getString(key = FILL_ASSIST_RULES_URL_KEY)
set(value) = putString(key = FILL_ASSIST_RULES_URL_KEY, value = value)
}

View File

@@ -7,6 +7,7 @@ import com.bitwarden.network.interceptor.BaseUrlsProvider
import com.bitwarden.network.model.BitwardenServiceClientConfig
import com.bitwarden.network.service.ConfigService
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.FillAssistService
import com.bitwarden.network.service.PushService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.AuthTokenManager
@@ -32,6 +33,12 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object PlatformNetworkModule {
@Provides
@Singleton
fun providesFillAssistService(
bitwardenServiceClient: BitwardenServiceClient,
): FillAssistService = bitwardenServiceClient.fillAssistService
@Provides
@Singleton
fun providesConfigService(

View File

@@ -31,4 +31,6 @@ class BaseUrlsProviderImpl(
.toEnvironmentUrlsOrDefault()
.environmentUrlData
.baseEventsUrl
override fun getBaseFillAssistUrl(): String? = environmentDiskSource.fillAssistRulesUrl
}

View File

@@ -2,7 +2,7 @@ package com.x8bit.bitwarden.ui.platform.feature.debugmenu
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
import kotlinx.serialization.Serializable
/**
@@ -27,7 +27,7 @@ fun NavGraphBuilder.debugMenuDestination(
onNavigateBack: () -> Unit,
onSplashScreenRemoved: () -> Unit,
) {
composableWithSlideTransitions<DebugRoute> {
composableWithPushTransitions<DebugRoute> {
DebugMenuScreen(onNavigateBack = onNavigateBack)
// If we are displaying the debug screen, then we can just hide the splash screen.
onSplashScreenRemoved()

View File

@@ -1,22 +0,0 @@
package com.x8bit.bitwarden.ui.platform.feature.overlaynav
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import kotlinx.serialization.Serializable
/**
* The type-safe route for the overlay navigation screen.
*/
@Serializable
data object OverlayNavRoute
/**
* Add the overlay navigation screen to the nav graph.
*/
fun NavGraphBuilder.overlayNavDestination(
onSplashScreenRemoved: () -> Unit,
) {
composable<OverlayNavRoute> {
OverlayNavScreen(onSplashScreenRemoved = onSplashScreenRemoved)
}
}

View File

@@ -1,76 +0,0 @@
package com.x8bit.bitwarden.ui.platform.feature.overlaynav
import androidx.compose.runtime.Composable
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.util.rememberBitwardenNavController
import com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure.accessibilityDisclosureDestination
import com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure.navigateToAccessibilityDisclosure
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.cookieAcquisitionDestination
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.navigateToCookieAcquisition
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.localNetworkAccessDestination
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.navigateToLocalNetworkAccess
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavigationRoute
import com.x8bit.bitwarden.ui.platform.feature.rootnav.rootNavDestination
/**
* Controls the overlay [NavHost] for the app including the [rootNavDestination] and any screen
* that can appear on top of it without affecting its state.
*/
@Composable
fun OverlayNavScreen(
viewModel: OverlayNavViewModel = hiltViewModel(),
navController: NavHostController = rememberBitwardenNavController(name = "OverlayNavScreen"),
onSplashScreenRemoved: () -> Unit,
) {
OverlayNavEventsEffect(
viewModel = viewModel,
navController = navController,
)
NavHost(
navController = navController,
startDestination = RootNavigationRoute,
) {
// This is the overlay level of navigation that sits above the root nav. These screens
// can appear on top of the rest of the app without interacting with the state-based
// navigation used by RootNavScreen (which also exists here).
rootNavDestination(onSplashScreenRemoved = onSplashScreenRemoved)
cookieAcquisitionDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = onSplashScreenRemoved,
)
localNetworkAccessDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = onSplashScreenRemoved,
)
accessibilityDisclosureDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = onSplashScreenRemoved,
)
}
}
@Composable
private fun OverlayNavEventsEffect(
viewModel: OverlayNavViewModel,
navController: NavController,
) {
EventsEffect(viewModel = viewModel) { event ->
when (event) {
OverlayNavEvent.NavigateToCookieAcquisition -> {
navController.navigateToCookieAcquisition()
}
OverlayNavEvent.NavigateToLocalNetworkAccess -> {
navController.navigateToLocalNetworkAccess()
}
OverlayNavEvent.NavigateToAccessibilityDisclosure -> {
navController.navigateToAccessibilityDisclosure()
}
}
}
}

View File

@@ -1,127 +0,0 @@
package com.x8bit.bitwarden.ui.platform.feature.overlaynav
import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.base.DeferredBackgroundEvent
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
/**
* Manages the overlay navigation, hosting the root-navigation and any screen that can overlay it.
*/
@HiltViewModel
class OverlayNavViewModel @Inject constructor(
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
networkPermissionManager: NetworkPermissionManager,
settingsRepository: SettingsRepository,
) : BaseViewModel<Unit, OverlayNavEvent, OverlayNavAction>(initialState = Unit) {
init {
settingsRepository
.hasShownAccessibilityDisclaimerFlow
.filterNot { it }
.map { OverlayNavAction.Internal.AccessibilityDisclosureRequired }
.onEach(::trySendAction)
.launchIn(viewModelScope)
networkPermissionManager
.isLocalNetworkAccessRequiredStateFlow
.filter { it }
.map { OverlayNavAction.Internal.LocalNetworkAccessRequired }
.onEach(::sendAction)
.launchIn(viewModelScope)
cookieAcquisitionRequestManager
.cookieAcquisitionRequestFlow
.filterNotNull()
.map { OverlayNavAction.Internal.CookieAcquisitionReady }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: OverlayNavAction) {
when (action) {
is OverlayNavAction.Internal -> handleInternal(action)
}
}
private fun handleInternal(action: OverlayNavAction.Internal) {
when (action) {
OverlayNavAction.Internal.AccessibilityDisclosureRequired -> {
handleAccessibilityDisclosureRequired()
}
OverlayNavAction.Internal.CookieAcquisitionReady -> handleCookieAcquisitionReady()
OverlayNavAction.Internal.LocalNetworkAccessRequired -> {
handleLocalNetworkAccessRequired()
}
}
}
private fun handleAccessibilityDisclosureRequired() {
sendEvent(OverlayNavEvent.NavigateToAccessibilityDisclosure)
}
private fun handleCookieAcquisitionReady() {
sendEvent(OverlayNavEvent.NavigateToCookieAcquisition)
}
private fun handleLocalNetworkAccessRequired() {
sendEvent(OverlayNavEvent.NavigateToLocalNetworkAccess)
}
}
/**
* Models events for the overlay navigation screen.
*/
sealed class OverlayNavEvent {
/**
* Navigate to the cookie acquisition screen.
*/
data object NavigateToCookieAcquisition : OverlayNavEvent(), DeferredBackgroundEvent
/**
* Navigate to the local network access screen.
*/
data object NavigateToLocalNetworkAccess : OverlayNavEvent(), DeferredBackgroundEvent
/**
* Navigate to the accessibility disclosure screen.
*/
data object NavigateToAccessibilityDisclosure : OverlayNavEvent(), DeferredBackgroundEvent
}
/**
* Models actions for the overlay navigation screen.
*/
sealed class OverlayNavAction {
/**
* Internal ViewModel actions.
*/
sealed class Internal : OverlayNavAction() {
/**
* Indicates that the cookie acquisition conditions are met and navigation
* should proceed.
*/
data object CookieAcquisitionReady : Internal()
/**
* Indicates that the local network access is required.
*/
data object LocalNetworkAccessRequired : Internal()
/**
* Indicates that the accessibility disclosure needs to be displayed.
*/
data object AccessibilityDisclosureRequired : Internal()
}
}

View File

@@ -12,7 +12,9 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
@@ -26,8 +28,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -37,6 +37,7 @@ import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.account.dialog.BitwardenLogoutConfirmationDialog
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.badge.NotificationBadge
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.card.BitwardenActionCard
import com.bitwarden.ui.platform.components.card.actionCardExitAnimation
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
@@ -634,23 +635,50 @@ private fun FingerPrintPhraseDialog(
onDismissRequest: () -> Unit,
onLearnMore: () -> Unit,
) {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.fingerprint_phrase),
message = buildAnnotatedString {
append("${stringResource(id = BitwardenString.your_accounts_fingerprint)}:\n\n")
withStyle(
style = BitwardenTheme.typography.sensitiveInfoSmall
.toSpanStyle()
.copy(color = BitwardenTheme.colorScheme.text.codePink),
) {
append(fingerprintPhrase())
AlertDialog(
onDismissRequest = onDismissRequest,
dismissButton = {
BitwardenTextButton(
label = stringResource(id = BitwardenString.close),
onClick = onDismissRequest,
)
},
confirmButton = {
BitwardenTextButton(
label = stringResource(id = BitwardenString.learn_more),
isExternalLink = true,
onClick = onLearnMore,
)
},
title = {
Text(
text = stringResource(id = BitwardenString.fingerprint_phrase),
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.headlineSmall,
modifier = Modifier.fillMaxWidth(),
)
},
text = {
Column {
Text(
text = "${stringResource(id = BitwardenString.your_accounts_fingerprint)}:",
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = fingerprintPhrase(),
color = BitwardenTheme.colorScheme.text.codePink,
style = BitwardenTheme.typography.sensitiveInfoSmall,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButtonText = stringResource(id = BitwardenString.learn_more),
dismissButtonText = stringResource(id = BitwardenString.close),
onConfirmClick = onLearnMore,
onDismissClick = onDismissRequest,
onDismissRequest = onDismissRequest,
containerColor = BitwardenTheme.colorScheme.background.primary,
iconContentColor = BitwardenTheme.colorScheme.icon.secondary,
titleContentColor = BitwardenTheme.colorScheme.text.primary,
textContentColor = BitwardenTheme.colorScheme.text.primary,
)
}

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
@@ -77,7 +78,7 @@ fun PinInputDialog(
// This background is necessary for the dialog to not be transparent.
.background(
color = BitwardenTheme.colorScheme.background.primary,
shape = BitwardenTheme.shapes.dialog,
shape = RoundedCornerShape(28.dp),
),
horizontalAlignment = Alignment.End,
) {

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -56,7 +57,7 @@ fun AddEditBlockedUriDialog(
// This background is necessary for the dialog to not be transparent.
.background(
color = BitwardenTheme.colorScheme.background.primary,
shape = BitwardenTheme.shapes.dialog,
shape = RoundedCornerShape(28.dp),
),
horizontalAlignment = Alignment.End,
) {

View File

@@ -255,7 +255,7 @@ private fun PrivilegedAppsListContent(
) {
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_delete,
contentDescription = stringResource(id = BitwardenString.delete),
contentDescription = "",
onClick = remember(item) {
{ onDeleteClick(item) }
},

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
@@ -185,7 +186,7 @@ private fun ScanHintBanner(
.semantics { liveRegion = LiveRegionMode.Polite }
.background(
color = BitwardenTheme.colorScheme.background.scrim,
shape = BitwardenTheme.shapes.content,
shape = RoundedCornerShape(size = 8.dp),
)
.padding(horizontal = 16.dp, vertical = 12.dp),
)

View File

@@ -163,21 +163,13 @@ fun VaultItemIdentityContent(
}
identityState.passportNumber?.let { passportNumber ->
item(key = "passportNumber") {
BitwardenPasswordField(
IdentityCopyField(
label = stringResource(id = BitwardenString.passport_number),
value = passportNumber,
onValueChange = {},
readOnly = true,
actions = {
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_copy,
contentDescription = stringResource(id = BitwardenString.copy_passport_number),
onClick = vaultIdentityItemTypeHandlers.onCopyPassportNumberClick,
modifier = Modifier.testTag(tag = "IdentityCopyPassportNumberButton"),
)
},
passwordFieldTestTag = "IdentityPassportNumberEntry",
showPasswordTestTag = "IdentityViewPassportNumberButton",
copyContentDescription = stringResource(id = BitwardenString.copy_passport_number),
textFieldTestTag = "IdentityPassportNumberEntry",
copyActionTestTag = "IdentityCopyPassportNumberButton",
onCopyClick = vaultIdentityItemTypeHandlers.onCopyPassportNumberClick,
cardStyle = identityState
.propertyList
.toListItemCardStyle(

View File

@@ -55,14 +55,17 @@ import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionReques
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.CookieAcquisitionRequest
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.util.isAddTotpLoginItemFromAuthenticator
@@ -111,6 +114,7 @@ class MainViewModelTest : BaseViewModelTest() {
private val mutableAppLanguageFlow = MutableStateFlow(AppLanguage.DEFAULT)
private val mutableScreenCaptureAllowedFlow = MutableStateFlow(true)
private val mutableIsDynamicColorsEnabledFlow = MutableStateFlow(false)
private val mutableHasShownAccessibilityDisclaimerFlow = MutableStateFlow(true)
private val settingsRepository = mockk<SettingsRepository> {
every { appTheme } returns AppTheme.DEFAULT
every { appThemeStateFlow } returns mutableAppThemeFlow
@@ -121,6 +125,10 @@ class MainViewModelTest : BaseViewModelTest() {
every { appLanguage = any() } just runs
every { isDynamicColorsEnabled } returns false
every { isDynamicColorsEnabledFlow } returns mutableIsDynamicColorsEnabledFlow
every {
hasShownAccessibilityDisclaimerFlow
} returns mutableHasShownAccessibilityDisclaimerFlow
every { accessibilityDisclaimerHasBeenShown() } just runs
}
private val authRepository = mockk<AuthRepository> {
every { activeUserId } returns DEFAULT_USER_STATE.activeUserId
@@ -175,6 +183,17 @@ class MainViewModelTest : BaseViewModelTest() {
every { show(message = any(), duration = any()) } just runs
every { show(messageId = any(), duration = any()) } just runs
}
private val mutableCookieAcquisitionRequestFlow =
MutableStateFlow<CookieAcquisitionRequest?>(null)
private val cookieAcquisitionRequestManager: CookieAcquisitionRequestManager = mockk {
every { cookieAcquisitionRequestFlow } returns mutableCookieAcquisitionRequestFlow
}
private val mutableIsLocalNetworkAccessRequiredStateFlow = MutableStateFlow(false)
private val networkPermissionManager: NetworkPermissionManager = mockk {
every {
isLocalNetworkAccessRequiredStateFlow
} returns mutableIsLocalNetworkAccessRequiredStateFlow
}
private val credentialProviderRequestManager: CredentialProviderRequestManager = mockk {
every { getPendingCredentialRequest() } returns null
}
@@ -1300,6 +1319,53 @@ class MainViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `cookie acquisition should emit NavigateToCookieAcquisition when vault unlocked with matching hostname`() =
runTest {
mutableCookieAcquisitionRequestFlow.value = CookieAcquisitionRequest(
hostname = DEFAULT_US_WEB_VAULT_URL,
)
val viewModel = createViewModel()
viewModel.eventFlow.test {
// Skip init events (appLanguage + appTheme)
skipItems(2)
mutableUserStateFlow.value = DEFAULT_USER_STATE
assertEquals(
MainEvent.NavigateToCookieAcquisition,
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `local network access required should emit NavigateToLocalNetworkAccess when stateflow emits`() =
runTest {
mutableIsLocalNetworkAccessRequiredStateFlow.value = true
val viewModel = createViewModel()
viewModel.eventFlow.test {
// Skip init events (appLanguage + appTheme)
skipItems(2)
assertEquals(MainEvent.NavigateToLocalNetworkAccess, awaitItem())
}
}
@Test
fun `cookie acquisition should not emit event when conditions are false`() =
runTest {
mutableCookieAcquisitionRequestFlow.value = null
val viewModel = createViewModel()
viewModel.eventFlow.test {
// Skip init events (appLanguage + appTheme)
skipItems(2)
mutableUserStateFlow.value = DEFAULT_USER_STATE
expectNoEvents()
}
}
@Test
fun `on handleResizeHasBeenRequested should set hasResizeBeenRequested as true`() = runTest {
val viewModel = createViewModel()
@@ -1324,12 +1390,31 @@ class MainViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `on HasShownAccessibilityDisclaimerUpdate with false should show the accessibility disclosure`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
// We skip the first 2 events because they are the default appTheme and appLanguage
skipItems(2)
mutableHasShownAccessibilityDisclaimerFlow.value = false
assertEquals(MainEvent.NavigateToAccessibilityDisclosure, awaitItem())
mutableHasShownAccessibilityDisclaimerFlow.value = true
expectNoEvents()
}
}
private fun createViewModel(
initialSpecialCircumstance: SpecialCircumstance? = null,
): MainViewModel = MainViewModel(
accessibilitySelectionManager = accessibilitySelectionManager,
addTotpItemFromAuthenticatorManager = addTotpItemAuthenticatorManager,
autofillSelectionManager = autofillSelectionManager,
cookieAcquisitionRequestManager = cookieAcquisitionRequestManager,
networkPermissionManager = networkPermissionManager,
specialCircumstanceManager = specialCircumstanceManager,
garbageCollectionManager = garbageCollectionManager,
credentialProviderRequestManager = credentialProviderRequestManager,
@@ -1360,6 +1445,7 @@ private val DEFAULT_FIRST_TIME_STATE = FirstTimeState(
private const val SPECIAL_CIRCUMSTANCE_KEY: String = "special-circumstance"
private const val ACTIVE_USER_ID: String = "activeUserId"
private const val DEFAULT_US_WEB_VAULT_URL: String = "https://vault.bitwarden.com"
private val DEFAULT_ACCOUNT = UserState.Account(
userId = ACTIVE_USER_ID,
name = "Active User",

View File

@@ -7589,6 +7589,7 @@ class AuthRepositoryTest {
identityUrl = "mockIdentityUrl",
notificationsUrl = "mockNotificationsUrl",
ssoUrl = "mockSsoUrl",
fillAssistRulesUrl = null,
),
featureStates = emptyMap(),
communication = null,

View File

@@ -95,11 +95,26 @@ class EnvironmentDiskSourceTest {
json.parseToJsonElement(requireNotNull(actual)),
)
}
@Test
fun `fillAssistRulesUrl should pull from and update SharedPreferences`() {
assertNull(environmentDiskSource.fillAssistRulesUrl)
assertNull(fakeSharedPreferences.getString(FILL_ASSIST_RULES_URL_KEY, null))
environmentDiskSource.fillAssistRulesUrl = "https://fill-assist.example.com/"
assertEquals(
"https://fill-assist.example.com/",
fakeSharedPreferences.getString(FILL_ASSIST_RULES_URL_KEY, null),
)
environmentDiskSource.fillAssistRulesUrl = null
assertNull(fakeSharedPreferences.getString(FILL_ASSIST_RULES_URL_KEY, null))
}
}
private const val EMAIL = "email@example.com"
private const val EMAIL_VERIFICATION_URLS_KEY = "bwPreferencesStorage:emailVerificationUrls"
private const val PRE_AUTH_URLS_KEY = "bwPreferencesStorage:preAuthEnvironmentUrls"
private const val FILL_ASSIST_RULES_URL_KEY = "bwPreferencesStorage:fillAssistRulesUrl"
private const val ENVIRONMENT_URL_DATA_JSON = """
{

View File

@@ -29,6 +29,8 @@ class FakeEnvironmentDiskSource : EnvironmentDiskSource {
storedEmailVerificationUrls[userEmail] = urls
}
override var fillAssistRulesUrl: String? = null
private val mutablePreAuthEnvironmentUrlDataFlow =
bufferedMutableSharedFlow<EnvironmentUrlDataJson?>(replay = 1)
}

View File

@@ -62,6 +62,7 @@ class ServerCommunicationConfigRepositoryTest {
identityUrl = null,
notificationsUrl = null,
ssoUrl = null,
fillAssistRulesUrl = null,
),
featureStates = null,
communication = ConfigResponseJson.CommunicationJson(

View File

@@ -319,6 +319,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"dummy-boolean" to JsonPrimitive(true),

View File

@@ -4,6 +4,7 @@ import com.bitwarden.data.repository.model.Environment
import com.x8bit.bitwarden.data.platform.datasource.disk.FakeEnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.provider.BaseUrlsProviderImpl
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class BaseUrlsProviderTest {
@@ -66,4 +67,16 @@ class BaseUrlsProviderTest {
baseUrlsManager.getBaseEventsUrl(),
)
}
@Test
fun `getBaseFillAssistUrl should return url from disk source when present`() {
fakeEnvironmentDiskSource.fillAssistRulesUrl = "https://example.com/"
assertEquals("https://example.com/", baseUrlsManager.getBaseFillAssistUrl())
}
@Test
fun `getBaseFillAssistUrl should return null when not set`() {
fakeEnvironmentDiskSource.fillAssistRulesUrl = null
assertNull(baseUrlsManager.getBaseFillAssistUrl())
}
}

View File

@@ -36,6 +36,7 @@ class SdkRepositoryFactoryTests {
override fun getBaseApiUrl(): String = BASE_API_URL
override fun getBaseIdentityUrl(): String = BASE_IDENTITY_URL
override fun getBaseEventsUrl(): String = BASE_EVENTS_URL
override fun getBaseFillAssistUrl(): String? = null
},
authTokenProvider = mockk(),
certificateProvider = mockk(),

View File

@@ -48,6 +48,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),

View File

@@ -1,68 +0,0 @@
package com.x8bit.bitwarden.ui.platform.feature.overlaynav
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.platform.base.createMockNavHostController
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure.AccessibilityDisclosureRoute
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.CookieAcquisitionRoute
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.LocalNetworkAccessRoute
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Test
class OverlayNavScreenTest : BitwardenComposeTest() {
private val mockNavHostController = createMockNavHostController()
private val mutableEventFlow = bufferedMutableSharedFlow<OverlayNavEvent>()
private val viewModel = mockk<OverlayNavViewModel> {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns MutableStateFlow(Unit)
}
@Before
fun setup() {
setContent {
OverlayNavScreen(
viewModel = viewModel,
navController = mockNavHostController,
onSplashScreenRemoved = {},
)
}
}
@Test
fun `on NavigateToCookieAcquisition should navigate to the cookie acquisition screen`() {
mutableEventFlow.tryEmit(OverlayNavEvent.NavigateToCookieAcquisition)
composeTestRule.runOnIdle {
verify(exactly = 1) {
mockNavHostController.navigate(route = CookieAcquisitionRoute, builder = any())
}
}
}
@Test
fun `on NavigateToLocalNetworkAccess should navigate to the local network access screen`() {
mutableEventFlow.tryEmit(OverlayNavEvent.NavigateToLocalNetworkAccess)
composeTestRule.runOnIdle {
verify(exactly = 1) {
mockNavHostController.navigate(route = LocalNetworkAccessRoute, builder = any())
}
}
}
@Suppress("MaxLineLength")
@Test
fun `on NavigateToAccessibilityDisclosure should navigate to the accessibility disclosure screen`() {
mutableEventFlow.tryEmit(OverlayNavEvent.NavigateToAccessibilityDisclosure)
composeTestRule.runOnIdle {
verify(exactly = 1) {
mockNavHostController.navigate(
route = AccessibilityDisclosureRoute,
builder = any(),
)
}
}
}
}

View File

@@ -1,71 +0,0 @@
package com.x8bit.bitwarden.ui.platform.feature.overlaynav
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.model.CookieAcquisitionRequest
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class OverlayNavViewModelTest : BaseViewModelTest() {
private val mutableAccessibilityDisclaimerFlow = MutableStateFlow(true)
private val settingsRepository: SettingsRepository = mockk {
every { hasShownAccessibilityDisclaimerFlow } returns mutableAccessibilityDisclaimerFlow
}
private val mutableLocalNetworkAccessFlow = MutableStateFlow(false)
private val networkPermissionManager: NetworkPermissionManager = mockk {
every { isLocalNetworkAccessRequiredStateFlow } returns mutableLocalNetworkAccessFlow
}
private val mutableCookieAcquisitionFlow = MutableStateFlow<CookieAcquisitionRequest?>(null)
private val cookieAcquisitionRequestManager: CookieAcquisitionRequestManager = mockk {
every { cookieAcquisitionRequestFlow } returns mutableCookieAcquisitionFlow
}
@Suppress("MaxLineLength")
@Test
fun `when accessibility disclaimer flow is false should emit NavigateToAccessibilityDisclosure`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
expectNoEvents()
mutableAccessibilityDisclaimerFlow.value = false
assertEquals(OverlayNavEvent.NavigateToAccessibilityDisclosure, awaitItem())
}
}
@Test
fun `when local network access flow is true should emit NavigateToLocalNetworkAccess`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
expectNoEvents()
mutableLocalNetworkAccessFlow.value = true
assertEquals(OverlayNavEvent.NavigateToLocalNetworkAccess, awaitItem())
}
}
@Test
fun `when a cookie acquisition request is ready should emit NavigateToCookieAcquisition`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
expectNoEvents()
mutableCookieAcquisitionFlow.value = CookieAcquisitionRequest(
hostname = "vault.bitwarden.com",
)
assertEquals(OverlayNavEvent.NavigateToCookieAcquisition, awaitItem())
}
}
private fun createViewModel(): OverlayNavViewModel = OverlayNavViewModel(
cookieAcquisitionRequestManager = cookieAcquisitionRequestManager,
networkPermissionManager = networkPermissionManager,
settingsRepository = settingsRepository,
)
}

View File

@@ -1571,7 +1571,7 @@ class AccountSecurityScreenTest : BitwardenComposeTest() {
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "fingerprint-placeholder", substring = true)
.onNodeWithText("fingerprint-placeholder")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}

View File

@@ -20,4 +20,6 @@ object BaseUrlsProviderImpl : BaseUrlsProvider {
override fun getBaseEventsUrl(): String =
Environment.Us.environmentUrlData.baseEventsUrl
override fun getBaseFillAssistUrl(): String? = null
}

View File

@@ -262,6 +262,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"dummy-boolean" to JsonPrimitive(true),

View File

@@ -48,6 +48,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),

View File

@@ -107,6 +107,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),

View File

@@ -161,6 +161,7 @@ private val SERVER_CONFIG = ServerConfig(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),
@@ -185,6 +186,7 @@ private val CONFIG_RESPONSE_JSON = ConfigResponseJson(
identityUrl = "http://localhost:33656",
notificationsUrl = "http://localhost:61840",
ssoUrl = "http://localhost:51822",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"duo-redirect" to JsonPrimitive(true),

View File

@@ -16,6 +16,7 @@ import com.bitwarden.network.service.DevicesService
import com.bitwarden.network.service.DigitalAssetLinkService
import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.FillAssistService
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.HaveIBeenPwnedService
import com.bitwarden.network.service.IdentityService
@@ -107,6 +108,11 @@ interface BitwardenServiceClient {
*/
val eventService: EventService
/**
* Provides access to the Fill-Assist service.
*/
val fillAssistService: FillAssistService
/**
* Provides access to the Folder service.
*/

View File

@@ -29,6 +29,8 @@ import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.service.DownloadServiceImpl
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.EventServiceImpl
import com.bitwarden.network.service.FillAssistService
import com.bitwarden.network.service.FillAssistServiceImpl
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.FolderServiceImpl
import com.bitwarden.network.service.HaveIBeenPwnedService
@@ -121,7 +123,7 @@ internal class BitwardenServiceClientImpl(
override val configService: ConfigService by lazy {
ConfigServiceImpl(
configApi = retrofits.authenticatedApiRetrofit.create(),
configApi = retrofits.unauthenticatedApiRetrofit.create(),
)
}
@@ -155,6 +157,10 @@ internal class BitwardenServiceClientImpl(
)
}
override val fillAssistService: FillAssistService by lazy {
FillAssistServiceImpl(api = retrofits.fillAssistRetrofit.create())
}
override val haveIBeenPwnedService: HaveIBeenPwnedService by lazy {
HaveIBeenPwnedServiceImpl(
api = retrofits

View File

@@ -0,0 +1,27 @@
package com.bitwarden.network.api
import com.bitwarden.network.model.FillAssistFormsJson
import com.bitwarden.network.model.FillAssistManifestJson
import com.bitwarden.network.model.NetworkResult
import retrofit2.http.GET
import retrofit2.http.Path
/**
* Defines endpoints for retrieving fill-assist targeting rules. The base URL is set dynamically
* at runtime via [com.bitwarden.network.interceptor.BaseUrlInterceptors.fillAssistInterceptor].
*/
internal interface FillAssistApi {
/**
* Fetches the fill-assist manifest.
*/
@GET("manifest.json")
suspend fun getManifest(): NetworkResult<FillAssistManifestJson>
/**
* Fetches the forms rules file by [filename] (e.g. "forms.v1.json").
*/
@GET("{filename}")
suspend fun getForms(
@Path("filename") filename: String,
): NetworkResult<FillAssistFormsJson>
}

View File

@@ -19,6 +19,7 @@ import java.time.Clock
import java.time.Instant
import java.time.temporal.ChronoUnit
private const val MISSING_TOKEN_MESSAGE: String = "Auth token is missing!"
private const val MISSING_PROVIDER_MESSAGE: String = "Refresh token provider is missing!"
private const val EXPIRATION_OFFSET_MINUTES: Long = 5L
@@ -82,10 +83,8 @@ internal class AuthTokenManager(
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val token = getAccessToken() ?: run {
Timber.w("Auth token is missing! Proceeding without token.")
return chain.proceed(chain.request())
}
val token = getAccessToken()
?: throw IOException(IllegalStateException(MISSING_TOKEN_MESSAGE))
val request = chain
.request()
.newBuilder()

View File

@@ -29,4 +29,11 @@ internal class BaseUrlInterceptors(
val eventsInterceptor: BaseUrlInterceptor = BaseUrlInterceptor {
baseUrlsProvider.getBaseEventsUrl()
}
/**
* An interceptor for fill-assist calls.
*/
val fillAssistInterceptor: BaseUrlInterceptor = BaseUrlInterceptor {
baseUrlsProvider.getBaseFillAssistUrl()
}
}

View File

@@ -18,4 +18,10 @@ interface BaseUrlsProvider {
* Gets the base URL for "/events" calls.
*/
fun getBaseEventsUrl(): String
/**
* Gets the base URL for fill-assist calls, or null if the server does not provide
* fill-assist targeting rules.
*/
fun getBaseFillAssistUrl(): String?
}

View File

@@ -62,6 +62,8 @@ data class ConfigResponseJson(
* @param identityUrl The URL of the identity service in the environment.
* @param notificationsUrl The URL of the notifications service in the environment.
* @param ssoUrl The URL of the single sign-on (SSO) service in the environment.
* @param fillAssistRulesUrl The base URL of the fill-assist targeting rules, or null if
* the server does not provide fill-assist rules.
*/
@Serializable
data class EnvironmentJson(
@@ -82,6 +84,9 @@ data class ConfigResponseJson(
@SerialName("sso")
val ssoUrl: String?,
@SerialName("fillAssistRules")
val fillAssistRulesUrl: String?,
)
/**

View File

@@ -0,0 +1,68 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Represents the fill-assist forms rules file.
*
* @property schemaVersion The semantic version string for this file (e.g. "1.0.0").
* @property hosts Map of hostname (optionally with port) to [HostEntryJson], or null if the host
* is explicitly excluded from fill-assist.
*/
@Serializable
data class FillAssistFormsJson(
@SerialName("schemaVersion")
val schemaVersion: String,
@SerialName("hosts")
val hosts: Map<String, HostEntryJson?>,
) {
/**
* Form descriptions and pathname-specific overrides for a single host.
*
* @property forms Site-wide fallback form descriptions.
* @property pathnames Pathname-specific overrides; a null value means that path is excluded.
*/
@Serializable
data class HostEntryJson(
@SerialName("forms")
val forms: List<FormJson>?,
@SerialName("pathnames")
val pathnames: Map<String, PathnameEntryJson?>?,
)
/**
* Form descriptions for a specific pathname.
*
* @property forms The form descriptions for this path.
*/
@Serializable
data class PathnameEntryJson(
@SerialName("forms")
val forms: List<FormJson>,
)
/**
* Describes one logical form on a page.
*
* @property category The categorical purpose of this form (e.g. "account-login").
* @property container Optional CSS selectors identifying the form's container element.
* @property fields Map of field key to [JsonElement] representing a compositeSelectorArray.
* Each array element is either a CSS selector string or an array of strings for composite
* multi-input fields. Unknown fields are gracefully ignored via [ignoreUnknownKeys].
*/
@Serializable
data class FormJson(
@SerialName("category")
val category: String,
@SerialName("container")
val container: List<String>?,
@SerialName("fields")
val fields: Map<String, JsonElement>,
)
}

View File

@@ -0,0 +1,64 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the fill-assist manifest returned by the fill-assist service.
*
* @property buildId The unique identifier for this build.
* @property timestamp The ISO-8601 timestamp when this build was produced.
* @property gitSha The git commit SHA for this build.
* @property maps The map data entries keyed by map type.
*/
@Serializable
data class FillAssistManifestJson(
@SerialName("buildId")
val buildId: String,
@SerialName("timestamp")
val timestamp: String,
@SerialName("gitSha")
val gitSha: String,
@SerialName("maps")
val maps: MapsJson,
) {
/**
* Container for all available maps.
*
* @property forms Map of schema version string (e.g. "v1", "v2") to [FileEntryJson].
* Using a [Map] allows new versions to appear automatically without model changes.
*/
@Serializable
data class MapsJson(
@SerialName("forms")
val forms: Map<String, FileEntryJson>,
)
/**
* Metadata for a single versioned file in a map.
*
* @property filename The filename to fetch (e.g. "forms.v1.json").
* @property cid The SHA-256 content hash in "sha256:<hex>" format. Used as a staleness key
* to detect when the forms file has changed on the server, avoiding unnecessary re-downloads.
* @property schema The schema filename associated with this file version.
* @property deprecated When true, this version has entered its end-of-life support window.
* Consumers should plan migration but may continue using the version until it is removed.
*/
@Serializable
data class FileEntryJson(
@SerialName("filename")
val filename: String,
@SerialName("cid")
val cid: String,
@SerialName("schema")
val schema: String,
@SerialName("deprecated")
val deprecated: Boolean?,
)
}

View File

@@ -36,6 +36,12 @@ internal interface Retrofits {
*/
val unauthenticatedIdentityRetrofit: Retrofit
/**
* Allows access to fill-assist calls. The base URL is determined dynamically via the
* [BaseUrlInterceptors.fillAssistInterceptor].
*/
val fillAssistRetrofit: Retrofit
/**
* Allows access to static API calls (ex: external APIs).
*

View File

@@ -64,6 +64,16 @@ internal class RetrofitsImpl(
//endregion Unauthenticated Retrofits
//region Fill-Assist Retrofit
override val fillAssistRetrofit: Retrofit by lazy {
createUnauthenticatedRetrofit(
baseUrlInterceptor = baseUrlInterceptors.fillAssistInterceptor,
)
}
//endregion Fill-Assist Retrofit
//region Static Retrofit
override fun createStaticRetrofit(isAuthenticated: Boolean, baseUrl: String): Retrofit {

View File

@@ -0,0 +1,21 @@
package com.bitwarden.network.service
import com.bitwarden.network.model.FillAssistFormsJson
import com.bitwarden.network.model.FillAssistManifestJson
/**
* Provides access to the fill-assist targeting rules service.
*/
interface FillAssistService {
/**
* Fetches and parses the fill-assist manifest.
*/
suspend fun getManifest(): Result<FillAssistManifestJson>
/**
* Downloads and parses the forms rules file identified by [filename] (e.g. "forms.v1.json").
*
* Returns [Result.failure] if the network request fails or parsing fails.
*/
suspend fun getForms(filename: String): Result<FillAssistFormsJson>
}

View File

@@ -0,0 +1,20 @@
package com.bitwarden.network.service
import com.bitwarden.network.api.FillAssistApi
import com.bitwarden.network.model.FillAssistFormsJson
import com.bitwarden.network.model.FillAssistManifestJson
import com.bitwarden.network.util.toResult
/**
* Default implementation of [FillAssistService].
*/
internal class FillAssistServiceImpl(
private val api: FillAssistApi,
) : FillAssistService {
override suspend fun getManifest(): Result<FillAssistManifestJson> =
api.getManifest().toResult()
override suspend fun getForms(filename: String): Result<FillAssistFormsJson> =
api.getForms(filename = filename).toResult()
}

View File

@@ -270,19 +270,16 @@ class AuthTokenManagerTest {
}
@Test
fun `intercept should proceed without token when an auth token data is missing`() {
val token = "token"
authTokenManager.refreshTokenProvider = object : RefreshTokenProvider {
override fun refreshAccessTokenSynchronously(
userId: String,
): Result<String> = token.asSuccess()
fun `intercept should throw an exception when an auth token data is missing`() {
val throwable = assertThrows(IOException::class.java) {
authTokenManager.intercept(
chain = FakeInterceptorChain(request = request),
)
}
every { mockAuthTokenProvider.getAuthTokenDataOrNull() } returns null
val response = authTokenManager.intercept(
chain = FakeInterceptorChain(request = request),
assertEquals(
"Auth token is missing!",
throwable.cause?.message,
)
assertNull(response.request.header("Authorization"))
}
}
}

View File

@@ -68,6 +68,7 @@ private val CONFIG_RESPONSE = ConfigResponseJson(
notificationsUrl = "notificationsUrl",
identityUrl = "identityUrl",
ssoUrl = "ssoUrl",
fillAssistRulesUrl = null,
),
featureStates = mapOf(
"feature one" to JsonPrimitive(false),

View File

@@ -0,0 +1,116 @@
package com.bitwarden.network.service
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.network.api.FillAssistApi
import com.bitwarden.network.base.BaseServiceTest
import com.bitwarden.network.model.FillAssistFormsJson
import com.bitwarden.network.model.FillAssistManifestJson
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import retrofit2.create
class FillAssistServiceTest : BaseServiceTest() {
private val api: FillAssistApi = retrofit.create()
private val service = FillAssistServiceImpl(api = api)
@Test
fun `getManifest should parse manifest response`() = runTest {
server.enqueue(MockResponse().setBody(MANIFEST_JSON))
assertEquals(MANIFEST.asSuccess(), service.getManifest())
}
@Test
fun `getManifest should return failure on server error`() = runTest {
server.enqueue(MockResponse().setResponseCode(500))
assertTrue(service.getManifest().isFailure)
}
@Test
fun `getForms should parse and return forms`() = runTest {
server.enqueue(MockResponse().setBody(FORMS_V1_JSON))
assertEquals(FORMS_V1.asSuccess(), service.getForms(filename = "forms.v1.json"))
}
@Test
fun `getForms should return failure on server error`() = runTest {
server.enqueue(MockResponse().setResponseCode(404))
assertTrue(service.getForms(filename = "forms.v1.json").isFailure)
}
}
private val MANIFEST = FillAssistManifestJson(
buildId = "local-build",
timestamp = "2026-05-20T15:01:02.956Z",
gitSha = "abc123",
maps = FillAssistManifestJson.MapsJson(
forms = mapOf(
"v1" to FillAssistManifestJson.FileEntryJson(
filename = "forms.v1.json",
cid = "sha256:5b8f688d24bb9c38b4094838fa2baacb3cc4ab302e3545adf016b05f6b6b96db",
schema = "forms.v1.schema.json",
deprecated = null,
),
),
),
)
private val FORMS_V1 = FillAssistFormsJson(
schemaVersion = "1.0.0",
hosts = mapOf(
"example.com" to FillAssistFormsJson.HostEntryJson(
forms = listOf(
FillAssistFormsJson.FormJson(
category = "account-login",
container = null,
fields = mapOf(
"username" to JsonArray(listOf(JsonPrimitive("input#user"))),
"password" to JsonArray(listOf(JsonPrimitive("input#pass"))),
),
),
),
pathnames = null,
),
),
)
private const val MANIFEST_JSON = """
{
"buildId": "local-build",
"timestamp": "2026-05-20T15:01:02.956Z",
"gitSha": "abc123",
"maps": {
"forms": {
"v1": {
"filename": "forms.v1.json",
"cid": "sha256:5b8f688d24bb9c38b4094838fa2baacb3cc4ab302e3545adf016b05f6b6b96db",
"schema": "forms.v1.schema.json"
}
}
}
}
"""
private const val FORMS_V1_JSON = """
{
"schemaVersion": "1.0.0",
"hosts": {
"example.com": {
"forms": [
{
"category": "account-login",
"fields": {
"username": ["input#user"],
"password": ["input#pass"]
}
}
]
}
}
}
"""

View File

@@ -31,7 +31,6 @@ fun createMockNavHostController(): NavHostController =
every { setViewModelStore(viewModelStore = any()) } just runs
every { setLifecycleOwner(owner = any()) } just runs
every { navigate(route = any<Any>(), navOptions = any()) } just runs
every { navigate(route = any<Any>(), builder = any()) } just runs
}
/**