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 588733f..5cbf34a 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -1,5 +1,120 @@ package ru.komet.app +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import java.net.NetworkInterface +import java.util.Collections -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + + private val channelName = "ru.komet.app/vpn_bypass" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + channelName, + ).setMethodCallHandler { call, result -> + when (call.method) { + "detectInterfaces" -> result.success(detectInterfaces()) + "bindToNonVpnNetwork" -> result.success(bindToNonVpnNetwork()) + "unbindNetwork" -> result.success(unbindNetwork()) + else -> result.notImplemented() + } + } + } + + private fun connectivityManager(): ConnectivityManager = + getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + // Перечисляет активные интерфейсы: есть ли tun-туннель и какие прямые. + private fun detectInterfaces(): Map { + val tunNames = ArrayList() + val directNames = ArrayList() + val interfaces = try { + Collections.list(NetworkInterface.getNetworkInterfaces()) + } catch (_: Exception) { + emptyList() + } + for (nif in interfaces) { + val name = nif.name ?: continue + val up = try { + nif.isUp && !nif.isLoopback + } catch (_: Exception) { + false + } + if (!up) continue + when { + name.startsWith("tun") || name.startsWith("ppp") || + name.startsWith("ipsec") || name.startsWith("wg") -> + tunNames.add(name) + name.startsWith("wlan") || name.startsWith("rmnet") || + name.startsWith("eth") -> + directNames.add(name) + } + } + return mapOf( + "hasTun" to tunNames.isNotEmpty(), + "tunNames" to tunNames, + "directInterfaces" to directNames, + ) + } + + // Привязывает процесс к не-VPN сети: Wi-Fi → Ethernet → моб. + private fun bindToNonVpnNetwork(): Map { + val cm = connectivityManager() + var best: Network? = null + var bestIface: String? = null + var bestTransport: String? = null + var bestScore = -1 + + for (network in cm.allNetworks) { + val caps = cm.getNetworkCapabilities(network) ?: continue + if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) 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 + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 2 + caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 1 + else -> continue + } + val transport = when (baseScore) { + 3 -> "wifi" + 2 -> "ethernet" + else -> "cellular" + } + 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 chosen = best + ?: return mapOf("bound" to false, "reason" to "no_non_vpn_network") + + val ok = cm.bindProcessToNetwork(chosen) + return mapOf( + "bound" to ok, + "interface" to bestIface, + "transport" to bestTransport, + "reason" to if (ok) null else "bind_failed", + ) + } + + private fun unbindNetwork(): Map { + connectivityManager().bindProcessToNetwork(null) + return mapOf("bound" to false, "reason" to "unbound") + } +} diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index c0b0532..72c250a 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; import '../config/proxy_config.dart'; import '../utils/logger.dart'; import 'proxy_connector.dart'; +import 'vpn_bypass.dart'; enum SocketState { disconnected, connecting, connected } @@ -34,6 +35,12 @@ class Connection { _setState(SocketState.connecting); try { + try { + await VpnBypassService.instance.applyIfNeeded(); + } catch (e) { + logger.w('VPN bypass: пропущено ($e)'); + } + final proxySettings = await ProxyConfig.load(); RawSocket rawSocket; diff --git a/lib/core/transport/vpn_bypass.dart b/lib/core/transport/vpn_bypass.dart new file mode 100644 index 0000000..3798253 --- /dev/null +++ b/lib/core/transport/vpn_bypass.dart @@ -0,0 +1,147 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../utils/logger.dart'; + +class VpnBypassResult { + final bool enabled; + final bool tunDetected; + final bool bound; + final String? boundInterface; + final String? transport; + final String? reason; + + const VpnBypassResult({ + required this.enabled, + this.tunDetected = false, + this.bound = false, + this.boundInterface, + this.transport, + this.reason, + }); + + @override + String toString() => + 'VpnBypassResult(enabled: $enabled, tun: $tunDetected, bound: $bound, ' + 'iface: $boundInterface, transport: $transport, reason: $reason)'; +} + +/// При активном VPN (tun-интерфейс) привязывает процесс к не-VPN сети +/// (wlan*/rmnet*). Только Android, по умолчанию выключено. +class VpnBypassService { + VpnBypassService._(); + static final VpnBypassService instance = VpnBypassService._(); + + static const String prefKey = 'dev_vpn_bypass'; + + static const MethodChannel _channel = + MethodChannel('ru.komet.app/vpn_bypass'); + + bool _bound = false; + + bool get _supported => Platform.isAndroid; + + Future isEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefKey) ?? false; + } + + Future setEnabled(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefKey, value); + } + + /// Вызывается перед каждым (ре)коннектом. + Future applyIfNeeded() async { + if (!_supported) { + return const VpnBypassResult( + enabled: false, + reason: 'unsupported_platform', + ); + } + + if (!await isEnabled()) { + await _restoreDefault(); + return const VpnBypassResult(enabled: false); + } + + final tunDetected = await _hasTunInterface(); + if (!tunDetected) { + await _restoreDefault(); + logger.i('VPN bypass: tun-интерфейс не найден — маршрут по умолчанию'); + return const VpnBypassResult(enabled: true, tunDetected: false); + } + + try { + final res = await _channel + .invokeMapMethod('bindToNonVpnNetwork'); + final bound = res?['bound'] == true; + _bound = bound; + final result = VpnBypassResult( + enabled: true, + tunDetected: true, + bound: bound, + boundInterface: res?['interface'] as String?, + transport: res?['transport'] as String?, + reason: res?['reason'] as String?, + ); + if (bound) { + logger.i( + 'VPN bypass: трафик направлен мимо VPN → ' + '${result.boundInterface} (${result.transport})', + ); + } else { + logger.w('VPN bypass: не удалось обойти VPN (${result.reason})'); + } + return result; + } on PlatformException catch (e) { + logger.e('VPN bypass: ошибка платформы: ${e.message}'); + return VpnBypassResult( + enabled: true, + tunDetected: true, + reason: e.code, + ); + } on MissingPluginException { + return const VpnBypassResult( + enabled: true, + tunDetected: true, + reason: 'no_plugin', + ); + } + } + + Future _hasTunInterface() async { + try { + final res = await _channel + .invokeMapMethod('detectInterfaces'); + if (res != null && res.containsKey('hasTun')) { + return res['hasTun'] == true; + } + } catch (_) {} + try { + final ifaces = await NetworkInterface.list( + includeLoopback: false, + includeLinkLocal: true, + ); + return ifaces.any((i) { + final n = i.name.toLowerCase(); + return n.startsWith('tun') || + n.startsWith('ppp') || + n.startsWith('ipsec') || + n.startsWith('wg'); + }); + } catch (_) { + return false; + } + } + + Future _restoreDefault() async { + if (!_bound) return; + try { + await _channel.invokeMethod('unbindNetwork'); + } catch (_) {} + _bound = false; + } +} diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index fc43f31..dc7de36 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -159,6 +159,73 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: appState == null + ? const SizedBox.shrink() + : ValueListenableBuilder( + valueListenable: appState.vpnBypassEnabled, + builder: (context, bypassOn, _) { + return Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.vpn_key_off, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'Обход VPN', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Если обнаружен VPN (tun-интерфейс), ' + 'подключаться напрямую через Wi-Fi или ' + 'моб. сеть в обход туннеля. Только Android', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: bypassOn, + onChanged: (v) { + appState.setVpnBypassEnabled(v); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), diff --git a/lib/main.dart b/lib/main.dart index 4233010..848f518 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'backend/modules/contacts.dart'; import 'backend/modules/messages.dart'; import 'core/push/push_service.dart'; import 'core/storage/app_database.dart'; +import 'core/transport/vpn_bypass.dart'; import 'core/storage/token_storage.dart'; import 'core/utils/haptics.dart'; import 'core/protocol/packet.dart'; @@ -57,10 +58,12 @@ void main() async { final prefs = await SharedPreferences.getInstance(); final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false; + final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false; runApp( KometApp( initialLocale: initialLocale, initialFpsOverlay: initialFpsOverlay, + initialVpnBypass: initialVpnBypass, ), ); } @@ -70,10 +73,12 @@ class KometApp extends StatefulWidget { super.key, required this.initialLocale, this.initialFpsOverlay = false, + this.initialVpnBypass = false, }); final Locale initialLocale; final bool initialFpsOverlay; + final bool initialVpnBypass; static final navigatorKey = GlobalKey(); static KometAppState? stateOf(BuildContext context) { @@ -94,6 +99,9 @@ class KometAppState extends State { late final ValueNotifier fpsOverlayEnabled = ValueNotifier( widget.initialFpsOverlay, ); + late final ValueNotifier vpnBypassEnabled = ValueNotifier( + widget.initialVpnBypass, + ); final _profileUpdateController = StreamController.broadcast(); Stream get profileUpdateStream => _profileUpdateController.stream; @@ -153,6 +161,7 @@ class KometAppState extends State { _loginStatusSub?.cancel(); _profileUpdateController.close(); fpsOverlayEnabled.dispose(); + vpnBypassEnabled.dispose(); super.dispose(); } @@ -163,6 +172,13 @@ class KometAppState extends State { await prefs.setBool('dev_fps_overlay', value); } + Future setVpnBypassEnabled(bool value) async { + if (vpnBypassEnabled.value == value) return; + vpnBypassEnabled.value = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(VpnBypassService.prefKey, value); + } + Future applyLocale(Locale locale) async { if (!AppLocalizations.supportedLocales.any( (l) => l.languageCode == locale.languageCode,