Skip to content
4 changes: 2 additions & 2 deletions V2rayNG/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@
android:name=".service.CoreTestService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":RunSoLibV2RayDaemon">
android:process=":Probe">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="test" />
Expand All @@ -266,7 +266,7 @@
android:name=".service.SubscriptionUpdateService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":RunSoLibV2RayDaemon">
android:process=":SubscriptionUpdate">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="test" />
Expand Down
148 changes: 126 additions & 22 deletions V2rayNG/app/src/main/java/com/v2ray/ang/core/CoreConfigContextBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.dto.entities.SubscriptionItem
import com.v2ray.ang.enums.BalancerStrategyType
import com.v2ray.ang.enums.CoreResolvedType
import com.v2ray.ang.enums.EConfigType
Expand All @@ -23,6 +24,68 @@ import com.v2ray.ang.util.Utils
*/
object CoreConfigContextBuilder {

/** Lazily decoded profile snapshot shared by every config in one probe batch. */
internal class ProbeProfileLookup(requestedGuids: List<String>) {
private val profilesByGuid = linkedMapOf<String, ProfileItem>()
private val subscriptionsByGuid = mutableMapOf<String, SubscriptionItem?>()
private var profilesByRemarks: Map<String, ProfileItem>? = null
private var allProfiles: List<ProfileItem>? = null

init {
requestedGuids.forEach(::loadProfile)
}

fun findByGuid(guid: String): ProfileItem? =
profilesByGuid[guid] ?: loadProfile(guid)

fun findByRemarks(remarks: String?): ProfileItem? {
if (remarks.isNullOrEmpty()) return null
ensureAllProfilesLoaded()
return profilesByRemarks?.get(remarks)
}

fun profiles(): List<ProfileItem> {
ensureAllProfilesLoaded()
return allProfiles.orEmpty()
}

fun subscription(guid: String): SubscriptionItem? {
if (guid in subscriptionsByGuid) return subscriptionsByGuid[guid]
return MmkvManager.decodeSubscription(guid).also { subscriptionsByGuid[guid] = it }
}

private fun loadProfile(guid: String): ProfileItem? {
if (guid.isBlank()) return null
return MmkvManager.decodeServerConfig(guid)?.also { profilesByGuid[guid] = it }
}

private fun ensureAllProfilesLoaded() {
if (allProfiles != null) return
val ordered = mutableListOf<ProfileItem>()
val seenGuids = mutableSetOf<String>()
MmkvManager.decodeAllServerList().forEach { guid ->
val profile = findByGuid(guid) ?: return@forEach
if (seenGuids.add(guid)) ordered += profile
}
profilesByGuid.forEach { (guid, profile) ->
if (seenGuids.add(guid)) ordered += profile
}
allProfiles = ordered
profilesByRemarks = buildMap {
ordered.forEach { profile -> putIfAbsent(profile.remarks, profile) }
}
}
}

private fun findProfileByRemarks(
lookup: ProbeProfileLookup?,
remarks: String?,
): ProfileItem? = if (lookup != null) {
lookup.findByRemarks(remarks)
} else {
SettingsManager.getServerViaRemarks(remarks)
}

/**
* Load one profile and produce a fully analyzed context.
*
Expand All @@ -31,22 +94,42 @@ object CoreConfigContextBuilder {
fun build(context: Context, guid: String): CoreConfigContext? {
val config = MmkvManager.decodeServerConfig(guid) ?: return null

return buildResolved(context, guid, config, lookup = null, includeRouting = true)
}

/** Build only the outbound dependency graph required by a RealDelay probe. */
internal fun buildForProbe(
context: Context,
guid: String,
lookup: ProbeProfileLookup,
): CoreConfigContext? {
val config = lookup.findByGuid(guid) ?: return null
return buildResolved(context, guid, config, lookup, includeRouting = false)
}

private fun buildResolved(
context: Context,
guid: String,
config: ProfileItem,
lookup: ProbeProfileLookup?,
includeRouting: Boolean,
): CoreConfigContext? {
// CUSTOM: return immediately — CoreConfigManager handles this path on its own.
if (config.configType == EConfigType.CUSTOM) {
return CoreConfigContext(context = context, guid = guid, isCustom = true)
}

// Step 1: Resolve the main outbound (always tag = TAG_PROXY).
val primaryResolvedOutbound = resolveOutbound(AppConfig.TAG_PROXY, config) ?: run {
val primaryResolvedOutbound = resolveOutbound(AppConfig.TAG_PROXY, config, lookup) ?: run {
LogUtil.e(AppConfig.TAG, "Failed to resolve main outbound for '${config.remarks}'")
return null
}

// Step 2: Resolve all non-builtin routing outbound tags.
val routingResolvedOutbounds = resolveRoutingOutbounds()
val routingResolvedOutbounds = if (includeRouting) resolveRoutingOutbounds() else emptyList()
val resolvedOutbounds = listOf(primaryResolvedOutbound) + routingResolvedOutbounds
val fallbackResolvedOutbounds = resolveFallbackOutbounds(resolvedOutbounds)
val routingDomainRules = collectRoutingDomainRulesForDns()
val fallbackResolvedOutbounds = resolveFallbackOutbounds(resolvedOutbounds, lookup)
val routingDomainRules = if (includeRouting) collectRoutingDomainRulesForDns() else emptyList()

return CoreConfigContext(
context = context,
Expand All @@ -61,25 +144,29 @@ object CoreConfigContextBuilder {
*
* Custom profiles are ignored at this stage and produce no entry.
*/
private fun resolveOutbound(tag: String, profile: ProfileItem): CoreConfigContext.ResolvedOutbound? {
private fun resolveOutbound(
tag: String,
profile: ProfileItem,
lookup: ProbeProfileLookup? = null,
): CoreConfigContext.ResolvedOutbound? {
if (profile.configType == EConfigType.CUSTOM) {
return null
}

val (resolvedProfiles, resolvedType) = when (profile.configType) {
EConfigType.POLICYGROUP -> Pair(
resolvePolicyGroupProfiles(profile),
resolvePolicyGroupProfiles(profile, lookup),
CoreResolvedType.POLICYGROUP,
)

EConfigType.PROXYCHAIN -> {
val chainProfiles = resolveProxyChainProfiles(profile)
val chainProfiles = resolveProxyChainProfiles(profile, lookup)
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
Pair(chainProfiles, type)
}

else -> {
val chainProfiles = resolveProxyChainProfilesFromGroup(profile)
val chainProfiles = resolveProxyChainProfilesFromGroup(profile, lookup)
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
Pair(chainProfiles, type)
}
Expand Down Expand Up @@ -141,12 +228,14 @@ object CoreConfigContextBuilder {
return resolvedOutbounds
}

private fun resolvePolicyGroupProfiles(config: ProfileItem): List<ProfileItem> {
private fun resolvePolicyGroupProfiles(
config: ProfileItem,
lookup: ProbeProfileLookup?,
): List<ProfileItem> {
try {
val serverList = MmkvManager.decodeAllServerList()
return serverList
.asSequence()
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
val profiles = lookup?.profiles() ?: MmkvManager.decodeAllServerList()
.mapNotNull(MmkvManager::decodeServerConfig)
return profiles.asSequence()
.filter { profile ->
val subscriptionId = config.policyGroupSubscriptionId
if (subscriptionId.isNullOrBlank()) {
Expand Down Expand Up @@ -177,15 +266,18 @@ object CoreConfigContextBuilder {
}
}

private fun resolveProxyChainProfiles(config: ProfileItem): List<ProfileItem> {
private fun resolveProxyChainProfiles(
config: ProfileItem,
lookup: ProbeProfileLookup?,
): List<ProfileItem> {
if (config.proxyChainProfiles.isNullOrBlank()) {
return listOf(config)
}

try {
return config.proxyChainProfiles.orEmpty().split(",")
.asSequence()
.mapNotNull { remark -> SettingsManager.getServerViaRemarks(remark) }
.mapNotNull { remark -> findProfileByRemarks(lookup, remark) }
.filter { it.server.isNotNullEmpty() }
.filter { Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { !it.configType.isComplexType() }
Expand All @@ -202,17 +294,26 @@ object CoreConfigContextBuilder {
*
* When no chain is available, return a single-node result.
*/
private fun resolveProxyChainProfilesFromGroup(config: ProfileItem): List<ProfileItem> {
private fun resolveProxyChainProfilesFromGroup(
config: ProfileItem,
lookup: ProbeProfileLookup?,
): List<ProfileItem> {
if (config.subscriptionId.isEmpty()) {
return listOf(config)
}

try {
val subItem = MmkvManager.decodeSubscription(config.subscriptionId) ?: return listOf(config)
val subItem = if (lookup != null) {
lookup.subscription(config.subscriptionId)
} else {
MmkvManager.decodeSubscription(config.subscriptionId)
} ?: return listOf(config)
val resolved = mutableListOf<ProfileItem>()
SettingsManager.getServerViaRemarks(subItem.nextProfile)?.let { resolved.add(it) }
findProfileByRemarks(lookup, subItem.nextProfile)
?.let { resolved.add(it) }
resolved.add(config)
SettingsManager.getServerViaRemarks(subItem.prevProfile)?.let { resolved.add(it) }
findProfileByRemarks(lookup, subItem.prevProfile)
?.let { resolved.add(it) }
return resolved
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain from group for '${config.remarks}'", e)
Expand Down Expand Up @@ -255,7 +356,10 @@ object CoreConfigContextBuilder {
*
* Fallback targets must not overlap with already resolved tags or builtin tags.
*/
private fun resolveFallbackOutbounds(resolvedOutbounds: List<CoreConfigContext.ResolvedOutbound>): List<CoreConfigContext.ResolvedOutbound> {
private fun resolveFallbackOutbounds(
resolvedOutbounds: List<CoreConfigContext.ResolvedOutbound>,
lookup: ProbeProfileLookup?,
): List<CoreConfigContext.ResolvedOutbound> {
return resolvedOutbounds
.asSequence()
.filter { it.resolvedType == CoreResolvedType.POLICYGROUP }
Expand All @@ -264,9 +368,9 @@ object CoreConfigContextBuilder {
.filter { it !in AppConfig.BUILTIN_OUTBOUND_TAGS && resolvedOutbounds.none { outbound -> outbound.tag == it } }
.distinct()
.mapNotNull { tag ->
SettingsManager.getServerViaRemarks(tag)
findProfileByRemarks(lookup, tag)
?.takeUnless { it.configType == EConfigType.CUSTOM || it.configType == EConfigType.POLICYGROUP }
?.let { resolveOutbound(tag, it) }
?.let { resolveOutbound(tag, it, lookup) }
}
.toList()
}
Expand Down
62 changes: 53 additions & 9 deletions V2rayNG/app/src/main/java/com/v2ray/ang/core/CoreConfigManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.google.gson.JsonObject
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ConfigResult
import com.v2ray.ang.dto.CoreConfigContext
import com.v2ray.ang.dto.ProbePlan
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.RulesetItem
Expand Down Expand Up @@ -58,7 +59,7 @@ object CoreConfigManager {
*
* The core flow is reused, then non-essential sections are removed.
*/
fun getV2rayConfig4Speedtest(context: Context, guid: String): ConfigResult {
fun getV2rayConfig4RealDelay(context: Context, guid: String): ConfigResult {
try {
val configContext = CoreConfigContextBuilder.build(context, guid)
?: return ConfigResult(
Expand All @@ -69,20 +70,52 @@ object CoreConfigManager {
if (configContext.isCustom) {
return buildV2rayCustomConfig(configContext)
}
val v2rayConfig = buildUnifiedConfig(configContext)
postProcessForSpeedtest(v2rayConfig)

return toConfigResult(configContext, v2rayConfig)
return toConfigResult(configContext, buildRealDelayConfig(configContext))
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config for speedtest", e)
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config for real delay", e)
return ConfigResult(
status = false,
guid = guid,
errorMessage = "Failed to get V2ray config: ${e.message ?: e.javaClass.simpleName}"
errorMessage = "Failed to get V2ray config for real delay: ${e.message ?: e.javaClass.simpleName}"
)
}
}

/** Builds one isolated Xray configuration for a complete UI delay-test batch. */
internal fun getProbePlan(context: Context, guids: List<String>): ProbePlan {
val sources = mutableListOf<ProbeConfigBuilder.Source>()
val individualGuids = mutableListOf<String>()
val failedGuids = mutableListOf<String>()
val distinctGuids = guids.distinct()
val profileLookup = CoreConfigContextBuilder.ProbeProfileLookup(distinctGuids)
distinctGuids.forEach { guid ->
try {
val configContext = CoreConfigContextBuilder.buildForProbe(context, guid, profileLookup)
if (configContext == null) {
failedGuids += guid
} else if (configContext.isCustom) {
individualGuids += guid
} else {
sources += ProbeConfigBuilder.Source(guid, buildRealDelayConfig(configContext))
}
} catch (error: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to build probe config for $guid", error)
failedGuids += guid
}
}
val plan = ProbeConfigBuilder.build(
sources = sources,
destination = SettingsManager.getDelayTestUrl(),
)
return plan.copy(
individualGuids = individualGuids + plan.individualGuids,
failedGuids = failedGuids,
)
}

private fun buildRealDelayConfig(configContext: CoreConfigContext): V2rayConfig =
buildUnifiedConfig(configContext).also(::postProcessForRealDelay)

/**
* Build configuration for custom profiles.
*/
Expand Down Expand Up @@ -432,10 +465,21 @@ object CoreConfigManager {
/**
* Trim runtime sections that are not needed for latency testing.
*/
private fun postProcessForSpeedtest(v2rayConfig: V2rayConfig) {
private fun postProcessForRealDelay(v2rayConfig: V2rayConfig) {
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
v2rayConfig.inbounds.clear()
val usesPrimaryBalancer = v2rayConfig.routing.balancers
?.any { it.tag == AppConfig.TAG_BALANCER }
?: false
v2rayConfig.routing.rules.clear()
if (usesPrimaryBalancer) {
v2rayConfig.routing.rules.add(
V2rayConfig.RoutingBean.RulesBean(
network = "tcp,udp",
balancerTag = AppConfig.TAG_BALANCER,
)
)
}
v2rayConfig.dns = null
v2rayConfig.fakedns = null
v2rayConfig.stats = null
Expand Down Expand Up @@ -704,7 +748,7 @@ object CoreConfigManager {
}

/**
* Remove speed-test runtime sections when the feature is disabled.
* Remove speed-display runtime sections when the feature is disabled.
*/
private fun applySpeedDisabled(v2rayConfig: V2rayConfig) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
Expand Down
Loading