diff --git a/.gitignore b/.gitignore index 47110d5..51c8d78 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ migrate_working_dir/ **/build/ **/bin/ **/obj/ +/plugins/**/pubspec.lock # Local release artifacts belong in GitHub Releases, not git history /release/ @@ -85,6 +86,8 @@ config.json profiles.json # Local diagnostics and one-off inspection scripts +/.agents/ +/.codex/ /.tmp_xray_jar_dump/ /temp_fetch.js /temp_links_view.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce7fb8..281418c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## 1.0.114 - 2026-07-15 + +- Expanded the Russian and English in-app FAQ with protocol guidance plus + practical Smart Route and Auto DNS setup, verification, and fallback steps. +- Added a rolling native watchdog detector that restarts the VPN runtime after + four degraded endpoint quorums in five minutes while ignoring isolated + timeouts and resetting its history after a real Android network change. +- Persisted the active profile label across native background recovery so the + foreground notification no longer falls back to an empty profile name. +- Completed a 12-hour single-profile LTE baseline with 720/720 healthy samples, + no process restarts, crashes, ANRs, duplicate TUN interfaces, or memory leak; + extended observation then reproduced the long-session TLS degradation that + this release recovers from earlier. + +## 1.0.113 - 2026-07-14 + +- Added an Xray UID traffic sampler so VLESS XHTTP sessions report live upload, + download, and session totals without depending on the sing-box status channel; + stopping the tunnel now clears stale speeds while preserving the final total. + +## 1.0.112 - 2026-07-14 + +- Restarted the isolated VPN process cleanly on every runtime-config switch, + preventing stale TUN descriptors and native crashes between sing-box and Xray. +- Fixed XHTTP startup with compact Xray configs and Android-compatible uTLS + fingerprint selection while preserving explicit non-Chrome fingerprints. +- Kept the foreground notification synchronized with the verified native tunnel + state after watchdog recovery and coalesced rapid Flutter status updates. +- Persisted watchdog restart cooldown across process recovery and added a short + startup grace so Hysteria2 can warm up without an unnecessary early restart. +- Completed a 12-hour LTE soak across VLESS Reality, NaiveProxy, Hysteria2, and + XHTTP with 720 samples, no app crashes or ANRs, and no duplicate TUN interfaces. + +## 1.0.111 - 2026-07-12 + +- Delayed native `Started` status until DNS and HTTPS succeed through the + selected tunnel on at least two independent external endpoints. +- Marked an active session as reconnecting while Android changes the default + Wi-Fi or cellular network, even if the VPN NetworkAgent remains validated. +- Added bounded readiness retries, controlled runtime restart, and background + retry after restart cooldown instead of reporting a false connection. +- Made VPN session state the single owner of native status updates and added + TLS hostname verification to native health probes. + +## 1.0.110 - 2026-07-12 + +- Isolated the encrypted VPN runtime configuration from cross-process service + flags so Android cannot restore an older profile after a process restart. +- Added one-time migration for existing encrypted runtime configurations. +- Reconciled a restored VLESS tunnel with the profile selected in Flutter and + restarted it once when the native runtime still contains another profile. +- Added regression coverage for semantic runtime-config comparison. + ## 1.0.109 - 2026-07-11 - Synchronized explicit start, stop, and restart actions between the Android diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5dc008d..21045dc 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -67,6 +67,10 @@ android { versionCode = flutter.versionCode + 10000 buildConfigField("String", "DISTRIBUTION_CHANNEL", "\"play\"") } + create("soak") { + dimension = "distribution" + buildConfigField("String", "DISTRIBUTION_CHANNEL", "\"soak\"") + } } signingConfigs { diff --git a/android/app/src/soak/AndroidManifest.xml b/android/app/src/soak/AndroidManifest.xml new file mode 100644 index 0000000..a8d71dd --- /dev/null +++ b/android/app/src/soak/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/soak/kotlin/online/dnsai/ivanvpn/qa/SoakControlActivity.kt b/android/app/src/soak/kotlin/online/dnsai/ivanvpn/qa/SoakControlActivity.kt new file mode 100644 index 0000000..04ebb6b --- /dev/null +++ b/android/app/src/soak/kotlin/online/dnsai/ivanvpn/qa/SoakControlActivity.kt @@ -0,0 +1,22 @@ +package online.dnsai.ivanvpn.qa + +import android.app.Activity +import android.os.Bundle +import android.util.Log + +class SoakControlActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + try { + SoakControlCommandHandler.handle(applicationContext, intent) + } catch (error: Throwable) { + Log.e(TAG, "QA command failed: ${error.message}", error) + } finally { + finish() + } + } + + companion object { + private const val TAG = "SoakControlActivity" + } +} diff --git a/android/app/src/soak/kotlin/online/dnsai/ivanvpn/qa/SoakControlReceiver.kt b/android/app/src/soak/kotlin/online/dnsai/ivanvpn/qa/SoakControlReceiver.kt new file mode 100644 index 0000000..dd279d4 --- /dev/null +++ b/android/app/src/soak/kotlin/online/dnsai/ivanvpn/qa/SoakControlReceiver.kt @@ -0,0 +1,133 @@ +package online.dnsai.ivanvpn.qa + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Base64 +import android.util.Log +import androidx.core.content.ContextCompat +import com.tecclub.flutter_singbox.Application +import com.tecclub.flutter_singbox.bg.BoxService +import com.tecclub.flutter_singbox.config.SimpleConfigManager +import com.tecclub.flutter_singbox.constant.Action +import com.tecclub.flutter_singbox.database.Settings +import org.json.JSONObject +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPInputStream +import kotlin.concurrent.thread + +class SoakControlReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val pendingResult = goAsync() + val appContext = context.applicationContext + thread(name = "yurich-soak-control") { + try { + SoakControlCommandHandler.handle(appContext, intent) + } catch (error: Throwable) { + Log.e(TAG, "QA command failed: ${error.message}", error) + } finally { + pendingResult.finish() + } + } + } + + companion object { + private const val TAG = "SoakControlReceiver" + } +} + +internal object SoakControlCommandHandler { + fun handle(context: Context, intent: Intent) { + Application.initializeIfNeeded(context) + when (intent.getStringExtra(EXTRA_COMMAND)?.trim()?.lowercase()) { + COMMAND_ACTIVATE -> activate(context, intent) + COMMAND_STOP -> { + BoxService.stop() + Log.i(TAG, "QA stop requested") + } + COMMAND_RESTART -> { + context.sendBroadcast( + Intent(Action.SERVICE_RESTART).apply { + `package` = context.packageName + putExtra(Action.EXTRA_USER_INITIATED, true) + }, + ) + Log.i(TAG, "QA notification-equivalent restart requested") + } + COMMAND_STATUS -> { + Log.i( + TAG, + "QA status validConfig=${SimpleConfigManager.hasValidConfig()} " + + "startedByUser=${SimpleConfigManager.getStartedByUser()}", + ) + } + else -> error("Unsupported or missing QA command") + } + } + + private fun activate(context: Context, intent: Intent) { + val encoded = intent.getStringExtra(EXTRA_CONFIG_GZIP_BASE64) + ?: error("Missing compressed runtime config") + val config = decodeConfig(encoded) + JSONObject(config) + + check(SimpleConfigManager.saveConfig(config)) { + "Unable to persist QA runtime config" + } + SimpleConfigManager.setStartedByUser(true) + SimpleConfigManager.setManualDisconnectRequested(false) + + val label = intent.getStringExtra(EXTRA_PROFILE_LABEL) + ?.replace(Regex("[\\r\\n\\t]"), " ") + ?.trim() + ?.take(MAX_LABEL_LENGTH) + .orEmpty() + if (label.isNotEmpty()) { + SimpleConfigManager.setNotificationDescription(label) + } + + val serviceIntent = Intent(context, Settings.serviceClass()).apply { + action = BoxService.ACTION_START + putExtra(BoxService.EXTRA_CONFIG_CONTENT, config) + } + ContextCompat.startForegroundService(context, serviceIntent) + Log.i(TAG, "QA activate requested label=$label configLength=${config.length}") + } + + private fun decodeConfig(encoded: String): String { + val compressed = Base64.decode(encoded, Base64.NO_WRAP) + require(compressed.size <= MAX_COMPRESSED_BYTES) { + "Compressed runtime config is too large" + } + + val output = ByteArrayOutputStream() + GZIPInputStream(ByteArrayInputStream(compressed)).use { input -> + val buffer = ByteArray(BUFFER_SIZE) + while (true) { + val count = input.read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + require(output.size() <= MAX_CONFIG_BYTES) { + "Runtime config exceeds QA limit" + } + } + } + return output.toString(Charsets.UTF_8.name()).also { + require(it.isNotBlank()) { "Runtime config is empty" } + } + } + + private const val TAG = "SoakControlCommandHandler" + private const val EXTRA_COMMAND = "command" + private const val EXTRA_CONFIG_GZIP_BASE64 = "configGzipB64" + private const val EXTRA_PROFILE_LABEL = "profileLabel" + private const val COMMAND_ACTIVATE = "activate" + private const val COMMAND_STOP = "stop" + private const val COMMAND_RESTART = "restart" + private const val COMMAND_STATUS = "status" + private const val MAX_COMPRESSED_BYTES = 256 * 1024 + private const val MAX_CONFIG_BYTES = 1024 * 1024 + private const val MAX_LABEL_LENGTH = 96 + private const val BUFFER_SIZE = 8192 +} diff --git a/android/app/src/soak/kotlin/online/dnsai/ivanvpn/qa/SoakControlService.kt b/android/app/src/soak/kotlin/online/dnsai/ivanvpn/qa/SoakControlService.kt new file mode 100644 index 0000000..68d00ff --- /dev/null +++ b/android/app/src/soak/kotlin/online/dnsai/ivanvpn/qa/SoakControlService.kt @@ -0,0 +1,26 @@ +package online.dnsai.ivanvpn.qa + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.util.Log + +class SoakControlService : Service() { + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + try { + requireNotNull(intent) { "Missing QA control intent" } + SoakControlCommandHandler.handle(applicationContext, intent) + } catch (error: Throwable) { + Log.e(TAG, "QA command failed: ${error.message}", error) + } finally { + stopSelf(startId) + } + return START_NOT_STICKY + } + + companion object { + private const val TAG = "SoakControlService" + } +} diff --git a/lib/src/screens/home_screen.dart b/lib/src/screens/home_screen.dart index d17852e..bcb170b 100644 --- a/lib/src/screens/home_screen.dart +++ b/lib/src/screens/home_screen.dart @@ -21,6 +21,7 @@ import '../services/profile_auto_selector.dart'; import '../services/profile_country_resolver.dart'; import '../services/profile_geo_service.dart'; import '../services/profile_importer.dart'; +import '../services/runtime_config_matcher.dart'; import '../services/profile_engine_selector.dart'; import '../services/profile_store.dart'; import '../services/sensitive_data_redactor.dart'; @@ -221,10 +222,13 @@ class _HomeScreenState extends State bool _statusWatchdogInFlight = false; bool _tunnelHealthCheckInFlight = false; bool _notificationSyncInFlight = false; + bool _notificationSyncPending = false; bool _autoRecoveryArmed = false; bool _manualDisconnectRequested = false; bool _pingAllInFlight = false; bool _countryResolveInFlight = false; + bool _runtimeReconcileInFlight = false; + bool _runtimeReconciled = false; bool _logsExpanded = false; String? _lastConfigSummary; String? _lastKeeperAction; @@ -823,6 +827,7 @@ class _HomeScreenState extends State unawaited(_pingProfiles(profiles)); unawaited(_resolveProfileCountries(profiles)); unawaited(_refreshNetworkSnapshot('load')); + unawaited(_reconcileRestoredVlessRuntime()); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { unawaited(_showSubscriptionRenewalReminder(profiles)); @@ -871,6 +876,79 @@ class _HomeScreenState extends State } } + Future _reconcileRestoredVlessRuntime() async { + if (_runtimeReconcileInFlight || _runtimeReconciled) { + return; + } + _runtimeReconcileInFlight = true; + try { + await Future.delayed(const Duration(milliseconds: 1200)); + if (!mounted || _manualDisconnectRequested || _stoppingByUser) { + return; + } + + final profile = _explicitSelectedProfile; + if (profile == null || + (profile.kind != VpnProfileKind.vlessReality && + profile.kind != VpnProfileKind.vlessTls)) { + return; + } + + var status = await _vpnEngine.getVPNStatus().timeout( + _nativeShortTimeout, + onTimeout: () => _status, + ); + if (status == AurumVpnStatus.starting) { + status = await _waitForVpnStatus({ + AurumVpnStatus.started, + AurumVpnStatus.stopped, + }, timeout: const Duration(seconds: 8)); + } + if (!mounted || + status != AurumVpnStatus.started || + _manualDisconnectRequested || + _stoppingByUser) { + return; + } + + final smartRouteBypassPackages = await _smartRouteBypassPackages(); + final expectedConfig = _buildRuntimeConfig( + profile, + plan: _connectionPlans(profile).first, + smartRouteBypassPackages: smartRouteBypassPackages, + ); + final currentConfig = await _vpnEngine.getConfig().timeout( + _nativeConfigTimeout, + ); + if (RuntimeConfigMatcher.equivalent(currentConfig, expectedConfig)) { + _recordStabilityEvent('runtime-config-reconcile:matched'); + return; + } + + _recordStabilityEvent('runtime-config-reconcile:mismatch'); + _queueLog( + 'Restored VPN runtime differs from the selected VLESS profile; ' + 'restarting with the selected profile.', + ); + final operation = _sessionController.beginProfileSwitch(); + await _runQueuedBusy( + operation, + () => _startVpnCore(profile, operation: operation, rapidRestart: true), + message: s.switchingProfile, + ); + } on VpnSessionCancelled catch (error) { + _queueLog('Runtime config reconciliation superseded: $error'); + } on Object catch (error, stackTrace) { + final errorText = _redactSensitive('$error'); + _recordStabilityEvent('runtime-config-reconcile:error:$errorText'); + _queueLog('Runtime config reconciliation failed: $errorText'); + _queueLog(_redactSensitive(stackTrace.toString().split('\n').first)); + } finally { + _runtimeReconcileInFlight = false; + _runtimeReconciled = true; + } + } + Future _initVpn() async { _statusSubscription = _vpnEngine.onStatusChanged.listen( (event) { @@ -1164,6 +1242,7 @@ class _HomeScreenState extends State Future _syncConnectionNotification({bool force = false}) async { if (_notificationSyncInFlight) { + _notificationSyncPending = true; return; } final now = DateTime.now(); @@ -1183,6 +1262,11 @@ class _HomeScreenState extends State ); } finally { _notificationSyncInFlight = false; + final shouldResync = _notificationSyncPending; + _notificationSyncPending = false; + if (shouldResync && mounted) { + unawaited(_syncConnectionNotification(force: true)); + } } } @@ -2065,7 +2149,7 @@ class _HomeScreenState extends State if (started) { final finalStatus = await _waitForVpnStatus({ AurumVpnStatus.started, - }, timeout: const Duration(seconds: 14)); + }, timeout: const Duration(seconds: 40)); if (finalStatus == AurumVpnStatus.started) { _sessionController.ensureCurrent(operation); final requiresSuccessfulProbe = diff --git a/lib/src/screens/home_screen_strings.dart b/lib/src/screens/home_screen_strings.dart index 96c86e7..639547b 100644 --- a/lib/src/screens/home_screen_strings.dart +++ b/lib/src/screens/home_screen_strings.dart @@ -484,9 +484,9 @@ class _Strings { if (kind == VpnProfileKind.vlessXhttp) { return switch (this) { _Strings.en => - 'VLESS XHTTP is imported and shown, but connection requires Xray/libXray. This APK currently runs VPN through sing-box.', + 'The bundled Xray core could not start this VLESS XHTTP profile. Check type, mode, path, TLS/Reality parameters, and Xray logs.', _ => - 'VLESS XHTTP импортируется и показывается, но для подключения нужен Xray/libXray. Этот APK сейчас запускает VPN через sing-box.', + 'Встроенное Xray-ядро не смогло запустить этот VLESS XHTTP профиль. Проверь type, mode, path, параметры TLS/Reality и логи Xray.', }; } return switch (this) { @@ -620,7 +620,7 @@ class _Strings { addProfileHint: 'Добавь подписку Remnawave, QR или отдельный ключ', nothingToImport: 'Нечего импортировать.', supportedProtocolsOnly: - 'В этой сборке запускаются VLESS Reality, VLESS TLS, NaiveProxy и Hysteria/Hysteria2. VLESS XHTTP импортируется и показывается, но подключение требует Xray/libXray.', + 'В этой сборке запускаются Reality, HTTPS/NaiveProxy, Turbo/Hysteria2 и XHTTP через встроенные sing-box и Xray.', switchingProfile: 'Переключаю профиль...', importFirst: 'Сначала импортируй профиль.', autoConnectNoStableProfile: @@ -737,9 +737,94 @@ class _Strings { 'Нажми + в разделе профилей. Можно вставить ссылку вручную, из буфера или отсканировать QR.', ), _FaqItem( - question: 'Какие протоколы поддерживаются?', + question: 'Наши протоколы', answer: - 'В Android-клиенте запускаются стабильные направления: VLESS Reality, VLESS TLS, NaiveProxy и Hysteria/Hysteria2. XHTTP импортируется и виден в VLESS-разделе, но для подключения нужен Xray/libXray. mKCP и raw sing-box JSON остаются скрыты.', + 'Название в приложении -> технология -> транспорт\n\n' + 'HTTPS -> NaiveProxy -> TCP/443\n' + 'Turbo -> Hysteria2 -> UDP/443, QUIC\n' + 'Reality -> VLESS Reality Vision -> TCP/443\n' + 'XHTTP -> VLESS XHTTP -> TLS или Reality + HTTP/2/443', + ), + _FaqItem( + question: 'HTTPS — NaiveProxy', + answer: + 'Работает как обычное защищённое HTTPS-соединение через Caddy.\n\n' + '• Ссылка: naive+https://\n' + '• Хорошая совместимость с Wi-Fi и мобильными операторами.\n' + '• Трафик похож на обычный веб-браузер.\n' + '• Надёжный запасной вариант, если Reality не работает.\n' + '• В приложении находится в разделе HTTPS.', + ), + _FaqItem( + question: 'Turbo — Hysteria2', + answer: + 'Использует QUIC поверх UDP и хорошо переносит потерю пакетов.\n\n' + '• Ссылка: hy2://\n' + '• Обычно быстрее на мобильной сети.\n' + '• Хорошо подходит для видео, загрузок и нестабильного LTE.\n' + '• Может не работать в сетях, где оператор блокирует UDP.\n' + '• В приложении находится в разделе Turbo.', + ), + _FaqItem( + question: 'Reality — VLESS Reality', + answer: + 'Основной лёгкий протокол Xray. Имитирует настоящее TLS-соединение без отдельного сертификата Reality.\n\n' + '• Ссылка: vless://\n' + '• Параметры: security=reality, type=tcp, flow=xtls-rprx-vision.\n' + '• Работает через TCP/443.\n' + '• HAProxy по SNI направляет соединение в Xray.\n' + '• Хороший баланс скорости, стабильности и маскировки.\n' + '• В приложении находится в разделе Reality.', + ), + _FaqItem( + question: 'XHTTP — VLESS XHTTP', + answer: + 'VLESS передаётся внутри трафика, похожего на обычные HTTP-запросы.\n\n' + '• Ссылка: vless://\n' + '• Транспорт: type=xhttp, обычно mode=packet-up.\n' + '• Путь профиля обычно /xhttp.\n' + '• Защита задаётся профилем: security=tls или security=reality.\n' + '• Работает через порт 443; для TLS-профилей используется HTTP/2.\n' + '• Запускается встроенным современным Xray-ядром.\n' + '• Сейчас XHTTP доступен на Finland и отдельной локации Poland 2.', + ), + _FaqItem( + question: 'Какой протокол выбирать?', + answer: + '• Основной вариант: Reality.\n' + '• Нестабильная мобильная сеть: Turbo.\n' + '• Максимальная совместимость: HTTPS.\n' + '• Дополнительный современный вариант: XHTTP.\n\n' + 'Если сеть блокирует UDP, вместо Turbo выбери Reality или HTTPS. HAProxy, Caddy, DNS, WARP и Smart Route — не клиентские протоколы: они отвечают за распределение соединений, маскировку, DNS и маршрутизацию.', + ), + _FaqItem( + question: 'Как работает Smart Route?', + answer: + 'Smart Route разделяет трафик: известные российские сервисы и приложения идут напрямую, а зарубежные и неизвестные направления — через VPN. Банки, Госуслуги, Яндекс, VK и маркетплейсы могут работать напрямую; ChatGPT, Google, YouTube, Telegram и другие глобальные сервисы принудительно остаются в VPN. Браузеры также оставлены в VPN, чтобы случайно не раскрыть внешний IP.', + ), + _FaqItem( + question: 'Как включить и проверить Smart Route?', + answer: + '1. Открой блок «Профиль и сеть».\n' + '2. Включи Smart Route. При активном VPN приложение выполнит короткое переподключение.\n' + '3. Проверь российский сервис в его приложении и отдельно открой зарубежный сервис. Проверка IP в браузере всегда должна показывать VPN, потому что браузеры не выводятся напрямую.\n' + '4. Если нужный сервис маршрутизируется неверно, временно выключи Smart Route и отправь отчёт разработчику.\n\n' + 'Выключенный Smart Route направляет весь обычный трафик через VPN.', + ), + _FaqItem( + question: 'Как работает Auto DNS?', + answer: + 'Auto DNS перехватывает DNS-запросы внутри TUN и защищает их от подмены оператором. Для Reality, Turbo и XHTTP используются защищённые резолверы Cloudflare и Google через туннель. HTTPS/NaiveProxy сохраняет локальный bootstrap DNS ради совместимости и стабильного поиска адреса сервера — это осознанный компромисс режима HTTPS.', + ), + _FaqItem( + question: 'Как включить и проверить Auto DNS?', + answer: + '1. Открой блок «Профиль и сеть».\n' + '2. Включи Auto DNS. При активном VPN приложение выполнит короткое переподключение.\n' + '3. На LTE и публичном Wi-Fi рекомендуется держать Auto DNS включённым.\n' + '4. Если Wi-Fi требует входа через captive portal, временно выключи VPN или Auto DNS, авторизуйся в сети и включи защиту снова.\n' + '5. Для проверки открой DNS leak test после подключения: для Reality, Turbo и XHTTP нормальны резолверы Cloudflare или Google.\n\n' + 'Auto DNS не меняет страну VPN и не заменяет Smart Route.', ), _FaqItem( question: 'Что делать, если после смены профиля пропал интернет?', @@ -771,7 +856,7 @@ class _Strings { addProfileHint: 'Add a Remnawave subscription, QR code, or single key', nothingToImport: 'Nothing to import.', supportedProtocolsOnly: - 'This build runs VLESS Reality, VLESS TLS, NaiveProxy, and Hysteria/Hysteria2. VLESS XHTTP is imported and shown, but connection requires Xray/libXray.', + 'This build runs Reality, HTTPS/NaiveProxy, Turbo/Hysteria2, and XHTTP through the bundled sing-box and Xray cores.', switchingProfile: 'Switching profile...', importFirst: 'Import a profile first.', autoConnectNoStableProfile: @@ -887,9 +972,94 @@ class _Strings { 'Use the add button in Profiles. You can paste manually, import from clipboard, or scan a QR code.', ), _FaqItem( - question: 'Which protocols are supported?', + question: 'Our protocols', + answer: + 'App name -> technology -> transport\n\n' + 'HTTPS -> NaiveProxy -> TCP/443\n' + 'Turbo -> Hysteria2 -> UDP/443, QUIC\n' + 'Reality -> VLESS Reality Vision -> TCP/443\n' + 'XHTTP -> VLESS XHTTP -> TLS or Reality + HTTP/2/443', + ), + _FaqItem( + question: 'HTTPS — NaiveProxy', + answer: + 'Works like a regular secure HTTPS connection through Caddy.\n\n' + '• Link: naive+https://\n' + '• Strong compatibility with Wi-Fi and mobile operators.\n' + '• Traffic resembles a normal web browser.\n' + '• A reliable fallback when Reality does not work.\n' + '• Shown in the HTTPS section of the app.', + ), + _FaqItem( + question: 'Turbo — Hysteria2', + answer: + 'Uses QUIC over UDP and handles packet loss well.\n\n' + '• Link: hy2://\n' + '• Usually faster on mobile networks.\n' + '• Well suited to video, downloads, and unstable LTE.\n' + '• May fail on networks where the operator blocks UDP.\n' + '• Shown in the Turbo section of the app.', + ), + _FaqItem( + question: 'Reality — VLESS Reality', + answer: + 'The main lightweight Xray protocol. It imitates a real TLS connection without a separate Reality certificate.\n\n' + '• Link: vless://\n' + '• Parameters: security=reality, type=tcp, flow=xtls-rprx-vision.\n' + '• Runs over TCP/443.\n' + '• HAProxy routes the connection to Xray by SNI.\n' + '• A strong balance of speed, stability, and camouflage.\n' + '• Shown in the Reality section of the app.', + ), + _FaqItem( + question: 'XHTTP — VLESS XHTTP', + answer: + 'Carries VLESS inside traffic that resembles normal HTTP requests.\n\n' + '• Link: vless://\n' + '• Transport: type=xhttp, usually mode=packet-up.\n' + '• The profile path is usually /xhttp.\n' + '• Security is profile-specific: security=tls or security=reality.\n' + '• Uses port 443; TLS profiles use HTTP/2.\n' + '• Runs on the bundled modern Xray core.\n' + '• XHTTP is currently available on Finland and the separate Poland 2 location.', + ), + _FaqItem( + question: 'Which protocol should I choose?', + answer: + '• Primary option: Reality.\n' + '• Unstable mobile network: Turbo.\n' + '• Maximum compatibility: HTTPS.\n' + '• Additional modern option: XHTTP.\n\n' + 'If a network blocks UDP, choose Reality or HTTPS instead of Turbo. HAProxy, Caddy, DNS, WARP, and Smart Route are not client protocols; they provide connection distribution, camouflage, DNS, and routing.', + ), + _FaqItem( + question: 'How does Smart Route work?', + answer: + 'Smart Route splits traffic: known Russian services and apps connect directly, while international and unknown destinations use the VPN. Banks, Gosuslugi, Yandex, VK, and marketplaces may go direct; ChatGPT, Google, YouTube, Telegram, and other global services are forced through the VPN. Browsers also stay on the VPN to avoid exposing the external IP by accident.', + ), + _FaqItem( + question: 'How do I enable and verify Smart Route?', + answer: + '1. Open the “Profile and network” section.\n' + '2. Enable Smart Route. An active VPN performs a short reconnect.\n' + '3. Test a Russian service in its own app, then test an international service separately. Browser IP checks should always show the VPN because browsers are never routed directly.\n' + '4. If a service takes the wrong route, temporarily disable Smart Route and send a developer report.\n\n' + 'With Smart Route disabled, all regular traffic goes through the VPN.', + ), + _FaqItem( + question: 'How does Auto DNS work?', + answer: + 'Auto DNS captures DNS requests inside the TUN and protects them from operator manipulation. Reality, Turbo, and XHTTP use protected Cloudflare and Google resolvers through the tunnel. HTTPS/NaiveProxy keeps local bootstrap DNS for compatibility and reliable server lookup; this is an intentional tradeoff of HTTPS mode.', + ), + _FaqItem( + question: 'How do I enable and verify Auto DNS?', answer: - 'The Android client runs stable profiles: VLESS Reality, VLESS TLS, NaiveProxy, and Hysteria/Hysteria2. XHTTP is now imported and visible in the VLESS tab, but connection requires Xray/libXray. mKCP and raw sing-box JSON remain hidden. PingTunnel (Experimental) is shown for tracking and is not started in this build.', + '1. Open the “Profile and network” section.\n' + '2. Enable Auto DNS. An active VPN performs a short reconnect.\n' + '3. Keep Auto DNS enabled on LTE and public Wi-Fi.\n' + '4. If Wi-Fi requires a captive portal, temporarily disable the VPN or Auto DNS, sign in, then enable protection again.\n' + '5. Run a DNS leak test after connecting. Cloudflare or Google resolvers are expected for Reality, Turbo, and XHTTP.\n\n' + 'Auto DNS does not change the VPN country and does not replace Smart Route.', ), _FaqItem( question: 'What if internet stops after switching profiles?', diff --git a/lib/src/services/runtime_config_matcher.dart b/lib/src/services/runtime_config_matcher.dart new file mode 100644 index 0000000..5cba978 --- /dev/null +++ b/lib/src/services/runtime_config_matcher.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; + +class RuntimeConfigMatcher { + const RuntimeConfigMatcher._(); + + static bool equivalent(String current, String expected) { + if (current == expected) { + return true; + } + + try { + return jsonEncode(_normalize(jsonDecode(current))) == + jsonEncode(_normalize(jsonDecode(expected))); + } on FormatException { + return current.trim() == expected.trim(); + } + } + + static Object? _normalize(Object? value) { + if (value is Map) { + final keys = value.keys.map((key) => '$key').toList()..sort(); + return { + for (final key in keys) key: _normalize(value[key]), + }; + } + if (value is List) { + return value.map(_normalize).toList(growable: false); + } + return value; + } +} diff --git a/lib/src/services/sing_box_config_builder.dart b/lib/src/services/sing_box_config_builder.dart index 773b06e..33b4bd7 100644 --- a/lib/src/services/sing_box_config_builder.dart +++ b/lib/src/services/sing_box_config_builder.dart @@ -54,6 +54,13 @@ class SingBoxConfigBuilder { final config = { 'log': {'level': 'warn', 'timestamp': true}, + 'experimental': { + 'cache_file': { + 'enabled': true, + 'path': 'cache.db', + 'store_fakeip': true, + }, + }, 'dns': _dnsConfig(profile, useRemoteDns: useRemoteDns), 'inbounds': [ _tunInbound( diff --git a/lib/src/services/xray_config_builder.dart b/lib/src/services/xray_config_builder.dart index 9a11476..ef3e65b 100644 --- a/lib/src/services/xray_config_builder.dart +++ b/lib/src/services/xray_config_builder.dart @@ -231,6 +231,10 @@ class XrayConfigBuilder { final xhttp = transportType == 'xhttp' ? _xhttpSettings(transport) : const {}; + final fingerprint = _runtimeFingerprint( + profile: profile, + configured: _string(utls?['fingerprint']), + ); final streamSettings = { 'network': transportType == 'xhttp' ? 'xhttp' : 'tcp', @@ -243,8 +247,7 @@ class XrayConfigBuilder { 'publicKey': _string(reality['public_key']), if (_string(reality['short_id'])?.isNotEmpty == true) 'shortId': _string(reality['short_id']), - if (_string(utls?['fingerprint'])?.isNotEmpty == true) - 'fingerprint': _string(utls?['fingerprint']), + 'fingerprint': ?fingerprint, 'spiderX': '/', }, } else ...{ @@ -252,8 +255,7 @@ class XrayConfigBuilder { 'tlsSettings': { 'serverName': _string(tls['server_name']) ?? server, if (tls['insecure'] == true) 'allowInsecure': true, - if (_string(utls?['fingerprint'])?.isNotEmpty == true) - 'fingerprint': _string(utls?['fingerprint']), + 'fingerprint': ?fingerprint, if (tls['alpn'] is List) 'alpn': tls['alpn'], }, }, @@ -337,6 +339,19 @@ class XrayConfigBuilder { return supportedModes.contains(normalized) ? normalized : value; } + String? _runtimeFingerprint({ + required VpnProfile profile, + required String? configured, + }) { + final normalized = configured?.trim().toLowerCase(); + if (profile.kind == VpnProfileKind.vlessXhttp && + (normalized == null || normalized.isEmpty || normalized == 'chrome')) { + // The Android Xray/uTLS Chrome ClientHello stalls on some mobile paths. + return 'firefox'; + } + return normalized == null || normalized.isEmpty ? null : configured!.trim(); + } + Map? _map(Object? value) => (value as Map?)?.cast(); diff --git a/plugins/flutter_singbox_vpn/android/src/main/AndroidManifest.xml b/plugins/flutter_singbox_vpn/android/src/main/AndroidManifest.xml index 0061fc3..fdb3576 100644 --- a/plugins/flutter_singbox_vpn/android/src/main/AndroidManifest.xml +++ b/plugins/flutter_singbox_vpn/android/src/main/AndroidManifest.xml @@ -62,6 +62,10 @@ + + ConnectionStatus.Connected + Status.Starting, + Status.Stopping -> ConnectionStatus.Connecting + Status.Stopped -> ConnectionStatus.Disconnected + } +} diff --git a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/FlutterSingboxPlugin.kt b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/FlutterSingboxPlugin.kt index 74def1f..a54f018 100644 --- a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/FlutterSingboxPlugin.kt +++ b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/FlutterSingboxPlugin.kt @@ -190,6 +190,7 @@ class FlutterSingboxPlugin : private val configSaveGeneration = AtomicLong(0L) private val configSaveMutex = Mutex() private var periodicStatusJob: kotlinx.coroutines.Job? = null + private var xrayTrafficJob: Job? = null @Volatile private var serviceStatusCheckInFlight = false private var networkGenerationId = 0 private var lastNetworkFingerprint = "" @@ -463,6 +464,7 @@ class FlutterSingboxPlugin : override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { android.util.Log.e("FlutterSingboxPlugin", "Traffic event channel - onListen called") trafficEventSink = events + syncXrayTrafficSampler(_vpnStatus.value) } override fun onCancel(arguments: Any?) { @@ -547,6 +549,7 @@ class FlutterSingboxPlugin : try { periodicStatusJob?.cancel() periodicStatusJob = null + stopXrayTrafficSampler() serviceStatusCheckInFlight = false connection.disconnect() statusClient.disconnect() @@ -661,6 +664,7 @@ class FlutterSingboxPlugin : sessionReason: String? = null, desiredRunning: Boolean? = null, ) { + syncXrayTrafficSampler(status) android.util.Log.e("FlutterSingboxPlugin", "Sending status update to Flutter: ${status.name}") val statusMap = currentNetworkSnapshot().toMutableMap() statusMap.putAll(mapOf( @@ -957,11 +961,23 @@ class FlutterSingboxPlugin : private fun updateConnectionNotification(state: Map<*, *>?, result: Result) { try { - connectionUiState = ConnectionUiState.fromMap(state) - if ( - connectionUiState.status != ConnectionStatus.Disconnected || - _vpnStatus.value != Status.Stopped - ) { + val nativeStatus = _vpnStatus.value + val requestedState = ConnectionUiState.fromMap(state) + val resolvedStatus = ConnectionNotificationStatusPolicy.resolve( + requestedStatus = requestedState.status, + nativeStatus = nativeStatus, + ) + if (requestedState.status != resolvedStatus) { + android.util.Log.w( + "FlutterSingboxPlugin", + "Ignoring stale notification status ${requestedState.status}; native=$nativeStatus", + ) + } + connectionUiState = requestedState.copy(status = resolvedStatus) + requestedState.profileName + ?.takeIf { it.isNotBlank() } + ?.let(SimpleConfigManager::setActiveProfileName) + if (nativeStatus != Status.Stopped) { vpnNotificationHelper.updateNotification(connectionUiState) } result.success(true) @@ -1736,25 +1752,81 @@ class FlutterSingboxPlugin : "formattedSessionTotal" to TrafficStats.formatBytes(sessionUplink + sessionDownlink) )) - _trafficStats.value = stats as Map + publishTrafficStats(stats) + } + + private fun syncXrayTrafficSampler(status: Status) { + val isXrayRuntime = isCurrentXrayRuntime() + val shouldCollect = XrayTrafficEventPolicy.shouldCollect( + status = status, + isXrayRuntime = isXrayRuntime, + ) + if (shouldCollect) { + startXrayTrafficSampler() + } else { + stopXrayTrafficSampler(publishIdleSample = isXrayRuntime) + } + } + + private fun startXrayTrafficSampler() { + if (xrayTrafficJob?.isActive == true) { + return + } + if (sessionStartUidTxBytes < 0L || + sessionStartUidRxBytes < 0L || + lastUidTrafficSampleAt <= 0L + ) { + resetUidTrafficSession() + } + + android.util.Log.i("FlutterSingboxPlugin", "Starting Xray UID traffic sampler") + xrayTrafficJob = coroutineScope.launch { + try { + while (isActive && _vpnStatus.value == Status.Started) { + val stats = XrayTrafficEventPolicy.build( + sample = sampleUidTraffic(), + networkSnapshot = currentNetworkSnapshot(), + ) + publishTrafficStats(stats) + kotlinx.coroutines.delay(XRAY_TRAFFIC_SAMPLE_INTERVAL_MS) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + android.util.Log.e( + "FlutterSingboxPlugin", + "Xray UID traffic sampler failed", + error, + ) + } + } + } + + private fun stopXrayTrafficSampler(publishIdleSample: Boolean = false) { + val job = xrayTrafficJob ?: return + xrayTrafficJob = null + job.cancel() + if (publishIdleSample) { + publishTrafficStats( + XrayTrafficEventPolicy.build( + sample = sampleUidTraffic(), + networkSnapshot = currentNetworkSnapshot(), + active = false, + ), + ) + } + android.util.Log.i("FlutterSingboxPlugin", "Stopped Xray UID traffic sampler") + } + + private fun publishTrafficStats(stats: Map) { + _trafficStats.value = stats updateTrafficNotification(stats) - - // Send traffic stats to Flutter - val handler = Handler(Looper.getMainLooper()) - handler.post { + + Handler(Looper.getMainLooper()).post { trafficEventSink?.success(stats) } } - private data class UidTrafficSample( - val txTotal: Long, - val rxTotal: Long, - val txSpeed: Long, - val rxSpeed: Long, - val sessionTx: Long, - val sessionRx: Long - ) - private fun resetUidTrafficSession() { val tx = readUidTxBytes() val rx = readUidRxBytes() @@ -1765,12 +1837,12 @@ class FlutterSingboxPlugin : lastUidTrafficSampleAt = System.currentTimeMillis() } - private fun sampleUidTraffic(): UidTrafficSample { + private fun sampleUidTraffic(): XrayUidTrafficSample { val now = System.currentTimeMillis() val tx = readUidTxBytes() val rx = readUidRxBytes() if (tx < 0L || rx < 0L) { - return UidTrafficSample(0L, 0L, 0L, 0L, 0L, 0L) + return XrayUidTrafficSample(0L, 0L, 0L, 0L, 0L, 0L) } if (sessionStartUidTxBytes < 0L || sessionStartUidRxBytes < 0L) { @@ -1794,7 +1866,7 @@ class FlutterSingboxPlugin : lastUidRxBytes = rx lastUidTrafficSampleAt = now - return UidTrafficSample( + return XrayUidTrafficSample( txTotal = tx, rxTotal = rx, txSpeed = txSpeed, @@ -1835,6 +1907,10 @@ class FlutterSingboxPlugin : vpnNotificationHelper.updateNotification(connectionUiState) } + companion object { + private const val XRAY_TRAFFIC_SAMPLE_INTERVAL_MS = 1_000L + } + private fun currentSessionDuration(): String? { val startedAt = sessionStartedAt if (startedAt <= 0L) return connectionUiState.sessionDuration diff --git a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/VpnStatusResolver.kt b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/VpnStatusResolver.kt index aef1b53..6a051a6 100644 --- a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/VpnStatusResolver.kt +++ b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/VpnStatusResolver.kt @@ -15,6 +15,7 @@ internal object VpnStatusResolver { isShuttingDown -> Status.Stopping currentStatus == Status.Stopping -> Status.Stopping !startedByUser -> Status.Stopped + currentStatus == Status.Starting -> Status.Starting requiresActiveVpnNetwork && !hasActiveVpnNetwork -> Status.Starting else -> Status.Started } diff --git a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/XrayTrafficEventPolicy.kt b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/XrayTrafficEventPolicy.kt new file mode 100644 index 0000000..e4b049e --- /dev/null +++ b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/XrayTrafficEventPolicy.kt @@ -0,0 +1,55 @@ +package com.tecclub.flutter_singbox + +import com.tecclub.flutter_singbox.constant.Status +import com.tecclub.flutter_singbox.constant.TrafficStats + +internal data class XrayUidTrafficSample( + val txTotal: Long, + val rxTotal: Long, + val txSpeed: Long, + val rxSpeed: Long, + val sessionTx: Long, + val sessionRx: Long, +) + +internal object XrayTrafficEventPolicy { + fun shouldCollect(status: Status, isXrayRuntime: Boolean): Boolean = + status == Status.Started && isXrayRuntime + + fun build( + sample: XrayUidTrafficSample, + networkSnapshot: Map, + active: Boolean = true, + ): Map { + val uplinkSpeed = if (active) sample.txSpeed.coerceAtLeast(0L) else 0L + val downlinkSpeed = if (active) sample.rxSpeed.coerceAtLeast(0L) else 0L + val uplinkTotal = sample.txTotal.coerceAtLeast(0L) + val downlinkTotal = sample.rxTotal.coerceAtLeast(0L) + val sessionUplink = sample.sessionTx.coerceAtLeast(0L) + val sessionDownlink = sample.sessionRx.coerceAtLeast(0L) + val sessionTotal = sessionUplink + sessionDownlink + + return networkSnapshot.toMutableMap().apply { + putAll( + mapOf( + "uplinkSpeed" to uplinkSpeed, + "downlinkSpeed" to downlinkSpeed, + "uplinkTotal" to uplinkTotal, + "downlinkTotal" to downlinkTotal, + "connectionsIn" to 0, + "connectionsOut" to 0, + "sessionUplink" to sessionUplink, + "sessionDownlink" to sessionDownlink, + "sessionTotal" to sessionTotal, + "formattedUplinkSpeed" to TrafficStats.formatBytes(uplinkSpeed) + "/s", + "formattedDownlinkSpeed" to TrafficStats.formatBytes(downlinkSpeed) + "/s", + "formattedUplinkTotal" to TrafficStats.formatBytes(uplinkTotal), + "formattedDownlinkTotal" to TrafficStats.formatBytes(downlinkTotal), + "formattedSessionUplink" to TrafficStats.formatBytes(sessionUplink), + "formattedSessionDownlink" to TrafficStats.formatBytes(sessionDownlink), + "formattedSessionTotal" to TrafficStats.formatBytes(sessionTotal), + ), + ) + } + } +} diff --git a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/BoxService.kt b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/BoxService.kt index dce8514..eeb2657 100644 --- a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/BoxService.kt +++ b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/BoxService.kt @@ -5,12 +5,14 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.net.Network import android.os.Build import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.ParcelFileDescriptor import android.os.PowerManager +import android.os.SystemClock import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.lifecycle.MutableLiveData @@ -36,7 +38,10 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -45,9 +50,11 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.net.InetSocketAddress import java.net.Socket +import java.security.MessageDigest +import java.util.concurrent.atomic.AtomicLong import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory -import java.security.MessageDigest +import kotlin.system.exitProcess class BoxService( private val service: Service, private val platformInterface: PlatformInterface @@ -61,16 +68,24 @@ class BoxService( private const val WATCHDOG_INTERVAL_MS = 60_000L private const val WATCHDOG_IDLE_INTERVAL_MS = 90_000L private const val WATCHDOG_RESTART_COOLDOWN_MS = 90_000L - private const val WATCHDOG_FAILURE_LIMIT = 3 + private const val WATCHDOG_FLAP_WINDOW_MS = 5 * 60 * 1000L + private const val WATCHDOG_FLAP_RESTART_THRESHOLD = 4 + private const val READINESS_INITIAL_DELAY_MS = 1_500L + private const val READINESS_RETRY_DELAY_MS = 3_000L + private const val READINESS_BACKGROUND_RETRY_MS = 20_000L + private const val READINESS_PROBE_ATTEMPTS = 2 + private const val READINESS_STARTUP_RESTART_GRACE_MS = 30_000L + private const val HEALTH_CONNECT_TIMEOUT_MS = 2_000 + private const val HEALTH_PROXY_RESPONSE_TIMEOUT_MS = 3_000 + private const val HEALTH_TLS_TIMEOUT_MS = 4_000 private const val KEEPER_WAKE_LOCK_MS = 10 * 60 * 1000L private const val STICKY_RESTART_DELAY_MS = 2_500L private const val NETWORK_SETTLE_DELAY_MS = 6_000L private const val WAKE_SETTLE_DELAY_MS = 3_000L private const val NETWORK_WAKE_DEBOUNCE_MS = 5_000L private const val NETWORK_WAKE_GRACE_MS = 45_000L - private const val NETWORK_WAKE_PROBE_ATTEMPTS = 3 - private const val NETWORK_WAKE_PROBE_DELAY_MS = 4_000L private const val LIFECYCLE_RECOVERY_TIMEOUT_MS = 30_000L + private const val CORE_PROCESS_EXIT_DELAY_MS = 600L fun start() { val intent = Intent(Application.application, Settings.serviceClass()).apply { @@ -92,8 +107,8 @@ class BoxService( private val status = MutableLiveData(Status.Stopped) private val binder = ServiceBinder(status) // We're using StatusClient now for traffic stats - private val notification: ServiceNotification by lazy { - ServiceNotification(status, service) + private val notification: ServiceNotification by lazy { + ServiceNotification(service) } private var commandServer: CommandServer? = null private var xrayRunner: XrayRunner? = null @@ -102,16 +117,26 @@ class BoxService( private val lifecycleMutex = Mutex() private var lifecycleJob: Job? = null private var watchdogJob: Job? = null + @Volatile private var readinessJob: Job? = null + private val readinessRevision = AtomicLong(0L) private var watchdogFailures = 0 + private val watchdogFlapDetector = TunnelFlapDetector( + threshold = WATCHDOG_FLAP_RESTART_THRESHOLD, + windowMs = WATCHDOG_FLAP_WINDOW_MS, + ) private var watchdogMixedProxyEnabled = false - private var lastWatchdogRestartAt = 0L private var lastNetworkWakeEventAt = 0L private var lastStartAttemptAt = 0L + private var lastStartAttemptAtElapsed = 0L private var lastStopAttemptAt = 0L @Volatile private var watchdogRestarting = false + @Volatile private var stickyRestartScheduled = false + @Volatile private var cleanProcessRestartScheduled = false + @Volatile private var lastHealthyDefaultNetwork: Network? = null private var keeperWakeLock: PowerManager.WakeLock? = null private var receiverRegistered = false private var lastConfigFingerprint: String? = null + private var activeRuntimeCore: VpnRuntimeCore? = null private val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { @@ -164,7 +189,30 @@ class BoxService( this.commandServer = commandServer } - private var lastProfileName = "" + private fun closeCommandServer(reason: String) { + val staleCommandServer = commandServer ?: return + commandServer = null + runCatching { + staleCommandServer.closeService() + }.onFailure { + android.util.Log.e("BoxService", "$reason: closeService failed", it) + } + runCatching { + staleCommandServer.close() + }.onFailure { + android.util.Log.e("BoxService", "$reason: command server close failed", it) + } + } + + @Volatile private var lastProfileName = "" + + private fun currentProfileName(): String { + val persistedName = SimpleConfigManager.getActiveProfileName() + if (persistedName.isNotBlank()) { + lastProfileName = persistedName + } + return lastProfileName.ifBlank { "Yurich Connect" } + } private fun currentSessionStatus(): Status = when (sessionState.snapshot().phase) { VpnSessionPhase.Stopped, @@ -239,8 +287,16 @@ class BoxService( startXrayService(xrayConfig.configJson, generation) return } + activeRuntimeCore = VpnRuntimeCore.SingBox + + Application.ensureLibboxInitialized(service.applicationContext) + startCommandServer() + val activeCommandServer = checkNotNull(commandServer) { + "Command server is unavailable after initialization" + } - lastProfileName = "Yurich Connect" + lastProfileName = SimpleConfigManager.getActiveProfileName() + .ifBlank { "Yurich Connect" } // withContext(Dispatchers.Main) { // android.util.Log.e("BoxService", "Updating notification with profile name") // // notification.show(lastProfileName, "Starting...") @@ -258,7 +314,7 @@ class BoxService( android.util.Log.e("BoxService", "Config accepted, length: ${content.length}") try { - commandServer?.startOrReloadService(content, OverrideOptions()) + activeCommandServer.startOrReloadService(content, OverrideOptions()) android.util.Log.e("BoxService", "SingBox service started successfully") } catch (e: Exception) { android.util.Log.e("BoxService", "Failed to start SingBox service: ${e.message}", e) @@ -267,27 +323,25 @@ class BoxService( } ensureCurrentSession(generation) - android.util.Log.e("BoxService", "Posting status as Started") - if (!sessionState.markConnected(generation, "sing-box-started")) { - throw CancellationException("Sing-box start completed for stale generation $generation") - } - publishSessionStatus() - - // Start traffic monitoring + android.util.Log.i( + "BoxService", + "Sing-box runtime started; waiting for external tunnel readiness" + ) android.util.Log.e("BoxService", "Starting traffic monitor") startTrafficMonitor() - - android.util.Log.e("BoxService", "Updating notification to Connected") + withContext(Dispatchers.Main) { - notification.show(lastProfileName, "Подключено") + notification.show(currentProfileName(), "Проверка соединения...") } - - android.util.Log.e("BoxService", "Starting notification") - notification.start() refreshKeeperWakeLock("service-start") - startNativeWatchdog() - - android.util.Log.e("BoxService", "Service startup complete") + startReadinessValidation( + generation = generation, + reason = "sing-box-start", + initialDelayMs = READINESS_INITIAL_DELAY_MS, + demoteUntilReady = true, + ) + + android.util.Log.e("BoxService", "Service startup waiting for readiness") } catch (e: CancellationException) { android.util.Log.w("BoxService", "Cancelled stale start generation $generation") throw e @@ -310,9 +364,14 @@ class BoxService( try { ensureCurrentSession(generation) + activeRuntimeCore = VpnRuntimeCore.Xray android.util.Log.e("BoxService", "Starting Xray service...") - lastProfileName = "Yurich Connect XHTTP" - watchdogMixedProxyEnabled = configJson.contains("\"port\": $WATCHDOG_MIXED_PROXY_PORT") + lastProfileName = SimpleConfigManager.getActiveProfileName() + .ifBlank { "Yurich Connect XHTTP" } + watchdogMixedProxyEnabled = XrayRuntimeConfig.exposesHttpProxy( + configJson, + WATCHDOG_MIXED_PROXY_PORT, + ) DefaultNetworkMonitor.setNetworkChangeObserver { handleNetworkWakeEvent("default-network") } @@ -325,16 +384,16 @@ class BoxService( android.util.Log.e("BoxService", "Xray service started: $response") ensureCurrentSession(generation) - if (!sessionState.markConnected(generation, "xray-started")) { - throw CancellationException("Xray start completed for stale generation $generation") - } - publishSessionStatus() withContext(Dispatchers.Main) { - notification.show(lastProfileName, "Подключено") + notification.show(currentProfileName(), "Проверка соединения...") } - notification.start() refreshKeeperWakeLock("xray-service-start") - startNativeWatchdog() + startReadinessValidation( + generation = generation, + reason = "xray-start", + initialDelayMs = READINESS_INITIAL_DELAY_MS, + demoteUntilReady = true, + ) } catch (e: CancellationException) { android.util.Log.w("BoxService", "Cancelled stale Xray start generation $generation") throw e @@ -349,28 +408,29 @@ class BoxService( val reconnect = sessionState.requestReconnect("service-reload") ?: return publishSessionStatus() launchLifecycle(reconnect.generation) { - stopNativeWatchdog() - notification.stop() - stopXrayRunner("serviceReload") - runCatching { - commandServer?.closeService() - }.onFailure { - android.util.Log.e("BoxService", "service: error when closing sing-box on reload", it) - } - runCatching { - DefaultNetworkMonitor.stop() - }.onFailure { - android.util.Log.e("BoxService", "service: error when stopping network monitor on reload", it) - } - closeTunFileDescriptor() - delay(300L) - ensureCurrentSession(reconnect.generation) - startService(reconnect.generation) + recycleVpnService("service-reload", reconnect.generation) + } + } + + private suspend fun recycleVpnService(reason: String, generation: Long) { + ensureCurrentSession(generation) + withContext(Dispatchers.Main) { + restartInCleanProcess(reason) } } override fun serviceStop() { - stopService() + android.util.Log.d("BoxService", "Native service requested runtime stop") + readinessRevision.incrementAndGet() + readinessJob?.cancel() + readinessJob = null + stopNativeWatchdog() + closeTunFileDescriptor() + runCatching { + commandServer?.closeService() + }.onFailure { + android.util.Log.e("BoxService", "Native runtime stop failed", it) + } } override fun writeDebugMessage(message: String) { @@ -619,6 +679,7 @@ class BoxService( val incomingFingerprint = runCatching { configFingerprint(incomingConfig) }.getOrDefault("") + val incomingRuntimeCore = VpnRuntimeCorePolicy.classify(incomingConfig) if (currentStatus != Status.Stopped) { if (shouldRecoverStaleLifecycleState(currentStatus)) { @@ -632,16 +693,15 @@ class BoxService( incomingFingerprint != (lastConfigFingerprint ?: "") if (hasConfigChange) { - android.util.Log.e( - "BoxService", - "Runtime config changed while service is ${currentStatus.name}; reloading" + sessionState.requestReconnect("runtime-config-switch") + publishSessionStatus() + restartInCleanProcess( + "runtime-config-switch:${activeRuntimeCore?.name}->${incomingRuntimeCore.name}" ) - ensureReceiversRegistered() - refreshRunningService("on-start-command-reload") - serviceReload() + return Service.START_NOT_STICKY } else { android.util.Log.e("BoxService", "Service already running, reusing config") - val notificationTitle = lastProfileName.ifBlank { "Yurich Connect" } + val notificationTitle = currentProfileName() val notificationText = if (currentStatus == Status.Started) { "Подключено" } else { @@ -663,6 +723,7 @@ class BoxService( android.util.Log.e("BoxService", "Setting status to Starting") val start = sessionState.requestStart("on-start-command") lastStartAttemptAt = System.currentTimeMillis() + lastStartAttemptAtElapsed = SystemClock.elapsedRealtime() publishSessionStatus() ensureReceiversRegistered() @@ -671,6 +732,11 @@ class BoxService( launchLifecycle(start.generation) { try { val runtimeConfig = XrayRuntimeConfig.from(SimpleConfigManager.getConfig()) + activeRuntimeCore = if (runtimeConfig.enabled) { + VpnRuntimeCore.Xray + } else { + VpnRuntimeCore.SingBox + } if (runtimeConfig.enabled) { android.util.Log.e( "BoxService", @@ -695,6 +761,37 @@ class BoxService( return if (keepRunning) Service.START_STICKY else Service.START_NOT_STICKY } + private fun restartInCleanProcess(reason: String) { + if (cleanProcessRestartScheduled) { + android.util.Log.d("BoxService", "Clean VPN process restart already scheduled") + return + } + cleanProcessRestartScheduled = true + android.util.Log.w( + "BoxService", + "Restarting the VPN process cleanly after $reason", + ) + + readinessRevision.incrementAndGet() + readinessJob?.cancel() + readinessJob = null + stopNativeWatchdog() + releaseKeeperWakeLock() + notification.show("Yurich Connect", "Переключение протокола...") + + service.sendBroadcast( + Intent(service, VpnProcessRestartReceiver::class.java).apply { + action = VpnProcessRestartReceiver.ACTION_RESTART_CLEAN_PROCESS + } + ) + closeTunFileDescriptor() + service.stopSelf() + Handler(Looper.getMainLooper()).postDelayed({ + android.util.Log.i("BoxService", "Exiting old VPN process after $reason") + exitProcess(0) + }, CORE_PROCESS_EXIT_DELAY_MS) + } + private fun ensureReceiversRegistered() { if (receiverRegistered) { return @@ -715,9 +812,23 @@ class BoxService( private fun refreshRunningService(reason: String) { refreshKeeperWakeLock(reason) commandServer?.wake() - if (currentSessionStatus() == Status.Started && watchdogJob?.isActive != true) { + val snapshot = sessionState.snapshot() + if (snapshot.phase == VpnSessionPhase.Connected && watchdogJob?.isActive != true) { android.util.Log.w("BoxService", "Restarting missing watchdog after $reason") startNativeWatchdog() + } else if ( + (snapshot.phase == VpnSessionPhase.Starting || + snapshot.phase == VpnSessionPhase.Reconnecting) && + readinessJob?.isActive != true && + hasActiveRuntime() + ) { + android.util.Log.w("BoxService", "Restarting missing readiness check after $reason") + startReadinessValidation( + generation = snapshot.generation, + reason = reason, + initialDelayMs = READINESS_INITIAL_DELAY_MS, + demoteUntilReady = true, + ) } } @@ -726,9 +837,9 @@ class BoxService( } internal fun onDestroy() { - val destroyStatus = currentSessionStatus() + val destroySnapshot = sessionState.snapshot() val shouldRestore = runCatching { - destroyStatus == Status.Started && + destroySnapshot.desiredRunning && SimpleConfigManager.getStartedByUser() && SimpleConfigManager.hasValidConfig() }.getOrDefault(false) @@ -797,6 +908,141 @@ class BoxService( ) } + private suspend fun markRuntimeReady(generation: Long, reason: String): Boolean { + ensureCurrentSession(generation) + val snapshot = sessionState.snapshot() + val wasConnected = snapshot.phase == VpnSessionPhase.Connected + if (!wasConnected && !sessionState.markConnected(generation, "ready:$reason")) { + return false + } + + lastHealthyDefaultNetwork = DefaultNetworkMonitor.defaultNetwork + watchdogFailures = 0 + if (!wasConnected) { + android.util.Log.i( + "BoxService", + "Tunnel readiness confirmed after $reason; publishing Started" + ) + publishSessionStatus() + withContext(Dispatchers.Main) { + notification.show(currentProfileName(), "Подключено") + } + } + startNativeWatchdog() + return true + } + + private fun startReadinessValidation( + generation: Long, + reason: String, + initialDelayMs: Long, + demoteUntilReady: Boolean, + ) { + if (!sessionState.isCurrent(generation)) { + return + } + + val snapshot = sessionState.snapshot() + if (!watchdogMixedProxyEnabled) { + serviceScope.launch { + markRuntimeReady(generation, "probe-unavailable:$reason") + } + return + } + val shouldDemote = demoteUntilReady && snapshot.phase == VpnSessionPhase.Connected + if (shouldDemote) { + if (!sessionState.markReconnecting(generation, "readiness:$reason")) { + return + } + publishSessionStatus() + } + + cancelPeriodicWatchdog() + val revision = readinessRevision.incrementAndGet() + readinessJob?.cancel() + readinessJob = serviceScope.launch { + var sawDefaultNetwork = false + try { + if (demoteUntilReady || snapshot.phase != VpnSessionPhase.Connected) { + withContext(Dispatchers.Main) { + notification.show(currentProfileName(), "Проверка соединения...") + } + } + + delay(initialDelayMs) + repeat(READINESS_PROBE_ATTEMPTS) { attempt -> + if (!isReadinessCurrent(generation, revision)) { + return@launch + } + + val hasNetwork = hasDefaultNetwork() + sawDefaultNetwork = sawDefaultNetwork || hasNetwork + val probe = if (hasNetwork) probeMixedProxy() else null + val healthy = probe?.healthy == true + if (healthy) { + markRuntimeReady(generation, reason) + return@launch + } + + android.util.Log.w( + "BoxService", + "Readiness probe failed ${attempt + 1}/$READINESS_PROBE_ATTEMPTS after $reason" + ) + if (attempt + 1 < READINESS_PROBE_ATTEMPTS) { + delay(READINESS_RETRY_DELAY_MS) + } + } + + if (!isReadinessCurrent(generation, revision)) { + return@launch + } + withContext(Dispatchers.Main) { + notification.show(currentProfileName(), "Восстановление соединения...") + } + + val startupGraceElapsed = + TunnelReadinessPolicy.canRestartAfterStartupGrace( + nowMs = SystemClock.elapsedRealtime(), + startAttemptAtMs = lastStartAttemptAtElapsed, + graceMs = READINESS_STARTUP_RESTART_GRACE_MS, + ) + if (!startupGraceElapsed) { + android.util.Log.w( + "BoxService", + "Watchdog: restart deferred during runtime startup grace", + ) + } + + serviceScope.launch { + val restarted = sawDefaultNetwork && startupGraceElapsed && + restartFromWatchdog("readiness:$reason") + if (!restarted && sessionState.isCurrent(generation)) { + startReadinessValidation( + generation = generation, + reason = "retry:$reason", + initialDelayMs = READINESS_BACKGROUND_RETRY_MS, + demoteUntilReady = true, + ) + } + } + } finally { + if (readinessRevision.get() == revision) { + readinessJob = null + } + } + } + } + + private fun isReadinessCurrent(generation: Long, revision: Long): Boolean { + return readinessRevision.get() == revision && sessionState.isCurrent(generation) + } + + private fun cancelPeriodicWatchdog() { + watchdogJob?.cancel() + watchdogJob = null + watchdogFailures = 0 + } + private fun startNativeWatchdog() { watchdogJob?.cancel() watchdogFailures = 0 @@ -819,7 +1065,7 @@ class BoxService( watchdogFailures = 0 android.util.Log.w("BoxService", "Watchdog: waiting for default network") withContext(Dispatchers.Main) { - notification.show(lastProfileName, "Ожидание сети...") + notification.show(currentProfileName(), "Ожидание сети...") } delay(15_000L) continue @@ -832,7 +1078,23 @@ class BoxService( continue } - val healthy = probeMixedProxy() + val probe = probeMixedProxy() + val repeatedDegradation = watchdogFlapDetector.record( + nowMs = SystemClock.elapsedRealtime(), + successfulEndpoints = probe.successfulEndpoints, + totalEndpoints = probe.totalEndpoints, + ) + if (repeatedDegradation) { + android.util.Log.w( + "BoxService", + "Watchdog: repeated degraded quorum; restarting VPN runtime" + ) + if (restartFromWatchdog("repeated-degraded-quorum")) { + return@launch + } + } + + val healthy = probe.healthy if (healthy) { if (watchdogFailures > 0) { android.util.Log.d("BoxService", "Watchdog: tunnel recovered") @@ -844,10 +1106,14 @@ class BoxService( "BoxService", "Watchdog: tunnel probe failed #$watchdogFailures" ) - if (watchdogFailures >= WATCHDOG_FAILURE_LIMIT) { - watchdogFailures = 0 - restartFromWatchdog("health-probe") - } + val snapshot = sessionState.snapshot() + startReadinessValidation( + generation = snapshot.generation, + reason = "periodic-health", + initialDelayMs = READINESS_RETRY_DELAY_MS, + demoteUntilReady = true, + ) + return@launch } delay(watchdogDelayMs()) @@ -856,109 +1122,96 @@ class BoxService( } private fun stopNativeWatchdog() { - watchdogJob?.cancel() - watchdogJob = null - watchdogFailures = 0 + cancelPeriodicWatchdog() + readinessRevision.incrementAndGet() + readinessJob?.cancel() + readinessJob = null watchdogRestarting = false + lastHealthyDefaultNetwork = null + watchdogFlapDetector.reset() } private fun handleNetworkWakeEvent(reason: String) { - if (currentSessionStatus() != Status.Started || watchdogRestarting) { + val snapshot = sessionState.snapshot() + if (!snapshot.desiredRunning || + (snapshot.phase != VpnSessionPhase.Connected && + snapshot.phase != VpnSessionPhase.Reconnecting) || + watchdogRestarting + ) { return } + val currentDefaultNetwork = DefaultNetworkMonitor.defaultNetwork + val isDefaultNetworkEvent = reason.contains("default-network", ignoreCase = true) + val networkChanged = isDefaultNetworkEvent && + currentDefaultNetwork != lastHealthyDefaultNetwork + if (networkChanged) { + watchdogFlapDetector.reset() + } + val alreadyValidating = snapshot.phase == VpnSessionPhase.Reconnecting val now = System.currentTimeMillis() - if (now - lastNetworkWakeEventAt < NETWORK_WAKE_DEBOUNCE_MS) { + if (now - lastNetworkWakeEventAt < NETWORK_WAKE_DEBOUNCE_MS && + !networkChanged && + !alreadyValidating + ) { android.util.Log.d("BoxService", "Watchdog: network/wake event debounced: $reason") return } lastNetworkWakeEventAt = now - serviceScope.launch { - android.util.Log.d("BoxService", "Watchdog: network/wake event $reason") - refreshKeeperWakeLock(reason) - commandServer?.wake() - - // The monitor callback has already selected the new network and - // scheduled libbox's interface update. Restarting it here drops - // the InterfaceUpdateListener and cancels that pending update. - - if (watchdogJob?.isActive != true) { - startNativeWatchdog() - } - - if (!watchdogMixedProxyEnabled) { - return@launch - } - - delay(settleDelayFor(reason)) - if (currentSessionStatus() != Status.Started || !hasDefaultNetwork()) { - return@launch - } + android.util.Log.d( + "BoxService", + "Watchdog: network/wake event $reason, changed=$networkChanged" + ) + refreshKeeperWakeLock(reason) + commandServer?.wake() - repeat(NETWORK_WAKE_PROBE_ATTEMPTS) { attempt -> - if (currentSessionStatus() != Status.Started || !hasDefaultNetwork()) { - return@launch - } - if (probeMixedProxy()) { - watchdogFailures = 0 - android.util.Log.d( - "BoxService", - "Watchdog: network/wake probe recovered after $reason" - ) - return@launch - } - android.util.Log.w( - "BoxService", - "Watchdog: network/wake probe failed ${attempt + 1}/$NETWORK_WAKE_PROBE_ATTEMPTS after $reason" - ) - if (attempt + 1 < NETWORK_WAKE_PROBE_ATTEMPTS) { - delay(NETWORK_WAKE_PROBE_DELAY_MS) - } - } - restartFromWatchdog(reason) - } + // A changed Android default network invalidates the previous outbound + // readiness result even while the VPN NetworkAgent remains VALIDATED. + startReadinessValidation( + generation = snapshot.generation, + reason = reason, + initialDelayMs = settleDelayFor(reason), + demoteUntilReady = networkChanged || alreadyValidating, + ) } - private suspend fun restartFromWatchdog(reason: String) { - val now = System.currentTimeMillis() - if (watchdogRestarting || now - lastWatchdogRestartAt < WATCHDOG_RESTART_COOLDOWN_MS) { + private suspend fun restartFromWatchdog(reason: String): Boolean { + val now = SystemClock.elapsedRealtime() + val lastRestartAt = SimpleConfigManager.getLastWatchdogRestartAt() + if (watchdogRestarting || + !TunnelReadinessPolicy.canRestart( + nowMs = now, + lastRestartAtMs = lastRestartAt, + cooldownMs = WATCHDOG_RESTART_COOLDOWN_MS, + ) + ) { android.util.Log.w("BoxService", "Watchdog: restart skipped by cooldown") - return + return false } + watchdogRestarting = true + if (!SimpleConfigManager.setLastWatchdogRestartAt(now)) { + watchdogRestarting = false + android.util.Log.e("BoxService", "Watchdog: unable to persist restart cooldown") + return false + } val reconnect = sessionState.requestReconnect("watchdog:$reason") ?: run { + SimpleConfigManager.setLastWatchdogRestartAt(lastRestartAt) + watchdogRestarting = false android.util.Log.w("BoxService", "Watchdog: restart rejected by session state") - return + return false } - watchdogRestarting = true - lastWatchdogRestartAt = now publishSessionStatus() try { lifecycleMutex.withLock { ensureCurrentSession(reconnect.generation) - android.util.Log.w("BoxService", "Watchdog: restarting sing-box after $reason") + android.util.Log.w("BoxService", "Watchdog: restarting VPN runtime after $reason") refreshKeeperWakeLock("watchdog-restart") withContext(Dispatchers.Main) { - notification.show(lastProfileName, "Восстановление соединения...") - } - - stopXrayRunner("watchdog-restart") - runCatching { - commandServer?.closeService() - }.onFailure { - android.util.Log.e("BoxService", "Watchdog: closeService failed", it) + notification.show(currentProfileName(), "Восстановление соединения...") } - try { - DefaultNetworkMonitor.stop() - } catch (e: Exception) { - android.util.Log.e("BoxService", "Watchdog: network monitor stop failed", e) - } - closeTunFileDescriptor() - - delay(900L) - ensureCurrentSession(reconnect.generation) - startService(reconnect.generation) + recycleVpnService("watchdog:$reason", reconnect.generation) } } catch (e: CancellationException) { android.util.Log.w("BoxService", "Watchdog restart cancelled for stale generation") @@ -966,69 +1219,98 @@ class BoxService( } finally { watchdogRestarting = false } + return true } - private fun probeMixedProxy(): Boolean { + private data class TunnelProbeResult( + val successfulEndpoints: Int, + val totalEndpoints: Int, + ) { + val healthy: Boolean + get() = TunnelReadinessPolicy.isHealthy( + successfulEndpoints = successfulEndpoints, + totalEndpoints = totalEndpoints, + ) + } + + private suspend fun probeMixedProxy(): TunnelProbeResult = coroutineScope { val targets = arrayOf( - "cp.cloudflare.com" to "/generate_204", - "www.gstatic.com" to "/generate_204", - "connectivitycheck.gstatic.com" to "/generate_204" + "www.cloudflare.com" to "/cdn-cgi/trace", + "connectivitycheck.gstatic.com" to "/generate_204", + "www.google.com" to "/generate_204", + ) + val results = targets.map { (host, path) -> + async(Dispatchers.IO) { + probeMixedProxyEndpoint(host, path) + } + }.awaitAll() + val successfulEndpoints = results.count { it } + val result = TunnelProbeResult( + successfulEndpoints = successfulEndpoints, + totalEndpoints = targets.size, ) + android.util.Log.d( + "BoxService", + "External readiness quorum: $successfulEndpoints/${targets.size}, healthy=${result.healthy}" + ) + result + } - for ((host, path) in targets) { - var rawSocket: Socket? = null - var tlsSocket: SSLSocket? = null - try { - rawSocket = Socket() - rawSocket.connect( - InetSocketAddress("127.0.0.1", WATCHDOG_MIXED_PROXY_PORT), - 2500 - ) - rawSocket.soTimeout = 3500 - val connectRequest = "CONNECT $host:443 HTTP/1.1\r\n" + - "Host: $host:443\r\n" + - "Connection: close\r\n\r\n" - rawSocket.getOutputStream().write(connectRequest.toByteArray(Charsets.US_ASCII)) - rawSocket.getOutputStream().flush() - val connectReader = rawSocket.getInputStream().bufferedReader(Charsets.US_ASCII) - val connectStatus = connectReader.readLine() - if (connectStatus?.contains(" 200 ") != true) { - android.util.Log.w("BoxService", "Watchdog CONNECT status: $connectStatus") - continue - } - while (true) { - val header = connectReader.readLine() ?: break - if (header.isEmpty()) break - } + private fun probeMixedProxyEndpoint(host: String, path: String): Boolean { + var rawSocket: Socket? = null + var tlsSocket: SSLSocket? = null + try { + rawSocket = Socket() + rawSocket.connect( + InetSocketAddress("127.0.0.1", WATCHDOG_MIXED_PROXY_PORT), + HEALTH_CONNECT_TIMEOUT_MS, + ) + rawSocket.soTimeout = HEALTH_PROXY_RESPONSE_TIMEOUT_MS + val connectRequest = "CONNECT $host:443 HTTP/1.1\r\n" + + "Host: $host:443\r\n" + + "Connection: close\r\n\r\n" + rawSocket.getOutputStream().write(connectRequest.toByteArray(Charsets.US_ASCII)) + rawSocket.getOutputStream().flush() + val connectReader = rawSocket.getInputStream().bufferedReader(Charsets.US_ASCII) + val connectStatus = connectReader.readLine() + if (connectStatus?.contains(" 200 ") != true) { + android.util.Log.w("BoxService", "Watchdog CONNECT status for $host: $connectStatus") + return false + } + while (true) { + val header = connectReader.readLine() ?: break + if (header.isEmpty()) break + } - tlsSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) - .createSocket(rawSocket, host, 443, true) as SSLSocket - tlsSocket.soTimeout = 4500 - tlsSocket.startHandshake() - val request = "GET $path HTTP/1.1\r\n" + - "Host: $host\r\n" + - "User-Agent: YurichConnectNativeKeeper/1\r\n" + - "Connection: close\r\n\r\n" - tlsSocket.getOutputStream().write(request.toByteArray(Charsets.US_ASCII)) - tlsSocket.getOutputStream().flush() - val responseStatus = tlsSocket.getInputStream() - .bufferedReader(Charsets.US_ASCII) - .readLine() - if (responseStatus?.startsWith("HTTP/") == true && - (responseStatus.contains(" 204 ") || responseStatus.contains(" 200 ")) - ) { - return true - } - android.util.Log.w("BoxService", "Watchdog HTTPS status: $responseStatus") - } catch (e: Exception) { - android.util.Log.w("BoxService", "Watchdog probe failed for $host: ${e.message}") - } finally { - runCatching { tlsSocket?.close() } - runCatching { rawSocket?.close() } + tlsSocket = (SSLSocketFactory.getDefault() as SSLSocketFactory) + .createSocket(rawSocket, host, 443, true) as SSLSocket + tlsSocket.soTimeout = HEALTH_TLS_TIMEOUT_MS + tlsSocket.sslParameters = tlsSocket.sslParameters.apply { + endpointIdentificationAlgorithm = "HTTPS" + } + tlsSocket.startHandshake() + val request = "GET $path HTTP/1.1\r\n" + + "Host: $host\r\n" + + "User-Agent: YurichConnectNativeKeeper/2\r\n" + + "Connection: close\r\n\r\n" + tlsSocket.getOutputStream().write(request.toByteArray(Charsets.US_ASCII)) + tlsSocket.getOutputStream().flush() + val responseStatus = tlsSocket.getInputStream() + .bufferedReader(Charsets.US_ASCII) + .readLine() + val successful = responseStatus?.startsWith("HTTP/") == true && + (responseStatus.contains(" 204 ") || responseStatus.contains(" 200 ")) + if (!successful) { + android.util.Log.w("BoxService", "Watchdog HTTPS status for $host: $responseStatus") } + return successful + } catch (e: Exception) { + android.util.Log.w("BoxService", "Watchdog probe failed for $host: ${e.message}") + return false + } finally { + runCatching { tlsSocket?.close() } + runCatching { rawSocket?.close() } } - - return false } private fun hasDefaultNetwork(): Boolean { @@ -1106,8 +1388,14 @@ class BoxService( } private fun scheduleStickyRestart(reason: String) { + if (stickyRestartScheduled) { + android.util.Log.d("BoxService", "Sticky restart already scheduled") + return + } + stickyRestartScheduled = true android.util.Log.w("BoxService", "Scheduling sticky restart after $reason") Handler(Looper.getMainLooper()).postDelayed({ + stickyRestartScheduled = false val shouldRestore = runCatching { SimpleConfigManager.getStartedByUser() && SimpleConfigManager.hasValidConfig() }.getOrDefault(false) @@ -1118,6 +1406,7 @@ class BoxService( val intent = Intent(service.applicationContext, Settings.serviceClass()).apply { action = ACTION_START + putExtra(EXTRA_CONFIG_CONTENT, SimpleConfigManager.getConfig()) } ContextCompat.startForegroundService(service.applicationContext, intent) }, STICKY_RESTART_DELAY_MS) diff --git a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/ServiceNotification.kt b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/ServiceNotification.kt index 43ba239..f302df7 100644 --- a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/ServiceNotification.kt +++ b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/ServiceNotification.kt @@ -3,12 +3,9 @@ package com.tecclub.flutter_singbox.bg import android.app.Service import android.content.pm.ServiceInfo import android.os.Build -import com.tecclub.flutter_singbox.constant.Status -import androidx.lifecycle.MutableLiveData import com.tecclub.flutter_singbox.model.ConnectionUiState class ServiceNotification( - private val statusLiveData: MutableLiveData, private val service: Service ) { companion object { @@ -58,15 +55,7 @@ class ServiceNotification( helper.updateNotification(state) } - fun start() { - // This method is called when the service is successfully started - statusLiveData.postValue(Status.Started) - } - fun stop() { - // This method is called when the service is stopping - statusLiveData.postValue(Status.Stopped) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { service.stopForeground(Service.STOP_FOREGROUND_REMOVE) } else { diff --git a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/TunnelFlapDetector.kt b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/TunnelFlapDetector.kt new file mode 100644 index 0000000..ce45806 --- /dev/null +++ b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/TunnelFlapDetector.kt @@ -0,0 +1,47 @@ +package com.tecclub.flutter_singbox.bg + +import java.util.ArrayDeque + +internal class TunnelFlapDetector( + private val threshold: Int, + private val windowMs: Long, +) { + private val degradedProbeTimes = ArrayDeque() + + init { + require(threshold > 0) { "threshold must be positive" } + require(windowMs >= 0L) { "windowMs must not be negative" } + } + + @Synchronized + fun record( + nowMs: Long, + successfulEndpoints: Int, + totalEndpoints: Int, + ): Boolean { + prune(nowMs) + if (totalEndpoints <= 0 || successfulEndpoints >= totalEndpoints) { + return false + } + + degradedProbeTimes.addLast(nowMs) + if (degradedProbeTimes.size < threshold) { + return false + } + + degradedProbeTimes.clear() + return true + } + + @Synchronized + fun reset() { + degradedProbeTimes.clear() + } + + private fun prune(nowMs: Long) { + val cutoff = nowMs - windowMs + while (degradedProbeTimes.isNotEmpty() && degradedProbeTimes.first < cutoff) { + degradedProbeTimes.removeFirst() + } + } +} diff --git a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/TunnelReadinessPolicy.kt b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/TunnelReadinessPolicy.kt new file mode 100644 index 0000000..2ec1435 --- /dev/null +++ b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/TunnelReadinessPolicy.kt @@ -0,0 +1,34 @@ +package com.tecclub.flutter_singbox.bg + +internal object TunnelReadinessPolicy { + const val REQUIRED_ENDPOINT_SUCCESSES = 2 + + fun isHealthy(successfulEndpoints: Int, totalEndpoints: Int): Boolean { + if (totalEndpoints < REQUIRED_ENDPOINT_SUCCESSES) { + return false + } + return successfulEndpoints >= REQUIRED_ENDPOINT_SUCCESSES + } + + fun canRestart(nowMs: Long, lastRestartAtMs: Long, cooldownMs: Long): Boolean { + if (cooldownMs < 0L) { + return false + } + return lastRestartAtMs == 0L || + nowMs < lastRestartAtMs || + nowMs - lastRestartAtMs >= cooldownMs + } + + fun canRestartAfterStartupGrace( + nowMs: Long, + startAttemptAtMs: Long, + graceMs: Long, + ): Boolean { + if (graceMs < 0L) { + return false + } + return startAttemptAtMs == 0L || + nowMs < startAttemptAtMs || + nowMs - startAttemptAtMs >= graceMs + } +} diff --git a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/VPNService.kt b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/VPNService.kt index 6a7537f..f5dee6a 100644 --- a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/VPNService.kt +++ b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/VPNService.kt @@ -18,12 +18,15 @@ import com.tecclub.flutter_singbox.ktx.toList import com.tecclub.flutter_singbox.database.Settings import io.nekohasekai.libbox.Libbox import java.io.FileDescriptor +import java.util.concurrent.atomic.AtomicInteger class VPNService : VpnService(), PlatformInterfaceWrapper { companion object { private const val TAG = "VPNService" + private const val PROTECT_LOG_LIMIT = 3 private val XRAY_TUN_DNS_SERVERS = listOf("1.1.1.1", "8.8.8.8") + private val protectCallCount = AtomicInteger(0) } private val service = BoxService(this, this) @@ -80,14 +83,26 @@ class VPNService : VpnService(), PlatformInterfaceWrapper { } override fun autoDetectInterfaceControl(fd: Int) { - android.util.Log.d("VPNService", "autoDetectInterfaceControl called with fd=$fd") val result = protect(fd) - android.util.Log.d("VPNService", "protect($fd) returned $result") + val call = protectCallCount.incrementAndGet() + if (call <= PROTECT_LOG_LIMIT || !result) { + val message = "sing-box protect call=$call fd=$fd protected=$result" + if (result) { + Log.d(TAG, message) + } else { + Log.w(TAG, message) + } + } } override fun openTun(options: TunOptions): Int { if (prepare(this) != null) error("android: missing vpn permission") + // A reload can reach openTun before the previous native service has released + // its descriptor. Keep only one Android VPN interface across profile switches. + service.fileDescriptor?.close() + service.fileDescriptor = null + val builder = Builder() .setSession("sing-box") .setMtu(options.mtu) diff --git a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/VpnProcessRestartReceiver.kt b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/VpnProcessRestartReceiver.kt new file mode 100644 index 0000000..f9eb39a --- /dev/null +++ b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/VpnProcessRestartReceiver.kt @@ -0,0 +1,56 @@ +package com.tecclub.flutter_singbox.bg + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.content.ContextCompat +import com.tecclub.flutter_singbox.Application +import com.tecclub.flutter_singbox.config.SimpleConfigManager +import com.tecclub.flutter_singbox.database.Settings +import kotlin.concurrent.thread + +class VpnProcessRestartReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION_RESTART_CLEAN_PROCESS) { + return + } + + val pendingResult = goAsync() + val appContext = context.applicationContext + thread(name = "yurich-vpn-core-switch") { + try { + Thread.sleep(RESTART_DELAY_MS) + Application.initializeIfNeeded(appContext) + val shouldRestart = SimpleConfigManager.getStartedByUser(appContext) && + SimpleConfigManager.hasValidConfig(appContext) + if (!shouldRestart) { + Log.w(TAG, "Clean VPN process restart skipped: user flag/config missing") + return@thread + } + + val serviceIntent = Intent(appContext, Settings.serviceClass()).apply { + action = BoxService.ACTION_START + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ContextCompat.startForegroundService(appContext, serviceIntent) + } else { + appContext.startService(serviceIntent) + } + Log.i(TAG, "Clean VPN process restart requested") + } catch (error: Throwable) { + Log.e(TAG, "Unable to restart VPN in a clean process", error) + } finally { + pendingResult.finish() + } + } + } + + companion object { + const val ACTION_RESTART_CLEAN_PROCESS = + "com.tecclub.flutter_singbox.action.RESTART_CLEAN_VPN_PROCESS" + private const val TAG = "VpnProcessRestart" + private const val RESTART_DELAY_MS = 1_800L + } +} diff --git a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/VpnRuntimeCorePolicy.kt b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/VpnRuntimeCorePolicy.kt new file mode 100644 index 0000000..fc9012c --- /dev/null +++ b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/bg/VpnRuntimeCorePolicy.kt @@ -0,0 +1,17 @@ +package com.tecclub.flutter_singbox.bg + +import com.tecclub.flutter_singbox.xray.XrayRuntimeConfig + +internal enum class VpnRuntimeCore { + SingBox, + Xray, +} + +internal object VpnRuntimeCorePolicy { + fun classify(config: String): VpnRuntimeCore { + val isXray = runCatching { XrayRuntimeConfig.isXray(config) } + .getOrDefault(false) + return if (isXray) VpnRuntimeCore.Xray else VpnRuntimeCore.SingBox + } + +} diff --git a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/config/SimpleConfigManager.kt b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/config/SimpleConfigManager.kt index 8697889..3ba7bc7 100644 --- a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/config/SimpleConfigManager.kt +++ b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/config/SimpleConfigManager.kt @@ -6,11 +6,14 @@ import com.tecclub.flutter_singbox.Application object SimpleConfigManager { private const val TAG = "SimpleConfigManager" - private const val PREF_NAME = "singbox_config" + private const val STATE_PREF_NAME = "singbox_config" + private const val CONFIG_PREF_NAME = "singbox_runtime_config" private const val KEY_CONFIG = "config_json" private const val KEY_AUTO_START = "auto_start" private const val KEY_STARTED_BY_USER = "started_by_user" private const val KEY_MANUAL_DISCONNECT_REQUESTED = "manual_disconnect_requested" + private const val KEY_LAST_WATCHDOG_RESTART_AT = "last_watchdog_restart_at" + private const val KEY_ACTIVE_PROFILE_NAME = "active_profile_name" private const val KEY_NOTIFICATION_TITLE = "notification_title" private const val KEY_NOTIFICATION_DESCRIPTION = "notification_description" private const val DEFAULT_CONFIG = "{}" @@ -44,7 +47,7 @@ object SimpleConfigManager { if (config.isBlank()) return false return try { - val prefs = Application.application.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val prefs = Application.application.getSharedPreferences(CONFIG_PREF_NAME, Context.MODE_PRIVATE) val encrypted = SecureConfigCipher.encrypt(config) if (!prefs.edit().putString(KEY_CONFIG, encrypted).commit()) { Log.e(TAG, "Config commit returned false") @@ -59,6 +62,7 @@ object SimpleConfigManager { } // Get current config JSON string + @Synchronized fun getConfig(): String { Log.e(TAG, "Getting config") @@ -70,9 +74,8 @@ object SimpleConfigManager { // Otherwise load from preferences try { - val prefs = Application.application.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - val stored = prefs.getString(KEY_CONFIG, DEFAULT_CONFIG) ?: DEFAULT_CONFIG - val config = decodeStoredConfig(prefs, stored) + val context = Application.application + val config = loadPersistedConfig(context) cachedConfig = config Log.e(TAG, "Config loaded from preferences, length: ${config.length}") @@ -91,9 +94,7 @@ object SimpleConfigManager { fun hasValidConfig(context: Context): Boolean { return try { - val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - val stored = prefs.getString(KEY_CONFIG, DEFAULT_CONFIG) ?: DEFAULT_CONFIG - val config = decodeStoredConfig(prefs, stored) + val config = loadPersistedConfig(context) config.isNotEmpty() && config != DEFAULT_CONFIG } catch (e: Exception) { Log.e(TAG, "Failed to check config with context", e) @@ -101,6 +102,31 @@ object SimpleConfigManager { } } + private fun loadPersistedConfig(context: Context): String { + val configPrefs = context.getSharedPreferences(CONFIG_PREF_NAME, Context.MODE_PRIVATE) + val stored = configPrefs.getString(KEY_CONFIG, DEFAULT_CONFIG) ?: DEFAULT_CONFIG + if (stored.isNotEmpty() && stored != DEFAULT_CONFIG) { + return decodeStoredConfig(configPrefs, stored) + } + + // Older releases kept runtime secrets and service flags in one preference + // file. A flag commit from the separate :vpn process could then restore an + // older config. Migrate once into a config-only file to make writes isolated. + val legacyPrefs = context.getSharedPreferences(STATE_PREF_NAME, Context.MODE_PRIVATE) + val legacyStored = legacyPrefs.getString(KEY_CONFIG, DEFAULT_CONFIG) ?: DEFAULT_CONFIG + if (legacyStored.isEmpty() || legacyStored == DEFAULT_CONFIG) { + return DEFAULT_CONFIG + } + + val config = decodeStoredConfig(legacyPrefs, legacyStored) + val encrypted = SecureConfigCipher.encrypt(config) + check(configPrefs.edit().putString(KEY_CONFIG, encrypted).commit()) { + "Unable to migrate runtime config into isolated storage" + } + Log.i(TAG, "Migrated runtime config into isolated storage") + return config + } + private fun decodeStoredConfig( prefs: android.content.SharedPreferences, stored: String, @@ -123,7 +149,7 @@ object SimpleConfigManager { // Set auto-start setting fun setAutoStart(enabled: Boolean) { try { - val prefs = Application.application.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val prefs = Application.application.getSharedPreferences(STATE_PREF_NAME, Context.MODE_PRIVATE) prefs.edit().putBoolean(KEY_AUTO_START, enabled).apply() Log.d(TAG, "Auto-start set to: $enabled") } catch (e: Exception) { @@ -134,7 +160,7 @@ object SimpleConfigManager { // Get auto-start setting fun getAutoStart(): Boolean { return try { - val prefs = Application.application.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val prefs = Application.application.getSharedPreferences(STATE_PREF_NAME, Context.MODE_PRIVATE) prefs.getBoolean(KEY_AUTO_START, false) } catch (e: UninitializedPropertyAccessException) { Log.w(TAG, "Application not initialized, cannot get auto-start setting") @@ -145,7 +171,7 @@ object SimpleConfigManager { // Set notification title fun setNotificationTitle(title: String) { try { - val prefs = Application.application.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val prefs = Application.application.getSharedPreferences(STATE_PREF_NAME, Context.MODE_PRIVATE) prefs.edit().putString(KEY_NOTIFICATION_TITLE, title).apply() Log.d(TAG, "Notification title set to: $title") } catch (e: Exception) { @@ -156,7 +182,7 @@ object SimpleConfigManager { // Get notification title fun getNotificationTitle(): String { return try { - val prefs = Application.application.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val prefs = Application.application.getSharedPreferences(STATE_PREF_NAME, Context.MODE_PRIVATE) prefs.getString(KEY_NOTIFICATION_TITLE, DEFAULT_NOTIFICATION_TITLE) ?: DEFAULT_NOTIFICATION_TITLE } catch (e: Exception) { Log.e(TAG, "Failed to get notification title", e) @@ -167,7 +193,7 @@ object SimpleConfigManager { // Set notification description fun setNotificationDescription(description: String) { try { - val prefs = Application.application.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val prefs = Application.application.getSharedPreferences(STATE_PREF_NAME, Context.MODE_PRIVATE) prefs.edit().putString(KEY_NOTIFICATION_DESCRIPTION, description).apply() Log.d(TAG, "Notification description set to: $description") } catch (e: Exception) { @@ -178,7 +204,7 @@ object SimpleConfigManager { // Get notification description fun getNotificationDescription(): String { return try { - val prefs = Application.application.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val prefs = Application.application.getSharedPreferences(STATE_PREF_NAME, Context.MODE_PRIVATE) prefs.getString(KEY_NOTIFICATION_DESCRIPTION, DEFAULT_NOTIFICATION_DESCRIPTION) ?: DEFAULT_NOTIFICATION_DESCRIPTION } catch (e: Exception) { Log.e(TAG, "Failed to get notification description", e) @@ -189,7 +215,7 @@ object SimpleConfigManager { // Get auto-start setting with context (for use before Application is initialized) fun getAutoStart(context: Context): Boolean { return try { - val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val prefs = context.getSharedPreferences(STATE_PREF_NAME, Context.MODE_PRIVATE) prefs.getBoolean(KEY_AUTO_START, false) } catch (e: Exception) { Log.e(TAG, "Failed to get auto-start setting", e) @@ -200,7 +226,7 @@ object SimpleConfigManager { // Set started by user flag fun setStartedByUser(started: Boolean) { try { - val prefs = Application.application.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val prefs = Application.application.getSharedPreferences(STATE_PREF_NAME, Context.MODE_PRIVATE) if (!prefs.edit().putBoolean(KEY_STARTED_BY_USER, started).commit()) { Log.w(TAG, "Started-by-user commit returned false") } @@ -211,13 +237,13 @@ object SimpleConfigManager { // Get started by user flag fun getStartedByUser(): Boolean { - val prefs = Application.application.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val prefs = Application.application.getSharedPreferences(STATE_PREF_NAME, Context.MODE_PRIVATE) return prefs.getBoolean(KEY_STARTED_BY_USER, false) } fun getStartedByUser(context: Context): Boolean { return try { - val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val prefs = context.getSharedPreferences(STATE_PREF_NAME, Context.MODE_PRIVATE) prefs.getBoolean(KEY_STARTED_BY_USER, false) } catch (e: Exception) { Log.e(TAG, "Failed to get started-by-user setting with context", e) @@ -227,7 +253,7 @@ object SimpleConfigManager { fun setManualDisconnectRequested(requested: Boolean) { try { - val prefs = Application.application.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val prefs = Application.application.getSharedPreferences(STATE_PREF_NAME, Context.MODE_PRIVATE) if (!prefs.edit().putBoolean(KEY_MANUAL_DISCONNECT_REQUESTED, requested).commit()) { Log.w(TAG, "Manual-disconnect commit returned false") } @@ -238,7 +264,7 @@ object SimpleConfigManager { fun getManualDisconnectRequested(): Boolean? { return try { - val prefs = Application.application.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + val prefs = Application.application.getSharedPreferences(STATE_PREF_NAME, Context.MODE_PRIVATE) if (!prefs.contains(KEY_MANUAL_DISCONNECT_REQUESTED)) { null } else { @@ -249,4 +275,60 @@ object SimpleConfigManager { null } } + + fun setLastWatchdogRestartAt(timestampMs: Long): Boolean { + return try { + val prefs = Application.application.getSharedPreferences( + STATE_PREF_NAME, + Context.MODE_PRIVATE, + ) + prefs.edit().putLong(KEY_LAST_WATCHDOG_RESTART_AT, timestampMs).commit() + } catch (e: Exception) { + Log.e(TAG, "Failed to persist watchdog restart timestamp", e) + false + } + } + + fun getLastWatchdogRestartAt(): Long { + return try { + val prefs = Application.application.getSharedPreferences( + STATE_PREF_NAME, + Context.MODE_PRIVATE, + ) + prefs.getLong(KEY_LAST_WATCHDOG_RESTART_AT, 0L) + } catch (e: Exception) { + Log.e(TAG, "Failed to read watchdog restart timestamp", e) + 0L + } + } + + fun setActiveProfileName(profileName: String): Boolean { + val normalized = profileName.trim().take(160) + if (normalized.isEmpty()) { + return false + } + return try { + val prefs = Application.application.getSharedPreferences( + STATE_PREF_NAME, + Context.MODE_PRIVATE, + ) + prefs.edit().putString(KEY_ACTIVE_PROFILE_NAME, normalized).commit() + } catch (e: Exception) { + Log.e(TAG, "Failed to persist active profile name", e) + false + } + } + + fun getActiveProfileName(): String { + return try { + val prefs = Application.application.getSharedPreferences( + STATE_PREF_NAME, + Context.MODE_PRIVATE, + ) + prefs.getString(KEY_ACTIVE_PROFILE_NAME, "")?.trim().orEmpty() + } catch (e: Exception) { + Log.e(TAG, "Failed to read active profile name", e) + "" + } + } } diff --git a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/config/parser/ConfigParser.kt b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/config/parser/ConfigParser.kt index 77ca4a9..2feba89 100644 --- a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/config/parser/ConfigParser.kt +++ b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/config/parser/ConfigParser.kt @@ -101,6 +101,13 @@ object ConfigParser { put("level", "warn") put("timestamp", true) }) + put("experimental", buildJsonObject { + put("cache_file", buildJsonObject { + put("enabled", true) + put("path", "cache.db") + put("store_fakeip", true) + }) + }) put("dns", dnsConfig(config.host)) put("inbounds", buildJsonArray { add(buildJsonObject { diff --git a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/session/VpnSessionStateMachine.kt b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/session/VpnSessionStateMachine.kt index e94710c..7ab76f0 100644 --- a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/session/VpnSessionStateMachine.kt +++ b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/session/VpnSessionStateMachine.kt @@ -62,6 +62,17 @@ internal class VpnSessionStateMachine { true } + fun markReconnecting(generation: Long, reason: String): Boolean = synchronized(lock) { + if (!isCurrentLocked(generation, requireDesiredRunning = true) || + phase == VpnSessionPhase.Stopping + ) { + return@synchronized false + } + phase = VpnSessionPhase.Reconnecting + this.reason = reason + true + } + fun markStopped( generation: Long, reason: String, diff --git a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/xray/XrayRunner.kt b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/xray/XrayRunner.kt index 7c222b6..13aef98 100644 --- a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/xray/XrayRunner.kt +++ b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/xray/XrayRunner.kt @@ -8,6 +8,7 @@ import dalvik.system.DexClassLoader import java.io.File import java.lang.reflect.Proxy import java.nio.charset.StandardCharsets +import java.util.concurrent.atomic.AtomicInteger class XrayRunner( private val service: VPNService, @@ -33,6 +34,7 @@ class XrayRunner( } bridge.ensureInitialized(service.applicationContext) + Log.i(TAG, "Embedded Xray version: ${redact(bridge.xrayVersion()).take(96)}") val testResponse = validateConfig(datDir, configJson) if (looksFailed(testResponse)) { throw IllegalStateException("Xray config validation failed: $testResponse") @@ -139,6 +141,8 @@ class XrayRunner( private var initialized = false private lateinit var classLoader: ClassLoader private lateinit var libXrayClass: Class<*> + private var dialerControllerProxy: Any? = null + private val protectCallCount = AtomicInteger(0) @Synchronized fun ensureInitialized(context: Context) { @@ -244,7 +248,15 @@ class XrayRunner( when (method.name) { "protectFd" -> { val fd = (args?.firstOrNull() as? Number)?.toInt() - fd != null && service.protect(fd) + val protected = fd != null && service.protect(fd) + val call = protectCallCount.incrementAndGet() + if (call <= PROTECT_LOG_LIMIT || !protected) { + Log.i( + TAG, + "Xray protectFd call=$call fd=${fd ?: -1} protected=$protected", + ) + } + protected } "toString" -> "YurichConnectXrayDialerController" else -> null @@ -253,6 +265,12 @@ class XrayRunner( libXrayClass .getMethod("registerDialerController", controllerClass) .invoke(null, proxy) + dialerControllerProxy = proxy + } + + fun xrayVersion(): String { + check(initialized) { "Xray bridge is not initialized" } + return libXrayClass.getMethod("xrayVersion").invoke(null) as String } fun setTunFd(fd: Int) { @@ -297,5 +315,9 @@ class XrayRunner( check(initialized) { "Xray bridge is not initialized" } return libXrayClass.getMethod("getXrayState").invoke(null) as Boolean } + + companion object { + private const val PROTECT_LOG_LIMIT = 3 + } } } diff --git a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/xray/XrayRuntimeConfig.kt b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/xray/XrayRuntimeConfig.kt index 5406343..2a906ee 100644 --- a/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/xray/XrayRuntimeConfig.kt +++ b/plugins/flutter_singbox_vpn/android/src/main/kotlin/com/tecclub/flutter_singbox/xray/XrayRuntimeConfig.kt @@ -1,7 +1,10 @@ package com.tecclub.flutter_singbox.xray import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -34,5 +37,23 @@ data class XrayRuntimeConfig( } fun isXray(content: String): Boolean = from(content).enabled + + fun exposesHttpProxy(configJson: String, port: Int): Boolean { + if (configJson.isBlank() || port !in 1..65535) { + return false + } + + val root = runCatching { + Json.parseToJsonElement(configJson).jsonObject + }.getOrElse { + return false + } + val inbounds = root["inbounds"] as? JsonArray ?: return false + return inbounds.any { element -> + val inbound = element as? JsonObject ?: return@any false + inbound["protocol"]?.jsonPrimitive?.contentOrNull == "http" && + inbound["port"]?.jsonPrimitive?.intOrNull == port + } + } } } diff --git a/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/ConnectionNotificationStatusPolicyTest.kt b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/ConnectionNotificationStatusPolicyTest.kt new file mode 100644 index 0000000..6310f14 --- /dev/null +++ b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/ConnectionNotificationStatusPolicyTest.kt @@ -0,0 +1,52 @@ +package com.tecclub.flutter_singbox + +import com.tecclub.flutter_singbox.constant.Status +import com.tecclub.flutter_singbox.model.ConnectionStatus +import kotlin.test.Test +import kotlin.test.assertEquals + +class ConnectionNotificationStatusPolicyTest { + @Test + fun `stale disconnected update cannot overwrite a ready tunnel`() { + assertEquals( + ConnectionStatus.Connected, + ConnectionNotificationStatusPolicy.resolve( + requestedStatus = ConnectionStatus.Disconnected, + nativeStatus = Status.Started, + ), + ) + } + + @Test + fun `stale connected update cannot overwrite recovery state`() { + assertEquals( + ConnectionStatus.Connecting, + ConnectionNotificationStatusPolicy.resolve( + requestedStatus = ConnectionStatus.Connected, + nativeStatus = Status.Starting, + ), + ) + } + + @Test + fun `stopped native service always resolves to disconnected`() { + assertEquals( + ConnectionStatus.Disconnected, + ConnectionNotificationStatusPolicy.resolve( + requestedStatus = ConnectionStatus.Connected, + nativeStatus = Status.Stopped, + ), + ) + } + + @Test + fun `matching connected state stays connected`() { + assertEquals( + ConnectionStatus.Connected, + ConnectionNotificationStatusPolicy.resolve( + requestedStatus = ConnectionStatus.Connected, + nativeStatus = Status.Started, + ), + ) + } +} diff --git a/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/VpnStatusResolverTest.kt b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/VpnStatusResolverTest.kt index e38fd50..bc814e5 100644 --- a/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/VpnStatusResolverTest.kt +++ b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/VpnStatusResolverTest.kt @@ -58,6 +58,14 @@ class VpnStatusResolverTest { ) } + @Test + fun runningServicePreservesReadinessGate() { + assertEquals( + Status.Starting, + resolve(startedByUser = true, currentStatus = Status.Starting) + ) + } + private fun resolve( startedByUser: Boolean, isStarting: Boolean = false, diff --git a/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/XrayTrafficEventPolicyTest.kt b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/XrayTrafficEventPolicyTest.kt new file mode 100644 index 0000000..588e332 --- /dev/null +++ b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/XrayTrafficEventPolicyTest.kt @@ -0,0 +1,79 @@ +package com.tecclub.flutter_singbox + +import com.tecclub.flutter_singbox.constant.Status +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class XrayTrafficEventPolicyTest { + @Test + fun `collects only for a started Xray runtime`() { + assertTrue(XrayTrafficEventPolicy.shouldCollect(Status.Started, true)) + assertFalse(XrayTrafficEventPolicy.shouldCollect(Status.Started, false)) + assertFalse(XrayTrafficEventPolicy.shouldCollect(Status.Starting, true)) + assertFalse(XrayTrafficEventPolicy.shouldCollect(Status.Stopped, true)) + } + + @Test + fun `maps UID counters to the Flutter traffic contract`() { + val stats = XrayTrafficEventPolicy.build( + sample = XrayUidTrafficSample( + txTotal = 10_000L, + rxTotal = 20_000L, + txSpeed = 1_024L, + rxSpeed = 2_048L, + sessionTx = 3_000L, + sessionRx = 7_000L, + ), + networkSnapshot = mapOf("networkType" to "wifi", "generation" to 4), + ) + + assertEquals(1_024L, stats["uplinkSpeed"]) + assertEquals(2_048L, stats["downlinkSpeed"]) + assertEquals(3_000L, stats["sessionUplink"]) + assertEquals(7_000L, stats["sessionDownlink"]) + assertEquals(10_000L, stats["sessionTotal"]) + assertEquals("1.00 KB/s", stats["formattedUplinkSpeed"]) + assertEquals("2.00 KB/s", stats["formattedDownlinkSpeed"]) + assertEquals("9.77 KB", stats["formattedSessionTotal"]) + assertEquals("wifi", stats["networkType"]) + assertEquals(4, stats["generation"]) + } + + @Test + fun `clamps unsupported counters instead of publishing negative traffic`() { + val stats = XrayTrafficEventPolicy.build( + sample = XrayUidTrafficSample(-1L, -1L, -1L, -1L, -1L, -1L), + networkSnapshot = emptyMap(), + ) + + assertEquals(0L, stats["uplinkSpeed"]) + assertEquals(0L, stats["downlinkSpeed"]) + assertEquals(0L, stats["sessionTotal"]) + assertEquals("0 B", stats["formattedSessionTotal"]) + } + + @Test + fun `idle event preserves totals and clears the last speed`() { + val stats = XrayTrafficEventPolicy.build( + sample = XrayUidTrafficSample( + txTotal = 10_000L, + rxTotal = 20_000L, + txSpeed = 1_024L, + rxSpeed = 2_048L, + sessionTx = 3_000L, + sessionRx = 7_000L, + ), + networkSnapshot = emptyMap(), + active = false, + ) + + assertEquals(0L, stats["uplinkSpeed"]) + assertEquals(0L, stats["downlinkSpeed"]) + assertEquals(10_000L, stats["sessionTotal"]) + assertEquals("0 B/s", stats["formattedUplinkSpeed"]) + assertEquals("0 B/s", stats["formattedDownlinkSpeed"]) + assertEquals("9.77 KB", stats["formattedSessionTotal"]) + } +} diff --git a/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/bg/TunnelFlapDetectorTest.kt b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/bg/TunnelFlapDetectorTest.kt new file mode 100644 index 0000000..01e2e9e --- /dev/null +++ b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/bg/TunnelFlapDetectorTest.kt @@ -0,0 +1,45 @@ +package com.tecclub.flutter_singbox.bg + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TunnelFlapDetectorTest { + @Test + fun `repeated degraded quorums trigger recovery inside the window`() { + val detector = TunnelFlapDetector(threshold = 4, windowMs = 300_000L) + + assertFalse(detector.record(0L, 2, 3)) + assertFalse(detector.record(60_000L, 2, 3)) + assertFalse(detector.record(120_000L, 3, 3)) + assertFalse(detector.record(180_000L, 1, 3)) + assertTrue(detector.record(240_000L, 2, 3)) + } + + @Test + fun `sparse endpoint failures do not trigger recovery`() { + val detector = TunnelFlapDetector(threshold = 3, windowMs = 60_000L) + + assertFalse(detector.record(0L, 2, 3)) + assertFalse(detector.record(61_000L, 2, 3)) + assertFalse(detector.record(122_000L, 1, 3)) + } + + @Test + fun `reset discards previous degradation history`() { + val detector = TunnelFlapDetector(threshold = 2, windowMs = 60_000L) + + assertFalse(detector.record(0L, 1, 3)) + detector.reset() + assertFalse(detector.record(10_000L, 1, 3)) + assertTrue(detector.record(20_000L, 1, 3)) + } + + @Test + fun `full quorum and invalid totals are ignored`() { + val detector = TunnelFlapDetector(threshold = 1, windowMs = 60_000L) + + assertFalse(detector.record(0L, 3, 3)) + assertFalse(detector.record(1L, 0, 0)) + } +} diff --git a/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/bg/TunnelReadinessPolicyTest.kt b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/bg/TunnelReadinessPolicyTest.kt new file mode 100644 index 0000000..6b2c24b --- /dev/null +++ b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/bg/TunnelReadinessPolicyTest.kt @@ -0,0 +1,72 @@ +package com.tecclub.flutter_singbox.bg + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TunnelReadinessPolicyTest { + @Test + fun `one endpoint cannot report the tunnel ready`() { + assertFalse(TunnelReadinessPolicy.isHealthy(1, 3)) + } + + @Test + fun `two independent endpoints confirm readiness`() { + assertTrue(TunnelReadinessPolicy.isHealthy(2, 3)) + } + + @Test + fun `restart is blocked inside cooldown across service instances`() { + assertFalse( + TunnelReadinessPolicy.canRestart( + nowMs = 15_000L, + lastRestartAtMs = 10_000L, + cooldownMs = 90_000L, + ) + ) + } + + @Test + fun `restart is allowed after cooldown`() { + assertTrue( + TunnelReadinessPolicy.canRestart( + nowMs = 100_000L, + lastRestartAtMs = 10_000L, + cooldownMs = 90_000L, + ) + ) + } + + @Test + fun `first restart is allowed`() { + assertTrue( + TunnelReadinessPolicy.canRestart( + nowMs = 10_000L, + lastRestartAtMs = 0L, + cooldownMs = 90_000L, + ) + ) + } + + @Test + fun `runtime restart is deferred during startup grace`() { + assertFalse( + TunnelReadinessPolicy.canRestartAfterStartupGrace( + nowMs = 22_000L, + startAttemptAtMs = 10_000L, + graceMs = 30_000L, + ) + ) + } + + @Test + fun `runtime restart is allowed after startup grace`() { + assertTrue( + TunnelReadinessPolicy.canRestartAfterStartupGrace( + nowMs = 40_000L, + startAttemptAtMs = 10_000L, + graceMs = 30_000L, + ) + ) + } +} diff --git a/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/bg/VpnRuntimeCorePolicyTest.kt b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/bg/VpnRuntimeCorePolicyTest.kt new file mode 100644 index 0000000..24d50ac --- /dev/null +++ b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/bg/VpnRuntimeCorePolicyTest.kt @@ -0,0 +1,22 @@ +package com.tecclub.flutter_singbox.bg + +import kotlin.test.Test +import kotlin.test.assertEquals + +class VpnRuntimeCorePolicyTest { + @Test + fun `classifies wrapped Xray config`() { + val config = + """{"_yurich":{"core":"xray"},"xray":{"inbounds":[],"outbounds":[]}}""" + + assertEquals(VpnRuntimeCore.Xray, VpnRuntimeCorePolicy.classify(config)) + } + + @Test + fun `classifies plain config as sing-box`() { + assertEquals( + VpnRuntimeCore.SingBox, + VpnRuntimeCorePolicy.classify("""{"inbounds":[],"outbounds":[]}"""), + ) + } +} diff --git a/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/config/parser/ConfigParserTest.kt b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/config/parser/ConfigParserTest.kt index 7d5dc63..b53bca3 100644 --- a/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/config/parser/ConfigParserTest.kt +++ b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/config/parser/ConfigParserTest.kt @@ -216,6 +216,13 @@ internal class ConfigParserTest { assertEquals("local-dns", dns["final"]!!.jsonPrimitive.content) assertEquals(8192, dns["cache_capacity"]!!.jsonPrimitive.int) assertNotNull(dns["rules"]!!.jsonArray.first().jsonObject["domain"]) + + val cacheFile = json["experimental"]!! + .jsonObject["cache_file"]!! + .jsonObject + assertTrue(cacheFile["enabled"]!!.jsonPrimitive.boolean) + assertEquals("cache.db", cacheFile["path"]!!.jsonPrimitive.content) + assertTrue(cacheFile["store_fakeip"]!!.jsonPrimitive.boolean) } private fun parseJson(value: String) = Json.parseToJsonElement(value).jsonObject diff --git a/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/session/VpnSessionStateMachineTest.kt b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/session/VpnSessionStateMachineTest.kt index 45ca2c7..688596e 100644 --- a/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/session/VpnSessionStateMachineTest.kt +++ b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/session/VpnSessionStateMachineTest.kt @@ -55,6 +55,21 @@ class VpnSessionStateMachineTest { assertTrue(state.markConnected(reconnect.generation, "recovered")) } + @Test + fun `network readiness gate keeps the active generation`() { + val state = VpnSessionStateMachine() + val start = state.requestStart("connect") + assertTrue(state.markConnected(start.generation, "connected")) + + assertTrue(state.markReconnecting(start.generation, "network-validation")) + assertEquals(VpnSessionPhase.Reconnecting, state.snapshot().phase) + assertEquals(start.generation, state.snapshot().generation) + assertTrue(state.snapshot().desiredRunning) + + assertTrue(state.markConnected(start.generation, "network-validated")) + assertEquals(VpnSessionPhase.Connected, state.snapshot().phase) + } + @Test fun `failed startup can clear automatic restart intent`() { val state = VpnSessionStateMachine() diff --git a/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/xray/XrayRuntimeConfigTest.kt b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/xray/XrayRuntimeConfigTest.kt index 806a733..dc7e283 100644 --- a/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/xray/XrayRuntimeConfigTest.kt +++ b/plugins/flutter_singbox_vpn/android/src/test/kotlin/com/tecclub/flutter_singbox/xray/XrayRuntimeConfigTest.kt @@ -29,4 +29,41 @@ internal class XrayRuntimeConfigTest { assertFalse(runtime.enabled) } + + @Test + fun detectsCompactHttpHealthProxy() { + val config = + """{"inbounds":[{"listen":"127.0.0.1","port":20808,"protocol":"http"}]}""" + + assertTrue(XrayRuntimeConfig.exposesHttpProxy(config, 20808)) + } + + @Test + fun detectsFormattedHttpHealthProxy() { + val config = + """ + { + "inbounds": [ + {"protocol": "http", "port": 20808} + ] + } + """.trimIndent() + + assertTrue(XrayRuntimeConfig.exposesHttpProxy(config, 20808)) + } + + @Test + fun rejectsNonHttpAndWrongPortHealthProxies() { + val config = + """{"inbounds":[{"port":20808,"protocol":"socks"},{"port":1080,"protocol":"http"}]}""" + + assertFalse(XrayRuntimeConfig.exposesHttpProxy(config, 20808)) + } + + @Test + fun rejectsInvalidHealthProxyConfig() { + assertFalse(XrayRuntimeConfig.exposesHttpProxy("not-json", 20808)) + assertFalse(XrayRuntimeConfig.exposesHttpProxy("{}", 20808)) + assertFalse(XrayRuntimeConfig.exposesHttpProxy("{\"inbounds\":[]}", 0)) + } } diff --git a/pubspec.yaml b/pubspec.yaml index 60f938a..fd26744 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.0.109+22109 +version: 1.0.114+24117 environment: sdk: ^3.11.5 diff --git a/test/profile_importer_test.dart b/test/profile_importer_test.dart index 2813662..1324f34 100644 --- a/test/profile_importer_test.dart +++ b/test/profile_importer_test.dart @@ -202,6 +202,9 @@ void main() { expect(profiles.first.outbound?['password'], 'pass'); expect(proxy['type'], 'naive'); expect(proxy['tls'], {'enabled': true, 'server_name': 'example.com'}); + expect(config['experimental'], { + 'cache_file': {'enabled': true, 'path': 'cache.db', 'store_fakeip': true}, + }); final dnsServers = (config['dns'] as Map)['servers'] as List; expect(dnsServers.first, {'type': 'local', 'tag': 'local-dns'}); diff --git a/test/runtime_config_matcher_test.dart b/test/runtime_config_matcher_test.dart new file mode 100644 index 0000000..700fb21 --- /dev/null +++ b/test/runtime_config_matcher_test.dart @@ -0,0 +1,31 @@ +import 'package:aurum_vpn/src/services/runtime_config_matcher.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('matches equivalent runtime JSON independent of formatting', () { + const current = + '{"outbounds":[{"server":"fi.example","port":443}],"log":{"level":"warn"}}'; + const expected = ''' + { + "log": {"level": "warn"}, + "outbounds": [{"port": 443, "server": "fi.example"}] + } + '''; + + expect(RuntimeConfigMatcher.equivalent(current, expected), isTrue); + }); + + test('rejects a runtime config for another selected endpoint', () { + const current = '{"outbounds":[{"server":"pl.example","port":443}]}'; + const expected = '{"outbounds":[{"server":"fi.example","port":443}]}'; + + expect(RuntimeConfigMatcher.equivalent(current, expected), isFalse); + }); + + test('falls back to trimmed comparison for non-JSON configs', () { + expect( + RuntimeConfigMatcher.equivalent(' raw-config ', 'raw-config'), + isTrue, + ); + }); +} diff --git a/test/xray_config_builder_test.dart b/test/xray_config_builder_test.dart index eda729a..b9d075e 100644 --- a/test/xray_config_builder_test.dart +++ b/test/xray_config_builder_test.dart @@ -76,7 +76,7 @@ void main() { expect(stream['realitySettings']['serverName'], 'www.microsoft.com'); expect(stream['realitySettings']['publicKey'], 'abc123'); expect(stream['realitySettings']['shortId'], '01'); - expect(stream['realitySettings']['fingerprint'], 'chrome'); + expect(stream['realitySettings']['fingerprint'], 'firefox'); expect(stream['xhttpSettings']['path'], '/xhttp'); expect(stream['xhttpSettings']['host'], 'cdn.example.com'); expect(stream['xhttpSettings'].containsKey('headers'), isFalse); @@ -117,6 +117,7 @@ void main() { expect(stream['security'], 'reality'); expect(stream['realitySettings']['serverName'], 'www.microsoft.com'); expect(stream['realitySettings']['publicKey'], 'abc123'); + expect(stream['realitySettings']['fingerprint'], 'chrome'); final vnext = proxy['settings']['vnext'] as List; final users = vnext.first['users'] as List; expect(users.first['flow'], 'xtls-rprx-vision'); @@ -124,6 +125,20 @@ void main() { expect(xray['routing']['rules'].last['outboundTag'], 'proxy'); }); + test('preserves an explicit non-Chrome XHTTP fingerprint', () async { + const link = + 'vless://11111111-1111-4111-8111-111111111111@example.com:443?security=tls&type=xhttp&sni=example.com&path=%2Fxhttp&mode=packet-up&fp=safari#XHTTP'; + + final profile = (await ProfileImporter().importFromText(link)).first; + final wrapper = + jsonDecode(XrayConfigBuilder().build(profile)) as Map; + final xray = wrapper['xray'] as Map; + final proxy = (xray['outbounds'] as List).first as Map; + final stream = proxy['streamSettings'] as Map; + + expect(stream['tlsSettings']['fingerprint'], 'safari'); + }); + test('builds smart-route rules when enabled for Xray', () async { const link = 'vless://11111111-1111-4111-8111-111111111111@example.com:443?security=reality&type=tcp&sni=www.microsoft.com&flow=xtls-rprx-vision&fp=chrome&pbk=abc123&sid=01#REALITY';