Random and Round-Robin strategies can exclude dead outbounds (#5926)

* Add policy group fallback health checks

* Refine policy group observatory defaults

* Simplify policy group fallback

* Localize policy group fallback settings

* Update CoreConfigContextBuilder.kt

* Update CoreConfigManager.kt

* Restore implicit policy group fallback

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
This commit is contained in:
eliotcougarand2dust authored and GitHub committed 2026-07-24 10:20:16 +08:00
1 parent ce75aad55e
commit 2be47f37fe
14 files changed
+119 -12

No files matched your search

@@ -4,6 +4,7 @@ import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.CoreConfigContext
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.BalancerStrategyType
import com.v2ray.ang.enums.CoreResolvedType
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType
@@ -43,12 +44,14 @@ object CoreConfigContextBuilder {
// Step 2: Resolve all non-builtin routing outbound tags.
val routingResolvedOutbounds = resolveRoutingOutbounds()
val resolvedOutbounds = listOf(primaryResolvedOutbound) + routingResolvedOutbounds
val fallbackResolvedOutbounds = resolveFallbackOutbounds(resolvedOutbounds)
val routingDomainRules = collectRoutingDomainRulesForDns()
return CoreConfigContext(
context = context,
guid = guid,
resolvedOutbounds = listOf(primaryResolvedOutbound) + routingResolvedOutbounds,
resolvedOutbounds = resolvedOutbounds + fallbackResolvedOutbounds,
routingDomainRules = routingDomainRules,
)
}
@@ -246,4 +249,25 @@ object CoreConfigContextBuilder {
return result
}
/**
* Resolve and collect fallback outbounds from all POLICYGROUP nodes.
*
* Fallback targets must not overlap with already resolved tags or builtin tags.
*/
private fun resolveFallbackOutbounds(resolvedOutbounds: List<CoreConfigContext.ResolvedOutbound>): List<CoreConfigContext.ResolvedOutbound> {
return resolvedOutbounds
.asSequence()
.filter { it.resolvedType == CoreResolvedType.POLICYGROUP }
.filter { BalancerStrategyType.from(it.profile.policyGroupType).supportsObservatory && it.profile.policyGroupTestOutbounds != false }
.mapNotNull { it.profile.policyGroupFallbackTag }
.filter { it !in AppConfig.BUILTIN_OUTBOUND_TAGS && resolvedOutbounds.none { outbound -> outbound.tag == it } }
.distinct()
.mapNotNull { tag ->
SettingsManager.getServerViaRemarks(tag)
?.takeUnless { it.configType == EConfigType.CUSTOM || it.configType == EConfigType.POLICYGROUP }
?.let { resolveOutbound(tag, it) }
}
.toList()
}
}
@@ -374,10 +374,19 @@ object CoreConfigManager {
} else {
"${AppConfig.TAG_BALANCER_PRE}-${resolvedOutbound.tag}"
}
val strategyType = BalancerStrategyType.from(resolvedOutbound.profile.policyGroupType)
val fallbackTag = if (strategyType.supportsObservatory && resolvedOutbound.profile.policyGroupTestOutbounds != false) {
resolvedOutbound.profile.policyGroupFallbackTag
?.takeIf { it.isNotEmpty() && it != AppConfig.TAG_PROXY }
// Xray excludes dead random/roundRobin candidates only when fallbackTag is set;
// without this default, an enabled empty field creates no observatory.
?: membersToAdd.first().tag
} else null
val strategy = buildBalancerStrategy(
policyGroupType = resolvedOutbound.profile.policyGroupType,
strategyType = strategyType,
selector = listOf(memberTagPrefix),
balancerTag = balancerTag,
fallbackTag = fallbackTag,
)
val existingBalancers = v2rayConfig.routing.balancers?.toMutableList() ?: mutableListOf()
if (existingBalancers.none { it.tag == balancerTag }) {
@@ -1180,9 +1189,10 @@ object CoreConfigManager {
* Build balancer and probe settings from one policy-group strategy value.
*/
private fun buildBalancerStrategy(
policyGroupType: String?,
strategyType: BalancerStrategyType,
selector: List<String>,
balancerTag: String = AppConfig.TAG_BALANCER,
fallbackTag: String? = null,
): BalancerStrategy {
val probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL
val leastPingInterval = decodeObservatoryDuration(AppConfig.PREF_OBSERVATORY_LEAST_PING_INTERVAL, AppConfig.OBSERVATORY_LEAST_PING_INTERVAL)
@@ -1190,13 +1200,13 @@ object CoreConfigManager {
val leastLoadMethod = MmkvManager.decodeSettingsString(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_METHOD, AppConfig.OBSERVATORY_LEAST_LOAD_METHOD)
val leastLoadSampling = decodeObservatorySampling()
val leastLoadTimeout = decodeObservatoryDuration(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_TIMEOUT, AppConfig.OBSERVATORY_LEAST_LOAD_TIMEOUT)
val strategyType = BalancerStrategyType.from(policyGroupType)
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = balancerTag,
selector = selector,
fallbackTag = fallbackTag,
strategy = V2rayConfig.RoutingBean.StrategyObject(type = strategyType.policyGroupType)
)
val observatory = if (strategyType.requiresObservatory) {
val observatory = if (strategyType.requiresObservatory || fallbackTag != null) {
V2rayConfig.ObservatoryObject(
subjectSelector = selector,
probeUrl = probeUrl,
@@ -67,6 +67,8 @@ data class ProfileItem(
var policyGroupType: String? = null,
var policyGroupSubscriptionId: String? = null,
var policyGroupFilter: String? = null,
var policyGroupTestOutbounds: Boolean? = null,
var policyGroupFallbackTag: String? = null,
var proxyChainProfiles: String? = null,
var browserDialerMode: String? = null,
@@ -7,20 +7,22 @@ package com.v2ray.ang.enums
* @param policyGroupTypeValue The numeric string stored in [com.v2ray.ang.dto.entities.ProfileItem.policyGroupType].
* @param requiresBurstObservatory Whether this strategy needs a burstObservatory (leastLoad).
* @param requiresObservatory Whether this strategy needs an observatory (leastPing).
* @param supportsObservatory Whether an observatory can health-filter this strategy.
*/
enum class BalancerStrategyType(
val policyGroupType: String,
val policyGroupTypeValue: String,
val requiresBurstObservatory: Boolean = false,
val requiresObservatory: Boolean = false,
val supportsObservatory: Boolean = false,
) {
LEAST_LOAD("leastLoad", "1", requiresBurstObservatory = true),
RANDOM("random", "2"),
ROUND_ROBIN("roundRobin", "3"),
RANDOM("random", "2", supportsObservatory = true),
ROUND_ROBIN("roundRobin", "3", supportsObservatory = true),
LEAST_PING("leastPing", "", requiresObservatory = true); // default / else
companion object {
fun from(policyGroupType: String?): BalancerStrategyType =
entries.firstOrNull { it.policyGroupTypeValue == policyGroupType } ?: LEAST_PING
}
}
}
@@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
@@ -21,17 +23,22 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS
import com.v2ray.ang.AppConfig.TAG_PROXY
import com.v2ray.ang.R
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.ConfirmDialog
import com.v2ray.ang.compose.FormDropdownField
import com.v2ray.ang.compose.FormTextField
import com.v2ray.ang.compose.SettingsSwitchItem
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.BalancerStrategyType
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.ui.base.BaseComponentActivity
class ServerGroupActivity : BaseComponentActivity() {
@@ -45,21 +52,32 @@ class ServerGroupActivity : BaseComponentActivity() {
private val subscriptionId by lazy { intent.getStringExtra("subscriptionId") }
private val subIds = mutableListOf<String>()
private val subDisplay = mutableListOf<String>()
private lateinit var fallbackSuggestions: List<String>
private lateinit var initialRemarks: String
private lateinit var initialFilter: String
private var initialType: Int = 0
private var initialSubIndex: Int = 0
private var initialTestOutbounds: Boolean = false
private lateinit var initialFallbackTag: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val config = MmkvManager.decodeServerConfig(editGuid)
populateSubscriptionSpinner()
fallbackSuggestions = (
BUILTIN_OUTBOUND_TAGS + SettingsManager.getProfileRemarks(
excludeConfigTypes = setOf(EConfigType.CUSTOM, EConfigType.POLICYGROUP)
)
).filter { it != TAG_PROXY }
initialRemarks = config?.remarks ?: ""
initialFilter = config?.policyGroupFilter ?: ""
initialType = config?.policyGroupType?.toIntOrNull() ?: 0
initialTestOutbounds = config == null || config.policyGroupTestOutbounds != false ||
!BalancerStrategyType.from(config.policyGroupType).supportsObservatory
initialFallbackTag = config?.policyGroupFallbackTag.orEmpty()
initialSubIndex = if (config != null) {
subIds.indexOf(config.policyGroupSubscriptionId ?: "").let { if (it >= 0) it else 0 }
} else if (subscriptionId.isNotNullEmpty()) {
@@ -77,8 +95,13 @@ class ServerGroupActivity : BaseComponentActivity() {
initialFilter = initialFilter,
initialType = initialType,
initialSubIndex = initialSubIndex,
initialTestOutbounds = initialTestOutbounds,
initialFallbackTag = initialFallbackTag,
fallbackSuggestions = fallbackSuggestions,
onBackClick = { finish() },
onSave = { remarks, filter, typeIdx, subIdx -> saveServer(remarks, filter, typeIdx, subIdx) },
onSave = { remarks, filter, typeIdx, subIdx, testOutbounds, fallbackTag ->
saveServer(remarks, filter, typeIdx, subIdx, testOutbounds, fallbackTag)
},
onDelete = { deleteServer() }
)
}
@@ -87,7 +110,9 @@ class ServerGroupActivity : BaseComponentActivity() {
remarks: String,
filter: String,
typeIdx: Int,
subIdx: Int
subIdx: Int,
testOutbounds: Boolean,
fallbackTag: String,
): Boolean {
if (remarks.isBlank()) {
toast(R.string.server_lab_remarks)
@@ -103,6 +128,8 @@ class ServerGroupActivity : BaseComponentActivity() {
config.policyGroupType = typeIdx.toString()
config.policyGroupSubscriptionId =
subIds.getOrNull(subIdx)
config.policyGroupTestOutbounds = testOutbounds
config.policyGroupFallbackTag = fallbackTag.trim().takeIf { it.isNotEmpty() }
if (
config.subscriptionId.isEmpty() &&
@@ -189,8 +216,11 @@ fun ServerGroupScreen(
initialFilter: String,
initialType: Int,
initialSubIndex: Int,
initialTestOutbounds: Boolean,
initialFallbackTag: String,
fallbackSuggestions: List<String>,
onBackClick: () -> Unit,
onSave: (String, String, Int, Int) -> Boolean,
onSave: (String, String, Int, Int, Boolean, String) -> Boolean,
onDelete: () -> Unit
) {
val typeEntries = stringArrayResource(R.array.policy_group_type).toList()
@@ -199,8 +229,12 @@ fun ServerGroupScreen(
var filter by rememberSaveable { mutableStateOf(initialFilter) }
var typeValue by rememberSaveable { mutableStateOf(typeEntries.getOrNull(initialType).orEmpty()) }
var subValue by rememberSaveable { mutableStateOf(subDisplay.getOrNull(initialSubIndex).orEmpty()) }
var testOutbounds by rememberSaveable { mutableStateOf(initialTestOutbounds) }
var fallbackTag by rememberSaveable { mutableStateOf(initialFallbackTag) }
var showDeleteConfirm by remember { mutableStateOf(false) }
val showDelete = editGuid.isNotEmpty() && !isRunning
val selectedType = typeEntries.indexOf(typeValue).coerceAtLeast(0).toString()
val supportsObservatory = BalancerStrategyType.from(selectedType).supportsObservatory
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
@@ -217,7 +251,7 @@ fun ServerGroupScreen(
IconButton(onClick = {
val typeIdx = typeEntries.indexOf(typeValue).coerceAtLeast(0)
val subIdx = subDisplay.indexOf(subValue).coerceAtLeast(0)
onSave(remarks, filter, typeIdx, subIdx)
onSave(remarks, filter, typeIdx, subIdx, testOutbounds, fallbackTag)
}) {
Icon(painterResource(R.drawable.ic_fab_check), contentDescription = stringResource(R.string.menu_item_save_config))
}
@@ -232,6 +266,7 @@ fun ServerGroupScreen(
.consumeWindowInsets(innerPadding)
.imePadding()
.padding(vertical = 8.dp)
.verticalScroll(rememberScrollState())
) {
FormTextField(stringResource(R.string.server_lab_remarks), remarks, { remarks = it })
FormDropdownField(
@@ -247,6 +282,22 @@ fun ServerGroupScreen(
onValueChange = { subValue = it }
)
FormTextField(stringResource(R.string.title_policy_group_subscription_filter), filter, { filter = it })
if (supportsObservatory) {
SettingsSwitchItem(
title = stringResource(R.string.title_policy_group_test_outbounds),
checked = testOutbounds,
onCheckedChange = { testOutbounds = it }
)
if (testOutbounds) {
FormDropdownField(
label = stringResource(R.string.title_policy_group_fallback),
value = fallbackTag,
options = fallbackSuggestions,
onValueChange = { fallbackTag = it },
editable = true
)
}
}
}
}
@@ -387,6 +387,8 @@
<string name="title_policy_group_type">Policy group type</string>
<string name="title_policy_group_subscription_id">From subscription group</string>
<string name="title_policy_group_subscription_filter">Remarks regular filter</string>
<string name="title_policy_group_test_outbounds">اختبار المخارج</string>
<string name="title_policy_group_fallback">المخرج الاحتياطي (اختياري)</string>
<string name="server_proxy_chain_members">اعضاء سلسلة الوكيل</string>
<string name="server_proxy_chain_pick_members">اضغط هنا لاختيار عضو</string>
<string name="server_proxy_chain_member_unselected">اختر عضوا</string>
@@ -386,6 +386,8 @@
<string name="title_policy_group_type">Policy group type</string>
<string name="title_policy_group_subscription_id">From subscription group</string>
<string name="title_policy_group_subscription_filter">Remarks regular filter</string>
<string name="title_policy_group_test_outbounds">আউটবাউন্ড পরীক্ষা করুন</string>
<string name="title_policy_group_fallback">ফলব্যাক আউটবাউন্ড (ঐচ্ছিক)</string>
<string name="server_proxy_chain_members">Proxy chain members</string>
<string name="server_proxy_chain_pick_members">Tap here to pick member</string>
<string name="server_proxy_chain_member_unselected">Select a member</string>
@@ -392,6 +392,8 @@
<string name="title_policy_group_type">نوع بونکۊ سیاست</string>
<string name="title_policy_group_subscription_id">ز بونکۊ اشتراک</string>
<string name="title_policy_group_subscription_filter">توزیهات فیلتر معمۊلی</string>
<string name="title_policy_group_test_outbounds">آزمایش بارتا</string>
<string name="title_policy_group_fallback">بارت جایگزین (اختیاری)</string>
<string name="server_proxy_chain_members">ٱئزا زنجیره پروکسی</string>
<string name="server_proxy_chain_pick_members">سی پسند عوزو ایچو بزنین</string>
<string name="server_proxy_chain_member_unselected">ی عوزو پسند کۊنین</string>
@@ -383,6 +383,8 @@
<string name="title_policy_group_type">نوع گروه خط مشی</string>
<string name="title_policy_group_subscription_id">از گروه اشتراک</string>
<string name="title_policy_group_subscription_filter">توضیحات فیلتر معمولی</string>
<string name="title_policy_group_test_outbounds">آزمایش خروجی‌ها</string>
<string name="title_policy_group_fallback">خروجی جایگزین (اختیاری)</string>
<string name="server_proxy_chain_members">اعضای زنجیره پروکسی</string>
<string name="server_proxy_chain_pick_members">برای انتخاب عضو اینجا بزنید</string>
<string name="server_proxy_chain_member_unselected">یک عضو را انتخاب کنید</string>
@@ -398,6 +398,8 @@
<string name="title_policy_group_type">Тип политики группы</string>
<string name="title_policy_group_subscription_id">Из группы подписки</string>
<string name="title_policy_group_subscription_filter">Название фильтра</string>
<string name="title_policy_group_test_outbounds">Проверять исходящие соединения</string>
<string name="title_policy_group_fallback">Резервное исходящее соединение (необязательно)</string>
<string name="server_proxy_chain_members">Участники цепочки прокси</string>
<string name="server_proxy_chain_pick_members">Нажмите здесь, чтобы выбрать участника</string>
@@ -388,6 +388,8 @@
<string name="title_policy_group_type">Policy group type</string>
<string name="title_policy_group_subscription_id">From subscription group</string>
<string name="title_policy_group_subscription_filter">Remarks regular filter</string>
<string name="title_policy_group_test_outbounds">Kiểm tra outbound</string>
<string name="title_policy_group_fallback">Outbound dự phòng (tùy chọn)</string>
<string name="server_proxy_chain_members">Proxy chain members</string>
<string name="server_proxy_chain_pick_members">Tap here to pick member</string>
<string name="server_proxy_chain_member_unselected">Select a member</string>
@@ -402,6 +402,8 @@
<string name="title_policy_group_type">策略组类型</string>
<string name="title_policy_group_subscription_id">来自订阅分组</string>
<string name="title_policy_group_subscription_filter">别名正则过滤</string>
<string name="title_policy_group_test_outbounds">测试出站</string>
<string name="title_policy_group_fallback">备用出站(可选)</string>
<string name="server_proxy_chain_members">代理链成员</string>
<string name="server_proxy_chain_pick_members">点此选择成员</string>
<string name="server_proxy_chain_member_unselected">请选择成员</string>
@@ -400,6 +400,8 @@
<string name="title_policy_group_type">策略群組類型</string>
<string name="title_policy_group_subscription_id">來自訂閱分組</string>
<string name="title_policy_group_subscription_filter">別名正規過濾</string>
<string name="title_policy_group_test_outbounds">測試出口</string>
<string name="title_policy_group_fallback">備用出口(選填)</string>
<string name="server_proxy_chain_members">代理鏈成員</string>
<string name="server_proxy_chain_pick_members">點此選擇成員</string>
<string name="server_proxy_chain_member_unselected">請選擇成員</string>
@@ -407,6 +407,8 @@
<string name="title_policy_group_type">Policy group type</string>
<string name="title_policy_group_subscription_id">From subscription group</string>
<string name="title_policy_group_subscription_filter">Remarks regular filter</string>
<string name="title_policy_group_test_outbounds">Test outbounds</string>
<string name="title_policy_group_fallback">Fallback outbound (optional)</string>
<string name="server_proxy_chain_members">Proxy chain members</string>
<string name="server_proxy_chain_pick_members">Tap here to pick member</string>