feat: add vpn kill switch (#476)

This commit is contained in:
Zane Schepke 2024-12-07 18:10:03 -05:00 committed by GitHub
parent c3a2e05eb2
commit cda747deee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 907 additions and 362 deletions

View File

@ -0,0 +1,267 @@
{
"formatVersion": 1,
"database": {
"version": 13,
"identityHash": "ff209157b98a641c424f5086818ec585",
"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, `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_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_enabled` INTEGER NOT NULL DEFAULT false, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT false, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT false, `is_ping_enabled` INTEGER NOT NULL DEFAULT false, `is_amnezia_enabled` INTEGER NOT NULL DEFAULT false, `is_wildcards_enabled` INTEGER NOT NULL DEFAULT false, `is_wifi_by_shell_enabled` INTEGER NOT NULL DEFAULT false, `is_stop_on_no_internet_enabled` INTEGER NOT NULL DEFAULT false, `is_vpn_kill_switch_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_kill_switch_enabled` INTEGER NOT NULL DEFAULT false, `is_lan_on_kill_switch_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": "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": "isTunnelOnWifiEnabled",
"columnName": "is_tunnel_on_wifi_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isKernelEnabled",
"columnName": "is_kernel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isRestoreOnBootEnabled",
"columnName": "is_restore_on_boot_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isMultiTunnelEnabled",
"columnName": "is_multi_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPingEnabled",
"columnName": "is_ping_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isAmneziaEnabled",
"columnName": "is_amnezia_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isWildcardsEnabled",
"columnName": "is_wildcards_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isWifiNameByShellEnabled",
"columnName": "is_wifi_by_shell_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isStopOnNoInternetEnabled",
"columnName": "is_stop_on_no_internet_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isVpnKillSwitchEnabled",
"columnName": "is_vpn_kill_switch_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isKernelKillSwitchEnabled",
"columnName": "is_kernel_kill_switch_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isLanOnKillSwitchEnabled",
"columnName": "is_lan_on_kill_switch_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, `tunnel_networks` TEXT NOT NULL DEFAULT '', `is_mobile_data_tunnel` INTEGER NOT NULL DEFAULT false, `is_primary_tunnel` INTEGER NOT NULL DEFAULT false, `am_quick` TEXT NOT NULL DEFAULT '', `is_Active` INTEGER NOT NULL DEFAULT false, `is_ping_enabled` INTEGER NOT NULL DEFAULT false, `ping_interval` INTEGER DEFAULT null, `ping_cooldown` INTEGER DEFAULT null, `ping_ip` TEXT DEFAULT null, `is_ethernet_tunnel` INTEGER NOT NULL DEFAULT false)",
"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
},
{
"fieldPath": "tunnelNetworks",
"columnName": "tunnel_networks",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isMobileDataTunnel",
"columnName": "is_mobile_data_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPrimaryTunnel",
"columnName": "is_primary_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "amQuick",
"columnName": "am_quick",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isActive",
"columnName": "is_Active",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPingEnabled",
"columnName": "is_ping_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "pingInterval",
"columnName": "ping_interval",
"affinity": "INTEGER",
"notNull": false,
"defaultValue": "null"
},
{
"fieldPath": "pingCooldown",
"columnName": "ping_cooldown",
"affinity": "INTEGER",
"notNull": false,
"defaultValue": "null"
},
{
"fieldPath": "pingIp",
"columnName": "ping_ip",
"affinity": "TEXT",
"notNull": false,
"defaultValue": "null"
},
{
"fieldPath": "isEthernetTunnel",
"columnName": "is_ethernet_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"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, 'ff209157b98a641c424f5086818ec585')"
]
}
}

View File

@ -7,8 +7,11 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import com.zaneschepke.logcatter.LogReader import com.zaneschepke.logcatter.LogReader
import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.service.tunnel.BackendState
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.util.LocaleUtil import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
import com.zaneschepke.wireguardautotunnel.util.ReleaseTree import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
@ -32,6 +35,12 @@ class WireGuardAutoTunnel : Application() {
@Inject @Inject
lateinit var appStateRepository: AppStateRepository lateinit var appStateRepository: AppStateRepository
@Inject
lateinit var settingsRepository: SettingsRepository
@Inject
lateinit var tunnelService: TunnelService
@Inject @Inject
@IoDispatcher @IoDispatcher
lateinit var ioDispatcher: CoroutineDispatcher lateinit var ioDispatcher: CoroutineDispatcher
@ -53,6 +62,10 @@ class WireGuardAutoTunnel : Application() {
Timber.plant(ReleaseTree()) Timber.plant(ReleaseTree())
} }
applicationScope.launch { applicationScope.launch {
if (!settingsRepository.getSettings().isKernelEnabled) {
tunnelService.setBackendState(BackendState.SERVICE_ACTIVE, emptyList())
}
appStateRepository.getLocale()?.let { appStateRepository.getLocale()?.let {
val locale = LocaleUtil.getLocaleFromPrefCode(it) val locale = LocaleUtil.getLocaleFromPrefCode(it)
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(locale) val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(locale)
@ -69,6 +82,13 @@ class WireGuardAutoTunnel : Application() {
} }
} }
override fun onTerminate() {
applicationScope.launch {
tunnelService.setBackendState(BackendState.INACTIVE, emptyList())
}
super.onTerminate()
}
companion object { companion object {
lateinit var instance: WireGuardAutoTunnel lateinit var instance: WireGuardAutoTunnel
private set private set

View File

@ -11,7 +11,7 @@ import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
@Database( @Database(
entities = [Settings::class, TunnelConfig::class], entities = [Settings::class, TunnelConfig::class],
version = 12, version = 13,
autoMigrations = autoMigrations =
[ [
AutoMigration(from = 1, to = 2), AutoMigration(from = 1, to = 2),
@ -45,6 +45,10 @@ import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
from = 11, from = 11,
to = 12, to = 12,
), ),
AutoMigration(
from = 12,
to = 13,
),
], ],
exportSchema = true, exportSchema = true,
) )

View File

@ -16,7 +16,7 @@ object Queries {
VALUES VALUES
('false', ('false',
'false', 'false',
'sampleSSID1,sampleSSID2', '',
'false', 'false',
'false', 'false',
'false', 'false',

View File

@ -65,4 +65,19 @@ data class Settings(
defaultValue = "false", defaultValue = "false",
) )
val isStopOnNoInternetEnabled: Boolean = false, val isStopOnNoInternetEnabled: Boolean = false,
@ColumnInfo(
name = "is_vpn_kill_switch_enabled",
defaultValue = "false",
)
val isVpnKillSwitchEnabled: Boolean = false,
@ColumnInfo(
name = "is_kernel_kill_switch_enabled",
defaultValue = "false",
)
val isKernelKillSwitchEnabled: Boolean = false,
@ColumnInfo(
name = "is_lan_on_kill_switch_enabled",
defaultValue = "false",
)
val isLanOnKillSwitchEnabled: Boolean = false,
) )

View File

@ -92,5 +92,14 @@ data class TunnelConfig(
} }
const val AM_QUICK_DEFAULT = "" const val AM_QUICK_DEFAULT = ""
val IPV4_PUBLIC_NETWORKS = setOf(
"0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3",
"64.0.0.0/2", "128.0.0.0/3", "160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12",
"172.32.0.0/11", "172.64.0.0/10", "172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7",
"176.0.0.0/4", "192.0.0.0/9", "192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16",
"192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10",
"193.0.0.0/8", "194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4",
)
} }
} }

View File

