Support custom outbound tags in routing

Allow routing rules to reference outbounds by server remarks and inject missing outbounds into the generated V2ray config. Adds BUILTIN_OUTBOUND_TAGS, a pre-pass (injectCustomOutbounds) that converts referenced non-builtin tags to outbounds, and a fallback to the proxy tag when an outbound couldn't be injected. Updates getBalance to use AppConfig.TAG_PROXY constant. Replaces the outbound-tag Spinner with an AutoCompleteTextView + dropdown button that suggests builtin tags and existing profile remarks, adds a drop-down drawable and a hint string resource.
This commit is contained in:
2dust committed 2026-04-17 15:10:19 +08:00
1 parent f371da490a
commit 04478ab490
6 files changed
+132 -15

No files matched your search

@@ -279,4 +279,9 @@ object AppConfig {
"Chocolate4U/Iran-v2ray-rules"
)
val BUILTIN_OUTBOUND_TAGS = setOf(
TAG_PROXY,
TAG_DIRECT,
TAG_BLOCKED,
)
}
@@ -4,6 +4,7 @@ import android.content.Context
import android.text.TextUtils
import com.google.gson.JsonArray
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS
import com.v2ray.ang.dto.ConfigResult
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem
@@ -445,6 +446,45 @@ object V2rayConfigManager {
}
}
/**
* 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.
*
* @param v2rayConfig The V2ray configuration object to be modified
*/
private fun injectCustomOutbounds(v2rayConfig: V2rayConfig) {
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 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
}
updateOutboundWithGlobalSettings(outbound)
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.
*
@@ -460,6 +500,9 @@ object V2rayConfigManager {
MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY)
?: "AsIs"
// Pre-pass: inject outbounds referenced by remarks in routing rules
injectCustomOutbounds(v2rayConfig)
val rulesetItems = MmkvManager.decodeRoutingRulesets()
rulesetItems?.forEach { key ->
getRoutingUserRule(context, key, v2rayConfig)
@@ -510,6 +553,16 @@ object V2rayConfigManager {
rule.process = null
}
// If the outbound tag is a custom one that failed to inject, fall back to proxy
val outboundTag = rule.outboundTag
if (!outboundTag.isNullOrBlank()
&& outboundTag !in BUILTIN_OUTBOUND_TAGS
&& v2rayConfig.outbounds.none { it.tag == outboundTag }
) {
LogUtil.w(AppConfig.TAG, "Outbound tag '$outboundTag' not found, falling back to '${AppConfig.TAG_PROXY}'")
rule.outboundTag = AppConfig.TAG_PROXY
}
v2rayConfig.routing.rules.add(rule)
} catch (e: Exception) {
@@ -888,7 +941,7 @@ object V2rayConfigManager {
private fun getBalance(v2rayConfig: V2rayConfig, config: ProfileItem) {
try {
v2rayConfig.routing.rules.forEach { rule ->
if (rule.outboundTag == "proxy") {
if (rule.outboundTag == AppConfig.TAG_PROXY) {
rule.outboundTag = null
rule.balancerTag = AppConfig.TAG_BALANCER
}
@@ -3,14 +3,18 @@ package com.v2ray.ang.ui
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS
import com.v2ray.ang.AppConfig.TAG_PROXY
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityRoutingEditBinding
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.extension.nullIfBlank
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.util.Utils
import kotlinx.coroutines.Dispatchers
@@ -20,15 +24,12 @@ class RoutingEditActivity : BaseActivity() {
private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) }
private val position by lazy { intent.getIntExtra("position", -1) }
private val outbound_tag: Array<out String> by lazy {
resources.getStringArray(R.array.outbound_tag)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title))
setupOutboundTagInput()
val rulesetItem = SettingsManager.getRoutingRuleset(position)
if (rulesetItem != null) {
bindingServer(rulesetItem)
@@ -41,6 +42,33 @@ class RoutingEditActivity : BaseActivity() {
}
}
/**
* Sets up the AutoCompleteTextView for outbound tag:
* suggestions = built-in tags (proxy/direct/block) + all existing profile remarks.
* The dropdown button triggers showing the full list without typing.
*/
private fun setupOutboundTagInput() {
val profileRemarks = MmkvManager.decodeAllServerList()
.mapNotNull { id -> MmkvManager.decodeServerConfig(id)?.remarks }
.filter { it.isNotBlank() }
val suggestions = (BUILTIN_OUTBOUND_TAGS.toList() + profileRemarks).distinct()
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
binding.spOutboundTag.setAdapter(adapter)
// threshold=0 means show all suggestions even before typing; still need focus+request
binding.spOutboundTag.threshold = 0
// Dropdown arrow button shows the full suggestion list
binding.btnOutboundTagDropdown.setOnClickListener {
binding.spOutboundTag.requestFocus()
binding.spOutboundTag.showDropDown()
}
// Also show on field click when it already has focus
binding.spOutboundTag.setOnClickListener {
binding.spOutboundTag.showDropDown()
}
}
private fun bindingServer(rulesetItem: RulesetItem): Boolean {
binding.etRemarks.text = Utils.getEditable(rulesetItem.remarks)
binding.chkLocked.isChecked = rulesetItem.locked == true
@@ -50,15 +78,14 @@ class RoutingEditActivity : BaseActivity() {
binding.etPort.text = Utils.getEditable(rulesetItem.port)
binding.etProtocol.text = Utils.getEditable(rulesetItem.protocol?.joinToString(","))
binding.etNetwork.text = Utils.getEditable(rulesetItem.network)
val outbound = Utils.arrayFind(outbound_tag, rulesetItem.outboundTag)
binding.spOutboundTag.setSelection(outbound)
// Set text directly; filter won't fire because we're not using setText(filter=true)
binding.spOutboundTag.setText(rulesetItem.outboundTag, false)
return true
}
private fun clearServer(): Boolean {
binding.etRemarks.text = null
binding.spOutboundTag.setSelection(0)
binding.spOutboundTag.setText(BUILTIN_OUTBOUND_TAGS.first(), false)
return true
}
@@ -74,7 +101,7 @@ class RoutingEditActivity : BaseActivity() {
protocol = binding.etProtocol.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
port = binding.etPort.text.toString().nullIfBlank()
network = binding.etNetwork.text.toString().nullIfBlank()
outboundTag = outbound_tag[binding.spOutboundTag.selectedItemPosition]
outboundTag = binding.spOutboundTag.text.toString().trim().ifEmpty { TAG_PROXY }
}
if (rulesetItem.remarks.isNullOrEmpty()) {
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M7,10l5,5 5,-5z" />
</vector>
@@ -190,13 +190,33 @@
android:layout_height="wrap_content"
android:text="@string/routing_settings_outbound_tag" />
<Spinner
android:id="@+id/sp_outbound_tag"
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:layout_marginBottom="@dimen/padding_spacing_dp8"
android:entries="@array/outbound_tag" />
android:layout_marginBottom="@dimen/padding_spacing_dp8">
<AutoCompleteTextView
android:id="@+id/sp_outbound_tag"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:completionThreshold="0"
android:hint="@string/routing_settings_outbound_tag_hint"
android:inputType="text"
android:imeOptions="actionDone"
android:paddingEnd="40dp" />
<ImageButton
android:id="@+id/btn_outbound_tag_dropdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/routing_settings_outbound_tag"
android:src="@drawable/ic_arrow_drop_down" />
</RelativeLayout>
</LinearLayout>
</LinearLayout>
@@ -342,6 +342,7 @@
<string name="routing_settings_protocol_tip">[http,tls,bittorrent]</string>
<string name="routing_settings_network">network</string>
<string name="routing_settings_network_tip">[udp|tcp]</string>
<string name="routing_settings_outbound_tag_hint" translatable="false">proxy / direct / block / &lt;remarks&gt;</string>
<string name="routing_settings_outbound_tag">outboundTag</string>
<string name="connection_test_pending">Check Connectivity</string>