feat: support for Always-On VPN and Android TV

Added support for Android TV

Added support for Always-On VPN in settings

Fixes bug where handshake notification is not dismissed after successful handshake

Fixes bug that allowed you to use Auto tunneling without Location Services enabled. Now checks for Location Services before allowing access to feature.

Closes #2,  Closes #5, Closes #8
This commit is contained in:
Zane Schepke 2023-07-23 00:16:16 -04:00
parent f6612abe28
commit c673a8dc91
19 changed files with 138 additions and 23 deletions

View File

@ -29,8 +29,8 @@ This is an alternative Android Application for [WireGuard](https://www.wireguard
<p float="center"> <p float="center">
<img label="Main" style="padding-right:25px" src="asset/main_screen.png" width="200" /> <img label="Main" style="padding-right:25px" src="asset/main_screen.png" width="200" />
<img label="Config" style="padding-left:25px" src="./asset/config_screen.png" width="200" /> <img label="Config" style="padding-left:25px" src="./asset/config_screen.png" width="200" />
<img label="Settings" style="padding-left:25px" src="./asset/settings_screen.png" width="200" /> <img label="Settings" style="padding-left:25px" src="asset/settings_screen.png" width="200" />
<img label="Support" style="padding-left:25px" src="./asset/support_screen.png" width="200" /> <img label="Support" style="padding-left:25px" src="asset/support_screen.png" width="200" />
</p> </p>
<span align="left"> <span align="left">
@ -44,6 +44,7 @@ The inspiration for this app came from the inconvenience of constantly having to
* Add tunnels via .conf file * Add tunnels via .conf file
* Auto connect to VPN based on Wi-Fi SSID * Auto connect to VPN based on Wi-Fi SSID
* Split tunneling by application * Split tunneling by application
* Always-on VPN for Android support
* Configurable Trusted Network list * Configurable Trusted Network list
* Optional auto connect on mobile data * Optional auto connect on mobile data
* Automatic service restart after reboot * Automatic service restart after reboot

View File

@ -16,8 +16,8 @@ android {
compileSdk = 34 compileSdk = 34
val versionMajor = 2 val versionMajor = 2
val versionMinor = 1 val versionMinor = 2
val versionPatch = 4 val versionPatch = 0
val versionBuild = 0 val versionBuild = 0
defaultConfig { defaultConfig {

View File

@ -31,7 +31,7 @@
}, },
{ {
"id": "2:8887605597748372702", "id": "2:8887605597748372702",
"lastPropertyId": "8:4981008812459251156", "lastPropertyId": "9:4468844863383145378",
"name": "Settings", "name": "Settings",
"properties": [ "properties": [
{ {
@ -59,6 +59,11 @@
"id": "6:3370284381040192129", "id": "6:3370284381040192129",
"name": "defaultTunnel", "name": "defaultTunnel",
"type": 9 "type": 9
},
{
"id": "9:4468844863383145378",
"name": "isAlwaysOnVpnEnabled",
"type": 1
} }
], ],
"relations": [] "relations": []

View File

@ -59,11 +59,6 @@
"id": "6:3370284381040192129", "id": "6:3370284381040192129",
"name": "defaultTunnel", "name": "defaultTunnel",
"type": 9 "type": 9
},
{
"id": "8:4981008812459251156",
"name": "showProminentDisclosure",
"type": 1
} }
], ],
"relations": [] "relations": []
@ -91,7 +86,8 @@
7555225587864607050, 7555225587864607050,
969146862000617878, 969146862000617878,
5057486545428188436, 5057486545428188436,
2814640993034665120 2814640993034665120,
4981008812459251156
], ],
"retiredRelationUids": [], "retiredRelationUids": [],
"version": 1 "version": 1

View File

