Pre-resolve and inject custom outbounds

Pre-resolve outbound profiles referenced by routing rules and pass them through the runtime context so they can be injected into generated V2ray configs.

Key changes:
- Added customOutboundProfiles: Map<String, ProfileItem> to CoreConfigContext.
- CoreConfigContextBuilder: new resolveCustomOutbounds() to collect non-builtin outbound tags from routing rulesets and build the map; builder now populates customOutboundProfiles and simplifies resolvedProfiles/type handling.
- CoreConfigManager: refactored many APIs to accept CoreConfigContext instead of raw Context/guid (toConfigResult, initV2rayConfig, getRouting, getOutbounds, getBalance, etc.), and uses the pre-resolved customOutboundProfiles when injecting custom outbounds.
- injectCustomOutbounds now accepts the pre-resolved map and converts ProfileItem -> Outbound only for missing tags.
- Added logging/error handling and minor tidying to avoid repeated lookups and ensure custom outbounds are available before routing is applied.

Rationale: ensure outbounds referenced by rulesets are available early in the build pipeline, reduce duplicate lookups, and centralize runtime data in CoreConfigContext.
This commit is contained in:
2dust committed 2026-05-02 14:47:03 +08:00
1 parent dc1d3a27dd
commit 3e823a7440
3 files changed
+113 -79

No files matched your search

