Compare commits

..

9 Commits

Author SHA1 Message Date
zaneschepke 619e3c1cde chore: release 5.0.5 2026-06-23 12:51:01 -04:00
zaneschepke 77f8a8215b fix: improve mobile network detection for dual sim setups 2026-06-23 11:18:31 -04:00
zaneschepke 8772036dd7 build: fix localization string mismatch 2026-06-23 10:57:11 -04:00
zaneschepke 63625ccbd7 refactor: service manager to use new user start function 2026-06-23 10:46:08 -04:00
zaneschepke 9ac7ae77b3 fix: improve always on vpn reliability
#1289
2026-06-23 10:38:28 -04:00
zaneschepke e062fbb34d fix: vpnservice not cleaned up properly in certain scenarios 2026-06-23 09:40:44 -04:00
alexandervlpl 16d5586433 feat: config import via wg:// deep links (#1213)
Co-authored-by: zaneschepke <dev@zaneschepke.com>
2026-06-22 11:40:00 -04:00
zaneschepke 48a3ad64f4 chore: release 5.0.4 2026-06-20 13:13:19 -04:00
zaneschepke e5796d641d fix: auto tunnel rapid toggle bug
Improve notification efficiency
#1288
2026-06-20 12:33:25 -04:00
22 changed files with 374 additions and 236 deletions
+7
View File
@@ -74,6 +74,13 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="wg" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.SHOW_APP_INFO" />
@@ -31,15 +31,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircleOutline
import androidx.compose.material.icons.outlined.Error
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material.icons.rounded.Error
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -87,6 +82,7 @@ import com.zaneschepke.wireguardautotunnel.domain.sideeffect.GlobalSideEffect
import com.zaneschepke.wireguardautotunnel.ui.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.common.banner.AppAlertBanner
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.VpnDeniedDialog
import com.zaneschepke.wireguardautotunnel.ui.navigation.Route
import com.zaneschepke.wireguardautotunnel.ui.navigation.SecureRoute
@@ -181,7 +177,8 @@ class MainActivity : AppCompatActivity() {
roomBackup = RoomBackup(this).database(appDatabase).enableLogDebug(true).maxFileCount(5)
handleIncomingIntent(intent)
handleConfigFileIntent(intent)
handleWgDeepLinkIntent(intent)
installSplashScreen().apply {
setKeepOnScreenCondition { !viewModel.container.stateFlow.value.isAppLoaded }
@@ -295,6 +292,17 @@ class MainActivity : AppCompatActivity() {
},
)
uiState.pendingWgImportUrl?.let { url ->
val host = Uri.parse(url).host ?: url
InfoDialog(
onDismiss = { viewModel.dismissWgImport() },
onAttest = { viewModel.importFromUrl(url) },
title = stringResource(R.string.add_from_url),
body = { Text(stringResource(R.string.wg_url_confirm_message, host)) },
confirmText = stringResource(R.string.okay),
)
}
LaunchedEffect(Unit) {
if (uiState.shouldShowDonationSnackbar && !uiState.alreadyDonated) {
viewModel.setShouldShowDonationSnackbar(false)
@@ -597,6 +605,21 @@ class MainActivity : AppCompatActivity() {
}
}
private fun handleWgDeepLinkIntent(intent: Intent) {
if (intent.action == Intent.ACTION_VIEW) {
val uri = intent.data ?: return
if (uri.scheme == "wg") {
val httpsUrl = uri.toString().replaceFirst("wg://", "https://")
viewModel.promptWgImport(httpsUrl)
}
}
}
override fun onResume() {
super.onResume()
networkMonitor.checkPermissionsAndUpdateState()
}
fun performBackup(encrypt: Boolean = false, password: String? = null) {
roomBackup
.backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG)
@@ -673,18 +696,14 @@ class MainActivity : AppCompatActivity() {
.restore()
}
override fun onResume() {
super.onResume()
networkMonitor.checkPermissionsAndUpdateState()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIncomingIntent(intent)
handleConfigFileIntent(intent)
handleWgDeepLinkIntent(intent)
}
private fun handleIncomingIntent(intent: Intent?) {
private fun handleConfigFileIntent(intent: Intent?) {
intent ?: return
when (intent.action) {
Intent.ACTION_VIEW,
@@ -51,6 +51,13 @@ class WireGuardAutoTunnel : Application(), KoinComponent {
private val backend: Backend by inject()
private val alwaysOnCallback =
object : VpnService.AlwaysOnCallback {
override fun alwaysOnTriggered() {
applicationScope.launch { tunnelCoordinator.startDefault() }
}
}
@OptIn(KoinViewModelScopeApi::class)
override fun onCreate() {
super.onCreate()
@@ -86,13 +93,7 @@ class WireGuardAutoTunnel : Application(), KoinComponent {
Timber.plant(ReleaseTree())
}
backend.setAlwaysOnCallback(
object : VpnService.AlwaysOnCallback {
override fun alwaysOnTriggered() {
applicationScope.launch { tunnelCoordinator.startDefault() }
}
}
)
backend.setAlwaysOnCallback(alwaysOnCallback)
val dispatcher = get<TunnelEventDispatcher>()
val coordinator = get<TunnelCoordinator>()
@@ -27,13 +27,14 @@ import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.to
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
@@ -77,13 +78,16 @@ class AutoTunnelService : LifecycleService() {
@Volatile private var hasUserOverride = false
private var lastNetworkFingerprint: AutoTunnelState.NetworkFingerprint? = null
@OptIn(FlowPreview::class)
private val autoTunnelStateFlow: Flow<AutoTunnelState> by lazy {
val networkFlow = networkEngine.stableState.mapNotNull { it?.state?.toDomain() }
val settingsFlow = combineSettings()
val backendFlow =
tunnelCoordinator.backendStatus.distinctUntilChangedBy { it.activeTunnels.keys.toSet() }
tunnelCoordinator.backendStatus
.distinctUntilChanged { old, new -> old.activeTunnels == new.activeTunnels }
.debounce(300L.milliseconds)
combine(networkFlow, settingsFlow, backendFlow) { network, settings, backend ->
AutoTunnelState(
@@ -18,5 +18,6 @@ data class GlobalAppUiState(
val selectedTunnelCount: Int = 0,
val alreadyDonated: Boolean = false,
val isPinVerified: Boolean = false,
val pendingWgImportUrl: String? = null,
val isScreenRecordingProtectionEnabled: Boolean = false,
)
@@ -0,0 +1,13 @@
package com.zaneschepke.wireguardautotunnel.util.extensions
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
suspend fun HttpResponse.isHtmlResponse(): Boolean {
val contentType = headers["Content-Type"] ?: ""
if (contentType.contains("text/html", ignoreCase = true)) return true
val bodyStart = bodyAsText().trimStart()
return bodyStart.startsWith("<!DOCTYPE", ignoreCase = true) ||
bodyStart.startsWith("<html", ignoreCase = true)
}
@@ -29,6 +29,7 @@ import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.QuickConfig
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelName
import com.zaneschepke.wireguardautotunnel.util.extensions.asStringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.isHtmlResponse
import com.zaneschepke.wireguardautotunnel.util.extensions.saveTunnelsUniquely
import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils
import io.ktor.client.HttpClient
@@ -274,17 +275,30 @@ class SharedAppViewModel(
fun importFromQr(conf: String) = intent { importFromClipboard(conf) }
fun promptWgImport(url: String) = intent { reduce { state.copy(pendingWgImportUrl = url) } }
fun dismissWgImport() = intent { reduce { state.copy(pendingWgImportUrl = null) } }
fun importFromUrl(url: String) = intent {
reduce { state.copy(pendingWgImportUrl = null) }
try {
httpClient.prepareGet(url).execute { response ->
if (response.status.value in 200..299) {
val body = response.bodyAsText()
importFromClipboard(body)
} else {
throw IOException(
"Failed to download file with error status: ${response.status.value}"
)
if (response.status.value !in 200..299) {
throw IOException("Server returned error: ${response.status.value}")
}
if (response.isHtmlResponse()) {
postSideEffect(
GlobalSideEffect.Snackbar(
StringValue.StringResource(R.string.error_invalid_config_url),
ToastType.Error,
)
)
return@execute
}
val body = response.bodyAsText()
importFromClipboard(body)
}
} catch (e: Exception) {
Timber.e(e)
+1
View File
@@ -138,6 +138,7 @@
<string name="config_error">Ungültige Konfiguration</string>
<string name="join_matrix">Matrix-Community beitreten</string>
<string name="error_download_failed">Download der Konfiguration fehlgeschlagen</string>
<string name="wg_url_confirm_message">Möchtest du wirklich Tunnel von %1$s hinzufügen? Verbinde dich niemals mit einem nicht vertrauenswürdigen VPN!</string>
<string name="add_from_url">Von URL hinzufügen</string>
<string name="export_logs">Gespeicherte Logs exportieren</string>
<string name="app_permission_title">Steuere Tunnel und Auto-Tunnel Funktionen.</string>
+1
View File
@@ -155,6 +155,7 @@
<string name="delete">Удалить</string>
<string name="export_failed">Экспорт не выполнен</string>
<string name="error_download_failed">Невозможно скачать конфигурацию</string>
<string name="wg_url_confirm_message">Добавить туннели от %1$s? Никогда не подключайтесь к неизвестному VPN!</string>
<string name="select_all">Выбрать все</string>
<string name="export_success">Экспорт успешно выполнен</string>
<string name="check_for_update">Проверить обновление</string>
+2
View File
@@ -153,6 +153,7 @@
<string name="add_from_url">Add from URL</string>
<string name="enter_config_url">Enter config URL</string>
<string name="error_download_failed">Failed to download config</string>
<string name="wg_url_confirm_message">Are you sure you want to add tunnels from %1$s? Never connect to an untrusted VPN!</string>
<string name="save">Save</string>
<string name="search">Search</string>
<string name="select">Select</string>
@@ -535,4 +536,5 @@
<string name="hide_password">Hide password</string>
<string name="restore_failed_wrong_password">Restore failed. Wrong password</string>
<string name="restore_failed_invalid_file">Restore failed. Select a valid backup file (.sqlite3 or .sqlite3.aes)</string>
<string name="error_invalid_config_url">This link returned an invalid config file. Make sure you are using a direct download link</string>
</resources>
+2 -2
View File
@@ -1,6 +1,6 @@
object Constants {
const val VERSION_NAME = "5.0.3"
const val VERSION_CODE = 50003
const val VERSION_NAME = "5.0.5"
const val VERSION_CODE = 50005
const val TARGET_SDK = 37
const val MIN_SDK = 26
@@ -0,0 +1,2 @@
What's new:
- Bugfix for auto tunnel rapid toggle
@@ -0,0 +1,5 @@
What's new:
- Bugfix for certain scenarios that were not cleaning up the vpn service fully
- Allows tunnel imports via wg:// url deep links
- Improvements to Always-On VPN reliability
- Improved cellular connectivity detection for dual sims
@@ -102,6 +102,8 @@ class AndroidNetworkMonitor(
private var ethernetCallback: ConnectivityManager.NetworkCallback? = null
private val airplaneModeState = MutableStateFlow(appContext.isAirplaneModeOn())
private val activeCellularNetworks =
MutableStateFlow<Map<Network, NetworkCapabilities>>(emptyMap())
private val airplaneModeFlow: Flow<Boolean> = airplaneModeState.asStateFlow()
// tracking to prevent races that occur when VPN is first activated and to prevent redundant
@@ -317,14 +319,19 @@ class AndroidNetworkMonitor(
private val cellularFlow: Flow<TransportEvent> = callbackFlow {
val onAvailable: (Network) -> Unit = { network ->
Timber.d("Cellular onAvailable: $network")
// Defensive cleanup
activeCellularNetworks.update { it - network }
}
val onLost: (Network) -> Unit = { network ->
Timber.d("Cellular onLost: $network")
activeCellularNetworks.update { it - network }
trySend(TransportEvent.Lost(network))
}
val onCapabilitiesChanged: (Network, NetworkCapabilities) -> Unit = { network, caps ->
Timber.d("Cellular onCapabilitiesChanged: $network")
trySend(TransportEvent.CapabilitiesChanged(network, caps))
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
activeCellularNetworks.update { it + (network to caps) }
trySend(TransportEvent.CapabilitiesChanged(network, caps))
}
}
cellularCallback =
@@ -339,13 +346,10 @@ class AndroidNetworkMonitor(
val request =
NetworkRequest.Builder()
.apply { addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) }
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.build()
connectivityManager?.registerNetworkCallback(request, cellularCallback!!)
trySend(TransportEvent.Unknown)
awaitClose {
runCatching { connectivityManager?.unregisterNetworkCallback(cellularCallback!!) }
.onFailure { Timber.e(it, "Error unregistering cellular network callback") }
@@ -438,6 +442,26 @@ class AndroidNetworkMonitor(
.also { Timber.d("Current SSID via ${method.name}: $it") }
}
private fun hasGoodCellularNetwork(): Boolean =
activeCellularNetworks.value.values.any { caps ->
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
(Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED))
}
private fun getGoodCellularNetwork(): Network? =
activeCellularNetworks.value.entries
.firstOrNull { (_, caps) ->
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
(Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED))
}
?.key
// default network events don't contain detailed capability information of underlying networks,
// so we need to track separately
private data class NetworkData(
@@ -577,21 +601,13 @@ class AndroidNetworkMonitor(
}
// only count cellular as connected if validated AND not in airplane mode
!isAirplaneOn &&
networkData.cellularEvent is TransportEvent.CapabilitiesChanged &&
networkData.cellularEvent.networkCapabilities?.let { caps ->
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
caps.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET
) &&
caps.hasCapability(
NetworkCapabilities.NET_CAPABILITY_VALIDATED
) &&
caps.hasCapability(
NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED
)
} == true -> {
ActiveNetwork.Cellular(networkData.cellularEvent.network)
!isAirplaneOn && hasGoodCellularNetwork() -> {
val goodNetwork = getGoodCellularNetwork()
if (goodNetwork != null) {
ActiveNetwork.Cellular(goodNetwork)
} else {
ActiveNetwork.Disconnected()
}
}
else -> ActiveNetwork.Disconnected()
@@ -23,10 +23,8 @@ import com.zaneschepke.tunnel.state.KillSwitchState
import com.zaneschepke.tunnel.util.RootShell
import com.zaneschepke.tunnel.util.RootShellException
import com.zaneschepke.tunnel.util.buildResolvedPeers
import com.zaneschepke.tunnel.util.isLastTunnelOfServiceType
import com.zaneschepke.tunnel.util.toHostMap
import com.zaneschepke.wireguardautotunnel.parser.ActiveConfig
import java.lang.ref.WeakReference
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -92,8 +90,6 @@ class TunnelBackend(
NETWORK_CHANGE_RESET,
}
private var dnsConfigJob: Job? = null
private val statusCallback = StatusCallback { handle, code ->
val state = Tunnel.State.fromNative(code) ?: return@StatusCallback
val tunnelId = byHandle[handle] ?: return@StatusCallback
@@ -107,7 +103,7 @@ class TunnelBackend(
tunnelMutex.withLock {
runCatching {
if (_status.value.activeTunnels.containsKey(tunnel.id)) {
Timber.d("Tunnel ${tunnel.id} already running — ignoring start")
Timber.w("Tunnel ${tunnel.id} already running")
return@runCatching
}
@@ -130,74 +126,68 @@ class TunnelBackend(
if (scriptsEnabled)
mode.config.`interface`.preUp?.let { runScripts(it, tunnel.id) }
val fd = setupServiceForMode(tunnel, mode)
setupServiceForMode(tunnel, mode)
if (hasDynamicEndpoints(mode)) {
pendingResolutionJobs[tunnel.id] = startTunnelBootstrapJob(tunnel, mode, fd)
pendingResolutionJobs[tunnel.id] = startTunnelBootstrapJob(tunnel, mode)
} else {
val result = engine.start(tunnel, mode, fd)
val result = engine.start(tunnel, mode)
onEngineStartResult(tunnel.id, result)
if (scriptsEnabled) {
mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) }
}
tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, mode)
}
}
.onFailure { cleanup(tunnel.id) }
}
private fun startTunnelBootstrapJob(tunnel: Tunnel, mode: BackendMode, fd: Int?) =
scope.launch {
try {
updateTunnelBootstrapState(tunnel.id, BootstrapState.ResolvingDns)
private fun startTunnelBootstrapJob(tunnel: Tunnel, mode: BackendMode) = scope.launch {
try {
updateTunnelBootstrapState(tunnel.id, BootstrapState.ResolvingDns)
val resultMap = endpointResolver.resolvePeers(mode)
ensureActive()
val resultMap = endpointResolver.resolvePeers(mode)
ensureActive()
val networkHasIpv6 = stableNetworkEngine.stableState.value?.state?.hasIpv6 ?: false
val hostMap =
resultMap.toHostMap(
preferIpv6 =
tunnel.ipStrategy is Tunnel.IpStrategy.PreferIpv6 && networkHasIpv6
)
val resolvedPeers = mode.config.buildResolvedPeers(hostMap)
val networkHasIpv6 = stableNetworkEngine.stableState.value?.state?.hasIpv6 ?: false
val hostMap =
resultMap.toHostMap(
preferIpv6 = tunnel.ipStrategy is Tunnel.IpStrategy.PreferIpv6 && networkHasIpv6
)
val resolvedPeers = mode.config.buildResolvedPeers(hostMap)
updateTunnelBootstrapState(tunnel.id, BootstrapState.Complete)
updateTunnelBootstrapState(tunnel.id, BootstrapState.Complete)
val resolvedConfig = mode.config.copy(peers = resolvedPeers)
val updatedMode =
when (mode) {
is BackendMode.Vpn -> mode.copy(config = resolvedConfig)
is BackendMode.Proxy.Standard -> mode.copy(config = resolvedConfig)
is BackendMode.Proxy.KillSwitchPrimary -> mode.copy(config = resolvedConfig)
}
val result = engine.start(tunnel, updatedMode, fd)
onEngineStartResult(tunnel.id, result)
val scriptsEnabled = tunnel.scriptsEnabled
if (scriptsEnabled) {
mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) }
val resolvedConfig = mode.config.copy(peers = resolvedPeers)
val updatedMode =
when (mode) {
is BackendMode.Vpn -> mode.copy(config = resolvedConfig)
is BackendMode.Proxy.Standard -> mode.copy(config = resolvedConfig)
is BackendMode.Proxy.KillSwitchPrimary -> mode.copy(config = resolvedConfig)
}
tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, updatedMode)
} catch (t: Throwable) {
if (t is kotlinx.coroutines.CancellationException) {
Timber.d("Bootstrap job cancelled for tunnel ${tunnel.id}")
throw t
} else {
Timber.e(t, "Tunnel bootstrap failed for ${tunnel.id}")
}
cleanup(tunnel.id)
} finally {
pendingResolutionJobs.remove(tunnel.id)
val result = engine.start(tunnel, updatedMode)
onEngineStartResult(tunnel.id, result)
val scriptsEnabled = tunnel.scriptsEnabled
if (scriptsEnabled) {
mode.config.`interface`.postUp?.let { runScripts(it, tunnel.id) }
}
}
private suspend fun setupServiceForMode(tunnel: Tunnel, mode: BackendMode): Int? {
var fd: Int? = null
tunnelJobs[tunnel.id] = startTunnelJobs(result.handle, tunnel, updatedMode)
} catch (t: Throwable) {
if (t is kotlinx.coroutines.CancellationException) {
Timber.d("Bootstrap job cancelled for tunnel ${tunnel.id}")
} else {
Timber.e(t, "Tunnel bootstrap failed for ${tunnel.id}")
cleanup(tunnel.id)
}
if (t is kotlinx.coroutines.CancellationException) throw t
}
}
private suspend fun setupServiceForMode(tunnel: Tunnel, mode: BackendMode) {
when (mode) {
is BackendMode.Proxy.KillSwitchPrimary -> {
serviceHolder.ensureVpnProtectorRegistered()
@@ -207,10 +197,9 @@ class TunnelBackend(
}
is BackendMode.Vpn -> {
val service = serviceHolder.ensureVpnProtectorRegistered()
fd = service.createTunInterface(tunnel, mode.config)?.detachFd()
service.createTunInterface(tunnel, mode.config)
}
}
return fd
}
private fun onEngineStartResult(tunnelId: Int, result: EngineStartResult) {
@@ -221,13 +210,27 @@ class TunnelBackend(
byTunnelId[tunnelId] = result.handle
}
private fun cleanup(tunnelId: Int) {
private suspend fun cleanup(tunnelId: Int) {
pendingResolutionJobs.remove(tunnelId)?.cancel()
tunnelJobs.remove(tunnelId)?.cancel()
val activeTunnels = _status.value.activeTunnels
val vpnTypeCount = activeTunnels.values.count { it.mode is BackendMode.Vpn }
val proxyTypeCount = activeTunnels.values.count { it.mode is BackendMode.Proxy.Standard }
removeActiveTunnel(tunnelId)
byTunnelId[tunnelId]?.let { byHandle.remove(it) }
byTunnelId.remove(tunnelId)
peerUpdateMutexes.remove(tunnelId)
if (vpnTypeCount == 1) {
serviceHolder.stopVpnService()
}
if (proxyTypeCount == 1) {
serviceHolder.stopTunnelService()
}
}
private suspend fun runScripts(commands: List<String>, tunnelId: Int) {
@@ -246,29 +249,20 @@ class TunnelBackend(
}
override fun setAlwaysOnCallback(alwaysOnCallback: VpnService.AlwaysOnCallback) {
ServiceHolder.alwaysOnCallback = WeakReference(alwaysOnCallback)
ServiceHolder.alwaysOnCallback = alwaysOnCallback
}
override suspend fun stop(id: Int): Result<Unit> = tunnelMutex.withLock {
runCatching {
val activeTun = _status.value.activeTunnels[id] ?: return@runCatching
val mode = activeTun.mode ?: return@runCatching
updateTunnelTransportState(id, Tunnel.State.Stopping)
val isLast = _status.value.activeTunnels.size == 1
val isLastOfServiceType = _status.value.isLastTunnelOfServiceType(id)
try {
stopTunnelInternal(id, activeTun)
} finally {
applicationProvider.refreshTile(serviceHolder.context)
if (isLast) VpnBackend.setStatusCallback(null)
if (isLastOfServiceType) {
when (mode) {
is BackendMode.Proxy.KillSwitchPrimary,
is BackendMode.Vpn -> serviceHolder.stopVpnService()
is BackendMode.Proxy.Standard -> serviceHolder.stopTunnelService()
}
if (_status.value.activeTunnels.isEmpty()) {
VpnBackend.setStatusCallback(null)
}
}
}
@@ -277,8 +271,6 @@ class TunnelBackend(
private suspend fun stopTunnelInternal(tunnelId: Int, activeTunnel: ActiveTunnel) {
updateTunnelTransportState(tunnelId, Tunnel.State.Stopping)
pendingResolutionJobs.remove(tunnelId)?.cancel()
val handle = byTunnelId[tunnelId]
if (handle == null) {
@@ -8,7 +8,7 @@ import com.zaneschepke.wireguardautotunnel.parser.PeerSection
internal interface TunnelEngine {
suspend fun start(tunnel: Tunnel, mode: BackendMode, fd: Int?): EngineStartResult
suspend fun start(tunnel: Tunnel, mode: BackendMode): EngineStartResult
suspend fun stop(handle: Int, mode: BackendMode)
@@ -17,7 +17,7 @@ import java.util.UUID
internal class WireGuardTunnelEngine(private val serviceHolder: ServiceHolder) : TunnelEngine {
override suspend fun start(tunnel: Tunnel, mode: BackendMode, fd: Int?): EngineStartResult {
override suspend fun start(tunnel: Tunnel, mode: BackendMode): EngineStartResult {
val ifName = WGT_INTERFACE_PREFIX + tunnel.id
@@ -56,7 +56,8 @@ internal class WireGuardTunnelEngine(private val serviceHolder: ServiceHolder) :
startProxyTunnel(ifName, mode.config, proxyConfig, false)
}
is BackendMode.Vpn -> {
startVpnTunnel(ifName, mode.config, fd)
val service = serviceHolder.getVpnService()
startVpnTunnel(ifName, mode.config, service.detachVpnTunnelFd())
}
}
@@ -0,0 +1,13 @@
package com.zaneschepke.tunnel.service
import com.zaneschepke.tunnel.model.BackendMode
import com.zaneschepke.tunnel.state.BackendStatus
fun BackendStatus.toNotificationComparisonKey(): Any =
activeTunnels.mapValues { (_, tunnel) ->
Triple(
tunnel.transportState,
tunnel.bootstrapState,
tunnel.mode is BackendMode.Vpn || tunnel.mode is BackendMode.Proxy.KillSwitchPrimary,
)
} to (activeTunnels.keys to (killSwitch.enabled to dnsMode))
@@ -4,7 +4,6 @@ import android.content.Context
import android.content.Intent
import com.zaneschepke.tunnel.ProxyBackend
import com.zaneschepke.tunnel.util.BackendException
import java.lang.ref.WeakReference
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
@@ -50,7 +49,7 @@ internal class ServiceHolder(val context: Context) {
}
if (_vpnService.value == null) {
context.startService(Intent(context, VpnService::class.java))
VpnService.start(context, VpnService::class.java)
}
return try {
@@ -76,16 +75,22 @@ internal class ServiceHolder(val context: Context) {
suspend fun stopVpnService() {
val service = _vpnService.value ?: return
clearVpnService()
service.shutdown()
withTimeoutOrNull(1_000L.milliseconds) { vpnServiceFlow.first { it == null } }
try {
service.shutdown()
withTimeoutOrNull(1_500L.milliseconds) { vpnServiceFlow.first { it == null } }
} finally {
clearVpnService()
}
}
suspend fun stopTunnelService() {
val service = _tunnelService.value ?: return
clearTunnelService()
service.shutdown()
withTimeoutOrNull(1_000L.milliseconds) { tunnelServiceFlow.first { it == null } }
try {
service.shutdown()
withTimeoutOrNull(1_500L.milliseconds) { tunnelServiceFlow.first { it == null } }
} finally {
clearTunnelService()
}
}
/**
@@ -104,6 +109,6 @@ internal class ServiceHolder(val context: Context) {
const val SPECIAL_USE_SERVICE_TYPE_ID = 1 shl 30
const val DEFAULT_MTU = 1280
// for consumer to set AOVPN callback
var alwaysOnCallback: WeakReference<VpnService.AlwaysOnCallback>? = null
var alwaysOnCallback: VpnService.AlwaysOnCallback? = null
}
}
@@ -15,7 +15,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject
import timber.log.Timber
@@ -51,7 +51,7 @@ class TunnelService : LifecycleService() {
(intent.component!!.packageName != packageName)
) {
Timber.d("TunnelService started by system")
alwaysOnCallback?.get()?.alwaysOnTriggered()
alwaysOnCallback?.alwaysOnTriggered()
}
return START_STICKY
@@ -61,12 +61,11 @@ class TunnelService : LifecycleService() {
private fun observeProxyPersistentNotification() {
lifecycleScope.launch {
backend.status
.distinctUntilChanged { old, new -> old.activeTunnels == new.activeTunnels }
.debounce(1_000.milliseconds)
.distinctUntilChangedBy { it.toNotificationComparisonKey() }
.debounce(700.milliseconds)
.collect { status ->
val notification =
backend.applicationProvider.buildProxyPersistentNotification(status)
notificationManager.notify(
backend.applicationProvider.proxyNotificationId,
notification,
@@ -10,7 +10,7 @@ import com.zaneschepke.tunnel.backend.Backend
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import timber.log.Timber
@@ -43,8 +43,8 @@ class VpnCompanionService : LifecycleService() {
private fun observeVpnPersistentNotification() {
lifecycleScope.launch {
backend.status
.distinctUntilChanged { old, new -> old.activeTunnels == new.activeTunnels }
.debounce(1000.milliseconds)
.distinctUntilChangedBy { it.toNotificationComparisonKey() }
.debounce(700.milliseconds)
.collect { status ->
val notification =
backend.applicationProvider.buildVpnPersistentNotification(status)
@@ -1,5 +1,6 @@
package com.zaneschepke.tunnel.service
import android.content.Context
import android.content.Intent
import android.net.TrafficStats
import android.os.Build
@@ -39,10 +40,9 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val shutdownScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile private var userActivatedShutdown = false
private var hevBridgeJob: Job? = null
@Volatile private var fd: ParcelFileDescriptor? = null
@Volatile private var hevBridgeFd: ParcelFileDescriptor? = null
@Volatile private var vpnTunFd: ParcelFileDescriptor? = null
override fun onCreate() {
serviceHolder.set(this)
@@ -58,31 +58,21 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
// Stop the companion foreground service alongside the VPN teardown
stopService(Intent(this, VpnCompanionService::class.java))
closeVpnTunnelFd()
disableKillSwitch()
hevBridgeJob?.cancel()
serviceScope.cancel()
stopHevSocks5Bridge()
if (!userActivatedShutdown) {
Timber.d("Service being killed by system, clean up tunnels")
shutdownScope.launch { backend.stopAllActiveTunnels() }
}
} finally {
super.onDestroy()
}
}
@OptIn(ExperimentalAtomicApi::class)
fun shutdown() {
userActivatedShutdown = true
stopSelf()
}
override fun onRevoke() {
Timber.w("VPN privilege revoked by system")
userActivatedShutdown = false
Timber.w("VPN revoked by user via system settings")
disableKillSwitch()
stopHevSocks5Bridge()
serviceScope.launch { backend.stopAllActiveTunnels() }
shutdownScope.launch { backend.stopAllActiveTunnels() }
stopSelf()
super.onRevoke()
}
@@ -90,21 +80,40 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
serviceHolder.set(this)
// Ensure the companion service is up immediately to provide foreground process
bootKeepaliveService()
// Service restarted by system or Always-on VPN started
if (
intent == null ||
intent.component == null ||
(intent.component!!.packageName != packageName)
) {
Timber.d("VpnService started by system (Always-On trigger)")
alwaysOnCallback?.get()?.alwaysOnTriggered()
// system recovery restart
if (intent == null) {
return START_STICKY
}
val isUserLaunch = intent.getBooleanExtra(getUserLaunchExtraKey(this), false)
val platformSaysAlwaysOn =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
isAlwaysOn
} else {
false
}
val isAlwaysOnTrigger =
!isUserLaunch && (intent.action == SERVICE_INTERFACE || platformSaysAlwaysOn)
if (isAlwaysOnTrigger) {
Timber.d("VpnService started by system (Always-On trigger)")
alwaysOnCallback?.alwaysOnTriggered()
}
return START_STICKY
}
fun shutdown() {
// have to close fds before we can trigger service shutdown
closeVpnTunnelFd()
disableKillSwitch()
stopSelf()
}
private fun bootKeepaliveService() {
try {
val intent = Intent(this, VpnCompanionService::class.java)
@@ -119,7 +128,7 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
val job = serviceScope.launch {
TrafficStats.setThreadStatsTag(HEV_BRIDGE_TRAFFIC_TAG)
try {
val vpnFd = fd ?: throw IOException("No VPN interface fd available")
val vpnFd = hevBridgeFd ?: throw IOException("No VPN interface fd available")
repeat(60) { attempt ->
try {
@@ -176,15 +185,15 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
}
private fun disableKillSwitch() {
fd?.close()
fd = null
hevBridgeFd?.close()
hevBridgeFd = null
}
override fun setKillSwitch(config: KillSwitchConfig?) {
if (config == null) return disableKillSwitch()
fd?.close()
hevBridgeFd?.close()
val intent = backend.applicationProvider.createVpnConfigurePendingIntent(this@VpnService)
fd =
hevBridgeFd =
Builder()
.apply {
setSession(LOCKDOWN_SESSION_NAME)
@@ -211,76 +220,94 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
.establish()
}
fun createTunInterface(tunnel: Tunnel, config: Config): ParcelFileDescriptor? {
fun createTunInterface(tunnel: Tunnel, config: Config) {
val intent = backend.applicationProvider.createVpnConfigurePendingIntent(this@VpnService)
return Builder()
.apply {
setSession(tunnel.name)
setConfigureIntent(intent)
setMtu(config.`interface`.mtu ?: DEFAULT_MTU)
setBlocking(true)
setUnderlyingNetworks(null)
vpnTunFd?.close()
vpnTunFd = null
vpnTunFd =
Builder()
.apply {
setSession(tunnel.name)
setConfigureIntent(intent)
setMtu(config.`interface`.mtu ?: DEFAULT_MTU)
setBlocking(true)
setUnderlyingNetworks(null)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setMetered(tunnel.isMetered)
}
config.`interface`.includedApplications?.forEach { addAllowedApplication(it) }
config.`interface`.excludedApplications?.forEach { addDisallowedApplication(it) }
var hasIpv4 = false
var hasIpv6 = false
var sawDefaultRoute = false
// Parse interface addresses
config.`interface`.address?.split(",")?.forEach { rawAddress ->
val (address, prefixLength) = rawAddress.parseInetNetwork()
addAddress(address, prefixLength)
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true
}
// Parse peer routes
config.peers.forEach { peer ->
peer.allowedIPs
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.forEach { entry ->
val (address, prefix) = entry.parseInetNetwork()
addRoute(address, prefix)
if (prefix == 0) {
sawDefaultRoute = true
}
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true
}
}
// "Kill-switch" semantics (mirrors wireguard-android)
val isKillSwitchRouting = sawDefaultRoute && config.peers.size == 1
if (!isKillSwitchRouting) {
allowFamily(OsConstants.AF_INET)
allowFamily(OsConstants.AF_INET6)
}
// Only add DNS servers whose family is supported
config.`interface`.dns?.let { rawDns ->
val dnsConfig = rawDns.parseDns()
dnsConfig.dnsServers.forEach { dnsServer ->
val isIpv6 = dnsServer is Inet6Address
if ((isIpv6 && hasIpv6) || (!isIpv6 && hasIpv4)) {
addDnsServer(dnsServer)
} else {
Timber.w(
"Dropped DNS server $dnsServer: IP family not allowed by interface/routes"
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setMetered(tunnel.isMetered)
}
config.`interface`.includedApplications?.forEach { addAllowedApplication(it) }
config.`interface`.excludedApplications?.forEach {
addDisallowedApplication(it)
}
var hasIpv4 = false
var hasIpv6 = false
var sawDefaultRoute = false
// Parse interface addresses
config.`interface`.address?.split(",")?.forEach { rawAddress ->
val (address, prefixLength) = rawAddress.parseInetNetwork()
addAddress(address, prefixLength)
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true
}
// Parse peer routes
config.peers.forEach { peer ->
peer.allowedIPs
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.forEach { entry ->
val (address, prefix) = entry.parseInetNetwork()
addRoute(address, prefix)
if (prefix == 0) {
sawDefaultRoute = true
}
if (address is Inet4Address) hasIpv4 = true else hasIpv6 = true
}
}
// "Kill-switch" semantics (mirrors wireguard-android)
val isKillSwitchRouting = sawDefaultRoute && config.peers.size == 1
if (!isKillSwitchRouting) {
allowFamily(OsConstants.AF_INET)
allowFamily(OsConstants.AF_INET6)
}
// Only add DNS servers whose family is supported
config.`interface`.dns?.let { rawDns ->
val dnsConfig = rawDns.parseDns()
dnsConfig.dnsServers.forEach { dnsServer ->
val isIpv6 = dnsServer is Inet6Address
if ((isIpv6 && hasIpv6) || (!isIpv6 && hasIpv4)) {
addDnsServer(dnsServer)
} else {
Timber.w(
"Dropped DNS server $dnsServer: IP family not allowed by interface/routes"
)
}
}
dnsConfig.searchDomains.forEach { addSearchDomain(it) }
}
dnsConfig.searchDomains.forEach { addSearchDomain(it) }
}
}
.establish()
.establish()
}
fun detachVpnTunnelFd(): Int? {
val tunFd = vpnTunFd
vpnTunFd = null
return tunFd?.detachFd()
}
fun closeVpnTunnelFd() {
try {
vpnTunFd?.close()
} catch (_: Exception) {}
vpnTunFd = null
}
override fun startHevSocks5Bridge(port: Int, pass: String) {
@@ -317,6 +344,21 @@ class VpnService : android.net.VpnService(), KillSwitch, SocketProtector {
}
companion object {
private fun getUserLaunchExtraKey(context: Context): String {
return "${context.packageName}.EXTRA_IS_USER_LAUNCH"
}
@JvmStatic
fun start(context: Context, serviceClass: Class<out VpnService>) {
val intent =
Intent(context, serviceClass).apply {
action = SERVICE_INTERFACE
putExtra(getUserLaunchExtraKey(context), true)
}
context.startService(intent)
}
private const val LOCKDOWN_SESSION_NAME = "Lockdown"
private const val LOCALHOST = "127.0.0.1"
private const val IPV4_INTERFACE_ADDRESS = "10.0.0.1"