diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/ServiceManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/ServiceManager.kt index 9a00504b..9852dc47 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/ServiceManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/ServiceManager.kt @@ -73,7 +73,7 @@ class ServiceManager @Inject constructor( } } - fun startBackgroundService(tunnelConf: TunnelConf) { + fun startTunnelForegroundService(tunnelConf: TunnelConf) { applicationScope.launch(ioDispatcher) { if (backgroundService.isCompleted) return@launch runCatching { @@ -88,7 +88,19 @@ class ServiceManager @Inject constructor( } } - fun stopBackgroundService() { + fun updateTunnelForegroundServiceNotification(tunnelConf: TunnelConf) { + applicationScope.launch(ioDispatcher) { + if (!backgroundService.isCompleted) return@launch + runCatching { + val service = backgroundService.await() + service.start(tunnelConf) + }.onFailure { + Timber.e(it) + } + } + } + + fun stopTunnelForegroundService() { applicationScope.launch(ioDispatcher) { if (!backgroundService.isCompleted) return@launch runCatching { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelForegroundService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelForegroundService.kt index 4339d18c..7bdcf21f 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelForegroundService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/service/TunnelForegroundService.kt @@ -49,12 +49,12 @@ class TunnelForegroundService : LifecycleService() { } fun stop() { - stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } override fun onDestroy() { serviceManager.backgroundService = CompletableDeferred() + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) super.onDestroy() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/BaseTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/BaseTunnel.kt index 99c76240..72ca7e9d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/BaseTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/BaseTunnel.kt @@ -1,5 +1,6 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel +import com.wireguard.android.backend.BackendException import com.wireguard.android.backend.Tunnel import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.networkmonitor.NetworkStatus @@ -8,12 +9,10 @@ import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager -import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider.Companion.CHECK_INTERVAL import com.zaneschepke.wireguardautotunnel.di.ApplicationScope import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.enums.BackendError -import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState @@ -22,24 +21,24 @@ import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.StringValue import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState -import com.zaneschepke.wireguardautotunnel.util.extensions.cancelWithMessage +import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import timber.log.Timber import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicBoolean -open class BaseTunnel( +abstract class BaseTunnel( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, @ApplicationScope private val applicationScope: CoroutineScope, private val networkMonitor: NetworkMonitor, @@ -48,167 +47,98 @@ open class BaseTunnel( private val notificationManager: NotificationManager, ) : TunnelProvider { + companion object { + const val CHECK_INTERVAL = 1000L + } + internal val tunnels = MutableStateFlow>(emptyList()) + private val _activeTunnels = MutableStateFlow>(emptyMap()) + override val activeTunnels = _activeTunnels.asStateFlow() - private val _tunnelStates = MutableStateFlow>(emptyMap()) - - private val tunnelJobs = ConcurrentHashMap() - - private val isNetworkAvailable = AtomicBoolean(false) + protected val tunnelJobs = ConcurrentHashMap>() + private val mutex = Mutex() + private val isNetworkConnected = MutableStateFlow(true) init { applicationScope.launch(ioDispatcher) { - launch { startNetworkJob() } + launch { monitorNetworkStatus() } launch { monitorTunnelConfigChanges() } - tunnels.collect { tuns -> - val previousTunIds = tunnelJobs.keys.toSet() - val currentTunIds = tuns.map { it.id }.toSet() - val newTuns = tuns.filter { it.id !in previousTunIds } - val removedTunIds = previousTunIds - currentTunIds + launch { monitorTunnels() } + } + } - newTuns.forEach { tun -> - Timber.d("Starting tunnel jobs for tun ${tun.name} (ID: ${tun.id})") - tunnelJobs[tun.id] = startTunnelJobs(tun) + private suspend fun monitorTunnels() { + tunnels.collectLatest { newTunnels -> + mutex.withLock { + val previousIds = tunnelJobs.keys + val currentIds = newTunnels.map { it.id }.toSet() + val added = newTunnels.filter { it.id !in previousIds && it.isActive } + val removed = previousIds - currentIds + + added.forEach { tunnel -> + Timber.d("Starting jobs for tunnel ${tunnel.id}: ${tunnel.tunName}") + if (tunnelJobs[tunnel.id] == null) { + tunnelJobs[tunnel.id] = mutableListOf(startTunnelJobs(tunnel)) + } } - removedTunIds.forEach { tunId -> - tunnelJobs[tunId]?.cancelWithMessage("Canceling tunnel jobs for tunnel ID: $tunId") - tunnelJobs.remove(tunId) - _tunnelStates.update { it - tunId } + removed.forEach { id -> + Timber.d("Stopping jobs for tunnel $id") + tunnelJobs[id]?.forEach { it.cancelAndJoin() } + tunnelJobs.remove(id) + _activeTunnels.update { it - id } serviceManager.updateTunnelTile() } } } } - private fun startTunnelJobs(tunnel: TunnelConf) = applicationScope.launch(ioDispatcher) { - launch { startTunnelStatisticsJob(tunnel) } - if (tunnel.isPingEnabled) launch { startPingJob(tunnel) } - } - - private fun updateTunnelState(tunnelId: Int, newState: TunnelStatus) { - Timber.d("Updating tunnel state for ID $tunnelId to $newState") - _tunnelStates.update { current -> - val currentState = current[tunnelId] - val updatedState = currentState?.copy(state = newState) ?: TunnelState(state = newState) - val newMap = current + (tunnelId to updatedState) - Timber.d("New tunnel states: $newMap") - newMap + protected fun startTunnelJobs(tunnel: TunnelConf): Job { + return applicationScope.launch(ioDispatcher) { + val jobs = mutableListOf() + jobs += launch { updateTunnelStatistics(tunnel) } + if (tunnel.isPingEnabled) jobs += launch { monitorTunnelPing(tunnel) } + jobs.forEach { it.join() } } } - internal fun beforeStartTunnel(tunnelConf: TunnelConf) { - tunnelConf.setStateChangeCallback { state -> - Timber.d("New tunnel state $state") - when (state) { - is Tunnel.State -> updateTunnelState(tunnelConf.id, state.asTunnelState()) - is org.amnezia.awg.backend.Tunnel.State -> updateTunnelState(tunnelConf.id, state.asTunnelState()) - } - applicationScope.launch(ioDispatcher) { - serviceManager.updateTunnelTile() - } - } - } - - override fun startTunnel(tunnelConf: TunnelConf) { - applicationScope.launch(ioDispatcher) { - serviceManager.startBackgroundService(tunnelConf) - appDataRepository.tunnels.save(tunnelConf.copy(isActive = true)) - addToActiveTunnels(tunnelConf) - } - } - - override fun stopTunnel(tunnelConf: TunnelConf?) { - // Default empty implementation; subclasses override - } - - override suspend fun bounceTunnel(tunnelConf: TunnelConf) { - stopTunnel(tunnelConf) - delay(1000) - startTunnel(tunnelConf) - } - - override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection) { - // Default empty implementation - } - - override suspend fun runningTunnelNames(): Set { - return emptySet() - } - - override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics { - throw NotImplementedError("Get statistics not implemented in base class") - } - - override val activeTunnels: StateFlow> - get() = _tunnelStates.asStateFlow() - - internal suspend fun onTunnelStop(tunnelConf: TunnelConf) { - appDataRepository.tunnels.save(tunnelConf.copy(isActive = false)) - removeFromActiveTunnels(tunnelConf) - if (tunnels.value.isEmpty()) serviceManager.stopBackgroundService() - } - - internal fun stopAllTunnels() { - tunnels.value.forEach { - stopTunnel(it) - } - } - - private fun addToActiveTunnels(conf: TunnelConf) { - tunnels.update { - it.toMutableList().apply { - add(conf) - } - } - } - - private fun removeFromActiveTunnels(conf: TunnelConf) { - tunnels.update { - it.toMutableList().apply { - remove(conf) - } - } - } - - private suspend fun startNetworkJob() = coroutineScope { - networkMonitor.getNetworkStatusFlow(includeWifiSsid = false, useRootShell = false) - .flowOn(ioDispatcher).collect { - isNetworkAvailable.set(it !is NetworkStatus.Disconnected) - } - } - - private suspend fun startPingJob(tunnel: TunnelConf) = coroutineScope { - while (isActive) { + private suspend fun updateTunnelStatistics(tunnel: TunnelConf) { + while (true) { runCatching { - if (isNetworkAvailable.get() && tunnel.isActive) { + val stats = getStatistics(tunnel) + updateTunnelState(tunnel.id, stats = stats) + }.onFailure { e -> + Timber.e(e, "Failed to update stats for ${tunnel.tunName}") + } + delay(CHECK_INTERVAL) + } + } + + private suspend fun monitorTunnelPing(tunnel: TunnelConf) { + while (true) { + runCatching { + if (isNetworkConnected.value && tunnel.isActive) { val pingSuccess = tunnel.isTunnelPingable(ioDispatcher) - handlePingResult(tunnel, pingSuccess) + if (!pingSuccess) bounceTunnel(tunnel) } - delay(tunnel.pingInterval ?: Constants.PING_INTERVAL) + }.onFailure { e -> + Timber.e(e, "Ping failed for ${tunnel.tunName}") } + delay(tunnel.pingInterval ?: Constants.PING_INTERVAL) } } - private suspend fun handlePingResult(tunnel: TunnelConf, pingSuccess: Boolean) { - if (!pingSuccess) { - if (isNetworkAvailable.get()) { - Timber.i("Ping result: target was not reachable, bouncing the tunnel") - bounceTunnel(tunnel) - delay(tunnel.pingCooldown ?: Constants.PING_COOLDOWN) - } else { - Timber.i("Ping result: target was not reachable, but no network available") - } - } else { - Timber.i("Ping result: all ping targets were reached successfully") + protected fun handleBackendThrowable(throwable: Throwable) { + val backendError = when (throwable) { + is BackendException -> throwable.toBackendError() + is org.amnezia.awg.backend.BackendException -> throwable.toBackendError() + else -> BackendError.Unknown } - } - - internal fun handleBackendThrowable(backendError: BackendError) { val message = when (backendError) { BackendError.Config -> StringValue.StringResource(R.string.start_failed_config) BackendError.DNS -> StringValue.StringResource(R.string.dns_error) BackendError.Unauthorized -> StringValue.StringResource(R.string.unauthorized) + BackendError.Unknown -> StringValue.StringResource(R.string.unknown_error) } if (WireGuardAutoTunnel.isForeground()) { SnackbarController.showMessage(message) @@ -224,33 +154,129 @@ open class BaseTunnel( } } - private suspend fun monitorTunnelConfigChanges() = coroutineScope { - appDataRepository.tunnels.flow.collect { storageTuns -> - storageTuns.forEach { storageTun -> - val currentTun = tunnels.value.firstOrNull { it.id == storageTun.id } - if (currentTun != null) { - if (!currentTun.isQuickConfigMatching(storageTun)) { - Timber.d("Tunnel config changed for ID $storageTun, bouncing tunnel") - bounceTunnel(storageTun) + protected fun updateTunnelState(tunnelId: Int, state: TunnelStatus? = null, stats: TunnelStatistics? = null) { + applicationScope.launch(ioDispatcher) { + mutex.withLock { + _activeTunnels.update { current -> + val existing = current[tunnelId] ?: TunnelState() + val newState = state ?: existing.state + if (existing.state == newState && stats == null) { + Timber.d("Skipping redundant state update for $tunnelId: $newState") + current + } else { + val updated = existing.copy( + state = newState, + statistics = stats ?: existing.statistics, + ) + current + (tunnelId to updated) } } } } } - private suspend fun startTunnelStatisticsJob(tunnel: TunnelConf) = coroutineScope { - while (this.isActive) { - runCatching { - val stats = getStatistics(tunnel) - _tunnelStates.update { currentStates -> - val updatedState = currentStates[tunnel.id]?.copy(statistics = stats) - ?: TunnelState(statistics = stats) - currentStates + (tunnel.id to updatedState) + protected suspend fun configureTunnel(tunnelConf: TunnelConf) { + tunnelConf.setStateChangeCallback { state -> + Timber.d("State change callback triggered for tunnel ${tunnelConf.id}: ${tunnelConf.tunName} with state $state at ${System.currentTimeMillis()}") + when (state) { + is Tunnel.State -> updateTunnelState(tunnelConf.id, state.asTunnelState()) + is org.amnezia.awg.backend.Tunnel.State -> updateTunnelState(tunnelConf.id, state.asTunnelState()) + } + applicationScope.launch(ioDispatcher) { serviceManager.updateTunnelTile() } + } + } + + override fun startTunnel(tunnelConf: TunnelConf) { + applicationScope.launch(ioDispatcher) { + mutex.withLock { + val currentState = _activeTunnels.value[tunnelConf.id]?.state + if (currentState == TunnelStatus.UP || currentState == TunnelStatus.STARTING) { + Timber.w("Tunnel ${tunnelConf.id} is already $currentState, skipping start (possible duplicate call)") + return@launch + } + + val existingNames = tunnels.value.map { it.tunName }.toSet() + if (tunnelConf.tunName in existingNames && tunnels.value.any { it.id != tunnelConf.id && it.tunName == tunnelConf.tunName }) { + Timber.w("Duplicate tunName ${tunnelConf.tunName} detected for tunnel ${tunnelConf.id}") + } + + val lockedConf = tunnelConf.copyWithCallback(isActive = true) + Timber.d("Starting tunnel with TunnelConf: $lockedConf") + if (tunnels.value.isEmpty()) { + Timber.d("No active tunnels, starting background service for ${lockedConf.id}") + serviceManager.startTunnelForegroundService(lockedConf) + } else { + Timber.d("Tunnels already active, updating service notification for ${lockedConf.id}") + serviceManager.updateTunnelForegroundServiceNotification(lockedConf) + } + appDataRepository.tunnels.save(lockedConf) + tunnels.update { current -> + current.filter { it.id != lockedConf.id } + lockedConf } - delay(CHECK_INTERVAL) - }.onFailure { exception -> - Timber.e(exception, "Failed to update tunnel statistics for ${tunnel.tunName}") } } } + + override fun stopTunnel(tunnelConf: TunnelConf?) { + tunnelConf?.let { + applicationScope.launch(ioDispatcher) { + mutex.withLock { + val lockedConf = it.copyWithCallback(isActive = false) + Timber.d("Stopping tunnel with TunnelConf: $lockedConf") + tunnels.update { tunnels -> tunnels.filter { t -> t.id != lockedConf.id } } + appDataRepository.tunnels.save(lockedConf) + if (tunnels.value.isEmpty()) { + Timber.d("No tunnels active, stopping background service") + serviceManager.stopTunnelForegroundService() + } else { + Timber.d("Other tunnels still active, updating service notification") + val nextActive = tunnels.value.firstOrNull { it.isActive } + if (nextActive != null) { + Timber.d("Next active tunnel: ${nextActive.id}") + serviceManager.updateTunnelForegroundServiceNotification(nextActive) + } else { + Timber.w("No active tunnels found in _tunnels, forcing service stop") + serviceManager.stopTunnelForegroundService() + } + } + } + } + } + } + + override suspend fun bounceTunnel(tunnelConf: TunnelConf) { + stopTunnel(tunnelConf) + delay(1000) + startTunnel(tunnelConf) + } + + private suspend fun monitorNetworkStatus() { + networkMonitor.getNetworkStatusFlow(includeWifiSsid = false, useRootShell = false) + .flowOn(ioDispatcher) + .collectLatest { status -> + val isAvailable = status !is NetworkStatus.Disconnected + isNetworkConnected.value = isAvailable + Timber.d("Network status: $isAvailable") + } + } + + private suspend fun monitorTunnelConfigChanges() { + appDataRepository.tunnels.flow.collectLatest { storedTunnels -> + mutex.withLock { + storedTunnels.forEach { stored -> + val current = tunnels.value.find { it.id == stored.id } + if (current != null && !current.isQuickConfigMatching(stored)) { + Timber.d("Config changed for ${stored.id}, bouncing") + bounceTunnel(stored) + } + } + } + } + } + + override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? { + throw NotImplementedError("Must be implemented by subclass") + } + + override suspend fun runningTunnelNames(): Set = tunnels.value.map { it.tunName }.toSet() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/KernelTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/KernelTunnel.kt index ab038fed..0c8b8f2c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/KernelTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/KernelTunnel.kt @@ -1,7 +1,6 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel import com.wireguard.android.backend.Backend -import com.wireguard.android.backend.BackendException import com.wireguard.android.backend.Tunnel import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager @@ -10,14 +9,16 @@ import com.zaneschepke.wireguardautotunnel.di.ApplicationScope import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import com.zaneschepke.wireguardautotunnel.domain.state.WireGuardStatistics -import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject class KernelTunnel @Inject constructor( @@ -30,41 +31,88 @@ class KernelTunnel @Inject constructor( networkMonitor: NetworkMonitor, ) : BaseTunnel(ioDispatcher, applicationScope, networkMonitor, appDataRepository, serviceManager, notificationManager) { + private val startedTunnels = ConcurrentHashMap() + override fun startTunnel(tunnelConf: TunnelConf) { - Timber.d("Starting tunnel ${tunnelConf.id} kernel") + Timber.i("Starting tunnel ${tunnelConf.id} kernel") applicationScope.launch(ioDispatcher) { - if (tunnels.value.any { it.id == tunnelConf.id }) return@launch Timber.w("Tunnel already running") runCatching { - Timber.d("Setting backend state UP") - super.beforeStartTunnel(tunnelConf) - backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toWgConfig()) - Timber.d("Calling super.startTunnel") + updateTunnelState(tunnelConf.id, TunnelStatus.STARTING) + Timber.d("Set STARTING state for tunnel ${tunnelConf.id} at ${System.currentTimeMillis()}") + + runBlocking { configureTunnel(tunnelConf) } + Timber.d("Callback set for tunnel ${tunnelConf.id} at ${System.currentTimeMillis()}") + super.startTunnel(tunnelConf) - }.onFailure { - Timber.e(it, "Failed to start tunnel ${tunnelConf.id} kernel") - onTunnelStop(tunnelConf) - if (it is BackendException) { - handleBackendThrowable(it.toBackendError()) + Timber.d("Calling backend.setState UP for tunnel ${tunnelConf.id}") + backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toWgConfig()) + startedTunnels[tunnelConf.id] = tunnelConf + + val backendState = backend.getState(tunnelConf) + if (backendState == Tunnel.State.UP) { + updateTunnelState(tunnelConf.id, TunnelStatus.UP) + Timber.d("Confirmed UP state for tunnel ${tunnelConf.id} at ${System.currentTimeMillis()}") } else { - Timber.e(it) + Timber.w("Tunnel ${tunnelConf.id} not UP after setState, state: $backendState") } + + // Start stats jobs only after UP is confirmed + tunnelJobs[tunnelConf.id] = mutableListOf(startTunnelJobs(tunnelConf)) + Timber.d("Started stats jobs for tunnel ${tunnelConf.id} at ${System.currentTimeMillis()}") + }.onFailure { exception -> + Timber.e(exception, "Failed to start tunnel ${tunnelConf.id} kernel") + stopTunnel(tunnelConf) + handleBackendThrowable(exception) + }.onSuccess { + Timber.i("Tunnel ${tunnelConf.id} started successfully") } } } - override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics { - return WireGuardStatistics(backend.getStatistics(tunnelConf)) + override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? { + return try { + WireGuardStatistics(backend.getStatistics(tunnelConf)) + } catch (e: Exception) { + Timber.e(e) + null + } } override fun stopTunnel(tunnelConf: TunnelConf?) { applicationScope.launch(ioDispatcher) { runCatching { - tunnels.value.firstOrNull { it.id == tunnelConf?.id }?.let { - backend.setState(it, Tunnel.State.DOWN, it.toWgConfig()) - onTunnelStop(it) - } ?: stopAllTunnels() - }.onFailure { - Timber.e(it) + val originalTunnel = tunnelConf?.let { startedTunnels.getOrDefault(it.id, null) } + if (originalTunnel != null) { + Timber.i( + "Stopping tunnel ${originalTunnel.id} kernel", + ) +// updateTunnelState(tunnelConf.id, TunnelStatus.STOPPING) + backend.setState(originalTunnel, Tunnel.State.DOWN, originalTunnel.toWgConfig()) + super.stopTunnel(originalTunnel) + startedTunnels.remove(originalTunnel.id) + tunnelJobs[originalTunnel.id]?.forEach { it.cancel() } + tunnelJobs.remove(originalTunnel.id) + if (backend.getState(originalTunnel) == Tunnel.State.DOWN) { + updateTunnelState(originalTunnel.id, TunnelStatus.DOWN) + Timber.d("Confirmed DOWN state for tunnel ${originalTunnel.id}") + } + } else { + Timber.w("Tunnel not found in startedTunnels, stopping all tunnels") + startedTunnels.forEach { (_, config) -> +// updateTunnelState(config.id, TunnelStatus.STOPPING) + val state = backend.setState(config, Tunnel.State.DOWN, config.toWgConfig()) + super.stopTunnel(tunnelConf) + if (state == Tunnel.State.DOWN) { + startedTunnels.remove(config.id) + tunnelJobs[config.id]?.forEach { it.cancel() } + tunnelJobs.remove(config.id) + updateTunnelState(config.id, TunnelStatus.DOWN) + Timber.d("Confirmed DOWN state for tunnel ${config.id} after fallback") + } + } + } + }.onFailure { e -> + Timber.e(e, "Failed to stop tunnel ${tunnelConf?.id}") } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt index ae1c5525..0339e1fd 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelManager.kt @@ -76,7 +76,7 @@ class TunnelManager @Inject constructor( return tunnelProviderFlow.value.runningTunnelNames() } - override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics { + override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? { return tunnelProviderFlow.value.getStatistics(tunnelConf) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt index 47bb59eb..82bff610 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/TunnelProvider.kt @@ -7,15 +7,11 @@ import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import kotlinx.coroutines.flow.StateFlow interface TunnelProvider { - companion object { - const val CHECK_INTERVAL = 1000L - } - fun startTunnel(tunnelConf: TunnelConf) fun stopTunnel(tunnelConf: TunnelConf? = null) suspend fun bounceTunnel(tunnelConf: TunnelConf) suspend fun setBackendState(backendState: BackendState, allowedIps: Collection) suspend fun runningTunnelNames(): Set - fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics + fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? val activeTunnels: StateFlow> } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/UserspaceTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/UserspaceTunnel.kt index 9f2eba47..731539e4 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/UserspaceTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/core/tunnel/UserspaceTunnel.kt @@ -1,6 +1,5 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel -import com.wireguard.android.backend.BackendException import com.zaneschepke.networkmonitor.NetworkMonitor import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager @@ -8,17 +7,19 @@ import com.zaneschepke.wireguardautotunnel.di.ApplicationScope import com.zaneschepke.wireguardautotunnel.di.IoDispatcher import com.zaneschepke.wireguardautotunnel.domain.entity.TunnelConf import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState +import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.domain.state.AmneziaStatistics import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics import com.zaneschepke.wireguardautotunnel.util.extensions.asAmBackendState -import com.zaneschepke.wireguardautotunnel.util.extensions.toBackendError import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.amnezia.awg.backend.Backend -import org.amnezia.awg.backend.Tunnel import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject class UserspaceTunnel @Inject constructor( @@ -31,28 +32,55 @@ class UserspaceTunnel @Inject constructor( networkMonitor: NetworkMonitor, ) : BaseTunnel(ioDispatcher, applicationScope, networkMonitor, appDataRepository, serviceManager, notificationManager) { + private val startedTunnels = ConcurrentHashMap() + override fun startTunnel(tunnelConf: TunnelConf) { + Timber.i("Starting tunnel ${tunnelConf.id} userspace") applicationScope.launch(ioDispatcher) { - Timber.d("Starting tunnel ${tunnelConf.id} userspace") - if (tunnels.value.any { it.id == tunnelConf.id }) return@launch Timber.w("Tunnel already running") - if (tunnels.value.isNotEmpty()) { - Timber.d("Stopping all tunnels") - stopAllTunnels() - } runCatching { - Timber.d("Setting backend state UP") - super.beforeStartTunnel(tunnelConf) - backend.setState(tunnelConf, Tunnel.State.UP, tunnelConf.toAmConfig()) - Timber.d("Calling super.startTunnel") + stopActiveTunnels(tunnelConf) + updateTunnelState(tunnelConf.id, TunnelStatus.STARTING) + Timber.d("Set STARTING state for tunnel ${tunnelConf.id} at ${System.currentTimeMillis()}") + + runBlocking { configureTunnel(tunnelConf) } + Timber.d("Callback set for tunnel ${tunnelConf.id} at ${System.currentTimeMillis()}") + super.startTunnel(tunnelConf) - }.onFailure { - Timber.e(it, "Failed to start tunnel ${tunnelConf.id} userspace") - onTunnelStop(tunnelConf) - if (it is BackendException) { - handleBackendThrowable(it.toBackendError()) + Timber.d("Calling backend.setState UP for tunnel ${tunnelConf.id}") + backend.setState(tunnelConf, org.amnezia.awg.backend.Tunnel.State.UP, tunnelConf.toAmConfig()) + startedTunnels[tunnelConf.id] = tunnelConf + + val backendState = backend.getState(tunnelConf) + if (backendState == org.amnezia.awg.backend.Tunnel.State.UP) { + updateTunnelState(tunnelConf.id, TunnelStatus.UP) + Timber.d("Confirmed UP state for tunnel ${tunnelConf.id} at ${System.currentTimeMillis()}") } else { - Timber.e(it) + Timber.w("Tunnel ${tunnelConf.id} not UP after setState, state: $backendState") } + + // Start stats jobs only after UP is confirmed + tunnelJobs[tunnelConf.id] = mutableListOf(startTunnelJobs(tunnelConf)) + Timber.d("Started stats jobs for tunnel ${tunnelConf.id} at ${System.currentTimeMillis()}") + }.onFailure { exception -> + Timber.e(exception, "Failed to start tunnel ${tunnelConf.id} userspace") + stopTunnel(tunnelConf) + handleBackendThrowable(exception) + }.onSuccess { + Timber.i("Tunnel ${tunnelConf.id} started successfully") + } + } + } + + private suspend fun stopActiveTunnels(tunnelConf: TunnelConf) { + val runningTunnels = activeTunnels.value.filter { (id, state) -> + id != tunnelConf.id && state.state == TunnelStatus.UP + } + runningTunnels.forEach { (id, _) -> + val runningTunnel = startedTunnels[id] + if (runningTunnel != null) { + Timber.i("Stopping running tunnel ${runningTunnel.id} before starting ${tunnelConf.id}") + stopTunnel(runningTunnel) + delay(300) } } } @@ -60,17 +88,44 @@ class UserspaceTunnel @Inject constructor( override fun stopTunnel(tunnelConf: TunnelConf?) { applicationScope.launch(ioDispatcher) { runCatching { - tunnels.value.firstOrNull { it.id == tunnelConf?.id }?.let { - backend.setState(it, Tunnel.State.DOWN, it.toAmConfig()) - onTunnelStop(it) - } ?: stopAllTunnels() - }.onFailure { - Timber.e(it) + val originalTunnel = tunnelConf?.let { startedTunnels.getOrDefault(it.id, null) } + if (originalTunnel != null) { + Timber.i( + "Stopping tunnel ${originalTunnel.id} userspace", + ) +// updateTunnelState(tunnelConf.id, TunnelStatus.STOPPING) + backend.setState(originalTunnel, org.amnezia.awg.backend.Tunnel.State.DOWN, originalTunnel.toAmConfig()) + super.stopTunnel(originalTunnel) + startedTunnels.remove(originalTunnel.id) + tunnelJobs[originalTunnel.id]?.forEach { it.cancel() } + tunnelJobs.remove(originalTunnel.id) + if (backend.getState(originalTunnel) == org.amnezia.awg.backend.Tunnel.State.DOWN) { + updateTunnelState(originalTunnel.id, TunnelStatus.DOWN) + Timber.d("Confirmed DOWN state for tunnel ${originalTunnel.id}") + } + } else { + Timber.w("Tunnel not found in startedTunnels, stopping all tunnels") + startedTunnels.forEach { (_, config) -> +// updateTunnelState(config.id, TunnelStatus.STOPPING) + val state = backend.setState(config, org.amnezia.awg.backend.Tunnel.State.DOWN, config.toAmConfig()) + super.stopTunnel(tunnelConf) + if (state == org.amnezia.awg.backend.Tunnel.State.DOWN) { + startedTunnels.remove(config.id) + tunnelJobs[config.id]?.forEach { it.cancel() } + tunnelJobs.remove(config.id) + updateTunnelState(config.id, TunnelStatus.DOWN) + Timber.d("Confirmed DOWN state for tunnel ${config.id} after fallback") + } + } + } + }.onFailure { e -> + Timber.e(e, "Failed to stop tunnel ${tunnelConf?.id}") } } } override suspend fun setBackendState(backendState: BackendState, allowedIps: Collection) { + Timber.d("Setting backend state: $backendState with allowedIps: $allowedIps") backend.setBackendState(backendState.asAmBackendState(), allowedIps) } @@ -78,7 +133,12 @@ class UserspaceTunnel @Inject constructor( return backend.runningTunnelNames } - override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics { - return AmneziaStatistics(backend.getStatistics(tunnelConf)) + override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? { + return try { + AmneziaStatistics(backend.getStatistics(tunnelConf)) + } catch (e: Exception) { + Timber.e(e, "Failed to get stats for ${tunnelConf.tunName}") + null + } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/entity/TunnelConf.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/entity/TunnelConf.kt index 1fc61467..efe628b2 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/entity/TunnelConf.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/entity/TunnelConf.kt @@ -1,16 +1,19 @@ package com.zaneschepke.wireguardautotunnel.domain.entity +import com.wireguard.android.backend.Tunnel import com.wireguard.config.Config import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.extensions.isReachable import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString -import kotlinx.coroutines.withContext -import kotlinx.serialization.Transient -import org.amnezia.awg.backend.Tunnel -import timber.log.Timber +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.io.InputStream import java.net.InetAddress import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.nio.charset.StandardCharsets data class TunnelConf( val id: Int = 0, @@ -29,10 +32,41 @@ data class TunnelConf( val isIpv4Preferred: Boolean = true, @Transient private var stateChangeCallback: ((Any) -> Unit)? = null, -) : Tunnel, com.wireguard.android.backend.Tunnel { +) : Tunnel, org.amnezia.awg.backend.Tunnel { - fun setStateChangeCallback(callback: (Any) -> Unit) { - stateChangeCallback = callback + // Mutex to protect stateChangeCallback access + private val callbackMutex = Mutex() + + suspend fun setStateChangeCallback(callback: (Any) -> Unit) { + callbackMutex.withLock { + stateChangeCallback = callback + } + } + + // Ensure callback is copied over + fun copyWithCallback( + id: Int = this.id, + tunName: String = this.tunName, + wgQuick: String = this.wgQuick, + tunnelNetworks: List = this.tunnelNetworks, + isMobileDataTunnel: Boolean = this.isMobileDataTunnel, + isPrimaryTunnel: Boolean = this.isPrimaryTunnel, + amQuick: String = this.amQuick, + isActive: Boolean = this.isActive, + isPingEnabled: Boolean = this.isPingEnabled, + pingInterval: Long? = this.pingInterval, + pingCooldown: Long? = this.pingCooldown, + pingIp: String? = this.pingIp, + isEthernetTunnel: Boolean = this.isEthernetTunnel, + isIpv4Preferred: Boolean = this.isIpv4Preferred, + ): TunnelConf { + return TunnelConf( + id, tunName, wgQuick, tunnelNetworks, isMobileDataTunnel, isPrimaryTunnel, + amQuick, isActive, isPingEnabled, pingInterval, pingCooldown, pingIp, + isEthernetTunnel, isIpv4Preferred, + ).apply { + stateChangeCallback = this@TunnelConf.stateChangeCallback + } } fun toAmConfig(): org.amnezia.awg.config.Config { @@ -43,25 +77,40 @@ data class TunnelConf( return configFromWgQuick(wgQuick) } - override fun getName(): String { - return tunName - } + override fun getName(): String = tunName - override fun isIpv4ResolutionPreferred(): Boolean { - return isIpv4Preferred - } + override fun isIpv4ResolutionPreferred(): Boolean = isIpv4Preferred - override fun onStateChange(newState: com.wireguard.android.backend.Tunnel.State) { - stateChangeCallback?.invoke(newState) + override fun onStateChange(newState: org.amnezia.awg.backend.Tunnel.State) { + Timber.d("onStateChange called for tunnel $id: $tunName with state $newState") + runBlocking { + callbackMutex.withLock { + if (stateChangeCallback != null) { + Timber.d("Invoking stateChangeCallback for tunnel $id: $tunName with state $newState") + stateChangeCallback?.invoke(newState) + } else { + Timber.w("No stateChangeCallback set for tunnel $id: $tunName") + } + } + } } override fun onStateChange(newState: Tunnel.State) { - stateChangeCallback?.invoke(newState) + Timber.d("onStateChange called for tunnel $id: $tunName with state $newState") + runBlocking { + callbackMutex.withLock { + if (stateChangeCallback != null) { + Timber.d("Invoking stateChangeCallback for tunnel $id: $tunName with state $newState") + stateChangeCallback?.invoke(newState) + } else { + Timber.w("No stateChangeCallback set for tunnel $id: $tunName") + } + } + } } fun isQuickConfigMatching(updatedConf: TunnelConf): Boolean { - return updatedConf.wgQuick == wgQuick || - updatedConf.amQuick == amQuick + return updatedConf.wgQuick == wgQuick || updatedConf.amQuick == amQuick } fun isPingConfigMatching(updatedConf: TunnelConf): Boolean { @@ -78,7 +127,6 @@ data class TunnelConf( return@withContext InetAddress.getByName(pingIp) .isReachable(Constants.PING_TIMEOUT.toInt()) } - Timber.i("Pinging all peers") config.peers.map { peer -> peer.isReachable(isIpv4Preferred) }.all { true } @@ -88,14 +136,14 @@ data class TunnelConf( companion object { fun configFromWgQuick(wgQuick: String): Config { val inputStream: InputStream = wgQuick.byteInputStream() - return inputStream.bufferedReader(Charsets.UTF_8).use { + return inputStream.bufferedReader(StandardCharsets.UTF_8).use { Config.parse(it) } } fun configFromAmQuick(amQuick: String): org.amnezia.awg.config.Config { val inputStream: InputStream = amQuick.byteInputStream() - return inputStream.bufferedReader(Charsets.UTF_8).use { + return inputStream.bufferedReader(StandardCharsets.UTF_8).use { org.amnezia.awg.config.Config.parse(it) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/BackendError.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/BackendError.kt index df3326f1..a59abd2f 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/BackendError.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/BackendError.kt @@ -4,4 +4,5 @@ sealed class BackendError() { data object DNS : BackendError() data object Unauthorized : BackendError() data object Config : BackendError() + data object Unknown : BackendError() } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelStatus.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelStatus.kt index f2f3e782..abe8c853 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelStatus.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/domain/enums/TunnelStatus.kt @@ -3,6 +3,8 @@ package com.zaneschepke.wireguardautotunnel.domain.enums enum class TunnelStatus { UP, DOWN, + STARTING, + STOPPING, ; fun isDown(): Boolean {