diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/core/CoreConfigContextBuilder.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/core/CoreConfigContextBuilder.kt index 58689092..42ed6698 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/core/CoreConfigContextBuilder.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/core/CoreConfigContextBuilder.kt @@ -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): List { + 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() + } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/core/CoreConfigManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/core/CoreConfigManager.kt index 7921ec8e..6a5a922b 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/core/CoreConfigManager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/core/CoreConfigManager.kt @@ -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, 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, diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/ProfileItem.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/ProfileItem.kt index 5c790d76..3c01b89e 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/ProfileItem.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/ProfileItem.kt @@ -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, diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/enums/BalancerStrategyType.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/enums/BalancerStrategyType.kt index 6e30cf71..401288b0 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/enums/BalancerStrategyType.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/enums/BalancerStrategyType.kt @@ -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 } -} \ No newline at end of file +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/server/ServerGroupActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/server/ServerGroupActivity.kt index 0caf5196..4e807dc2 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/server/ServerGroupActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/server/ServerGroupActivity.kt @@ -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() private val subDisplay = mutableListOf() + private lateinit var fallbackSuggestions: List 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, 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 + ) + } + } } } diff --git a/V2rayNG/app/src/main/res/values-ar/strings.xml b/V2rayNG/app/src/main/res/values-ar/strings.xml index 6f190365..1b497535 100644 --- a/V2rayNG/app/src/main/res/values-ar/strings.xml +++ b/V2rayNG/app/src/main/res/values-ar/strings.xml @@ -387,6 +387,8 @@ Policy group type From subscription group Remarks regular filter + اختبار المخارج + المخرج الاحتياطي (اختياري) اعضاء سلسلة الوكيل اضغط هنا لاختيار عضو اختر عضوا diff --git a/V2rayNG/app/src/main/res/values-bn/strings.xml b/V2rayNG/app/src/main/res/values-bn/strings.xml index 61da2f06..0be9ab1e 100644 --- a/V2rayNG/app/src/main/res/values-bn/strings.xml +++ b/V2rayNG/app/src/main/res/values-bn/strings.xml @@ -386,6 +386,8 @@ Policy group type From subscription group Remarks regular filter + আউটবাউন্ড পরীক্ষা করুন + ফলব্যাক আউটবাউন্ড (ঐচ্ছিক) Proxy chain members Tap here to pick member Select a member diff --git a/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml b/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml index e00978c2..c2db579c 100644 --- a/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml +++ b/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml @@ -392,6 +392,8 @@ نوع بونکۊ سیاست ز بونکۊ اشتراک توزیهات فیلتر معمۊلی + آزمایش بارتا + بارت جایگزین (اختیاری) ٱئزا زنجیره پروکسی سی پسند عوزو ایچو بزنین ی عوزو پسند کۊنین diff --git a/V2rayNG/app/src/main/res/values-fa/strings.xml b/V2rayNG/app/src/main/res/values-fa/strings.xml index 5325fb6c..dcdfaa1e 100644 --- a/V2rayNG/app/src/main/res/values-fa/strings.xml +++ b/V2rayNG/app/src/main/res/values-fa/strings.xml @@ -383,6 +383,8 @@ نوع گروه خط مشی از گروه اشتراک توضیحات فیلتر معمولی + آزمایش خروجی‌ها + خروجی جایگزین (اختیاری) اعضای زنجیره پروکسی برای انتخاب عضو اینجا بزنید یک عضو را انتخاب کنید diff --git a/V2rayNG/app/src/main/res/values-ru/strings.xml b/V2rayNG/app/src/main/res/values-ru/strings.xml index bd28b984..47a774f7 100644 --- a/V2rayNG/app/src/main/res/values-ru/strings.xml +++ b/V2rayNG/app/src/main/res/values-ru/strings.xml @@ -398,6 +398,8 @@ Тип политики группы Из группы подписки Название фильтра + Проверять исходящие соединения + Резервное исходящее соединение (необязательно) Участники цепочки прокси Нажмите здесь, чтобы выбрать участника diff --git a/V2rayNG/app/src/main/res/values-vi/strings.xml b/V2rayNG/app/src/main/res/values-vi/strings.xml index 51159769..cc0fe944 100644 --- a/V2rayNG/app/src/main/res/values-vi/strings.xml +++ b/V2rayNG/app/src/main/res/values-vi/strings.xml @@ -388,6 +388,8 @@ Policy group type From subscription group Remarks regular filter + Kiểm tra outbound + Outbound dự phòng (tùy chọn) Proxy chain members Tap here to pick member Select a member diff --git a/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml b/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml index 5b53d02d..d53d1242 100644 --- a/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml +++ b/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml @@ -402,6 +402,8 @@ 策略组类型 来自订阅分组 别名正则过滤 + 测试出站 + 备用出站(可选) 代理链成员 点此选择成员 请选择成员 diff --git a/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml b/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml index b06d355e..03fc003f 100644 --- a/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml +++ b/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml @@ -400,6 +400,8 @@ 策略群組類型 來自訂閱分組 別名正規過濾 + 測試出口 + 備用出口(選填) 代理鏈成員 點此選擇成員 請選擇成員 diff --git a/V2rayNG/app/src/main/res/values/strings.xml b/V2rayNG/app/src/main/res/values/strings.xml index 3908b72b..0cffd237 100644 --- a/V2rayNG/app/src/main/res/values/strings.xml +++ b/V2rayNG/app/src/main/res/values/strings.xml @@ -407,6 +407,8 @@ Policy group type From subscription group Remarks regular filter + Test outbounds + Fallback outbound (optional) Proxy chain members Tap here to pick member