Compare commits

..

3 Commits

Author SHA1 Message Date
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
Zane Schepke 37bae82700 feat: zip file import export
Added support for importing zip files containing multiple config files.
Closes #33

Added support for exporting all config files to downloads folder as a zip with biometric or security pin approval.

Added support for editing or viewing private key with biometric or security pin approval.

Fixed a bug where VPN status indicator functionality was unintentionally disabled.

Other various enhancements and refactors.
2023-10-16 22:43:28 -04:00
37 changed files with 812 additions and 253 deletions
+16 -2
View File
@@ -14,11 +14,15 @@ android {
applicationId = "com.zaneschepke.wireguardautotunnel" applicationId = "com.zaneschepke.wireguardautotunnel"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 31300 versionCode = 31600
versionName = "3.1.3" versionName = "3.1.6"
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"
@@ -128,6 +132,9 @@ dependencies {
//lifecycle //lifecycle
implementation(libs.lifecycle.runtime.compose) implementation(libs.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.process)
//icons //icons
implementation(libs.material.icons.extended) implementation(libs.material.icons.extended)
@@ -142,4 +149,11 @@ dependencies {
//barcode scanning //barcode scanning
implementation(libs.zxing.android.embedded) implementation(libs.zxing.android.embedded)
implementation(libs.zxing.core) implementation(libs.zxing.core)
//bio
implementation(libs.androidx.biometric.ktx)
//shortcuts
implementation(libs.androidx.core)
implementation(libs.androidx.core.google.shortcuts)
} }
@@ -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.
* *
+4
View File
@@ -4,6 +4,10 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"
android:maxSdkVersion="30" android:maxSdkVersion="30"
tools:ignore="LeanbackUsesWifi" /> tools:ignore="LeanbackUsesWifi" />
@@ -2,13 +2,15 @@ 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
const val FADE_IN_ANIMATION_DURATION = 1000 const val FADE_IN_ANIMATION_DURATION = 1000
const val SLIDE_IN_ANIMATION_DURATION = 500 const val SLIDE_IN_ANIMATION_DURATION = 500
const val SLIDE_IN_TRANSITION_OFFSET = 1000 const val SLIDE_IN_TRANSITION_OFFSET = 1000
const val VALID_FILE_EXTENSION = ".conf" const val CONF_FILE_EXTENSION = ".conf"
const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content" const val URI_CONTENT_SCHEME = "content"
const val URI_PACKAGE_SCHEME = "package" const val URI_PACKAGE_SCHEME = "package"
const val ALLOWED_FILE_TYPES = "*/*" const val ALLOWED_FILE_TYPES = "*/*"
@@ -0,0 +1,24 @@
package com.zaneschepke.wireguardautotunnel
import android.content.BroadcastReceiver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
fun BroadcastReceiver.goAsync(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> Unit
) {
val pendingResult = goAsync()
@OptIn(DelicateCoroutinesApi::class) // Must run globally; there's no teardown callback.
GlobalScope.launch(context) {
try {
block()
} finally {
pendingResult.finish()
}
}
}
@@ -3,11 +3,11 @@ package com.zaneschepke.wireguardautotunnel
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.lifecycle.ProcessLifecycleOwner
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
@@ -27,13 +27,17 @@ class WireGuardAutoTunnel : Application() {
} }
private fun initSettings() { private fun initSettings() {
CoroutineScope(Dispatchers.IO).launch { with(ProcessLifecycleOwner.get()) {
if(settingsRepo.getAll().isEmpty()) { lifecycleScope.launch {
settingsRepo.save(Settings()) if(settingsRepo.getAll().isEmpty()) {
settingsRepo.save(Settings())
}
} }
} }
} }
companion object { companion object {
fun isRunningOnAndroidTv(context : Context) : Boolean { fun isRunningOnAndroidTv(context : Context) : Boolean {
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
@@ -3,13 +3,11 @@ package com.zaneschepke.wireguardautotunnel.receiver
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.goAsync
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -18,20 +16,18 @@ class BootReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var settingsRepo : SettingsDoa lateinit var settingsRepo : SettingsDoa
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) = goAsync {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) { if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
CoroutineScope(Dispatchers.IO).launch { try {
try { val settings = settingsRepo.getAll()
val settings = settingsRepo.getAll() if (settings.isNotEmpty()) {
if (settings.isNotEmpty()) { val setting = settings.first()
val setting = settings.first() if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) {
if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) { ServiceManager.startWatcherService(context, setting.defaultTunnel!!)
ServiceManager.startWatcherService(context, setting.defaultTunnel!!)
}
} }
} finally {
cancel()
} }
} finally {
cancel()
} }
} }
} }
@@ -4,14 +4,12 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.goAsync
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -19,21 +17,19 @@ class NotificationActionReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var settingsRepo : SettingsDoa lateinit var settingsRepo : SettingsDoa
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) = goAsync {
CoroutineScope(Dispatchers.IO).launch { try {
try { val settings = settingsRepo.getAll()
val settings = settingsRepo.getAll() if (settings.isNotEmpty()) {
if (settings.isNotEmpty()) { val setting = settings.first()
val setting = settings.first() if (setting.defaultTunnel != null) {
if (setting.defaultTunnel != null) { ServiceManager.stopVpnService(context)
ServiceManager.stopVpnService(context) delay(Constants.TOGGLE_TUNNEL_DELAY)
delay(Constants.TOGGLE_TUNNEL_DELAY) ServiceManager.startVpnService(context, setting.defaultTunnel.toString())
ServiceManager.startVpnService(context, setting.defaultTunnel.toString())
}
} }
} finally {
cancel()
} }
} finally {
cancel()
} }
} }
} }
@@ -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
@@ -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()
} }
@@ -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)
@@ -7,12 +7,11 @@ 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
@@ -22,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
@@ -50,24 +49,26 @@ class WireGuardTunnelService : ForegroundService() {
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key)) val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
cancelJob() cancelJob()
job = lifecycleScope.launch(Dispatchers.IO) { job = lifecycleScope.launch(Dispatchers.IO) {
if(tunnelConfigString != null) { launch {
try { if(tunnelConfigString != null) {
val tunnelConfig = TunnelConfig.from(tunnelConfigString) try {
tunnelName = tunnelConfig.name val tunnelConfig = TunnelConfig.from(tunnelConfigString)
vpnService.startTunnel(tunnelConfig)
} catch (e : Exception) {
Timber.e("Problem starting tunnel: ${e.message}")
stopService(extras)
}
} else {
Timber.d("Tunnel config null, starting default tunnel")
val settings = settingsRepo.getAll();
if(settings.isNotEmpty()) {
val setting = settings[0]
if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) {
val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!)
tunnelName = tunnelConfig.name tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig) vpnService.startTunnel(tunnelConfig)
} catch (e : Exception) {
Timber.e("Problem starting tunnel: ${e.message}")
stopService(extras)
}
} else {
Timber.d("Tunnel config null, starting default tunnel")
val settings = settingsRepo.getAll()
if(settings.isNotEmpty()) {
val setting = settings[0]
if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) {
val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!)
tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig)
}
} }
} }
} }
@@ -141,7 +142,8 @@ class WireGuardTunnelService : ForegroundService() {
val notification = notificationService.createNotification( val notification = notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id), channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name), channelName = getString(R.string.vpn_channel_name),
action = PendingIntent.getBroadcast(this,0,Intent(this, NotificationActionReceiver::class.java),PendingIntent.FLAG_IMMUTABLE), action = PendingIntent.getBroadcast(this,0,
Intent(this, NotificationActionReceiver::class.java),PendingIntent.FLAG_IMMUTABLE),
actionText = getString(R.string.restart), actionText = getString(R.string.restart),
title = getString(R.string.vpn_connection_failed), title = getString(R.string.vpn_connection_failed),
onGoing = false, onGoing = false,
@@ -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,
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.service.shortcut
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.repository.model.Settings
@@ -11,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
@@ -27,10 +26,8 @@ class ShortcutsActivity : ComponentActivity() {
@Inject @Inject
lateinit var tunnelConfigRepo : TunnelConfigDao lateinit var tunnelConfigRepo : TunnelConfigDao
private val scope = CoroutineScope(Dispatchers.Main);
private fun attemptWatcherServiceToggle(tunnelConfig : String) { private fun attemptWatcherServiceToggle(tunnelConfig : String) {
scope.launch { lifecycleScope.launch(Dispatchers.Main) {
val settings = getSettings() val settings = getSettings()
if(settings.isAutoTunnelEnabled) { if(settings.isAutoTunnelEnabled) {
ServiceManager.toggleWatcherServiceForeground(this@ShortcutsActivity, tunnelConfig) ServiceManager.toggleWatcherServiceForeground(this@ShortcutsActivity, tunnelConfig)
@@ -42,32 +39,35 @@ class ShortcutsActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if(intent.getStringExtra(CLASS_NAME_EXTRA_KEY) if(intent.getStringExtra(CLASS_NAME_EXTRA_KEY)
.equals(WireGuardTunnelService::class.java.simpleName)) { .equals(WireGuardTunnelService::class.java.simpleName)) {
scope.launch { 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)
} }
} }
} }
finish() finish()
} }
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
private suspend fun getSettings() : Settings { private suspend fun getSettings() : Settings {
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()
return if (settings.isNotEmpty()) { return if (settings.isNotEmpty()) {
@@ -77,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
@@ -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()
} }
} }
} }
@@ -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
} }
@@ -78,7 +85,6 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend,
if(getState() == Tunnel.State.UP) { if(getState() == Tunnel.State.UP) {
val state = backend.setState(this, Tunnel.State.DOWN, null) val state = backend.setState(this, Tunnel.State.DOWN, null)
_state.emit(state) _state.emit(state)
scope.cancel()
} }
} catch (e : BackendException) { } catch (e : BackendException) {
Timber.e("Failed to stop tunnel with error: ${e.message}") Timber.e("Failed to stop tunnel with error: ${e.message}")
@@ -90,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 {
@@ -11,6 +11,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import com.google.accompanist.navigation.animation.AnimatedNavHost import com.google.accompanist.navigation.animation.AnimatedNavHost
import com.google.accompanist.navigation.animation.composable import com.google.accompanist.navigation.animation.composable
import com.google.accompanist.navigation.animation.rememberAnimatedNavController import com.google.accompanist.navigation.animation.rememberAnimatedNavController
@@ -41,9 +43,9 @@ import com.google.accompanist.permissions.rememberPermissionState
import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.GoBackend
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.detail.DetailScreen import com.zaneschepke.wireguardautotunnel.ui.screens.detail.DetailScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
@@ -52,7 +54,6 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
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.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@@ -99,10 +100,10 @@ class MainActivity : AppCompatActivity() {
} }
fun showSnackBarMessage(message : String) { fun showSnackBarMessage(message : String) {
CoroutineScope(Dispatchers.Main).launch { lifecycleScope.launch(Dispatchers.Main) {
val result = snackbarHostState.showSnackbar( val result = snackbarHostState.showSnackbar(
message = message, message = message,
actionLabel = "Okay", actionLabel = applicationContext.getString(R.string.okay),
duration = SnackbarDuration.Short, duration = SnackbarDuration.Short,
) )
when (result) { when (result) {
@@ -184,7 +185,10 @@ class MainActivity : AppCompatActivity() {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
} }
} }
}) { }, exitTransition = {
ExitTransition.None
}
) {
MainScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, navController = navController) MainScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, navController = navController)
} }
composable(Routes.Settings.name, enterTransition = { composable(Routes.Settings.name, enterTransition = {
@@ -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
@@ -0,0 +1,79 @@
package com.zaneschepke.wireguardautotunnel.ui.common.prompt
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
@Composable
fun AuthorizationPrompt(onSuccess : () -> Unit, onFailure : () -> Unit, onError : (String) -> Unit) {
val context = LocalContext.current
val biometricManager = BiometricManager.from(context)
val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
val isBiometricAvailable = remember {
when(bio){
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
onError("Biometrics not available")
false
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("Biometrics not created")
false
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found")
false
}
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
onError("Biometric security update required")
false
}
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
onError("Biometrics not supported")
false
}
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
onError("Biometrics status unknown")
false
}
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
}
}
if(isBiometricAvailable) {
val executor = remember { ContextCompat.getMainExecutor(context) }
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
.setTitle("Biometric Authentication")
.setSubtitle("Log in using your biometric credential")
.build()
val biometricPrompt = BiometricPrompt(
context as FragmentActivity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
onFailure()
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
onSuccess()
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
onFailure()
}
}
)
biometricPrompt.authenticate(promptInfo)
}
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.common package com.zaneschepke.wireguardautotunnel.ui.common.prompt
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -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 = "",
@@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.config
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -66,6 +67,7 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -77,7 +79,9 @@ import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.Routes import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar 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.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
@@ -95,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
@@ -109,6 +113,8 @@ fun ConfigScreen(
val proxyPeers by viewModel.proxyPeers.collectAsStateWithLifecycle() val proxyPeers by viewModel.proxyPeers.collectAsStateWithLifecycle()
val proxyInterface by viewModel.interfaceProxy.collectAsStateWithLifecycle() val proxyInterface by viewModel.interfaceProxy.collectAsStateWithLifecycle()
var showApplicationsDialog by remember { mutableStateOf(false) } var showApplicationsDialog by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) }
var isAuthenticated by remember { mutableStateOf(false) }
val baseTextBoxModifier = Modifier.onFocusChanged { val baseTextBoxModifier = Modifier.onFocusChanged {
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
keyboardController?.hide() keyboardController?.hide()
@@ -117,18 +123,7 @@ fun ConfigScreen(
val keyboardActions = KeyboardActions( val keyboardActions = KeyboardActions(
onDone = { onDone = {
//focusManager.clearFocus()
keyboardController?.hide() keyboardController?.hide()
},
onNext = {
keyboardController?.hide()
},
onPrevious = {
keyboardController?.hide()
},
onGo = {
keyboardController?.hide(
)
} }
) )
@@ -142,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)
}
} }
} }
@@ -156,6 +153,21 @@ fun ConfigScreen(
else "${checkedPackages.size} " + (if (include) "included" else "excluded") else "${checkedPackages.size} " + (if (include) "included" else "excluded")
} }
if(showAuthPrompt) {
AuthorizationPrompt(onSuccess = {
showAuthPrompt = false
isAuthenticated = true },
onError = { error ->
showSnackbarMessage(error)
showAuthPrompt = false
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(context.getString(R.string.authentication_failed))
})
}
if (showApplicationsDialog) { if (showApplicationsDialog) {
val sortedPackages = remember(packages) { val sortedPackages = remember(packages) {
packages.sortedBy { viewModel.getPackageLabel(it) } packages.sortedBy { viewModel.getPackageLabel(it) }
@@ -236,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(
@@ -399,10 +411,12 @@ fun ConfigScreen(
modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(focusRequester) modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(focusRequester)
) )
OutlinedTextField( OutlinedTextField(
modifier = baseTextBoxModifier.fillMaxWidth(), modifier = baseTextBoxModifier.fillMaxWidth().clickable {
showAuthPrompt = true
},
value = proxyInterface.privateKey, value = proxyInterface.privateKey,
visualTransformation = PasswordVisualTransformation(), visualTransformation = if((id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated) VisualTransformation.None else PasswordVisualTransformation(),
enabled = id == Constants.MANUAL_TUNNEL_CONFIG_ID, enabled = (id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated,
onValueChange = { value -> onValueChange = { value ->
viewModel.onPrivateKeyChange(value) viewModel.onPrivateKeyChange(value)
}, },
@@ -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
} }
@@ -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)) }
) )
} }
@@ -285,7 +285,7 @@ fun MainScreen(
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp)
) )
Text( Text(
stringResource(id = R.string.add_from_file), stringResource(id = R.string.add_tunnels_text),
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp)
) )
} }
@@ -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)
@@ -7,8 +7,6 @@ import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.wireguard.config.BadConfigException
import com.wireguard.config.Config import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@@ -31,6 +29,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipInputStream
import javax.inject.Inject import javax.inject.Inject
@@ -138,13 +137,6 @@ class MainViewModel @Inject constructor(
} }
} }
private fun validateFileExtension(fileName: String) {
val extension = getFileExtensionFromFileName(fileName)
if (extension != Constants.VALID_FILE_EXTENSION) {
throw WgTunnelException(application.getString(R.string.file_extension_message))
}
}
private fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) { private fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8) val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
@@ -160,17 +152,41 @@ class MainViewModel @Inject constructor(
?: throw WgTunnelException(application.getString(R.string.stream_failed)) ?: throw WgTunnelException(application.getString(R.string.stream_failed))
} }
fun onTunnelFileSelected(uri: Uri) { suspend fun onTunnelFileSelected(uri: Uri) {
try { try {
val fileName = getFileName(application.applicationContext, uri) val fileName = getFileName(application.applicationContext, uri)
validateFileExtension(fileName) val fileExtension = getFileExtensionFromFileName(fileName)
val stream = getInputStreamFromUri(uri) when(fileExtension){
saveTunnelConfigFromStream(stream, fileName) Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri)
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
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.message ?: "Error importing file")
} }
} }
private suspend fun saveTunnelsFromZipUri(uri: Uri) {
ZipInputStream(getInputStreamFromUri(uri)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot { it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION }
.forEach {
val name = getNameFromFileName(it.name)
val config = Config.parse(zip)
viewModelScope.launch(Dispatchers.IO) {
addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString()))
}
}
}
}
private fun saveTunnelFromConfUri(name : String, uri: Uri) {
val stream = getInputStreamFromUri(uri)
saveTunnelConfigFromStream(stream, name)
}
private suspend fun addTunnel(tunnelConfig: TunnelConfig) { private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
saveTunnel(tunnelConfig) saveTunnel(tunnelConfig)
} }
@@ -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
@@ -69,8 +68,12 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
@OptIn( @OptIn(
ExperimentalPermissionsApi::class, ExperimentalPermissionsApi::class,
@@ -84,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
@@ -98,11 +101,28 @@ fun SettingsScreen(
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var didShowLocationDisclaimer by remember { mutableStateOf(false) } var didShowLocationDisclaimer by remember { mutableStateOf(false) }
var isBackgroundLocationGranted by remember { mutableStateOf(true) } var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showAuthPrompt by remember { mutableStateOf(false) }
var didExportFiles by remember { mutableStateOf(false) }
val screenPadding = 5.dp val screenPadding = 5.dp
val fillMaxHeight = .85f
val fillMaxWidth = .85f val fillMaxWidth = .85f
fun exportAllConfigs() {
try {
val files = tunnels.map { File(context.cacheDir, "${it.name}.conf") }
files.forEachIndexed { index, file ->
file.outputStream().use {
it.write(tunnels[index].wgQuick.toByteArray())
}
}
StorageUtil.saveFilesToZip(context, files)
didExportFiles = true
showSnackbarMessage(context.getString(R.string.exported_configs_message))
} catch (e : Exception) {
showSnackbarMessage(e.message!!)
}
}
fun saveTrustedSSID() { fun saveTrustedSSID() {
if (currentText.isNotEmpty()) { if (currentText.isNotEmpty()) {
@@ -111,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))
} }
} }
} }
@@ -192,6 +212,20 @@ fun SettingsScreen(
} }
} }
if(showAuthPrompt) {
AuthorizationPrompt(onSuccess = {
showAuthPrompt = false
exportAllConfigs() },
onError = { error ->
showSnackbarMessage(error)
showAuthPrompt = false
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(context.getString(R.string.authentication_failed))
})
}
if (tunnels.isEmpty()) { if (tunnels.isEmpty()) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -315,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,
@@ -322,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 {
@@ -363,6 +408,31 @@ fun SettingsScreen(
} }
} }
) )
ConfigurationToggle(stringResource(R.string.enabled_app_shortcuts),
enabled = true,
checked = settings.isShortcutsEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleShortcutsEnabled()
}
}
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center
) {
TextButton(
enabled = !didExportFiles,
onClick = {
showAuthPrompt = true
}) {
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
))
}
} }
@@ -0,0 +1,53 @@
package com.zaneschepke.wireguardautotunnel.util
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.MediaStore.MediaColumns
import com.zaneschepke.wireguardautotunnel.Constants
import java.io.File
import java.io.OutputStream
import java.time.Instant
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
object StorageUtil {
private const val ZIP_FILE_MIME_TYPE = "application/zip"
private fun createDownloadsFileOutputStream(context: Context, fileName: String, mimeType : String = Constants.ALLOWED_FILE_TYPES) : OutputStream? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver
val contentValues = ContentValues().apply {
put(MediaColumns.DISPLAY_NAME, fileName)
put(MediaColumns.MIME_TYPE, mimeType)
put(MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
return resolver.openOutputStream(uri)
}
} else {
val target = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
fileName
)
return target.outputStream()
}
return null
}
fun saveFilesToZip(context: Context, files : List<File>) {
val zipOutputStream = createDownloadsFileOutputStream(context, "wg-export_${Instant.now().epochSecond}.zip", ZIP_FILE_MIME_TYPE)
ZipOutputStream(zipOutputStream).use { zos ->
files.forEach { file ->
val entry = ZipEntry( file.name)
zos.putNextEntry(entry)
if (file.isFile) {
file.inputStream().use { fis -> fis.copyTo(zos) }
}
}
}
}
}
+15 -4
View File
@@ -8,7 +8,7 @@
<string name="foreground_file">FOREGROUND_FILE</string> <string name="foreground_file">FOREGROUND_FILE</string>
<string name="github_url">https://github.com/zaneschepke/wgtunnel</string> <string name="github_url">https://github.com/zaneschepke/wgtunnel</string>
<string name="privacy_policy_url">https://zaneschepke.github.io/wgtunnel/</string> <string name="privacy_policy_url">https://zaneschepke.github.io/wgtunnel/</string>
<string name="file_extension_message">File is not a .conf file</string> <string name="file_extension_message">File is not a .conf or .zip</string>
<string name="turn_off_tunnel">Turn off tunnel before editing</string> <string name="turn_off_tunnel">Turn off tunnel before editing</string>
<string name="no_tunnels">No tunnels added yet!</string> <string name="no_tunnels">No tunnels added yet!</string>
<string name="tunnel_exists">Tunnel name already exists</string> <string name="tunnel_exists">Tunnel name already exists</string>
@@ -38,9 +38,9 @@
<string name="trusted_ssid_empty_description">Enter SSID</string> <string name="trusted_ssid_empty_description">Enter SSID</string>
<string name="trusted_ssid_value_description">Submit SSID</string> <string name="trusted_ssid_value_description">Submit SSID</string>
<string name="config_validation">[Interface]</string> <string name="config_validation">[Interface]</string>
<string name="add_from_file">Add tunnel from files</string> <string name="add_tunnels_text">Add from file or zip</string>
<string name="open_file">File Open</string> <string name="open_file">File Open</string>
<string name="add_from_qr">Add tunnel from QR code</string> <string name="add_from_qr">Add from QR code</string>
<string name="qr_scan">QR Scan</string> <string name="qr_scan">QR Scan</string>
<string name="tunnel_edit">Tunnel Edit</string> <string name="tunnel_edit">Tunnel Edit</string>
<string name="tunnel_name">Tunnel Name</string> <string name="tunnel_name">Tunnel Name</string>
@@ -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).
* *
+6
View File
@@ -3,6 +3,8 @@ accompanist = "0.31.2-alpha"
activityCompose = "1.8.0" 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"
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"
@@ -40,6 +42,10 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss
accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" }
#room #room
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-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" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomVersion" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomVersion" }