Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
166 changes: 140 additions & 26 deletions V2rayNG/app/src/main/java/com/v2ray/ang/service/CoreTestService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, 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<String>, generation: Int) {
val tcpGuids = guids.filter(RealPingWorkerService::supportsTcpPrecheck)
val directHandshakeGuids = guids.filterNot(RealPingWorkerService::supportsTcpPrecheck)
val tcpPassed = Collections.synchronizedList(mutableListOf<String>())

// 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<String>) {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
17 changes: 7 additions & 10 deletions V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) {
Expand Down
4 changes: 4 additions & 0 deletions V2rayNG/app/src/main/res/menu/menu_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@
android:icon="@drawable/ic_share_24dp"
android:title="@string/title_export_all"
app:showAsAction="never" />
<item
android:id="@+id/smart_test_all"
android:title="@string/title_smart_test_all_server"
app:showAsAction="never" />
<item
android:id="@+id/tcp_ping_all"
android:title="@string/title_tcp_test_all_server"
Expand Down
1 change: 1 addition & 0 deletions V2rayNG/app/src/main/res/values-ru/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@
<string name="toast_invalid_update_interval">Неправильный интервал обновления. Минимум 15 минут.</string>
<string name="title_sub_update">Обновить подписку</string>
<string name="title_real_ping_all_server">Проверить задержку профилей</string>
<string name="title_smart_test_all_server">Умная проверка: TCP → handshake</string>
<string name="title_tcp_test_all_server">Быстрая TCP-проверка профилей</string>
<string name="title_handshake_test_all_server">Проверка handshake профилей</string>
<string name="title_user_asset_setting">Файлы ресурсов</string>
Expand Down
1 change: 1 addition & 0 deletions V2rayNG/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@
<string name="toast_invalid_update_interval">Invalid update interval. Minimum is 15 minutes.</string>
<string name="title_sub_update">Update subscription</string>
<string name="title_real_ping_all_server">Real delay config</string>
<string name="title_smart_test_all_server">Smart test: TCP → handshake</string>
<string name="title_tcp_test_all_server">Quick TCP profile test</string>
<string name="title_handshake_test_all_server">Protocol handshake profile test</string>
<string name="title_user_asset_setting">Asset files</string>
Expand Down
Loading