From 63300e2542601610aeb7c0568509edf04a844fcb Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 18 Jul 2024 14:25:48 +0200 Subject: [PATCH 1/7] Add auto-start on launch to devices without always-on setting --- android/app/src/main/AndroidManifest.xml | 9 +++ .../compose/screen/VpnSettingsScreen.kt | 65 ++++++++++++------- .../compose/state/VpnSettingsUiState.kt | 3 + .../net/mullvad/mullvadvpn/di/UiModule.kt | 16 ++++- .../receiver/BootCompletedReceiver.kt | 30 +++++++++ .../AutoStartAndConnectOnBootRepository.kt | 49 ++++++++++++++ .../viewmodel/VpnSettingsViewModel.kt | 19 ++++-- .../viewmodel/VpnSettingsViewModelState.kt | 3 + .../resource/src/main/res/values/strings.xml | 2 + 9 files changed, 168 insertions(+), 28 deletions(-) create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/BootCompletedReceiver.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AutoStartAndConnectOnBootRepository.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 19e468783c75..8dc3c4e38551 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + + + + + + + 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 75d12bd7175e..99de9066e721 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 @@ -264,6 +264,7 @@ fun VpnSettings( dropUnlessResumed { navigator.navigate(ShadowsocksSettingsDestination) }, navigateToUdp2TcpSettings = dropUnlessResumed { navigator.navigate(Udp2TcpSettingsDestination) }, + onToggleAutoStartAndConnectOnBoot = vm::onToggleAutoStartAndConnectOnBoot, ) } @@ -303,6 +304,7 @@ fun VpnSettingsScreen( onWireguardPortSelected: (port: Constraint) -> Unit = {}, navigateToShadowSocksSettings: () -> Unit = {}, navigateToUdp2TcpSettings: () -> Unit = {}, + onToggleAutoStartAndConnectOnBoot: (Boolean) -> Unit = {}, ) { var expandContentBlockersState by rememberSaveable { mutableStateOf(false) } val biggerPadding = 54.dp @@ -330,33 +332,50 @@ fun VpnSettingsScreen( text = stringResource(id = R.string.auto_connect_and_lockdown_mode_footer) ) } - } - item { - Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) - HeaderSwitchComposeCell( - title = stringResource(R.string.auto_connect_legacy), - isToggled = state.isAutoConnectEnabled, - isEnabled = true, - onCellClicked = { newValue -> onToggleAutoConnect(newValue) }, - ) - } - item { - SwitchComposeSubtitleCell( - text = - HtmlCompat.fromHtml( - if (state.systemVpnSettingsAvailable) { + item { + Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) + HeaderSwitchComposeCell( + title = stringResource(R.string.auto_connect_legacy), + isToggled = state.isAutoConnectEnabled, + isEnabled = true, + onCellClicked = { newValue -> onToggleAutoConnect(newValue) }, + ) + } + item { + SwitchComposeSubtitleCell( + text = + HtmlCompat.fromHtml( textResource( R.string.auto_connect_footer_legacy, textResource(R.string.auto_connect_and_lockdown_mode), - ) - } else { - textResource(R.string.auto_connect_footer_legacy_tv) - }, - HtmlCompat.FROM_HTML_MODE_COMPACT, - ) - .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) - ) + ), + HtmlCompat.FROM_HTML_MODE_COMPACT, + ) + .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) + ) + } + } else { + item { + Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) + HeaderSwitchComposeCell( + title = stringResource(R.string.connect_on_start), + isToggled = state.autoStartAndConnectOnBoot, + onCellClicked = { newValue -> onToggleAutoStartAndConnectOnBoot(newValue) }, + ) + SwitchComposeSubtitleCell( + text = + HtmlCompat.fromHtml( + textResource( + R.string.connect_on_start_footer, + textResource(R.string.auto_connect_and_lockdown_mode), + ), + HtmlCompat.FROM_HTML_MODE_COMPACT, + ) + .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) + ) + } } + item { Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) HeaderSwitchComposeCell( 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 7884f199f2c4..4c5f2c2ffb3f 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 @@ -25,6 +25,7 @@ data class VpnSettingsUiState( val customWireguardPort: Port?, val availablePortRanges: List, val systemVpnSettingsAvailable: Boolean, + val autoStartAndConnectOnBoot: Boolean, ) { val isCustomWireguardPort = selectedWireguardPort is Constraint.Only && @@ -47,6 +48,7 @@ data class VpnSettingsUiState( customWireguardPort: Port? = null, availablePortRanges: List = emptyList(), systemVpnSettingsAvailable: Boolean = false, + autoStartAndConnectOnBoot: Boolean = false, ) = VpnSettingsUiState( mtu, @@ -64,6 +66,7 @@ data class VpnSettingsUiState( customWireguardPort, availablePortRanges, systemVpnSettingsAvailable, + autoStartAndConnectOnBoot, ) } } 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 b08708826d35..61d491094a12 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 @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.di +import android.content.ComponentName import android.content.Context import android.content.SharedPreferences import android.content.pm.PackageManager @@ -11,7 +12,9 @@ import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport import net.mullvad.mullvadvpn.lib.payment.PaymentProvider import net.mullvad.mullvadvpn.lib.shared.VoucherRepository +import net.mullvad.mullvadvpn.receiver.BootCompletedReceiver import net.mullvad.mullvadvpn.repository.ApiAccessRepository +import net.mullvad.mullvadvpn.repository.AutoStartAndConnectOnBootRepository import net.mullvad.mullvadvpn.repository.ChangelogRepository import net.mullvad.mullvadvpn.repository.CustomListsRepository import net.mullvad.mullvadvpn.repository.InAppNotificationController @@ -102,6 +105,10 @@ val uiModule = module { single { androidContext().packageManager } single(named(SELF_PACKAGE_NAME)) { androidContext().packageName } + single(named(BOOT_COMPLETED_RECEIVER_COMPONENT_NAME)) { + ComponentName(androidContext(), BootCompletedReceiver::class.java) + } + viewModel { SplitTunnelingViewModel(get(), get(), Dispatchers.Default) } single { ApplicationsProvider(get(), get(named(SELF_PACKAGE_NAME))) } @@ -127,6 +134,12 @@ val uiModule = module { single { ApiAccessRepository(get()) } single { NewDeviceRepository() } single { SplashCompleteRepository() } + single { + AutoStartAndConnectOnBootRepository( + get(), + get(named(BOOT_COMPLETED_RECEIVER_COMPONENT_NAME)), + ) + } single { AccountExpiryNotificationUseCase(get()) } single { TunnelStateNotificationUseCase(get()) } @@ -195,7 +208,7 @@ val uiModule = module { viewModel { SettingsViewModel(get(), get(), IS_PLAY_BUILD) } viewModel { SplashViewModel(get(), get(), get(), get()) } viewModel { VoucherDialogViewModel(get()) } - viewModel { VpnSettingsViewModel(get(), get(), get()) } + viewModel { VpnSettingsViewModel(get(), get(), get(), get()) } viewModel { WelcomeViewModel(get(), get(), get(), get(), isPlayBuild = IS_PLAY_BUILD) } viewModel { ReportProblemViewModel(get(), get()) } viewModel { ViewLogsViewModel(get()) } @@ -226,3 +239,4 @@ val uiModule = module { const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" const val APP_PREFERENCES_NAME = "${BuildConfig.APPLICATION_ID}.app_preferences" +const val BOOT_COMPLETED_RECEIVER_COMPONENT_NAME = "BOOT_COMPLETED_RECEIVER_COMPONENT_NAME" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/BootCompletedReceiver.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/BootCompletedReceiver.kt new file mode 100644 index 000000000000..9f153e724b9a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/receiver/BootCompletedReceiver.kt @@ -0,0 +1,30 @@ +package net.mullvad.mullvadvpn.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.net.VpnService +import co.touchlab.kermit.Logger +import net.mullvad.mullvadvpn.lib.common.constant.KEY_CONNECT_ACTION +import net.mullvad.mullvadvpn.lib.common.constant.VPN_SERVICE_CLASS + +class BootCompletedReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == Intent.ACTION_BOOT_COMPLETED) { + context?.let { startAndConnectTunnel(context) } + } + } + + private fun startAndConnectTunnel(context: Context) { + val hasVpnPermission = VpnService.prepare(context) == null + Logger.i("AutoStart on boot and connect, hasVpnPermission: $hasVpnPermission") + if (hasVpnPermission) { + val intent = + Intent().apply { + setClassName(context.packageName, VPN_SERVICE_CLASS) + action = KEY_CONNECT_ACTION + } + context.startForegroundService(intent) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AutoStartAndConnectOnBootRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AutoStartAndConnectOnBootRepository.kt new file mode 100644 index 000000000000..3fc0f38c7c65 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/AutoStartAndConnectOnBootRepository.kt @@ -0,0 +1,49 @@ +package net.mullvad.mullvadvpn.repository + +import android.content.ComponentName +import android.content.pm.PackageManager +import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DEFAULT +import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED +import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED +import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER +import android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED +import android.content.pm.PackageManager.DONT_KILL_APP +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class AutoStartAndConnectOnBootRepository( + private val packageManager: PackageManager, + private val bootCompletedComponentName: ComponentName, +) { + private val _autoStartAndConnectOnBoot = MutableStateFlow(isAutoStartAndConnectOnBoot()) + val autoStartAndConnectOnBoot: StateFlow = _autoStartAndConnectOnBoot + + fun setAutoStartAndConnectOnBoot(enabled: Boolean) { + packageManager.setComponentEnabledSetting( + bootCompletedComponentName, + if (enabled) { + COMPONENT_ENABLED_STATE_ENABLED + } else { + COMPONENT_ENABLED_STATE_DISABLED + }, + DONT_KILL_APP, + ) + + _autoStartAndConnectOnBoot.value = isAutoStartAndConnectOnBoot() + } + + private fun isAutoStartAndConnectOnBoot(): Boolean = + when (packageManager.getComponentEnabledSetting(bootCompletedComponentName)) { + COMPONENT_ENABLED_STATE_DEFAULT -> BOOT_COMPLETED_DEFAULT_STATE + COMPONENT_ENABLED_STATE_ENABLED -> true + COMPONENT_ENABLED_STATE_DISABLED -> false + COMPONENT_ENABLED_STATE_DISABLED_USER, + COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED -> + error("Enabled setting only applicable for application") + else -> error("Unknown component enabled setting") + } + + companion object { + private const val BOOT_COMPLETED_DEFAULT_STATE = false + } +} 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 3baeda244a10..ec73067687be 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 @@ -28,6 +28,7 @@ import net.mullvad.mullvadvpn.lib.model.Port import net.mullvad.mullvadvpn.lib.model.QuantumResistantState import net.mullvad.mullvadvpn.lib.model.Settings import net.mullvad.mullvadvpn.lib.model.WireguardConstraints +import net.mullvad.mullvadvpn.repository.AutoStartAndConnectOnBootRepository import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.repository.SettingsRepository import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsAvailableUseCase @@ -46,6 +47,7 @@ class VpnSettingsViewModel( private val repository: SettingsRepository, private val relayListRepository: RelayListRepository, private val systemVpnSettingsUseCase: SystemVpnSettingsAvailableUseCase, + private val autoStartAndConnectOnBootRepository: AutoStartAndConnectOnBootRepository, private val dispatcher: CoroutineDispatcher = Dispatchers.IO, ) : ViewModel() { @@ -55,10 +57,12 @@ class VpnSettingsViewModel( private val customPort = MutableStateFlow(null) private val vmState = - combine(repository.settingsUpdates, relayListRepository.portRanges, customPort) { - settings, - portRanges, - customWgPort -> + combine( + repository.settingsUpdates, + relayListRepository.portRanges, + customPort, + autoStartAndConnectOnBootRepository.autoStartAndConnectOnBoot, + ) { settings, portRanges, customWgPort, autoStartAndConnectOnBoot -> VpnSettingsViewModelState( mtuValue = settings?.tunnelOptions?.wireguard?.mtu, isAutoConnectEnabled = settings?.autoConnect ?: false, @@ -78,6 +82,7 @@ class VpnSettingsViewModel( customWireguardPort = customWgPort, availablePortRanges = portRanges, systemVpnSettingsAvailable = systemVpnSettingsUseCase(), + autoStartAndConnectOnBoot = autoStartAndConnectOnBoot, ) } .stateIn( @@ -254,6 +259,12 @@ class VpnSettingsViewModel( } } + fun onToggleAutoStartAndConnectOnBoot(autoStartAndConnect: Boolean) { + viewModelScope.launch(dispatcher) { + autoStartAndConnectOnBootRepository.setAutoStartAndConnectOnBoot(autoStartAndConnect) + } + } + private fun updateDefaultDnsOptionsViaRepository(contentBlockersOption: DefaultDnsOptions) = viewModelScope.launch(dispatcher) { repository 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 31d5515a3c90..4798a8a91b95 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 @@ -25,6 +25,7 @@ data class VpnSettingsViewModelState( val customWireguardPort: Port?, val availablePortRanges: List, val systemVpnSettingsAvailable: Boolean, + val autoStartAndConnectOnBoot: Boolean, ) { val isCustomWireguardPort = selectedWireguardPort is Constraint.Only && @@ -47,6 +48,7 @@ data class VpnSettingsViewModelState( customWireguardPort, availablePortRanges, systemVpnSettingsAvailable, + autoStartAndConnectOnBoot, ) companion object { @@ -67,6 +69,7 @@ data class VpnSettingsViewModelState( customWireguardPort = null, availablePortRanges = emptyList(), systemVpnSettingsAvailable = false, + autoStartAndConnectOnBoot = false, ) } } diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 338aae32eeaf..fc02f8cc0d79 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -403,4 +403,6 @@ In %1$s more... Connect + Connect on device start-up + Automatically connect on device start-up. Only works if the app has been granted the VPN permission. From 3b5218697a5ee581b36839941573c05d212e1e3b Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 18 Jul 2024 14:28:17 +0200 Subject: [PATCH 2/7] Update and add unit tests --- ...AutoStartAndConnectOnBootRepositoryTest.kt | 73 +++++++++++++++++++ .../viewmodel/VpnSettingsViewModelTest.kt | 42 +++++++++++ 2 files changed, 115 insertions(+) create mode 100644 android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/AutoStartAndConnectOnBootRepositoryTest.kt diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/AutoStartAndConnectOnBootRepositoryTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/AutoStartAndConnectOnBootRepositoryTest.kt new file mode 100644 index 000000000000..f29ba4c2df2b --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/repository/AutoStartAndConnectOnBootRepositoryTest.kt @@ -0,0 +1,73 @@ +package net.mullvad.mullvadvpn.repository + +import android.content.ComponentName +import android.content.pm.PackageManager +import app.cash.turbine.test +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AutoStartAndConnectOnBootRepositoryTest { + + private val mockPackageManager: PackageManager = mockk() + private val mockComponentName: ComponentName = mockk() + + private lateinit var autoStartAndConnectOnBootRepository: AutoStartAndConnectOnBootRepository + + @BeforeEach + fun setUp() { + every { mockPackageManager.getComponentEnabledSetting(mockComponentName) } returns + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT + + autoStartAndConnectOnBootRepository = + AutoStartAndConnectOnBootRepository( + packageManager = mockPackageManager, + bootCompletedComponentName = mockComponentName, + ) + } + + @Test + fun `autoStartAndConnectOnBoot should emit false when default state is returned by package manager`() = + runTest { + // Assert + autoStartAndConnectOnBootRepository.autoStartAndConnectOnBoot.test { + assertEquals(false, awaitItem()) + } + } + + @Test + fun `when setting autoStartAndConnectOnBoot to true should call package manager and update autoStartAndConnectOnBoot`() = + runTest { + // Arrange + val targetState = true + every { + mockPackageManager.setComponentEnabledSetting( + mockComponentName, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP, + ) + } just Runs + every { mockPackageManager.getComponentEnabledSetting(mockComponentName) } returns + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + + // Act, Assert + autoStartAndConnectOnBootRepository.autoStartAndConnectOnBoot.test { + assertEquals(false, awaitItem()) // Default state + autoStartAndConnectOnBootRepository.setAutoStartAndConnectOnBoot(targetState) + verify { + mockPackageManager.setComponentEnabledSetting( + mockComponentName, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP, + ) + } + assertEquals(targetState, awaitItem()) + } + } +} 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 1e15b1956a31..5b878b52b925 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 @@ -3,11 +3,14 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.viewModelScope import app.cash.turbine.test import arrow.core.right +import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.unmockkAll +import io.mockk.verify import kotlin.test.assertEquals import kotlin.test.assertIs import kotlinx.coroutines.cancel @@ -26,6 +29,7 @@ import net.mullvad.mullvadvpn.lib.model.Settings import net.mullvad.mullvadvpn.lib.model.TunnelOptions import net.mullvad.mullvadvpn.lib.model.WireguardConstraints import net.mullvad.mullvadvpn.lib.model.WireguardTunnelOptions +import net.mullvad.mullvadvpn.repository.AutoStartAndConnectOnBootRepository import net.mullvad.mullvadvpn.repository.RelayListRepository import net.mullvad.mullvadvpn.repository.SettingsRepository import net.mullvad.mullvadvpn.usecase.SystemVpnSettingsAvailableUseCase @@ -41,9 +45,12 @@ class VpnSettingsViewModelTest { private val mockSystemVpnSettingsUseCase: SystemVpnSettingsAvailableUseCase = mockk(relaxed = true) private val mockRelayListRepository: RelayListRepository = mockk() + private val mockAutoStartAndConnectOnBootRepository: AutoStartAndConnectOnBootRepository = + mockk() private val mockSettingsUpdate = MutableStateFlow(null) private val portRangeFlow = MutableStateFlow(emptyList()) + private val autoStartAndConnectOnBootFlow = MutableStateFlow(false) private lateinit var viewModel: VpnSettingsViewModel @@ -51,6 +58,8 @@ class VpnSettingsViewModelTest { fun setup() { every { mockSettingsRepository.settingsUpdates } returns mockSettingsUpdate every { mockRelayListRepository.portRanges } returns portRangeFlow + every { mockAutoStartAndConnectOnBootRepository.autoStartAndConnectOnBoot } returns + autoStartAndConnectOnBootFlow viewModel = VpnSettingsViewModel( @@ -58,6 +67,7 @@ class VpnSettingsViewModelTest { systemVpnSettingsUseCase = mockSystemVpnSettingsUseCase, relayListRepository = mockRelayListRepository, dispatcher = UnconfinedTestDispatcher(), + autoStartAndConnectOnBootRepository = mockAutoStartAndConnectOnBootRepository, ) } @@ -195,4 +205,36 @@ class VpnSettingsViewModelTest { assertEquals(systemVpnSettingsAvailable, awaitItem().systemVpnSettingsAvailable) } } + + @Test + fun `when autoStartAndConnectOnBoot is true then uiState should be autoStart=true`() = runTest { + // Arrange + val connectOnStart = true + + // Act + autoStartAndConnectOnBootFlow.value = connectOnStart + + // Assert + viewModel.uiState.test { + assertEquals(connectOnStart, awaitItem().autoStartAndConnectOnBoot) + } + } + + @Test + fun `calling onToggleAutoStartAndConnectOnBoot should call autoStartAndConnectOnBoot`() = + runTest { + // Arrange + val targetState = true + every { + mockAutoStartAndConnectOnBootRepository.setAutoStartAndConnectOnBoot(targetState) + } just Runs + + // Act + viewModel.onToggleAutoStartAndConnectOnBoot(targetState) + + // Assert + verify { + mockAutoStartAndConnectOnBootRepository.setAutoStartAndConnectOnBoot(targetState) + } + } } From 5b3073a412671334684bc4bafdad3ed132e3a22d Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 18 Jul 2024 14:41:50 +0200 Subject: [PATCH 3/7] Update and add ui tests --- .../compose/screen/VpnSettingsScreenTest.kt | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt index a15875477a07..5c0c004036a8 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt @@ -47,8 +47,6 @@ class VpnSettingsScreenTest { // Arrange setContentWithTheme { VpnSettingsScreen(state = VpnSettingsUiState.createDefault()) } - onNodeWithText("Auto-connect (legacy)").assertExists() - onNodeWithTag(LAZY_LIST_TEST_TAG) .performScrollToNode(hasTestTag(LAZY_LIST_LAST_ITEM_TEST_TAG)) @@ -524,6 +522,43 @@ class VpnSettingsScreenTest { verify { mockOnShowCustomPortDialog.invoke() } } + @Test + fun ensureConnectOnStartIsShownWhenSystemVpnSettingsAvailableIsFalse() = + composeExtension.use { + // Arrange + setContentWithTheme { + VpnSettingsScreen( + state = VpnSettingsUiState.createDefault(systemVpnSettingsAvailable = false), + ) + } + + // Assert + onNodeWithText("Connect on device start-up").assertExists() + } + + @Test + fun whenClickingOnConnectOnStartShouldCallOnToggleAutoStartAndConnectOnBoot() = + composeExtension.use { + // Arrange + val mockOnToggleAutoStartAndConnectOnBoot: (Boolean) -> Unit = mockk(relaxed = true) + setContentWithTheme { + VpnSettingsScreen( + state = + VpnSettingsUiState.createDefault( + systemVpnSettingsAvailable = false, + autoStartAndConnectOnBoot = false + ), + onToggleAutoStartAndConnectOnBoot = mockOnToggleAutoStartAndConnectOnBoot + ) + } + + // Act + onNodeWithText("Connect on device start-up").performClick() + + // Assert + verify { mockOnToggleAutoStartAndConnectOnBoot.invoke(true) } + } + companion object { private const val LOCAL_DNS_SERVER_WARNING = "The local DNS server will not work unless you enable " + From 1a0f8524aef5d224e2001bd958c0a1cb42986aaa Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 18 Jul 2024 14:44:52 +0200 Subject: [PATCH 4/7] Update changelog --- android/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 5c632d7c9e2f..fc05c7e91dba 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -27,6 +27,10 @@ Line wrap the file at 100 chars. Th - Add DAITA (Defence against AI-guided Traffic Analysis) setting. - Add WireGuard over Shadowsocks. - Add feature indicators to the main view along with redesigning the connection details. +- Add new "Connect on device start-up" setting for devices without system VPN settings. + +### Removed +- Legacy auto-connect feature. ### Changed - Update colors in the app to be more in line with material design. From 9db61ea67bf6fc14e5cf98c20b840d8369d19292 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Thu, 18 Jul 2024 14:45:02 +0200 Subject: [PATCH 5/7] Update translations --- gui/locales/messages.pot | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index a64416dca1e9..aac7d2de6faa 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -2308,6 +2308,9 @@ 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 "Automatically connect on device start-up. Only works if the app has been granted the VPN permission." +msgstr "" + msgid "Automatically connect when the app launches. This setting will be replaced with a new connect on device start-up feature in a future update." msgstr "" @@ -2317,6 +2320,9 @@ msgstr "" msgid "Changes to DNS related settings might not go into effect immediately due to cached results." msgstr "" +msgid "Connect on device start-up" +msgstr "" + msgid "Connecting..." msgstr "" From fced0fa39344e4b2ca1f8cf2127681850f09d898 Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Thu, 29 Aug 2024 12:58:39 +0200 Subject: [PATCH 6/7] Disable daemon auto-start setting on Android --- mullvad-daemon/src/settings/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mullvad-daemon/src/settings/mod.rs b/mullvad-daemon/src/settings/mod.rs index d5a222cd021c..8a42ec620448 100644 --- a/mullvad-daemon/src/settings/mod.rs +++ b/mullvad-daemon/src/settings/mod.rs @@ -111,6 +111,9 @@ impl SettingsPersister { if cfg!(target_os = "android") { should_save |= !settings.tunnel_options.generic.enable_ipv6; settings.tunnel_options.generic.enable_ipv6 = true; + + // Auto-connect is managed by Android itself. + settings.auto_connect = false; } if crate::version::is_beta_version() { should_save |= !settings.show_beta_releases; From a984929ab268193a8569f6604e596ad46d0b7051 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Mon, 2 Sep 2024 16:03:43 +0200 Subject: [PATCH 7/7] Remove auto-connect feature from ui --- .../compose/screen/VpnSettingsScreenTest.kt | 6 ++--- ...SettingsUiStatePreviewParameterProvider.kt | 2 +- .../compose/screen/VpnSettingsScreen.kt | 25 ------------------- .../compose/state/VpnSettingsUiState.kt | 3 --- .../repository/SettingsRepository.kt | 2 -- .../viewmodel/VpnSettingsViewModel.kt | 9 ------- .../viewmodel/VpnSettingsViewModelState.kt | 3 --- .../lib/daemon/grpc/ManagementService.kt | 7 ------ .../lib/daemon/grpc/mapper/ToDomain.kt | 1 - .../lib/model/SetAutoConnectError.kt | 5 ---- .../mullvad/mullvadvpn/lib/model/Settings.kt | 1 - 11 files changed, 4 insertions(+), 60 deletions(-) delete mode 100644 android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetAutoConnectError.kt diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt index 5c0c004036a8..42a939250216 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreenTest.kt @@ -528,7 +528,7 @@ class VpnSettingsScreenTest { // Arrange setContentWithTheme { VpnSettingsScreen( - state = VpnSettingsUiState.createDefault(systemVpnSettingsAvailable = false), + state = VpnSettingsUiState.createDefault(systemVpnSettingsAvailable = false) ) } @@ -546,9 +546,9 @@ class VpnSettingsScreenTest { state = VpnSettingsUiState.createDefault( systemVpnSettingsAvailable = false, - autoStartAndConnectOnBoot = false + autoStartAndConnectOnBoot = false, ), - onToggleAutoStartAndConnectOnBoot = mockOnToggleAutoStartAndConnectOnBoot + onToggleAutoStartAndConnectOnBoot = mockOnToggleAutoStartAndConnectOnBoot, ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/VpnSettingsUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/VpnSettingsUiStatePreviewParameterProvider.kt index e583e6ea0ca6..c041f5516c52 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/VpnSettingsUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/VpnSettingsUiStatePreviewParameterProvider.kt @@ -19,7 +19,6 @@ class VpnSettingsUiStatePreviewParameterProvider : PreviewParameterProvider Unit = {}, onToggleBlockAds: (Boolean) -> Unit = {}, onToggleBlockMalware: (Boolean) -> Unit = {}, - onToggleAutoConnect: (Boolean) -> Unit = {}, onToggleLocalNetworkSharing: (Boolean) -> Unit = {}, onDisableDaita: () -> Unit = {}, onToggleBlockAdultContent: (Boolean) -> Unit = {}, @@ -332,28 +329,6 @@ fun VpnSettingsScreen( text = stringResource(id = R.string.auto_connect_and_lockdown_mode_footer) ) } - item { - Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) - HeaderSwitchComposeCell( - title = stringResource(R.string.auto_connect_legacy), - isToggled = state.isAutoConnectEnabled, - isEnabled = true, - onCellClicked = { newValue -> onToggleAutoConnect(newValue) }, - ) - } - item { - SwitchComposeSubtitleCell( - text = - HtmlCompat.fromHtml( - textResource( - R.string.auto_connect_footer_legacy, - textResource(R.string.auto_connect_and_lockdown_mode), - ), - HtmlCompat.FROM_HTML_MODE_COMPACT, - ) - .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) - ) - } } else { 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 4c5f2c2ffb3f..eede76ff7c01 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 @@ -11,7 +11,6 @@ import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem data class VpnSettingsUiState( val mtu: Mtu?, - val isAutoConnectEnabled: Boolean, val isLocalNetworkSharingEnabled: Boolean, val isDaitaEnabled: Boolean, val isCustomDnsEnabled: Boolean, @@ -34,7 +33,6 @@ data class VpnSettingsUiState( companion object { fun createDefault( mtu: Mtu? = null, - isAutoConnectEnabled: Boolean = false, isLocalNetworkSharingEnabled: Boolean = false, isDaitaEnabled: Boolean = false, isCustomDnsEnabled: Boolean = false, @@ -52,7 +50,6 @@ data class VpnSettingsUiState( ) = VpnSettingsUiState( mtu, - isAutoConnectEnabled, isLocalNetworkSharingEnabled, isDaitaEnabled, isCustomDnsEnabled, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt index 0fa5e69940dd..8be8d2ae7ed2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt @@ -68,8 +68,6 @@ class SettingsRepository( suspend fun setObfuscation(value: ObfuscationMode) = managementService.setObfuscation(value) - suspend fun setAutoConnect(isEnabled: Boolean) = managementService.setAutoConnect(isEnabled) - suspend fun setLocalNetworkSharing(isEnabled: Boolean) = managementService.setAllowLan(isEnabled) 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 ec73067687be..7829d0ce24cc 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 @@ -65,7 +65,6 @@ class VpnSettingsViewModel( ) { settings, portRanges, customWgPort, autoStartAndConnectOnBoot -> VpnSettingsViewModelState( mtuValue = settings?.tunnelOptions?.wireguard?.mtu, - isAutoConnectEnabled = settings?.autoConnect ?: false, isLocalNetworkSharingEnabled = settings?.allowLan ?: false, isDaitaEnabled = settings?.isDaitaEnabled() ?: false, isCustomDnsEnabled = settings?.isCustomDnsEnabled() ?: false, @@ -114,14 +113,6 @@ class VpnSettingsViewModel( } } - fun onToggleAutoConnect(isEnabled: Boolean) { - viewModelScope.launch(dispatcher) { - repository.setAutoConnect(isEnabled).onLeft { - _uiSideEffect.send(VpnSettingsSideEffect.ShowToast.GenericError) - } - } - } - fun onToggleLocalNetworkSharing(isEnabled: Boolean) { viewModelScope.launch(dispatcher) { repository.setLocalNetworkSharing(isEnabled).onLeft { 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 4798a8a91b95..624716198d6f 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 @@ -11,7 +11,6 @@ import net.mullvad.mullvadvpn.lib.model.QuantumResistantState data class VpnSettingsViewModelState( val mtuValue: Mtu?, - val isAutoConnectEnabled: Boolean, val isLocalNetworkSharingEnabled: Boolean, val isDaitaEnabled: Boolean, val isCustomDnsEnabled: Boolean, @@ -34,7 +33,6 @@ data class VpnSettingsViewModelState( fun toUiState(): VpnSettingsUiState = VpnSettingsUiState( mtuValue, - isAutoConnectEnabled, isLocalNetworkSharingEnabled, isDaitaEnabled, isCustomDnsEnabled, @@ -55,7 +53,6 @@ data class VpnSettingsViewModelState( fun default() = VpnSettingsViewModelState( mtuValue = null, - isAutoConnectEnabled = false, isLocalNetworkSharingEnabled = false, isDaitaEnabled = false, isCustomDnsEnabled = false, diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt index 514d4f83aa6b..3ffbe0fc4a2e 100644 --- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/ManagementService.kt @@ -103,7 +103,6 @@ import net.mullvad.mullvadvpn.lib.model.RemoveApiAccessMethodError import net.mullvad.mullvadvpn.lib.model.RemoveSplitTunnelingAppError import net.mullvad.mullvadvpn.lib.model.SetAllowLanError import net.mullvad.mullvadvpn.lib.model.SetApiAccessMethodError -import net.mullvad.mullvadvpn.lib.model.SetAutoConnectError import net.mullvad.mullvadvpn.lib.model.SetDaitaSettingsError import net.mullvad.mullvadvpn.lib.model.SetDnsOptionsError import net.mullvad.mullvadvpn.lib.model.SetObfuscationOptionsError @@ -502,12 +501,6 @@ class ManagementService( .mapLeft(SetObfuscationOptionsError::Unknown) .mapEmpty() - suspend fun setAutoConnect(isEnabled: Boolean): Either = - Either.catch { grpc.setAutoConnect(BoolValue.of(isEnabled)) } - .onLeft { Logger.e("Set auto connect error") } - .mapLeft(SetAutoConnectError::Unknown) - .mapEmpty() - suspend fun setAllowLan(allow: Boolean): Either = Either.catch { grpc.setAllowLan(BoolValue.of(allow)) } .onLeft { Logger.e("Set allow lan error") } diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt index 0f57ce58dc99..99b4554f0b2c 100644 --- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt +++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt @@ -254,7 +254,6 @@ internal fun ManagementInterface.Settings.toDomain(): Settings = obfuscationSettings = obfuscationSettings.toDomain(), customLists = customLists.customListsList.map { it.toDomain() }, allowLan = allowLan, - autoConnect = autoConnect, tunnelOptions = tunnelOptions.toDomain(), relayOverrides = relayOverridesList.map { it.toDomain() }, showBetaReleases = showBetaReleases, diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetAutoConnectError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetAutoConnectError.kt deleted file mode 100644 index b2b3b74edf36..000000000000 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/SetAutoConnectError.kt +++ /dev/null @@ -1,5 +0,0 @@ -package net.mullvad.mullvadvpn.lib.model - -sealed interface SetAutoConnectError { - data class Unknown(val throwable: Throwable) : SetAutoConnectError -} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Settings.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Settings.kt index f59d85184cfd..b3f1a2e8a071 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Settings.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/Settings.kt @@ -8,7 +8,6 @@ data class Settings( val obfuscationSettings: ObfuscationSettings, val customLists: List, val allowLan: Boolean, - val autoConnect: Boolean, val tunnelOptions: TunnelOptions, val relayOverrides: List, val showBetaReleases: Boolean,