@ -17,10 +17,6 @@ interface AppStateRepository {
suspend fun setBatteryOptimizationDisableShown(shown: Boolean) suspend fun setBatteryOptimizationDisableShown(shown: Boolean)
suspend fun getCurrentSsid(): String?
suspend fun setCurrentSsid(ssid: String)
suspend fun isTunnelStatsExpanded(): Boolean suspend fun isTunnelStatsExpanded(): Boolean
suspend fun setTunnelStatsExpanded(expanded: Boolean) suspend fun setTunnelStatsExpanded(expanded: Boolean)

View File

@ -38,14 +38,6 @@ class DataStoreAppStateRepository(
dataStoreManager.saveToDataStore(DataStoreManager.batteryDisableShown, shown) dataStoreManager.saveToDataStore(DataStoreManager.batteryDisableShown, shown)
} }
override suspend fun getCurrentSsid(): String? {
return dataStoreManager.getFromStore(DataStoreManager.currentSSID)
}
override suspend fun setCurrentSsid(ssid: String) {
dataStoreManager.saveToDataStore(DataStoreManager.currentSSID, ssid)
}
override suspend fun isTunnelStatsExpanded(): Boolean { override suspend fun isTunnelStatsExpanded(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.tunnelStatsExpanded) return dataStoreManager.getFromStore(DataStoreManager.tunnelStatsExpanded)
?: GeneralState.IS_TUNNEL_STATS_EXPANDED ?: GeneralState.IS_TUNNEL_STATS_EXPANDED

View File

@ -9,12 +9,16 @@ import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.wireguard.android.util.RootShell import com.wireguard.android.util.RootShell
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.AppShell import com.zaneschepke.wireguardautotunnel.module.AppShell
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.module.MainImmediateDispatcher import com.zaneschepke.wireguardautotunnel.module.MainImmediateDispatcher
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.autotunnel.model.AutoTunnelEvent
import com.zaneschepke.wireguardautotunnel.service.foreground.autotunnel.model.AutoTunnelState
import com.zaneschepke.wireguardautotunnel.service.foreground.autotunnel.model.NetworkState
import com.zaneschepke.wireguardautotunnel.service.network.EthernetService import com.zaneschepke.wireguardautotunnel.service.network.EthernetService
import com.zaneschepke.wireguardautotunnel.service.network.MobileDataService import com.zaneschepke.wireguardautotunnel.service.network.MobileDataService
import com.zaneschepke.wireguardautotunnel.service.network.NetworkService import com.zaneschepke.wireguardautotunnel.service.network.NetworkService
@ -23,6 +27,7 @@ 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.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
import com.zaneschepke.wireguardautotunnel.util.extensions.cancelWithMessage import com.zaneschepke.wireguardautotunnel.util.extensions.cancelWithMessage
import com.zaneschepke.wireguardautotunnel.util.extensions.getCurrentWifiName import com.zaneschepke.wireguardautotunnel.util.extensions.getCurrentWifiName
import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable
@ -32,8 +37,8 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -63,7 +68,7 @@ class AutoTunnelService : LifecycleService() {
lateinit var ethernetService: NetworkService<EthernetService> lateinit var ethernetService: NetworkService<EthernetService>
@Inject @Inject
lateinit var appDataRepository: AppDataRepository lateinit var appDataRepository: Provider<AppDataRepository>
@Inject @Inject
lateinit var notificationService: NotificationService lateinit var notificationService: NotificationService
@ -92,6 +97,7 @@ class AutoTunnelService : LifecycleService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
serviceManager.autoTunnelService.complete(this)
lifecycleScope.launch(mainImmediateDispatcher) { lifecycleScope.launch(mainImmediateDispatcher) {
kotlin.runCatching { kotlin.runCatching {
launchWatcherNotification() launchWatcherNotification()
@ -103,7 +109,6 @@ class AutoTunnelService : LifecycleService() {
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? {
super.onBind(intent) super.onBind(intent)
// We don't provide binding, so return null
return null return null
} }
@ -119,9 +124,8 @@ class AutoTunnelService : LifecycleService() {
launchWatcherNotification() launchWatcherNotification()
initWakeLock() initWakeLock()
} }
startSettingsJob() startAutoTunnelJob()
startVpnStateJob() startAutoTunnelStateJob()
startNetworkJobs()
startPingStateJob() startPingStateJob()
}.onFailure { }.onFailure {
Timber.e(it) Timber.e(it)
@ -129,11 +133,7 @@ class AutoTunnelService : LifecycleService() {
} }
fun stop() { fun stop() {
wakeLock?.let { wakeLock?.let { if (it.isHeld) it.release() }
if (it.isHeld) {
it.release()
}
}
stopSelf() stopSelf()
} }
@ -160,48 +160,23 @@ class AutoTunnelService : LifecycleService() {
} }
private fun initWakeLock() { private fun initWakeLock() {
wakeLock = wakeLock = (getSystemService(POWER_SERVICE) as PowerManager).run {
(getSystemService(POWER_SERVICE) as PowerManager).run { val tag = this.javaClass.name
val tag = this.javaClass.name newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply { try {
try { Timber.i("Initiating wakelock with 10 min timeout")
Timber.i("Initiating wakelock with 10 min timeout") acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT)
acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT) } finally {
} finally { release()
release()
}
} }
} }
} }
private fun startSettingsJob() = lifecycleScope.launch {
watchForSettingsChanges()
}
private fun startVpnStateJob() = lifecycleScope.launch {
watchForVpnStateChanges()
}
private fun startWifiJob() = lifecycleScope.launch {
watchForWifiConnectivityChanges()
}
private fun startMobileDataJob() = lifecycleScope.launch {
watchForMobileDataConnectivityChanges()
}
private fun startEthernetJob() = lifecycleScope.launch {
watchForEthernetConnectivityChanges()
} }
private fun startPingJob() = lifecycleScope.launch { private fun startPingJob() = lifecycleScope.launch {
watchForPingFailure() watchForPingFailure()
} }
private fun startNetworkEventJob() = lifecycleScope.launch {
handleNetworkEventChanges()
}
private fun startPingStateJob() = lifecycleScope.launch { private fun startPingStateJob() = lifecycleScope.launch {
autoTunnelStateFlow.collect { autoTunnelStateFlow.collect {
if (it.isPingEnabled()) { if (it.isPingEnabled()) {
@ -212,30 +187,6 @@ class AutoTunnelService : LifecycleService() {
} }
} }
private suspend fun watchForMobileDataConnectivityChanges() {
withContext(ioDispatcher) {
Timber.i("Starting mobile data watcher")
mobileDataService.networkStatus.collect { status ->
when (status) {
is NetworkStatus.Available -> {
Timber.i("Gained Mobile data connection")
emitMobileDataConnected(true)
}
is NetworkStatus.CapabilitiesChanged -> {
emitMobileDataConnected(true)
Timber.i("Mobile data capabilities changed")
}
is NetworkStatus.Unavailable -> {
emitMobileDataConnected(false)
Timber.i("Lost mobile data connection")
}
}
}
}
}
private suspend fun watchForPingFailure() { private suspend fun watchForPingFailure() {
withContext(ioDispatcher) { withContext(ioDispatcher) {
Timber.i("Starting ping watcher") Timber.i("Starting ping watcher")
@ -274,136 +225,52 @@ class AutoTunnelService : LifecycleService() {
} }
} }
private suspend fun watchForSettingsChanges() { private fun startAutoTunnelStateJob() = lifecycleScope.launch(ioDispatcher) {
Timber.i("Starting settings watcher") combine(
withContext(ioDispatcher) { combineSettings(),
appDataRepository.settings.getSettingsFlow().combine( combineNetworkEventsJob(),
appDataRepository.tunnels.getTunnelConfigsFlow(), ) { double, networkState ->
) { settings, tunnels -> AutoTunnelState(tunnelService.get().vpnState.value, networkState, double.first, double.second)
Pair(settings, tunnels) }.collect { state ->
}.collect { pair -> autoTunnelStateFlow.update {
autoTunnelStateFlow.update { it.copy(state.vpnState, state.networkState, state.settings, state.tunnels)
it.copy(
settings = pair.first,
tunnels = pair.second,
)
}
} }
} }
} }
private suspend fun watchForVpnStateChanges() {
Timber.i("Starting vpn state watcher")
withContext(ioDispatcher) {
tunnelService.get().vpnState.collect { state ->
autoTunnelStateFlow.update {
it.copy(vpnState = state)
}
}
}
}
private fun startNetworkJobs() {
Timber.i("Starting all network state jobs..")
startWifiJob()
startEthernetJob()
startMobileDataJob()
startNetworkEventJob()
}
private fun cancelAndResetPingJob() { private fun cancelAndResetPingJob() {
pingJob?.cancelWithMessage("Ping job canceled") pingJob?.cancelWithMessage("Ping job canceled")
pingJob = null pingJob = null
} }
private fun emitEthernetConnected(connected: Boolean) { private fun combineNetworkEventsJob(): Flow<NetworkState> {
autoTunnelStateFlow.update { return combine(
it.copy( wifiService.networkStatus,
isEthernetConnected = connected, mobileDataService.networkStatus,
ethernetService.networkStatus,
) { wifi, mobileData, ethernet ->
NetworkState(
wifi.isConnected,
mobileData.isConnected,
ethernet.isConnected,
when (wifi) {
is NetworkStatus.CapabilitiesChanged -> getWifiSSID(wifi.networkCapabilities)
is NetworkStatus.Available -> autoTunnelStateFlow.value.networkState.wifiName
is NetworkStatus.Unavailable -> null
},
) )
} }.distinctUntilChanged()
} }
private fun emitWifiConnected(connected: Boolean) { private fun combineSettings(): Flow<Pair<Settings, TunnelConfigs>> {
autoTunnelStateFlow.update { return combine(
it.copy( appDataRepository.get().settings.getSettingsFlow(),
isWifiConnected = connected, appDataRepository.get().tunnels.getTunnelConfigsFlow().distinctUntilChanged { old, new ->
) old.map { it.isActive } != new.map { it.isActive }
} },
} ) { settings, tunnels ->
Pair(settings, tunnels)
private fun emitWifiSSID(ssid: String) { }.distinctUntilChanged()
autoTunnelStateFlow.update {
it.copy(
currentNetworkSSID = ssid,
)
}
}
private fun emitMobileDataConnected(connected: Boolean) {
autoTunnelStateFlow.update {
it.copy(
isMobileDataConnected = connected,
)
}
}
private suspend fun watchForEthernetConnectivityChanges() {
withContext(ioDispatcher) {
Timber.i("Starting ethernet data watcher")
ethernetService.networkStatus.collect { status ->
when (status) {
is NetworkStatus.Available -> {
Timber.i("Gained Ethernet connection")
emitEthernetConnected(true)
}
is NetworkStatus.CapabilitiesChanged -> {
Timber.i("Ethernet capabilities changed")
emitEthernetConnected(true)
}
is NetworkStatus.Unavailable -> {
emitEthernetConnected(false)
Timber.i("Lost Ethernet connection")
}
}
}
}
}
private suspend fun watchForWifiConnectivityChanges() {
withContext(ioDispatcher) {
Timber.i("Starting wifi watcher")
wifiService.networkStatus.collect { status ->
when (status) {
is NetworkStatus.Available -> {
Timber.i("Gained Wi-Fi connection")
emitWifiConnected(true)
}
is NetworkStatus.CapabilitiesChanged -> {
Timber.i("Wifi capabilities changed")
emitWifiConnected(true)
val ssid = getWifiSSID(status.networkCapabilities)
ssid?.let { name ->
if (name.contains(Constants.UNREADABLE_SSID)) {
Timber.w("SSID unreadable: missing permissions")
} else {
Timber.i("Detected valid SSID")
}
appDataRepository.appState.setCurrentSsid(name)
emitWifiSSID(name)
} ?: Timber.w("Failed to read ssid")
}
is NetworkStatus.Unavailable -> {
emitWifiConnected(false)
Timber.i("Lost Wi-Fi connection")
}
}
}
}
} }
private suspend fun getWifiSSID(networkCapabilities: NetworkCapabilities): String? { private suspend fun getWifiSSID(networkCapabilities: NetworkCapabilities): String? {
@ -411,26 +278,28 @@ class AutoTunnelService : LifecycleService() {
with(autoTunnelStateFlow.value.settings) { with(autoTunnelStateFlow.value.settings) {
if (isWifiNameByShellEnabled) return@withContext rootShell.get().getCurrentWifiName() if (isWifiNameByShellEnabled) return@withContext rootShell.get().getCurrentWifiName()
wifiService.getNetworkName(networkCapabilities) wifiService.getNetworkName(networkCapabilities)
} }.also {
} if (it?.contains(Constants.UNREADABLE_SSID) == true) {
} Timber.w("SSID unreadable: missing permissions")
} else {
private suspend fun handleNetworkEventChanges() { Timber.i("Detected valid SSID")
withContext(ioDispatcher) {
Timber.i("Starting auto-tunnel network event watcher")
// ignore vpnState emits to allow manual overrides
autoTunnelStateFlow.distinctUntilChanged { old, new ->
old.copy(vpnState = new.vpnState) == new || old.tunnels.map { it.isActive } != new.tunnels.map { it.isActive }
}.collect { watcherState ->
when (val event = watcherState.asAutoTunnelEvent()) {
is AutoTunnelEvent.Start -> tunnelService.get().startTunnel(
event.tunnelConfig
?: appDataRepository.getPrimaryOrFirstTunnel(),
)
is AutoTunnelEvent.Stop -> tunnelService.get().stopTunnel()
AutoTunnelEvent.DoNothing -> Timber.i("Auto-tunneling: no condition met")
} }
} }
} }
} }
private fun startAutoTunnelJob() = lifecycleScope.launch(ioDispatcher) {
Timber.i("Starting auto-tunnel network event watcher")
autoTunnelStateFlow.collect { watcherState ->
Timber.d("New auto tunnel state emitted")
when (val event = watcherState.asAutoTunnelEvent()) {
is AutoTunnelEvent.Start -> tunnelService.get().startTunnel(
event.tunnelConfig
?: appDataRepository.get().getPrimaryOrFirstTunnel(),
)
is AutoTunnelEvent.Stop -> tunnelService.get().stopTunnel()
AutoTunnelEvent.DoNothing -> Timber.i("Auto-tunneling: no condition met")
}
}
}
} }

View File

@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.service.foreground.autotunnel package com.zaneschepke.wireguardautotunnel.service.foreground.autotunnel.model
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig

View File

@ -1,23 +1,21 @@
package com.zaneschepke.wireguardautotunnel.service.foreground.autotunnel package com.zaneschepke.wireguardautotunnel.service.foreground.autotunnel.model
import com.zaneschepke.wireguardautotunnel.data.domain.Settings import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList
import timber.log.Timber
data class AutoTunnelState( data class AutoTunnelState(
val vpnState: VpnState = VpnState(), val vpnState: VpnState = VpnState(),
val isWifiConnected: Boolean = false, val networkState: NetworkState = NetworkState(),
val isEthernetConnected: Boolean = false,
val isMobileDataConnected: Boolean = false,
val currentNetworkSSID: String = "",
val settings: Settings = Settings(), val settings: Settings = Settings(),
val tunnels: TunnelConfigs = emptyList(), val tunnels: TunnelConfigs = emptyList(),
) { ) {
private fun isMobileDataActive(): Boolean { private fun isMobileDataActive(): Boolean {
return !isEthernetConnected && !isWifiConnected && isMobileDataConnected return !networkState.isEthernetConnected && !networkState.isWifiConnected && networkState.isMobileDataConnected
} }
private fun isMobileTunnelDataChangeNeeded(): Boolean { private fun isMobileTunnelDataChangeNeeded(): Boolean {
@ -44,19 +42,19 @@ data class AutoTunnelState(
} }
private fun isWifiActive(): Boolean { private fun isWifiActive(): Boolean {
return !isEthernetConnected && isWifiConnected return !networkState.isEthernetConnected && networkState.isWifiConnected
} }
private fun startOnEthernet(): Boolean { private fun startOnEthernet(): Boolean {
return isEthernetConnected && settings.isTunnelOnEthernetEnabled && vpnState.status.isDown() return networkState.isEthernetConnected && settings.isTunnelOnEthernetEnabled && vpnState.status.isDown()
} }
private fun stopOnEthernet(): Boolean { private fun stopOnEthernet(): Boolean {
return isEthernetConnected && !settings.isTunnelOnEthernetEnabled && vpnState.status.isUp() return networkState.isEthernetConnected && !settings.isTunnelOnEthernetEnabled && vpnState.status.isUp()
} }
fun isNoConnectivity(): Boolean { fun isNoConnectivity(): Boolean {
return !isEthernetConnected && !isWifiConnected && !isMobileDataConnected return !networkState.isEthernetConnected && !networkState.isWifiConnected && !networkState.isMobileDataConnected
} }
private fun stopOnMobileData(): Boolean { private fun stopOnMobileData(): Boolean {
@ -72,7 +70,7 @@ data class AutoTunnelState(
} }
private fun changeOnEthernet(): Boolean { private fun changeOnEthernet(): Boolean {
return isEthernetConnected && settings.isTunnelOnEthernetEnabled && isEthernetTunnelChangeNeeded() return networkState.isEthernetConnected && settings.isTunnelOnEthernetEnabled && isEthernetTunnelChangeNeeded()
} }
private fun stopOnWifi(): Boolean { private fun stopOnWifi(): Boolean {
@ -84,6 +82,7 @@ data class AutoTunnelState(
} }
private fun startOnUntrustedWifi(): Boolean { private fun startOnUntrustedWifi(): Boolean {
Timber.d("Is tunnel on wifi enabled ${settings.isTunnelOnWifiEnabled}")
return isWifiActive() && settings.isTunnelOnWifiEnabled && vpnState.status.isDown() && !isCurrentSSIDTrusted() return isWifiActive() && settings.isTunnelOnWifiEnabled && vpnState.status.isDown() && !isCurrentSSIDTrusted()
} }
@ -120,19 +119,23 @@ data class AutoTunnelState(
} }
private fun isCurrentSSIDTrusted(): Boolean { private fun isCurrentSSIDTrusted(): Boolean {
return networkState.wifiName?.let {
hasTrustedWifiName(it)
} == true
}
private fun hasTrustedWifiName(wifiName: String, wifiNames: List<String> = settings.trustedNetworkSSIDs): Boolean {
return if (settings.isWildcardsEnabled) { return if (settings.isWildcardsEnabled) {
settings.trustedNetworkSSIDs.isMatchingToWildcardList(currentNetworkSSID) wifiNames.isMatchingToWildcardList(wifiName)
} else { } else {
settings.trustedNetworkSSIDs.contains(currentNetworkSSID) wifiNames.contains(wifiName)
} }
} }
private fun getTunnelWithMatchingTunnelNetwork(): TunnelConfig? { private fun getTunnelWithMatchingTunnelNetwork(): TunnelConfig? {
return tunnels.firstOrNull { return networkState.wifiName?.let { wifiName ->
if (settings.isWildcardsEnabled) { tunnels.firstOrNull {
it.tunnelNetworks.isMatchingToWildcardList(currentNetworkSSID) hasTrustedWifiName(wifiName, it.tunnelNetworks)
} else {
it.tunnelNetworks.contains(currentNetworkSSID)
} }
} }
} }

View File

@ -0,0 +1,8 @@
package com.zaneschepke.wireguardautotunnel.service.foreground.autotunnel.model
data class NetworkState(
val isWifiConnected: Boolean = false,
val isMobileDataConnected: Boolean = false,
val isEthernetConnected: Boolean = false,
val wifiName: String? = null,
)

View File

@ -10,7 +10,10 @@ import android.os.Build
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import timber.log.Timber
abstract class BaseNetworkService<T : BaseNetworkService<T>>( abstract class BaseNetworkService<T : BaseNetworkService<T>>(
val context: Context, val context: Context,
@ -22,8 +25,17 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(
val wifiManager = val wifiManager =
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
fun checkHasCapability(networkCapability: Int): Boolean {
val network = connectivityManager.activeNetwork
val networkCapabilities = connectivityManager.getNetworkCapabilities(network)
return networkCapabilities?.hasTransport(networkCapability) == true
}
override val networkStatus = override val networkStatus =
callbackFlow { callbackFlow {
if (!checkHasCapability(networkCapability)) {
trySend(NetworkStatus.Unavailable())
}
val networkStatusCallback = val networkStatusCallback =
when (Build.VERSION.SDK_INT) { when (Build.VERSION.SDK_INT) {
in Build.VERSION_CODES.S..Int.MAX_VALUE -> { in Build.VERSION_CODES.S..Int.MAX_VALUE -> {
@ -36,7 +48,7 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(
} }
override fun onLost(network: Network) { override fun onLost(network: Network) {
trySend(NetworkStatus.Unavailable(network)) trySend(NetworkStatus.Unavailable())
} }
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
@ -57,7 +69,7 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(
} }
override fun onLost(network: Network) { override fun onLost(network: Network) {
trySend(NetworkStatus.Unavailable(network)) trySend(NetworkStatus.Unavailable())
} }
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
@ -80,17 +92,20 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(
connectivityManager.registerNetworkCallback(request, networkStatusCallback) connectivityManager.registerNetworkCallback(request, networkStatusCallback)
awaitClose { connectivityManager.unregisterNetworkCallback(networkStatusCallback) } awaitClose { connectivityManager.unregisterNetworkCallback(networkStatusCallback) }
} }.catch {
Timber.e(it)
// conflate for backpressure
}.conflate()
} }
inline fun <Result> Flow<NetworkStatus>.map( inline fun <Result> Flow<NetworkStatus>.map(
crossinline onUnavailable: suspend (network: Network) -> Result, crossinline onUnavailable: suspend () -> Result,
crossinline onAvailable: suspend (network: Network) -> Result, crossinline onAvailable: suspend (network: Network) -> Result,
crossinline onCapabilitiesChanged: crossinline onCapabilitiesChanged:
suspend (network: Network, networkCapabilities: NetworkCapabilities) -> Result, suspend (network: Network, networkCapabilities: NetworkCapabilities) -> Result,
): Flow<Result> = map { status -> ): Flow<Result> = map { status ->
when (status) { when (status) {
is NetworkStatus.Unavailable -> onUnavailable(status.network) is NetworkStatus.Unavailable -> onUnavailable()
is NetworkStatus.Available -> onAvailable(status.network) is NetworkStatus.Available -> onAvailable(status.network)
is NetworkStatus.CapabilitiesChanged -> is NetworkStatus.CapabilitiesChanged ->
onCapabilitiesChanged( onCapabilitiesChanged(

View File

@ -4,10 +4,11 @@ import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
sealed class NetworkStatus { sealed class NetworkStatus {
class Available(val network: Network) : NetworkStatus() abstract val isConnected: Boolean
class Available(val network: Network, override val isConnected: Boolean = true) : NetworkStatus()
class Unavailable(val network: Network) : NetworkStatus() class Unavailable(override val isConnected: Boolean = false) : NetworkStatus()
class CapabilitiesChanged(val network: Network, val networkCapabilities: NetworkCapabilities) : class CapabilitiesChanged(val network: Network, val networkCapabilities: NetworkCapabilities, override val isConnected: Boolean = true) :
NetworkStatus() NetworkStatus()
} }

View File

@ -0,0 +1,7 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel
enum class BackendState {
KILL_SWITCH_ACTIVE,
SERVICE_ACTIVE,
INACTIVE,
}

View File

@ -12,13 +12,17 @@ interface TunnelService : Tunnel, org.amnezia.awg.backend.Tunnel {
suspend fun bounceTunnel() suspend fun bounceTunnel()
suspend fun getBackendState(): BackendState
suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>)
val vpnState: StateFlow<VpnState> val vpnState: StateFlow<VpnState>
suspend fun runningTunnelNames(): Set<String> suspend fun runningTunnelNames(): Set<String>
suspend fun getState(): TunnelState suspend fun getState(): TunnelState
fun cancelStatsJob() fun cancelActiveTunnelJobs()
fun startStatsJob() fun startActiveTunnelJobs()
} }

View File

@ -12,15 +12,15 @@ import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.AmneziaStatistics import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.AmneziaStatistics
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.WireGuardStatistics import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.WireGuardStatistics
import com.zaneschepke.wireguardautotunnel.util.extensions.asAmBackendState
import com.zaneschepke.wireguardautotunnel.util.extensions.asBackendState
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@ -35,7 +35,7 @@ class WireGuardTunnel
@Inject @Inject
constructor( constructor(
private val amneziaBackend: Provider<org.amnezia.awg.backend.Backend>, private val amneziaBackend: Provider<org.amnezia.awg.backend.Backend>,
tunnelConfigRepository: TunnelConfigRepository, private val tunnelConfigRepository: TunnelConfigRepository,
@Kernel private val kernelBackend: Provider<Backend>, @Kernel private val kernelBackend: Provider<Backend>,
private val appDataRepository: AppDataRepository, private val appDataRepository: AppDataRepository,
@ApplicationScope private val applicationScope: CoroutineScope, @ApplicationScope private val applicationScope: CoroutineScope,
@ -44,22 +44,28 @@ constructor(
) : TunnelService { ) : TunnelService {
private val _vpnState = MutableStateFlow(VpnState()) private val _vpnState = MutableStateFlow(VpnState())
override val vpnState: StateFlow<VpnState> = _vpnState.combine( override val vpnState: StateFlow<VpnState> = _vpnState.asStateFlow()
tunnelConfigRepository.getTunnelConfigsFlow(),
) {
vpnState, tunnels ->
vpnState.copy(
tunnelConfig = tunnels.firstOrNull { it.id == vpnState.tunnelConfig?.id },
)
}.stateIn(applicationScope, SharingStarted.Eagerly, VpnState())
private var statsJob: Job? = null private var statsJob: Job? = null
private var tunnelChangesJob: Job? = null
private val mutex = Mutex() @get:Synchronized @set:Synchronized
private var isKernelBackend: Boolean? = null
private val tunnelControlMutex = Mutex()
init {
applicationScope.launch(ioDispatcher) {
appDataRepository.settings.getSettingsFlow().collect {
isKernelBackend = it.isKernelEnabled
}
}
}
private suspend fun backend(): Any { private suspend fun backend(): Any {
val settings = appDataRepository.settings.getSettings() val isKernelEnabled = isKernelBackend
if (settings.isKernelEnabled) return kernelBackend.get() ?: appDataRepository.settings.getSettings().isKernelEnabled
if (isKernelEnabled) return kernelBackend.get()
return amneziaBackend.get() return amneziaBackend.get()
} }
@ -103,13 +109,15 @@ constructor(
override suspend fun startTunnel(tunnelConfig: TunnelConfig?, background: Boolean) { override suspend fun startTunnel(tunnelConfig: TunnelConfig?, background: Boolean) {
if (tunnelConfig == null) return if (tunnelConfig == null) return
withContext(ioDispatcher) { withContext(ioDispatcher) {
mutex.withLock { if (isTunnelAlreadyRunning(tunnelConfig)) return@withContext
if (isTunnelAlreadyRunning(tunnelConfig)) return@withContext withServiceActive {
onBeforeStart(background) onBeforeStart(background)
setState(tunnelConfig, TunnelState.UP).onSuccess { tunnelControlMutex.withLock {
startStatsJob() setState(tunnelConfig, TunnelState.UP).onSuccess {
if (it.isUp()) appDataRepository.tunnels.save(tunnelConfig.copy(isActive = true)) startActiveTunnelJobs()
updateTunnelState(it, tunnelConfig) if (it.isUp()) appDataRepository.tunnels.save(tunnelConfig.copy(isActive = true))
updateTunnelState(it, tunnelConfig)
}
}.onFailure { }.onFailure {
Timber.e(it) Timber.e(it)
} }
@ -119,10 +127,10 @@ constructor(
override suspend fun stopTunnel() { override suspend fun stopTunnel() {
withContext(ioDispatcher) { withContext(ioDispatcher) {
mutex.withLock { if (_vpnState.value.status.isDown()) return@withContext
if (_vpnState.value.status.isDown()) return@withContext with(_vpnState.value) {
with(_vpnState.value) { if (tunnelConfig == null) return@withContext
if (tunnelConfig == null) return@withContext tunnelControlMutex.withLock {
setState(tunnelConfig, TunnelState.DOWN).onSuccess { setState(tunnelConfig, TunnelState.DOWN).onSuccess {
updateTunnelState(it, null) updateTunnelState(it, null)
onStop(tunnelConfig) onStop(tunnelConfig)
@ -135,11 +143,64 @@ constructor(
} }
} }
private suspend fun toggleTunnel(tunnelConfig: TunnelConfig) {
withContext(ioDispatcher) {
tunnelControlMutex.withLock {
setState(tunnelConfig, TunnelState.TOGGLE)
}
}
}
// utility to keep vpnService alive during rapid changes to prevent bad states
private suspend fun withServiceActive(callback: suspend () -> Unit) {
when (val backend = backend()) {
is org.amnezia.awg.backend.Backend -> {
val backendState = backend.backendState
if (backendState == org.amnezia.awg.backend.Backend.BackendState.INACTIVE) {
backend.setBackendState(org.amnezia.awg.backend.Backend.BackendState.SERVICE_ACTIVE, emptyList())
}
callback()
}
is Backend -> {
callback()
}
}
}
override suspend fun bounceTunnel() { override suspend fun bounceTunnel() {
if (_vpnState.value.tunnelConfig == null) return _vpnState.value.tunnelConfig?.let {
val config = _vpnState.value.tunnelConfig withServiceActive {
stopTunnel() toggleTunnel(it)
startTunnel(config) toggleTunnel(it)
}
}
}
override suspend fun getBackendState(): BackendState {
return when (val backend = backend()) {
is org.amnezia.awg.backend.Backend -> {
backend.backendState.asBackendState()
}
is Backend -> {
BackendState.SERVICE_ACTIVE
}
else -> BackendState.INACTIVE
}
}
override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection<String>) {
kotlin.runCatching {
when (val backend = backend()) {
is org.amnezia.awg.backend.Backend -> {
backend.setBackendState(backendState.asAmBackendState(), allowedIps)
}
is Backend -> {
// TODO not yet implemented
Timber.d("Kernel backend state not yet implemented")
}
else -> Unit
}
}
} }
private suspend fun shutDownActiveTunnel() { private suspend fun shutDownActiveTunnel() {
@ -169,7 +230,7 @@ constructor(
private suspend fun onStop(tunnelConfig: TunnelConfig) { private suspend fun onStop(tunnelConfig: TunnelConfig) {
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = false)) appDataRepository.tunnels.save(tunnelConfig.copy(isActive = false))
cancelStatsJob() cancelActiveTunnelJobs()
resetBackendStatistics() resetBackendStatistics()
} }
@ -179,7 +240,13 @@ constructor(
} }
} }
private fun emitBackendStatistics(statistics: TunnelStatistics) { private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) {
_vpnState.update {
it.copy(tunnelConfig = tunnelConfig)
}
}
private fun updateBackendStatistics(statistics: TunnelStatistics) {
_vpnState.update { _vpnState.update {
it.copy(statistics = statistics) it.copy(statistics = statistics)
} }
@ -199,12 +266,14 @@ constructor(
} }
} }
override fun cancelStatsJob() { override fun cancelActiveTunnelJobs() {
statsJob?.cancel() statsJob?.cancel()
tunnelChangesJob?.cancel()
} }
override fun startStatsJob() { override fun startActiveTunnelJobs() {
statsJob = startTunnelStatisticsJob() statsJob = startTunnelStatisticsJob()
tunnelChangesJob = startTunnelConfigChangesJob()
} }
override fun getName(): String { override fun getName(): String {
@ -216,11 +285,11 @@ constructor(
delay(STATS_START_DELAY) delay(STATS_START_DELAY)
while (true) { while (true) {
when (backend) { when (backend) {
is Backend -> emitBackendStatistics( is Backend -> updateBackendStatistics(
WireGuardStatistics(backend.getStatistics(this@WireGuardTunnel)), WireGuardStatistics(backend.getStatistics(this@WireGuardTunnel)),
) )
is org.amnezia.awg.backend.Backend -> { is org.amnezia.awg.backend.Backend -> {
emitBackendStatistics( updateBackendStatistics(
AmneziaStatistics( AmneziaStatistics(
backend.getStatistics(this@WireGuardTunnel), backend.getStatistics(this@WireGuardTunnel),
), ),
@ -231,6 +300,22 @@ constructor(
} }
} }
private fun startTunnelConfigChangesJob() = applicationScope.launch(ioDispatcher) {
tunnelConfigRepository.getTunnelConfigsFlow().collect {
with(_vpnState.value) {
if (status.isDown() || tunnelConfig == null) return@collect
val vpnConfigFromStorage = it.first { it.id == tunnelConfig.id }
val isRestartNeeded = vpnConfigFromStorage.wgQuick != tunnelConfig.wgQuick ||
vpnConfigFromStorage.amQuick != tunnelConfig.amQuick
updateTunnelConfig(vpnConfigFromStorage)
if (isRestartNeeded) {
Timber.d("Bouncing tunnel on config change")
bounceTunnel()
}
}
}
}
override fun onStateChange(newState: Tunnel.State) { override fun onStateChange(newState: Tunnel.State) {
_vpnState.update { _vpnState.update {
it.copy(status = TunnelState.from(newState)) it.copy(status = TunnelState.from(newState))

View File

@ -9,10 +9,12 @@ import com.wireguard.android.util.RootShell
import com.zaneschepke.logcatter.LogReader import com.zaneschepke.logcatter.LogReader
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.AppShell import com.zaneschepke.wireguardautotunnel.module.AppShell
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.BackendState
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
@ -34,6 +36,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
import xyz.teamgravity.pin_lock_compose.PinManager import xyz.teamgravity.pin_lock_compose.PinManager
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
@ -80,7 +83,7 @@ constructor(
init { init {
viewModelScope.launch { viewModelScope.launch {
initPin() initPin()
initAutoTunnel() initServices()
initTunnel() initTunnel()
appReadyCheck() appReadyCheck()
} }
@ -94,7 +97,7 @@ constructor(
} }
private suspend fun initTunnel() { private suspend fun initTunnel() {
if (tunnelService.get().getState() == TunnelState.UP) tunnelService.get().startStatsJob() if (tunnelService.get().getState() == TunnelState.UP) tunnelService.get().startActiveTunnelJobs()
val activeTunnels = appDataRepository.tunnels.getActive() val activeTunnels = appDataRepository.tunnels.getActive()
if (activeTunnels.isNotEmpty() && if (activeTunnels.isNotEmpty() &&
tunnelService.get().getState() == TunnelState.DOWN tunnelService.get().getState() == TunnelState.DOWN
@ -108,9 +111,12 @@ constructor(
if (isPinEnabled) PinManager.initialize(WireGuardAutoTunnel.instance) if (isPinEnabled) PinManager.initialize(WireGuardAutoTunnel.instance)
} }
private suspend fun initAutoTunnel() { private suspend fun initServices() {
val settings = appDataRepository.settings.getSettings() withContext(ioDispatcher) {
if (settings.isAutoTunnelEnabled) serviceManager.startAutoTunnel(false) val settings = appDataRepository.settings.getSettings()
handleVpnKillSwitchChange(settings.isVpnKillSwitchEnabled)
if (settings.isAutoTunnelEnabled) serviceManager.startAutoTunnel(false)
}
} }
fun onPinLockDisabled() = viewModelScope.launch(ioDispatcher) { fun onPinLockDisabled() = viewModelScope.launch(ioDispatcher) {
@ -170,10 +176,50 @@ constructor(
} }
} }
fun onToggleVpnKillSwitch(enabled: Boolean) = viewModelScope.launch {
with(uiState.value.settings) {
appDataRepository.settings.save(
copy(
isVpnKillSwitchEnabled = enabled,
isLanOnKillSwitchEnabled = if (enabled) isLanOnKillSwitchEnabled else false,
),
)
}
handleVpnKillSwitchChange(enabled)
}
private suspend fun handleVpnKillSwitchChange(enabled: Boolean) {
withContext(ioDispatcher) {
if (enabled) {
Timber.d("Starting kill switch")
val allowedIps = if (appDataRepository.settings.getSettings().isLanOnKillSwitchEnabled) {
TunnelConfig.IPV4_PUBLIC_NETWORKS
} else {
emptySet()
}
tunnelService.get().setBackendState(BackendState.KILL_SWITCH_ACTIVE, allowedIps)
} else {
Timber.d("Sending shutdown of kill switch")
tunnelService.get().setBackendState(BackendState.SERVICE_ACTIVE, emptySet())
}
}
}
fun onToggleLanOnKillSwitch(enabled: Boolean) = viewModelScope.launch(ioDispatcher) {
appDataRepository.settings.save(
uiState.value.settings.copy(
isLanOnKillSwitchEnabled = enabled,
),
)
val allowedIps = if (enabled) TunnelConfig.IPV4_PUBLIC_NETWORKS else emptySet()
Timber.d("Setting allowedIps $allowedIps")
tunnelService.get().setBackendState(BackendState.KILL_SWITCH_ACTIVE, allowedIps)
}
fun onToggleShortcutsEnabled() = viewModelScope.launch { fun onToggleShortcutsEnabled() = viewModelScope.launch {
with(uiState.value.settings) { with(uiState.value.settings) {
appDataRepository.settings.save( appDataRepository.settings.save(
this.copy( copy(
isShortcutsEnabled = !isShortcutsEnabled, isShortcutsEnabled = !isShortcutsEnabled,
), ),
) )

View File

@ -52,6 +52,7 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.displa
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.LanguageScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.LanguageScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.AutoTunnelScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.AutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.disclosure.LocationDisclosureScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.disclosure.LocationDisclosureScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch.KillSwitchScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
@ -144,8 +145,8 @@ class MainActivity : AppCompatActivity() {
), ),
) )
}, },
) { ) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(it)) { Box(modifier = Modifier.fillMaxSize().padding(padding)) {
NavHost( NavHost(
navController, navController,
enterTransition = { fadeIn(tween(Constants.TRANSITION_ANIMATION_TIME)) }, enterTransition = { fadeIn(tween(Constants.TRANSITION_ANIMATION_TIME)) },
@ -207,6 +208,9 @@ class MainActivity : AppCompatActivity() {
composable<Route.Scanner> { composable<Route.Scanner> {
ScannerScreen() ScannerScreen()
} }
composable<Route.KillSwitch> {
KillSwitchScreen(appUiState, viewModel)
}
} }
} }
} }
@ -218,6 +222,6 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
// save battery by not polling stats while app is closed // save battery by not polling stats while app is closed
tunnelService.cancelStatsJob() tunnelService.cancelActiveTunnelJobs()
} }
} }

View File

@ -21,6 +21,9 @@ sealed class Route {
@Serializable @Serializable
data object Display : Route() data object Display : Route()
@Serializable
data object KillSwitch : Route()
@Serializable @Serializable
data object Language : Route() data object Language : Route()

View File

@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components package com.zaneschepke.wireguardautotunnel.ui.common.permission.vpn
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme

View File

@ -0,0 +1,38 @@
package com.zaneschepke.wireguardautotunnel.ui.common.permission.vpn
import android.net.VpnService
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
@Composable
inline fun <T> withVpnPermission(crossinline onSuccess: (t: T) -> Unit): (t: T) -> Unit {
val context = LocalContext.current
var showVpnPermissionDialog by remember { mutableStateOf(false) }
val vpnActivity =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
onResult = {
if (it.resultCode != RESULT_OK) showVpnPermissionDialog = true
},
)
VpnDeniedDialog(showVpnPermissionDialog, onDismiss = { showVpnPermissionDialog = false })
return {
val intent = VpnService.prepare(context)
if (intent != null) {
vpnActivity.launch(intent)
} else {
onSuccess(it)
}
}
}

View File

@ -0,0 +1,35 @@
package com.zaneschepke.wireguardautotunnel.ui.common.permission
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.zaneschepke.wireguardautotunnel.util.extensions.isBatteryOptimizationsDisabled
@Composable
inline fun withIgnoreBatteryOpt(ignore: Boolean, crossinline callback: () -> Unit): () -> Unit {
val context = LocalContext.current
val batteryActivity =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result: ActivityResult ->
// we only ask once
callback()
}
return {
if (ignore || context.isBatteryOptimizationsDisabled()) {
callback()
} else {
batteryActivity.launch(
Intent().apply {
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.parse("package:${context.packageName}")
},
)
}
}
}

View File

@ -1,13 +1,7 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main package com.zaneschepke.wireguardautotunnel.ui.screens.main
import android.content.Intent
import android.net.Uri
import android.net.VpnService
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@ -51,15 +45,15 @@ import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.permission.vpn.withVpnPermission
import com.zaneschepke.wireguardautotunnel.ui.common.permission.withIgnoreBatteryOpt
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.AutoTunnelRowItem import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.AutoTunnelRowItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.GettingStartedLabel import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.GettingStartedLabel
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissFab import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissFab
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImportSheet import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImportSheet
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelRowItem import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelRowItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.VpnDeniedDialog
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isBatteryOptimizationsDisabled
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
@ -73,30 +67,28 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
val snackbar = SnackbarController.current val snackbar = SnackbarController.current
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var showVpnPermissionDialog by remember { mutableStateOf(false) }
var isFabVisible by rememberSaveable { mutableStateOf(true) } var isFabVisible by rememberSaveable { mutableStateOf(true) }
var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) } var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) }
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) } var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val isRunningOnTv = remember { context.isRunningOnTv() } val isRunningOnTv = remember { context.isRunningOnTv() }
val startAutoTunnel = withVpnPermission<Unit> { viewModel.onToggleAutoTunnel() }
val startTunnel = withVpnPermission<TunnelConfig> {
viewModel.onTunnelStart(it, uiState.settings.isKernelEnabled)
}
val autoTunnelToggleBattery = withIgnoreBatteryOpt(uiState.generalState.isBatteryOptimizationDisableShown) {
if (!uiState.generalState.isBatteryOptimizationDisableShown) viewModel.setBatteryOptimizeDisableShown()
if (uiState.settings.isKernelEnabled) {
viewModel.onToggleAutoTunnel()
} else {
startAutoTunnel.invoke(Unit)
}
}
val nestedScrollConnection = remember { val nestedScrollConnection = remember {
NestedScrollListener({ isFabVisible = false }, { isFabVisible = true }) NestedScrollListener({ isFabVisible = false }, { isFabVisible = true })
} }
val vpnActivity =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
onResult = {
if (it.resultCode != RESULT_OK) showVpnPermissionDialog = true
},
)
val batteryActivity =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result: ActivityResult ->
viewModel.setBatteryOptimizeDisableShown()
}
val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult(onNoFileExplorer = { val tunnelFileImportResultLauncher = rememberFileImportLauncherForResult(onNoFileExplorer = {
snackbar.showMessage( snackbar.showMessage(
context.getString(R.string.error_no_file_explorer), context.getString(R.string.error_no_file_explorer),
@ -112,8 +104,6 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
navController.navigate(Route.Scanner) navController.navigate(Route.Scanner)
} }
VpnDeniedDialog(showVpnPermissionDialog, onDismiss = { showVpnPermissionDialog = false })
if (showDeleteTunnelAlertDialog) { if (showDeleteTunnelAlertDialog) {
InfoDialog( InfoDialog(
onDismiss = { showDeleteTunnelAlertDialog = false }, onDismiss = { showDeleteTunnelAlertDialog = false },
@ -128,35 +118,13 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
) )
} }
fun requestBatteryOptimizationsDisabled() {
val intent =
Intent().apply {
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.parse("package:${context.packageName}")
}
batteryActivity.launch(intent)
}
fun onAutoTunnelToggle() {
if (!uiState.generalState.isBatteryOptimizationDisableShown &&
!context.isBatteryOptimizationsDisabled() && !isRunningOnTv
) {
return requestBatteryOptimizationsDisabled()
}
val intent = if (!uiState.settings.isKernelEnabled) {
VpnService.prepare(context)
} else {
null
}
if (intent != null) return vpnActivity.launch(intent)
viewModel.onToggleAutoTunnel()
}
fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) { fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) {
val intent = if (uiState.settings.isKernelEnabled) null else VpnService.prepare(context)
if (intent != null) return vpnActivity.launch(intent)
if (!checked) viewModel.onTunnelStop().also { return } if (!checked) viewModel.onTunnelStop().also { return }
viewModel.onTunnelStart(tunnel, uiState.settings.isKernelEnabled) if (uiState.settings.isKernelEnabled) {
viewModel.onTunnelStart(tunnel, uiState.settings.isKernelEnabled)
} else {
startTunnel.invoke(tunnel)
}
} }
Scaffold( Scaffold(
@ -239,9 +207,9 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
} }
} else { } else {
item { item {
AutoTunnelRowItem(uiState, { AutoTunnelRowItem(uiState) {
onAutoTunnelToggle() autoTunnelToggleBattery.invoke()
}) }
} }
} }
items( items(

View File

@ -12,13 +12,13 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ViewQuilt import androidx.compose.material.icons.automirrored.outlined.ViewQuilt
import androidx.compose.material.icons.filled.AppShortcut import androidx.compose.material.icons.filled.AppShortcut
import androidx.compose.material.icons.outlined.AdminPanelSettings
import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Code import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.FolderZip import androidx.compose.material.icons.outlined.FolderZip
import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.Pin import androidx.compose.material.icons.outlined.Pin
import androidx.compose.material.icons.outlined.Restore import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material.icons.outlined.VpnKeyOff
import androidx.compose.material.icons.outlined.VpnLock import androidx.compose.material.icons.outlined.VpnLock
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -49,7 +49,6 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.Forwar
import com.zaneschepke.wireguardautotunnel.ui.theme.topPadding import com.zaneschepke.wireguardautotunnel.ui.theme.topPadding
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.launchNotificationSettings import com.zaneschepke.wireguardautotunnel.util.extensions.launchNotificationSettings
import com.zaneschepke.wireguardautotunnel.util.extensions.launchVpnSettings
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
import com.zaneschepke.wireguardautotunnel.util.extensions.showToast import com.zaneschepke.wireguardautotunnel.util.extensions.showToast
@ -179,18 +178,18 @@ fun SettingsScreen(viewModel: SettingsViewModel = hiltViewModel(), appViewModel:
onClick = { appViewModel.onToggleAlwaysOnVPN() }, onClick = { appViewModel.onToggleAlwaysOnVPN() },
), ),
SelectionItem( SelectionItem(
Icons.Outlined.AdminPanelSettings, Icons.Outlined.VpnKeyOff,
title = { title = {
Text( Text(
stringResource(R.string.kill_switch), stringResource(R.string.kill_switch_options),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface), style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
) )
}, },
onClick = { onClick = {
context.launchVpnSettings() navController.navigate(Route.KillSwitch)
}, },
trailing = { trailing = {
ForwardButton { context.launchVpnSettings() } ForwardButton { navController.navigate(Route.KillSwitch) }
}, },
), ),
), ),

View File

@ -0,0 +1,142 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AdminPanelSettings
import androidx.compose.material.icons.outlined.Lan
import androidx.compose.material.icons.outlined.VpnKey
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.permission.vpn.withVpnPermission
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ForwardButton
import com.zaneschepke.wireguardautotunnel.util.extensions.launchVpnSettings
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledHeight
import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth
@Composable
fun KillSwitchScreen(uiState: AppUiState, appViewModel: AppViewModel) {
val context = LocalContext.current
val toggleVpnSwitch = withVpnPermission<Boolean> { appViewModel.onToggleVpnKillSwitch(it) }
fun toggleVpnKillSwitch() {
with(uiState.settings) {
if (isVpnKillSwitchEnabled) {
appViewModel.onToggleVpnKillSwitch(false)
} else {
toggleVpnSwitch.invoke(true)
}
}
}
fun toggleLanOnKillSwitch() {
with(uiState.settings) {
appViewModel.onToggleLanOnKillSwitch(!isLanOnKillSwitchEnabled)
}
}
Scaffold(
topBar = {
TopNavBar(stringResource(R.string.kill_switch))
},
) { padding ->
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top),
modifier =
Modifier
.fillMaxSize().padding(padding)
.padding(top = 24.dp.scaledHeight())
.padding(horizontal = 24.dp.scaledWidth()),
) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
Icons.Outlined.AdminPanelSettings,
title = {
Text(
stringResource(R.string.native_kill_switch),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { context.launchVpnSettings() },
trailing = {
ForwardButton { context.launchVpnSettings() }
},
),
),
)
SurfaceSelectionGroupButton(
buildList {
add(
SelectionItem(
Icons.Outlined.VpnKey,
title = {
Text(
stringResource(R.string.vpn_kill_switch),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = {
toggleVpnKillSwitch()
},
trailing = {
ScaledSwitch(
uiState.settings.isVpnKillSwitchEnabled,
onClick = {
toggleVpnKillSwitch()
},
)
},
),
)
if (uiState.settings.isVpnKillSwitchEnabled) {
add(
SelectionItem(
Icons.Outlined.Lan,
title = {
Text(
stringResource(R.string.allow_lan_traffic),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
onClick = { toggleLanOnKillSwitch() },
description = {
Text(
stringResource(R.string.bypass_lan_for_kill_switch),
style = MaterialTheme.typography.bodySmall.copy(MaterialTheme.colorScheme.outline),
)
},
trailing = {
ScaledSwitch(
uiState.settings.isLanOnKillSwitchEnabled,
onClick = {
toggleLanOnKillSwitch()
},
)
},
),
)
}
},
)
}
}
}

View File

@ -3,12 +3,14 @@ package com.zaneschepke.wireguardautotunnel.util.extensions
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.wireguard.android.util.RootShell import com.wireguard.android.util.RootShell
import com.wireguard.config.Peer import com.wireguard.config.Peer
import com.zaneschepke.wireguardautotunnel.service.tunnel.BackendState
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.ui.theme.Straw import com.zaneschepke.wireguardautotunnel.ui.theme.Straw
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import org.amnezia.awg.backend.Backend
import org.amnezia.awg.config.Config import org.amnezia.awg.config.Config
import timber.log.Timber import timber.log.Timber
import java.net.InetAddress import java.net.InetAddress
@ -85,3 +87,11 @@ fun RootShell.getCurrentWifiName(): String? {
this.run(response, "dumpsys wifi | grep -o \"SSID: [^,]*\" | cut -d ' ' -f2- | tr -d '\"'") this.run(response, "dumpsys wifi | grep -o \"SSID: [^,]*\" | cut -d ' ' -f2- | tr -d '\"'")
return response.lastOrNull() return response.lastOrNull()
} }
fun Backend.BackendState.asBackendState(): BackendState {
return BackendState.valueOf(this.name)
}
fun BackendState.asAmBackendState(): Backend.BackendState {
return Backend.BackendState.valueOf(this.name)
}

View File

@ -182,4 +182,9 @@
<string name="stop_on_internet_loss">Stop tunnel on internet loss</string> <string name="stop_on_internet_loss">Stop tunnel on internet loss</string>
<string name="ethernet_tunnel">Ethernet tunnel</string> <string name="ethernet_tunnel">Ethernet tunnel</string>
<string name="set_ethernet_tunnel">Set as ethernet tunnel</string> <string name="set_ethernet_tunnel">Set as ethernet tunnel</string>
<string name="native_kill_switch">Native kill switch</string>
<string name="vpn_kill_switch">VPN kill switch</string>
<string name="kill_switch_options">Kill switch options</string>
<string name="allow_lan_traffic">Allow LAN traffic</string>
<string name="bypass_lan_for_kill_switch">Bypass LAN for kill switch</string>
</resources> </resources>

View File

@ -1,7 +1,7 @@
[versions] [versions]
accompanist = "0.36.0" accompanist = "0.36.0"
activityCompose = "1.9.3" activityCompose = "1.9.3"
amneziawgAndroid = "1.2.2" amneziawgAndroid = "1.2.3"
androidx-junit = "1.2.1" androidx-junit = "1.2.1"
appcompat = "1.7.0" appcompat = "1.7.0"
biometricKtx = "1.2.0-alpha05" biometricKtx = "1.2.0-alpha05"
@ -10,7 +10,7 @@ coreKtx = "1.15.0"
datastorePreferences = "1.1.1" datastorePreferences = "1.1.1"
desugar_jdk_libs = "2.1.3" desugar_jdk_libs = "2.1.3"
espressoCore = "3.6.1" espressoCore = "3.6.1"
hiltAndroid = "2.52" hiltAndroid = "2.53"
hiltNavigationCompose = "1.2.0" hiltNavigationCompose = "1.2.0"
junit = "4.13.2" junit = "4.13.2"
kotlinx-serialization-json = "1.7.3" kotlinx-serialization-json = "1.7.3"
@ -21,9 +21,9 @@ pinLockCompose = "1.0.4"
roomVersion = "2.6.1" roomVersion = "2.6.1"
timber = "5.0.1" timber = "5.0.1"
tunnel = "1.2.1" tunnel = "1.2.1"
androidGradlePlugin = "8.7.2" androidGradlePlugin = "8.8.0-rc01"
kotlin = "2.0.21" kotlin = "2.1.0"
ksp = "2.0.21-1.0.28" ksp = "2.1.0-1.0.29"
composeBom = "2024.11.00" composeBom = "2024.11.00"
compose = "1.7.5" compose = "1.7.5"
zxingAndroidEmbedded = "4.3.0" zxingAndroidEmbedded = "4.3.0"
@ -32,7 +32,7 @@ gradlePlugins-grgit = "5.3.0"
#plugins #plugins
material = "1.12.0" material = "1.12.0"
gradlePlugins-ktlint="12.1.1" gradlePlugins-ktlint="12.1.2"
[libraries] [libraries]

View File

@ -1,8 +1,8 @@
#Wed Oct 11 22:39:21 EDT 2023 #Wed Oct 11 22:39:21 EDT 2023
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab distributionSha256Sum=31c55713e40233a8303827ceb42ca48a47267a0ad4bab9177123121e71524c26
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists