diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 56f154b..9a34051 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -59,6 +59,7 @@ import com.tailscale.ipn.ui.view.MainViewNavigation import com.tailscale.ipn.ui.view.ManagedByView import com.tailscale.ipn.ui.view.MullvadExitNodePicker import com.tailscale.ipn.ui.view.MullvadExitNodePickerList +import com.tailscale.ipn.ui.view.MullvadInfoView import com.tailscale.ipn.ui.view.PeerDetails import com.tailscale.ipn.ui.view.PermissionsView import com.tailscale.ipn.ui.view.RunExitNodeView @@ -179,6 +180,7 @@ class MainActivity : ComponentActivity() { }, onNavigateBackToExitNodes = backTo("exitNodes"), onNavigateToMullvad = { navController.navigate("mullvad") }, + onNavigateToMullvadInfo = { navController.navigate("mullvad_info") }, onNavigateBackToMullvad = backTo("mullvad"), onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") }, onNavigateToRunAsExitNode = { navController.navigate("runExitNode") }) @@ -198,6 +200,7 @@ class MainActivity : ComponentActivity() { composable("settings") { SettingsView(settingsNav) } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) } + composable("mullvad_info") { MullvadInfoView(exitNodePickerNav) } composable( "mullvad/{countryCode}", arguments = diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt index a1ad251..c72aa7a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt @@ -49,6 +49,7 @@ fun ExitNodePicker( val mullvadExitNodesByCountryCode by model.mullvadExitNodesByCountryCode.collectAsState() val mullvadExitNodeCount by model.mullvadExitNodeCount.collectAsState() val anyActive by model.anyActive.collectAsState() + val shouldShowMullvadInfo by model.shouldShowMullvadInfo.collectAsState() val allowLANAccess = Notifier.prefs.collectAsState().value?.ExitNodeAllowLANAccess == true val showRunAsExitNode by MDMSettings.runExitNode.flow.collectAsState() val allowLanAccessMDMDisposition by MDMSettings.exitNodeAllowLANAccess.flow.collectAsState() @@ -91,6 +92,11 @@ fun ExitNodePicker( MullvadItem( nav, mullvadExitNodesByCountryCode.size, mullvadExitNodesByCountryCode.selected) } + } else if (shouldShowMullvadInfo) { + item(key = "mullvad_info") { + Lists.SectionDivider() + MullvadInfoItem(nav) + } } if (!allowLanAccessMDMDisposition.hiddenFromUser) { @@ -167,6 +173,24 @@ fun MullvadItem(nav: ExitNodePickerNav, count: Int, selected: Boolean) { } } +@Composable +fun MullvadInfoItem(nav: ExitNodePickerNav) { + Box { + ListItem( + modifier = Modifier.clickable { nav.onNavigateToMullvadInfo() }, + headlineContent = { + Text( + stringResource(R.string.mullvad_exit_nodes), + style = MaterialTheme.typography.bodyMedium) + }, + supportingContent = { + Text( + stringResource(R.string.enable_in_the_admin_console), + style = MaterialTheme.typography.bodyMedium) + }) + } +} + @Composable fun RunAsExitNodeItem( nav: ExitNodePickerNav, diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadInfoView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadInfoView.kt new file mode 100644 index 0000000..3f83337 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadInfoView.kt @@ -0,0 +1,56 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.tailscale.ipn.R +import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav + +@Composable +fun MullvadInfoView(nav: ExitNodePickerNav) { + Scaffold( + topBar = { + Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBackToExitNodes) + }) { innerPadding -> + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 48.dp), + modifier = Modifier.padding(innerPadding)) { + item { + Image( + painter = painterResource(id = R.drawable.mullvad_logo), + contentDescription = stringResource(R.string.the_mullvad_vpn_logo)) + } + item { + Text( + stringResource(R.string.mullvad_info_title), + fontFamily = MaterialTheme.typography.titleLarge.fontFamily, + fontSize = MaterialTheme.typography.titleLarge.fontSize, + fontWeight = FontWeight.SemiBold) + } + item { + Text( + stringResource(R.string.mullvad_info_explainer), + color = MaterialTheme.colorScheme.secondary, + textAlign = TextAlign.Center) + } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt index 0e952b6..c24b4e5 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt @@ -23,6 +23,7 @@ data class ExitNodePickerNav( val onNavigateBackHome: () -> Unit, val onNavigateBackToExitNodes: () -> Unit, val onNavigateToMullvad: () -> Unit, + val onNavigateToMullvadInfo: () -> Unit, val onNavigateBackToMullvad: () -> Unit, val onNavigateToMullvadCountry: (String) -> Unit, val onNavigateToRunAsExitNode: () -> Unit, @@ -55,6 +56,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel val mullvadBestAvailableByCountry: StateFlow> = MutableStateFlow(TreeMap()) val mullvadExitNodeCount: StateFlow = MutableStateFlow(0) val anyActive: StateFlow = MutableStateFlow(false) + val shouldShowMullvadInfo: StateFlow = MutableStateFlow(false) init { viewModelScope.launch { @@ -128,6 +130,13 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel mullvadBestAvailableByCountry.set(bestAvailableByCountry) anyActive.set(allNodes.any { it.selected }) + + prefs?.let { prefs -> + // Only show the Mullvad info view if the user is an admin and is using a Tailscale + // control server, as it wouldn't be actionable information otherwise. + shouldShowMullvadInfo.set( + netmap.SelfNode.isAdmin && prefs.ControlURL.endsWith(".tailscale.com")) + } } } } diff --git a/android/src/main/res/drawable/mullvad_logo.png b/android/src/main/res/drawable/mullvad_logo.png new file mode 100644 index 0000000..9427849 Binary files /dev/null and b/android/src/main/res/drawable/mullvad_logo.png differ diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 664c68a..572c4eb 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -283,4 +283,10 @@ Specifies a list of apps that will always use Tailscale routes and DNS when Tailscale is running. All other apps won\'t use Tailscale if this value is non-empty. Included packages Excluded packages + + + Enable in the admin console + Once you enable Mullvad VPN on the admin console, you\'ll be able to encrypt and route your traffic using Mullvad’s global network of servers as exit nodes. + Mullvad VPN is not configured + The Mullvad VPN logo