Compare commits

..

9 Commits

Author SHA1 Message Date
bw-ghapp[bot]
c370f5cc88 SDK Update - com.bitwarden:sdk-android 3.0.0-7395-1e454441 2026-06-12 17:25:54 +00:00
bw-ghapp[bot]
bd69b2acdb SDK Update - com.bitwarden:sdk-android 3.0.0-7394-17ed3986 2026-06-12 15:57:20 +00:00
bw-ghapp[bot]
de80c09240 SDK Update - com.bitwarden:sdk-android 3.0.0-7376-5006c30d 2026-06-11 17:18:13 +00:00
bw-ghapp[bot]
3a125605a4 SDK Update - com.bitwarden:sdk-android 3.0.0-7375-11e2105f 2026-06-11 16:26:49 +00:00
bw-ghapp[bot]
cac951d6c2 SDK Update - com.bitwarden:sdk-android 3.0.0-7370-e3dc2934 2026-06-11 10:56:11 +00:00
bw-ghapp[bot]
4516ae9eac SDK Update - com.bitwarden:sdk-android 3.0.0-7361-4bb8873e 2026-06-11 09:11:00 +00:00
bw-ghapp[bot]
cada80b4b8 SDK Update - com.bitwarden:sdk-android 3.0.0-7360-56536238 2026-06-11 04:17:24 +00:00
bw-ghapp[bot]
b7a2691804 SDK Update - com.bitwarden:sdk-android 3.0.0-7351-75d69bfb 2026-06-10 14:32:19 +00:00
bw-ghapp[bot]
2c2c534bcd SDK Update - com.bitwarden:sdk-android 3.0.0-7350-9eb8c6eb 2026-06-10 14:24:30 +00:00
27 changed files with 588 additions and 841 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

