From 6571d18696f0c48fc834adbe250dbe5467c8c908 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 16 May 2026 09:13:51 +0300 Subject: [PATCH] fix(network): make non-VPN network selection robust + show failure reason --- .../main/kotlin/ru/komet/app/MainActivity.kt | 77 +++++++++++++------ lib/core/transport/vpn_bypass.dart | 6 ++ lib/main.dart | 3 +- 3 files changed, 61 insertions(+), 25 deletions(-) diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index 9459159..2165818 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities +import android.util.Log import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -14,6 +15,10 @@ class MainActivity : FlutterActivity() { private val channelName = "ru.komet.app/vpn_bypass" + private companion object { + const val LOG_TAG = "VpnBypass" + } + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) MethodChannel( @@ -79,19 +84,28 @@ class MainActivity : FlutterActivity() { return false } + private data class Candidate( + val network: Network, + val iface: String?, + val transport: String, + val score: Int, + ) + // Привязывает процесс к не-VPN сети: Wi-Fi → Ethernet → моб. + // Жёсткий фильтр — только исключение VPN-транспорта; INTERNET/NOT_VPN/ + // VALIDATED лишь повышают приоритет (физическая сеть под активным VPN + // часто теряет эти capability, но через неё всё равно можно ходить). private fun bindToNonVpnNetwork(): Map { val cm = connectivityManager() - var best: Network? = null - var bestIface: String? = null - var bestTransport: String? = null - var bestScore = -1 + val networks = cm.allNetworks + val candidates = ArrayList() - for (network in cm.allNetworks) { - val caps = cm.getNetworkCapabilities(network) ?: continue - if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) continue + for (network in networks) { + val caps = cm.getNetworkCapabilities(network) + val iface = cm.getLinkProperties(network)?.interfaceName + Log.i(LOG_TAG, "net=$network iface=$iface caps=$caps") + if (caps == null) continue if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) continue - if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) continue val baseScore = when { caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 3 @@ -104,27 +118,42 @@ class MainActivity : FlutterActivity() { 2 -> "ethernet" else -> "cellular" } + val internet = + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + val notVpn = + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) val validated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - val score = baseScore * 2 + if (validated) 1 else 0 - if (score > bestScore) { - bestScore = score - best = network - bestIface = cm.getLinkProperties(network)?.interfaceName - bestTransport = transport - } + val score = baseScore * 8 + + (if (internet) 4 else 0) + + (if (notVpn) 2 else 0) + + (if (validated) 1 else 0) + candidates.add(Candidate(network, iface, transport, score)) } - val chosen = best - ?: return mapOf("bound" to false, "reason" to "no_non_vpn_network") + candidates.sortByDescending { it.score } + Log.i(LOG_TAG, "candidates=${candidates.map { "${it.iface}:${it.score}" }}") - val ok = cm.bindProcessToNetwork(chosen) - return mapOf( - "bound" to ok, - "interface" to bestIface, - "transport" to bestTransport, - "reason" to if (ok) null else "bind_failed", - ) + if (candidates.isEmpty()) { + return mapOf( + "bound" to false, + "reason" to "no_non_vpn_network(scanned=${networks.size})", + ) + } + + for (c in candidates) { + if (cm.bindProcessToNetwork(c.network)) { + Log.i(LOG_TAG, "bound to ${c.iface} (${c.transport})") + return mapOf( + "bound" to true, + "interface" to c.iface, + "transport" to c.transport, + "reason" to null, + ) + } + Log.w(LOG_TAG, "bindProcessToNetwork failed for ${c.iface}") + } + return mapOf("bound" to false, "reason" to "bind_failed") } private fun unbindNetwork(): Map { diff --git a/lib/core/transport/vpn_bypass.dart b/lib/core/transport/vpn_bypass.dart index 7536139..a764811 100644 --- a/lib/core/transport/vpn_bypass.dart +++ b/lib/core/transport/vpn_bypass.dart @@ -96,6 +96,12 @@ class VpnBypassService { reason: 'no_plugin', ); } + if (result.bound) { + logger.i('VPN bypass: привязано к ${result.boundInterface} ' + '(${result.transport})'); + } else { + logger.w('VPN bypass: обойти не удалось (${result.reason})'); + } _eventController.add(result); return result; } diff --git a/lib/main.dart b/lib/main.dart index bf40959..8596491 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -161,7 +161,8 @@ class KometAppState extends State { final msg = r.bound ? 'Соединение через VPN не работает — ' 'используется ${r.boundInterface ?? r.transport ?? 'прямое подключение'}' - : 'Соединение через VPN не работает, обойти не удалось'; + : 'Соединение через VPN не работает, обойти не удалось' + '${r.reason != null ? ' (${r.reason})' : ''}'; final now = DateTime.now(); if (msg == _lastVpnNotice &&