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';