mirror of
https://github.com/bitwarden/android.git
synced 2026-06-12 14:07:54 -05:00
Compare commits
7 Commits
optional-a
...
PM-37255/f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6f0a47d77 | ||
|
|
31a9cf7c34 | ||
|
|
b25404e112 | ||
|
|
0b39ad2731 | ||
|
|
f66485facd | ||
|
|
f57a7d09a2 | ||
|
|
c6463722f2 |
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -31,4 +31,6 @@ class BaseUrlsProviderImpl(
|
||||
.toEnvironmentUrlsOrDefault()
|
||||
.environmentUrlData
|
||||
.baseEventsUrl
|
||||
|
||||
override fun getBaseFillAssistUrl(): String? = environmentDiskSource.fillAssistRulesUrl
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -255,7 +255,7 @@ private fun PrivilegedAppsListContent(
|
||||
) {
|
||||
BitwardenStandardIconButton(
|
||||
vectorIconRes = BitwardenDrawable.ic_delete,
|
||||
contentDescription = stringResource(id = BitwardenString.delete),
|
||||
contentDescription = "",
|
||||
onClick = remember(item) {
|
||||
{ onDeleteClick(item) }
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7589,6 +7589,7 @@ class AuthRepositoryTest {
|
||||
identityUrl = "mockIdentityUrl",
|
||||
notificationsUrl = "mockNotificationsUrl",
|
||||
ssoUrl = "mockSsoUrl",
|
||||
fillAssistRulesUrl = null,
|
||||
),
|
||||
featureStates = emptyMap(),
|
||||
communication = null,
|
||||
|
||||
@@ -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 = """
|
||||
{
|
||||
|
||||
@@ -29,6 +29,8 @@ class FakeEnvironmentDiskSource : EnvironmentDiskSource {
|
||||
storedEmailVerificationUrls[userEmail] = urls
|
||||
}
|
||||
|
||||
override var fillAssistRulesUrl: String? = null
|
||||
|
||||
private val mutablePreAuthEnvironmentUrlDataFlow =
|
||||
bufferedMutableSharedFlow<EnvironmentUrlDataJson?>(replay = 1)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ class ServerCommunicationConfigRepositoryTest {
|
||||
identityUrl = null,
|
||||
notificationsUrl = null,
|
||||
ssoUrl = null,
|
||||
fillAssistRulesUrl = null,
|
||||
),
|
||||
featureStates = null,
|
||||
communication = ConfigResponseJson.CommunicationJson(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -20,4 +20,6 @@ object BaseUrlsProviderImpl : BaseUrlsProvider {
|
||||
|
||||
override fun getBaseEventsUrl(): String =
|
||||
Environment.Us.environmentUrlData.baseEventsUrl
|
||||
|
||||
override fun getBaseFillAssistUrl(): String? = null
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
}
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ private val CONFIG_RESPONSE = ConfigResponseJson(
|
||||
notificationsUrl = "notificationsUrl",
|
||||
identityUrl = "identityUrl",
|
||||
ssoUrl = "ssoUrl",
|
||||
fillAssistRulesUrl = null,
|
||||
),
|
||||
featureStates = mapOf(
|
||||
"feature one" to JsonPrimitive(false),
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user