Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<!--foreground service permissions-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />

<!--start service on boot permission-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ class NotificationActionReceiver : BroadcastReceiver(), KoinComponent {
autoTunnelRepository.updateAutoTunnelEnabled(false)
NotificationAction.TUNNEL_OFF.name -> {
val tunnelId = intent.getIntExtra(NotificationManager.EXTRA_ID, 0)
if (tunnelId == STOP_ALL_TUNNELS_ID)
if (tunnelId == STOP_ALL_TUNNELS_ID) {
return@launch tunnelManager.stopActiveTunnels()
}
tunnelManager.stopTunnel(tunnelId)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,17 @@ class ServiceManager(
val intent = Intent(context, serviceClass)
context.startForegroundService(intent)
context.bindService(intent, tunnelServiceConnection, Context.BIND_AUTO_CREATE)
val connected = withTimeoutOrNull(5000L) { _tunnelService.first { it != null } }
if (connected == null) {
Timber.e(
"Tunnel service failed to bind within 5s, cleaning up dangling connection"
)
try {
context.unbindService(tunnelServiceConnection)
} catch (e: Exception) {
Timber.w(e, "Failed to unbind after connect timeout")
}
}
} else {
Timber.e("Service still not null after timeout")
}
Expand All @@ -171,6 +182,7 @@ class ServiceManager(
} catch (e: Exception) {
Timber.e(e, "Failed to unbind Tunnel Service")
}
_tunnelService.update { null }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ class AutoTunnelService : LifecycleService() {
SettingsChange(appMode, settings, tunnels)
}

val tunnelsFlow = tunnelManager.activeTunnels.map(::ActiveTunnelsChange)
val tunnelsFlow =
tunnelManager.activeTunnels.map(::ActiveTunnelsChange).distinctUntilChanged()

var reevaluationJob: Job? = null

Expand Down Expand Up @@ -220,11 +221,30 @@ class AutoTunnelService : LifecycleService() {
}
is ActiveTunnelsChange -> {
autoTunnelStateFlow.update { it.copy(activeTunnels = change.activeTunnels) }
// ActiveTunnelsChange only keeps state in sync for future decisions.
// Stats/ping updates emit frequently (~1/s) but never require tunnel
// action,
// so skip event handling and re-evaluation to avoid a hot loop.
return@collect
}
}

handleAutoTunnelEvent(autoTunnelStateFlow.value.determineAutoTunnelEvent(change))
val event = autoTunnelStateFlow.value.determineAutoTunnelEvent(change)
handleAutoTunnelEvent(event)

// When the network type changes (WiFi ↔ 4G / Ethernet) but the same
// tunnel stays active, clear stale health states so the monitoring
// shows UNKNOWN (gray) instead of a false UNHEALTHY (red) from
// pings that failed during the brief network transition.
if (
change is NetworkChange &&
event is AutoTunnelEvent.DoNothing &&
change.networkState.hasInternet() &&
previousState.networkState.activeNetwork::class !=
change.networkState.activeNetwork::class
) {
clearStaleHealthStates()
}

// re-evaluate network state after a short duration to prevent missed state changes
reevaluationJob = launch {
Expand Down Expand Up @@ -355,6 +375,14 @@ class AutoTunnelService : LifecycleService() {
}
}

private suspend fun clearStaleHealthStates() {
for ((id, state) in autoTunnelStateFlow.value.activeTunnels) {
if (!state.status.isUp()) continue
Timber.i("Network type changed, clearing stale ping states for tunnel %d", id)
tunnelManager.updateTunnelStatus(id, null, null, emptyMap(), null)
}
}

private suspend fun handleAutoTunnelEvent(autoTunnelEvent: AutoTunnelEvent) {
autoTunMutex.withLock {
when (
Expand All @@ -363,14 +391,18 @@ class AutoTunnelService : LifecycleService() {
Timber.i("Auto tunnel event: ${it.javaClass.simpleName}")
}
) {
is AutoTunnelEvent.Start ->
(event.tunnelConfig ?: tunnelsRepository.getDefaultTunnel())?.let {
is AutoTunnelEvent.Start -> {
val tunnelConfig = event.tunnelConfig ?: tunnelsRepository.getDefaultTunnel()
tunnelConfig?.let {
tunnelManager.startTunnel(it).onFailure { e ->
Timber.e(e, "Auto-tunnel start failed for ${it.name}")
// TODO notify or retry
}
}
is AutoTunnelEvent.Stop -> tunnelManager.stopActiveTunnels()
}
is AutoTunnelEvent.Stop -> {
tunnelManager.stopActiveTunnels()
}
AutoTunnelEvent.DoNothing -> Timber.i("Auto-tunneling: nothing to do")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,9 @@ class TunnelControlTile : TileService(), LifecycleOwner {
unlockAndRun {
lifecycleScope.launch {
startLock.withLock {
if (tunnelManager.activeTunnels.value.isNotEmpty())
if (tunnelManager.activeTunnels.value.isNotEmpty()) {
return@launch tunnelManager.stopActiveTunnels()
}
val lastActive = WireGuardAutoTunnel.getLastActiveTunnels()
if (lastActive.isEmpty()) {
tunnelsRepository.getStartTunnel()?.let { tunnelManager.startTunnel(it) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,11 @@ class TunnelLifecycleManager(
tunStatusMutex.withLock {
sharedActiveTunnels.update { currentTuns ->
if (!currentTuns.containsKey(tunnelId) && status != TunnelStatus.Starting) {
Timber.d("Ignoring update for inactive tunnel $tunnelId")
return@update currentTuns
val hasActiveJob = tunnelJobs.containsKey(tunnelId)
if (!hasActiveJob || status == null) {
Timber.d("Ignoring update for inactive tunnel $tunnelId")
return@update currentTuns
}
}
val existingState = currentTuns[tunnelId] ?: TunnelState()
val newStatus = status ?: existingState.status
Expand Down Expand Up @@ -179,6 +182,9 @@ class TunnelLifecycleManager(
override fun handleDnsReresolve(tunnelConfig: TunnelConfig): Boolean =
backend.handleDnsReresolve(tunnelConfig)

override suspend fun forceSocketRebind(tunnelConfig: TunnelConfig): Boolean =
backend.forceSocketRebind(tunnelConfig)

override fun getStatistics(tunnelId: Int): TunnelStatistics? = backend.getStatistics(tunnelId)

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.zaneschepke.wireguardautotunnel.core.tunnel.handler.DynamicDnsHandler
import com.zaneschepke.wireguardautotunnel.core.tunnel.handler.TunnelActiveStatePersister
import com.zaneschepke.wireguardautotunnel.core.tunnel.handler.TunnelMonitorHandler
import com.zaneschepke.wireguardautotunnel.core.tunnel.handler.TunnelServiceHandler
import com.zaneschepke.wireguardautotunnel.core.tunnel.handler.WifiRoamingHandler
import com.zaneschepke.wireguardautotunnel.data.model.AppMode
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendMode
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
Expand Down Expand Up @@ -130,6 +131,9 @@ class TunnelManager(
override fun handleDnsReresolve(tunnelConfig: TunnelConfig): Boolean =
getProvider().handleDnsReresolve(tunnelConfig)

override suspend fun forceSocketRebind(tunnelConfig: TunnelConfig): Boolean =
getProvider().forceSocketRebind(tunnelConfig)

override fun getStatistics(tunnelId: Int): TunnelStatistics? =
getProvider().getStatistics(tunnelId)

Expand Down Expand Up @@ -209,6 +213,18 @@ class TunnelManager(
ioDispatcher = ioDispatcher,
)

private val wifiRoamingHandler =
WifiRoamingHandler(
activeTunnels = activeTunnels,
settingsRepository = settingsRepository,
networkMonitor = networkMonitor,
powerManager = powerManager,
forceSocketRebind = { config -> forceSocketRebind(config) },
getTunnelConfig = { id -> tunnelsRepository.getById(id) },
applicationScope = applicationScope,
ioDispatcher = ioDispatcher,
)

init {
applicationScope.launch(ioDispatcher) {
val initialEmit = AtomicBoolean(true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ interface TunnelProvider {

fun handleDnsReresolve(tunnelConfig: TunnelConfig): Boolean

suspend fun forceSocketRebind(tunnelConfig: TunnelConfig): Boolean

fun getStatistics(tunnelId: Int): TunnelStatistics?

val activeTunnels: StateFlow<Map<Int, TunnelState>>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,30 @@ class KernelTunnel(private val runConfigHelper: RunConfigHelper, private val bac

private val runtimeTunnels = ConcurrentHashMap<Int, Tunnel>()

init {
try {
val staleNames = backend.runningTunnelNames
for (name in staleNames) {
try {
val stub =
object : Tunnel {
override fun getName() = name

override fun onStateChange(newState: Tunnel.State) {}

override fun isIpv4ResolutionPreferred() = true
}
backend.setState(stub, Tunnel.State.DOWN, null)
Timber.i("Released stale kernel tunnel: %s", name)
} catch (e: Exception) {
Timber.e(e, "Failed to release stale kernel tunnel: %s", name)
}
}
} catch (e: Exception) {
Timber.d(e, "Kernel backend not available for cleanup")
}
}

private fun validateWireGuardInterfaceName(name: String): Result<Unit> {
if (name.isEmpty() || name.length > 15)
return Result.failure(KernelTunnelName(R.string.kernel_name_error))
Expand Down Expand Up @@ -111,6 +135,12 @@ class KernelTunnel(private val runConfigHelper: RunConfigHelper, private val bac
throw NotImplementedError()
}

override suspend fun forceSocketRebind(tunnelConfig: TunnelConfig): Boolean {
// Kernel mode handles socket rebinding natively
Timber.d("Kernel mode: socket rebind handled natively")
return true
}

override suspend fun runningTunnelNames(): Set<String> {
return backend.runningTunnelNames
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ interface TunnelBackend {

fun handleDnsReresolve(tunnelConfig: TunnelConfig): Boolean

/**
* Forces the tunnel's UDP socket to rebind to the current network. Used after WiFi roaming when
* the endpoint IP hasn't changed but the socket is still bound to the old network path.
*
* @return true if rebind was successful, false otherwise
*/
suspend fun forceSocketRebind(tunnelConfig: TunnelConfig): Boolean

suspend fun runningTunnelNames(): Set<String>

suspend fun forceStopTunnel(tunnelId: Int)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,28 @@ class UserspaceTunnel(private val backend: Backend, private val runConfigHelper:

private val runtimeTunnels = ConcurrentHashMap<Int, Tunnel>()

init {
val staleNames = backend.runningTunnelNames
for (name in staleNames) {
try {
val stub =
object : Tunnel {
override fun getName() = name

override fun onStateChange(newState: Tunnel.State) {}

override fun isIpv4ResolutionPreferred() = true

override fun isMetered() = false
}
backend.setState(stub, Tunnel.State.DOWN, null)
Timber.i("Released stale tunnel socket: %s", name)
} catch (e: Exception) {
Timber.e(e, "Failed to release stale tunnel: %s", name)
}
}
}

override fun tunnelStateFlow(tunnelConfig: TunnelConfig): Flow<TunnelStatus> = callbackFlow {
val stateChannel = Channel<Tunnel.State>()

Expand Down Expand Up @@ -92,6 +114,20 @@ class UserspaceTunnel(private val backend: Backend, private val runConfigHelper:
return backend.resolveDDNS(tunnelConfig.toAmConfig(), tunnel.isIpv4ResolutionPreferred)
}

override suspend fun forceSocketRebind(tunnelConfig: TunnelConfig): Boolean {
val tunnel = runtimeTunnels[tunnelConfig.id] ?: throw ServiceNotRunning()
return try {
// Re-apply the config to force socket rebind
val runConfig = runConfigHelper.buildAmRunConfig(tunnelConfig)
backend.setState(tunnel, Tunnel.State.UP, runConfig)
Timber.d("Force socket rebind successful for ${tunnelConfig.name}")
true
} catch (e: Exception) {
Timber.w(e, "Force socket rebind failed for ${tunnelConfig.name}")
false
}
}

override suspend fun runningTunnelNames(): Set<String> {
return backend.runningTunnelNames
}
Expand Down
Loading