@ -5,7 +5,8 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"
android:maxSdkVersion="30" /> android:maxSdkVersion="30"
tools:ignore="LeanbackUsesWifi" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
@ -19,6 +20,14 @@
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<!--start service on boot permission--> <!--start service on boot permission-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!--android tv support-->
<uses-feature android:name="android.software.leanback"
android:required="false" />
<uses-feature android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.hardware.location.gps"
android:required="false" />
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@ -30,6 +39,7 @@
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:banner="@mipmap/ic_launcher_foreground"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
@ -42,6 +52,7 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service <service
@ -52,9 +63,15 @@
</service> </service>
<service <service
android:name=".service.foreground.WireGuardTunnelService" android:name=".service.foreground.WireGuardTunnelService"
android:permission="android.permission.BIND_VPN_SERVICE"
android:enabled="true" android:enabled="true"
android:foregroundServiceType="remoteMessaging" android:foregroundServiceType="remoteMessaging"
android:exported="false"> android:exported="false">
<intent-filter>
<action android:name="android.net.VpnService"/>
</intent-filter>
<meta-data android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
android:value="true"/>
</service> </service>
<service <service
android:name=".service.foreground.WireGuardConnectivityWatcherService" android:name=".service.foreground.WireGuardConnectivityWatcherService"

View File

@ -7,7 +7,6 @@ import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
import com.zaneschepke.wireguardautotunnel.service.barcode.CodeScanner import com.zaneschepke.wireguardautotunnel.service.barcode.CodeScanner
import com.zaneschepke.wireguardautotunnel.service.barcode.QRScanner import com.zaneschepke.wireguardautotunnel.service.barcode.QRScanner
import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn

View File

@ -4,7 +4,10 @@ import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
open class ForegroundService : Service() { open class ForegroundService : Service() {
@ -24,6 +27,10 @@ open class ForegroundService : Service() {
when (action) { when (action) {
Action.START.name -> startService(intent.extras) Action.START.name -> startService(intent.extras)
Action.STOP.name -> stopService(intent.extras) Action.STOP.name -> stopService(intent.extras)
"android.net.VpnService" -> {
Timber.d("Always-on VPN starting service")
startService(intent.extras)
}
else -> Timber.d("This should never happen. No action in the received intent") else -> Timber.d("This should never happen. No action in the received intent")
} }
} else { } else {

View File

@ -5,9 +5,11 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver
import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -26,6 +28,9 @@ class WireGuardTunnelService : ForegroundService() {
@Inject @Inject
lateinit var vpnService : VpnService lateinit var vpnService : VpnService
@Inject
lateinit var settingsRepo: Repository<Settings>
@Inject @Inject
lateinit var notificationService : NotificationService lateinit var notificationService : NotificationService
@ -48,7 +53,16 @@ class WireGuardTunnelService : ForegroundService() {
stopService(extras) stopService(extras)
} }
} else { } else {
Timber.e("Tunnel config null") Timber.d("Tunnel config null, starting default tunnel")
val settings = settingsRepo.getAll();
if(!settings.isNullOrEmpty()) {
val setting = settings[0]
if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) {
val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!)
tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig)
}
}
} }
} }
CoroutineScope(job).launch { CoroutineScope(job).launch {
@ -62,6 +76,7 @@ class WireGuardTunnelService : ForegroundService() {
if(!didShowFailedHandshakeNotification) { if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.initial_connection_failure_message)) launchVpnConnectionFailedNotification(getString(R.string.initial_connection_failure_message))
didShowFailedHandshakeNotification = true didShowFailedHandshakeNotification = true
didShowConnected = false
} }
} }
HandshakeStatus.HEALTHY -> { HandshakeStatus.HEALTHY -> {
@ -74,6 +89,7 @@ class WireGuardTunnelService : ForegroundService() {
if(!didShowFailedHandshakeNotification) { if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.lost_connection_failure_message)) launchVpnConnectionFailedNotification(getString(R.string.lost_connection_failure_message))
didShowFailedHandshakeNotification = true didShowFailedHandshakeNotification = true
didShowConnected = false
} }
} }
} }

View File

