Compare commits

...

2 Commits

Author SHA1 Message Date
David Perez
38a5e6fe55 PM-36508: Chore: Local network access permission (#6916) 2026-05-15 19:30:02 +00:00
David Perez
2f6a36ce1a bug: Update Passport and License date formats in VaultItemScreen (#6927) 2026-05-15 19:03:42 +00:00
34 changed files with 1259 additions and 30 deletions

View File

@@ -9,6 +9,7 @@
android:name="android.hardware.nfc"
android:required="false" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.NFC" />

View File

@@ -41,6 +41,8 @@ import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.navigateToCooki
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.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
@@ -151,6 +153,10 @@ class MainActivity : AppCompatActivity() {
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
localNetworkAccessDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
}
}
}
@@ -235,6 +241,9 @@ class MainActivity : AppCompatActivity() {
MainEvent.Recreate -> handleRecreate()
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
MainEvent.NavigateToCookieAcquisition -> navController.navigateToCookieAcquisition()
MainEvent.NavigateToLocalNetworkAccess -> {
navController.navigateToLocalNetworkAccess()
}
is MainEvent.UpdateAppLocale -> {
AppCompatDelegate.setApplicationLocales(

View File

@@ -37,6 +37,7 @@ import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManage
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
@@ -80,6 +81,7 @@ class MainViewModel @Inject constructor(
accessibilitySelectionManager: AccessibilitySelectionManager,
autofillSelectionManager: AutofillSelectionManager,
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
networkPermissionManager: NetworkPermissionManager,
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
@@ -168,6 +170,13 @@ class MainViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
networkPermissionManager
.isLocalNetworkAccessRequiredStateFlow
.filter { it }
.map { MainAction.Internal.LocalNetworkAccessRequired }
.onEach(::sendAction)
.launchIn(viewModelScope)
cookieAcquisitionRequestManager
.cookieAcquisitionRequestFlow
.filterNotNull()
@@ -223,6 +232,7 @@ class MainViewModel @Inject constructor(
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()
}
}
@@ -304,6 +314,10 @@ class MainViewModel @Inject constructor(
sendEvent(MainEvent.NavigateToCookieAcquisition)
}
private fun handleLocalNetworkAccessRequired() {
sendEvent(MainEvent.NavigateToLocalNetworkAccess)
}
private fun handleResizeHasBeenRequested() {
mutableStateFlow.update { it.copy(hasResizeBeenRequested = true) }
}
@@ -656,6 +670,11 @@ sealed class MainAction {
*/
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
*/
@@ -694,6 +713,11 @@ sealed class MainEvent {
*/
data object NavigateToCookieAcquisition : MainEvent()
/**
* Navigate to the local network access screen.
*/
data object NavigateToLocalNetworkAccess : MainEvent()
/**
* Indicates that the app language has been updated.
*/

View File

@@ -15,6 +15,7 @@ import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_CL
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_USER_AGENT
import com.x8bit.bitwarden.data.platform.manager.CertificateManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -58,6 +59,7 @@ object PlatformNetworkModule {
certificateManager: CertificateManager,
buildInfoManager: BuildInfoManager,
networkCookieManager: NetworkCookieManager,
networkPermissionManager: NetworkPermissionManager,
clock: Clock,
): BitwardenServiceClientConfig = BitwardenServiceClientConfig(
clock = clock,
@@ -72,6 +74,7 @@ object PlatformNetworkModule {
certificateProvider = certificateManager,
enableHttpBodyLogging = buildInfoManager.isDevBuild,
cookieProvider = networkCookieManager,
permissionProvider = networkPermissionManager,
)
@Provides

View File

@@ -74,6 +74,8 @@ import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManage
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManagerImpl
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkPlatformApiFactory
@@ -450,4 +452,14 @@ object PlatformManagerModule {
cookieDiskSource = cookieDiskSource,
cookieAcquisitionRequestManager = cookieAcquisitionRequestManager,
)
@Provides
@Singleton
fun provideNetworkPermissionManager(
@ApplicationContext context: Context,
resourceManager: ResourceManager,
): NetworkPermissionManager = NetworkPermissionManagerImpl(
context = context,
resourceManager = resourceManager,
)
}

View File

@@ -0,0 +1,21 @@
package com.x8bit.bitwarden.data.platform.manager.network
import com.bitwarden.network.provider.PermissionProvider
import kotlinx.coroutines.flow.StateFlow
/**
* A manager class for handling network permissions.
*/
interface NetworkPermissionManager : PermissionProvider {
/**
* StateFlow indicating if local network access is being requested at this moment.
*
* Emits `true` when local network access is required, `false` otherwise.
*/
val isLocalNetworkAccessRequiredStateFlow: StateFlow<Boolean>
/**
* Sets the local network access required state to `false`.
*/
fun clearIsLocalNetworkAccessRequired()
}

View File

@@ -0,0 +1,49 @@
package com.x8bit.bitwarden.data.platform.manager.network
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* The default implementation of [NetworkPermissionManager].
*/
internal class NetworkPermissionManagerImpl(
private val context: Context,
private val resourceManager: ResourceManager,
) : NetworkPermissionManager {
private val mutableIsLocalNetworkAccessRequiredStateFlow = MutableStateFlow(value = false)
override val errorMessageString: String
get() = resourceManager.getString(
resId = BitwardenString
.your_request_was_interrupted_because_the_app_needs_local_network_access,
)
override val hasLocalNetworkAccessPermission: Boolean
get() = if (isBuildVersionAtLeast(version = Build.VERSION_CODES.CINNAMON_BUN)) {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_LOCAL_NETWORK,
) == PackageManager.PERMISSION_GRANTED
} else {
true
}
override val isLocalNetworkAccessRequiredStateFlow: StateFlow<Boolean> =
mutableIsLocalNetworkAccessRequiredStateFlow
override fun acquireLocalNetworkAccessPermission() {
mutableIsLocalNetworkAccessRequiredStateFlow.value = true
}
override fun clearIsLocalNetworkAccessRequired() {
mutableIsLocalNetworkAccessRequiredStateFlow.value = false
}
}

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.platform.util
import com.bitwarden.network.exception.CookieRedirectException
import com.bitwarden.network.exception.LocalNetworkAccessException
/**
* Returns a user-friendly error message if this [Throwable] is an allow-listed
@@ -8,6 +9,7 @@ import com.bitwarden.network.exception.CookieRedirectException
*/
val Throwable.userFriendlyMessage: String?
get() = when (this) {
is LocalNetworkAccessException -> message
is CookieRedirectException -> message
else -> null
}

View File

@@ -0,0 +1,40 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
/**
* The type-safe route for the local network access screen.
*/
@OmitFromCoverage
@Serializable
data object LocalNetworkAccessRoute
/**
* Add the local network access screen to the nav graph.
*/
fun NavGraphBuilder.localNetworkAccessDestination(
onDismiss: () -> Unit,
onSplashScreenRemoved: () -> Unit,
) {
composableWithSlideTransitions<LocalNetworkAccessRoute> {
LocalNetworkAccessScreen(onDismiss = onDismiss)
// If we are displaying the local network access screen, then we can just hide
// the splash screen.
onSplashScreenRemoved()
}
}
/**
* Navigate to the local network access screen.
*/
fun NavController.navigateToLocalNetworkAccess() {
this.navigate(route = LocalNetworkAccessRoute) {
launchSingleTop = true
}
}

View File

@@ -0,0 +1,204 @@
package com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess
import android.Manifest
import android.annotation.SuppressLint
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.LifecycleEventEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.composition.LocalIntentManager
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.manager.util.startAppSettingsActivity
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.composition.LocalPermissionsManager
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.handlers.LocalNetworkAccessHandler
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.handlers.rememberLocalNetworkAccessHandler
import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager
@SuppressLint("InlinedApi")
private const val LOCAL_NETWORK_PERMISSION: String = Manifest.permission.ACCESS_LOCAL_NETWORK
/**
* Top-level composable for the Local Network Access screen.
*/
@Composable
fun LocalNetworkAccessScreen(
onDismiss: () -> Unit,
viewModel: LocalNetworkAccessViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
permissionsManager: PermissionsManager = LocalPermissionsManager.current,
) {
EventsEffect(viewModel = viewModel) { event ->
when (event) {
LocalNetworkAccessEvent.NavigateBack -> onDismiss()
LocalNetworkAccessEvent.NavigateToSettings -> intentManager.startAppSettingsActivity()
}
}
val handler = rememberLocalNetworkAccessHandler(viewModel)
LifecycleEventEffect { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
handler.onResumed(permissionsManager.checkPermission(LOCAL_NETWORK_PERMISSION))
}
else -> Unit
}
}
BackHandler(onBack = handler.onCloseClick)
BitwardenScaffold(
contentWindowInsets = ScaffoldDefaults
.contentWindowInsets
.union(WindowInsets.displayCutout)
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top),
) {
LocalNetworkAccessContent(
permissionsManager = permissionsManager,
handler = handler,
modifier = Modifier.fillMaxSize(),
)
}
}
@Suppress("LongMethod")
@Composable
private fun LocalNetworkAccessContent(
permissionsManager: PermissionsManager,
handler: LocalNetworkAccessHandler,
modifier: Modifier = Modifier,
) {
var shouldShowPermissionDialog by rememberSaveable { mutableStateOf(value = false) }
val localNetworkAccessPermissionLauncher = permissionsManager.getLauncher { isGranted ->
if (isGranted) {
handler.onCloseClick()
} else if (
!permissionsManager.shouldShowRequestPermissionRationale(LOCAL_NETWORK_PERMISSION)
) {
// "shouldShowRequestPermissionRationale" will only be 'true' after you have declined
// the first OS prompt but have not seen the second prompt attempt. We do not want
// to display the dialog after the first time we were declined, but we do after that.
shouldShowPermissionDialog = true
}
}
if (shouldShowPermissionDialog) {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.local_network_access_required),
message = stringResource(
id = BitwardenString
.without_this_permission_bitwarden_wont_be_able_to_sync_with_your_server,
),
confirmButtonText = stringResource(id = BitwardenString.go_to_settings),
dismissButtonText = stringResource(id = BitwardenString.no_thanks),
onConfirmClick = {
shouldShowPermissionDialog = false
handler.onSettingsClick()
},
onDismissClick = { shouldShowPermissionDialog = false },
onDismissRequest = { shouldShowPermissionDialog = false },
)
}
Column(
modifier = modifier.verticalScroll(state = rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(height = 32.dp))
Image(
painter = rememberVectorPainter(id = BitwardenDrawable.ill_sso_cookie_sync),
contentDescription = null,
contentScale = ContentScale.FillHeight,
modifier = Modifier
.standardHorizontalMargin()
.size(size = 100.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(height = 24.dp))
Text(
text = stringResource(id = BitwardenString.access_your_local_network),
style = BitwardenTheme.typography.titleMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
Text(
text = stringResource(
id = BitwardenString.bitwarden_needs_local_network_access_to_sync_with_your_server,
),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 24.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.enable_local_network_access),
onClick = {
localNetworkAccessPermissionLauncher.launch(input = LOCAL_NETWORK_PERMISSION)
},
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenOutlinedButton(
label = stringResource(id = BitwardenString.ask_again_later),
onClick = handler.onContinueWithoutPermissionClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}

View File

@@ -0,0 +1,99 @@
package com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess
import android.os.Parcelable
import com.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
/**
* ViewModel for the Local Network Access screen.
*/
@HiltViewModel
class LocalNetworkAccessViewModel @Inject constructor(
private val networkPermissionManager: NetworkPermissionManager,
) : BaseViewModel<LocalNetworkAccessState, LocalNetworkAccessEvent, LocalNetworkAccessAction>(
initialState = LocalNetworkAccessState,
) {
override fun handleAction(action: LocalNetworkAccessAction) {
when (action) {
LocalNetworkAccessAction.CloseClick -> handleCloseClick()
LocalNetworkAccessAction.ContinueWithoutPermissionClick -> {
handleContinueWithoutPermissionClick()
}
is LocalNetworkAccessAction.Resumed -> handleResumed(action)
LocalNetworkAccessAction.SettingsClick -> handleSettingsClick()
}
}
private fun handleCloseClick() {
networkPermissionManager.clearIsLocalNetworkAccessRequired()
sendEvent(LocalNetworkAccessEvent.NavigateBack)
}
private fun handleContinueWithoutPermissionClick() {
networkPermissionManager.clearIsLocalNetworkAccessRequired()
sendEvent(LocalNetworkAccessEvent.NavigateBack)
}
private fun handleResumed(action: LocalNetworkAccessAction.Resumed) {
if (action.hasLocalNetworkAccessPermission) {
networkPermissionManager.clearIsLocalNetworkAccessRequired()
sendEvent(LocalNetworkAccessEvent.NavigateBack)
}
}
private fun handleSettingsClick() {
sendEvent(LocalNetworkAccessEvent.NavigateToSettings)
}
}
/**
* State for the Local Network Access screen.
*/
@Parcelize
data object LocalNetworkAccessState : Parcelable
/**
* Events for the Local Network Access screen.
*/
sealed class LocalNetworkAccessEvent {
/**
* Navigate away from this screen.
*/
data object NavigateBack : LocalNetworkAccessEvent()
/**
* Navigate to the OS settings.
*/
data object NavigateToSettings : LocalNetworkAccessEvent()
}
/**
* Actions for the Local Network Access screen.
*/
sealed class LocalNetworkAccessAction {
/**
* The user has clicked the close button.
*/
data object CloseClick : LocalNetworkAccessAction()
/**
* The user has clicked the continue without permission button.
*/
data object ContinueWithoutPermissionClick : LocalNetworkAccessAction()
/**
* The user has clicked the Settings button.
*/
data object SettingsClick : LocalNetworkAccessAction()
/**
* The screen has resumed.
*/
data class Resumed(
val hasLocalNetworkAccessPermission: Boolean,
) : LocalNetworkAccessAction()
}

View File

@@ -0,0 +1,44 @@
package com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.handlers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.LocalNetworkAccessAction
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.LocalNetworkAccessViewModel
/**
* A class to handle user interactions for the Local Network Access screen.
*/
data class LocalNetworkAccessHandler(
val onCloseClick: () -> Unit,
val onContinueWithoutPermissionClick: () -> Unit,
val onSettingsClick: () -> Unit,
val onResumed: (hasPermission: Boolean) -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Creates an instance of [LocalNetworkAccessHandler] using the provided
* [LocalNetworkAccessViewModel].
*/
fun create(
viewModel: LocalNetworkAccessViewModel,
): LocalNetworkAccessHandler = LocalNetworkAccessHandler(
onCloseClick = { viewModel.trySendAction(LocalNetworkAccessAction.CloseClick) },
onContinueWithoutPermissionClick = {
viewModel.trySendAction(LocalNetworkAccessAction.ContinueWithoutPermissionClick)
},
onSettingsClick = { viewModel.trySendAction(LocalNetworkAccessAction.SettingsClick) },
onResumed = { viewModel.trySendAction(LocalNetworkAccessAction.Resumed(it)) },
)
}
}
/**
* Helper function to create and remember a [LocalNetworkAccessHandler] instance.
*/
@Composable
fun rememberLocalNetworkAccessHandler(
viewModel: LocalNetworkAccessViewModel,
): LocalNetworkAccessHandler = remember(viewModel) {
LocalNetworkAccessHandler.create(viewModel)
}

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.item.util
import androidx.annotation.DrawableRes
import com.bitwarden.core.data.util.toFormattedDateStyle
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
import com.bitwarden.ui.platform.base.util.nullIfAllEqual
import com.bitwarden.ui.platform.base.util.orNullIfBlank
@@ -32,6 +33,8 @@ import com.x8bit.bitwarden.ui.vault.util.formatCardNumber
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.time.Clock
import java.time.LocalDate
import java.time.format.DateTimeParseException
import java.time.format.FormatStyle
import java.util.Locale
@@ -236,12 +239,12 @@ fun CipherView.toViewState(
middleName = driversLicense?.middleName,
lastName = driversLicense?.lastName,
licenseNumber = driversLicense?.licenseNumber,
dateOfBirth = driversLicense?.dateOfBirth,
dateOfBirth = driversLicense?.dateOfBirth?.toFormattedDate(clock = clock),
issuingCountry = driversLicense?.issuingCountry,
issuingState = driversLicense?.issuingState,
issuingAuthority = driversLicense?.issuingAuthority,
issueDate = driversLicense?.issueDate,
expirationDate = driversLicense?.expirationDate,
issueDate = driversLicense?.issueDate?.toFormattedDate(clock = clock),
expirationDate = driversLicense?.expirationDate?.toFormattedDate(clock = clock),
licenseClass = driversLicense?.licenseClass,
)
}
@@ -249,7 +252,7 @@ fun CipherView.toViewState(
CipherType.PASSPORT -> VaultItemState.ViewState.Content.ItemType.Passport(
givenName = passport?.givenName,
surname = passport?.surname,
dateOfBirth = passport?.dateOfBirth,
dateOfBirth = passport?.dateOfBirth?.toFormattedDate(clock = clock),
sex = passport?.sex,
birthPlace = passport?.birthPlace,
nationality = passport?.nationality,
@@ -258,8 +261,8 @@ fun CipherView.toViewState(
nationalIdentificationNumber = passport?.nationalIdentificationNumber,
issuingCountry = passport?.issuingCountry,
issuingAuthority = passport?.issuingAuthority,
issueDate = passport?.issueDate,
expirationDate = passport?.expirationDate,
issueDate = passport?.issueDate?.toFormattedDate(clock = clock),
expirationDate = passport?.expirationDate?.toFormattedDate(clock = clock),
)
},
)
@@ -302,6 +305,24 @@ fun FieldView.toCustomField(
)
}
/**
* Takes a string date that is formatted in the default ISO-8601 format (uuuu-MM-dd) and converts
* it to appropriate human-readable format.
*/
private fun String.toFormattedDate(
clock: Clock,
): String? {
val localDate = try {
LocalDate.parse(this)
} catch (_: DateTimeParseException) {
null
}
return localDate?.toFormattedDateStyle(
dateStyle = FormatStyle.LONG,
clock = clock,
)
}
private fun LoginUriView.toUriData() =
VaultItemState.ViewState.Content.ItemType.Login.UriData(
uri = uri.orZeroWidthSpace(),

View File

@@ -4,8 +4,6 @@ import android.content.Intent
import android.net.Uri
import androidx.browser.auth.AuthTabIntent
import androidx.credentials.GetPublicKeyCredentialOption
import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.billing.util.getPremiumCheckoutCallbackResult
import androidx.credentials.provider.BiometricPromptResult
import androidx.credentials.provider.ProviderCreateCredentialRequest
import androidx.credentials.provider.ProviderGetCredentialRequest
@@ -48,6 +46,8 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult
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.credentials.model.CreateCredentialRequest
@@ -65,6 +65,7 @@ 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
@@ -182,6 +183,12 @@ class MainViewModelTest : BaseViewModelTest() {
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
}
@@ -1312,6 +1319,20 @@ class MainViewModelTest : BaseViewModelTest() {
}
}
@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 {
@@ -1351,11 +1372,12 @@ class MainViewModelTest : BaseViewModelTest() {
private fun createViewModel(
initialSpecialCircumstance: SpecialCircumstance? = null,
) = MainViewModel(
): MainViewModel = MainViewModel(
accessibilitySelectionManager = accessibilitySelectionManager,
addTotpItemFromAuthenticatorManager = addTotpItemAuthenticatorManager,
autofillSelectionManager = autofillSelectionManager,
cookieAcquisitionRequestManager = cookieAcquisitionRequestManager,
networkPermissionManager = networkPermissionManager,
specialCircumstanceManager = specialCircumstanceManager,
garbageCollectionManager = garbageCollectionManager,
credentialProviderRequestManager = credentialProviderRequestManager,

View File

@@ -0,0 +1,134 @@
package com.x8bit.bitwarden.data.platform.manager.network
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import app.cash.turbine.test
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class NetworkPermissionManagerTest {
private val context: Context = mockk()
private val resourceManager: ResourceManager = mockk()
private val networkPermissionManager: NetworkPermissionManager = NetworkPermissionManagerImpl(
context = context,
resourceManager = resourceManager,
)
@BeforeEach
fun setup() {
mockkStatic(
ContextCompat::checkSelfPermission,
::isBuildVersionAtLeast,
)
}
@AfterEach
fun tearDown() {
unmockkStatic(
ContextCompat::checkSelfPermission,
::isBuildVersionAtLeast,
)
}
@Test
fun `errorMessageString should return the string from context`() {
every {
resourceManager.getString(
resId = BitwardenString
.your_request_was_interrupted_because_the_app_needs_local_network_access,
)
} returns ERROR_MESSAGE
assertEquals(ERROR_MESSAGE, networkPermissionManager.errorMessageString)
verify(exactly = 1) {
resourceManager.getString(
resId = BitwardenString
.your_request_was_interrupted_because_the_app_needs_local_network_access,
)
}
}
@Test
fun `hasLocalNetworkAccessPermission returns true when below CINNAMON_BUN`() {
every { isBuildVersionAtLeast(Build.VERSION_CODES.CINNAMON_BUN) } returns false
assertTrue(networkPermissionManager.hasLocalNetworkAccessPermission)
}
@Test
fun `hasLocalNetworkAccessPermission returns true when permission granted on CINNAMON_BUN+`() {
every { isBuildVersionAtLeast(Build.VERSION_CODES.CINNAMON_BUN) } returns true
every {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_LOCAL_NETWORK,
)
} returns PackageManager.PERMISSION_GRANTED
assertTrue(networkPermissionManager.hasLocalNetworkAccessPermission)
}
@Test
fun `hasLocalNetworkAccessPermission returns false when permission denied on CINNAMON_BUN+`() {
every { isBuildVersionAtLeast(Build.VERSION_CODES.CINNAMON_BUN) } returns true
every {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_LOCAL_NETWORK,
)
} returns PackageManager.PERMISSION_DENIED
assertFalse(networkPermissionManager.hasLocalNetworkAccessPermission)
}
@Test
fun `isLocalNetworkAccessRequiredStateFlow initial value is false`() = runTest {
networkPermissionManager.isLocalNetworkAccessRequiredStateFlow.test {
assertFalse(awaitItem())
}
}
@Test
fun `acquireLocalNetworkAccessPermission sets isLocalNetworkAccessRequired to true`() =
runTest {
networkPermissionManager.isLocalNetworkAccessRequiredStateFlow.test {
assertFalse(awaitItem())
networkPermissionManager.acquireLocalNetworkAccessPermission()
assertTrue(awaitItem())
}
}
@Test
fun `clearIsLocalNetworkAccessRequired sets isLocalNetworkAccessRequired to false`() =
runTest {
networkPermissionManager.isLocalNetworkAccessRequiredStateFlow.test {
assertFalse(awaitItem())
networkPermissionManager.acquireLocalNetworkAccessPermission()
assertTrue(awaitItem())
networkPermissionManager.clearIsLocalNetworkAccessRequired()
assertFalse(awaitItem())
}
}
}
private const val ERROR_MESSAGE = "error message"

View File

@@ -40,6 +40,7 @@ class SdkRepositoryFactoryTests {
authTokenProvider = mockk(),
certificateProvider = mockk(),
cookieProvider = mockk(),
permissionProvider = mockk(),
clock = FIXED_CLOCK,
)

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.platform.util
import com.bitwarden.network.exception.CookieRedirectException
import com.bitwarden.network.exception.LocalNetworkAccessException
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
@@ -22,6 +23,13 @@ class ThrowableExtensionsTest {
)
}
@Test
fun `userFriendlyMessage should return message for LocalNetworkAccessException`() {
val message = "Fail!"
val exception = LocalNetworkAccessException(message = message)
assertEquals(message, exception.userFriendlyMessage)
}
@Test
fun `userFriendlyMessage should return null for IOException`() {
val exception = IOException("io error")

View File

@@ -271,13 +271,13 @@ fun createMockDriversLicenseView(
firstName: String? = "mockFirstName-$number",
middleName: String? = "mockMiddleName-$number",
lastName: String? = "mockLastName-$number",
dateOfBirth: String? = "mockDateOfBirth-$number",
dateOfBirth: String? = "2006-05-11",
licenseNumber: String? = "mockLicenseNumber-$number",
issuingCountry: String? = "mockIssuingCountry-$number",
issuingState: String? = "mockIssuingState-$number",
issuingAuthority: String? = "mockIssuingAuthority-$number",
issueDate: String? = "mockIssueDate-$number",
expirationDate: String? = "mockExpirationDate-$number",
issueDate: String? = "2024-06-15",
expirationDate: String? = "2031-11-25",
licenseClass: String? = "mockLicenseClass-$number",
): DriversLicenseView =
DriversLicenseView(
@@ -302,7 +302,7 @@ fun createMockPassportView(
number: Int,
surname: String? = "mockSurname-$number",
givenName: String? = "mockGivenName-$number",
dateOfBirth: String? = "mockDateOfBirth-$number",
dateOfBirth: String? = "2006-05-11",
birthPlace: String? = "mockBirthPlace-$number",
sex: String? = "mockSex-$number",
nationality: String? = "mockNationality-$number",
@@ -310,8 +310,8 @@ fun createMockPassportView(
passportType: String? = "mockPassportType-$number",
issuingCountry: String? = "mockIssuingCountry-$number",
issuingAuthority: String? = "mockIssuingAuthority-$number",
issueDate: String? = "mockIssueDate-$number",
expirationDate: String? = "mockExpirationDate-$number",
issueDate: String? = "2024-06-15",
expirationDate: String? = "2031-11-25",
nationalIdentificationNumber: String? = "mockNationalIdentificationNumber-$number",
): PassportView =
PassportView(

View File

@@ -0,0 +1,163 @@
package com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.manager.util.startAppSettingsActivity
import com.bitwarden.ui.util.assertNoDialogExists
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class LocalNetworkAccessScreenTest : BitwardenComposeTest() {
private var onDismissCalled: Boolean = false
private val mutableStateFlow = MutableStateFlow(value = LocalNetworkAccessState)
private val mutableEventFlow = bufferedMutableSharedFlow<LocalNetworkAccessEvent>()
private val viewModel = mockk<LocalNetworkAccessViewModel>(relaxed = true) {
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
}
private val intentManager = mockk<IntentManager> {
every { startActivity(intent = any()) } returns true
}
private val permissionsManager = FakePermissionManager()
@Before
fun setup() {
setContent(
intentManager = intentManager,
permissionsManager = permissionsManager,
) {
LocalNetworkAccessScreen(
onDismiss = { onDismissCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `screen displays title, description, and buttons`() {
composeTestRule
.onNodeWithText(text = "Access your local network")
.assertIsDisplayed()
composeTestRule
.onNodeWithText(
text = "Bitwarden needs local network access to sync with your server. Without " +
"this permission, the app wont be able to connect.",
)
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "Enable local network access")
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "Ask again later")
.assertIsDisplayed()
}
@Test
fun `system back press sends CloseClick action`() {
backDispatcher?.onBackPressed()
verify { viewModel.trySendAction(LocalNetworkAccessAction.CloseClick) }
}
@Test
fun `Ask again later click sends ContinueWithoutPermissionClick action`() {
composeTestRule
.onNodeWithText(text = "Ask again later")
.performClick()
verify { viewModel.trySendAction(LocalNetworkAccessAction.ContinueWithoutPermissionClick) }
}
@Test
fun `Enable local network access click with permission granted sends CloseClick action`() {
permissionsManager.getPermissionsResult = true
composeTestRule
.onNodeWithText(text = "Enable local network access")
.performClick()
verify { viewModel.trySendAction(LocalNetworkAccessAction.CloseClick) }
}
@Test
fun `Enable local network access click with permission denied shows dialog`() {
permissionsManager.getPermissionsResult = false
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithText(text = "Enable local network access")
.performClick()
composeTestRule
.onNodeWithText(
text = "Without this permission, Bitwarden wont be able to connect and sync " +
"with your server. You can enable this in your device settings.",
)
.assertIsDisplayed()
}
@Suppress("MaxLineLength")
@Test
fun `Enable local network access click with permission denied and rationale shows does not show dialog`() {
permissionsManager.getPermissionsResult = false
permissionsManager.shouldShowRequestRationale = true
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithText(text = "Enable local network access")
.performClick()
composeTestRule.assertNoDialogExists()
}
@Test
fun `dialog Go to settings click sends SettingsClick and hides dialog`() {
permissionsManager.getPermissionsResult = false
composeTestRule
.onNodeWithText(text = "Enable local network access")
.performClick()
composeTestRule
.onAllNodesWithText(text = "Go to settings")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(LocalNetworkAccessAction.SettingsClick) }
composeTestRule.assertNoDialogExists()
}
@Test
fun `dialog No thanks click hides dialog`() {
permissionsManager.getPermissionsResult = false
composeTestRule
.onNodeWithText(text = "Enable local network access")
.performClick()
composeTestRule
.onAllNodesWithText(text = "No thanks")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule.assertNoDialogExists()
}
@Test
fun `NavigateBack event calls onDismiss`() {
mutableEventFlow.tryEmit(LocalNetworkAccessEvent.NavigateBack)
assertTrue(onDismissCalled)
}
@Test
fun `NavigateToSettings event calls startAppSettingsActivity`() {
mockkStatic(IntentManager::startAppSettingsActivity) {
every { intentManager.startAppSettingsActivity() } returns true
mutableEventFlow.tryEmit(LocalNetworkAccessEvent.NavigateToSettings)
verify(exactly = 1) { intentManager.startAppSettingsActivity() }
}
}
}

View File

@@ -0,0 +1,83 @@
package com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class LocalNetworkAccessViewModelTest : BaseViewModelTest() {
private val networkPermissionManager = mockk<NetworkPermissionManager> {
every { clearIsLocalNetworkAccessRequired() } just runs
}
@Test
fun `initial state is LocalNetworkAccessState`() {
val viewModel = createViewModel()
assertEquals(LocalNetworkAccessState, viewModel.stateFlow.value)
}
@Test
fun `CloseClick clears permission and sends NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(LocalNetworkAccessAction.CloseClick)
assertEquals(LocalNetworkAccessEvent.NavigateBack, awaitItem())
}
verify(exactly = 1) { networkPermissionManager.clearIsLocalNetworkAccessRequired() }
}
@Test
fun `ContinueWithoutPermissionClick clears permission and sends NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(LocalNetworkAccessAction.ContinueWithoutPermissionClick)
assertEquals(LocalNetworkAccessEvent.NavigateBack, awaitItem())
}
verify(exactly = 1) { networkPermissionManager.clearIsLocalNetworkAccessRequired() }
}
@Test
fun `SettingsClick sends NavigateToSettings`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(LocalNetworkAccessAction.SettingsClick)
assertEquals(LocalNetworkAccessEvent.NavigateToSettings, awaitItem())
}
}
@Test
fun `Resumed with permission granted clears permission and sends NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
LocalNetworkAccessAction.Resumed(hasLocalNetworkAccessPermission = true),
)
assertEquals(LocalNetworkAccessEvent.NavigateBack, awaitItem())
}
verify(exactly = 1) { networkPermissionManager.clearIsLocalNetworkAccessRequired() }
}
@Test
fun `Resumed with permission not granted does not send any event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
LocalNetworkAccessAction.Resumed(hasLocalNetworkAccessPermission = false),
)
expectNoEvents()
}
verify(exactly = 0) { networkPermissionManager.clearIsLocalNetworkAccessRequired() }
}
private fun createViewModel(): LocalNetworkAccessViewModel = LocalNetworkAccessViewModel(
networkPermissionManager = networkPermissionManager,
)
}

