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">
<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="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="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" />
</p>
<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
* Auto connect to VPN based on Wi-Fi SSID
* Split tunneling by application
* Always-on VPN for Android support
* Configurable Trusted Network list
* Optional auto connect on mobile data
* Automatic service restart after reboot

View File

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

View File

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

View File

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

View File

@ -5,7 +5,8 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<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.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
@ -19,6 +20,14 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!--start service on boot permission-->
<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>
<intent>
<action android:name="android.intent.action.MAIN" />
@ -30,6 +39,7 @@
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:banner="@mipmap/ic_launcher_foreground"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
@ -42,6 +52,7 @@
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<service
@ -52,9 +63,15 @@
</service>
<service
android:name=".service.foreground.WireGuardTunnelService"
android:permission="android.permission.BIND_VPN_SERVICE"
android:enabled="true"
android:foregroundServiceType="remoteMessaging"
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
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.zaneschepke.wireguardautotunnel.service.barcode.CodeScanner
import com.zaneschepke.wireguardautotunnel.service.barcode.QRScanner
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn

View File

@ -4,7 +4,10 @@ import android.app.Service
import android.content.Intent
import android.os.Bundle
import android.os.IBinder
import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import timber.log.Timber
import javax.inject.Inject
open class ForegroundService : Service() {
@ -24,6 +27,10 @@ open class ForegroundService : Service() {
when (action) {
Action.START.name -> startService(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 {

View File

@ -5,9 +5,11 @@ import android.content.Intent
import android.os.Bundle
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver
import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
@ -26,6 +28,9 @@ class WireGuardTunnelService : ForegroundService() {
@Inject
lateinit var vpnService : VpnService
@Inject
lateinit var settingsRepo: Repository<Settings>
@Inject
lateinit var notificationService : NotificationService
@ -48,7 +53,16 @@ class WireGuardTunnelService : ForegroundService() {
stopService(extras)
}
} 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 {
@ -62,6 +76,7 @@ class WireGuardTunnelService : ForegroundService() {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.initial_connection_failure_message))
didShowFailedHandshakeNotification = true
didShowConnected = false
}
}
HandshakeStatus.HEALTHY -> {
@ -74,6 +89,7 @@ class WireGuardTunnelService : ForegroundService() {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.lost_connection_failure_message))
didShowFailedHandshakeNotification = true
didShowConnected = false
}
}
}

View File

@ -10,5 +10,6 @@ data class Settings(
var isAutoTunnelEnabled : Boolean = false,
var isTunnelOnMobileDataEnabled : Boolean = false,
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]
setting.defaultTunnel = null
setting.isAutoTunnelEnabled = false
setting.isAlwaysOnVpnEnabled = false
settingsRepo.save(setting)
}
}

View File

@ -94,6 +94,7 @@ fun SettingsScreen(
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
var currentText by remember { mutableStateOf("") }
val scrollState = rememberScrollState()
var isLocationServicesEnabled by remember { mutableStateOf(viewModel.checkLocationServicesEnabled())}
LaunchedEffect(viewState) {
if (viewState.showSnackbarMessage) {
@ -176,6 +177,34 @@ fun SettingsScreen(
}
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(
horizontalAlignment = Alignment.Start,
@ -197,6 +226,7 @@ fun SettingsScreen(
) {
Text(stringResource(R.string.enable_auto_tunnel))
Switch(
enabled = !settings.isAlwaysOnVpnEnabled,
checked = settings.isAutoTunnelEnabled,
onCheckedChange = {
scope.launch {
@ -213,12 +243,12 @@ fun SettingsScreen(
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
if(!settings.isAutoTunnelEnabled) {
if(!(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled)) {
expanded = !expanded }},
modifier = Modifier.padding(start = 15.dp, top = 5.dp, bottom = 10.dp),
) {
TextField(
enabled = !settings.isAutoTunnelEnabled,
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
value = settings.defaultTunnel?.let {
TunnelConfig.from(it).name }
?: "",
@ -266,11 +296,11 @@ fun SettingsScreen(
scope.launch {
viewModel.onDeleteTrustedSSID(ssid)
}
}, text = ssid, icon = Icons.Filled.Close, enabled = !settings.isAutoTunnelEnabled)
}, text = ssid, icon = Icons.Filled.Close, enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled))
}
}
OutlinedTextField(
enabled = !settings.isAutoTunnelEnabled,
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) },
@ -306,7 +336,7 @@ fun SettingsScreen(
) {
Text(stringResource(R.string.tunnel_mobile_data))
Switch(
enabled = !settings.isAutoTunnelEnabled,
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnMobileDataEnabled,
onCheckedChange = {
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
import android.app.Application
import android.content.Context
import android.location.LocationManager
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R
@ -17,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(private val application : Application,
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()
init {
checkLocationServicesEnabled()
viewModelScope.launch {
settingsRepo.itemFlow.collect {
val settings = it.first()
@ -69,7 +73,7 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
suspend fun toggleAutoTunnel() {
if(_settings.value.defaultTunnel.isNullOrEmpty() && !_settings.value.isAutoTunnelEnabled) {
showSnackBarMessage("Please select a tunnel first")
showSnackBarMessage(application.getString(R.string.select_tunnel_message))
return
}
if(_settings.value.isAutoTunnelEnabled) {
@ -99,8 +103,7 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
}
}
}
private suspend fun showSnackBarMessage(message : String) {
suspend fun showSnackBarMessage(message : String) {
_viewState.emit(_viewState.value.copy(
showSnackbarMessage = true,
snackbarMessage = message,
@ -118,4 +121,20 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
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="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="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>

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