feat: shortcut intents and battery saver

Added the ability to turn on and off tunnels via intents to the shortcut activity.

Added a setting to enable or disable shortcut/intent control of tunnels.

Added an experimental battery saver setting to auto-tunneling to fix the battery drain issue caused by wakelock.

Fixes a bug where sometimes the config screen could crash if there are issues parsing the tunnel config data.

Database migration
This commit is contained in:
Zane Schepke 2023-10-21 12:50:20 -04:00
parent 37bae82700
commit a1941b7229
28 changed files with 474 additions and 163 deletions

View File

@ -14,11 +14,15 @@ android {
applicationId = "com.zaneschepke.wireguardautotunnel" applicationId = "com.zaneschepke.wireguardautotunnel"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 31400 versionCode = 31500
versionName = "3.1.4" versionName = "3.1.5"
multiDexEnabled = true multiDexEnabled = true
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
resourceConfigurations.addAll(listOf("en")) resourceConfigurations.addAll(listOf("en"))
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@ -149,4 +153,7 @@ dependencies {
//bio //bio
implementation(libs.androidx.biometric.ktx) implementation(libs.androidx.biometric.ktx)
//shortcuts
implementation(libs.androidx.core)
implementation(libs.androidx.core.google.shortcuts)
} }

View File

@ -0,0 +1,112 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "ba86153e6fb0b823197b987239b03e64",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultTunnel",
"columnName": "default_tunnel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ba86153e6fb0b823197b987239b03e64')"
]
}
}

View File

@ -0,0 +1,126 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "65b1c9efff61712231fa64d1f19f3915",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_battery_saver_enabled` INTEGER NOT NULL DEFAULT false)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultTunnel",
"columnName": "default_tunnel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isBatterySaverEnabled",
"columnName": "is_battery_saver_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '65b1c9efff61712231fa64d1f19f3915')"
]
}
}

View File

@ -1,13 +1,11 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
* *

View File

@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel
object Constants { object Constants {
const val MANUAL_TUNNEL_CONFIG_ID = "0" const val MANUAL_TUNNEL_CONFIG_ID = "0"
const val WATCHER_SERVICE_WAKE_LOCK_TIMEOUT = 10*60*1000L /*10 minute*/
const val VPN_CONNECTIVITY_CHECK_INTERVAL = 3000L const val VPN_CONNECTIVITY_CHECK_INTERVAL = 3000L
const val VPN_STATISTIC_CHECK_INTERVAL = 10000L const val VPN_STATISTIC_CHECK_INTERVAL = 10000L
const val TOGGLE_TUNNEL_DELAY = 500L const val TOGGLE_TUNNEL_DELAY = 500L

View File

@ -8,8 +8,6 @@ import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject

View File

@ -1,12 +1,15 @@
package com.zaneschepke.wireguardautotunnel.repository package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.AutoMigration
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
@Database(entities = [Settings::class, TunnelConfig::class], version = 1, exportSchema = false) @Database(entities = [Settings::class, TunnelConfig::class], version = 2, autoMigrations = [
AutoMigration(from = 1, to = 2)
], exportSchema = true)
@TypeConverters(DatabaseListConverters::class) @TypeConverters(DatabaseListConverters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun settingDao(): SettingsDoa abstract fun settingDao(): SettingsDoa

View File

@ -8,7 +8,7 @@ class DatabaseListConverters {
return value.joinToString(",") return value.joinToString(",")
} }
@TypeConverter @TypeConverter
fun <T> stringToList(value: String): MutableList<String> { fun stringToList(value: String): MutableList<String> {
if(value.isEmpty()) return mutableListOf() if(value.isEmpty()) return mutableListOf()
return value.split(",").toMutableList() return value.split(",").toMutableList()
} }

View File

@ -13,6 +13,8 @@ data class Settings(
@ColumnInfo(name = "default_tunnel") var defaultTunnel : String? = null, @ColumnInfo(name = "default_tunnel") var defaultTunnel : String? = null,
@ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled : Boolean = false, @ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled : Boolean = false,
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled") var isTunnelOnEthernetEnabled : Boolean = false, @ColumnInfo(name = "is_tunnel_on_ethernet_enabled") var isTunnelOnEthernetEnabled : Boolean = false,
@ColumnInfo(name = "is_shortcuts_enabled", defaultValue = "false") var isShortcutsEnabled : Boolean = false,
@ColumnInfo(name = "is_battery_saver_enabled", defaultValue = "false") var isBatterySaverEnabled : Boolean = false,
) { ) {
fun isTunnelConfigDefault(tunnelConfig: TunnelConfig) : Boolean { fun isTunnelConfigDefault(tunnelConfig: TunnelConfig) : Boolean {
return if (defaultTunnel != null) { return if (defaultTunnel != null) {

View File

@ -91,14 +91,6 @@ object ServiceManager {
WireGuardConnectivityWatcherService::class.java) WireGuardConnectivityWatcherService::class.java)
} }
fun toggleWatcherService(context: Context, tunnelConfig : String) {
when(getServiceState( context,
WireGuardConnectivityWatcherService::class.java,)) {
ServiceState.STARTED -> stopWatcherService(context)
ServiceState.STOPPED -> startWatcherService(context, tunnelConfig)
}
}
fun toggleWatcherServiceForeground(context: Context, tunnelConfig : String) { fun toggleWatcherServiceForeground(context: Context, tunnelConfig : String) {
when(getServiceState( context, when(getServiceState( context,
WireGuardConnectivityWatcherService::class.java,)) { WireGuardConnectivityWatcherService::class.java,)) {

View File

@ -21,24 +21,24 @@ import com.zaneschepke.wireguardautotunnel.service.network.WifiService
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WireGuardConnectivityWatcherService : ForegroundService() { class WireGuardConnectivityWatcherService : ForegroundService() {
private val foregroundId = 122; private val foregroundId = 122
@Inject @Inject
lateinit var wifiService : NetworkService<WifiService> lateinit var wifiService: NetworkService<WifiService>
@Inject @Inject
lateinit var mobileDataService : NetworkService<MobileDataService> lateinit var mobileDataService: NetworkService<MobileDataService>
@Inject @Inject
lateinit var ethernetService: NetworkService<EthernetService> lateinit var ethernetService: NetworkService<EthernetService>
@ -47,22 +47,22 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
lateinit var settingsRepo: SettingsDoa lateinit var settingsRepo: SettingsDoa
@Inject @Inject
lateinit var notificationService : NotificationService lateinit var notificationService: NotificationService
@Inject @Inject
lateinit var vpnService : VpnService lateinit var vpnService: VpnService
private var isWifiConnected = false; private var isWifiConnected = false
private var isEthernetConnected = false; private var isEthernetConnected = false
private var isMobileDataConnected = false; private var isMobileDataConnected = false
private var currentNetworkSSID = ""; private var currentNetworkSSID = ""
private lateinit var watcherJob : Job; private lateinit var watcherJob: Job
private lateinit var setting : Settings private lateinit var setting: Settings
private lateinit var tunnelConfig: String private lateinit var tunnelConfig: String
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private val tag = this.javaClass.name; private val tag = this.javaClass.name
override fun onCreate() { override fun onCreate() {
@ -80,9 +80,11 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
this.tunnelConfig = tunnelId this.tunnelConfig = tunnelId
} }
// we need this lock so our service gets not affected by Doze Mode // we need this lock so our service gets not affected by Doze Mode
initWakeLock() lifecycleScope.launch {
initWakeLock()
}
cancelWatcherJob() cancelWatcherJob()
if(this::tunnelConfig.isInitialized) { if (this::tunnelConfig.isInitialized) {
startWatcherJob() startWatcherJob()
} else { } else {
stopService(extras) stopService(extras)
@ -104,7 +106,8 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
val notification = notificationService.createNotification( val notification = notificationService.createNotification(
channelId = getString(R.string.watcher_channel_id), channelId = getString(R.string.watcher_channel_id),
channelName = getString(R.string.watcher_channel_name), channelName = getString(R.string.watcher_channel_name),
description = getString(R.string.watcher_notification_text)) description = getString(R.string.watcher_notification_text)
)
super.startForeground(foregroundId, notification) super.startForeground(foregroundId, notification)
} }
@ -112,46 +115,59 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
override fun onTaskRemoved(rootIntent: Intent) { override fun onTaskRemoved(rootIntent: Intent) {
Timber.d("Task Removed called") Timber.d("Task Removed called")
val restartServiceIntent = Intent(rootIntent) val restartServiceIntent = Intent(rootIntent)
val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, val restartServicePendingIntent: PendingIntent = PendingIntent.getService(
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE); this, 1, restartServiceIntent,
applicationContext.getSystemService(Context.ALARM_SERVICE); PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager; )
alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent); applicationContext.getSystemService(Context.ALARM_SERVICE)
val alarmService: AlarmManager =
applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmService.set(
AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime() + 1000,
restartServicePendingIntent
)
} }
private fun initWakeLock() { private suspend fun initWakeLock() {
val isBatterySaverOn = withContext(lifecycleScope.coroutineContext) {
settingsRepo.getAll().firstOrNull()?.isBatterySaverEnabled ?: false
}
wakeLock = wakeLock =
(getSystemService(Context.POWER_SERVICE) as PowerManager).run { (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
//TODO decide what to do here with the wakelock if (isBatterySaverOn) {
//this is draining battery. Perhaps users only care for VPN to connect when their screen is on Timber.d("Initiating wakelock with timeout")
//and they are actively using apps acquire(Constants.WATCHER_SERVICE_WAKE_LOCK_TIMEOUT)
acquire() } else {
Timber.d("Initiating wakelock with zero timeout")
acquire()
}
} }
} }
} }
private fun cancelWatcherJob() { private fun cancelWatcherJob() {
if(this::watcherJob.isInitialized) { if (this::watcherJob.isInitialized) {
watcherJob.cancel() watcherJob.cancel()
} }
} }
private fun startWatcherJob() { private fun startWatcherJob() {
watcherJob = lifecycleScope.launch(Dispatchers.IO) { watcherJob = lifecycleScope.launch(Dispatchers.IO) {
val settings = settingsRepo.getAll(); val settings = settingsRepo.getAll()
if(settings.isNotEmpty()) { if (settings.isNotEmpty()) {
setting = settings[0] setting = settings[0]
} }
launch { launch {
watchForWifiConnectivityChanges() watchForWifiConnectivityChanges()
} }
if(setting.isTunnelOnMobileDataEnabled) { if (setting.isTunnelOnMobileDataEnabled) {
launch { launch {
watchForMobileDataConnectivityChanges() watchForMobileDataConnectivityChanges()
} }
} }
if(setting.isTunnelOnEthernetEnabled) { if (setting.isTunnelOnEthernetEnabled) {
launch { launch {
watchForEthernetConnectivityChanges() watchForEthernetConnectivityChanges()
} }
@ -164,15 +180,17 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
private suspend fun watchForMobileDataConnectivityChanges() { private suspend fun watchForMobileDataConnectivityChanges() {
mobileDataService.networkStatus.collect { mobileDataService.networkStatus.collect {
when(it) { when (it) {
is NetworkStatus.Available -> { is NetworkStatus.Available -> {
Timber.d("Gained Mobile data connection") Timber.d("Gained Mobile data connection")
isMobileDataConnected = true isMobileDataConnected = true
} }
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
isMobileDataConnected = true isMobileDataConnected = true
Timber.d("Mobile data capabilities changed") Timber.d("Mobile data capabilities changed")
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
isMobileDataConnected = false isMobileDataConnected = false
Timber.d("Lost mobile data connection") Timber.d("Lost mobile data connection")
@ -188,10 +206,12 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
Timber.d("Gained Ethernet connection") Timber.d("Gained Ethernet connection")
isEthernetConnected = true isEthernetConnected = true
} }
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Ethernet capabilities changed") Timber.d("Ethernet capabilities changed")
isEthernetConnected = true isEthernetConnected = true
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
isEthernetConnected = false isEthernetConnected = false
Timber.d("Lost Ethernet connection") Timber.d("Lost Ethernet connection")
@ -202,45 +222,51 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
private suspend fun watchForWifiConnectivityChanges() { private suspend fun watchForWifiConnectivityChanges() {
wifiService.networkStatus.collect { wifiService.networkStatus.collect {
when (it) { when (it) {
is NetworkStatus.Available -> { is NetworkStatus.Available -> {
Timber.d("Gained Wi-Fi connection") Timber.d("Gained Wi-Fi connection")
isWifiConnected = true isWifiConnected = true
} }
is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Wifi capabilities changed") is NetworkStatus.CapabilitiesChanged -> {
isWifiConnected = true Timber.d("Wifi capabilities changed")
currentNetworkSSID = wifiService.getNetworkName(it.networkCapabilities) ?: ""; isWifiConnected = true
} currentNetworkSSID = wifiService.getNetworkName(it.networkCapabilities) ?: ""
is NetworkStatus.Unavailable -> { }
isWifiConnected = false
Timber.d("Lost Wi-Fi connection") is NetworkStatus.Unavailable -> {
} isWifiConnected = false
Timber.d("Lost Wi-Fi connection")
} }
} }
} }
}
private suspend fun manageVpn() { private suspend fun manageVpn() {
while(true) { while (true) {
if(isEthernetConnected && setting.isTunnelOnEthernetEnabled && vpnService.getState() == Tunnel.State.DOWN) { if (isEthernetConnected && setting.isTunnelOnEthernetEnabled && vpnService.getState() == Tunnel.State.DOWN) {
ServiceManager.startVpnService(this, tunnelConfig) ServiceManager.startVpnService(this, tunnelConfig)
} }
if(!isEthernetConnected && setting.isTunnelOnMobileDataEnabled && if (!isEthernetConnected && setting.isTunnelOnMobileDataEnabled &&
!isWifiConnected && !isWifiConnected &&
isMobileDataConnected isMobileDataConnected
&& vpnService.getState() == Tunnel.State.DOWN) { && vpnService.getState() == Tunnel.State.DOWN
) {
ServiceManager.startVpnService(this, tunnelConfig) ServiceManager.startVpnService(this, tunnelConfig)
} else if(!isEthernetConnected && !setting.isTunnelOnMobileDataEnabled && } else if (!isEthernetConnected && !setting.isTunnelOnMobileDataEnabled &&
!isWifiConnected && !isWifiConnected &&
vpnService.getState() == Tunnel.State.UP) { vpnService.getState() == Tunnel.State.UP
) {
ServiceManager.stopVpnService(this) ServiceManager.stopVpnService(this)
} else if(!isEthernetConnected && isWifiConnected && } else if (!isEthernetConnected && isWifiConnected &&
!setting.trustedNetworkSSIDs.contains(currentNetworkSSID) && !setting.trustedNetworkSSIDs.contains(currentNetworkSSID) &&
(vpnService.getState() != Tunnel.State.UP)) { (vpnService.getState() != Tunnel.State.UP)
) {
ServiceManager.startVpnService(this, tunnelConfig) ServiceManager.startVpnService(this, tunnelConfig)
} else if(!isEthernetConnected && (isWifiConnected && } else if (!isEthernetConnected && (isWifiConnected &&
setting.trustedNetworkSSIDs.contains(currentNetworkSSID)) && setting.trustedNetworkSSIDs.contains(currentNetworkSSID)) &&
(vpnService.getState() == Tunnel.State.UP)) { (vpnService.getState() == Tunnel.State.UP)
) {
ServiceManager.stopVpnService(this) ServiceManager.stopVpnService(this)
} }
delay(Constants.VPN_CONNECTIVITY_CHECK_INTERVAL) delay(Constants.VPN_CONNECTIVITY_CHECK_INTERVAL)

View File

@ -3,17 +3,15 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
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.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
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.repository.model.TunnelConfig
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -23,7 +21,7 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WireGuardTunnelService : ForegroundService() { class WireGuardTunnelService : ForegroundService() {
private val foregroundId = 123; private val foregroundId = 123
@Inject @Inject
lateinit var vpnService : VpnService lateinit var vpnService : VpnService
@ -63,7 +61,7 @@ class WireGuardTunnelService : ForegroundService() {
} }
} else { } else {
Timber.d("Tunnel config null, starting default tunnel") Timber.d("Tunnel config null, starting default tunnel")
val settings = settingsRepo.getAll(); val settings = settingsRepo.getAll()
if(settings.isNotEmpty()) { if(settings.isNotEmpty()) {
val setting = settings[0] val setting = settings[0]
if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) { if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) {

View File

@ -14,7 +14,7 @@ import javax.inject.Inject
class WireGuardNotification @Inject constructor(@ApplicationContext private val context: Context) : NotificationService { class WireGuardNotification @Inject constructor(@ApplicationContext private val context: Context) : NotificationService {
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
override fun createNotification( override fun createNotification(
channelId: String, channelId: String,

View File

@ -12,9 +12,7 @@ import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -28,7 +26,6 @@ class ShortcutsActivity : ComponentActivity() {
@Inject @Inject
lateinit var tunnelConfigRepo : TunnelConfigDao lateinit var tunnelConfigRepo : TunnelConfigDao
private fun attemptWatcherServiceToggle(tunnelConfig : String) { private fun attemptWatcherServiceToggle(tunnelConfig : String) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
val settings = getSettings() val settings = getSettings()
@ -43,20 +40,28 @@ class ShortcutsActivity : ComponentActivity() {
if(intent.getStringExtra(CLASS_NAME_EXTRA_KEY) if(intent.getStringExtra(CLASS_NAME_EXTRA_KEY)
.equals(WireGuardTunnelService::class.java.simpleName)) { .equals(WireGuardTunnelService::class.java.simpleName)) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
try { val settings = getSettings()
val settings = getSettings() if(settings.isShortcutsEnabled) {
val tunnelConfig = if(settings.defaultTunnel == null) { try {
tunnelConfigRepo.getAll().first() val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY)
} else { val tunnelConfig = if(tunnelName != null) {
TunnelConfig.from(settings.defaultTunnel!!) tunnelConfigRepo.getAll().firstOrNull { it.name == tunnelName }
} else {
if(settings.defaultTunnel == null) {
tunnelConfigRepo.getAll().first()
} else {
TunnelConfig.from(settings.defaultTunnel!!)
}
}
tunnelConfig ?: return@launch
attemptWatcherServiceToggle(tunnelConfig.toString())
when(intent.action){
Action.STOP.name -> ServiceManager.stopVpnService(this@ShortcutsActivity)
Action.START.name -> ServiceManager.startVpnService(this@ShortcutsActivity, tunnelConfig.toString())
}
} catch (e : Exception) {
Timber.e(e.message)
} }
attemptWatcherServiceToggle(tunnelConfig.toString())
when(intent.action){
Action.STOP.name -> ServiceManager.stopVpnService(this@ShortcutsActivity)
Action.START.name -> ServiceManager.startVpnService(this@ShortcutsActivity, tunnelConfig.toString())
}
} catch (e : Exception) {
Timber.e(e.message)
} }
} }
} }
@ -72,6 +77,7 @@ class ShortcutsActivity : ComponentActivity() {
} }
} }
companion object { companion object {
const val TUNNEL_NAME_EXTRA_KEY = "tunnelName"
const val CLASS_NAME_EXTRA_KEY = "className" const val CLASS_NAME_EXTRA_KEY = "className"
} }
} }

View File

@ -31,7 +31,7 @@ class TunnelControlTile : TileService() {
@Inject @Inject
lateinit var vpnService : VpnService lateinit var vpnService : VpnService
private val scope = CoroutineScope(Dispatchers.Main); private val scope = CoroutineScope(Dispatchers.Main)
private lateinit var job : Job private lateinit var job : Job
@ -46,7 +46,7 @@ class TunnelControlTile : TileService() {
super.onTileAdded() super.onTileAdded()
qsTile.contentDescription = this.resources.getString(R.string.toggle_vpn) qsTile.contentDescription = this.resources.getString(R.string.toggle_vpn)
scope.launch { scope.launch {
updateTileState(); updateTileState()
} }
} }
@ -65,7 +65,7 @@ class TunnelControlTile : TileService() {
unlockAndRun { unlockAndRun {
scope.launch { scope.launch {
try { try {
val tunnel = determineTileTunnel(); val tunnel = determineTileTunnel()
if(tunnel != null) { if(tunnel != null) {
attemptWatcherServiceToggle(tunnel.toString()) attemptWatcherServiceToggle(tunnel.toString())
if(vpnService.getState() == Tunnel.State.UP) { if(vpnService.getState() == Tunnel.State.UP) {
@ -84,23 +84,23 @@ class TunnelControlTile : TileService() {
} }
private suspend fun determineTileTunnel() : TunnelConfig? { private suspend fun determineTileTunnel() : TunnelConfig? {
var tunnelConfig : TunnelConfig? = null; var tunnelConfig : TunnelConfig? = null
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) { if (settings.isNotEmpty()) {
val setting = settings.first() val setting = settings.first()
tunnelConfig = if (setting.defaultTunnel != null) { tunnelConfig = if (setting.defaultTunnel != null) {
TunnelConfig.from(setting.defaultTunnel!!); TunnelConfig.from(setting.defaultTunnel!!)
} else { } else {
val configs = configRepo.getAll(); val configs = configRepo.getAll()
val config = if(configs.isNotEmpty()) { val config = if(configs.isNotEmpty()) {
configs.first(); configs.first()
} else { } else {
null null
} }
config config
} }
} }
return tunnelConfig; return tunnelConfig
} }
@ -123,13 +123,13 @@ class TunnelControlTile : TileService() {
qsTile.state = Tile.STATE_ACTIVE qsTile.state = Tile.STATE_ACTIVE
} }
Tunnel.State.DOWN -> { Tunnel.State.DOWN -> {
qsTile.state = Tile.STATE_INACTIVE; qsTile.state = Tile.STATE_INACTIVE
} }
else -> { else -> {
qsTile.state = Tile.STATE_UNAVAILABLE qsTile.state = Tile.STATE_UNAVAILABLE
} }
} }
val config = determineTileTunnel(); val config = determineTileTunnel()
setTileDescription(config?.name ?: this.resources.getString(R.string.no_tunnel_available)) setTileDescription(config?.name ?: this.resources.getString(R.string.no_tunnel_available))
qsTile.updateTile() qsTile.updateTile()
} }
@ -140,13 +140,13 @@ class TunnelControlTile : TileService() {
qsTile.subtitle = description qsTile.subtitle = description
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
qsTile.stateDescription = description; qsTile.stateDescription = description
} }
} }
private fun cancelJob() { private fun cancelJob() {
if(this::job.isInitialized) { if(this::job.isInitialized) {
job.cancel(); job.cancel()
} }
} }
} }

View File

@ -11,7 +11,6 @@ import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@ -47,28 +46,36 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend,
override val handshakeStatus: SharedFlow<HandshakeStatus> override val handshakeStatus: SharedFlow<HandshakeStatus>
get() = _handshakeStatus.asSharedFlow() get() = _handshakeStatus.asSharedFlow()
private val scope = CoroutineScope(Dispatchers.IO); private val scope = CoroutineScope(Dispatchers.IO)
private lateinit var statsJob : Job private lateinit var statsJob : Job
override suspend fun startTunnel(tunnelConfig: TunnelConfig) : Tunnel.State{ override suspend fun startTunnel(tunnelConfig: TunnelConfig) : Tunnel.State{
return try { return try {
if(getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) { stopTunnelOnConfigChange(tunnelConfig)
stopTunnel() emitTunnelName(tunnelConfig.name)
}
_tunnelName.emit(tunnelConfig.name)
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
val state = backend.setState( val state = backend.setState(
this, Tunnel.State.UP, config) this, Tunnel.State.UP, config)
_state.emit(state) _state.emit(state)
state; state
} catch (e : Exception) { } catch (e : Exception) {
Timber.e("Failed to start tunnel with error: ${e.message}") Timber.e("Failed to start tunnel with error: ${e.message}")
Tunnel.State.DOWN Tunnel.State.DOWN
} }
} }
private suspend fun emitTunnelName(name : String) {
_tunnelName.emit(name)
}
private suspend fun stopTunnelOnConfigChange(tunnelConfig: TunnelConfig) {
if(getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) {
stopTunnel()
}
}
override fun getName(): String { override fun getName(): String {
return _tunnelName.value return _tunnelName.value
} }
@ -89,7 +96,7 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend,
} }
override fun onStateChange(state : Tunnel.State) { override fun onStateChange(state : Tunnel.State) {
val tunnel = this; val tunnel = this
_state.tryEmit(state) _state.tryEmit(state)
if(state == Tunnel.State.UP) { if(state == Tunnel.State.UP) {
statsJob = scope.launch { statsJob = scope.launch {

View File

@ -7,14 +7,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)

View File

@ -1,6 +1,5 @@
package com.zaneschepke.wireguardautotunnel.ui.common.config package com.zaneschepke.wireguardautotunnel.ui.common.config
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField

View File

@ -1,7 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.models package com.zaneschepke.wireguardautotunnel.ui.models
import com.wireguard.config.Interface import com.wireguard.config.Interface
import com.wireguard.config.Peer
data class InterfaceProxy( data class InterfaceProxy(
var privateKey : String = "", var privateKey : String = "",

View File

@ -81,6 +81,7 @@ import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -98,7 +99,7 @@ fun ConfigScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope { Dispatchers.IO }
val clipboardManager: ClipboardManager = LocalClipboardManager.current val clipboardManager: ClipboardManager = LocalClipboardManager.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@ -136,11 +137,13 @@ fun ConfigScreen(
val screenPadding = 5.dp val screenPadding = 5.dp
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { scope.launch(Dispatchers.IO) {
viewModel.onScreenLoad(id) try {
} catch (e : Exception) { viewModel.onScreenLoad(id)
showSnackbarMessage(e.message!!) } catch (e : Exception) {
navController.navigate(Routes.Main.name) showSnackbarMessage(e.message!!)
navController.navigate(Routes.Main.name)
}
} }
} }
@ -161,7 +164,7 @@ fun ConfigScreen(
}, },
onFailure = { onFailure = {
showAuthPrompt = false showAuthPrompt = false
showSnackbarMessage("Authentication failed") showSnackbarMessage(context.getString(R.string.authentication_failed))
}) })
} }
@ -245,7 +248,7 @@ fun ConfigScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
SearchBar(viewModel::emitQueriedPackages); SearchBar(viewModel::emitQueriedPackages)
} }
Spacer(Modifier.padding(5.dp)) Spacer(Modifier.padding(5.dp))
LazyColumn( LazyColumn(

View File

@ -27,7 +27,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -63,14 +62,10 @@ class ConfigViewModel @Inject constructor(private val application : Application,
private lateinit var tunnelConfig: TunnelConfig private lateinit var tunnelConfig: TunnelConfig
fun onScreenLoad(id : String) { suspend fun onScreenLoad(id : String) {
if(id != Constants.MANUAL_TUNNEL_CONFIG_ID) { if(id != Constants.MANUAL_TUNNEL_CONFIG_ID) {
viewModelScope.launch(Dispatchers.IO) { tunnelConfig = getTunnelConfigById(id) ?: throw WgTunnelException("Config not found")
tunnelConfig = withContext(this.coroutineContext) { emitScreenData()
getTunnelConfigById(id) ?: throw WgTunnelException("Config not found")
}
emitScreenData()
}
} else { } else {
emitEmptyScreenData() emitEmptyScreenData()
} }

View File

@ -8,9 +8,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -110,7 +108,7 @@ fun DetailScreen(
}) })
Box(modifier = Modifier.padding(10.dp)) Box(modifier = Modifier.padding(10.dp))
tunnel?.peers?.forEach{ tunnel?.peers?.forEach{
val peerKey = it.publicKey.toBase64().toString() val peerKey = it.publicKey.toBase64()
val allowedIps = it.allowedIps.joinToString() val allowedIps = it.allowedIps.joinToString()
val endpoint = if(it.endpoint.isPresent) it.endpoint.get().toString() else stringResource( val endpoint = if(it.endpoint.isPresent) it.endpoint.get().toString() else stringResource(
id = R.string.none id = R.string.none

View File

@ -106,7 +106,7 @@ fun MainScreen(
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val context = LocalContext.current val context = LocalContext.current
val isVisible = rememberSaveable { mutableStateOf(true) } val isVisible = rememberSaveable { mutableStateOf(true) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope { Dispatchers.IO }
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
@ -150,7 +150,7 @@ fun MainScreen(
val name = it.activityInfo.packageName val name = it.activityInfo.packageName
name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB) name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB)
}) { }) {
throw WgTunnelException("No file explorer installed") throw WgTunnelException(context.getString(R.string.no_file_explorer))
} }
return intent return intent
} }
@ -160,7 +160,7 @@ fun MainScreen(
try { try {
viewModel.onTunnelFileSelected(data) viewModel.onTunnelFileSelected(data)
} catch (e : Exception) { } catch (e : Exception) {
showSnackbarMessage(e.message ?: "Unknown error occurred") showSnackbarMessage(e.message ?: context.getString(R.string.unknown_error))
} }
} }
} }
@ -198,7 +198,7 @@ fun MainScreen(
{ Text(text = stringResource(R.string.cancel)) } { Text(text = stringResource(R.string.cancel)) }
}, },
title = { Text(text = stringResource(R.string.primary_tunnel_change)) }, title = { Text(text = stringResource(R.string.primary_tunnel_change)) },
text = { Text(text = stringResource(R.string.primary_tunnnel_change_question)) } text = { Text(text = stringResource(R.string.primary_tunnel_change_question)) }
) )
} }
@ -363,12 +363,12 @@ fun MainScreen(
RowListItem(icon = { RowListItem(icon = {
if (settings.isTunnelConfigDefault(tunnel)) if (settings.isTunnelConfigDefault(tunnel))
Icon( Icon(
Icons.Rounded.Star, "status", Icons.Rounded.Star, stringResource(R.string.status),
tint = leadingIconColor, tint = leadingIconColor,
modifier = Modifier.padding(end = 10.dp).size(20.dp) modifier = Modifier.padding(end = 10.dp).size(20.dp)
) )
else Icon( else Icon(
Icons.Rounded.Circle, "status", Icons.Rounded.Circle, stringResource(R.string.status),
tint = leadingIconColor, tint = leadingIconColor,
modifier = Modifier.padding(end = 15.dp).size(15.dp) modifier = Modifier.padding(end = 15.dp).size(15.dp)
) )
@ -433,7 +433,7 @@ fun MainScreen(
onClick = { onClick = {
navController.navigate("${Routes.Detail.name}/${tunnel.id}") navController.navigate("${Routes.Detail.name}/${tunnel.id}")
}) { }) {
Icon(Icons.Rounded.Info, "Info") Icon(Icons.Rounded.Info, stringResource(R.string.info))
} }
IconButton(onClick = { IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) if (state == Tunnel.State.UP && tunnel.name == tunnelName)

View File

@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -72,9 +71,9 @@ import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.util.StorageUtil import com.zaneschepke.wireguardautotunnel.util.StorageUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import kotlin.math.exp
@OptIn( @OptIn(
ExperimentalPermissionsApi::class, ExperimentalPermissionsApi::class,
@ -88,7 +87,7 @@ fun SettingsScreen(
focusRequester: FocusRequester, focusRequester: FocusRequester,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@ -111,14 +110,14 @@ fun SettingsScreen(
fun exportAllConfigs() { fun exportAllConfigs() {
try { try {
val files = tunnels.map { File(context.cacheDir, "${it.name}.conf") } val files = tunnels.map { File(context.cacheDir, "${it.name}.conf") }
files.forEachIndexed() { index, file -> files.forEachIndexed { index, file ->
file.outputStream().use { file.outputStream().use {
it.write(tunnels[index].wgQuick.toByteArray()) it.write(tunnels[index].wgQuick.toByteArray())
} }
} }
StorageUtil.saveFilesToZip(context, files) StorageUtil.saveFilesToZip(context, files)
didExportFiles = true didExportFiles = true
showSnackbarMessage("Exported configs to downloads") showSnackbarMessage(context.getString(R.string.exported_configs_message))
} catch (e : Exception) { } catch (e : Exception) {
showSnackbarMessage(e.message!!) showSnackbarMessage(e.message!!)
} }
@ -132,7 +131,7 @@ fun SettingsScreen(
viewModel.onSaveTrustedSSID(currentText) viewModel.onSaveTrustedSSID(currentText)
currentText = "" currentText = ""
} catch (e : Exception) { } catch (e : Exception) {
showSnackbarMessage(e.message ?: "Unknown error") showSnackbarMessage(e.message ?: context.getString(R.string.unknown_error))
} }
} }
} }
@ -223,7 +222,7 @@ fun SettingsScreen(
}, },
onFailure = { onFailure = {
showAuthPrompt = false showAuthPrompt = false
showSnackbarMessage("Authentication failed") showSnackbarMessage(context.getString(R.string.authentication_failed))
}) })
} }
@ -350,6 +349,17 @@ fun SettingsScreen(
} }
} }
) )
ConfigurationToggle(
stringResource(R.string.battery_saver),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isBatterySaverEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleBatterySaver()
}
}
)
ConfigurationToggle(stringResource(R.string.enable_auto_tunnel), ConfigurationToggle(stringResource(R.string.enable_auto_tunnel),
enabled = !settings.isAlwaysOnVpnEnabled, enabled = !settings.isAlwaysOnVpnEnabled,
checked = settings.isAutoTunnelEnabled, checked = settings.isAutoTunnelEnabled,
@ -357,11 +367,11 @@ fun SettingsScreen(
onCheckChanged = { onCheckChanged = {
if(!isAllAutoTunnelPermissionsEnabled()) { if(!isAllAutoTunnelPermissionsEnabled()) {
val message = if(viewModel.isLocationServicesNeeded()){ val message = if(viewModel.isLocationServicesNeeded()){
"Location services required" context.getString(R.string.location_services_required)
} else if(!isBackgroundLocationGranted){ } else if(!isBackgroundLocationGranted){
"Background location required" context.getString(R.string.background_location_required)
} else { } else {
"Precise location required" context.getString(R.string.precise_location_required)
} }
showSnackbarMessage(message) showSnackbarMessage(message)
} else scope.launch { } else scope.launch {
@ -398,6 +408,16 @@ fun SettingsScreen(
} }
} }
) )
ConfigurationToggle(stringResource(R.string.enabled_app_shortcuts),
enabled = true,
checked = settings.isShortcutsEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleShortcutsEnabled()
}
}
)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
@ -410,7 +430,7 @@ fun SettingsScreen(
onClick = { onClick = {
showAuthPrompt = true showAuthPrompt = true
}) { }) {
Text("Export configs") Text(stringResource(R.string.export_configs))
} }
} }
} }

View File

@ -84,7 +84,7 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
} }
private suspend fun getFirstTunnelConfig() : TunnelConfig { private suspend fun getFirstTunnelConfig() : TunnelConfig {
return tunnelRepo.getAll().first(); return tunnelRepo.getAll().first()
} }
suspend fun onToggleAlwaysOnVPN() { suspend fun onToggleAlwaysOnVPN() {
@ -125,4 +125,16 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
fun isLocationServicesNeeded() : Boolean { fun isLocationServicesNeeded() : Boolean {
return(!isLocationServicesEnabled() && Build.VERSION.SDK_INT > Build.VERSION_CODES.P) return(!isLocationServicesEnabled() && Build.VERSION.SDK_INT > Build.VERSION_CODES.P)
} }
suspend fun onToggleShortcutsEnabled() {
settingsRepo.save(_settings.value.copy(
isShortcutsEnabled = !_settings.value.isShortcutsEnabled
))
}
suspend fun onToggleBatterySaver() {
settingsRepo.save(_settings.value.copy(
isBatterySaverEnabled = !_settings.value.isBatterySaverEnabled
))
}
} }

View File

@ -126,5 +126,16 @@
<string name="persistent_keepalive">Persistent keepalive</string> <string name="persistent_keepalive">Persistent keepalive</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
<string name="primary_tunnel_change">Primary tunnel change</string> <string name="primary_tunnel_change">Primary tunnel change</string>
<string name="primary_tunnnel_change_question">Would you like to make this your primary tunnel?</string> <string name="primary_tunnel_change_question">Would you like to make this your primary tunnel?</string>
<string name="authentication_failed">Authentication failed</string>
<string name="enabled_app_shortcuts">Enable app shortcuts</string>
<string name="export_configs">Export configs</string>
<string name="battery_saver">Battery saver (experimental)</string>
<string name="location_services_required">Location services required</string>
<string name="background_location_required">Background location required</string>
<string name="precise_location_required">Precise location required</string>
<string name="unknown_error">Unknown error occurred</string>
<string name="exported_configs_message">Exported configs to downloads</string>
<string name="no_file_explorer">No file explorer installed</string>
<string name="status">status</string>
</resources> </resources>

View File

@ -1,9 +1,8 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.Assert.*
/** /**
* Example local unit test, which will execute on the development machine (host). * Example local unit test, which will execute on the development machine (host).
* *

View File

@ -4,6 +4,7 @@ activityCompose = "1.8.0"
androidx-junit = "1.1.5" androidx-junit = "1.1.5"
appcompat = "1.6.1" appcompat = "1.6.1"
biometricKtx = "1.2.0-alpha05" biometricKtx = "1.2.0-alpha05"
coreGoogleShortcuts = "1.1.0"
coreKtx = "1.12.0" coreKtx = "1.12.0"
espressoCore = "3.5.1" espressoCore = "3.5.1"
firebase-crashlytics-gradle = "2.9.9" firebase-crashlytics-gradle = "2.9.9"
@ -42,6 +43,8 @@ accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-
#room #room
androidx-biometric-ktx = { module = "androidx.biometric:biometric-ktx", version.ref = "biometricKtx" } androidx-biometric-ktx = { module = "androidx.biometric:biometric-ktx", version.ref = "biometricKtx" }
androidx-core = { module = "androidx.core:core", version.ref = "coreKtx" }
androidx-core-google-shortcuts = { module = "androidx.core:core-google-shortcuts", version.ref = "coreGoogleShortcuts" }
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle-runtime-compose" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle-runtime-compose" }
androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle-runtime-compose" } androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle-runtime-compose" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomVersion" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomVersion" }