View File

@@ -452,7 +452,6 @@ class CipherViewExtensionsTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `toViewState should transform full CipherView into ViewState Drivers License Content`() {
val cipherView = createCipherView(type = CipherType.DRIVERS_LICENSE, isEmpty = false)
@@ -485,12 +484,12 @@ class CipherViewExtensionsTest {
middleName = "mockMiddleName-1",
lastName = "mockLastName-1",
licenseNumber = "mockLicenseNumber-1",
dateOfBirth = "mockDateOfBirth-1",
dateOfBirth = "May 11, 2006",
issuingCountry = "mockIssuingCountry-1",
issuingState = "mockIssuingState-1",
issuingAuthority = "mockIssuingAuthority-1",
issueDate = "mockIssueDate-1",
expirationDate = "mockExpirationDate-1",
issueDate = "June 15, 2024",
expirationDate = "November 25, 2031",
licenseClass = "mockLicenseClass-1",
),
),
@@ -498,7 +497,6 @@ class CipherViewExtensionsTest {
)
}
@Suppress("MaxLineLength")
@Test
fun `toViewState should transform empty CipherView into ViewState Drivers License Content`() {
val cipherView = createCipherView(type = CipherType.DRIVERS_LICENSE, isEmpty = true)
@@ -573,7 +571,7 @@ class CipherViewExtensionsTest {
type = VaultItemState.ViewState.Content.ItemType.Passport(
givenName = "mockGivenName-1",
surname = "mockSurname-1",
dateOfBirth = "mockDateOfBirth-1",
dateOfBirth = "May 11, 2006",
sex = "mockSex-1",
birthPlace = "mockBirthPlace-1",
nationality = "mockNationality-1",
@@ -582,8 +580,8 @@ class CipherViewExtensionsTest {
nationalIdentificationNumber = "mockNationalIdentificationNumber-1",
issuingCountry = "mockIssuingCountry-1",
issuingAuthority = "mockIssuingAuthority-1",
issueDate = "mockIssueDate-1",
expirationDate = "mockExpirationDate-1",
issueDate = "June 15, 2024",
expirationDate = "November 25, 2031",
),
),
viewState,

View File

@@ -13,6 +13,7 @@ import com.bitwarden.network.model.AuthTokenData
import com.bitwarden.network.model.BitwardenServiceClientConfig
import com.bitwarden.network.model.NetworkCookie
import com.bitwarden.network.provider.CookieProvider
import com.bitwarden.network.provider.PermissionProvider
import com.bitwarden.network.service.ConfigService
import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.ssl.CertificateProvider
@@ -83,6 +84,13 @@ object PlatformNetworkModule {
override fun acquireCookies(hostname: String): Unit = Unit
},
permissionProvider = object : PermissionProvider {
override val errorMessageString: String get() = "Error"
override val hasLocalNetworkAccessPermission: Boolean get() = false
override fun acquireLocalNetworkAccessPermission(): Unit = Unit
},
)
@Provides

View File

@@ -6,8 +6,8 @@ appVersionCode = "1"
appVersionName = "2026.4.0"
# SDK Versions
compileSdk = "36"
targetSdk = "36"
compileSdk = "37"
targetSdk = "37"
minSdk = "29"
minSdkBwa = "28"

View File

@@ -5,6 +5,7 @@ import com.bitwarden.network.interceptor.AuthTokenManager
import com.bitwarden.network.interceptor.BaseUrlInterceptors
import com.bitwarden.network.interceptor.CookieInterceptor
import com.bitwarden.network.interceptor.HeadersInterceptor
import com.bitwarden.network.interceptor.PermissionInterceptor
import com.bitwarden.network.model.BitwardenServiceClientConfig
import com.bitwarden.network.provider.CookieProvider
import com.bitwarden.network.provider.RefreshTokenProvider
@@ -71,6 +72,9 @@ internal class BitwardenServiceClientImpl(
cookieInterceptor = CookieInterceptor(
cookieProvider = cookieProvider,
),
permissionInterceptor = PermissionInterceptor(
permissionProvider = bitwardenServiceClientConfig.permissionProvider,
),
headersInterceptor = HeadersInterceptor(
userAgent = bitwardenServiceClientConfig.clientData.userAgent,
clientName = bitwardenServiceClientConfig.clientData.clientName,

View File

@@ -0,0 +1,10 @@
package com.bitwarden.network.exception
import android.Manifest
import java.io.IOException
/**
* Thrown when a user attempts to make a network request to a device on the local network but does
* not have the [Manifest.permission.ACCESS_LOCAL_NETWORK] permission.
*/
class LocalNetworkAccessException(message: String) : IOException(message)

View File

@@ -0,0 +1,43 @@
package com.bitwarden.network.interceptor
import androidx.annotation.WorkerThread
import com.bitwarden.network.exception.LocalNetworkAccessException
import com.bitwarden.network.provider.PermissionProvider
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import java.net.InetAddress
import java.net.UnknownHostException
/**
* Interceptor responsible for determining if the destination of the network request is on the
* local network or not.
*/
internal class PermissionInterceptor(
private val permissionProvider: PermissionProvider,
) : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
if (permissionProvider.hasLocalNetworkAccessPermission || !chain.isLocalRequest()) {
return chain.proceed(request = chain.request())
}
permissionProvider.acquireLocalNetworkAccessPermission()
throw LocalNetworkAccessException(message = permissionProvider.errorMessageString)
}
}
@WorkerThread
@Throws(IOException::class)
private fun Interceptor.Chain.isLocalRequest(): Boolean {
val host = this.request().url.host
val address = try {
InetAddress.getByName(host)
} catch (uhe: UnknownHostException) {
// We just rethrow this exception, it was gonna happen anyway.
throw uhe
} catch (_: SecurityException) {
// A security exception has occurred, lets be safe and assume it's a local request.
return true
}
return address.isSiteLocalAddress
}

View File

@@ -1,6 +1,7 @@
package com.bitwarden.network.model
import com.bitwarden.network.exception.CookieRedirectException
import com.bitwarden.network.exception.LocalNetworkAccessException
import okhttp3.ResponseBody.Companion.toResponseBody
import retrofit2.HttpException
import retrofit2.Response
@@ -48,12 +49,14 @@ sealed class BitwardenError {
*/
fun Throwable.toBitwardenError(): BitwardenError {
return when (this) {
// CookieRedirectException is a subclass of IOException thrown when SSO cookies
// expire in a load-balanced environment. It must be checked before IOException to
// avoid being classified as a generic Network error. We synthesize an Http error
// with a JSON body so the exception's message propagates through the existing
// The LocalNetworkAccessException and CookieRedirectException are subclasses of
// IOException thrown when specific conditions are met. They must be checked before
// IOException to avoid being classified as a generic Network error. We synthesize an
// Http error with a JSON body so the exception's message propagates through the existing
// parseErrorBodyOrNull pipeline used by service-layer recoverCatching blocks.
is CookieRedirectException -> {
is LocalNetworkAccessException,
is CookieRedirectException,
-> {
BitwardenError.Http(
throwable = HttpException(
Response.error<Any>(

View File

@@ -5,6 +5,7 @@ import com.bitwarden.network.interceptor.AuthTokenProvider
import com.bitwarden.network.interceptor.BaseUrlsProvider
import com.bitwarden.network.provider.AppIdProvider
import com.bitwarden.network.provider.CookieProvider
import com.bitwarden.network.provider.PermissionProvider
import com.bitwarden.network.ssl.CertificateProvider
import java.time.Clock
@@ -18,6 +19,7 @@ data class BitwardenServiceClientConfig(
val authTokenProvider: AuthTokenProvider,
val certificateProvider: CertificateProvider,
val cookieProvider: CookieProvider,
val permissionProvider: PermissionProvider,
val clock: Clock,
val enableHttpBodyLogging: Boolean = false,
) {

View File

@@ -0,0 +1,25 @@
package com.bitwarden.network.provider
import android.Manifest
/**
* A provider for network-related permissions.
*/
interface PermissionProvider {
/**
* The translated human-readable string to be displayed when the local network access
* permission is the reason for a request failure.
*/
val errorMessageString: String
/**
* Indicates if the app does or does not have the [Manifest.permission.ACCESS_LOCAL_NETWORK]
* permission.
*/
val hasLocalNetworkAccessPermission: Boolean
/**
* Signals that local network access permission is required for the current environment.
*/
fun acquireLocalNetworkAccessPermission()
}

View File

@@ -6,6 +6,7 @@ import com.bitwarden.network.interceptor.BaseUrlInterceptor
import com.bitwarden.network.interceptor.BaseUrlInterceptors
import com.bitwarden.network.interceptor.CookieInterceptor
import com.bitwarden.network.interceptor.HeadersInterceptor
import com.bitwarden.network.interceptor.PermissionInterceptor
import com.bitwarden.network.ssl.CertificateProvider
import com.bitwarden.network.ssl.configureSsl
import com.bitwarden.network.util.HEADER_KEY_AUTHORIZATION
@@ -27,6 +28,7 @@ internal class RetrofitsImpl(
cookieInterceptor: CookieInterceptor,
headersInterceptor: HeadersInterceptor,
json: Json,
private val permissionInterceptor: PermissionInterceptor,
private val certificateProvider: CertificateProvider,
private val logHttpBody: Boolean = false,
) : Retrofits {
@@ -72,6 +74,7 @@ internal class RetrofitsImpl(
baseClient
.newBuilder()
.addInterceptor(loggingInterceptor)
.addInterceptor(permissionInterceptor)
.build(),
)
.build()
@@ -129,6 +132,7 @@ internal class RetrofitsImpl(
.newBuilder()
.addInterceptor(baseUrlInterceptor)
.addInterceptor(loggingInterceptor)
.addInterceptor(permissionInterceptor)
.build(),
)
.build()
@@ -143,6 +147,7 @@ internal class RetrofitsImpl(
.newBuilder()
.addInterceptor(baseUrlInterceptor)
.addInterceptor(loggingInterceptor)
.addInterceptor(permissionInterceptor)
.build(),
)
.build()

View File

@@ -0,0 +1,147 @@
package com.bitwarden.network.interceptor
import com.bitwarden.network.exception.LocalNetworkAccessException
import com.bitwarden.network.provider.PermissionProvider
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import okhttp3.Request
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.net.InetAddress
import java.net.UnknownHostException
class PermissionInterceptorTest {
private val permissionProvider = mockk<PermissionProvider>()
private val interceptor = PermissionInterceptor(
permissionProvider = permissionProvider,
)
@BeforeEach
fun setup() {
mockkStatic(InetAddress::class)
}
@AfterEach
fun tearDown() {
unmockkStatic(InetAddress::class)
}
@Test
fun `intercept should proceed when local network access permission is granted`() {
every { permissionProvider.hasLocalNetworkAccessPermission } returns true
val chain = FakeInterceptorChain(
request = Request.Builder().url("http://192.168.1.1/api").build(),
)
val response = interceptor.intercept(chain)
assertEquals(200, response.code)
verify(exactly = 1) {
permissionProvider.hasLocalNetworkAccessPermission
}
verify(exactly = 0) {
permissionProvider.acquireLocalNetworkAccessPermission()
permissionProvider.errorMessageString
}
}
@Test
fun `intercept should proceed when request is not targeting the local network`() {
every { permissionProvider.hasLocalNetworkAccessPermission } returns false
every { InetAddress.getByName("8.8.8.8") } returns mockk {
every { isSiteLocalAddress } returns false
}
val chain = FakeInterceptorChain(
request = Request.Builder().url("http://8.8.8.8/api").build(),
)
val response = interceptor.intercept(chain)
assertEquals(200, response.code)
verify(exactly = 1) {
permissionProvider.hasLocalNetworkAccessPermission
}
verify(exactly = 0) {
permissionProvider.acquireLocalNetworkAccessPermission()
permissionProvider.errorMessageString
}
}
@Suppress("MaxLineLength")
@Test
fun `intercept should acquire permission and throw when request targets local network without permission`() {
val errorMessage = "Local network access required"
every { permissionProvider.hasLocalNetworkAccessPermission } returns false
every { permissionProvider.errorMessageString } returns errorMessage
every { permissionProvider.acquireLocalNetworkAccessPermission() } just runs
every { InetAddress.getByName("192.168.1.1") } returns mockk {
every { isSiteLocalAddress } returns true
}
val chain = FakeInterceptorChain(
request = Request.Builder().url("http://192.168.1.1/api").build(),
)
val exception = assertThrows(LocalNetworkAccessException::class.java) {
interceptor.intercept(chain)
}
assertEquals(errorMessage, exception.message)
verify(exactly = 1) {
permissionProvider.hasLocalNetworkAccessPermission
permissionProvider.acquireLocalNetworkAccessPermission()
permissionProvider.errorMessageString
}
}
@Test
fun `intercept should rethrow UnknownHostException when DNS resolution fails`() {
every { permissionProvider.hasLocalNetworkAccessPermission } returns false
every { InetAddress.getByName(any()) } throws UnknownHostException("unknownhost")
val chain = FakeInterceptorChain(
request = Request.Builder().url("http://unknownhost/api").build(),
)
assertThrows(UnknownHostException::class.java) {
interceptor.intercept(chain)
}
verify(exactly = 1) {
permissionProvider.hasLocalNetworkAccessPermission
}
verify(exactly = 0) {
permissionProvider.acquireLocalNetworkAccessPermission()
permissionProvider.errorMessageString
}
}
@Suppress("MaxLineLength")
@Test
fun `intercept should treat request as local and throw when SecurityException occurs during DNS resolution`() {
val errorMessage = "Local network access required"
every { permissionProvider.hasLocalNetworkAccessPermission } returns false
every { permissionProvider.errorMessageString } returns errorMessage
every { permissionProvider.acquireLocalNetworkAccessPermission() } just runs
every { InetAddress.getByName(any()) } throws SecurityException("permission denied")
val chain = FakeInterceptorChain(
request = Request.Builder().url("http://somehost/api").build(),
)
val exception = assertThrows(LocalNetworkAccessException::class.java) {
interceptor.intercept(chain)
}
assertEquals(errorMessage, exception.message)
verify(exactly = 1) {
permissionProvider.hasLocalNetworkAccessPermission
permissionProvider.acquireLocalNetworkAccessPermission()
permissionProvider.errorMessageString
}
}
}

View File

@@ -1,6 +1,7 @@
package com.bitwarden.network.model
import com.bitwarden.network.exception.CookieRedirectException
import com.bitwarden.network.exception.LocalNetworkAccessException
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
@@ -35,6 +36,29 @@ class BitwardenErrorTest {
assertTrue(body?.contains(message) == true)
}
@Test
fun `toBitwardenError with LocalNetworkAccessException should return Http with status 400`() {
val exception = LocalNetworkAccessException(message = "Fail!")
val result = exception.toBitwardenError()
assertTrue(result is BitwardenError.Http)
val httpError = result as BitwardenError.Http
assertEquals(400, httpError.code)
}
@Test
fun `toBitwardenError with LocalNetworkAccessException should include message in body`() {
val message = "Fail!"
val exception = LocalNetworkAccessException(message = message)
val result = exception.toBitwardenError()
val httpError = result as BitwardenError.Http
val body = httpError.responseBodyString
assertTrue(body?.contains(message) == true)
}
@Test
fun `toBitwardenError with IOException should return Network`() {
val exception = IOException("network failure")

View File

@@ -4,6 +4,7 @@ import com.bitwarden.network.interceptor.AuthTokenManager
import com.bitwarden.network.interceptor.BaseUrlInterceptors
import com.bitwarden.network.interceptor.CookieInterceptor
import com.bitwarden.network.interceptor.HeadersInterceptor
import com.bitwarden.network.interceptor.PermissionInterceptor
import com.bitwarden.network.model.NetworkResult
import com.bitwarden.network.ssl.CertificateProvider
import io.mockk.MockKMatcherScope
@@ -50,6 +51,9 @@ class RetrofitsTest {
private val cookieInterceptor = mockk<CookieInterceptor> {
mockIntercept { isCookieInterceptorCalled = true }
}
private val permissionInterceptor = mockk<PermissionInterceptor> {
mockIntercept { isPermissionInterceptorCalled = true }
}
private val headersInterceptors = mockk<HeadersInterceptor> {
mockIntercept { isHeadersInterceptorCalled = true }
}
@@ -65,6 +69,7 @@ class RetrofitsTest {
authTokenManager = authTokenManager,
baseUrlInterceptors = baseUrlInterceptors,
cookieInterceptor = cookieInterceptor,
permissionInterceptor = permissionInterceptor,
headersInterceptor = headersInterceptors,
certificateProvider = certificateProvider,
json = json,
@@ -73,6 +78,7 @@ class RetrofitsTest {
private var isAuthInterceptorCalled = false
private var isApiInterceptorCalled = false
private var isCookieInterceptorCalled = false
private var isPermissionInterceptorCalled = false
private var isHeadersInterceptorCalled = false
private var isIdentityInterceptorCalled = false
private var isEventsInterceptorCalled = false
@@ -176,6 +182,7 @@ class RetrofitsTest {
assertTrue(isAuthInterceptorCalled)
assertTrue(isApiInterceptorCalled)
assertTrue(isCookieInterceptorCalled)
assertTrue(isPermissionInterceptorCalled)
assertTrue(isHeadersInterceptorCalled)
assertFalse(isIdentityInterceptorCalled)
assertFalse(isEventsInterceptorCalled)
@@ -195,6 +202,7 @@ class RetrofitsTest {
assertTrue(isAuthInterceptorCalled)
assertFalse(isApiInterceptorCalled)
assertTrue(isCookieInterceptorCalled)
assertTrue(isPermissionInterceptorCalled)
assertTrue(isHeadersInterceptorCalled)
assertFalse(isIdentityInterceptorCalled)
assertTrue(isEventsInterceptorCalled)
@@ -214,6 +222,7 @@ class RetrofitsTest {
assertFalse(isAuthInterceptorCalled)
assertTrue(isApiInterceptorCalled)
assertTrue(isCookieInterceptorCalled)
assertTrue(isPermissionInterceptorCalled)
assertTrue(isHeadersInterceptorCalled)
assertFalse(isIdentityInterceptorCalled)
assertFalse(isEventsInterceptorCalled)
@@ -233,6 +242,7 @@ class RetrofitsTest {
assertFalse(isAuthInterceptorCalled)
assertFalse(isApiInterceptorCalled)
assertTrue(isCookieInterceptorCalled)
assertTrue(isPermissionInterceptorCalled)
assertTrue(isHeadersInterceptorCalled)
assertTrue(isIdentityInterceptorCalled)
assertFalse(isEventsInterceptorCalled)
@@ -253,6 +263,7 @@ class RetrofitsTest {
assertTrue(isAuthInterceptorCalled)
assertFalse(isApiInterceptorCalled)
assertTrue(isCookieInterceptorCalled)
assertTrue(isPermissionInterceptorCalled)
assertTrue(isHeadersInterceptorCalled)
assertFalse(isIdentityInterceptorCalled)
assertFalse(isEventsInterceptorCalled)
@@ -273,6 +284,7 @@ class RetrofitsTest {
assertFalse(isAuthInterceptorCalled)
assertFalse(isApiInterceptorCalled)
assertTrue(isCookieInterceptorCalled)
assertTrue(isPermissionInterceptorCalled)
assertTrue(isHeadersInterceptorCalled)
assertFalse(isIdentityInterceptorCalled)
assertFalse(isEventsInterceptorCalled)
@@ -294,6 +306,7 @@ class RetrofitsTest {
cookieInterceptor = cookieInterceptor,
headersInterceptor = headersInterceptors,
certificateProvider = certificateProvider,
permissionInterceptor = permissionInterceptor,
json = json,
)

View File

@@ -1365,4 +1365,11 @@ Do you want to switch to this account?</string>
<string name="birth_place">Birth place</string>
<string name="national_identification_number">National identification number</string>
<string name="copy_national_identification_number">Copy national identification number</string>
<string name="access_your_local_network">Access your local network</string>
<string name="local_network_access_required">Local network access required</string>
<string name="ask_again_later">Ask again later</string>
<string name="enable_local_network_access">Enable local network access</string>
<string name="bitwarden_needs_local_network_access_to_sync_with_your_server">Bitwarden needs local network access to sync with your server. Without this permission, the app wont be able to connect.</string>
<string name="without_this_permission_bitwarden_wont_be_able_to_sync_with_your_server">Without this permission, Bitwarden wont be able to connect and sync with your server. You can enable this in your device settings.</string>
<string name="your_request_was_interrupted_because_the_app_needs_local_network_access">Your request was interrupted because the app needs local network access. You can enable this in your device settings.</string>
</resources>