feat!: move ping from auto tunnel to tunnel

Ping feature will not be tunnel specific and work without auto tunneling being active
This commit is contained in:
Zane Schepke 2025-01-01 18:16:26 -05:00
parent ba064b267f
commit f772dc0f8a
14 changed files with 245 additions and 244 deletions

View File

@ -66,7 +66,7 @@ data class TunnelConfig(
) { ) {
fun toAmConfig(): org.amnezia.awg.config.Config { fun toAmConfig(): org.amnezia.awg.config.Config {
return configFromAmQuick(if (amQuick != "") amQuick else wgQuick) return configFromAmQuick(if (amQuick.isNotBlank()) amQuick else wgQuick)
} }
fun toWgConfig(): Config { fun toWgConfig(): Config {

View File

@ -9,7 +9,6 @@ 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.Settings
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.Ethernet import com.zaneschepke.wireguardautotunnel.module.Ethernet
@ -29,29 +28,20 @@ import com.zaneschepke.wireguardautotunnel.service.notification.WireGuardNotific
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.TunnelConfigs
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.onNotRunning
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.net.InetAddress
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
@ -100,10 +90,6 @@ class AutoTunnelService : LifecycleService() {
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private val pingTunnelRestartActive = AtomicBoolean(false)
private var pingJob: Job? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
serviceManager.autoTunnelService.complete(this) serviceManager.autoTunnelService.complete(this)
@ -135,7 +121,6 @@ class AutoTunnelService : LifecycleService() {
} }
startAutoTunnelJob() startAutoTunnelJob()
startAutoTunnelStateJob() startAutoTunnelStateJob()
startPingStateJob()
}.onFailure { }.onFailure {
Timber.e(it) Timber.e(it)
} }
@ -147,7 +132,6 @@ class AutoTunnelService : LifecycleService() {
} }
override fun onDestroy() { override fun onDestroy() {
cancelAndResetPingJob()
serviceManager.autoTunnelService = CompletableDeferred() serviceManager.autoTunnelService = CompletableDeferred()
super.onDestroy() super.onDestroy()
} }
@ -184,59 +168,6 @@ class AutoTunnelService : LifecycleService() {
} }
} }
private fun startPingJob() = lifecycleScope.launch {
watchForPingFailure()
}
private fun startPingStateJob() = lifecycleScope.launch {
autoTunnelStateFlow.collect {
if (it == defaultState) return@collect
if (it.isPingEnabled()) {
pingJob.onNotRunning { pingJob = startPingJob() }
} else {
if (!pingTunnelRestartActive.get()) cancelAndResetPingJob()
}
}
}
private suspend fun watchForPingFailure() {
withContext(ioDispatcher) {
Timber.i("Starting ping watcher")
runCatching {
do {
val vpnState = autoTunnelStateFlow.value.vpnState
if (vpnState.status.isUp() && !autoTunnelStateFlow.value.isNoConnectivity()) {
if (vpnState.tunnelConfig != null) {
val config = TunnelConfig.configFromWgQuick(vpnState.tunnelConfig.wgQuick)
val results = if (vpnState.tunnelConfig.pingIp != null) {
Timber.d("Pinging custom ip : ${vpnState.tunnelConfig.pingIp}")
listOf(InetAddress.getByName(vpnState.tunnelConfig.pingIp).isReachable(Constants.PING_TIMEOUT.toInt()))
} else {
Timber.d("Pinging all peers")
config.peers.map { peer ->
peer.isReachable()
}
}
Timber.i("Ping results reachable: $results")
if (results.contains(false)) {
Timber.i("Restarting VPN for ping failure")
val cooldown = vpnState.tunnelConfig.pingCooldown
pingTunnelRestartActive.set(true)
tunnelService.get().bounceTunnel()
pingTunnelRestartActive.set(false)
delay(cooldown ?: Constants.PING_COOLDOWN)
continue
}
}
}
delay(vpnState.tunnelConfig?.pingInterval ?: Constants.PING_INTERVAL)
} while (true)
}.onFailure {
Timber.e(it)
}
}
}
private fun startAutoTunnelStateJob() = lifecycleScope.launch(ioDispatcher) { private fun startAutoTunnelStateJob() = lifecycleScope.launch(ioDispatcher) {
combine( combine(
combineSettings(), combineSettings(),
@ -263,11 +194,6 @@ class AutoTunnelService : LifecycleService() {
} }
} }
private fun cancelAndResetPingJob() {
pingJob?.cancelWithMessage("Ping job canceled")
pingJob = null
}
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
private fun combineNetworkEventsJob(): Flow<NetworkState> { private fun combineNetworkEventsJob(): Flow<NetworkState> {
return combine( return combine(
@ -281,7 +207,7 @@ class AutoTunnelService : LifecycleService() {
wifi.name, wifi.name,
wifi.capabilities, wifi.capabilities,
) )
}.distinctUntilChanged().filterNot { it.isWifiConnected && it.wifiName == null } }.distinctUntilChanged()
} }
private fun combineSettings(): Flow<Pair<Settings, TunnelConfigs>> { private fun combineSettings(): Flow<Pair<Settings, TunnelConfigs>> {

View File

@ -17,8 +17,11 @@ import com.zaneschepke.wireguardautotunnel.service.notification.WireGuardNotific
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.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.asAmBackendState import com.zaneschepke.wireguardautotunnel.util.extensions.asAmBackendState
import com.zaneschepke.wireguardautotunnel.util.extensions.asBackendState import com.zaneschepke.wireguardautotunnel.util.extensions.asBackendState
import com.zaneschepke.wireguardautotunnel.util.extensions.cancelWithMessage
import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -33,6 +36,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.amnezia.awg.backend.Tunnel import org.amnezia.awg.backend.Tunnel
import timber.log.Timber import timber.log.Timber
import java.net.InetAddress
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
@ -54,6 +58,7 @@ constructor(
private var statsJob: Job? = null private var statsJob: Job? = null
private var tunnelChangesJob: Job? = null private var tunnelChangesJob: Job? = null
private var pingJob: Job? = null
@get:Synchronized @set:Synchronized @get:Synchronized @set:Synchronized
private var isKernelBackend: Boolean? = null private var isKernelBackend: Boolean? = null
@ -86,16 +91,9 @@ constructor(
private suspend fun setState(tunnelConfig: TunnelConfig, tunnelState: TunnelState): Result<TunnelState> { private suspend fun setState(tunnelConfig: TunnelConfig, tunnelState: TunnelState): Result<TunnelState> {
return runCatching { return runCatching {
when (val backend = backend()) { when (val backend = backend()) {
is Backend -> backend.setState(this, tunnelState.toWgState(), TunnelConfig.configFromWgQuick(tunnelConfig.wgQuick)).let { TunnelState.from(it) } is Backend -> backend.setState(this, tunnelState.toWgState(), tunnelConfig.toWgConfig()).let { TunnelState.from(it) }
is org.amnezia.awg.backend.Backend -> { is org.amnezia.awg.backend.Backend -> {
val config = if (tunnelConfig.amQuick.isBlank()) { backend.setState(this, tunnelState.toAmState(), tunnelConfig.toAmConfig()).let {
TunnelConfig.configFromAmQuick(
tunnelConfig.wgQuick,
)
} else {
TunnelConfig.configFromAmQuick(tunnelConfig.amQuick)
}
backend.setState(this, tunnelState.toAmState(), config).let {
TunnelState.from(it) TunnelState.from(it)
} }
} }
@ -108,9 +106,11 @@ constructor(
} }
private fun isTunnelAlreadyRunning(tunnelConfig: TunnelConfig): Boolean { private fun isTunnelAlreadyRunning(tunnelConfig: TunnelConfig): Boolean {
val isRunning = tunnelConfig.id == _vpnState.value.tunnelConfig?.id && _vpnState.value.status.isUp() return with(_vpnState.value) {
if (isRunning) Timber.w("Tunnel already running") this.tunnelConfig?.id == tunnelConfig.id && status.isUp().also {
return isRunning if (it) Timber.w("Tunnel already running")
}
}
} }
override suspend fun startTunnel(tunnelConfig: TunnelConfig?, background: Boolean) { override suspend fun startTunnel(tunnelConfig: TunnelConfig?, background: Boolean) {
@ -165,10 +165,12 @@ constructor(
} }
override suspend fun bounceTunnel() { override suspend fun bounceTunnel() {
_vpnState.value.tunnelConfig?.let { with(_vpnState.value) {
withServiceActive { if (tunnelConfig != null && status.isUp()) {
toggleTunnel(it) withServiceActive {
toggleTunnel(it) toggleTunnel(tunnelConfig)
toggleTunnel(tunnelConfig)
}
} }
} }
} }
@ -273,13 +275,15 @@ constructor(
} }
override fun cancelActiveTunnelJobs() { override fun cancelActiveTunnelJobs() {
statsJob?.cancel() statsJob?.cancelWithMessage("Tunnel stats job cancelled")
tunnelChangesJob?.cancel() tunnelChangesJob?.cancelWithMessage("Tunnel changes job cancelled")
pingJob?.cancelWithMessage("Ping job cancelled")
} }
override fun startActiveTunnelJobs() { override fun startActiveTunnelJobs() {
statsJob = startTunnelStatisticsJob() statsJob = startTunnelStatisticsJob()
tunnelChangesJob = startTunnelConfigChangesJob() tunnelChangesJob = startTunnelConfigChangesJob()
if (_vpnState.value.tunnelConfig?.isPingEnabled == true) pingJob = startPingJob()
} }
override fun getName(): String { override fun getName(): String {
@ -304,22 +308,92 @@ constructor(
} }
} }
private fun isQuickConfigChanged(config: TunnelConfig): Boolean {
return with(_vpnState.value) {
if (tunnelConfig == null) return false
config.wgQuick != tunnelConfig.wgQuick ||
config.amQuick != tunnelConfig.amQuick
}
}
private fun isPingConfigMatching(config: TunnelConfig): Boolean {
return with(_vpnState.value.tunnelConfig) {
if (this == null) return true
config.isPingEnabled == isPingEnabled &&
pingIp == config.pingIp &&
config.pingCooldown == pingCooldown &&
config.pingInterval == pingInterval
}
}
private fun handlePingConfigChanges() {
with(_vpnState.value.tunnelConfig) {
if (this == null) return
if (!isPingEnabled && pingJob?.isActive == true) {
pingJob?.cancelWithMessage("Ping job cancelled")
return
}
restartPingJob()
}
}
private fun restartPingJob() {
pingJob?.cancelWithMessage("Ping job cancelled")
pingJob = startPingJob()
}
private fun startTunnelConfigChangesJob() = applicationScope.launch(ioDispatcher) { private fun startTunnelConfigChangesJob() = applicationScope.launch(ioDispatcher) {
tunnelConfigRepository.getTunnelConfigsFlow().collect { tunnelConfigRepository.getTunnelConfigsFlow().collect { tunnels ->
with(_vpnState.value) { with(_vpnState.value) {
if (status.isDown() || tunnelConfig == null) return@collect if (tunnelConfig == null) return@collect
val vpnConfigFromStorage = it.first { it.id == tunnelConfig.id } val storageConfig = tunnels.firstOrNull { it.id == tunnelConfig.id }
val isRestartNeeded = vpnConfigFromStorage.wgQuick != tunnelConfig.wgQuick || if (storageConfig == null) return@collect
vpnConfigFromStorage.amQuick != tunnelConfig.amQuick val quickChanged = isQuickConfigChanged(storageConfig)
updateTunnelConfig(vpnConfigFromStorage) val pingMatching = isPingConfigMatching(storageConfig)
if (isRestartNeeded) { updateTunnelConfig(storageConfig)
Timber.d("Bouncing tunnel on config change") if (quickChanged) bounceTunnel()
bounceTunnel() if (!pingMatching) handlePingConfigChanges()
}
}
}
private suspend fun pingTunnel(tunnelConfig: TunnelConfig): List<Boolean> {
return withContext(ioDispatcher) {
val config = tunnelConfig.toWgConfig()
if (tunnelConfig.pingIp != null) {
Timber.i("Pinging custom ip")
listOf(InetAddress.getByName(tunnelConfig.pingIp).isReachable(Constants.PING_TIMEOUT.toInt()))
} else {
Timber.i("Pinging all peers")
config.peers.map { peer ->
peer.isReachable()
} }
} }
} }
} }
private fun startPingJob() = applicationScope.launch(ioDispatcher) {
do {
run {
with(_vpnState.value) {
// TODO ignore when no connectivity
if (status.isUp() && tunnelConfig != null) {
val reachable = pingTunnel(tunnelConfig)
if (reachable.contains(false)) {
Timber.i("Ping result: target was not reachable, bouncing the tunnel")
bounceTunnel()
delay(tunnelConfig.pingCooldown ?: Constants.PING_COOLDOWN)
return@run
} else {
Timber.i("Ping result: all ping targets were reached successfully")
}
}
delay(tunnelConfig?.pingInterval ?: Constants.PING_INTERVAL)
}
}
} while (true)
}
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

@ -381,15 +381,6 @@ constructor(
serviceManager.startAutoTunnel(true) serviceManager.startAutoTunnel(true)
} }
fun onTogglePrimaryTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch {
appDataRepository.tunnels.updatePrimaryTunnel(
when (tunnelConfig.isPrimaryTunnel) {
true -> null
false -> tunnelConfig
},
)
}
private suspend fun rebuildConfigs( private suspend fun rebuildConfigs(
amConfig: org.amnezia.awg.config.Config, amConfig: org.amnezia.awg.config.Config,
wgConfig: Config, wgConfig: Config,

View File

@ -5,6 +5,7 @@ import android.graphics.Color.TRANSPARENT
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
@ -35,6 +36,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
@ -69,6 +71,7 @@ import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import kotlin.system.exitProcess import kotlin.system.exitProcess
@ -228,7 +231,7 @@ class MainActivity : AppCompatActivity() {
composable<Route.TunnelOptions> { composable<Route.TunnelOptions> {
val args = it.toRoute<Route.TunnelOptions>() val args = it.toRoute<Route.TunnelOptions>()
val config = appUiState.tunnels.first { it.id == args.id } val config = appUiState.tunnels.first { it.id == args.id }
OptionsScreen(config, viewModel) OptionsScreen(config)
} }
composable<Route.Lock> { composable<Route.Lock> {
PinLockScreen(viewModel) PinLockScreen(viewModel)
@ -250,6 +253,13 @@ class MainActivity : AppCompatActivity() {
TunnelAutoTunnelScreen(config, appUiState.settings) TunnelAutoTunnelScreen(config, appUiState.settings)
} }
} }
BackHandler(enabled = true) {
lifecycleScope.launch {
if (!navController.popBackStack()) {
this@MainActivity.finish()
}
}
}
} }
} }
} }
@ -257,9 +267,4 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
override fun onDestroy() {
super.onDestroy()
// save battery by not polling stats while app is closed
tunnelService.cancelActiveTunnelJobs()
}
} }

View File

@ -19,7 +19,9 @@ fun TopNavBar(title: String, trailing: @Composable () -> Unit = {}, showBack: Bo
}, },
navigationIcon = { navigationIcon = {
if (showBack) { if (showBack) {
IconButton(onClick = { navController.popBackStack() }) { IconButton(onClick = {
navController.popBackStack()
}) {
val icon = Icons.AutoMirrored.Outlined.ArrowBack val icon = Icons.AutoMirrored.Outlined.ArrowBack
Icon( Icon(
imageVector = icon, imageVector = icon,

View File

@ -17,7 +17,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AirplanemodeActive import androidx.compose.material.icons.outlined.AirplanemodeActive
import androidx.compose.material.icons.outlined.Code import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.Filter1 import androidx.compose.material.icons.outlined.Filter1
import androidx.compose.material.icons.outlined.NetworkPing
import androidx.compose.material.icons.outlined.Security import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SettingsEthernet import androidx.compose.material.icons.outlined.SettingsEthernet
@ -316,24 +315,6 @@ fun AutoTunnelScreen(uiState: AppUiState, viewModel: AutoTunnelViewModel = hiltV
viewModel.onToggleTunnelOnEthernet() viewModel.onToggleTunnelOnEthernet()
}, },
), ),
SelectionItem(
Icons.Outlined.NetworkPing,
title = {
Text(
stringResource(R.string.restart_on_ping),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
checked = uiState.settings.isPingEnabled,
onClick = { viewModel.onToggleRestartOnPing() },
)
},
onClick = {
viewModel.onToggleRestartOnPing()
},
),
SelectionItem( SelectionItem(
Icons.Outlined.AirplanemodeActive, Icons.Outlined.AirplanemodeActive,
title = { title = {

View File

@ -119,16 +119,6 @@ constructor(
} }
} }
fun onToggleRestartOnPing() = viewModelScope.launch {
with(settings.value) {
appDataRepository.settings.save(
copy(
isPingEnabled = !isPingEnabled,
),
)
}
}
fun onToggleStopOnNoInternet() = viewModelScope.launch { fun onToggleStopOnNoInternet() = viewModelScope.launch {
with(settings.value) { with(settings.value) {
appDataRepository.settings.save( appDataRepository.settings.save(

View File

@ -6,11 +6,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.CallSplit import androidx.compose.material.icons.automirrored.outlined.CallSplit
import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.NetworkPing
import androidx.compose.material.icons.outlined.Star import androidx.compose.material.icons.outlined.Star
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -24,22 +26,30 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch 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.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.SubmitConfigurationTextBox
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.screens.settings.components.ForwardButton import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ForwardButton
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address
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 kotlin.text.isBlank
import kotlin.text.isNullOrBlank
import kotlin.text.toLong
@Composable @Composable
fun OptionsScreen(tunnelConfig: TunnelConfig, appViewModel: AppViewModel) { fun OptionsScreen(tunnelConfig: TunnelConfig, viewModel: TunnelOptionsViewModel = hiltViewModel()) {
val navController = LocalNavController.current val navController = LocalNavController.current
var currentText by remember { mutableStateOf("") } var currentText by remember { mutableStateOf("") }
@ -83,10 +93,10 @@ fun OptionsScreen(tunnelConfig: TunnelConfig, appViewModel: AppViewModel) {
trailing = { trailing = {
ScaledSwitch( ScaledSwitch(
tunnelConfig.isPrimaryTunnel, tunnelConfig.isPrimaryTunnel,
onClick = { appViewModel.onTogglePrimaryTunnel(tunnelConfig) }, onClick = { viewModel.onTogglePrimaryTunnel(tunnelConfig) },
) )
}, },
onClick = { appViewModel.onTogglePrimaryTunnel(tunnelConfig) }, onClick = { viewModel.onTogglePrimaryTunnel(tunnelConfig) },
), ),
SelectionItem( SelectionItem(
Icons.Outlined.Bolt, Icons.Outlined.Bolt,
@ -141,6 +151,81 @@ fun OptionsScreen(tunnelConfig: TunnelConfig, appViewModel: AppViewModel) {
), ),
), ),
) )
SurfaceSelectionGroupButton(
buildList {
add(
SelectionItem(
Icons.Outlined.NetworkPing,
title = {
Text(
stringResource(R.string.restart_on_ping),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConfig.isPingEnabled,
onClick = { viewModel.onToggleRestartOnPing(tunnelConfig) },
)
},
onClick = { viewModel.onToggleRestartOnPing(tunnelConfig) },
),
)
if (tunnelConfig.isPingEnabled) {
add(
SelectionItem(
title = {},
description = {
SubmitConfigurationTextBox(
tunnelConfig.pingIp,
stringResource(R.string.set_custom_ping_ip),
stringResource(R.string.default_ping_ip),
isErrorValue = { !it.isNullOrBlank() && !it.isValidIpv4orIpv6Address() },
onSubmit = {
viewModel.saveTunnelChanges(
tunnelConfig.copy(pingIp = it.ifBlank { null }),
)
},
)
fun isSecondsError(seconds: String?): Boolean {
return seconds?.let { value -> if (value.isBlank()) false else value.toLong() >= Long.MAX_VALUE / 1000 }
?: false
}
SubmitConfigurationTextBox(
tunnelConfig.pingInterval?.let { (it / 1000).toString() },
stringResource(R.string.set_custom_ping_internal),
"(${stringResource(R.string.optional_default)} ${Constants.PING_INTERVAL / 1000})",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
isErrorValue = ::isSecondsError,
onSubmit = {
viewModel.saveTunnelChanges(
tunnelConfig.copy(pingInterval = if (it.isBlank()) null else it.toLong() * 1000),
)
},
)
SubmitConfigurationTextBox(
tunnelConfig.pingCooldown?.let { (it / 1000).toString() },
stringResource(R.string.set_custom_ping_cooldown),
"(${stringResource(R.string.optional_default)} ${Constants.PING_COOLDOWN / 1000})",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
),
isErrorValue = ::isSecondsError,
onSubmit = {
viewModel.saveTunnelChanges(
tunnelConfig.copy(pingCooldown = if (it.isBlank()) null else it.toLong() * 1000),
)
},
)
},
),
)
}
},
)
} }
} }
} }

View File

@ -0,0 +1,37 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.tunneloptions
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TunnelOptionsViewModel
@Inject
constructor(
private val appDataRepository: AppDataRepository,
) : ViewModel() {
fun onToggleRestartOnPing(tunnelConfig: TunnelConfig) = viewModelScope.launch {
appDataRepository.tunnels.save(
tunnelConfig.copy(
isPingEnabled = !tunnelConfig.isPingEnabled,
),
)
}
fun onTogglePrimaryTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch {
appDataRepository.tunnels.updatePrimaryTunnel(
when (tunnelConfig.isPrimaryTunnel) {
true -> null
false -> tunnelConfig
},
)
}
fun saveTunnelChanges(tunnelConfig: TunnelConfig) = viewModelScope.launch {
appDataRepository.tunnels.save(tunnelConfig)
}
}

View File

@ -8,10 +8,8 @@ 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.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.NetworkPing
import androidx.compose.material.icons.outlined.PhoneAndroid import androidx.compose.material.icons.outlined.PhoneAndroid
import androidx.compose.material.icons.outlined.Security import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.SettingsEthernet import androidx.compose.material.icons.outlined.SettingsEthernet
@ -28,8 +26,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
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 com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@ -38,13 +34,10 @@ import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch 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.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.SubmitConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar import com.zaneschepke.wireguardautotunnel.ui.common.navigation.TopNavBar
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.TrustedNetworkTextBox import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.TrustedNetworkTextBox
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.WildcardsLabel import com.zaneschepke.wireguardautotunnel.ui.screens.settings.autotunnel.components.WildcardsLabel
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address
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
@ -119,76 +112,8 @@ fun TunnelAutoTunnelScreen(tunnelConfig: TunnelConfig, settings: Settings, tunne
}, },
onClick = { tunnelAutoTunnelViewModel.onToggleIsEthernetTunnel(tunnelConfig) }, onClick = { tunnelAutoTunnelViewModel.onToggleIsEthernetTunnel(tunnelConfig) },
), ),
SelectionItem(
Icons.Outlined.NetworkPing,
title = {
Text(
stringResource(R.string.restart_on_ping),
style = MaterialTheme.typography.bodyMedium.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ScaledSwitch(
checked = tunnelConfig.isPingEnabled,
onClick = { tunnelAutoTunnelViewModel.onToggleRestartOnPing(tunnelConfig) },
)
},
onClick = { tunnelAutoTunnelViewModel.onToggleRestartOnPing(tunnelConfig) },
),
), ),
) )
if (tunnelConfig.isPingEnabled || settings.isPingEnabled) {
add(
SelectionItem(
title = {},
description = {
SubmitConfigurationTextBox(
tunnelConfig.pingIp,
stringResource(R.string.set_custom_ping_ip),
stringResource(R.string.default_ping_ip),
isErrorValue = { !it.isNullOrBlank() && !it.isValidIpv4orIpv6Address() },
onSubmit = {
tunnelAutoTunnelViewModel.saveTunnelChanges(
tunnelConfig.copy(pingIp = it.ifBlank { null }),
)
},
)
fun isSecondsError(seconds: String?): Boolean {
return seconds?.let { value -> if (value.isBlank()) false else value.toLong() >= Long.MAX_VALUE / 1000 } ?: false
}
SubmitConfigurationTextBox(
tunnelConfig.pingInterval?.let { (it / 1000).toString() },
stringResource(R.string.set_custom_ping_internal),
"(${stringResource(R.string.optional_default)} ${Constants.PING_INTERVAL / 1000})",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
isErrorValue = ::isSecondsError,
onSubmit = {
tunnelAutoTunnelViewModel.saveTunnelChanges(
tunnelConfig.copy(pingInterval = if (it.isBlank()) null else it.toLong() * 1000),
)
},
)
SubmitConfigurationTextBox(
tunnelConfig.pingCooldown?.let { (it / 1000).toString() },
stringResource(R.string.set_custom_ping_cooldown),
"(${stringResource(R.string.optional_default)} ${Constants.PING_COOLDOWN / 1000})",
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
),
isErrorValue = ::isSecondsError,
onSubmit = {
tunnelAutoTunnelViewModel.saveTunnelChanges(
tunnelConfig.copy(pingCooldown = if (it.isBlank()) null else it.toLong() * 1000),
)
},
)
},
),
)
}
add( add(
SelectionItem( SelectionItem(
title = { title = {

View File

@ -61,14 +61,6 @@ constructor(
} }
} }
fun onToggleRestartOnPing(tunnelConfig: TunnelConfig) = viewModelScope.launch {
appDataRepository.tunnels.save(
tunnelConfig.copy(
isPingEnabled = !tunnelConfig.isPingEnabled,
),
)
}
fun onToggleIsEthernetTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch { fun onToggleIsEthernetTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch {
if (tunnelConfig.isEthernetTunnel) { if (tunnelConfig.isEthernetTunnel) {
appDataRepository.tunnels.updateEthernetTunnel(null) appDataRepository.tunnels.updateEthernetTunnel(null)

View File

@ -78,15 +78,9 @@ fun <T> CoroutineScope.asChannel(flow: Flow<T>): ReceiveChannel<T> = produce {
} }
} }
fun Job?.onNotRunning(callback: () -> Unit) {
if (this == null || this.isCompleted || this.isCompleted) {
callback.invoke()
}
}
fun Job.cancelWithMessage(message: String) { fun Job.cancelWithMessage(message: String) {
kotlin.runCatching { kotlin.runCatching {
this.cancel() cancel()
Timber.i(message) Timber.i(message)
} }
} }

View File

@ -46,11 +46,10 @@ fun Peer.isReachable(): Boolean {
} else { } else {
Constants.DEFAULT_PING_IP Constants.DEFAULT_PING_IP
} }
Timber.i("Checking reachability of peer: $host") Timber.d("Checking reachability of peer: $host")
val reachable = val reachable =
InetAddress.getByName(host) InetAddress.getByName(host)
.isReachable(Constants.PING_TIMEOUT.toInt()) .isReachable(Constants.PING_TIMEOUT.toInt())
Timber.i("Result: reachable - $reachable")
return reachable return reachable
} }