Compare commits

..

6 Commits

Author SHA1 Message Date
Zane Schepke e0cce8fba4 fix: converter backwards compatibility
Fixes database converter to allow for backwards compatabilty.
2023-10-27 00:11:31 -04:00
Zane Schepke b70ecbdfff fix: fdroid build and ssid comma
Fixes bug where commas in SSID names were splitting into multiple SSIDs due to database type converters.
Closes #48

Fixes bug where F-Droid pipeline was failing to build due to lack of proguard rule. Closes #47

Fixes bug where crashes could happen if config QR code or file has improperly configured data.

Bump versions.
2023-10-26 22:05:20 -04:00
Zane Schepke 513d08998b fix: config save bug
Fixes a bug where config changes were saving on the wrong thread, causing a failure to save changes.

Fixes a bug where the quick tile could cause a crash by initializing the tile state before it was ready.
2023-10-23 16:13:37 -04:00
Zane Schepke 79583e0e61 docs: update README 2023-10-21 15:17:09 -04:00
Zane Schepke 75790ec6d5 bump: app version to 3.1.6 2023-10-21 14:13:47 -04:00
Zane Schepke a1941b7229 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
2023-10-21 14:03:36 -04:00
36 changed files with 538 additions and 216 deletions
+9 -3
View File
@@ -14,10 +14,12 @@ android {
applicationId = "com.zaneschepke.wireguardautotunnel" applicationId = "com.zaneschepke.wireguardautotunnel"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 31400 versionCode = 32000
versionName = "3.1.4" versionName = "3.2.0"
multiDexEnabled = true ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
resourceConfigurations.addAll(listOf("en")) resourceConfigurations.addAll(listOf("en"))
@@ -45,6 +47,7 @@ android {
productFlavors { productFlavors {
create("fdroid") { create("fdroid") {
dimension = "type" dimension = "type"
proguardFile("fdroid-rules.pro")
} }
create("general") { create("general") {
dimension = "type" dimension = "type"
@@ -149,4 +152,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)
} }
+1
View File
@@ -0,0 +1 @@
-dontwarn com.google.errorprone.annotations.**
+1 -1
View File
@@ -18,4 +18,4 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
@@ -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')"
]
}
}
@@ -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')"
]
}
}
@@ -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.
* *
@@ -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
@@ -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
@@ -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
@@ -1,15 +1,23 @@
package com.zaneschepke.wireguardautotunnel.repository package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.TypeConverter import androidx.room.TypeConverter
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class DatabaseListConverters { class DatabaseListConverters {
@TypeConverter @TypeConverter
fun listToString(value: MutableList<String>): String { fun listToString(value: MutableList<String>): String {
return value.joinToString(",") return Json.encodeToString(value)
} }
@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 try {
Json.decodeFromString<MutableList<String>>(value)
} catch (e : Exception) {
val list = value.split(",").toMutableList()
val json = listToString(list)
Json.decodeFromString<MutableList<String>>(json)
}
} }
} }
@@ -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) {
@@ -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,)) {
@@ -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)
@@ -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) {
@@ -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,
@@ -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"
} }
} }
@@ -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
@@ -42,14 +42,6 @@ class TunnelControlTile : TileService() {
super.onStartListening() super.onStartListening()
} }
override fun onTileAdded() {
super.onTileAdded()
qsTile.contentDescription = this.resources.getString(R.string.toggle_vpn)
scope.launch {
updateTileState();
}
}
override fun onTileRemoved() { override fun onTileRemoved() {
super.onTileRemoved() super.onTileRemoved()
cancelJob() cancelJob()
@@ -65,7 +57,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 +76,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 +115,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 +132,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()
} }
} }
} }
@@ -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 {
@@ -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)
@@ -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
@@ -1,10 +1,12 @@
package com.zaneschepke.wireguardautotunnel.ui.common.prompt package com.zaneschepke.wireguardautotunnel.ui.common.prompt
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
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.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Info
@@ -42,14 +44,15 @@ fun CustomSnackBar(
if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
) { ) {
Row( Row(
modifier = Modifier.fillMaxSize(), modifier = Modifier.width(IntrinsicSize.Max).height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.Start
) { ) {
Icon( Icon(
Icons.Rounded.Info, Icons.Rounded.Info,
contentDescription = stringResource(R.string.info), contentDescription = stringResource(R.string.info),
tint = Color.White tint = Color.White,
modifier = Modifier.padding(end = 10.dp)
) )
Text(message, color = Color.White, modifier = Modifier.padding(end = 5.dp)) Text(message, color = Color.White, modifier = Modifier.padding(end = 5.dp))
} }
@@ -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 = "",
@@ -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
@@ -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(
@@ -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()
} }
@@ -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
@@ -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
} }
@@ -159,8 +159,8 @@ fun MainScreen(
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
try { try {
viewModel.onTunnelFileSelected(data) viewModel.onTunnelFileSelected(data)
} catch (e : Exception) { } catch (e : WgTunnelException) {
showSnackbarMessage(e.message ?: "Unknown error occurred") showSnackbarMessage(e.message)
} }
} }
} }
@@ -168,10 +168,12 @@ fun MainScreen(
val scanLauncher = rememberLauncherForActivityResult( val scanLauncher = rememberLauncherForActivityResult(
contract = ScanContract(), contract = ScanContract(),
onResult = { onResult = {
try { scope.launch {
viewModel.onTunnelQrResult(it.contents) try {
} catch (e: Exception) { viewModel.onTunnelQrResult(it.contents)
showSnackbarMessage(context.getString(R.string.qr_result_failed)) } catch (e: WgTunnelException) {
showSnackbarMessage(e.message)
}
} }
} }
) )
@@ -198,7 +200,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 +365,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 +435,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)
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import javax.inject.Inject import javax.inject.Inject
@@ -117,32 +118,26 @@ class MainViewModel @Inject constructor(
} }
private fun validateConfigString(config: String) { private fun validateConfigString(config: String) {
if (!config.contains(application.getString(R.string.config_validation))) { TunnelConfig.configFromQuick(config)
throw WgTunnelException(application.getString(R.string.config_validation)) }
suspend fun onTunnelQrResult(result: String) {
try {
validateConfigString(result)
val tunnelConfig =
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
addTunnel(tunnelConfig)
} catch (e : Exception) {
throw WgTunnelException(e)
} }
} }
fun onTunnelQrResult(result: String) { private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
viewModelScope.launch(Dispatchers.IO) { val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
try { val config = Config.parse(bufferReader)
validateConfigString(result) val tunnelName = getNameFromFileName(fileName)
val tunnelConfig = addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result) withContext(Dispatchers.IO) {
addTunnel(tunnelConfig)
} catch (e: WgTunnelException) {
throw WgTunnelException(
e.message ?: application.getString(R.string.unknown_error_message)
)
}
}
}
private fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
viewModelScope.launch(Dispatchers.IO) {
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
val config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName)
addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
stream.close() stream.close()
} }
} }
@@ -161,9 +156,8 @@ class MainViewModel @Inject constructor(
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri) Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
else -> throw WgTunnelException(application.getString(R.string.file_extension_message)) else -> throw WgTunnelException(application.getString(R.string.file_extension_message))
} }
} catch (e: Exception) { } catch (e: Exception) {
throw WgTunnelException(e.message ?: "Error importing file") throw WgTunnelException(e)
} }
} }
@@ -182,7 +176,7 @@ class MainViewModel @Inject constructor(
} }
} }
private fun saveTunnelFromConfUri(name : String, uri: Uri) { private suspend fun saveTunnelFromConfUri(name : String, uri: Uri) {
val stream = getInputStreamFromUri(uri) val stream = getInputStreamFromUri(uri)
saveTunnelConfigFromStream(stream, name) saveTunnelConfigFromStream(stream, name)
} }
@@ -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))
} }
} }
} }
@@ -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
))
}
} }
@@ -1,3 +1,15 @@
package com.zaneschepke.wireguardautotunnel.util package com.zaneschepke.wireguardautotunnel.util
class WgTunnelException(message: String) : Exception(message) import com.wireguard.config.BadConfigException
class WgTunnelException(e: Exception) : Exception() {
constructor(message : String) : this(Exception(message))
override val message: String = generateExceptionMessage(e)
private fun generateExceptionMessage(e : Exception) : String {
return when(e) {
is BadConfigException -> "${e.section.name} ${e.location.name} ${e.reason.name}"
else -> e.message ?: "Unknown error occurred"
}
}
}
+12 -1
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>
@@ -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).
* *
Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 125 KiB

+10 -7
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"
@@ -13,20 +14,20 @@ hiltNavigationCompose = "1.0.0"
junit = "4.13.2" junit = "4.13.2"
kotlinx-serialization-json = "1.5.1" kotlinx-serialization-json = "1.5.1"
lifecycle-runtime-compose = "2.6.2" lifecycle-runtime-compose = "2.6.2"
material-icons-extended = "1.5.3" material-icons-extended = "1.5.4"
material3 = "1.1.2" material3 = "1.1.2"
navigationCompose = "2.7.4" navigationCompose = "2.7.4"
roomVersion = "2.6.0-rc01" roomVersion = "2.6.0"
timber = "5.0.1" timber = "5.0.1"
tunnel = "1.0.20230706" tunnel = "1.0.20230706"
androidGradlePlugin = "8.3.0-alpha06" androidGradlePlugin = "8.3.0-alpha06"
kotlin="1.9.10" kotlin="1.9.10"
ksp="1.9.10-1.0.13" ksp="1.9.10-1.0.13"
composeBom="2023.10.00" composeBom="2023.10.01"
firebaseBom="32.3.1" firebaseBom="32.4.0"
compose="1.5.3" compose="1.5.4"
crashlytics="18.4.3" crashlytics="18.5.0"
analytics="21.3.0" analytics="21.4.0"
composeCompiler="1.5.3" composeCompiler="1.5.3"
zxingAndroidEmbedded = "4.3.0" zxingAndroidEmbedded = "4.3.0"
zxingCore = "3.4.1" zxingCore = "3.4.1"
@@ -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" }