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 | |
| 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'
31 files changed, 514 insertions, 14 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 2707f71b2f..4ea63cf7fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ Line wrap the file at 100 chars. Th #### Android - Add support for all screen orientations. - Add toggle for enabling or disabling split tunneling. +- Replace auto connect with auto connect and lockdown mode guide on platforms that has + system vpn settings. ### Fixed - Fix connectivity issues that would occur when using quantum-resistant tunnels with an incorrectly 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) + } + } } diff --git a/android/config/detekt.yml b/android/config/detekt.yml index d6444acd7f..9f42344321 100644 --- a/android/config/detekt.yml +++ b/android/config/detekt.yml @@ -761,6 +761,7 @@ style: excludeImportStatements: true excludeCommentStatements: false excludeRawStrings: true + ignoreAnnotated: ['Test'] MayBeConst: active: true ModifierOrder: diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ContextExtensions.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ContextExtensions.kt index b983e3538d..8ef70dad92 100644 --- a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ContextExtensions.kt +++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/ContextExtensions.kt @@ -41,3 +41,8 @@ fun Context.openLink(uri: Uri) { val intent = Intent(Intent.ACTION_VIEW, uri) startActivity(intent) } + +fun Context.openVpnSettings() { + val intent = Intent("android.settings.VPN_SETTINGS") + startActivity(intent) +} diff --git a/android/lib/resource/src/main/res/drawable-hdpi/carousel_slide_1_cogwheel.png b/android/lib/resource/src/main/res/drawable-hdpi/carousel_slide_1_cogwheel.png Binary files differnew file mode 100644 index 0000000000..b1f4ec3d93 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-hdpi/carousel_slide_1_cogwheel.png diff --git a/android/lib/resource/src/main/res/drawable-hdpi/carousel_slide_2_always_on.png b/android/lib/resource/src/main/res/drawable-hdpi/carousel_slide_2_always_on.png Binary files differnew file mode 100644 index 0000000000..3a9a10a160 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-hdpi/carousel_slide_2_always_on.png diff --git a/android/lib/resource/src/main/res/drawable-hdpi/carousel_slide_3_block_connections.png b/android/lib/resource/src/main/res/drawable-hdpi/carousel_slide_3_block_connections.png Binary files differnew file mode 100644 index 0000000000..1a31c0a64b --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-hdpi/carousel_slide_3_block_connections.png diff --git a/android/lib/resource/src/main/res/drawable-mdpi/carousel_slide_1_cogwheel.png b/android/lib/resource/src/main/res/drawable-mdpi/carousel_slide_1_cogwheel.png Binary files differnew file mode 100644 index 0000000000..e82ed3d283 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-mdpi/carousel_slide_1_cogwheel.png diff --git a/android/lib/resource/src/main/res/drawable-mdpi/carousel_slide_2_always_on.png b/android/lib/resource/src/main/res/drawable-mdpi/carousel_slide_2_always_on.png Binary files differnew file mode 100644 index 0000000000..8f4a886dd2 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-mdpi/carousel_slide_2_always_on.png diff --git a/android/lib/resource/src/main/res/drawable-mdpi/carousel_slide_3_block_connections.png b/android/lib/resource/src/main/res/drawable-mdpi/carousel_slide_3_block_connections.png Binary files differnew file mode 100644 index 0000000000..88a1cc405e --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-mdpi/carousel_slide_3_block_connections.png diff --git a/android/lib/resource/src/main/res/drawable-xhdpi/carousel_slide_1_cogwheel.png b/android/lib/resource/src/main/res/drawable-xhdpi/carousel_slide_1_cogwheel.png Binary files differnew file mode 100644 index 0000000000..8c7c25dc1f --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-xhdpi/carousel_slide_1_cogwheel.png diff --git a/android/lib/resource/src/main/res/drawable-xhdpi/carousel_slide_2_always_on.png b/android/lib/resource/src/main/res/drawable-xhdpi/carousel_slide_2_always_on.png Binary files differnew file mode 100644 index 0000000000..3cad85b537 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-xhdpi/carousel_slide_2_always_on.png diff --git a/android/lib/resource/src/main/res/drawable-xhdpi/carousel_slide_3_block_connections.png b/android/lib/resource/src/main/res/drawable-xhdpi/carousel_slide_3_block_connections.png Binary files differnew file mode 100644 index 0000000000..58b0d740d0 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-xhdpi/carousel_slide_3_block_connections.png diff --git a/android/lib/resource/src/main/res/drawable-xxhdpi/carousel_slide_1_cogwheel.png b/android/lib/resource/src/main/res/drawable-xxhdpi/carousel_slide_1_cogwheel.png Binary files differnew file mode 100644 index 0000000000..5f04ff4c7f --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-xxhdpi/carousel_slide_1_cogwheel.png diff --git a/android/lib/resource/src/main/res/drawable-xxhdpi/carousel_slide_2_always_on.png b/android/lib/resource/src/main/res/drawable-xxhdpi/carousel_slide_2_always_on.png Binary files differnew file mode 100644 index 0000000000..04e6f371b8 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-xxhdpi/carousel_slide_2_always_on.png diff --git a/android/lib/resource/src/main/res/drawable-xxhdpi/carousel_slide_3_block_connections.png b/android/lib/resource/src/main/res/drawable-xxhdpi/carousel_slide_3_block_connections.png Binary files differnew file mode 100644 index 0000000000..8753492ea1 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-xxhdpi/carousel_slide_3_block_connections.png diff --git a/android/lib/resource/src/main/res/drawable-xxxhdpi/carousel_slide_1_cogwheel.png b/android/lib/resource/src/main/res/drawable-xxxhdpi/carousel_slide_1_cogwheel.png Binary files differnew file mode 100644 index 0000000000..0a0fac8055 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-xxxhdpi/carousel_slide_1_cogwheel.png diff --git a/android/lib/resource/src/main/res/drawable-xxxhdpi/carousel_slide_2_always_on.png b/android/lib/resource/src/main/res/drawable-xxxhdpi/carousel_slide_2_always_on.png Binary files differnew file mode 100644 index 0000000000..4e80ea46c5 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-xxxhdpi/carousel_slide_2_always_on.png diff --git a/android/lib/resource/src/main/res/drawable-xxxhdpi/carousel_slide_3_block_connections.png b/android/lib/resource/src/main/res/drawable-xxxhdpi/carousel_slide_3_block_connections.png Binary files differnew file mode 100644 index 0000000000..5a47ba9a9b --- /dev/null +++ b/android/lib/resource/src/main/res/drawable-xxxhdpi/carousel_slide_3_block_connections.png diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 1c19dab826..b163c9f612 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -73,6 +73,26 @@ <string name="local_network_sharing">Local network sharing</string> <string name="allow_lan_footer">Allows access to other devices on the same network for sharing, printing etc.</string> <string name="auto_connect">Auto-connect</string> + <string name="auto_connect_and_lockdown_mode">Auto-connect & Lockdown mode</string> + <string name="auto_connect_and_lockdown_mode_two_lines">Auto-connect & \nLockdown mode</string> + <string name="auto_connect_and_lockdown_mode_footer">Makes sure the device is always on the VPN tunnel.</string> + <string name="go_to_vpn_settings">Go to VPN settings</string> + <string name="vpn_settings_not_found">There is no VPN settings on your device</string> + <string name="auto_connect_carousel_first_slide_top_text">The Auto-connect and Lockdown mode settings can be found in the Android system settings, follow this guide to enable one or both.</string> + <string name="auto_connect_carousel_first_slide_bottom_text"> + <![CDATA[1. After clicking on the <b>Go to VPN settings</b> button below, click on the cogwheel next to the <b>Mullvad VPN</b> name.]]> + </string> + <string name="auto_connect_carousel_second_slide_top_text">Auto-connect is called Always-on VPN in the Android system settings and it makes sure you are constantly connected to the VPN tunnel and auto connects after restart.</string> + <string name="auto_connect_carousel_second_slide_bottom_text"> + <![CDATA[2. To enable Auto-connect, click on the toggle next to <b>Always-on VPN</b>.]]> + </string> + <string name="auto_connect_carousel_third_slide_top_text"> + <![CDATA[The Lockdown mode blocks all internet access if the VPN tunnel is manually disconnected. <br/><b>Warning: This setting blocks split apps and the Local Network Sharing feature</b>.]]> + </string> + <string name="auto_connect_carousel_third_slide_bottom_text"> + <![CDATA[3. To enable Lockdown mode, click on the toggle next to <b>Block connections without VPN</b>.]]> + </string> + <string name="auto_connect_footer">Automatically connect to a server when the app launches.</string> <string name="wireguard_mtu">WireGuard MTU</string> <string name="wireguard_mtu_footer">Set WireGuard MTU value. Valid range: %1$d - %2$d.</string> diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt index 2e412d864e..ef6b04146e 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt @@ -9,6 +9,7 @@ data class Dimensions( val addIconSize: Dp = 24.dp, val backButtonSideMargin: Dp = 30.dp, val bigIconSize: Dp = 44.dp, + val bottomPadding: Dp = 4.dp, val buttonHeight: Dp = 44.dp, val buttonSpacing: Dp = 12.dp, val buttonVerticalPadding: Dp = 8.dp, @@ -41,6 +42,8 @@ data class Dimensions( val iconFailSuccessTopMargin: Dp = 30.dp, val iconHeight: Dp = 44.dp, val indentedCellStartPadding: Dp = 38.dp, + val indicatorPadding: Dp = 2.dp, + val indicatorSize: Dp = 6.dp, val infoButtonVerticalPadding: Dp = 13.dp, val largePadding: Dp = 32.dp, val listIconSize: Dp = 24.dp, @@ -76,6 +79,7 @@ data class Dimensions( val switchIconSize: Dp = 24.dp, val titleIconSize: Dp = 48.dp, val topBarHeight: Dp = 64.dp, + val topPadding: Dp = 20.dp, val verticalDividerPadding: Dp = 12.dp, val verticalSpace: Dp = 20.dp, val verticalSpacer: Dp = 1.dp, diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index f92ac6dc44..ccd498d2c3 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -2007,6 +2007,15 @@ msgstr "" msgid "%s was added to your account." msgstr "" +msgid "1. After clicking on the <b>Go to VPN settings</b> button below, click on the cogwheel next to the <b>Mullvad VPN</b> name." +msgstr "" + +msgid "2. To enable Auto-connect, click on the toggle next to <b>Always-on VPN</b>." +msgstr "" + +msgid "3. To enable Lockdown mode, click on the toggle next to <b>Block connections without VPN</b>." +msgstr "" + msgid "30 days was added to your account." msgstr "" @@ -2046,6 +2055,15 @@ msgstr "" msgid "Attention: this setting cannot be used in combination with <b>Use custom DNS server</b>." msgstr "" +msgid "Auto-connect & Lockdown mode" +msgstr "" + +msgid "Auto-connect & \\nLockdown mode" +msgstr "" + +msgid "Auto-connect is called Always-on VPN in the Android system settings and it makes sure you are constantly connected to the VPN tunnel and auto connects after restart." +msgstr "" + msgid "Blocking internet (device offline)" msgstr "" @@ -2085,6 +2103,9 @@ msgstr "" msgid "Excluded applications" msgstr "" +msgid "Go to VPN settings" +msgstr "" + msgid "Going to login will unblock the internet on this device." msgstr "" @@ -2100,6 +2121,9 @@ msgstr "" msgid "Install Mullvad VPN (%s) to stay up to date" msgstr "" +msgid "Makes sure the device is always on the VPN tunnel." +msgstr "" + msgid "Manage account" msgstr "" @@ -2154,9 +2178,18 @@ msgstr "" msgid "Submit" msgstr "" +msgid "The Auto-connect and Lockdown mode settings can be found in the Android system settings, follow this guide to enable one or both." +msgstr "" + +msgid "The Lockdown mode blocks all internet access if the VPN tunnel is manually disconnected. <br/><b>Warning: This setting blocks split apps and the Local Network Sharing feature</b>." +msgstr "" + msgid "The local DNS server will not work unless you enable \"Local Network Sharing\" under Preferences." msgstr "" +msgid "There is no VPN settings on your device" +msgstr "" + msgid "This address has already been entered." msgstr "" |