@@ -9,6 +9,7 @@ import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
/**
@@ -20,38 +21,42 @@ object CoreConfigContextBuilder {
/** Loads profile by guid and returns a resolved runtime context. */
fun build(context: Context, guid: String): CoreConfigContext? {
val config = MmkvManager.decodeServerConfig(guid) ?: return null
return when (config.configType) {
EConfigType.CUSTOM -> CoreConfigContext(
if (config.configType == EConfigType.CUSTOM) {
return CoreConfigContext(
context = context,
guid = guid,
selectedProfile = config,
resolvedProfiles = listOf(config),
resolvedType = CoreResolvedType.CUSTOM,
)
}
// Pre-resolve custom outbound profiles from routing rulesets
val customOutbounds = resolveCustomOutbounds()
// Determine resolved profiles and type based on config type
val (resolvedProfiles, resolvedType) = when (config.configType) {
EConfigType.POLICYGROUP -> {
val profiles = resolvePolicyGroupProfiles(config)
CoreConfigContext(
context = context,
guid = guid,
selectedProfile = config,
resolvedProfiles = profiles,
resolvedType = CoreResolvedType.POLICYGROUP,
)
Pair(profiles, CoreResolvedType.POLICYGROUP)
}
else -> {
val chainProfiles = resolveProxyChainProfiles(config)
CoreConfigContext(
context = context,
guid = guid,
selectedProfile = config,
resolvedProfiles = chainProfiles,
resolvedType = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN,
)
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
Pair(chainProfiles, type)
}
}
// Create context with common fields
return CoreConfigContext(
context = context,
guid = guid,
selectedProfile = config,
resolvedProfiles = resolvedProfiles,
resolvedType = resolvedType,
customOutboundProfiles = customOutbounds,
)
}
/** Resolves policy-group members with the same filters as runtime build. */
@@ -110,5 +115,43 @@ object CoreConfigContextBuilder {
return resolved
}
/**
* Resolves custom outbound profiles from routing rulesets.
* Scans rulesets for non-builtin outbound tags and looks up matching profiles by remarks.
* Returns a map of tag -> ProfileItem for independent processing.
*/
private fun resolveCustomOutbounds(): Map<String, ProfileItem> {
val customMap = mutableMapOf<String, ProfileItem>()
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return customMap
try {
val processedTags = mutableSetOf<String>()
rulesetItems
.filter { it.enabled }
.mapNotNull { it.outboundTag.takeIf { tag -> tag.isNotBlank() } }
.filter { tag -> tag !in AppConfig.BUILTIN_OUTBOUND_TAGS }
.distinct()
.forEach { tag ->
if (tag in processedTags) return@forEach
processedTags.add(tag)
try {
val profile = SettingsManager.getServerViaRemarks(tag) ?: run {
return@forEach
}
customMap[tag] = profile
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve custom outbound for tag '$tag', skipping", e)
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve custom outbound profiles", e)
}
return customMap
}
}
@@ -46,7 +46,7 @@ object CoreConfigManager {
CoreResolvedType.CUSTOM -> null
} ?: return ConfigResult(false)
return toConfigResult(configContext.guid, v2rayConfig)
return toConfigResult(configContext, v2rayConfig)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config", e)
return ConfigResult(false)
@@ -78,7 +78,7 @@ object CoreConfigManager {
postProcessForSpeedtest(v2rayConfig)
return toConfigResult(configContext.guid, v2rayConfig)
return toConfigResult(configContext, v2rayConfig)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config for speedtest", e)
return ConfigResult(false)
@@ -90,9 +90,8 @@ object CoreConfigManager {
*/
private fun getV2rayCustomConfig(configContext: CoreConfigContext): ConfigResult {
val context = configContext.context
val guid = configContext.guid
val raw = MmkvManager.decodeServerRaw(guid) ?: return ConfigResult(false)
val result = ConfigResult(true, guid, raw)
val raw = MmkvManager.decodeServerRaw(configContext.guid) ?: return ConfigResult(false)
val result = ConfigResult(true, configContext.guid, raw)
if (!needTun()) {
return result
}
@@ -128,7 +127,7 @@ object CoreConfigManager {
if (tunNotExists) {
// add tun inbound from template
initV2rayConfig(context)?.let { templateConfig ->
initV2rayConfig(configContext)?.let { templateConfig ->
templateConfig.inbounds.firstOrNull { it.tag == "tun" }?.let { inboundTun ->
inboundTun.settings?.mtu = SettingsManager.getVpnMtu()
inboundsJson.add(JsonUtil.parseString(JsonUtil.toJson(inboundTun)))
@@ -136,12 +135,11 @@ object CoreConfigManager {
}
}
return JsonUtil.toJsonPretty(json)?.let { ConfigResult(true, guid, it) } ?: result
return JsonUtil.toJsonPretty(json)?.let { ConfigResult(true, configContext.guid, it) } ?: result
}
/** Builds full config for policy-group mode. */
private fun buildGroupConfig(configContext: CoreConfigContext): V2rayConfig? {
val context = configContext.context
val config = configContext.selectedProfile
val validConfigs = configContext.resolvedProfiles
@@ -150,7 +148,7 @@ object CoreConfigManager {
return null
}
val v2rayConfig = initV2rayConfig(context) ?: return null
val v2rayConfig = initV2rayConfig(configContext) ?: return null
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
v2rayConfig.remarks = config.remarks
@@ -168,10 +166,10 @@ object CoreConfigManager {
outboundsList.addAll(v2rayConfig.outbounds)
v2rayConfig.outbounds = ArrayList(outboundsList)
getRouting(context, v2rayConfig)
getRouting(configContext, v2rayConfig)
getFakeDns(v2rayConfig)
getDns(v2rayConfig)
getBalance(v2rayConfig, config)
getBalance(configContext, v2rayConfig)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED)) {
getCustomLocalDns(v2rayConfig)
@@ -196,7 +194,6 @@ object CoreConfigManager {
* with dialerProxy.
*/
private fun buildProxyChainConfig(configContext: CoreConfigContext): V2rayConfig? {
val context = configContext.context
val config = configContext.selectedProfile
val resolvedProfiles = configContext.resolvedProfiles
@@ -206,7 +203,7 @@ object CoreConfigManager {
return null
}
val v2rayConfig = initV2rayConfig(context) ?: return null
val v2rayConfig = initV2rayConfig(configContext) ?: return null
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
v2rayConfig.remarks = config.remarks
@@ -234,7 +231,7 @@ object CoreConfigManager {
}
v2rayConfig.outbounds = ArrayList(chainOutbounds + builtinOutbounds)
getRouting(context, v2rayConfig)
getRouting(configContext, v2rayConfig)
getFakeDns(v2rayConfig)
getDns(v2rayConfig)
@@ -256,7 +253,6 @@ object CoreConfigManager {
/** Builds full config for normal single-node mode. */
private fun buildNormalConfig(configContext: CoreConfigContext): V2rayConfig? {
val context = configContext.context
val config = configContext.selectedProfile
val address = config.server ?: return null
@@ -265,13 +261,13 @@ object CoreConfigManager {
return null
}
val v2rayConfig = initV2rayConfig(context) ?: return null
val v2rayConfig = initV2rayConfig(configContext) ?: return null
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
v2rayConfig.remarks = config.remarks
getInbounds(v2rayConfig)
getOutbounds(v2rayConfig, config) ?: return null
getRouting(context, v2rayConfig)
getOutbounds(configContext, v2rayConfig) ?: return null
getRouting(configContext, v2rayConfig)
getFakeDns(v2rayConfig)
getDns(v2rayConfig)
@@ -306,10 +302,10 @@ object CoreConfigManager {
}
/** Converts a built config object into a unified result payload. */
private fun toConfigResult(guid: String, v2rayConfig: V2rayConfig): ConfigResult {
private fun toConfigResult(configContext: CoreConfigContext, v2rayConfig: V2rayConfig): ConfigResult {
return ConfigResult(
status = true,
guid = guid,
guid = configContext.guid,
content = JsonUtil.toJsonPretty(v2rayConfig) ?: ""
)
}
@@ -321,10 +317,11 @@ object CoreConfigManager {
* It first attempts to use the cached configuration if available, otherwise reads
* the configuration from the "v2ray_config.json" asset file.
*
* @param context Android context used to access application assets
* @param configContext Runtime context used to access app assets and profile data
* @return V2rayConfig object parsed from the JSON configuration, or null if the configuration is empty
*/
private fun initV2rayConfig(context: Context): V2rayConfig? {
private fun initV2rayConfig(configContext: CoreConfigContext): V2rayConfig? {
val context = configContext.context
var assets = ""
if (needTun()) {
assets = initConfigCacheWithTun ?: Utils.readTextFromAssets(context, "v2ray_config_with_tun.json")
@@ -449,64 +446,54 @@ object CoreConfigManager {
}
/**
* Pre-pass: scans all routing rulesets for non-builtin outbound tags,
* looks up the matching profile by remarks, converts it to an OutboundBean,
* and appends it to v2rayConfig.outbounds. This must run before getRouting
* so that every custom tag is already present when routing rules are applied.
* Injects custom outbound profiles into the V2ray configuration.
* Uses pre-resolved profiles from the context instead of re-parsing.
*
* @param v2rayConfig The V2ray configuration object to be modified
* @param v2rayConfig The V2ray configuration to modify
* @param customOutbounds Pre-resolved custom outbound profiles (tag -> ProfileItem mapping)
*/
private fun injectCustomOutbounds(v2rayConfig: V2rayConfig) {
private fun injectCustomOutbounds(v2rayConfig: V2rayConfig, customOutbounds: Map<String, ProfileItem>) {
val existingTags = v2rayConfig.outbounds.mapTo(mutableSetOf()) { it.tag }
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return
rulesetItems
.filter { it.enabled }
.mapNotNull { it.outboundTag.takeIf { tag -> tag.isNotBlank() } }
.filter { it !in AppConfig.BUILTIN_OUTBOUND_TAGS }
.distinct()
.forEach { tag ->
if (tag in existingTags) return@forEach
try {
val profile = SettingsManager.getServerViaRemarks(tag) ?: run {
LogUtil.w(AppConfig.TAG, "Custom outbound tag '$tag' not found by remarks, skipping")
return@forEach
}
val outbound = convertProfile2Outbound(profile) ?: run {
LogUtil.w(AppConfig.TAG, "Could not convert profile '$tag' to outbound, skipping")
return@forEach
}
outbound.tag = tag
v2rayConfig.outbounds.add(outbound)
existingTags.add(tag)
LogUtil.d(AppConfig.TAG, "Injected custom outbound: tag='$tag'")
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to inject custom outbound for tag '$tag', skipping", e)
customOutbounds.forEach { (tag, profile) ->
if (tag in existingTags) return@forEach
try {
val outbound = convertProfile2Outbound(profile) ?: run {
LogUtil.w(AppConfig.TAG, "Could not convert profile '$tag' to outbound, skipping")
return@forEach
}
outbound.tag = tag
v2rayConfig.outbounds.add(outbound)
existingTags.add(tag)
LogUtil.d(AppConfig.TAG, "Injected custom outbound: tag='$tag'")
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to inject custom outbound for tag '$tag', skipping", e)
}
}
}
/**
* Configures routing settings for V2ray.
*
* Sets up the domain strategy and adds routing rules from saved rulesets.
* Sets up the domain strategy, injects custom outbounds from rulesets, and adds routing rules.
*
* @param configContext Configuration context with custom outbound profiles
* @param v2rayConfig The V2ray configuration object to be modified
* @return true if routing configuration was successful, false otherwise
*/
private fun getRouting(context: Context, v2rayConfig: V2rayConfig): Boolean {
private fun getRouting(configContext: CoreConfigContext, v2rayConfig: V2rayConfig): Boolean {
try {
v2rayConfig.routing.domainStrategy =
MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY)
?: "AsIs"
// Pre-pass: inject outbounds referenced by remarks in routing rules
injectCustomOutbounds(v2rayConfig)
// Inject custom outbound profiles from routing rulesets
injectCustomOutbounds(v2rayConfig, configContext.customOutboundProfiles)
val rulesetItems = MmkvManager.decodeRoutingRulesets()
rulesetItems?.forEach { key ->
getRoutingUserRule(context, key, v2rayConfig)
getRoutingUserRule(configContext, key, v2rayConfig)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to configure routing", e)
@@ -518,10 +505,12 @@ object CoreConfigManager {
/**
* Adds a specific ruleset item to the routing configuration.
*
* @param configContext Runtime context used by routing helpers
* @param item The ruleset item to add
* @param v2rayConfig The V2ray configuration object to be modified
*/
private fun getRoutingUserRule(context: Context, item: RulesetItem?, v2rayConfig: V2rayConfig) {
private fun getRoutingUserRule(configContext: CoreConfigContext, item: RulesetItem?, v2rayConfig: V2rayConfig) {
val context = configContext.context
try {
if (item == null || !item.enabled) {
return
@@ -780,12 +769,12 @@ object CoreConfigManager {
*
* Converts the profile to an outbound configuration and applies global settings.
*
* @param configContext Runtime context containing the selected profile
* @param v2rayConfig The V2ray configuration object to be modified
* @param config The profile item containing connection details
* @return true if outbound configuration was successful, null if there was an error
*/
private fun getOutbounds(v2rayConfig: V2rayConfig, config: ProfileItem): Boolean? {
val outbound = convertProfile2Outbound(config) ?: return null
private fun getOutbounds(configContext: CoreConfigContext, v2rayConfig: V2rayConfig): Boolean? {
val outbound = convertProfile2Outbound(configContext.selectedProfile) ?: return null
if (v2rayConfig.outbounds.isNotEmpty()) {
v2rayConfig.outbounds[0] = outbound
@@ -798,10 +787,11 @@ object CoreConfigManager {
/**
* Configures load balancing settings for the V2ray configuration.
*
* @param configContext Runtime context containing policy group settings
* @param v2rayConfig The V2ray configuration object to be modified with balancing settings
* @param config The profile item containing policy group settings
*/
private fun getBalance(v2rayConfig: V2rayConfig, config: ProfileItem) {
private fun getBalance(configContext: CoreConfigContext, v2rayConfig: V2rayConfig) {
val config = configContext.selectedProfile
try {
v2rayConfig.routing.rules.forEach { rule ->
if (rule.outboundTag == AppConfig.TAG_PROXY) {
@@ -12,4 +12,5 @@ data class CoreConfigContext(
val selectedProfile: ProfileItem,
val resolvedProfiles: List<ProfileItem>,
val resolvedType: CoreResolvedType,
val customOutboundProfiles: Map<String, ProfileItem> = emptyMap(),
)