fix: make ping aware of network availability

add basic tunnel error messages
This commit is contained in:
Zane Schepke 2025-01-02 16:47:23 -05:00
parent 53c09267df
commit b1dc6c5d59
19 changed files with 165 additions and 83 deletions

View File

@ -164,7 +164,7 @@
tools:node="merge" />
<service
android:name=".service.foreground.TunnelBackgroundService"
android:name=".service.foreground.TunnelForegroundService"
android:exported="false"
android:persistent="true"
android:foregroundServiceType="systemExempted"

View File

@ -3,6 +3,9 @@ package com.zaneschepke.wireguardautotunnel
import android.app.Application
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.zaneschepke.logcatter.LogReader
import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
@ -52,6 +55,7 @@ class WireGuardAutoTunnel : Application() {
override fun onCreate() {
super.onCreate()
instance = this
ProcessLifecycleOwner.get().lifecycle.addObserver(AppLifecycleObserver())
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
StrictMode.setThreadPolicy(
@ -88,7 +92,25 @@ class WireGuardAutoTunnel : Application() {
super.onTerminate()
}
class AppLifecycleObserver : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
Timber.d("Application entered foreground")
foreground = true
}
override fun onPause(owner: LifecycleOwner) {
Timber.d("Application entered background")
foreground = false
}
}
companion object {
private var foreground = false
fun isForeground(): Boolean {
return foreground
}
lateinit var instance: WireGuardAutoTunnel
private set
}

View File

@ -7,25 +7,21 @@ import com.zaneschepke.wireguardautotunnel.service.network.WifiService
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ServiceComponent
import dagger.hilt.android.scopes.ServiceScoped
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(ServiceComponent::class)
@InstallIn(SingletonComponent::class)
abstract class ServiceModule {
@Binds
@Wifi
@ServiceScoped
abstract fun provideWifiService(wifiService: WifiService): NetworkService
@Binds
@MobileData
@ServiceScoped
abstract fun provideMobileDataService(mobileDataService: MobileDataService): NetworkService
@Binds
@Ethernet
@ServiceScoped
abstract fun provideEthernetService(ethernetService: EthernetService): NetworkService
}

View File

@ -10,6 +10,7 @@ import com.wireguard.android.util.ToolsInstaller
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.network.NetworkService
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel
@ -78,6 +79,9 @@ class TunnelModule {
@IoDispatcher ioDispatcher: CoroutineDispatcher,
serviceManager: ServiceManager,
notificationService: NotificationService,
@Wifi wifiService: NetworkService,
@MobileData mobileDataService: NetworkService,
@Ethernet ethernetService: NetworkService,
): TunnelService {
return WireGuardTunnel(
amneziaBackend,
@ -88,6 +92,9 @@ class TunnelModule {
ioDispatcher,
serviceManager,
notificationService,
wifiService,
mobileDataService,
ethernetService,
)
}

View File

@ -42,7 +42,7 @@ class AppUpdateReceiver : BroadcastReceiver() {
}
if (!settings.isAutoTunnelEnabled) {
val tunnels = appDataRepository.tunnels.getAll().filter { it.isActive }
if (tunnels.isNotEmpty()) tunnelService.get().startTunnel(tunnels.first(), true)
if (tunnels.isNotEmpty()) tunnelService.get().startTunnel(tunnels.first())
}
}
}

View File

