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 autostart on TV and remove auto connect feature on android #6761

Merged
merged 7 commits into from
Sep 26, 2024
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
4 changes: 4 additions & 0 deletions android/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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 " +
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 @@ -111,5 +112,13 @@
<action android:name="android.intent.action.LOCALE_CHANGED" />
</intent-filter>
</receiver>
<receiver android:name=".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 @@ -19,7 +19,6 @@ class VpnSettingsUiStatePreviewParameterProvider : PreviewParameterProvider<VpnS
VpnSettingsUiState.createDefault(),
VpnSettingsUiState.createDefault(
mtu = Mtu(MTU),
isAutoConnectEnabled = true,
isLocalNetworkSharingEnabled = true,
isDaitaEnabled = true,
isCustomDnsEnabled = true,
Expand All @@ -38,6 +37,7 @@ class VpnSettingsUiStatePreviewParameterProvider : PreviewParameterProvider<VpnS
customWireguardPort = PORT1,
availablePortRanges = listOf(PORT1..PORT2),
systemVpnSettingsAvailable = true,
autoStartAndConnectOnBoot = true,
),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,6 @@ private fun PreviewVpnSettings(
onToggleBlockTrackers = {},
onToggleBlockAds = {},
onToggleBlockMalware = {},
onToggleAutoConnect = {},
onToggleLocalNetworkSharing = {},
onToggleBlockAdultContent = {},
onToggleBlockGambling = {},
Expand Down Expand Up @@ -232,7 +231,6 @@ fun VpnSettings(
onToggleBlockTrackers = vm::onToggleBlockTrackers,
onToggleBlockAds = vm::onToggleBlockAds,
onToggleBlockMalware = vm::onToggleBlockMalware,
onToggleAutoConnect = vm::onToggleAutoConnect,
onToggleLocalNetworkSharing = vm::onToggleLocalNetworkSharing,
onDisableDaita = { vm.onToggleDaita(false) },
onToggleBlockAdultContent = vm::onToggleBlockAdultContent,
Expand Down Expand Up @@ -264,6 +262,7 @@ fun VpnSettings(
dropUnlessResumed { navigator.navigate(ShadowsocksSettingsDestination) },
navigateToUdp2TcpSettings =
dropUnlessResumed { navigator.navigate(Udp2TcpSettingsDestination) },
onToggleAutoStartAndConnectOnBoot = vm::onToggleAutoStartAndConnectOnBoot,
)
}

Expand All @@ -288,7 +287,6 @@ fun VpnSettingsScreen(
onToggleBlockTrackers: (Boolean) -> Unit = {},
onToggleBlockAds: (Boolean) -> Unit = {},
onToggleBlockMalware: (Boolean) -> Unit = {},
onToggleAutoConnect: (Boolean) -> Unit = {},
onToggleLocalNetworkSharing: (Boolean) -> Unit = {},
onDisableDaita: () -> Unit = {},
onToggleBlockAdultContent: (Boolean) -> Unit = {},
Expand All @@ -303,6 +301,7 @@ fun VpnSettingsScreen(
onWireguardPortSelected: (port: Constraint<Port>) -> Unit = {},
navigateToShadowSocksSettings: () -> Unit = {},
navigateToUdp2TcpSettings: () -> Unit = {},
onToggleAutoStartAndConnectOnBoot: (Boolean) -> Unit = {},
) {
var expandContentBlockersState by rememberSaveable { mutableStateOf(false) }
val biggerPadding = 54.dp
Expand Down Expand Up @@ -330,33 +329,28 @@ 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) {
} 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.auto_connect_footer_legacy,
R.string.connect_on_start_footer,
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)
)
}
}

item {
Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
HeaderSwitchComposeCell(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +24,7 @@ data class VpnSettingsUiState(
val customWireguardPort: Port?,
val availablePortRanges: List<PortRange>,
val systemVpnSettingsAvailable: Boolean,
val autoStartAndConnectOnBoot: Boolean,
) {
val isCustomWireguardPort =
selectedWireguardPort is Constraint.Only &&
Expand All @@ -33,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,
Expand All @@ -47,10 +46,10 @@ data class VpnSettingsUiState(
customWireguardPort: Port? = null,
availablePortRanges: List<PortRange> = emptyList(),
systemVpnSettingsAvailable: Boolean = false,
autoStartAndConnectOnBoot: Boolean = false,
) =
VpnSettingsUiState(
mtu,
isAutoConnectEnabled,
isLocalNetworkSharingEnabled,
isDaitaEnabled,
isCustomDnsEnabled,
Expand All @@ -64,6 +63,7 @@ data class VpnSettingsUiState(
customWireguardPort,
availablePortRanges,
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 @@ -102,6 +105,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 @@ -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()) }
Expand Down Expand Up @@ -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()) }
Expand Down Expand Up @@ -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"
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Boolean> = _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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading