diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt index 7b02293d..f5c66ee6 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt @@ -279,4 +279,9 @@ object AppConfig { "Chocolate4U/Iran-v2ray-rules" ) + val BUILTIN_OUTBOUND_TAGS = setOf( + TAG_PROXY, + TAG_DIRECT, + TAG_BLOCKED, + ) } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt index 6b8b3fe7..eeac9b6f 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt @@ -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 } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt index ab46b3d7..f20ce504 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt @@ -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 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()) { diff --git a/V2rayNG/app/src/main/res/drawable/ic_arrow_drop_down.xml b/V2rayNG/app/src/main/res/drawable/ic_arrow_drop_down.xml new file mode 100644 index 00000000..aba3c44d --- /dev/null +++ b/V2rayNG/app/src/main/res/drawable/ic_arrow_drop_down.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml b/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml index 65329d37..d5ad6052 100644 --- a/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml +++ b/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml @@ -190,13 +190,33 @@ android:layout_height="wrap_content" android:text="@string/routing_settings_outbound_tag" /> - + android:layout_marginBottom="@dimen/padding_spacing_dp8"> + + + + + + diff --git a/V2rayNG/app/src/main/res/values/strings.xml b/V2rayNG/app/src/main/res/values/strings.xml index 81e875e4..1eb2d747 100644 --- a/V2rayNG/app/src/main/res/values/strings.xml +++ b/V2rayNG/app/src/main/res/values/strings.xml @@ -342,6 +342,7 @@ [http,tls,bittorrent] network [udp|tcp] + proxy / direct / block / <remarks> outboundTag Check Connectivity