@ -41,7 +41,7 @@ class BootReceiver : BroadcastReceiver() {
val tunState = tunnelService.get().vpnState.value.status
if (activeTunnels.isNotEmpty() && tunState != TunnelState.UP) {
Timber.i("Starting previously active tunnel")
tunnelService.get().startTunnel(activeTunnels.first(), true)
tunnelService.get().startTunnel(activeTunnels.first())
}
if (isAutoTunnelEnabled) {
Timber.i("Starting watcher service from boot")

View File

@ -29,7 +29,7 @@ class ServiceManager
val autoTunnelActive = _autoTunnelActive.asStateFlow()
var autoTunnelService = CompletableDeferred<AutoTunnelService>()
var backgroundService = CompletableDeferred<TunnelBackgroundService>()
var backgroundService = CompletableDeferred<TunnelForegroundService>()
var autoTunnelTile = CompletableDeferred<AutoTunnelControlTile>()
var tunnelControlTile = CompletableDeferred<TunnelControlTile>()
@ -59,10 +59,10 @@ class ServiceManager
}
}
suspend fun startBackgroundService(tunnelConfig: TunnelConfig?) {
suspend fun startBackgroundService(tunnelConfig: TunnelConfig) {
if (backgroundService.isCompleted) return
kotlin.runCatching {
startService(TunnelBackgroundService::class.java, true)
startService(TunnelForegroundService::class.java, true)
backgroundService.await()
backgroundService.getCompleted().start(tunnelConfig)
}.onFailure {

View File

@ -16,7 +16,7 @@ import kotlinx.coroutines.CompletableDeferred
import javax.inject.Inject
@AndroidEntryPoint
class TunnelBackgroundService : LifecycleService() {
class TunnelForegroundService : LifecycleService() {
@Inject
lateinit var notificationService: NotificationService
@ -39,9 +39,9 @@ class TunnelBackgroundService : LifecycleService() {
return super.onStartCommand(intent, flags, startId)
}
fun start(tunnelConfig: TunnelConfig?) {
fun start(tunnelConfig: TunnelConfig) {
ServiceCompat.startForeground(
this,
this@TunnelForegroundService,
NotificationService.KERNEL_SERVICE_NOTIFICATION_ID,
createNotification(tunnelConfig),
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,

View File

@ -5,4 +5,8 @@ data class NetworkState(
val isMobileDataConnected: Boolean = false,
val isEthernetConnected: Boolean = false,
val wifiName: String? = null,
)
) {
fun hasNoCapabilities(): Boolean {
return !isWifiConnected && !isMobileDataConnected && !isEthernetConnected
}
}

View File

@ -45,7 +45,7 @@ class ShortcutsActivity : ComponentActivity() {
Timber.d("Shortcut action on name: ${tunnelConfig?.name}")
tunnelConfig?.let {
when (intent.action) {
Action.START.name -> tunnelService.get().startTunnel(it, true)
Action.START.name -> tunnelService.get().startTunnel(it)
Action.STOP.name -> tunnelService.get().stopTunnel()
else -> Unit
}

View File

@ -66,7 +66,7 @@ class TunnelControlTile : TileService() {
applicationScope.launch {
if (tunnelService.vpnState.value.status.isUp()) return@launch tunnelService.stopTunnel()
appDataRepository.getStartTunnelConfig()?.let {
tunnelService.startTunnel(it, true)
tunnelService.startTunnel(it)
}
}
}

View File

@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.StateFlow
interface TunnelService : Tunnel, org.amnezia.awg.backend.Tunnel {
suspend fun startTunnel(tunnelConfig: TunnelConfig?, background: Boolean = false)
suspend fun startTunnel(tunnelConfig: TunnelConfig?)
suspend fun stopTunnel()

View File

@ -2,33 +2,44 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.Tunnel.State
import com.zaneschepke.wireguardautotunnel.R
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.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.module.Ethernet
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.module.Kernel
import com.zaneschepke.wireguardautotunnel.module.MobileData
import com.zaneschepke.wireguardautotunnel.module.Wifi
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationAction
import com.zaneschepke.wireguardautotunnel.service.foreground.autotunnel.model.NetworkState
import com.zaneschepke.wireguardautotunnel.service.network.NetworkService
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService.Companion.VPN_NOTIFICATION_ID
import com.zaneschepke.wireguardautotunnel.service.notification.WireGuardNotification
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.AmneziaStatistics
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.WireGuardStatistics
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.service.notification.WireGuardNotification
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.asAmBackendState
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.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@ -37,6 +48,7 @@ import kotlinx.coroutines.withContext
import org.amnezia.awg.backend.Tunnel
import timber.log.Timber
import java.net.InetAddress
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Provider
@ -51,6 +63,9 @@ constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val serviceManager: ServiceManager,
private val notificationService: NotificationService,
@Wifi private val wifiService: NetworkService,
@MobileData private val mobileDataService: NetworkService,
@Ethernet private val ethernetService: NetworkService,
) : TunnelService {
private val _vpnState = MutableStateFlow(VpnState())
@ -59,9 +74,11 @@ constructor(
private var statsJob: Job? = null
private var tunnelChangesJob: Job? = null
private var pingJob: Job? = null
private var networkJob: Job? = null
@get:Synchronized @set:Synchronized
private var isKernelBackend: Boolean? = null
private val isNetworkAvailable = AtomicBoolean(false)
private val tunnelControlMutex = Mutex()
@ -88,6 +105,23 @@ constructor(
}
}
// TODO refactor duplicate
@OptIn(FlowPreview::class)
private fun combineNetworkEventsJob(): Flow<NetworkState> {
return combine(
wifiService.status,
mobileDataService.status,
ethernetService.status,
) { wifi, mobileData, ethernet ->
NetworkState(
wifi.available,
mobileData.available,
ethernet.available,
wifi.name,
)
}.distinctUntilChanged()
}
private suspend fun setState(tunnelConfig: TunnelConfig, tunnelState: TunnelState): Result<TunnelState> {
return runCatching {
when (val backend = backend()) {
@ -113,20 +147,43 @@ constructor(
}
}
override suspend fun startTunnel(tunnelConfig: TunnelConfig?, background: Boolean) {
override suspend fun startTunnel(tunnelConfig: TunnelConfig?) {
withContext(ioDispatcher) {
if (tunnelConfig == null || isTunnelAlreadyRunning(tunnelConfig)) return@withContext
onBeforeStart(background)
onBeforeStart(tunnelConfig)
updateTunnelConfig(tunnelConfig) // need to update this here
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = true))
withServiceActive {
setState(tunnelConfig, TunnelState.UP).onSuccess {
updateTunnelState(it, tunnelConfig)
onTunnelStart(tunnelConfig, background)
startActiveTunnelJobs()
}.onFailure {
onTunnelStop(tunnelConfig)
// TODO improve this with better statuses and handling
showTunnelStartFailed()
}
}
}
}
private fun showTunnelStartFailed() {
if (WireGuardAutoTunnel.isForeground()) {
SnackbarController.showMessage(StringValue.StringResource(R.string.error_tunnel_start))
} else {
launchStartFailedNotification()
}
}
private fun launchStartFailedNotification() {
with(notificationService) {
val notification = createNotification(
WireGuardNotification.NotificationChannels.VPN,
title = context.getString(R.string.error_tunnel_start),
)
show(VPN_NOTIFICATION_ID, notification)
}
}
override suspend fun stopTunnel() {
withContext(ioDispatcher) {
if (_vpnState.value.status.isDown()) return@withContext
@ -200,31 +257,10 @@ constructor(
}
}
private suspend fun onBeforeStart(background: Boolean) {
private suspend fun onBeforeStart(tunnelConfig: TunnelConfig) {
with(_vpnState.value) {
if (status.isUp()) stopTunnel() else clearJobsAndStats()
if (isKernelBackend == true || background) serviceManager.startBackgroundService(tunnelConfig)
}
}
private suspend fun onTunnelStart(tunnelConfig: TunnelConfig, background: Boolean) {
startActiveTunnelJobs()
if (_vpnState.value.status.isUp()) {
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = true))
}
if (isKernelBackend == false && !background) launchUserspaceTunnelNotification()
}
private fun launchUserspaceTunnelNotification() {
with(notificationService) {
val notification = createNotification(
WireGuardNotification.NotificationChannels.VPN,
title = "${context.getString(R.string.tunnel_running)} - ${_vpnState.value.tunnelConfig?.name}",
actions = listOf(
notificationService.createNotificationAction(NotificationAction.TUNNEL_OFF),
),
)
show(VPN_NOTIFICATION_ID, notification)
serviceManager.startBackgroundService(tunnelConfig)
}
}
@ -278,14 +314,21 @@ constructor(
statsJob?.cancelWithMessage("Tunnel stats job cancelled")
tunnelChangesJob?.cancelWithMessage("Tunnel changes job cancelled")
pingJob?.cancelWithMessage("Ping job cancelled")
networkJob?.cancelWithMessage("Network job cancelled")
}
override fun startActiveTunnelJobs() {
statsJob = startTunnelStatisticsJob()
tunnelChangesJob = startTunnelConfigChangesJob()
if (_vpnState.value.tunnelConfig?.isPingEnabled == true) pingJob = startPingJob()
if (_vpnState.value.tunnelConfig?.isPingEnabled == true) {
startPingJobs()
}
}
private fun startPingJobs() {
pingJob = startPingJob()
networkJob = startNetworkJob()
}
override fun getName(): String {
return _vpnState.value.tunnelConfig?.name ?: ""
}
@ -331,6 +374,7 @@ constructor(
if (this == null) return
if (!isPingEnabled && pingJob?.isActive == true) {
pingJob?.cancelWithMessage("Ping job cancelled")
networkJob?.cancelWithMessage("Network job cancelled")
return
}
restartPingJob()
@ -339,7 +383,8 @@ constructor(
private fun restartPingJob() {
pingJob?.cancelWithMessage("Ping job cancelled")
pingJob = startPingJob()
networkJob?.cancelWithMessage("Network job cancelled")
startPingJobs()
}
private fun startTunnelConfigChangesJob() = applicationScope.launch(ioDispatcher) {
@ -376,13 +421,16 @@ constructor(
do {
run {
with(_vpnState.value) {
// TODO ignore when no connectivity
if (status.isUp() && tunnelConfig != null) {
if (status.isUp() && tunnelConfig != null && isNetworkAvailable.get()) {
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)
if (isNetworkAvailable.get()) {
Timber.i("Ping result: target was not reachable, bouncing the tunnel")
bounceTunnel()
delay(tunnelConfig.pingCooldown ?: Constants.PING_COOLDOWN)
} else {
Timber.i("Ping result: target was not reachable, but not network available")
}
return@run
} else {
Timber.i("Ping result: all ping targets were reached successfully")
@ -394,6 +442,17 @@ constructor(
} while (true)
}
private fun startNetworkJob() = applicationScope.launch(ioDispatcher) {
combineNetworkEventsJob().collect {
Timber.d("New network state: $it")
if (!it.isWifiConnected && !it.isEthernetConnected && !it.isMobileDataConnected) {
isNetworkAvailable.set(false)
} else {
isNetworkAvailable.set(true)
}
}
}
override fun onStateChange(newState: Tunnel.State) {
_vpnState.update {
it.copy(status = TunnelState.from(newState))

View File

@ -112,12 +112,14 @@ constructor(
}
private suspend fun initTunnel() {
if (tunnelService.get().getState() == TunnelState.UP) tunnelService.get().startActiveTunnelJobs()
val activeTunnels = appDataRepository.tunnels.getActive()
if (activeTunnels.isNotEmpty() &&
tunnelService.get().getState() == TunnelState.DOWN
) {
tunnelService.get().startTunnel(activeTunnels.first())
withContext(ioDispatcher) {
if (tunnelService.get().getState() == TunnelState.UP) tunnelService.get().startActiveTunnelJobs()
val activeTunnels = appDataRepository.tunnels.getActive()
if (activeTunnels.isNotEmpty() &&
tunnelService.get().getState() == TunnelState.DOWN
) {
tunnelService.get().startTunnel(activeTunnels.first())
}
}
}

View File

@ -13,7 +13,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
@ -142,7 +141,6 @@ class MainActivity : AppCompatActivity() {
SnackbarControllerProvider { host ->
WireguardAutoTunnelTheme(theme = appUiState.generalState.theme) {
Scaffold(
modifier = Modifier.background(color = MaterialTheme.colorScheme.background),
contentWindowInsets = WindowInsets(0),
snackbarHost = {
SnackbarHost(host) { snackbarData: SnackbarData ->

View File

@ -82,7 +82,7 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
val startAutoTunnel = withVpnPermission<Unit> { viewModel.onToggleAutoTunnel() }
val startTunnel = withVpnPermission<TunnelConfig> {
viewModel.onTunnelStart(it, uiState.settings.isKernelEnabled)
viewModel.onTunnelStart(it)
}
val autoTunnelToggleBattery = withIgnoreBatteryOpt(uiState.generalState.isBatteryOptimizationDisableShown) {
if (!uiState.generalState.isBatteryOptimizationDisableShown) viewModel.setBatteryOptimizeDisableShown()
@ -129,7 +129,7 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) {
if (!checked) viewModel.onTunnelStop().also { return }
if (uiState.settings.isKernelEnabled) {
viewModel.onTunnelStart(tunnel, uiState.settings.isKernelEnabled)
viewModel.onTunnelStart(tunnel)
} else {
startTunnel.invoke(tunnel)
}
@ -226,8 +226,9 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState)
) { tunnel ->
val expanded = uiState.generalState.isTunnelStatsExpanded
TunnelRowItem(
tunnel.id == uiState.vpnState.tunnelConfig?.id &&
uiState.vpnState.status.isUp(),
tunnel.id == uiState.vpnState.tunnelConfig?.id && (
uiState.vpnState.status.isUp() || (uiState.settings.isKernelEnabled && tunnel.isActive)
),
expanded,
selectedTunnel?.id == tunnel.id,
tunnel,

View File

@ -67,9 +67,9 @@ constructor(
appDataRepository.appState.setTunnelStatsExpanded(expanded)
}
fun onTunnelStart(tunnelConfig: TunnelConfig, background: Boolean) = viewModelScope.launch {
fun onTunnelStart(tunnelConfig: TunnelConfig) = viewModelScope.launch {
Timber.i("Starting tunnel ${tunnelConfig.name}")
tunnelService.get().startTunnel(tunnelConfig, background)
tunnelService.get().startTunnel(tunnelConfig)
}
fun onTunnelStop() = viewModelScope.launch {
@ -86,20 +86,6 @@ constructor(
}
}
private fun generateQrCodeTunnelName(config: String): String {
var defaultName = generateQrCodeDefaultName(config)
val lines = config.lines().toMutableList()
val linesIterator = lines.iterator()
while (linesIterator.hasNext()) {
val next = linesIterator.next()
if (next.contains(Constants.QR_CODE_NAME_PROPERTY)) {
defaultName = next.substringAfter(Constants.QR_CODE_NAME_PROPERTY).trim()
break
}
}
return defaultName
}
private suspend fun makeTunnelNameUnique(name: String): String {
return withContext(ioDispatcher) {
val tunnels = appDataRepository.tunnels.getAll()

View File

@ -202,4 +202,5 @@
<string name="remove_amnezia_compatibility">Remove Amnezia compatibility</string>
<string name="exclude_lan">Exclude LAN</string>
<string name="include_lan">Include LAN</string>
<string name="error_tunnel_start">Failed to starting tunnel</string>
</resources>

View File

@ -0,0 +1,6 @@
What's new:
- Ping feature now works independent of auto tunnel
- Added convenience action for Amnezia compatibility
- Added convenience action for excluding LAN from tunnel
- Added debounce delay tuning option for auto tunnel
- Many bug fixes and improvements