@ -10,5 +10,6 @@ data class Settings(
var isAutoTunnelEnabled : Boolean = false, var isAutoTunnelEnabled : Boolean = false,
var isTunnelOnMobileDataEnabled : Boolean = false, var isTunnelOnMobileDataEnabled : Boolean = false,
var trustedNetworkSSIDs : MutableList<String> = mutableListOf(), var trustedNetworkSSIDs : MutableList<String> = mutableListOf(),
var defaultTunnel : String? = null var defaultTunnel : String? = null,
var isAlwaysOnVpnEnabled : Boolean = false
) )

View File

@ -86,6 +86,7 @@ class MainViewModel @Inject constructor(private val application : Application,
val setting = settings[0] val setting = settings[0]
setting.defaultTunnel = null setting.defaultTunnel = null
setting.isAutoTunnelEnabled = false setting.isAutoTunnelEnabled = false
setting.isAlwaysOnVpnEnabled = false
settingsRepo.save(setting) settingsRepo.save(setting)
} }
} }

View File

@ -94,6 +94,7 @@ fun SettingsScreen(
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION) rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
var currentText by remember { mutableStateOf("") } var currentText by remember { mutableStateOf("") }
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var isLocationServicesEnabled by remember { mutableStateOf(viewModel.checkLocationServicesEnabled())}
LaunchedEffect(viewState) { LaunchedEffect(viewState) {
if (viewState.showSnackbarMessage) { if (viewState.showSnackbarMessage) {
@ -176,6 +177,34 @@ fun SettingsScreen(
} }
return return
} }
if(!isLocationServicesEnabled) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Text(
stringResource(id = R.string.location_services_not_detected),
textAlign = TextAlign.Center,
modifier = Modifier.padding(15.dp),
fontStyle = FontStyle.Italic
)
Button(onClick = {
val locationServicesEnabled = viewModel.checkLocationServicesEnabled()
isLocationServicesEnabled = locationServicesEnabled
if(!locationServicesEnabled) {
scope.launch {
viewModel.showSnackBarMessage(context.getString(R.string.detecting_location_services_disabled))
}
}
}) {
Text(stringResource(id = R.string.check_again))
}
}
return
}
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
@ -197,6 +226,7 @@ fun SettingsScreen(
) { ) {
Text(stringResource(R.string.enable_auto_tunnel)) Text(stringResource(R.string.enable_auto_tunnel))
Switch( Switch(
enabled = !settings.isAlwaysOnVpnEnabled,
checked = settings.isAutoTunnelEnabled, checked = settings.isAutoTunnelEnabled,
onCheckedChange = { onCheckedChange = {
scope.launch { scope.launch {
@ -213,12 +243,12 @@ fun SettingsScreen(
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = expanded, expanded = expanded,
onExpandedChange = { onExpandedChange = {
if(!settings.isAutoTunnelEnabled) { if(!(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled)) {
expanded = !expanded }}, expanded = !expanded }},
modifier = Modifier.padding(start = 15.dp, top = 5.dp, bottom = 10.dp), modifier = Modifier.padding(start = 15.dp, top = 5.dp, bottom = 10.dp),
) { ) {
TextField( TextField(
enabled = !settings.isAutoTunnelEnabled, enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
value = settings.defaultTunnel?.let { value = settings.defaultTunnel?.let {
TunnelConfig.from(it).name } TunnelConfig.from(it).name }
?: "", ?: "",
@ -266,11 +296,11 @@ fun SettingsScreen(
scope.launch { scope.launch {
viewModel.onDeleteTrustedSSID(ssid) viewModel.onDeleteTrustedSSID(ssid)
} }
}, text = ssid, icon = Icons.Filled.Close, enabled = !settings.isAutoTunnelEnabled) }, text = ssid, icon = Icons.Filled.Close, enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled))
} }
} }
OutlinedTextField( OutlinedTextField(
enabled = !settings.isAutoTunnelEnabled, enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
value = currentText, value = currentText,
onValueChange = { currentText = it }, onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) }, label = { Text(stringResource(R.string.add_trusted_ssid)) },
@ -306,7 +336,7 @@ fun SettingsScreen(
) { ) {
Text(stringResource(R.string.tunnel_mobile_data)) Text(stringResource(R.string.tunnel_mobile_data))
Switch( Switch(
enabled = !settings.isAutoTunnelEnabled, enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnMobileDataEnabled, checked = settings.isTunnelOnMobileDataEnabled,
onCheckedChange = { onCheckedChange = {
scope.launch { scope.launch {
@ -315,6 +345,24 @@ fun SettingsScreen(
} }
) )
} }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(R.string.always_on_vpn_support))
Switch(
enabled = !settings.isAutoTunnelEnabled,
checked = settings.isAlwaysOnVpnEnabled,
onCheckedChange = {
scope.launch {
viewModel.onToggleAlwaysOnVPN()
}
}
)
}
} }
} }