@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.core.AuthRequestMethod
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.MasterPasswordUnlockData
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
@@ -1146,10 +1145,10 @@ class AuthRepositoryImpl(
organizationIdentifier: String,
password: String,
passwordHint: String?,
): SetPasswordResult = userStateManager.userStateTransaction {
): SetPasswordResult {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return@userStateTransaction SetPasswordResult.Error(error = NoActiveUserException())
return@userStateTransaction when (profile.forcePasswordResetReason) {
?: return SetPasswordResult.Error(error = NoActiveUserException())
return when (profile.forcePasswordResetReason) {
ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION -> {
setUpdatedPassword(
profile = profile,
@@ -1197,25 +1196,30 @@ class AuthRepositoryImpl(
keys = null,
),
)
.map { response }
.map { response.passwordHash }
}
.onSuccess { response ->
.flatMap { masterPasswordHash ->
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
is VaultUnlockResult.Success -> {
enrollUserInPasswordReset(
userId = userId,
organizationIdentifier = organizationIdentifier,
passwordHash = masterPasswordHash,
)
}
is VaultUnlockError -> {
(result.error ?: IllegalStateException("Failed to unlock vault"))
.asFailure()
}
}
}
.onSuccess {
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
masterPasswordUnlock = MasterPasswordUnlockData(
kdf = profile.toSdkParams(),
masterKeyWrappedUserKey = response.newKey,
salt = profile.email,
),
masterPasswordUnlock = null,
)
this.organizationIdentifier = null
}
.flatMap { response ->
enrollUserInPasswordReset(
userId = userId,
organizationIdentifier = organizationIdentifier,
passwordHash = response.passwordHash,
)
}
.fold(
onFailure = { SetPasswordResult.Error(error = it) },
onSuccess = { SetPasswordResult.Success },
@@ -1322,26 +1326,16 @@ class AuthRepositoryImpl(
privateKey = response.keys.private,
),
)
authDiskSource.userState = authDiskSource
.userState
?.toUserStateJsonWithPassword(
masterPasswordUnlock = MasterPasswordUnlockData(
kdf = profile.toSdkParams(),
masterKeyWrappedUserKey = response.encryptedUserKey,
salt = profile.email,
),
)
this.organizationIdentifier = null
}
.map { response }
.map { response.masterPasswordHash }
}
.flatMap { response ->
.flatMap { masterPasswordHash ->
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
is VaultUnlockResult.Success -> {
enrollUserInPasswordReset(
userId = userId,
organizationIdentifier = organizationIdentifier,
passwordHash = response.masterPasswordHash,
passwordHash = masterPasswordHash,
)
}
@@ -1351,6 +1345,12 @@ class AuthRepositoryImpl(
}
}
}
.onSuccess {
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
masterPasswordUnlock = null,
)
this.organizationIdentifier = null
}
.fold(
onFailure = { SetPasswordResult.Error(error = it) },
onSuccess = { SetPasswordResult.Success },

View File

@@ -10,7 +10,6 @@ import com.bitwarden.network.model.UserDecryptionOptionsJson
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.ui.platform.base.util.toHexColorRepresentation
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfRequestModel
@@ -85,17 +84,10 @@ fun UserStateJson.toUpdatedUserStateJson(
}
?: profile
.userDecryptionOptions
?.copy(
hasMasterPassword = false,
masterPasswordUnlock = null,
)
val forcePasswordResetReason = syncProfile.getForcePasswordResetReason(
userDecryptionOptions = userDecryptionOptions,
previousForcePasswordResetReason = profile.forcePasswordResetReason,
)
?.copy(masterPasswordUnlock = null)
val updatedProfile = profile
.copy(
forcePasswordResetReason = forcePasswordResetReason,
avatarColorHex = syncProfile.avatarColor,
stamp = syncProfile.securityStamp,
hasPremiumPersonally = syncProfile.isPremium,
@@ -103,31 +95,24 @@ fun UserStateJson.toUpdatedUserStateJson(
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
creationDate = syncProfile.creationDate,
userDecryptionOptions = userDecryptionOptions,
kdfType = masterPasswordUnlockKdf?.kdfType ?: profile.kdfType,
kdfIterations = masterPasswordUnlockKdf?.iterations ?: profile.kdfIterations,
kdfMemory = masterPasswordUnlockKdf?.memory ?: profile.kdfMemory,
kdfParallelism = masterPasswordUnlockKdf?.parallelism ?: profile.kdfParallelism,
kdfType = masterPasswordUnlockKdf?.kdfType
?: profile.kdfType,
kdfIterations = masterPasswordUnlockKdf?.iterations
?: profile.kdfIterations,
kdfMemory = masterPasswordUnlockKdf?.memory
?: profile.kdfMemory,
kdfParallelism = masterPasswordUnlockKdf?.parallelism
?: profile.kdfParallelism,
)
val updatedAccount = account.copy(profile = updatedProfile)
return this.copy(accounts = accounts.toMutableMap().apply { replace(userId, updatedAccount) })
}
private fun SyncResponseJson.Profile.getForcePasswordResetReason(
userDecryptionOptions: UserDecryptionOptionsJson?,
previousForcePasswordResetReason: ForcePasswordResetReason?,
): ForcePasswordResetReason? {
val hasManageResetPasswordPermission = this.organizations.orEmpty().any {
it.type == OrganizationType.OWNER ||
it.type == OrganizationType.ADMIN ||
it.permissions.shouldManageResetPassword
}
return ForcePasswordResetReason
.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION
.takeIf {
userDecryptionOptions?.hasMasterPassword == false &&
hasManageResetPasswordPermission
}
?: previousForcePasswordResetReason
return this
.copy(
accounts = accounts
.toMutableMap()
.apply {
replace(userId, updatedAccount)
},
)
}
/**
@@ -135,16 +120,20 @@ private fun SyncResponseJson.Profile.getForcePasswordResetReason(
* their password.
*/
fun UserStateJson.toUserStateJsonWithPassword(
masterPasswordUnlock: MasterPasswordUnlockData,
masterPasswordUnlock: MasterPasswordUnlockData?,
): UserStateJson {
val account = this.activeAccount
val profile = account.profile
val userDecryptionOptions = profile.userDecryptionOptions
val masterPasswordUnlockJson = MasterPasswordUnlockDataJson(
salt = masterPasswordUnlock.salt,
kdf = masterPasswordUnlock.kdf.toKdfRequestModel(),
masterKeyWrappedUserKey = masterPasswordUnlock.masterKeyWrappedUserKey,
)
val masterPasswordUnlockJson = masterPasswordUnlock
?.let {
MasterPasswordUnlockDataJson(
salt = it.salt,
kdf = it.kdf.toKdfRequestModel(),
masterKeyWrappedUserKey = it.masterKeyWrappedUserKey,
)
}
?: userDecryptionOptions?.masterPasswordUnlock
val updatedProfile = profile
.copy(
forcePasswordResetReason = null,

View File

@@ -6,12 +6,8 @@ import com.bitwarden.network.BitwardenServiceClient
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
private const val ENVIRONMENT_DEBOUNCE_TIMEOUT_MS: Long = 500L
@@ -30,11 +26,9 @@ class NetworkConfigManagerImpl(
private val collectionScope = CoroutineScope(dispatcherManager.unconfined)
init {
@OptIn(FlowPreview::class)
combine(
environmentRepository.environmentStateFlow,
authRepository.userStateFlow.map { it?.activeUserId }.distinctUntilChanged(),
) { _, _ -> }
@Suppress("OPT_IN_USAGE")
environmentRepository
.environmentStateFlow
.debounce(timeoutMillis = ENVIRONMENT_DEBOUNCE_TIMEOUT_MS)
.onEach { _ ->
// This updates the stored service configuration by performing a network request.

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

@@ -5795,6 +5795,9 @@ class AuthRepositoryTest {
userId = profile.userId,
)
} returns resetPasswordKey.asSuccess()
coEvery {
vaultRepository.unlockVaultWithMasterPassword(password)
} returns VaultUnlockResult.Success
val result = repository.setPassword(
organizationIdentifier = organizationIdentifier,
@@ -5819,6 +5822,7 @@ class AuthRepositoryTest {
passwordHash = passwordHash,
resetPasswordKey = resetPasswordKey,
)
vaultRepository.unlockVaultWithMasterPassword(password)
vaultSdkSource.getResetPasswordKey(
orgPublicKey = publicOrgKey,
userId = profile.userId,
@@ -7477,7 +7481,7 @@ class AuthRepositoryTest {
masterPasswordUnlock = MasterPasswordUnlockDataJson(
kdf = BASE_PROFILE_1.toSdkParams().toKdfRequestModel(),
masterKeyWrappedUserKey = ENCRYPTED_USER_KEY,
salt = EMAIL,
salt = "mockSalt",
),
),
)
@@ -7529,7 +7533,7 @@ class AuthRepositoryTest {
masterPasswordUnlock = MasterPasswordUnlockDataJson(
kdf = BASE_PROFILE_1.toSdkParams().toKdfRequestModel(),
masterKeyWrappedUserKey = ENCRYPTED_USER_KEY,
salt = EMAIL,
salt = "mockSalt",
),
),
),

View File

@@ -9,13 +9,10 @@ import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.KeyConnectorUserDecryptionOptionsJson
import com.bitwarden.network.model.MasterPasswordUnlockDataJson
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.bitwarden.network.model.UserDecryptionJson
import com.bitwarden.network.model.UserDecryptionOptionsJson
import com.bitwarden.network.model.createMockOrganizationNetwork
import com.bitwarden.network.model.createMockPermissions
import com.bitwarden.network.model.createMockProfile
import com.bitwarden.network.model.createMockSyncResponse
import com.bitwarden.policies.PolicyType
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
@@ -237,36 +234,36 @@ class UserStateJsonExtensionsTest {
)
val orgOnlyResult = originalState.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "color",
securityStamp = "stamp",
isPremium = false,
isPremiumFromOrganization = true,
),
userDecryption = null,
),
syncResponse = mockk {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "color"
every { securityStamp } returns "stamp"
every { isPremium } returns false
every { isPremiumFromOrganization } returns true
every { isTwoFactorEnabled } returns false
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
every { userDecryption } returns null
}
},
)
val orgOnlyProfile = orgOnlyResult.accounts.getValue("activeUserId").profile
assertEquals(false, orgOnlyProfile.hasPremiumPersonally)
assertEquals(true, orgOnlyProfile.hasPremiumFromOrganization)
val personalOnlyResult = originalState.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "color",
securityStamp = "stamp",
isPremium = true,
isPremiumFromOrganization = false,
),
userDecryption = null,
),
syncResponse = mockk {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "color"
every { securityStamp } returns "stamp"
every { isPremium } returns true
every { isPremiumFromOrganization } returns false
every { isTwoFactorEnabled } returns false
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
every { userDecryption } returns null
}
},
)
val personalOnlyProfile = personalOnlyResult.accounts.getValue("activeUserId").profile
assertEquals(true, personalOnlyProfile.hasPremiumPersonally)
@@ -401,20 +398,74 @@ class UserStateJsonExtensionsTest {
),
)
.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "avatarColor",
securityStamp = "securityStamp",
isPremium = true,
isPremiumFromOrganization = true,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
syncResponse = mockk {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "avatarColor"
every { securityStamp } returns "securityStamp"
every { isPremium } returns true
every { isPremiumFromOrganization } returns true
every { isTwoFactorEnabled } returns false
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
every { userDecryption } returns null
}
},
),
)
}
@Suppress("MaxLineLength")
@Test
fun `toUserStateJsonWithPassword should update active account to set hasMasterPassword and clear forcePasswordResetReason`() {
val originalProfile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremiumPersonally = true,
hasPremiumFromOrganization = null,
forcePasswordResetReason = ForcePasswordResetReason
.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = null,
isTwoFactorEnabled = false,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
)
val originalAccount = AccountJson(
profile = originalProfile,
tokens = mockk(),
settings = mockk(),
)
assertEquals(
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount.copy(
profile = originalProfile.copy(
forcePasswordResetReason = null,
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = null,
trustedDeviceUserDecryptionOptions = null,
masterPasswordUnlock = null,
),
),
userDecryption = null,
),
),
),
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount,
),
)
.toUserStateJsonWithPassword(masterPasswordUnlock = null),
)
}
@@ -485,6 +536,71 @@ class UserStateJsonExtensionsTest {
)
}
@Test
fun `toUserStateJsonWithPassword should preserve values of userDecryptionOptions`() {
val keyConnectorOptionsJson = KeyConnectorUserDecryptionOptionsJson("key")
val trustedDeviceOptionsJson = TrustedDeviceUserDecryptionOptionsJson(
encryptedPrivateKey = "encryptedPrivateKey",
encryptedUserKey = "encryptedUserKey",
hasAdminApproval = true,
hasLoginApprovingDevice = true,
hasManageResetPasswordPermission = true,
)
val originalProfile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremiumPersonally = true,
hasPremiumFromOrganization = null,
forcePasswordResetReason = null,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = keyConnectorOptionsJson,
trustedDeviceUserDecryptionOptions = trustedDeviceOptionsJson,
masterPasswordUnlock = null,
),
isTwoFactorEnabled = false,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
)
val originalAccount = AccountJson(
profile = originalProfile,
tokens = mockk(),
settings = mockk(),
)
assertEquals(
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount.copy(
profile = originalProfile.copy(
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = keyConnectorOptionsJson,
trustedDeviceUserDecryptionOptions = trustedDeviceOptionsJson,
masterPasswordUnlock = null,
),
),
),
),
),
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount,
),
)
.toUserStateJsonWithPassword(masterPasswordUnlock = null),
)
}
@Test
fun `toUserState should return the correct UserState for an unlocked vault`() {
val expectedCreationDate = Instant.parse("2024-06-15T10:30:00Z")
@@ -1877,22 +1993,20 @@ class UserStateJsonExtensionsTest {
accounts = mapOf("activeUserId" to originalAccount),
)
val syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "avatarColor",
securityStamp = "securityStamp",
isPremium = false,
isPremiumFromOrganization = false,
isTwoFactorEnabled = true,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
),
userDecryption = UserDecryptionJson(
val syncResponse = mockk<SyncResponseJson>(relaxed = true) {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "avatarColor"
every { securityStamp } returns "securityStamp"
every { isPremium } returns false
every { isPremiumFromOrganization } returns false
every { isTwoFactorEnabled } returns true
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
}
every { userDecryption } returns UserDecryptionJson(
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA,
),
)
)
}
assertEquals(
UserStateJson(
@@ -1968,22 +2082,20 @@ class UserStateJsonExtensionsTest {
accounts = mapOf("activeUserId" to originalAccount),
)
val syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "newAvatarColor",
securityStamp = "newSecurityStamp",
isPremium = true,
isPremiumFromOrganization = false,
isTwoFactorEnabled = true,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
),
userDecryption = UserDecryptionJson(
val syncResponse = mockk<SyncResponseJson> {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "newAvatarColor"
every { securityStamp } returns "newSecurityStamp"
every { isPremium } returns true
every { isPremiumFromOrganization } returns false
every { isTwoFactorEnabled } returns true
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
}
every { userDecryption } returns UserDecryptionJson(
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA,
),
)
)
}
assertEquals(
UserStateJson(
@@ -2017,7 +2129,7 @@ class UserStateJsonExtensionsTest {
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should clear hasMasterPassword and masterPasswordUnlock when syncResponse has no userDecryption`() {
fun `toUpdatedUserStateJson should update existing UserDecryptionOptionsJson when syncResponse has no userDecryption`() {
val keyConnectorOptions = KeyConnectorUserDecryptionOptionsJson("keyConnectorUrl")
val originalProfile = AccountJson.Profile(
userId = "activeUserId",
@@ -2053,20 +2165,18 @@ class UserStateJsonExtensionsTest {
accounts = mapOf("activeUserId" to originalAccount),
)
val syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "updatedAvatarColor",
securityStamp = "updatedSecurityStamp",
isPremium = false,
isPremiumFromOrganization = true,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
organizations = emptyList(),
),
userDecryption = null,
)
val syncResponse = mockk<SyncResponseJson> {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "updatedAvatarColor"
every { securityStamp } returns "updatedSecurityStamp"
every { isPremium } returns false
every { isPremiumFromOrganization } returns true
every { isTwoFactorEnabled } returns false
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
}
every { userDecryption } returns null
}
assertEquals(
UserStateJson(
@@ -2081,7 +2191,7 @@ class UserStateJsonExtensionsTest {
isTwoFactorEnabled = false,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = false,
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = keyConnectorOptions,
masterPasswordUnlock = null,
@@ -2126,28 +2236,31 @@ class UserStateJsonExtensionsTest {
accounts = mapOf("activeUserId" to originalAccount),
)
val syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = null,
securityStamp = null,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
),
userDecryption = UserDecryptionJson(
masterPasswordUnlock = MasterPasswordUnlockDataJson(
salt = "mockSalt",
kdf = KdfJson(
kdfType = KdfTypeJson.PBKDF2_SHA256,
iterations = DEFAULT_PBKDF2_ITERATIONS,
memory = null,
parallelism = null,
),
masterKeyWrappedUserKey = "mockMasterKeyWrappedUserKey",
),
),
)
val syncResponse = mockk<SyncResponseJson> {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns null
every { securityStamp } returns null
every { isPremium } returns false
every { isPremiumFromOrganization } returns false
every { isTwoFactorEnabled } returns false
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
}
val updatedKdf = KdfJson(
kdfType = KdfTypeJson.PBKDF2_SHA256,
iterations = DEFAULT_PBKDF2_ITERATIONS,
memory = null,
parallelism = null,
)
val updatedMasterPasswordUnlock = MasterPasswordUnlockDataJson(
salt = "mockSalt",
kdf = updatedKdf,
masterKeyWrappedUserKey = "mockMasterKeyWrappedUserKey",
)
every { userDecryption } returns UserDecryptionJson(
masterPasswordUnlock = updatedMasterPasswordUnlock,
)
}
assertEquals(
UserStateJson(
@@ -2180,137 +2293,6 @@ class UserStateJsonExtensionsTest {
)
}
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should set forcePasswordResetReason when user without master password is an organization admin`() {
val originalUserState = createUserStateWithDecryptionOptions(
userDecryptionOptions = TDE_USER_DECRYPTION_OPTIONS,
)
val result = originalUserState.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
organizations = listOf(
createMockOrganizationNetwork(
number = 1,
type = OrganizationType.ADMIN,
),
),
),
userDecryption = null,
),
)
assertEquals(
ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION,
result.accounts.getValue("activeUserId").profile.forcePasswordResetReason,
)
}
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should set forcePasswordResetReason when user without master password has reset password permission`() {
val originalUserState = createUserStateWithDecryptionOptions(
userDecryptionOptions = TDE_USER_DECRYPTION_OPTIONS,
)
val result = originalUserState.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
organizations = listOf(
createMockOrganizationNetwork(
number = 1,
type = OrganizationType.USER,
permissions = createMockPermissions(
shouldManageResetPassword = true,
),
),
),
),
userDecryption = UserDecryptionJson(masterPasswordUnlock = null),
),
)
assertEquals(
ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION,
result.accounts.getValue("activeUserId").profile.forcePasswordResetReason,
)
}
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should not set forcePasswordResetReason when user without master password lacks reset password permission`() {
val originalUserState = createUserStateWithDecryptionOptions(
userDecryptionOptions = TDE_USER_DECRYPTION_OPTIONS,
)
val result = originalUserState.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
organizations = listOf(
createMockOrganizationNetwork(
number = 1,
type = OrganizationType.USER,
),
),
),
userDecryption = null,
),
)
assertEquals(
null,
result.accounts.getValue("activeUserId").profile.forcePasswordResetReason,
)
}
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should preserve previous forcePasswordResetReason when user has a master password`() {
val originalUserState = createUserStateWithDecryptionOptions(
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
),
forcePasswordResetReason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
)
val result = originalUserState.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
organizations = listOf(
createMockOrganizationNetwork(
number = 1,
type = OrganizationType.OWNER,
),
),
),
userDecryption = UserDecryptionJson(
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA,
),
),
)
assertEquals(
ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
result.accounts.getValue("activeUserId").profile.forcePasswordResetReason,
)
}
@Test
fun `toUserStateJsonKdfUpdatedMinimums should update KDF settings to minimum values`() {
val originalProfile = AccountJson.Profile(
@@ -2512,53 +2494,3 @@ private val MOCK_MASTER_PASSWORD_UNLOCK_DATA = MasterPasswordUnlockDataJson(
),
masterKeyWrappedUserKey = "masterKeyWrappedUserKeyMock",
)
private val TDE_USER_DECRYPTION_OPTIONS = UserDecryptionOptionsJson(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = TrustedDeviceUserDecryptionOptionsJson(
encryptedPrivateKey = "encryptedPrivateKey",
encryptedUserKey = "encryptedUserKey",
hasAdminApproval = true,
hasLoginApprovingDevice = false,
hasManageResetPasswordPermission = false,
),
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
/**
* Creates a [UserStateJson] with a single "activeUserId" account using the given
* [userDecryptionOptions] and [forcePasswordResetReason].
*/
private fun createUserStateWithDecryptionOptions(
userDecryptionOptions: UserDecryptionOptionsJson?,
forcePasswordResetReason: ForcePasswordResetReason? = null,
): UserStateJson =
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to AccountJson(
profile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremiumPersonally = true,
hasPremiumFromOrganization = null,
forcePasswordResetReason = forcePasswordResetReason,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = userDecryptionOptions,
isTwoFactorEnabled = false,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
),
tokens = null,
settings = AccountJson.Settings(environmentUrlData = null),
),
),
)

View File

@@ -8,7 +8,6 @@ import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.BitwardenServiceClient
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import io.mockk.coEvery
import io.mockk.coVerify
@@ -30,12 +29,10 @@ class NetworkConfigManagerTest {
unconfined = testDispatcher,
)
private val mutableAuthStateFlow = MutableStateFlow<AuthState>(AuthState.Uninitialized)
private val mutableUserStateFlow = MutableStateFlow<UserState?>(null)
private val mutableEnvironmentStateFlow = MutableStateFlow<Environment>(Environment.Us)
private val authRepository: AuthRepository = mockk {
every { authStateFlow } returns mutableAuthStateFlow
every { userStateFlow } returns mutableUserStateFlow
}
private val environmentRepository: EnvironmentRepository = mockk {
every { environmentStateFlow } returns mutableEnvironmentStateFlow
@@ -60,13 +57,10 @@ class NetworkConfigManagerTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `changes in the Environment or active user ID should call getServerConfig after debounce period`() {
mutableEnvironmentStateFlow.value = Environment.Eu
mutableUserStateFlow.value = mockk { every { activeUserId } returns "userId" }
fun `changes in the Environment should call getServerConfig after debounce period`() {
mutableEnvironmentStateFlow.value = Environment.Us
mutableUserStateFlow.value = null
mutableEnvironmentStateFlow.value = Environment.Eu
testDispatcher.advanceTimeByAndRunCurrent(delayTimeMillis = 500L)
coVerify(exactly = 1) {
serverConfigRepository.getServerConfig(forceRefresh = 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

@@ -30,7 +30,7 @@ androidxRoom = "2.8.4"
androidxSecurityCrypto = "1.1.0"
androidxSplash = "1.2.0"
androidxWork = "2.11.2"
bitwardenSdk = "3.0.0-7338-5bdc976f"
bitwardenSdk = "3.0.0-7395-1e454441"
crashlytics = "3.0.7"
detekt = "1.23.8"
firebaseBom = "34.14.0"

View File

@@ -121,7 +121,7 @@ internal class BitwardenServiceClientImpl(
override val configService: ConfigService by lazy {
ConfigServiceImpl(
configApi = retrofits.authenticatedApiRetrofit.create(),
configApi = retrofits.unauthenticatedApiRetrofit.create(),
)
}

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

@@ -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

@@ -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
}
/**