From 0caee301dde8b2b98b215abd90ec31fc06a55226 Mon Sep 17 00:00:00 2001 From: Anonymouskeys Date: Thu, 23 Jul 2026 21:56:00 +0500 Subject: [PATCH] Separate Smart, TCP and Handshake tests --- .../com/v2ray/ang/dto/TestServiceMessage.kt | 1 + .../com/v2ray/ang/service/CoreTestService.kt | 166 +++++++++++++++--- .../ang/service/RealPingWorkerService.kt | 15 +- .../java/com/v2ray/ang/ui/MainActivity.kt | 6 + .../com/v2ray/ang/viewmodel/MainViewModel.kt | 17 +- V2rayNG/app/src/main/res/menu/menu_main.xml | 4 + .../app/src/main/res/values-ru/strings.xml | 1 + V2rayNG/app/src/main/res/values/strings.xml | 1 + 8 files changed, 173 insertions(+), 38 deletions(-) diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/TestServiceMessage.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/TestServiceMessage.kt index da8907f2..fa4ceacc 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/TestServiceMessage.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/TestServiceMessage.kt @@ -9,6 +9,7 @@ data class TestServiceMessage( val testMode: String = TEST_MODE_HANDSHAKE ) : Serializable { companion object { + const val TEST_MODE_SMART = "SMART" const val TEST_MODE_TCP = "TCP" const val TEST_MODE_HANDSHAKE = "HANDSHAKE" } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreTestService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreTestService.kt index 50e0daef..67935538 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreTestService.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreTestService.kt @@ -118,52 +118,166 @@ class CoreTestService : Service() { ) { LogUtil.i(AppConfig.TAG, "CoreTestService starting worker subscription ${message.subscriptionId}") + val titleRes = when (message.testMode) { + TestServiceMessage.TEST_MODE_SMART -> R.string.title_smart_test_all_server + TestServiceMessage.TEST_MODE_TCP -> R.string.title_tcp_test_all_server + else -> R.string.title_handshake_test_all_server + } NotificationHelper.startForeground( this, NotificationChannelType.CORE_TEST, getString(R.string.app_name), - getString(if (message.testMode == TestServiceMessage.TEST_MODE_TCP) R.string.title_tcp_test_all_server else R.string.title_handshake_test_all_server) + getString(titleRes) ) val guidsList = when { message.serverGuids.isNotEmpty() -> message.serverGuids message.subscriptionId.isNotEmpty() -> MmkvManager.decodeServerList(message.subscriptionId) else -> MmkvManager.decodeAllServerList() + }.distinct() + + if (guidsList.isEmpty()) { + NotificationHelper.stopForeground(this) + stopSelf(startId) + return } - if (guidsList.isNotEmpty()) { - // A second tap while a batch is alive must not cancel and restart hundreds of tests. - // Keep the current session and report its state instead. - if (activeWorkers.isNotEmpty()) { - LogUtil.i(AppConfig.TAG, "CoreTestService: batch already running; duplicate start ignored") - return + // A second tap while a batch is alive must not cancel and restart hundreds of tests. + if (activeWorkers.isNotEmpty()) { + LogUtil.i(AppConfig.TAG, "CoreTestService: batch already running; duplicate start ignored") + return + } + + if (generation != commandGeneration.get() || serviceJob.isCancelled) return + + when (message.testMode) { + TestServiceMessage.TEST_MODE_SMART -> startSmartTest(guidsList, generation) + TestServiceMessage.TEST_MODE_HANDSHAKE -> { + acquireDpiTestOwnerIfNeeded() + if (generation != commandGeneration.get() || serviceJob.isCancelled) { + releaseDpiTestOwner() + return + } + startStandardWorker(guidsList, TestServiceMessage.TEST_MODE_HANDSHAKE) } + else -> startStandardWorker(guidsList, TestServiceMessage.TEST_MODE_TCP) + } + } + + private fun startStandardWorker(guids: List, testMode: String) { + handleWorkerEvent(RealPingEvent.Progress("0 / ${guids.size}")) {} - val total = guidsList.distinct().size - handleWorkerEvent(RealPingEvent.Progress("0 / $total")) {} + lateinit var worker: RealPingWorkerService + worker = RealPingWorkerService( + context = this, + guids = guids, + testMode = testMode, + onEvent = { event -> handleWorkerEvent(event) { activeWorkers.remove(worker) } } + ) + activeWorkers.add(worker) + worker.start() + } - if (message.testMode == TestServiceMessage.TEST_MODE_HANDSHAKE) acquireDpiTestOwnerIfNeeded() + /** + * Smart Test is the only two-stage mode: direct TCP against every visible profile, followed by + * a real protocol handshake only for TCP-capable profiles that passed. ByeDPI ownership is + * acquired immediately before the handshake stage and released by the normal finish path, so + * standalone TCP testing never starts or changes the DPI runtime. + */ + private fun startSmartTest(guids: List, generation: Int) { + val tcpGuids = guids.filter(RealPingWorkerService::supportsTcpPrecheck) + val directHandshakeGuids = guids.filterNot(RealPingWorkerService::supportsTcpPrecheck) + val tcpPassed = Collections.synchronizedList(mutableListOf()) - // A cancel or a newer start may arrive while the native process is starting. Do not - // publish an obsolete worker after the blocking readiness probe completes. + if (tcpGuids.isEmpty()) { + acquireDpiTestOwnerIfNeeded() if (generation != commandGeneration.get() || serviceJob.isCancelled) { - if (activeWorkers.isEmpty()) releaseDpiTestOwner() + releaseDpiTestOwner() + finishBatch("-1") return } - - lateinit var worker: RealPingWorkerService - worker = RealPingWorkerService( - context = this, - guids = guidsList.distinct(), - testMode = message.testMode, - onEvent = { event -> handleWorkerEvent(event) { activeWorkers.remove(worker) } } - ) - activeWorkers.add(worker) - worker.start() - } else { - NotificationHelper.stopForeground(this) - stopSelf(startId) + startSmartHandshake(directHandshakeGuids) + return } + + publishStageProgress("TCP", "0 / ${tcpGuids.size}") + + lateinit var tcpWorker: RealPingWorkerService + tcpWorker = RealPingWorkerService( + context = this, + guids = tcpGuids, + testMode = TestServiceMessage.TEST_MODE_TCP, + onEvent = onEvent@{ event -> + when (event) { + is RealPingEvent.Result -> { + MmkvManager.encodeServerTestDelayMillis(event.guid, event.delayMillis) + MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, event.guid) + if (event.delayMillis >= 0L) tcpPassed.add(event.guid) + } + is RealPingEvent.Progress -> publishStageProgress("TCP", event.text) + is RealPingEvent.Finish -> { + activeWorkers.remove(tcpWorker) + if (event.status != "0" || generation != commandGeneration.get() || serviceJob.isCancelled) { + finishBatch(event.status) + return@onEvent + } + + // UDP/QUIC/complex profiles cannot be meaningfully pre-checked with a + // plain TCP socket, so Smart Test sends them directly to handshake. + val handshakeGuids = (directHandshakeGuids + tcpPassed).distinct() + if (handshakeGuids.isEmpty()) { + finishBatch("0") + return@onEvent + } + + acquireDpiTestOwnerIfNeeded() + if (generation != commandGeneration.get() || serviceJob.isCancelled) { + releaseDpiTestOwner() + finishBatch("-1") + return@onEvent + } + startSmartHandshake(handshakeGuids) + } + } + } + ) + activeWorkers.add(tcpWorker) + tcpWorker.start() + } + + private fun startSmartHandshake(guids: List) { + publishStageProgress("Handshake", "0 / ${guids.size}") + lateinit var worker: RealPingWorkerService + worker = RealPingWorkerService( + context = this, + guids = guids, + testMode = TestServiceMessage.TEST_MODE_HANDSHAKE, + onEvent = { event -> + when (event) { + is RealPingEvent.Progress -> publishStageProgress("Handshake", event.text) + else -> handleWorkerEvent(event) { activeWorkers.remove(worker) } + } + } + ) + activeWorkers.add(worker) + worker.start() + } + + private fun publishStageProgress(stage: String, progress: String) { + val text = "$stage: $progress" + NotificationHelper.updateNotification( + channelType = NotificationChannelType.CORE_TEST, + context = this, + content = getString(R.string.connection_runing_task_left, text) + ) + MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, text) + } + + private fun finishBatch(status: String) { + MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, status) + releaseDpiTestOwner() + NotificationHelper.stopForeground(this) + stopSelf() } private fun handleWorkerEvent(event: RealPingEvent, onWorkerDone: () -> Unit) { diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/service/RealPingWorkerService.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/service/RealPingWorkerService.kt index 2100f150..13a480b1 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/service/RealPingWorkerService.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/service/RealPingWorkerService.kt @@ -121,8 +121,8 @@ class RealPingWorkerService( val failed = PingResult(guid, -1L, transientFailure = false) val config = MmkvManager.decodeServerConfig(guid) ?: return failed - // The TCP menu is the cheap first stage. Handshake keeps one definitive attempt only; - // retries made large groups take minutes and could preserve stale green results. + // This is one definitive protocol attempt. Optional TCP pre-filtering belongs only to + // Smart Test; standalone Handshake intentionally checks every selected profile. val configResult = CoreConfigManager.getV2rayConfig4Speedtest(context, guid) if (!configResult.status) return failed @@ -140,6 +140,17 @@ class RealPingWorkerService( companion object { private const val TCP_TIMEOUT_MS = 1200 + + /** Profiles without a plain TCP endpoint must bypass Smart Test's TCP stage. */ + fun supportsTcpPrecheck(guid: String): Boolean { + val config = MmkvManager.decodeServerConfig(guid) ?: return false + return !config.configType.isComplexType() + && config.configType != EConfigType.HYSTERIA2 + && config.configType != EConfigType.WIREGUARD + && config.alpn?.startsWith("h3") != true + && config.server.isNotNullEmpty() + && config.serverPort?.toIntOrNull() != null + } } private fun publishFinal(result: PingResult) { diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt index 11bff0f4..848881db 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt @@ -386,6 +386,12 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect true } + R.id.smart_test_all -> { + toast(getString(R.string.connection_test_testing_count, mainViewModel.serversCache.count())) + mainViewModel.testAllSmart() + true + } + R.id.tcp_ping_all -> { toast(getString(R.string.connection_test_testing_count, mainViewModel.serversCache.count())) mainViewModel.testAllTcpPing() diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt index 08c2b772..be345ef6 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt @@ -177,21 +177,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { /** * Tests the real ping for all servers. */ + fun testAllSmart() { + startBatchTest(TestServiceMessage.TEST_MODE_SMART, serversCache.map { it.guid }.distinct()) + } + fun testAllTcpPing() { startBatchTest(TestServiceMessage.TEST_MODE_TCP, serversCache.map { it.guid }.distinct()) } fun testAllHandshake() { - // The intended workflow is TCP first, then protocol handshake only for reachable endpoints. - // A user may still run this directly; in that case all visible profiles are checked. - val visible = serversCache.map { it.guid }.distinct() - val tcpPassed = visible.filter { - (MmkvManager.decodeServerAffiliationInfo(it)?.testDelayMillis ?: -1L) >= 0L - } - startBatchTest( - TestServiceMessage.TEST_MODE_HANDSHAKE, - if (tcpPassed.isNotEmpty()) tcpPassed else visible - ) + // Handshake is an independent, definitive protocol test. It must never depend on a + // previously cached TCP result; Smart Test owns the optional TCP pre-filter instead. + startBatchTest(TestServiceMessage.TEST_MODE_HANDSHAKE, serversCache.map { it.guid }.distinct()) } private fun startBatchTest(testMode: String, guids: List) { diff --git a/V2rayNG/app/src/main/res/menu/menu_main.xml b/V2rayNG/app/src/main/res/menu/menu_main.xml index fdb8a20d..3fb01d17 100644 --- a/V2rayNG/app/src/main/res/menu/menu_main.xml +++ b/V2rayNG/app/src/main/res/menu/menu_main.xml @@ -103,6 +103,10 @@ android:icon="@drawable/ic_share_24dp" android:title="@string/title_export_all" app:showAsAction="never" /> + Неправильный интервал обновления. Минимум 15 минут. Обновить подписку Проверить задержку профилей + Умная проверка: TCP → handshake Быстрая TCP-проверка профилей Проверка handshake профилей Файлы ресурсов diff --git a/V2rayNG/app/src/main/res/values/strings.xml b/V2rayNG/app/src/main/res/values/strings.xml index 8951f6a3..82849be7 100644 --- a/V2rayNG/app/src/main/res/values/strings.xml +++ b/V2rayNG/app/src/main/res/values/strings.xml @@ -321,6 +321,7 @@ Invalid update interval. Minimum is 15 minutes. Update subscription Real delay config + Smart test: TCP → handshake Quick TCP profile test Protocol handshake profile test Asset files