View File

@ -1,6 +1,8 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import android.app.Application import android.app.Application
import android.content.Context
import android.location.LocationManager
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@ -17,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor(private val application : Application, class SettingsViewModel @Inject constructor(private val application : Application,
private val tunnelRepo : Repository<TunnelConfig>, private val settingsRepo : Repository<Settings> private val tunnelRepo : Repository<TunnelConfig>, private val settingsRepo : Repository<Settings>
@ -31,6 +34,7 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
val viewState get() = _viewState.asStateFlow() val viewState get() = _viewState.asStateFlow()
init { init {
checkLocationServicesEnabled()
viewModelScope.launch { viewModelScope.launch {
settingsRepo.itemFlow.collect { settingsRepo.itemFlow.collect {
val settings = it.first() val settings = it.first()
@ -69,7 +73,7 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
suspend fun toggleAutoTunnel() { suspend fun toggleAutoTunnel() {
if(_settings.value.defaultTunnel.isNullOrEmpty() && !_settings.value.isAutoTunnelEnabled) { if(_settings.value.defaultTunnel.isNullOrEmpty() && !_settings.value.isAutoTunnelEnabled) {
showSnackBarMessage("Please select a tunnel first") showSnackBarMessage(application.getString(R.string.select_tunnel_message))
return return
} }
if(_settings.value.isAutoTunnelEnabled) { if(_settings.value.isAutoTunnelEnabled) {
@ -99,8 +103,7 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
} }
} }
} }
suspend fun showSnackBarMessage(message : String) {
private suspend fun showSnackBarMessage(message : String) {
_viewState.emit(_viewState.value.copy( _viewState.emit(_viewState.value.copy(
showSnackbarMessage = true, showSnackbarMessage = true,
snackbarMessage = message, snackbarMessage = message,
@ -118,4 +121,20 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
showSnackbarMessage = false showSnackbarMessage = false
)) ))
} }
suspend fun onToggleAlwaysOnVPN() {
if(_settings.value.defaultTunnel != null) {
_settings.emit(
_settings.value.copy(isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled)
)
settingsRepo.save(_settings.value)
} else {
showSnackBarMessage(application.getString(R.string.select_tunnel_message))
}
}
fun checkLocationServicesEnabled() : Boolean {
val locationManager =
application.getSystemService(Context.LOCATION_SERVICE) as LocationManager
return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
}
} }

View File

@ -77,4 +77,9 @@
<string name="failed_connection_to">Failed connection to -</string> <string name="failed_connection_to">Failed connection to -</string>
<string name="initial_connection_failure_message">Attempting to connect to server after 30 seconds of no response.</string> <string name="initial_connection_failure_message">Attempting to connect to server after 30 seconds of no response.</string>
<string name="lost_connection_failure_message">Attempting to reconnect to server after more than one minute of no response.</string> <string name="lost_connection_failure_message">Attempting to reconnect to server after more than one minute of no response.</string>
<string name="always_on_vpn_support">Enable Always-On VPN support</string>
<string name="select_tunnel_message">Please select a tunnel first</string>
<string name="location_services_not_detected">Unable to detect Location Services which are required for this feature. Please enable Location Services.</string>
<string name="check_again">Check again</string>
<string name="detecting_location_services_disabled">Detecting Location Services disabled</string>
</resources> </resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
asset/android_tv_banner.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB