diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2024-02-19 11:22:51 +0100 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2024-02-19 11:22:51 +0100 |
| commit | 8ae2314de15388f25df17d435b01130146e42250 (patch) | |
| tree | a514d7951b2cc51fa8131c1eaf3b7b67d2f68b2c /android/app/src | |
| parent | df37a0da43149c63e60ae604d4d7fefba2de9b5c (diff) | |
| parent | f8f7da0221ecbfebe0b1023243e8305e03378a63 (diff) | |
| download | mullvadvpn-8ae2314de15388f25df17d435b01130146e42250.tar.xz mullvadvpn-8ae2314de15388f25df17d435b01130146e42250.zip | |
Merge branch 'add-guide-for-auto-connect-and-lockdown-mode-droid-548'
Diffstat (limited to 'android/app/src')
10 files changed, 449 insertions, 14 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt index 9a35df1ad3..b53b02670f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt @@ -1,15 +1,18 @@ package net.mullvad.mullvadvpn.compose.component +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar @@ -23,6 +26,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.PrimaryButton +import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar @@ -183,3 +190,63 @@ fun ScaffoldWithMediumTopBar( } ) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScaffoldWithLargeTopBarAndButton( + appBarTitle: String, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + onButtonClick: () -> Unit = {}, // Add button + buttonTitle: String, + scrollbarColor: Color = MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar), + content: @Composable (modifier: Modifier) -> Unit +) { + val appBarState = rememberTopAppBarState() + val scrollState = rememberScrollState() + val canScroll = scrollState.canScrollForward || scrollState.canScrollBackward + val scrollBehavior = + TopAppBarDefaults.exitUntilCollapsedScrollBehavior(appBarState, canScroll = { canScroll }) + Scaffold( + modifier = + modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .systemBarsPadding() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + MullvadLargeTopBar( + title = appBarTitle, + navigationIcon = navigationIcon, + actions, + scrollBehavior = scrollBehavior + ) + }, + bottomBar = { + PrimaryButton( + text = buttonTitle, + onClick = onButtonClick, + modifier = + Modifier.padding( + horizontal = Dimens.sideMargin, + vertical = Dimens.screenVerticalMargin + ), + icon = { + Icon( + painter = painterResource(id = R.drawable.icon_extlink), + contentDescription = null + ) + }, + ) + }, + content = { + content( + Modifier.fillMaxSize() + .padding(it) + .drawVerticalScrollbar(state = scrollState, color = scrollbarColor) + .verticalScroll(scrollState) + ) + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt index d7b4541116..52e6e03efb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.Surface @@ -198,6 +199,16 @@ private fun PreviewMediumTopBar() { } } +@Preview +@Composable +private fun PreviewLargeTopBar() { + AppTheme { + MullvadLargeTopBar( + title = "Title", + ) + } +} + @Preview(widthDp = 260) @Composable private fun PreviewSlimMediumTopBar() { @@ -237,6 +248,28 @@ fun MullvadMediumTopBar( ) } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MullvadLargeTopBar( + title: String, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior? = null +) { + LargeTopAppBar( + title = { Text(text = title, maxLines = 2, overflow = TextOverflow.Ellipsis) }, + navigationIcon = navigationIcon, + scrollBehavior = scrollBehavior, + colors = + TopAppBarDefaults.mediumTopAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + scrolledContainerColor = MaterialTheme.colorScheme.background, + actionIconContentColor = MaterialTheme.colorScheme.onPrimary.copy(AlphaTopBar), + ), + actions = actions + ) +} + @Preview @Composable private fun PreviewMullvadTopBarWithLongDeviceName() { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AutoConnectAndLockdownModeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AutoConnectAndLockdownModeScreen.kt new file mode 100644 index 0000000000..f4d417e6f3 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AutoConnectAndLockdownModeScreen.kt @@ -0,0 +1,274 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.constraintlayout.compose.ConstrainedLayoutReference +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.ConstraintLayoutScope +import androidx.core.text.HtmlCompat +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithLargeTopBarAndButton +import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition +import net.mullvad.mullvadvpn.lib.common.util.openVpnSettings +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription +import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible +import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible + +@Preview +@Composable +private fun PreviewAutoConnectAndLockdownModeScreen() { + AppTheme { AutoConnectAndLockdownModeScreen() } +} + +@Destination(style = SlideInFromRightTransition::class) +@Composable +fun AutoConnectAndLockdownMode(navigator: DestinationsNavigator) { + AutoConnectAndLockdownModeScreen(onBackClick = navigator::navigateUp) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AutoConnectAndLockdownModeScreen(onBackClick: () -> Unit = {}) { + val context = LocalContext.current + ScaffoldWithLargeTopBarAndButton( + appBarTitle = stringResource(id = R.string.auto_connect_and_lockdown_mode_two_lines), + navigationIcon = { NavigateBackIconButton(onBackClick) }, + buttonTitle = stringResource(id = R.string.go_to_vpn_settings), + onButtonClick = { context.openVpnSettings() }, + content = { modifier -> + Column(modifier = modifier, verticalArrangement = Arrangement.Center) { + val pagerState = rememberPagerState(pageCount = { PAGES.entries.size }) + val scope = rememberCoroutineScope() + ConstraintLayout( + modifier = Modifier.fillMaxSize(), + ) { + val (pager, backButtonRef, nextButtonRef, pageIndicatorRef) = createRefs() + + AutoConnectCarousel( + pagerState = pagerState, + backButtonRef = backButtonRef, + nextButtonRef = nextButtonRef, + pager = pager + ) + + // Go to previous page + CarouselNavigationButton( + modifier = + Modifier.constrainAs(backButtonRef) { + top.linkTo(parent.top) + start.linkTo(parent.start) + bottom.linkTo(parent.bottom) + }, + onClick = { + scope.launch { + pagerState.animateScrollToPage(pagerState.currentPage - 1) + } + }, + isEnabled = { pagerState.currentPage != 0 }, + rotation = 180f + ) + + // Go to next page + CarouselNavigationButton( + modifier = + Modifier.constrainAs(nextButtonRef) { + top.linkTo(parent.top) + end.linkTo(parent.end) + bottom.linkTo(parent.bottom) + }, + onClick = { + scope.launch { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + }, + isEnabled = { pagerState.currentPage != pagerState.pageCount - 1 }, + rotation = 0f + ) + + PageIndicator( + pagerState = pagerState, + pageIndicatorRef = pageIndicatorRef, + pager = pager + ) + } + } + } + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ConstraintLayoutScope.AutoConnectCarousel( + pagerState: PagerState, + backButtonRef: ConstrainedLayoutReference, + nextButtonRef: ConstrainedLayoutReference, + pager: ConstrainedLayoutReference +) { + HorizontalPager( + state = pagerState, + beyondBoundsPageCount = 2, + modifier = + Modifier.constrainAs(pager) { + top.linkTo(parent.top) + start.linkTo(backButtonRef.end) + end.linkTo(nextButtonRef.start) + bottom.linkTo(parent.bottom) + } + ) { page -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Text( + modifier = Modifier.padding(horizontal = Dimens.largePadding), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondary, + text = + HtmlCompat.fromHtml( + stringResource(id = PAGES.entries[page].topText), + HtmlCompat.FROM_HTML_MODE_COMPACT + ) + .toAnnotatedString( + boldSpanStyle = + SpanStyle( + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onPrimary + ) + ) + ) + Image( + modifier = Modifier.padding(top = Dimens.topPadding, bottom = Dimens.bottomPadding), + painter = painterResource(id = PAGES.entries[page].image), + contentDescription = null, + ) + Text( + modifier = Modifier.padding(horizontal = Dimens.largePadding), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondary, + text = + HtmlCompat.fromHtml( + stringResource(id = PAGES.entries[page].bottomText), + HtmlCompat.FROM_HTML_MODE_COMPACT + ) + .toAnnotatedString( + boldSpanStyle = + SpanStyle( + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onPrimary + ) + ) + ) + } + } +} + +@Composable +private fun CarouselNavigationButton( + modifier: Modifier = Modifier, + onClick: () -> Unit, + isEnabled: () -> Boolean, + rotation: Float, +) { + IconButton( + modifier = modifier.alpha(if (isEnabled.invoke()) AlphaVisible else AlphaInvisible), + onClick = onClick, + enabled = isEnabled.invoke() + ) { + Icon( + painter = painterResource(id = R.drawable.icon_chevron), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.rotate(rotation).alpha(AlphaDescription) + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ConstraintLayoutScope.PageIndicator( + pagerState: PagerState, + pageIndicatorRef: ConstrainedLayoutReference, + pager: ConstrainedLayoutReference +) { + Row( + Modifier.wrapContentHeight().fillMaxWidth().padding(top = Dimens.topPadding).constrainAs( + pageIndicatorRef + ) { + top.linkTo(pager.bottom) + end.linkTo(parent.end) + start.linkTo(parent.start) + }, + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.Bottom + ) { + repeat(pagerState.pageCount) { iteration -> + val color = + if (pagerState.currentPage == iteration) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.primary + Box( + modifier = + Modifier.padding(Dimens.indicatorPadding) + .clip(CircleShape) + .background(color) + .size(Dimens.indicatorSize) + ) + } + } +} + +private enum class PAGES(val topText: Int, val image: Int, val bottomText: Int) { + FIRST( + R.string.auto_connect_carousel_first_slide_top_text, + R.drawable.carousel_slide_1_cogwheel, + R.string.auto_connect_carousel_first_slide_bottom_text + ), + SECOND( + R.string.auto_connect_carousel_second_slide_top_text, + R.drawable.carousel_slide_2_always_on, + R.string.auto_connect_carousel_second_slide_bottom_text + ), + THIRD( + R.string.auto_connect_carousel_third_slide_top_text, + R.drawable.carousel_slide_3_block_connections, + R.string.auto_connect_carousel_third_slide_bottom_text + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt index 765975a446..32beb656e5 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt @@ -46,11 +46,13 @@ import net.mullvad.mullvadvpn.compose.cell.HeaderSwitchComposeCell import net.mullvad.mullvadvpn.compose.cell.InformationComposeCell import net.mullvad.mullvadvpn.compose.cell.MtuComposeCell import net.mullvad.mullvadvpn.compose.cell.MtuSubtitle +import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell import net.mullvad.mullvadvpn.compose.cell.NormalSwitchComposeCell import net.mullvad.mullvadvpn.compose.cell.SelectableCell import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.destinations.AutoConnectAndLockdownModeDestination import net.mullvad.mullvadvpn.compose.destinations.ContentBlockersInfoDialogDestination import net.mullvad.mullvadvpn.compose.destinations.CustomDnsInfoDialogDestination import net.mullvad.mullvadvpn.compose.destinations.DnsDialogDestination @@ -188,6 +190,9 @@ fun VpnSettings( navigateToContentBlockersInfo = { navigator.navigate(ContentBlockersInfoDialogDestination) { launchSingleTop = true } }, + navigateToAutoConnectScreen = { + navigator.navigate(AutoConnectAndLockdownModeDestination) { launchSingleTop = true } + }, navigateToCustomDnsInfo = { navigator.navigate(CustomDnsInfoDialogDestination) { launchSingleTop = true } }, @@ -245,12 +250,14 @@ fun VpnSettings( ) } +@Suppress("LongMethod") @OptIn(ExperimentalFoundationApi::class) @Composable fun VpnSettingsScreen( uiState: VpnSettingsUiState, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, navigateToContentBlockersInfo: () -> Unit = {}, + navigateToAutoConnectScreen: () -> Unit = {}, navigateToCustomDnsInfo: () -> Unit = {}, navigateToMalwareInfo: () -> Unit = {}, navigateToObfuscationInfo: () -> Unit = {}, @@ -288,17 +295,34 @@ fun VpnSettingsScreen( modifier = modifier.testTag(LAZY_LIST_TEST_TAG).animateContentSize(), state = lazyListState ) { - item { - Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) - HeaderSwitchComposeCell( - title = stringResource(R.string.auto_connect), - isToggled = uiState.isAutoConnectEnabled, - isEnabled = true, - onCellClicked = { newValue -> onToggleAutoConnect(newValue) } - ) - } - item { - SwitchComposeSubtitleCell(text = stringResource(id = R.string.auto_connect_footer)) + if (uiState.systemVpnSettingsAvailable) { + item { + Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) + NavigationComposeCell( + title = stringResource(id = R.string.auto_connect_and_lockdown_mode), + onClick = { navigateToAutoConnectScreen() }, + ) + } + item { + SwitchComposeSubtitleCell( + text = stringResource(id = R.string.auto_connect_and_lockdown_mode_footer) + ) + } + } else { + item { + Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) + HeaderSwitchComposeCell( + title = stringResource(R.string.auto_connect), + isToggled = uiState.isAutoConnectEnabled, + isEnabled = true, + onCellClicked = { newValue -> onToggleAutoConnect(newValue) } + ) + } + item { + SwitchComposeSubtitleCell( + text = stringResource(id = R.string.auto_connect_footer) + ) + } } item { Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt index 5525dee8ce..75abbc7cef 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt @@ -20,6 +20,7 @@ data class VpnSettingsUiState( val selectedWireguardPort: Constraint<Port>, val customWireguardPort: Constraint<Port>?, val availablePortRanges: List<PortRange>, + val systemVpnSettingsAvailable: Boolean, ) { companion object { @@ -35,6 +36,7 @@ data class VpnSettingsUiState( selectedWireguardPort: Constraint<Port> = Constraint.Any(), customWireguardPort: Constraint.Only<Port>? = null, availablePortRanges: List<PortRange> = emptyList(), + systemVpnSettingsAvailable: Boolean = false, ) = VpnSettingsUiState( mtu, @@ -48,6 +50,7 @@ data class VpnSettingsUiState( selectedWireguardPort, customWireguardPort, availablePortRanges, + systemVpnSettingsAvailable ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index c62cf03851..0c96f72b3b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -34,6 +34,7 @@ import net.mullvad.mullvadvpn.usecase.PlayPaymentUseCase import net.mullvad.mullvadvpn.usecase.PortRangeUseCase import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsUseCase import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase import net.mullvad.mullvadvpn.util.ChangelogDataProvider @@ -109,6 +110,7 @@ val uiModule = module { single { RelayListUseCase(get(), get()) } single { OutOfTimeUseCase(get(), get()) } single { ConnectivityUseCase(get()) } + single { SystemVpnSettingsUseCase(androidContext()) } single { InAppNotificationController(get(), get(), get(), get(), MainScope()) } @@ -151,7 +153,7 @@ val uiModule = module { viewModel { SettingsViewModel(get(), get(), IS_PLAY_BUILD) } viewModel { SplashViewModel(get(), get(), get()) } viewModel { VoucherDialogViewModel(get(), get()) } - viewModel { VpnSettingsViewModel(get(), get(), get(), get()) } + viewModel { VpnSettingsViewModel(get(), get(), get(), get(), get()) } viewModel { WelcomeViewModel(get(), get(), get(), get(), get(), isPlayBuild = IS_PLAY_BUILD) } viewModel { ReportProblemViewModel(get(), get()) } viewModel { ViewLogsViewModel(get()) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SystemVpnSettingsUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SystemVpnSettingsUseCase.kt new file mode 100644 index 0000000000..a2ca2cdc64 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/SystemVpnSettingsUseCase.kt @@ -0,0 +1,9 @@ +package net.mullvad.mullvadvpn.usecase + +import android.content.Context +import android.content.Intent + +class SystemVpnSettingsUseCase(val context: Context) { + fun systemVpnSettingsAvailable(): Boolean = + Intent("android.net.vpn.SETTINGS").resolveActivity(context.packageManager) != null +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt index 3e88ccc28a..3a5514d3d3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModel.kt @@ -36,6 +36,7 @@ import net.mullvad.mullvadvpn.model.WireguardConstraints import net.mullvad.mullvadvpn.repository.SettingsRepository import net.mullvad.mullvadvpn.usecase.PortRangeUseCase import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsUseCase import net.mullvad.mullvadvpn.util.isCustom sealed interface VpnSettingsSideEffect { @@ -49,6 +50,7 @@ class VpnSettingsViewModel( private val resources: Resources, portRangeUseCase: PortRangeUseCase, private val relayListUseCase: RelayListUseCase, + private val systemVpnSettingsUseCase: SystemVpnSettingsUseCase, private val dispatcher: CoroutineDispatcher = Dispatchers.IO ) : ViewModel() { @@ -75,7 +77,9 @@ class VpnSettingsViewModel( quantumResistant = settings?.quantumResistant() ?: QuantumResistantState.Off, selectedWireguardPort = settings?.getWireguardPort() ?: Constraint.Any(), customWireguardPort = customWgPort, - availablePortRanges = portRanges + availablePortRanges = portRanges, + systemVpnSettingsAvailable = + systemVpnSettingsUseCase.systemVpnSettingsAvailable() ) } .stateIn( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt index fd236e8405..91866d5cc2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelState.kt @@ -20,6 +20,7 @@ data class VpnSettingsViewModelState( val selectedWireguardPort: Constraint<Port>, val customWireguardPort: Constraint<Port>?, val availablePortRanges: List<PortRange>, + val systemVpnSettingsAvailable: Boolean, ) { fun toUiState(): VpnSettingsUiState = VpnSettingsUiState( @@ -34,6 +35,7 @@ data class VpnSettingsViewModelState( selectedWireguardPort, customWireguardPort, availablePortRanges, + systemVpnSettingsAvailable ) companion object { @@ -51,7 +53,8 @@ data class VpnSettingsViewModelState( quantumResistant = QuantumResistantState.Off, selectedWireguardPort = Constraint.Any(), customWireguardPort = null, - availablePortRanges = emptyList() + availablePortRanges = emptyList(), + systemVpnSettingsAvailable = false ) } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt index 51bbe3057c..5d44dca487 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnSettingsViewModelTest.kt @@ -27,6 +27,7 @@ import net.mullvad.mullvadvpn.model.WireguardTunnelOptions import net.mullvad.mullvadvpn.repository.SettingsRepository import net.mullvad.mullvadvpn.usecase.PortRangeUseCase import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsUseCase import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -39,6 +40,7 @@ class VpnSettingsViewModelTest { private val mockResources: Resources = mockk() private val mockPortRangeUseCase: PortRangeUseCase = mockk() private val mockRelayListUseCase: RelayListUseCase = mockk() + private val mockSystemVpnSettingsUseCase: SystemVpnSettingsUseCase = mockk(relaxed = true) private val mockSettingsUpdate = MutableStateFlow<Settings?>(null) private val portRangeFlow = MutableStateFlow(emptyList<PortRange>()) @@ -56,6 +58,7 @@ class VpnSettingsViewModelTest { resources = mockResources, portRangeUseCase = mockPortRangeUseCase, relayListUseCase = mockRelayListUseCase, + systemVpnSettingsUseCase = mockSystemVpnSettingsUseCase, dispatcher = UnconfinedTestDispatcher() ) } @@ -146,4 +149,17 @@ class VpnSettingsViewModelTest { mockRelayListUseCase.updateSelectedWireguardConstraints(wireguardConstraints) } } + + @Test + fun `given useCase systemVpnSettingsAvailable is true then uiState should be systemVpnSettingsAvailable=true`() = + runTest { + val systemVpnSettingsAvailable = true + + every { mockSystemVpnSettingsUseCase.systemVpnSettingsAvailable() } returns + systemVpnSettingsAvailable + + viewModel.uiState.test { + assertEquals(systemVpnSettingsAvailable, awaitItem().systemVpnSettingsAvailable) + } + } } |
