mirror of
https://github.com/bitwarden/android.git
synced 2026-05-17 03:59:18 -05:00
Compare commits
2 Commits
PM-34085-r
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38a5e6fe55 | ||
|
|
2f6a36ce1a |
@@ -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" />
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -40,6 +40,7 @@ class SdkRepositoryFactoryTests {
|
||||
authTokenProvider = mockk(),
|
||||
certificateProvider = mockk(),
|
||||
cookieProvider = mockk(),
|
||||
permissionProvider = mockk(),
|
||||
clock = FIXED_CLOCK,
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 won’t 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 won’t 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,8 +6,8 @@ appVersionCode = "1"
|
||||
appVersionName = "2026.4.0"
|
||||
|
||||
# SDK Versions
|
||||
compileSdk = "36"
|
||||
targetSdk = "36"
|
||||
compileSdk = "37"
|
||||
targetSdk = "37"
|
||||
minSdk = "29"
|
||||
minSdkBwa = "28"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 won’t be able to connect.</string>
|
||||
<string name="without_this_permission_bitwarden_wont_be_able_to_sync_with_your_server">Without this permission, Bitwarden won’t 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>
|
||||
|
||||
Reference in New Issue
Block a user