Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add auto-start on launch to devices without always-on setting #6371

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Line wrap the file at 100 chars. Th
## [Unreleased]
### Added
- Add the ability to customize how the app talks to the api.
- Add auto connect and start setting for devices without system vpn settings.

### Changed
- Migrate underlaying communication wtih daemon to gRPC. This also implies major changes and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,6 @@ class VpnSettingsScreenTest {
)
}

onNodeWithText("Auto-connect (legacy)").assertExists()

onNodeWithTag(LAZY_LIST_TEST_TAG)
.performScrollToNode(hasTestTag(LAZY_LIST_LAST_ITEM_TEST_TAG))

Expand Down Expand Up @@ -606,6 +604,43 @@ class VpnSettingsScreenTest {
verify { mockOnShowCustomPortDialog.invoke() }
}

@Test
fun ensureConnectOnStartIsShownWhenSystemVpnSettingsAvailableIsFalse() =
composeExtension.use {
// Arrange
setContentWithTheme {
VpnSettingsScreen(
state = VpnSettingsUiState.createDefault(systemVpnSettingsAvailable = false),
)
}

// Assert
onNodeWithText("Connect on boot").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 boot").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 " +
Expand Down
9 changes: 9 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- https://developer.android.com/guide/components/fg-service-types#system-exempted -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-feature android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature android:name="android.hardware.faketouch"
Expand Down Expand Up @@ -105,5 +106,13 @@
<meta-data android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<receiver android:name="net.mullvad.mullvadvpn.receiver.BootCompletedReceiver"
android:enabled="false"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ fun VpnSettings(
onSelectQuantumResistanceSetting = vm::onSelectQuantumResistanceSetting,
onWireguardPortSelected = vm::onWireguardPortSelected,
onObfuscationPortSelected = vm::onObfuscationPortSelected,
onToggleAutoStartAndConnectOnBoot = vm::onToggleAutoStartAndConnectOnBoot
)
}

Expand Down Expand Up @@ -292,6 +293,7 @@ fun VpnSettingsScreen(
onSelectQuantumResistanceSetting: (quantumResistant: QuantumResistantState) -> Unit = {},
onWireguardPortSelected: (port: Constraint<Port>) -> Unit = {},
onObfuscationPortSelected: (port: Constraint<Port>) -> Unit = {},
onToggleAutoStartAndConnectOnBoot: (Boolean) -> Unit = {}
) {
var expandContentBlockersState by rememberSaveable { mutableStateOf(false) }
var expandUdp2TcpPortSettings by rememberSaveable { mutableStateOf(false) }
Expand Down Expand Up @@ -320,33 +322,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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ data class VpnSettingsUiState(
val customWireguardPort: Constraint<Port>?,
val availablePortRanges: List<PortRange>,
val systemVpnSettingsAvailable: Boolean,
val autoStartAndConnectOnBoot: Boolean
) {
val selectObfuscationPortEnabled = selectedObfuscation != SelectedObfuscation.Off

Expand All @@ -41,6 +42,7 @@ data class VpnSettingsUiState(
customWireguardPort: Constraint.Only<Port>? = null,
availablePortRanges: List<PortRange> = emptyList(),
systemVpnSettingsAvailable: Boolean = false,
autoStartAndConnectOnBoot: Boolean = false,
) =
VpnSettingsUiState(
mtu,
Expand All @@ -55,7 +57,8 @@ data class VpnSettingsUiState(
selectedWireguardPort,
customWireguardPort,
availablePortRanges,
systemVpnSettingsAvailable
systemVpnSettingsAvailable,
autoStartAndConnectOnBoot
)
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -97,6 +100,10 @@ val uiModule = module {
single<PackageManager> { androidContext().packageManager }
single<String>(named(SELF_PACKAGE_NAME)) { androidContext().packageName }

single<ComponentName>(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))) }
Expand All @@ -123,6 +130,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()) }
Expand Down Expand Up @@ -187,7 +200,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()) }
Expand Down Expand Up @@ -215,3 +228,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"
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package net.mullvad.mullvadvpn.receiver

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.VpnService
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
if (hasVpnPermission) {
val intent =
Intent().apply {
setClassName(context.packageName, VPN_SERVICE_CLASS)
action = KEY_CONNECT_ACTION
}
context.startForegroundService(intent)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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

class AutoStartAndConnectOnBootRepository(
private val packageManager: PackageManager,
private val bootCompletedComponentName: ComponentName
) {
val autoStartAndConnectOnBoot = MutableStateFlow(isAutoStartAndConnectOnBoot())

fun setAutoStartAndConnectOnBoot(connect: Boolean) {
packageManager.setComponentEnabledSetting(
bootCompletedComponentName,
if (connect) {
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import net.mullvad.mullvadvpn.lib.model.QuantumResistantState
import net.mullvad.mullvadvpn.lib.model.SelectedObfuscation
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
Expand All @@ -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() {

Expand All @@ -59,7 +61,8 @@ class VpnSettingsViewModel(
repository.settingsUpdates,
relayListRepository.portRanges,
customPort,
) { settings, portRanges, customWgPort ->
autoStartAndConnectOnBootRepository.autoStartAndConnectOnBoot
) { settings, portRanges, customWgPort, autoStartAndConnectOnBoot ->
VpnSettingsViewModelState(
mtuValue = settings?.tunnelOptions?.wireguard?.mtu,
isAutoConnectEnabled = settings?.autoConnect ?: false,
Expand All @@ -76,7 +79,8 @@ class VpnSettingsViewModel(
selectedWireguardPort = settings?.getWireguardPort() ?: Constraint.Any,
customWireguardPort = customWgPort,
availablePortRanges = portRanges,
systemVpnSettingsAvailable = systemVpnSettingsUseCase()
systemVpnSettingsAvailable = systemVpnSettingsUseCase(),
autoStartAndConnectOnBoot = autoStartAndConnectOnBoot
)
}
.stateIn(
Expand Down Expand Up @@ -244,6 +248,12 @@ class VpnSettingsViewModel(
}
}

fun onToggleAutoStartAndConnectOnBoot(autoStartAndConnect: Boolean) {
viewModelScope.launch(dispatcher) {
autoStartAndConnectOnBootRepository.setAutoStartAndConnectOnBoot(autoStartAndConnect)
}
}

private fun updateDefaultDnsOptionsViaRepository(contentBlockersOption: DefaultDnsOptions) =
viewModelScope.launch(dispatcher) {
repository
Expand Down
Loading
Loading