Compare commits

...

16 Commits

Author SHA1 Message Date
2dust 4c86e7c26e up 2.1.5 2026-05-03 20:21:49 +08:00
2dust 5d0897cd94 Update AndroidLibXrayLite 2026-05-03 20:21:17 +08:00
2dust fc4ab733c8 Introduce RealPingEvent and refactor ping flow
Add RealPingEvent sealed class and refactor real-ping flow to use event-driven communication. RealPingWorkerService now emits Progress/Result/Finish events instead of direct callbacks/MessageUtil calls. CoreTestService reorganized into handlers (handleMeasureStart/handleMeasureCancel/handleWorkerEvent) to process events, update notifications, persist delays via MmkvManager, and notify UI. MainViewModel adjusted to read the result content as a string when handling success messages. Overall change decouples worker logic from service responsibilities and simplifies progress/result handling.
2026-05-03 16:46:40 +08:00
DHR60 d606453b83 Fix (#5593) 2026-05-03 16:08:52 +08:00
DHR60 c6c8eccd51 Opt dns (#5592) 2026-05-03 14:52:55 +08:00
2dust 03074fba61 Add NotificationHelper and improve core test service
Introduce unified notification support and use foreground notifications for core test tasks. Added NotificationChannelType enum and NotificationHelper (cached NotificationManager and Builder cache, channel creation, notify/update/startForeground/stopForeground/cancel). Updated AndroidManifest to mark CoreTestService as a specialUse foreground service. Renamed AppConfig message constants (MSG_MEASURE_CONFIG_START, MSG_MEASURE_CONFIG_CANCEL, MSG_MEASURE_CONFIG_SUCCESS) and updated usages: MessageUtil now starts a foreground service on O+ for measurement start and handles cancel without forcing start; CoreTestService starts foreground immediately, logs lifecycle, manages RealPingWorkerService via onProgress/onFinish callbacks, and stops foreground when done; RealPingWorkerService reports progress via onProgress instead of direct UI messages; SubscriptionUpdater now uses NotificationHelper and NotificationChannelType for update notifications. These changes improve notification performance, ensure startForegroundService compliance on Android O+, and centralize notification logic.
2026-05-03 14:44:38 +08:00
2dust ac02d23083 Add real-ping concurrency setting
Introduce a configurable concurrency for real-ping (true delay) tests. Add PREF_REAL_PING_CONCURRENCY and DEFAULT_REAL_PING_CONCURRENCY in AppConfig, a getter SettingsManager.getRealPingConcurrency() (parses stored value, defaults to 16, clamps to 1..128), and use that value to create the RealPingWorkerService thread pool instead of cpu * 4. Expose the setting in preferences (EditTextPreference) and add localized title strings across multiple resource files. This lets users control how many concurrent real-ping workers run.
2026-05-03 09:42:36 +08:00
David 5c6aaf285b Correct translation errors (#5588)
* Update Chinese strings for UI and settings

* Refine summary for auto-remove invalid config

* Refine summaries for auto-remove and auto-sort preferences

* Change UI color setting label to '深色模式'
2026-05-03 09:16:32 +08:00
DHR60 d3f9809486 Fix dns (#5587) 2026-05-03 09:09:10 +08:00
2dust a12909e33d Add proxy-chain feature (UI & core support)
Introduce a new Proxy Chain server type and full support across UI and core logic. Added EConfigType.PROXYCHAIN and ProfileItem.proxyChainProfiles, new ServerProxyChainActivity, ServerProxyChainMemberAdapter, layouts and menu entries, and localized strings. CoreConfigContextBuilder now resolves proxy-chain members (from explicit list or subscription group) with robust filtering and error logging; CoreConfigManager validates chain length and logs warnings. CoreOutboundBuilder and CoreServiceManager were updated to treat PROXYCHAIN as a non-directly-runnable config type. Various UI flows (GroupServerFragment, MainActivity, ServerActivity) updated to handle the new type.
2026-05-02 19:00:17 +08:00
2dust 3e823a7440 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.
2026-05-02 14:47:03 +08:00
2dust dc1d3a27dd Introduce CoreConfigContext and refactor manager
Add CoreConfigContext DTO and CoreResolvedType enum, and implement CoreConfigContextBuilder to centralize profile resolution (CUSTOM, POLICYGROUP, PROXYCHAIN/NORMAL) with policy-group filters and proxy-chain ordering (next -> current -> prev).

Refactor CoreConfigManager to consume CoreConfigContext: replace prior branching with buildGroupConfig, buildProxyChainConfig and buildNormalConfig, unify CUSTOM handling, add postProcessForSpeedtest and toConfigResult helpers, and remove the old getMoreOutbounds logic. Adjust outbound assembly, DNS/route handling and speedtest post-processing accordingly.

Minor cleanup: centralize outbound conversion comments in CoreOutboundBuilder and tidy several condition checks and log lines. Overall this extracts resolution logic, reduces duplication and clarifies the runtime build flow.
2026-05-02 10:53:53 +08:00
2dust d0f1e21f49 Move outbound global settings to builder
Extract updateOutboundWithGlobalSettings from CoreConfigManager into CoreOutboundBuilder and apply it during outbound conversion. CoreConfigManager no longer calls the function (and its NetworkType import was removed); CoreOutboundBuilder.convert now runs the global-settings update and returns null if the update fails. This centralizes outbound/global preference handling and eliminates duplicated update logic across the codebase.
2026-05-01 19:11:21 +08:00
2dust 3b8615fad0 Rename V2Ray components to Core
Refactor: rename handler package/classes and services from V2Ray*/V2ray* to Core*/Core (e.g. V2rayConfigManager -> CoreConfigManager, V2RayNativeManager -> CoreNativeManager, V2RayServiceManager -> CoreServiceManager, and service classes V2RayVpnService/V2RayProxyOnlyService/V2RayTestService -> CoreVpnService/CoreProxyOnlyService/CoreTestService). Update AndroidManifest service entries and all imports/usages across the app (notifications, receivers, widgets, UI, services, workers) to reference the renamed classes and package (com.v2ray.ang.core). Comments and references were adjusted to match the new names; no functional logic changes intended.
2026-05-01 18:48:43 +08:00
2dust d9d00f4137 Add CoreOutboundBuilder to build OutboundBeans
Introduce CoreOutboundBuilder.kt to centralize ProfileItem → OutboundBean conversion logic. Provides createInitOutbound and per-protocol builders for VMESS, VLESS, Shadowsocks, SOCKS, Trojan, WireGuard, Hysteria2 and HTTP. Includes reusable helpers populateTransportSettings and populateTlsSettings to configure network transport, TLS/REALITY settings, finalmask manipulation and optional fragmentation via MMKV prefs. Also adds domain resolution logic (with IPv6 preference) in getServerAddress and preserves original format-specific behaviors.
2026-05-01 17:06:53 +08:00
2dust 2aefbd43eb Suppress exceptions in RealPingWorkerService
Wrap startRealPing(guid) in a catch(Throwable) that ignores errors so exceptions during measurement won't propagate. This ensures the finally block always runs to decrement totalCount and runningCount, preventing the worker from being interrupted by unexpected errors.
2026-05-01 17:06:34 +08:00
65 changed files with 3038 additions and 2018 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 36
versionCode = 724
versionName = "2.1.4"
versionCode = 725
versionName = "2.1.5"
multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
+13 -4
View File
@@ -82,6 +82,10 @@
android:name=".ui.ServerGroupActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged" />
<activity
android:name=".ui.ServerProxyChainActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged" />
<activity
android:name=".ui.SettingsActivity"
android:exported="false" />
@@ -168,7 +172,7 @@
android:exported="false" />
<service
android:name=".service.V2RayVpnService"
android:name=".service.CoreVpnService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse"
@@ -188,7 +192,7 @@
</service>
<service
android:name=".service.V2RayProxyOnlyService"
android:name=".service.CoreProxyOnlyService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
@@ -199,9 +203,14 @@
</service>
<service
android:name=".service.V2RayTestService"
android:name=".service.CoreTestService"
android:exported="false"
android:process=":RunSoLibV2RayDaemon" />
android:foregroundServiceType="specialUse"
android:process=":RunSoLibV2RayDaemon">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="test" />
</service>
<receiver
android:name=".receiver.WidgetProvider"
@@ -78,6 +78,7 @@ object AppConfig {
const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout_v2"
const val PREF_AUTO_REMOVE_INVALID_AFTER_TEST = "pref_auto_remove_invalid_after_test"
const val PREF_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
const val PREF_REAL_PING_CONCURRENCY = "pref_real_ping_concurrency"
/** Cache keys. */
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
@@ -164,17 +165,15 @@ object AppConfig {
const val MSG_STATE_RESTART = 5
const val MSG_MEASURE_DELAY = 6
const val MSG_MEASURE_DELAY_SUCCESS = 61
const val MSG_MEASURE_CONFIG = 7
const val MSG_MEASURE_CONFIG_SUCCESS = 71
const val MSG_MEASURE_CONFIG_CANCEL = 72
const val MSG_MEASURE_CONFIG_START = 7
const val MSG_MEASURE_CONFIG_CANCEL = 71
const val MSG_MEASURE_CONFIG_SUCCESS = 72
const val MSG_MEASURE_CONFIG_NOTIFY = 73
const val MSG_MEASURE_CONFIG_FINISH = 74
/** Notification channel IDs and names. */
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
const val RAY_NG_CHANNEL_NAME = "v2rayNG Background Service"
const val SUBSCRIPTION_UPDATE_CHANNEL = "subscription_update_channel"
const val SUBSCRIPTION_UPDATE_CHANNEL_NAME = "Subscription Update Service"
/** Protocols Scheme **/
const val VMESS = "vmess://"
@@ -0,0 +1,197 @@
package com.v2ray.ang.core
import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.CoreConfigContext
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.CoreResolvedType
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
/**
* Builds [com.v2ray.ang.dto.CoreConfigContext] from the selected profile.
* Keeps parsing and resolution logic out of [CoreConfigManager].
*/
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
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)
Pair(profiles, CoreResolvedType.POLICYGROUP)
}
EConfigType.PROXYCHAIN -> {
val profiles = resolveProxyChainProfiles(config)
Pair(profiles, CoreResolvedType.PROXYCHAIN)
}
else -> {
val chainProfiles = resolveProxyChainProfilesFromGroup(config)
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. */
private fun resolvePolicyGroupProfiles(config: ProfileItem): List<ProfileItem> {
try {
val serverList = MmkvManager.decodeAllServerList()
return serverList
.asSequence()
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
.filter { profile ->
val subscriptionId = config.policyGroupSubscriptionId
if (subscriptionId.isNullOrBlank()) {
true
} else {
profile.subscriptionId == subscriptionId
}
}
.filter { profile ->
val filter = config.policyGroupFilter
if (filter.isNullOrBlank()) {
true
} else {
try {
Regex(filter).containsMatchIn(profile.remarks)
} catch (_: Exception) {
profile.remarks.contains(filter)
}
}
}
.filter { it.server.isNotNullEmpty() }
.filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { it.configType != EConfigType.CUSTOM }
.filter { it.configType != EConfigType.POLICYGROUP }
.filter { it.configType != EConfigType.PROXYCHAIN }
.toList()
}
catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve policy group profiles for config '${config.remarks}'", e)
return listOf(config)
}
}
/** Resolves proxy-chain members with the same filters as runtime build. */
private fun resolveProxyChainProfiles(config: ProfileItem): List<ProfileItem> {
if (config.proxyChainProfiles.isNullOrBlank()) {
return listOf(config)
}
try {
return config.proxyChainProfiles.orEmpty().split(",")
.asSequence()
.mapNotNull { remark -> SettingsManager.getServerViaRemarks(remark) }
.filter { it.server.isNotNullEmpty() }
.filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { it.configType != EConfigType.CUSTOM }
.filter { it.configType != EConfigType.POLICYGROUP }
.filter { it.configType != EConfigType.PROXYCHAIN }
.toList()
}
catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles for config '${config.remarks}'", e)
return listOf(config)
}
}
/**
* Resolves chain nodes in fixed order: next -> current -> prev.
* If chain cannot be built, caller treats result as normal mode.
*/
private fun resolveProxyChainProfilesFromGroup(config: ProfileItem): List<ProfileItem> {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == true) {
return listOf(config)
}
if (config.subscriptionId.isEmpty()) {
return listOf(config)
}
try {
val subItem = MmkvManager.decodeSubscription(config.subscriptionId) ?: return listOf(config)
val resolved = mutableListOf<ProfileItem>()
// Keep the same practical chain order as current runtime assembly:
// next -> current -> prev
SettingsManager.getServerViaRemarks(subItem.nextProfile)?.let { resolved.add(it) }
resolved.add(config)
SettingsManager.getServerViaRemarks(subItem.prevProfile)?.let { resolved.add(it) }
return resolved
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles from group for config '${config.remarks}'", e)
return listOf(config)
}
}
/**
* 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
}
}
@@ -0,0 +1,991 @@
package com.v2ray.ang.core
import android.content.Context
import android.text.TextUtils
import com.google.gson.JsonArray
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ConfigResult
import com.v2ray.ang.dto.CoreConfigContext
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.enums.CoreResolvedType
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.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.PackageUidResolver
import com.v2ray.ang.util.Utils
object CoreConfigManager {
private var initConfigCache: String? = null
private var initConfigCacheWithTun: String? = null
//region get config function
/**
* Builds normal runtime config JSON for the selected profile.
*
* @param context The context of the caller.
* @param guid The unique identifier for the V2ray configuration.
* @return A ConfigResult object containing the configuration details or indicating failure.
*/
fun getV2rayConfig(context: Context, guid: String): ConfigResult {
try {
val configContext = CoreConfigContextBuilder.build(context, guid) ?: return ConfigResult(false)
if (configContext.resolvedType == CoreResolvedType.CUSTOM) {
return getV2rayCustomConfig(configContext)
}
val v2rayConfig = when (configContext.resolvedType) {
CoreResolvedType.POLICYGROUP -> buildGroupConfig(configContext)
CoreResolvedType.PROXYCHAIN -> buildProxyChainConfig(configContext)
CoreResolvedType.NORMAL -> buildNormalConfig(configContext)
CoreResolvedType.CUSTOM -> null
} ?: return ConfigResult(false)
return toConfigResult(configContext, v2rayConfig)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config", e)
return ConfigResult(false)
}
}
/**
* Builds speedtest config for the selected profile.
*
* It reuses the same build flow as normal config, then removes
* unnecessary sections for delay testing.
*
* @param context The context of the caller.
* @param guid The unique identifier for the V2ray configuration.
* @return A ConfigResult object containing the configuration details or indicating failure.
*/
fun getV2rayConfig4Speedtest(context: Context, guid: String): ConfigResult {
try {
val configContext = CoreConfigContextBuilder.build(context, guid) ?: return ConfigResult(false)
if (configContext.resolvedType == CoreResolvedType.CUSTOM) {
return getV2rayCustomConfig(configContext)
}
val v2rayConfig = when (configContext.resolvedType) {
CoreResolvedType.POLICYGROUP -> buildGroupConfig(configContext)
CoreResolvedType.PROXYCHAIN -> buildProxyChainConfig(configContext)
CoreResolvedType.NORMAL -> buildNormalConfig(configContext)
CoreResolvedType.CUSTOM -> null
} ?: return ConfigResult(false)
postProcessForSpeedtest(v2rayConfig)
return toConfigResult(configContext, v2rayConfig)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config for speedtest", e)
return ConfigResult(false)
}
}
/**
* Builds config result for CUSTOM profiles.
*/
private fun getV2rayCustomConfig(configContext: CoreConfigContext): ConfigResult {
val context = configContext.context
val raw = MmkvManager.decodeServerRaw(configContext.guid) ?: return ConfigResult(false)
val result = ConfigResult(true, configContext.guid, raw)
if (!needTun()) {
return result
}
val json = JsonUtil.parseString(raw)?.takeIf { it.isJsonObject }?.asJsonObject ?: return result
// Check whether package names need to be replaced with UIDs
if (SettingsManager.canUseProcessRouting()) {
val rulesJson = json.get("routing")?.takeIf { it.isJsonObject }?.asJsonObject
?.get("rules")?.takeIf { it.isJsonArray }?.asJsonArray
?: JsonArray()
for (elem in rulesJson) {
val rule = elem.takeIf { it.isJsonObject }?.asJsonObject ?: continue
val process = rule.get("process")?.takeIf { it.isJsonArray }?.asJsonArray ?: continue
val packages = process.mapNotNull {
it.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }?.asString
}.takeIf { it.isNotEmpty() } ?: continue
val uids = PackageUidResolver.packageNamesToUids(context, packages).takeIf { it.isNotEmpty() } ?: continue
rule.add("process", JsonArray().apply { uids.forEach { add(it) } })
}
}
// check if tun inbound exists
val inboundsJson = json.get("inbounds")?.takeIf { it.isJsonArray }?.asJsonArray
?: JsonArray().also { json.add("inbounds", it) }
val tunNotExists = inboundsJson.none { elem ->
elem.isJsonObject && elem.asJsonObject.get("protocol")
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
?.asString == "tun"
}
if (tunNotExists) {
// add tun inbound from template
initV2rayConfig(configContext)?.let { templateConfig ->
templateConfig.inbounds.firstOrNull { it.tag == "tun" }?.let { inboundTun ->
inboundTun.settings?.mtu = SettingsManager.getVpnMtu()
inboundsJson.add(JsonUtil.parseString(JsonUtil.toJson(inboundTun)))
}
}
}
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 config = configContext.selectedProfile
val validConfigs = configContext.resolvedProfiles
if (validConfigs.isEmpty()) {
LogUtil.w(AppConfig.TAG, "All configs are invalid")
return null
}
val v2rayConfig = initV2rayConfig(configContext) ?: return null
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
v2rayConfig.remarks = config.remarks
getInbounds(v2rayConfig)
v2rayConfig.outbounds.removeAt(0)
val outboundsList = mutableListOf<V2rayConfig.OutboundBean>()
var index = 0
for (item in validConfigs) {
index++
val outbound = convertProfile2Outbound(item) ?: continue
outbound.tag = "proxy-$index-${item.remarks.trim()}"
outboundsList.add(outbound)
}
outboundsList.addAll(v2rayConfig.outbounds)
v2rayConfig.outbounds = ArrayList(outboundsList)
getRouting(configContext, v2rayConfig)
getFakeDns(v2rayConfig)
getDns(v2rayConfig)
getBalance(configContext, v2rayConfig)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED)) {
getCustomLocalDns(v2rayConfig)
}
if (!MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED)) {
v2rayConfig.stats = null
v2rayConfig.policy = null
}
// Resolve and add to DNS Hosts
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") == "1") {
resolveOutboundDomainsToHosts(v2rayConfig)
}
return v2rayConfig
}
/**
* Builds full V2Ray config for proxy-chain mode.
*
* Uses resolvedProfiles as an ordered chain and links each hop
* with dialerProxy.
*/
private fun buildProxyChainConfig(configContext: CoreConfigContext): V2rayConfig? {
val config = configContext.selectedProfile
val resolvedProfiles = configContext.resolvedProfiles
val v2rayConfig = initV2rayConfig(configContext) ?: return null
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
v2rayConfig.remarks = config.remarks
getInbounds(v2rayConfig)
// Build and link the whole chain directly from resolvedProfiles.
val chainOutbounds = resolvedProfiles.mapNotNull { profile ->
convertProfile2Outbound(profile)
}.toMutableList()
if (chainOutbounds.size < 2) {
LogUtil.w(AppConfig.TAG, "Proxy chain requires at least 2 valid profiles, but only ${chainOutbounds.size} found")
return null
}
chainOutbounds.forEachIndexed { index, outbound ->
outbound.tag = if (index == 0) AppConfig.TAG_PROXY else AppConfig.TAG_PROXY + index
}
for (index in 0 until chainOutbounds.size - 1) {
chainOutbounds[index].ensureSockopt().dialerProxy = chainOutbounds[index + 1].tag
}
// Keep built-in outbounds and place the chain before them.
val builtinOutbounds = if (v2rayConfig.outbounds.size > 1) {
v2rayConfig.outbounds.drop(1)
} else {
emptyList()
}
v2rayConfig.outbounds = ArrayList(chainOutbounds + builtinOutbounds)
getRouting(configContext, v2rayConfig)
getFakeDns(v2rayConfig)
getDns(v2rayConfig)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
getCustomLocalDns(v2rayConfig)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
v2rayConfig.stats = null
v2rayConfig.policy = null
}
// Resolve and add to DNS Hosts
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") == "1") {
resolveOutboundDomainsToHosts(v2rayConfig)
}
return v2rayConfig
}
/** Builds full config for normal single-node mode. */
private fun buildNormalConfig(configContext: CoreConfigContext): V2rayConfig? {
val config = configContext.selectedProfile
val address = config.server ?: return null
if (!Utils.isPureIpAddress(address) && !Utils.isValidUrl(address)) {
LogUtil.w(AppConfig.TAG, "$address is an invalid ip or domain")
return null
}
val v2rayConfig = initV2rayConfig(configContext) ?: return null
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
v2rayConfig.remarks = config.remarks
getInbounds(v2rayConfig)
getOutbounds(configContext, v2rayConfig) ?: return null
getRouting(configContext, v2rayConfig)
getFakeDns(v2rayConfig)
getDns(v2rayConfig)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
getCustomLocalDns(v2rayConfig)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
v2rayConfig.stats = null
v2rayConfig.policy = null
}
// Resolve and add to DNS Hosts
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") == "1") {
resolveOutboundDomainsToHosts(v2rayConfig)
}
return v2rayConfig
}
/**
* Removes non-essential sections for speedtest use.
*/
private fun postProcessForSpeedtest(v2rayConfig: V2rayConfig) {
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
v2rayConfig.inbounds.clear()
v2rayConfig.routing.rules.clear()
v2rayConfig.dns = null
v2rayConfig.fakedns = null
v2rayConfig.stats = null
v2rayConfig.policy = null
v2rayConfig.outbounds.forEach { key -> key.mux = null }
}
/** Converts a built config object into a unified result payload. */
private fun toConfigResult(configContext: CoreConfigContext, v2rayConfig: V2rayConfig): ConfigResult {
return ConfigResult(
status = true,
guid = configContext.guid,
content = JsonUtil.toJsonPretty(v2rayConfig) ?: ""
)
}
/**
* Initializes V2ray configuration.
*
* This function loads the V2ray configuration from assets or from a cached value.
* It first attempts to use the cached configuration if available, otherwise reads
* the configuration from the "v2ray_config.json" asset file.
*
* @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(configContext: CoreConfigContext): V2rayConfig? {
val context = configContext.context
var assets = ""
if (needTun()) {
assets = initConfigCacheWithTun ?: Utils.readTextFromAssets(context, "v2ray_config_with_tun.json")
if (TextUtils.isEmpty(assets)) {
return null
}
initConfigCacheWithTun = assets
} else {
assets = initConfigCache ?: Utils.readTextFromAssets(context, "v2ray_config.json")
if (TextUtils.isEmpty(assets)) {
return null
}
initConfigCache = assets
}
val config = JsonUtil.fromJson(assets, V2rayConfig::class.java)
return config
}
//endregion
//region some sub function
private fun needTun(): Boolean {
return SettingsManager.isVpnMode() && !SettingsManager.isUsingHevTun()
}
/**
* Configures the inbound settings for V2ray.
*
* This function sets up the listening ports, sniffing options, and other inbound-related configurations.
*
* @param v2rayConfig The V2ray configuration object to be modified
* @return true if inbound configuration was successful, false otherwise
*/
private fun getInbounds(v2rayConfig: V2rayConfig): Boolean {
try {
val vpn = SettingsManager.isVpnMode()
val useHev = SettingsManager.isUsingHevTun()
val forcedByHev = vpn && useHev
val enableLocalProxy = forcedByHev || MmkvManager.decodeSettingsBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true)
val socksPort = SettingsManager.getSocksPort()
val socksUsername = SettingsManager.getSocksUsername()
val socksPassword = SettingsManager.getSocksPassword()
val inbound1 = v2rayConfig.inbounds[0]
if (inbound1.settings == null) {
inbound1.settings = V2rayConfig.InboundBean.InSettingsBean()
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING) != true) {
inbound1.listen = AppConfig.LOOPBACK
}
inbound1.port = socksPort
inbound1.settings?.udp = MmkvManager.decodeSettingsBool(AppConfig.PREF_SOCKS_ENABLE_UDP, true)
if (socksUsername != null && socksPassword != null) {
inbound1.settings?.auth = "password"
inbound1.settings?.accounts = listOf(
V2rayConfig.InboundBean.InSettingsBean.SocksAccountBean(
user = socksUsername,
pass = socksPassword
)
)
} else {
inbound1.settings?.auth = "noauth"
inbound1.settings?.accounts = null
}
val fakedns = MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
val sniffAllTlsAndHttp =
MmkvManager.decodeSettingsBool(AppConfig.PREF_SNIFFING_ENABLED, true) != false
inbound1.sniffing?.enabled = fakedns || sniffAllTlsAndHttp
inbound1.sniffing?.routeOnly =
MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false)
if (!sniffAllTlsAndHttp) {
inbound1.sniffing?.destOverride?.clear()
}
if (fakedns) {
inbound1.sniffing?.destOverride?.add("fakedns")
}
if (!Utils.isXray()) {
val inbound2 = JsonUtil.fromJson(JsonUtil.toJson(inbound1), V2rayConfig.InboundBean::class.java) ?: return false
inbound2.tag = EConfigType.HTTP.name.lowercase()
inbound2.port = SettingsManager.getHttpPort()
inbound2.protocol = EConfigType.HTTP.name.lowercase()
inbound2.settings?.auth = null
inbound2.settings?.udp = null
v2rayConfig.inbounds.add(inbound2)
}
if (!enableLocalProxy) {
v2rayConfig.inbounds.removeIf { it.protocol == "socks" || it.protocol == "http" }
}
if (needTun()) {
val inboundTun = v2rayConfig.inbounds.firstOrNull { e -> e.tag == "tun" }
inboundTun?.settings?.mtu = SettingsManager.getVpnMtu()
inboundTun?.sniffing = inbound1.sniffing
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to configure inbounds", e)
return false
}
return true
}
/**
* Configures the fake DNS settings if enabled.
*
* Adds FakeDNS configuration to v2rayConfig if both local DNS and fake DNS are enabled.
*
* @param v2rayConfig The V2ray configuration object to be modified
*/
private fun getFakeDns(v2rayConfig: V2rayConfig) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true
&& MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
) {
v2rayConfig.fakedns = listOf(V2rayConfig.FakednsBean())
}
}
/**
* Injects custom outbound profiles into the V2ray configuration.
* Uses pre-resolved profiles from the context instead of re-parsing.
*
* @param v2rayConfig The V2ray configuration to modify
* @param customOutbounds Pre-resolved custom outbound profiles (tag -> ProfileItem mapping)
*/
private fun injectCustomOutbounds(v2rayConfig: V2rayConfig, customOutbounds: Map<String, ProfileItem>) {
val existingTags = v2rayConfig.outbounds.mapTo(mutableSetOf()) { it.tag }
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, 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(configContext: CoreConfigContext, v2rayConfig: V2rayConfig): Boolean {
try {
v2rayConfig.routing.domainStrategy =
MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY)
?: "AsIs"
// Inject custom outbound profiles from routing rulesets
injectCustomOutbounds(v2rayConfig, configContext.customOutboundProfiles)
val rulesetItems = MmkvManager.decodeRoutingRulesets()
rulesetItems?.forEach { key ->
getRoutingUserRule(configContext, key, v2rayConfig)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to configure routing", e)
return false
}
return true
}
/**
* 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(configContext: CoreConfigContext, item: RulesetItem?, v2rayConfig: V2rayConfig) {
val context = configContext.context
try {
if (item == null || !item.enabled) {
return
}
val rule = JsonUtil.fromJson(JsonUtil.toJson(item), V2rayConfig.RoutingBean.RulesBean::class.java) ?: return
// Replace specific geoip rules with ext versions
rule.ip?.let { ipList ->
val updatedIpList = ArrayList<String>()
ipList.forEach { ip ->
when (ip) {
AppConfig.GEOIP_CN -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:cn")
AppConfig.GEOIP_PRIVATE -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:private")
else -> updatedIpList.add(ip)
}
}
rule.ip = updatedIpList
}
if (SettingsManager.canUseProcessRouting()) {
// Convert process package names to UIDs
rule.process?.let { processList ->
if (processList.isNotEmpty()) {
val uids = PackageUidResolver.packageNamesToUids(context, processList)
rule.process = uids.ifEmpty { null }
}
}
} else {
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 AppConfig.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) {
LogUtil.e(AppConfig.TAG, "Failed to apply routing user rule", e)
}
}
/**
* Retrieves domain rules for a specific outbound tag.
*
* Searches through all rulesets to find domains targeting the specified tag.
*
* @param tag The outbound tag to search for
* @return ArrayList of domain rules matching the tag
*/
private fun getUserRule2Domain(tag: String): ArrayList<String> {
val domain = ArrayList<String>()
val rulesetItems = MmkvManager.decodeRoutingRulesets()
rulesetItems?.forEach { key ->
if (key.enabled && key.outboundTag == tag && !key.domain.isNullOrEmpty()) {
key.domain?.forEach {
domain.add(it)
}
}
}
return domain
}
/**
* Retrieves domain rules for custom outbound tags.
*
* Searches through all rulesets to find domains targeting any custom outbound tags.
*
* @return ArrayList of domain rules matching custom outbound tags
*/
private fun getCustomOutboundUserRule2Domain(): ArrayList<String> {
val domain = ArrayList<String>()
val rulesetItems = MmkvManager.decodeRoutingRulesets()
rulesetItems?.forEach { key ->
if (key.enabled && !AppConfig.BUILTIN_OUTBOUND_TAGS.contains(key.outboundTag)
&& !key.domain.isNullOrEmpty()
) {
key.domain?.forEach {
domain.add(it)
}
}
}
return domain
}
/**
* Configures custom local DNS settings.
*
* Sets up DNS inbound, outbound, and routing rules for local DNS resolution.
*
* @param v2rayConfig The V2ray configuration object to be modified
* @return true if custom local DNS configuration was successful, false otherwise
*/
private fun getCustomLocalDns(v2rayConfig: V2rayConfig): Boolean {
try {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true) {
val geositeCn = arrayListOf(AppConfig.GEOSITE_CN)
val proxyDomain = getUserRule2Domain(AppConfig.TAG_PROXY)
val directDomain = getUserRule2Domain(AppConfig.TAG_DIRECT)
val finalDomain = geositeCn.plus(proxyDomain).plus(directDomain).distinct()
// fakedns with all domains to make it always top priority
v2rayConfig.dns?.servers?.add(
0,
V2rayConfig.DnsBean.ServersBean(
address = "fakedns",
domains = finalDomain
)
)
}
if (SettingsManager.isVpnMode()) {
if (SettingsManager.isUsingHevTun()) {
//hev-socks5-tunnel dns routing
v2rayConfig.routing.rules.add(
0, V2rayConfig.RoutingBean.RulesBean(
inboundTag = arrayListOf("socks"),
outboundTag = "dns-out",
port = "53",
)
)
} else {
v2rayConfig.routing.rules.add(
0, V2rayConfig.RoutingBean.RulesBean(
inboundTag = arrayListOf("tun"),
outboundTag = "dns-out",
port = "53",
)
)
}
}
// DNS outbound
if (v2rayConfig.outbounds.none { e -> e.protocol == "dns" && e.tag == "dns-out" }) {
v2rayConfig.outbounds.add(
V2rayConfig.OutboundBean(
protocol = "dns",
tag = "dns-out",
settings = null,
streamSettings = null,
mux = null
)
)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to configure custom local DNS", e)
return false
}
return true
}
/**
* Configures the DNS settings for V2ray.
*
* Sets up DNS servers, hosts, and routing rules for DNS resolution.
*
* @param v2rayConfig The V2ray configuration object to be modified
* @return true if DNS configuration was successful, false otherwise
*/
private fun getDns(v2rayConfig: V2rayConfig): Boolean {
try {
val hosts = mutableMapOf<String, Any>()
val servers = ArrayList<Any>()
//remote Dns
val remoteDns = SettingsManager.getRemoteDnsServers()
val proxyDomain = (getUserRule2Domain(AppConfig.TAG_PROXY) + getCustomOutboundUserRule2Domain()).distinct()
remoteDns.forEach {
servers.add(it)
}
if (proxyDomain.isNotEmpty()) {
servers.add(
V2rayConfig.DnsBean.ServersBean(
address = remoteDns.first(),
domains = proxyDomain,
)
)
}
// domestic DNS
val domesticDns = SettingsManager.getDomesticDnsServers()
val directDomain = getUserRule2Domain(AppConfig.TAG_DIRECT)
val isCnRoutingMode = directDomain.contains(AppConfig.GEOSITE_CN)
val cnRegionFilter = { domain: String ->
domain.startsWith("geosite:") && (domain.endsWith("-cn") || domain.endsWith("@cn"))
|| domain == AppConfig.GEOSITE_CN
}
val finalDirectDomain = if (isCnRoutingMode) directDomain.filterNot {
cnRegionFilter(it)
} else directDomain
val domesticDnsTags = mutableListOf<String>()
domesticDns.forEachIndexed { index, element ->
val tag = AppConfig.TAG_DOMESTIC_DNS + index
servers.add(
V2rayConfig.DnsBean.ServersBean(
address = element,
domains = finalDirectDomain,
skipFallback = true,
tag = tag
)
)
domesticDnsTags.add(tag)
}
if (isCnRoutingMode) {
val geoipCn = arrayListOf(AppConfig.GEOIP_CN)
val cnRegionDomain = directDomain.filter { cnRegionFilter(it) }
domesticDns.forEachIndexed { index, element ->
val geositeCnDnsTag = AppConfig.TAG_DOMESTIC_DNS + index + "_cn_expect"
servers.add(
V2rayConfig.DnsBean.ServersBean(
address = element,
domains = cnRegionDomain,
expectIPs = geoipCn,
skipFallback = true,
tag = geositeCnDnsTag
)
)
domesticDnsTags.add(geositeCnDnsTag)
}
}
//block dns
val blkDomain = getUserRule2Domain(AppConfig.TAG_BLOCKED)
if (blkDomain.isNotEmpty()) {
hosts.putAll(blkDomain.map { it to AppConfig.LOOPBACK })
}
// hardcode googleapi rule to fix play store problems
hosts[AppConfig.GOOGLEAPIS_CN_DOMAIN] = AppConfig.GOOGLEAPIS_COM_DOMAIN
// hardcode popular Android Private DNS rule to fix localhost DNS problem
hosts[AppConfig.DNS_ALIDNS_DOMAIN] = AppConfig.DNS_ALIDNS_ADDRESSES
hosts[AppConfig.DNS_CLOUDFLARE_ONE_DOMAIN] = AppConfig.DNS_CLOUDFLARE_ONE_ADDRESSES
hosts[AppConfig.DNS_CLOUDFLARE_DNS_COM_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_COM_ADDRESSES
hosts[AppConfig.DNS_CLOUDFLARE_DNS_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_ADDRESSES
hosts[AppConfig.DNS_DNSPOD_DOMAIN] = AppConfig.DNS_DNSPOD_ADDRESSES
hosts[AppConfig.DNS_GOOGLE_DOMAIN] = AppConfig.DNS_GOOGLE_ADDRESSES
hosts[AppConfig.DNS_QUAD9_DOMAIN] = AppConfig.DNS_QUAD9_ADDRESSES
hosts[AppConfig.DNS_YANDEX_DOMAIN] = AppConfig.DNS_YANDEX_ADDRESSES
//User DNS hosts
try {
val userHosts = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
if (userHosts.isNotNullEmpty()) {
var userHostsMap = userHosts?.split(",")
?.filter { it.isNotEmpty() }
?.filter { it.contains(":") }
?.associate { it.split(":").let { (k, v) -> k to v } }
if (userHostsMap != null) hosts.putAll(userHostsMap)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to configure user DNS hosts", e)
}
// DNS dns
v2rayConfig.dns = V2rayConfig.DnsBean(
servers = servers,
hosts = hosts,
tag = AppConfig.TAG_DNS,
enableParallelQuery = if ((domesticDns.size + remoteDns.size) > 2) true else null
)
// DNS routing
v2rayConfig.routing.rules.add(
V2rayConfig.RoutingBean.RulesBean(
outboundTag = AppConfig.TAG_DIRECT,
inboundTag = domesticDnsTags,
domain = null
)
)
v2rayConfig.routing.rules.add(
V2rayConfig.RoutingBean.RulesBean(
outboundTag = AppConfig.TAG_PROXY,
inboundTag = arrayListOf(AppConfig.TAG_DNS),
domain = null
)
)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to configure DNS", e)
return false
}
return true
}
//endregion
//region outbound related functions
/**
* Configures the primary outbound connection.
*
* 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
* @return true if outbound configuration was successful, null if there was an error
*/
private fun getOutbounds(configContext: CoreConfigContext, v2rayConfig: V2rayConfig): Boolean? {
val outbound = convertProfile2Outbound(configContext.selectedProfile) ?: return null
if (v2rayConfig.outbounds.isNotEmpty()) {
v2rayConfig.outbounds[0] = outbound
} else {
v2rayConfig.outbounds.add(outbound)
}
return true
}
/**
* 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
*/
private fun getBalance(configContext: CoreConfigContext, v2rayConfig: V2rayConfig) {
val config = configContext.selectedProfile
try {
v2rayConfig.routing.rules.forEach { rule ->
if (rule.outboundTag == AppConfig.TAG_PROXY) {
rule.outboundTag = null
rule.balancerTag = AppConfig.TAG_BALANCER
}
}
val lstSelector = listOf("proxy-")
when (config.policyGroupType) {
// Least Ping goto else
"1" -> {
// Least Load
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = AppConfig.TAG_BALANCER,
selector = lstSelector,
strategy = V2rayConfig.RoutingBean.StrategyObject(
type = "leastLoad"
)
)
v2rayConfig.routing.balancers = listOf(balancer)
v2rayConfig.burstObservatory = V2rayConfig.BurstObservatoryObject(
subjectSelector = lstSelector,
pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject(
destination = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
interval = "5m",
sampling = 2,
timeout = "30s"
)
)
}
"2" -> {
// Random
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = AppConfig.TAG_BALANCER,
selector = lstSelector,
strategy = V2rayConfig.RoutingBean.StrategyObject(
type = "random"
)
)
v2rayConfig.routing.balancers = listOf(balancer)
}
"3" -> {
// Round Robin
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = AppConfig.TAG_BALANCER,
selector = lstSelector,
strategy = V2rayConfig.RoutingBean.StrategyObject(
type = "roundRobin"
)
)
v2rayConfig.routing.balancers = listOf(balancer)
}
else -> {
// Default: Least Ping
val balancer = V2rayConfig.RoutingBean.BalancerBean(
tag = AppConfig.TAG_BALANCER,
selector = lstSelector,
strategy = V2rayConfig.RoutingBean.StrategyObject(
type = "leastPing"
)
)
v2rayConfig.routing.balancers = listOf(balancer)
v2rayConfig.observatory = V2rayConfig.ObservatoryObject(
subjectSelector = lstSelector,
probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
probeInterval = "3m",
enableConcurrency = true
)
}
}
if (v2rayConfig.routing.domainStrategy == "IPIfNonMatch") {
v2rayConfig.routing.rules.add(
V2rayConfig.RoutingBean.RulesBean(
ip = arrayListOf("0.0.0.0/0", "::/0"),
balancerTag = AppConfig.TAG_BALANCER,
)
)
} else {
v2rayConfig.routing.rules.add(
V2rayConfig.RoutingBean.RulesBean(
network = "tcp,udp",
balancerTag = AppConfig.TAG_BALANCER,
)
)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to configure balance", e)
}
}
/**
* Resolves domain names to IP addresses in outbound connections.
*
* Pre-resolves domains to improve connection speed and reliability.
*
* @param v2rayConfig The V2ray configuration object to be modified
*/
private fun resolveOutboundDomainsToHosts(v2rayConfig: V2rayConfig) {
val proxyOutboundList = v2rayConfig.getAllProxyOutbound()
val dns = v2rayConfig.dns ?: return
val newHosts = dns.hosts?.toMutableMap() ?: mutableMapOf()
val preferIpv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true
for (item in proxyOutboundList) {
val domain = item.getServerAddress()
if (domain.isNullOrEmpty()) continue
if (newHosts.containsKey(domain)) {
item.ensureSockopt().domainStrategy = "UseIP"
item.ensureSockopt().happyEyeballs = V2rayConfig.OutboundBean.StreamSettingsBean.HappyEyeballsBean(
prioritizeIPv6 = preferIpv6,
interleave = 2
)
continue
}
val resolvedIps = HttpUtil.resolveHostToIP(domain, preferIpv6)
if (resolvedIps.isNullOrEmpty()) continue
item.ensureSockopt().domainStrategy = "UseIP"
item.ensureSockopt().happyEyeballs = V2rayConfig.OutboundBean.StreamSettingsBean.HappyEyeballsBean(
prioritizeIPv6 = preferIpv6,
interleave = 2
)
newHosts[domain] = if (resolvedIps.size == 1) {
resolvedIps[0]
} else {
resolvedIps
}
}
dns.hosts = newHosts
}
/**
* Converts a profile item to an outbound configuration.
*
* Delegates to [CoreOutboundBuilder] which owns all per-protocol
* conversion logic, keeping this manager focused on config orchestration.
*
* @param profileItem The profile item to convert
* @return OutboundBean configuration for the profile, or null if not supported
*/
private fun convertProfile2Outbound(profileItem: ProfileItem): V2rayConfig.OutboundBean? {
return CoreOutboundBuilder.convert(profileItem)
}
//endregion
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.handler
package com.v2ray.ang.core
import android.content.Context
import com.v2ray.ang.AppConfig
@@ -16,7 +16,7 @@ import java.util.concurrent.atomic.AtomicBoolean
* Thread-safe singleton wrapper for Libv2ray native methods.
* Provides initialization protection and unified API for V2Ray core operations.
*/
object V2RayNativeManager {
object CoreNativeManager {
private val initialized = AtomicBoolean(false)
/**
@@ -88,4 +88,4 @@ object V2RayNativeManager {
throw e
}
}
}
}
@@ -0,0 +1,697 @@
package com.v2ray.ang.core
import android.text.TextUtils
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
/**
* Centralizes ProfileItem -> OutboundBean conversion.
* Most protocol builders mirror the previous *Fmt.toOutbound behavior.
*/
object CoreOutboundBuilder {
/** Dispatches a profile to protocol-specific outbound builder. */
fun convert(profileItem: ProfileItem): OutboundBean? {
val outbound = when (profileItem.configType) {
EConfigType.VMESS -> toOutboundVmess(profileItem)
EConfigType.SHADOWSOCKS -> toOutboundShadowsocks(profileItem)
EConfigType.SOCKS -> toOutboundSocks(profileItem)
EConfigType.VLESS -> toOutboundVless(profileItem)
EConfigType.TROJAN -> toOutboundTrojan(profileItem)
EConfigType.WIREGUARD -> toOutboundWireguard(profileItem)
EConfigType.HYSTERIA2 -> toOutboundHysteria2(profileItem)
EConfigType.HTTP -> toOutboundHttp(profileItem)
else -> null
}
outbound ?: return null
val ret = updateOutboundWithGlobalSettings(outbound)
if (!ret) return null
return outbound
}
/** Applies global outbound options (mux, protocol-specific tweaks, etc.). */
private fun updateOutboundWithGlobalSettings(outbound: OutboundBean): Boolean {
try {
var muxEnabled = MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false)
val protocol = outbound.protocol
if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|| protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.WIREGUARD.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|| protocol.equals(EConfigType.HYSTERIA.name, true)
) {
muxEnabled = false
} else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) {
muxEnabled = false
}
if (muxEnabled) {
outbound.mux?.enabled = true
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
outbound.mux?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.vnext?.first()?.users?.first()?.flow?.isNotEmpty() == true) {
outbound.mux?.concurrency = -1
}
} else {
outbound.mux?.enabled = false
outbound.mux?.concurrency = -1
}
if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
var localTunAddr = if (outbound.settings?.address == null) {
listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4)
} else {
outbound.settings?.address as List<*>
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) != true) {
localTunAddr = listOf(localTunAddr.first())
}
outbound.settings?.address = localTunAddr
}
if (outbound.streamSettings?.network == AppConfig.DEFAULT_NETWORK
&& outbound.streamSettings?.tcpSettings?.header?.type == AppConfig.HEADER_TYPE_HTTP
) {
val path = outbound.streamSettings?.tcpSettings?.header?.request?.path
val host = outbound.streamSettings?.tcpSettings?.header?.request?.headers?.Host
val requestString: String by lazy {
"""{"version":"1.1","method":"GET","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.122 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate"],"Connection":["keep-alive"],"Pragma":["no-cache"]}}"""
}
outbound.streamSettings?.tcpSettings?.header?.request = JsonUtil.fromJson(
requestString,
OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean::class.java
)
outbound.streamSettings?.tcpSettings?.header?.request?.path =
if (path.isNullOrEmpty()) {
listOf("/")
} else {
path
}
outbound.streamSettings?.tcpSettings?.header?.request?.headers?.Host = host
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to update outbound with global settings", e)
return false
}
return true
}
/** Creates an initial outbound template for a protocol type. */
fun createInitOutbound(configType: EConfigType): OutboundBean? {
return when (configType) {
EConfigType.VMESS,
EConfigType.VLESS ->
return OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
vnext = listOf(
OutboundBean.OutSettingsBean.VnextBean(
users = listOf(OutboundBean.OutSettingsBean.VnextBean.UsersBean())
)
)
),
streamSettings = OutboundBean.StreamSettingsBean()
)
EConfigType.SHADOWSOCKS,
EConfigType.SOCKS,
EConfigType.HTTP,
EConfigType.TROJAN ->
return OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
servers = listOf(OutboundBean.OutSettingsBean.ServersBean())
),
streamSettings = OutboundBean.StreamSettingsBean()
)
EConfigType.WIREGUARD ->
return OutboundBean(
protocol = configType.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
secretKey = "",
peers = listOf(OutboundBean.OutSettingsBean.WireGuardBean())
)
)
EConfigType.HYSTERIA,
EConfigType.HYSTERIA2 ->
return OutboundBean(
protocol = EConfigType.HYSTERIA.name.lowercase(),
settings = OutboundBean.OutSettingsBean(
servers = null
),
streamSettings = OutboundBean.StreamSettingsBean()
)
else -> null
}
}
// ── Per-protocol builders — implementations are identical to each *Fmt.toOutbound ──
private fun toOutboundVmess(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.VMESS)
outboundBean?.settings?.vnext?.first()?.let { vnext ->
vnext.address = getServerAddress(profileItem)
vnext.port = profileItem.serverPort.orEmpty().toInt()
vnext.users[0].id = profileItem.password.orEmpty()
vnext.users[0].security = profileItem.method
}
val sni = outboundBean?.streamSettings?.let {
populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
private fun toOutboundVless(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.VLESS)
outboundBean?.settings?.vnext?.first()?.let { vnext ->
vnext.address = getServerAddress(profileItem)
vnext.port = profileItem.serverPort.orEmpty().toInt()
vnext.users[0].id = profileItem.password.orEmpty()
vnext.users[0].encryption = profileItem.method
vnext.users[0].flow = profileItem.flow
}
val sni = outboundBean?.streamSettings?.let {
populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
private fun toOutboundShadowsocks(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.SHADOWSOCKS)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.password = profileItem.password
server.method = profileItem.method
}
val sni = outboundBean?.streamSettings?.let {
populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
private fun toOutboundTrojan(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.TROJAN)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.password = profileItem.password
server.flow = profileItem.flow
}
val sni = outboundBean?.streamSettings?.let {
populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
private fun toOutboundSocks(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.SOCKS)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
if (profileItem.username.isNotNullEmpty()) {
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
socksUsersBean.user = profileItem.username.orEmpty()
socksUsersBean.pass = profileItem.password.orEmpty()
server.users = listOf(socksUsersBean)
}
}
return outboundBean
}
private fun toOutboundHttp(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.HTTP)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
if (profileItem.username.isNotNullEmpty()) {
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
socksUsersBean.user = profileItem.username.orEmpty()
socksUsersBean.pass = profileItem.password.orEmpty()
server.users = listOf(socksUsersBean)
}
}
return outboundBean
}
private fun toOutboundWireguard(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.WIREGUARD)
outboundBean?.settings?.let { wireguard ->
wireguard.secretKey = profileItem.secretKey
wireguard.address = (profileItem.localAddress ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4).split(",")
wireguard.peers?.firstOrNull()?.let { peer ->
peer.publicKey = profileItem.publicKey.orEmpty()
peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
}
wireguard.mtu = profileItem.mtu
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
}
return outboundBean
}
private fun toOutboundHysteria2(profileItem: ProfileItem): OutboundBean? {
val outboundBean = createInitOutbound(EConfigType.HYSTERIA2) ?: return null
profileItem.network = NetworkType.HYSTERIA.type
profileItem.alpn = "h3"
outboundBean.settings?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.version = 2
}
val sni = outboundBean.streamSettings?.let {
populateTransportSettings(it, profileItem)
}
outboundBean.streamSettings?.let {
populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
/**
* Configures transport settings for an outbound connection.
*
* Sets up protocol-specific transport options based on the profile settings.
*
* @param streamSettings The stream settings to configure
* @param profileItem The profile containing transport configuration
* @return The Server Name Indication (SNI) value to use, or null if not applicable
*/
fun populateTransportSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem): String? {
val transport = profileItem.network.orEmpty()
val headerType = profileItem.headerType
val host = profileItem.host
val path = profileItem.path
val seed = profileItem.seed
// val quicSecurity = profileItem.quicSecurity
// val key = profileItem.quicKey
val mode = profileItem.mode
val serviceName = profileItem.serviceName
val authority = profileItem.authority
val xhttpMode = profileItem.xhttpMode
val xhttpExtra = profileItem.xhttpExtra
val finalMask = profileItem.finalMask
var sni: String? = null
streamSettings.network = transport.ifEmpty { NetworkType.TCP.type }
when (streamSettings.network) {
NetworkType.TCP.type -> {
val tcpSetting = OutboundBean.StreamSettingsBean.TcpSettingsBean()
if (headerType == AppConfig.HEADER_TYPE_HTTP) {
tcpSetting.header.type = AppConfig.HEADER_TYPE_HTTP
if (!TextUtils.isEmpty(host) || !TextUtils.isEmpty(path)) {
val requestObj = OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean()
requestObj.headers.Host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
requestObj.path = path.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
tcpSetting.header.request = requestObj
sni = requestObj.headers.Host?.getOrNull(0)
}
} else {
tcpSetting.header.type = "none"
sni = host
}
streamSettings.tcpSettings = tcpSetting
}
NetworkType.KCP.type -> {
val kcpSetting = OutboundBean.StreamSettingsBean.KcpSettingsBean()
profileItem.kcpMtu?.let { kcpSetting.mtu = it }
profileItem.kcpTti?.let { kcpSetting.tti = it }
streamSettings.kcpSettings = kcpSetting
val udpMaskList = mutableListOf<OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean>()
if (!headerType.isNullOrEmpty() && headerType != "none") {
val kcpHeaderType = when {
headerType == "wechat-video" -> "header-wechat"
else -> "header-$headerType"
}
udpMaskList.add(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = kcpHeaderType,
settings = if (headerType == "dns" && !host.isNullOrEmpty()) {
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
domain = host
)
} else {
null
}
)
)
}
if (seed.isNullOrEmpty()) {
udpMaskList.add(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-original"
)
)
} else {
udpMaskList.add(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "mkcp-aes128gcm",
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
password = seed
)
)
)
}
streamSettings.finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
udp = udpMaskList.toList()
)
}
NetworkType.WS.type -> {
val wssetting = OutboundBean.StreamSettingsBean.WsSettingsBean()
wssetting.headers.Host = host.orEmpty()
sni = host
wssetting.path = path ?: "/"
streamSettings.wsSettings = wssetting
}
NetworkType.HTTP_UPGRADE.type -> {
val httpupgradeSetting = OutboundBean.StreamSettingsBean.HttpupgradeSettingsBean()
httpupgradeSetting.host = host.orEmpty()
sni = host
httpupgradeSetting.path = path ?: "/"
streamSettings.httpupgradeSettings = httpupgradeSetting
}
NetworkType.XHTTP.type -> {
val xhttpSetting = OutboundBean.StreamSettingsBean.XhttpSettingsBean()
xhttpSetting.host = host.orEmpty()
sni = host
xhttpSetting.path = path ?: "/"
xhttpSetting.mode = xhttpMode
xhttpSetting.extra = JsonUtil.parseString(xhttpExtra)
streamSettings.xhttpSettings = xhttpSetting
}
NetworkType.H2.type, NetworkType.HTTP.type -> {
streamSettings.network = NetworkType.H2.type
val h2Setting = OutboundBean.StreamSettingsBean.HttpSettingsBean()
h2Setting.host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
sni = h2Setting.host.getOrNull(0)
h2Setting.path = path ?: "/"
streamSettings.httpSettings = h2Setting
}
// "quic" -> {
// val quicsetting = QuicSettingBean()
// quicsetting.security = quicSecurity ?: "none"
// quicsetting.key = key.orEmpty()
// quicsetting.header.type = headerType ?: "none"
// quicSettings = quicsetting
// }
NetworkType.GRPC.type -> {
val grpcSetting = OutboundBean.StreamSettingsBean.GrpcSettingsBean()
grpcSetting.multiMode = mode == "multi"
grpcSetting.serviceName = serviceName.orEmpty()
grpcSetting.authority = authority.orEmpty()
grpcSetting.idle_timeout = 60
grpcSetting.health_check_timeout = 20
sni = authority
streamSettings.grpcSettings = grpcSetting
}
NetworkType.HYSTERIA.type -> {
val hysteriaSetting = OutboundBean.StreamSettingsBean.HysteriaSettingsBean(
version = 2,
auth = profileItem.password.orEmpty(),
)
val quicParams = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean(
brutalUp = profileItem.bandwidthUp?.nullIfBlank(),
brutalDown = profileItem.bandwidthDown?.nullIfBlank(),
)
quicParams.congestion = if (quicParams.brutalUp != null || quicParams.brutalDown != null) "brutal" else null
if (profileItem.portHopping.isNotNullEmpty()) {
val rawInterval = profileItem.portHoppingInterval?.trim().nullIfBlank()
val interval = if (rawInterval == null) {
"30"
} else {
val singleValue = rawInterval.toIntOrNull()
if (singleValue != null) {
if (singleValue < 5) {
"30"
} else {
rawInterval
}
} else {
val parts = rawInterval.split('-')
if (parts.size == 2) {
val start = parts[0].trim().toIntOrNull()
val end = parts[1].trim().toIntOrNull()
if (start != null && end != null) {
val minStart = maxOf(5, start)
val minEnd = maxOf(minStart, end)
"$minStart-$minEnd"
} else {
"30"
}
} else {
"30"
}
}
}
quicParams.udpHop = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean.UdpHopBean(
ports = profileItem.portHopping,
interval = interval
)
}
val finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
quicParams = quicParams
)
if (profileItem.obfsPassword.isNotNullEmpty()) {
finalmask.udp = listOf(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "salamander",
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
password = profileItem.obfsPassword.orEmpty()
)
)
)
}
streamSettings.hysteriaSettings = hysteriaSetting
streamSettings.finalmask = finalmask
}
}
finalMask?.let {
val parsedFinalMask = JsonUtil.parseString(finalMask)
if (parsedFinalMask != null) {
streamSettings.finalmask = parsedFinalMask
} else {
LogUtil.w("V2rayConfigManager", "Invalid finalMask JSON, keeping previously generated finalmask")
}
}
return sni
}
/**
* Configures TLS or REALITY security settings for an outbound connection.
*
* Sets up security-related parameters like certificates, fingerprints, and SNI.
*
* @param streamSettings The stream settings to configure
* @param profileItem The profile containing security configuration
* @param sniExt An external SNI value to use if the profile doesn't specify one
*/
fun populateTlsSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem, sniExt: String?) {
val streamSecurity = profileItem.security.orEmpty()
val allowInsecure = profileItem.insecure == true
val sni = if (profileItem.sni.isNullOrEmpty()) {
when {
sniExt.isNotNullEmpty() && Utils.isDomainName(sniExt) -> sniExt
profileItem.server.isNotNullEmpty() && Utils.isDomainName(profileItem.server) -> profileItem.server
else -> sniExt
}
} else {
profileItem.sni
}
streamSettings.security = streamSecurity.nullIfBlank()
if (streamSettings.security == null) return
val tlsSetting = OutboundBean.StreamSettingsBean.TlsSettingsBean(
allowInsecure = allowInsecure,
serverName = sni.nullIfBlank(),
fingerprint = profileItem.fingerPrint.nullIfBlank(),
alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() },
echConfigList = profileItem.echConfigList.nullIfBlank(),
pinnedPeerCertSha256 = profileItem.pinnedCA256.nullIfBlank(),
publicKey = profileItem.publicKey.nullIfBlank(),
shortId = profileItem.shortId.nullIfBlank(),
spiderX = profileItem.spiderX.nullIfBlank(),
mldsa65Verify = profileItem.mldsa65Verify.nullIfBlank(),
)
if (streamSettings.security == AppConfig.TLS) {
streamSettings.tlsSettings = tlsSetting
streamSettings.realitySettings = null
} else if (streamSettings.security == AppConfig.REALITY) {
streamSettings.tlsSettings = null
streamSettings.realitySettings = tlsSetting
}
if (profileItem.finalMask.isNullOrEmpty()) {
updateOutboundFragment(streamSettings)
}
}
/**
* Updates the outbound with fragment settings for traffic optimization.
*
* Configures packet fragmentation for TLS and REALITY protocols if enabled.
*
* @param streamSettings The streamSettings object to be modified
* @return true if fragment configuration was successful, false otherwise
*/
private fun updateOutboundFragment(streamSettings: OutboundBean.StreamSettingsBean): Boolean {
try {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == false) {
return true
}
if (streamSettings.security != AppConfig.TLS
&& streamSettings.security != AppConfig.REALITY
) {
return true
}
if (streamSettings.sockopt?.dialerProxy.isNotNullEmpty()) {
return true
}
var packets =
MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS) ?: "tlshello"
if (streamSettings.security == AppConfig.REALITY
&& packets == "tlshello"
) {
packets = "1-3"
} else if (streamSettings.security == AppConfig.TLS
&& packets != "tlshello"
) {
packets = "tlshello"
}
val fragmentMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "fragment",
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
packets = packets,
length = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH)
?: "50-100",
delay = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL)
?: "10-20"
)
)
val noiseMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
type = "noise",
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
noise = listOf(
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean.NoiseMaskBean(
rand = "10-20",
delay = "10-16",
)
)
)
)
val finalMaskObj = streamSettings.finalmask?.let { existingFinalMask ->
JsonUtil.parseString(JsonUtil.toJson(existingFinalMask))
} ?: JsonObject()
// finalmask.tcp / finalmask.udp are arrays; prepend mask at index 0.
fun prependMask(scope: String, mask: OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean) {
val current = finalMaskObj.get(scope)
if (current != null && current.isJsonArray && current.asJsonArray.size() > 0) {
return
}
val newArray = JsonArray()
newArray.add(JsonUtil.parseString(JsonUtil.toJson(mask)))
if (current != null && current.isJsonArray) {
current.asJsonArray.forEach { newArray.add(it) }
}
finalMaskObj.add(scope, newArray)
}
prependMask("tcp", fragmentMask)
prependMask("udp", noiseMask)
streamSettings.finalmask = finalMaskObj
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to update outbound fragment", e)
return false
}
return true
}
private fun getServerAddress(profileItem: ProfileItem): String {
if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
return profileItem.server.orEmpty()
}
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
return domain
}
//Resolve and replace domain
val resolvedIps = HttpUtil.resolveHostToIP(domain, MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
if (resolvedIps.isNullOrEmpty()) {
return domain
}
return resolvedIps.first()
}
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.handler
package com.v2ray.ang.core
import android.app.Service
import android.content.BroadcastReceiver
@@ -16,8 +16,12 @@ import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.service.V2RayProxyOnlyService
import com.v2ray.ang.service.V2RayVpnService
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.SpeedtestManager
import com.v2ray.ang.service.CoreProxyOnlyService
import com.v2ray.ang.service.CoreVpnService
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.Utils
@@ -30,9 +34,9 @@ import libv2ray.ProcessFinder
import java.lang.ref.SoftReference
import java.net.InetSocketAddress
object V2RayServiceManager {
object CoreServiceManager {
private val coreController: CoreController = V2RayNativeManager.newCoreController(CoreCallback())
private val coreController: CoreController = CoreNativeManager.newCoreController(CoreCallback())
private val mMsgReceive = ReceiveMessageHandler()
private var currentConfig: ProfileItem? = null
private var processFinder: XrayProcessFinder? = null
@@ -41,7 +45,7 @@ object V2RayServiceManager {
set(value) {
field = value
val service = value?.get()?.getService()
V2RayNativeManager.initCoreEnv(service)
CoreNativeManager.initCoreEnv(service)
if (service != null && processFinder == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
processFinder = XrayProcessFinder(service)
coreController.registerProcessFinder(processFinder)
@@ -123,6 +127,7 @@ object V2RayServiceManager {
if (config.configType != EConfigType.CUSTOM
&& config.configType != EConfigType.POLICYGROUP
&& config.configType != EConfigType.PROXYCHAIN
&& !Utils.isValidUrl(config.server)
&& !Utils.isPureIpAddress(config.server.orEmpty())
) {
@@ -144,10 +149,10 @@ object V2RayServiceManager {
val isVpnMode = SettingsManager.isVpnMode()
val intent = if (isVpnMode) {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
Intent(context.applicationContext, V2RayVpnService::class.java)
Intent(context.applicationContext, CoreVpnService::class.java)
} else {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
Intent(context.applicationContext, CoreProxyOnlyService::class.java)
}
try {
@@ -187,7 +192,7 @@ object V2RayServiceManager {
}
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting core loop for ${config.remarks}")
val result = V2rayConfigManager.getV2rayConfig(service, guid)
val result = CoreConfigManager.getV2rayConfig(service, guid)
LogUtil.d(AppConfig.TAG, result.content)
if (!result.status) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to get V2Ray config")
@@ -0,0 +1,16 @@
package com.v2ray.ang.dto
import android.content.Context
import com.v2ray.ang.enums.CoreResolvedType
/**
* Runtime context produced by the builder and consumed by CoreConfigManager.
*/
data class CoreConfigContext(
val context: Context,
val guid: String,
val selectedProfile: ProfileItem,
val resolvedProfiles: List<ProfileItem>,
val resolvedType: CoreResolvedType,
val customOutboundProfiles: Map<String, ProfileItem> = emptyMap(),
)
@@ -69,6 +69,7 @@ data class ProfileItem(
var policyGroupType: String? = null,
var policyGroupSubscriptionId: String? = null,
var policyGroupFilter: String? = null,
var proxyChainProfiles: String? = null,
) {
companion object {
@@ -129,6 +130,7 @@ data class ProfileItem(
&& this.portHopping == obj.portHopping
&& this.portHoppingInterval == obj.portHoppingInterval
&& this.pinnedCA256 == obj.pinnedCA256
&& this.proxyChainProfiles == obj.proxyChainProfiles
)
}
}
@@ -0,0 +1,14 @@
package com.v2ray.ang.dto
sealed class RealPingEvent {
/** Periodic progress update while the batch is still running. */
data class Progress(val text: String) : RealPingEvent()
/** A single server result is available. */
data class Result(val guid: String, val delayMillis: Long) : RealPingEvent()
/** The entire batch has finished or been cancelled. */
data class Finish(val status: String) : RealPingEvent()
}
@@ -401,6 +401,7 @@ data class V2rayConfig(
val clientIp: String? = null,
val disableCache: Boolean? = null,
val queryStrategy: String? = null,
val enableParallelQuery: Boolean? = null,
val tag: String? = null
) {
data class ServersBean(
@@ -0,0 +1,9 @@
package com.v2ray.ang.enums
/** Runtime type used during config assembly only. */
enum class CoreResolvedType {
NORMAL,
POLICYGROUP,
PROXYCHAIN,
CUSTOM,
}
@@ -15,7 +15,8 @@ enum class EConfigType(val value: Int, val protocolScheme: String) {
HYSTERIA2(9, AppConfig.HYSTERIA2),
HYSTERIA(900, AppConfig.HYSTERIA),
HTTP(10, AppConfig.HTTP),
POLICYGROUP(101, AppConfig.CUSTOM);
POLICYGROUP(101, AppConfig.CUSTOM),
PROXYCHAIN(102, AppConfig.CUSTOM);
companion object {
fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
@@ -0,0 +1,22 @@
package com.v2ray.ang.enums
/**
* Enum defining different notification channels.
* Each channel has a unique channelId, notificationId, and display name.
*/
enum class NotificationChannelType(
val channelId: String,
val channelName: String,
val notificationId: Int
) {
SUBSCRIPTION_UPDATE(
channelId = "subscription_update_channel",
channelName = "Subscription Update Service",
notificationId = 13
),
CORE_TEST(
channelId = "core_test_channel",
channelName = "Core Test Service",
notificationId = 12
)
}
@@ -172,21 +172,4 @@ open class FmtBase {
return dicQuery
}
fun getServerAddress(profileItem: ProfileItem): String {
if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
return profileItem.server.orEmpty()
}
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
return domain
}
//Resolve and replace domain
val resolvedIps = HttpUtil.resolveHostToIP(domain, MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
if (resolvedIps.isNullOrEmpty()) {
return domain
}
return resolvedIps.first()
}
}
@@ -1,32 +0,0 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.V2rayConfigManager
object HttpFmt : FmtBase() {
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HTTP)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
if (profileItem.username.isNotNullEmpty()) {
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
socksUsersBean.user = profileItem.username.orEmpty()
socksUsersBean.pass = profileItem.password.orEmpty()
server.users = listOf(socksUsersBean)
}
}
return outboundBean
}
}
@@ -2,14 +2,12 @@ package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -110,32 +108,4 @@ object Hysteria2Fmt : FmtBase() {
return toUri(config, config.password, dicQuery)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HYSTERIA2) ?: return null
profileItem.network = NetworkType.HYSTERIA.type
profileItem.alpn = "h3"
outboundBean.settings?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.version = 2
}
val sni = outboundBean.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}
@@ -2,11 +2,9 @@ package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -124,31 +122,4 @@ object ShadowsocksFmt : FmtBase() {
return toUri(config, Utils.encode(pw, true), null)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SHADOWSOCKS)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.password = profileItem.password
server.method = profileItem.method
}
val sni = outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}
@@ -1,11 +1,9 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -53,27 +51,4 @@ object SocksFmt : FmtBase() {
return toUri(config, Utils.encode(pw, true), null)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SOCKS)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
if (profileItem.username.isNotNullEmpty()) {
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
socksUsersBean.user = profileItem.username.orEmpty()
socksUsersBean.pass = profileItem.password.orEmpty()
server.users = listOf(socksUsersBean)
}
}
return outboundBean
}
}
@@ -2,12 +2,10 @@ package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -53,31 +51,4 @@ object TrojanFmt : FmtBase() {
return toUri(config, config.password, dicQuery)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.TROJAN)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.password = profileItem.password
server.flow = profileItem.flow
}
val sni = outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}
@@ -2,11 +2,9 @@ package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -50,31 +48,4 @@ object VlessFmt : FmtBase() {
return toUri(config, config.password, dicQuery)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VLESS)
outboundBean?.settings?.vnext?.first()?.let { vnext ->
vnext.address = getServerAddress(profileItem)
vnext.port = profileItem.serverPort.orEmpty().toInt()
vnext.users[0].id = profileItem.password.orEmpty()
vnext.users[0].encryption = profileItem.method
vnext.users[0].flow = profileItem.flow
}
val sni = outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}
@@ -3,14 +3,12 @@ package com.v2ray.ang.fmt
import android.text.TextUtils
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.VmessQRCode
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
@@ -174,31 +172,5 @@ object VmessFmt : FmtBase() {
return config
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VMESS)
outboundBean?.settings?.vnext?.first()?.let { vnext ->
vnext.address = getServerAddress(profileItem)
vnext.port = profileItem.serverPort.orEmpty().toInt()
vnext.users[0].id = profileItem.password.orEmpty()
vnext.users[0].security = profileItem.method
}
val sni = outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}
@@ -1,14 +1,11 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.removeWhiteSpace
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -31,7 +28,7 @@ object WireguardFmt : FmtBase() {
config.serverPort = uri.port.toString()
config.secretKey = uri.userInfo.orEmpty()
config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
config.localAddress = queryParam["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
config.publicKey = queryParam["publickey"].orEmpty()
config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
@@ -82,7 +79,7 @@ object WireguardFmt : FmtBase() {
config.secretKey = interfaceParams["privatekey"].orEmpty()
config.remarks = System.currentTimeMillis().toString()
config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
config.localAddress = interfaceParams["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.publicKey = peerParams["publickey"].orEmpty()
config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
@@ -100,29 +97,6 @@ object WireguardFmt : FmtBase() {
return config
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.WIREGUARD)
outboundBean?.settings?.let { wireguard ->
wireguard.secretKey = profileItem.secretKey
wireguard.address = (profileItem.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4).split(",")
wireguard.peers?.firstOrNull()?.let { peer ->
peer.publicKey = profileItem.publicKey.orEmpty()
peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
}
wireguard.mtu = profileItem.mtu
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
}
return outboundBean
}
/**
* Converts a ProfileItem object to a URI string.
@@ -6,6 +6,7 @@ import android.text.TextUtils
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.HY2
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreConfigManager
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem
@@ -111,7 +112,7 @@ object AngConfigManager {
fun shareFullContent2Clipboard(context: Context, guid: String?): Int {
try {
if (guid == null) return -1
val result = V2rayConfigManager.getV2rayConfig(context, guid)
val result = CoreConfigManager.getV2rayConfig(context, guid)
if (result.status) {
Utils.setClipboard(context, result.content)
} else {
@@ -136,15 +137,12 @@ object AngConfigManager {
return config.configType.protocolScheme + when (config.configType) {
EConfigType.VMESS -> VmessFmt.toUri(config)
EConfigType.CUSTOM -> ""
EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toUri(config)
EConfigType.SOCKS -> SocksFmt.toUri(config)
EConfigType.HTTP -> ""
EConfigType.VLESS -> VlessFmt.toUri(config)
EConfigType.TROJAN -> TrojanFmt.toUri(config)
EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
EConfigType.POLICYGROUP -> ""
else -> {}
}
} catch (e: Exception) {
@@ -13,6 +13,7 @@ import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toSpeedString
import com.v2ray.ang.ui.MainActivity
@@ -44,7 +45,7 @@ object NotificationManager {
*/
fun startSpeedNotification(currentConfig: ProfileItem?) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return
if (speedNotificationJob != null || CoreServiceManager.isRunning() == false) return
var lastZeroSpeed = false
val outboundTags = currentConfig?.getAllOutboundTags()
@@ -67,15 +68,15 @@ object NotificationManager {
var proxyTotal = 0L
val text = StringBuilder()
outboundTags?.forEach {
val up = V2RayServiceManager.queryStats(it, AppConfig.UPLINK)
val down = V2RayServiceManager.queryStats(it, AppConfig.DOWNLINK)
val up = CoreServiceManager.queryStats(it, AppConfig.UPLINK)
val down = CoreServiceManager.queryStats(it, AppConfig.DOWNLINK)
if (up + down > 0) {
appendSpeedString(text, it, up / sinceLastQueryInSeconds, down / sinceLastQueryInSeconds)
proxyTotal += up + down
}
}
val directUplink = V2RayServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.UPLINK)
val directDownlink = V2RayServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.DOWNLINK)
val directUplink = CoreServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.UPLINK)
val directDownlink = CoreServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.DOWNLINK)
val zeroSpeed = proxyTotal == 0L && directUplink == 0L && directDownlink == 0L
if (!zeroSpeed || !lastZeroSpeed) {
if (proxyTotal == 0L) {
@@ -251,6 +252,6 @@ object NotificationManager {
* @return The service instance.
*/
private fun getService(): Service? {
return V2RayServiceManager.serviceControl?.get()?.getService()
return CoreServiceManager.serviceControl?.get()?.getService()
}
}
@@ -393,6 +393,15 @@ object SettingsManager {
}
}
/**
* Get real ping concurrency.
* @return The number of concurrent real-ping tests (clamped to 1..64).
*/
fun getRealPingConcurrency(): Int {
val value = MmkvManager.decodeSettingsString(AppConfig.PREF_REAL_PING_CONCURRENCY)?.toIntOrNull() ?: 16
return value.coerceIn(1, 128)
}
/**
* Get the locale.
* @return The locale.
@@ -1,12 +1,7 @@
package com.v2ray.ang.handler
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
@@ -20,6 +15,8 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.NotificationHelper
import com.v2ray.ang.enums.NotificationChannelType
import java.util.concurrent.TimeUnit
object SubscriptionUpdater {
@@ -140,7 +137,7 @@ object SubscriptionUpdater {
LogUtil.i(
AppConfig.TAG,
"SubscriptionUpdater: scheduled [$subId] interval=${intervalMinutes}min " +
"initialDelay=${initialDelayMillis / 1000}s policy=$existingWorkPolicy"
"initialDelay=${initialDelayMillis / 1000}s policy=$existingWorkPolicy"
)
}
@@ -153,16 +150,6 @@ object SubscriptionUpdater {
class UpdateTask(context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
private val notificationManager = NotificationManagerCompat.from(applicationContext)
private val notification =
NotificationCompat.Builder(applicationContext, AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
.setWhen(0)
.setTicker("Update")
.setContentTitle(applicationContext.getString(R.string.title_pref_auto_update_subscription))
.setSmallIcon(R.drawable.ic_stat_name)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
@SuppressLint("MissingPermission")
override suspend fun doWork(): Result {
val subId = inputData.getString(KEY_SUB_ID)
@@ -173,7 +160,6 @@ object SubscriptionUpdater {
return Result.success()
}
val subItem = MmkvManager.decodeSubscription(subId)
if (subItem == null) {
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: no subscription found for $subId")
@@ -187,22 +173,20 @@ object SubscriptionUpdater {
val sub = SubscriptionCache(subId, subItem)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(
NotificationChannel(
AppConfig.SUBSCRIPTION_UPDATE_CHANNEL,
AppConfig.SUBSCRIPTION_UPDATE_CHANNEL_NAME,
NotificationManager.IMPORTANCE_MIN
)
)
}
// Notify about update start
NotificationHelper.notify(
NotificationChannelType.SUBSCRIPTION_UPDATE,
applicationContext,
applicationContext.getString(R.string.title_pref_auto_update_subscription),
"Updating ${sub.subscription.remarks}"
)
notificationManager.notify(3, notification.build())
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater automatic update: ---${sub.subscription.remarks}")
AngConfigManager.updateConfigViaSub(sub)
notification.setContentText("Updating ${sub.subscription.remarks}")
notificationManager.cancel(3)
// Clear notification
NotificationHelper.cancel(NotificationChannelType.SUBSCRIPTION_UPDATE, applicationContext)
return Result.success()
}
}
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,7 @@ import android.content.Intent
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.util.LogUtil
class BootReceiver : BroadcastReceiver() {
@@ -37,7 +37,7 @@ class BootReceiver : BroadcastReceiver() {
}
LogUtil.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
V2RayServiceManager.startVService(context)
CoreServiceManager.startVService(context)
SubscriptionUpdater.sync(context)
}
}
@@ -5,7 +5,7 @@ import android.content.Context
import android.content.Intent
import android.text.TextUtils
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.util.LogUtil
class TaskerReceiver : BroadcastReceiver() {
@@ -28,12 +28,12 @@ class TaskerReceiver : BroadcastReceiver() {
return
} else if (switch) {
if (guid == AppConfig.TASKER_DEFAULT_GUID) {
V2RayServiceManager.startVServiceFromToggle(context)
CoreServiceManager.startVServiceFromToggle(context)
} else {
V2RayServiceManager.startVService(context, guid)
CoreServiceManager.startVService(context, guid)
}
} else {
V2RayServiceManager.stopVService(context)
CoreServiceManager.stopVService(context)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
@@ -9,7 +9,7 @@ import android.content.Intent
import android.widget.RemoteViews
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
class WidgetProvider : AppWidgetProvider() {
/**
@@ -22,7 +22,7 @@ class WidgetProvider : AppWidgetProvider() {
*/
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
updateWidgetBackground(context, appWidgetManager, appWidgetIds, V2RayServiceManager.isRunning())
updateWidgetBackground(context, appWidgetManager, appWidgetIds, CoreServiceManager.isRunning())
}
/**
@@ -67,10 +67,10 @@ class WidgetProvider : AppWidgetProvider() {
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (AppConfig.BROADCAST_ACTION_WIDGET_CLICK == intent.action) {
if (V2RayServiceManager.isRunning()) {
V2RayServiceManager.stopVService(context)
if (CoreServiceManager.isRunning()) {
CoreServiceManager.stopVService(context)
} else {
V2RayServiceManager.startVServiceFromToggle(context)
CoreServiceManager.startVServiceFromToggle(context)
}
} else if (AppConfig.BROADCAST_ACTION_ACTIVITY == intent.action) {
AppWidgetManager.getInstance(context)?.let { manager ->
@@ -7,19 +7,19 @@ import android.os.IBinder
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import java.lang.ref.SoftReference
class V2RayProxyOnlyService : Service(), ServiceControl {
class CoreProxyOnlyService : Service(), ServiceControl {
/**
* Initializes the service.
*/
override fun onCreate() {
super.onCreate()
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service created")
V2RayServiceManager.serviceControl = SoftReference(this)
CoreServiceManager.serviceControl = SoftReference(this)
}
/**
@@ -31,7 +31,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
V2RayServiceManager.startCoreLoop(null)
CoreServiceManager.startCoreLoop(null)
return START_STICKY
}
@@ -40,7 +40,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
*/
override fun onDestroy() {
super.onDestroy()
V2RayServiceManager.stopCoreLoop()
CoreServiceManager.stopCoreLoop()
}
/**
@@ -0,0 +1,140 @@
package com.v2ray.ang.service
import android.app.Service
import android.content.Intent
import android.os.IBinder
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.RealPingEvent
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.NotificationHelper
import com.v2ray.ang.enums.NotificationChannelType
import java.util.Collections
class CoreTestService : Service() {
// manage active batch workers so each batch is independent and cancellable
private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
/**
* Initializes the V2Ray environment.
*/
override fun onCreate() {
super.onCreate()
CoreNativeManager.initCoreEnv(this)
}
/**
* Binds the service.
* @param intent The intent.
* @return The binder.
*/
override fun onBind(intent: Intent?): IBinder? {
return null
}
/**
* Cleans up resources when the service is destroyed.
*/
override fun onDestroy() {
LogUtil.i(AppConfig.TAG, "CoreTestService is being destroyed, cancelling ${activeWorkers.size} active workers")
// cancel any active workers
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
NotificationHelper.stopForeground(this)
super.onDestroy()
}
/**
* Handles the start command for the service.
* @param intent The intent.
* @param flags The flags.
* @param startId The start ID.
* @return The start mode.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val message = intent?.serializable<TestServiceMessage>("content")
if (message == null) {
stopSelf(startId)
return START_NOT_STICKY
}
when (message.key) {
AppConfig.MSG_MEASURE_CONFIG_START -> handleMeasureStart(message, startId)
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> handleMeasureCancel()
else -> { NotificationHelper.stopForeground(this); stopSelf(startId) }
}
return START_NOT_STICKY
}
private fun handleMeasureStart(message: TestServiceMessage, startId: Int) {
LogUtil.i(AppConfig.TAG, "CoreTestService starting worker subscription ${message.subscriptionId}")
NotificationHelper.startForeground(
this,
NotificationChannelType.CORE_TEST,
getString(R.string.app_name),
getString(R.string.title_real_ping_all_server)
)
val guidsList = when {
message.serverGuids.isNotEmpty() -> message.serverGuids
message.subscriptionId.isNotEmpty() -> MmkvManager.decodeServerList(message.subscriptionId)
else -> MmkvManager.decodeAllServerList()
}
if (guidsList.isNotEmpty()) {
lateinit var worker: RealPingWorkerService
worker = RealPingWorkerService(
context = this,
guids = guidsList,
onEvent = { event -> handleWorkerEvent(event) { activeWorkers.remove(worker) } }
)
activeWorkers.add(worker)
worker.start()
} else {
NotificationHelper.stopForeground(this)
stopSelf(startId)
}
}
private fun handleWorkerEvent(event: RealPingEvent, onWorkerDone: () -> Unit) {
when (event) {
is RealPingEvent.Progress -> {
NotificationHelper.updateNotification(
channelType = NotificationChannelType.CORE_TEST,
context = this,
content = getString(R.string.connection_runing_task_left, event.text)
)
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text)
}
is RealPingEvent.Result -> {
MmkvManager.encodeServerTestDelayMillis(event.guid, event.delayMillis)
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, event.guid)
}
is RealPingEvent.Finish -> {
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status)
onWorkerDone()
if (activeWorkers.isEmpty()) {
NotificationHelper.stopForeground(this)
stopSelf()
}
}
}
}
private fun handleMeasureCancel() {
LogUtil.i(AppConfig.TAG, "CoreTestService received cancel message, cancelling ${activeWorkers.size} active workers")
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
NotificationHelper.stopForeground(this)
stopSelf()
}
}
@@ -23,14 +23,14 @@ import com.v2ray.ang.contracts.Tun2SocksControl
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import com.v2ray.ang.util.Utils
import java.lang.ref.SoftReference
@SuppressLint("VpnServicePolicy")
class V2RayVpnService : VpnService(), ServiceControl {
class CoreVpnService : VpnService(), ServiceControl {
private lateinit var mInterface: ParcelFileDescriptor
private var isRunning = false
private var tun2SocksService: Tun2SocksControl? = null
@@ -77,7 +77,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service created")
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
StrictMode.setThreadPolicy(policy)
V2RayServiceManager.serviceControl = SoftReference(this)
CoreServiceManager.serviceControl = SoftReference(this)
}
override fun onRevoke() {
@@ -128,7 +128,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Interface not initialized")
return
}
if (!V2RayServiceManager.startCoreLoop(mInterface)) {
if (!CoreServiceManager.startCoreLoop(mInterface)) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to start core loop")
stopAllService()
return
@@ -361,7 +361,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
tun2SocksService?.stopTun2Socks()
tun2SocksService = null
V2RayServiceManager.stopCoreLoop()
CoreServiceManager.stopCoreLoop()
if (isForced) {
//stopSelf has to be called ahead of mInterface.close(). otherwise v2ray core cannot be stooped
@@ -10,7 +10,7 @@ import android.service.quicksettings.TileService
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.Utils
@@ -29,7 +29,7 @@ class QSTileService : TileService() {
qsTile?.label = getString(R.string.app_name)
} else if (state == Tile.STATE_ACTIVE) {
qsTile?.state = Tile.STATE_ACTIVE
qsTile?.label = V2RayServiceManager.getRunningServerName()
qsTile?.label = CoreServiceManager.getRunningServerName()
}
qsTile?.updateTile()
@@ -42,7 +42,7 @@ class QSTileService : TileService() {
override fun onStartListening() {
super.onStartListening()
if (V2RayServiceManager.isRunning()) {
if (CoreServiceManager.isRunning()) {
setState(Tile.STATE_ACTIVE)
} else {
setState(Tile.STATE_INACTIVE)
@@ -75,11 +75,11 @@ class QSTileService : TileService() {
super.onClick()
when (qsTile.state) {
Tile.STATE_INACTIVE -> {
V2RayServiceManager.startVServiceFromToggle(this)
CoreServiceManager.startVServiceFromToggle(this)
}
Tile.STATE_ACTIVE -> {
V2RayServiceManager.stopVService(this)
CoreServiceManager.stopVService(this)
}
}
}
@@ -1,11 +1,10 @@
package com.v2ray.ang.service
import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.RealPingEvent
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.core.CoreConfigManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
@@ -23,11 +22,11 @@ import java.util.concurrent.atomic.AtomicInteger
class RealPingWorkerService(
private val context: Context,
private val guids: List<String>,
private val onFinish: (status: String) -> Unit = {}
private val onEvent: (RealPingEvent) -> Unit = {}
) {
private val job = SupervisorJob()
private val cpu = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
private val dispatcher = Executors.newFixedThreadPool(cpu * 4).asCoroutineDispatcher()
private val concurrency = SettingsManager.getRealPingConcurrency()
private val dispatcher = Executors.newFixedThreadPool(concurrency).asCoroutineDispatcher()
private val scope = CoroutineScope(job + dispatcher + CoroutineName("RealPingBatchWorker"))
private val runningCount = AtomicInteger(0)
@@ -40,11 +39,13 @@ class RealPingWorkerService(
runningCount.incrementAndGet()
try {
val result = startRealPing(guid)
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
onEvent(RealPingEvent.Result(guid, result))
} catch (_: Throwable) {
// ignore
} finally {
val count = totalCount.decrementAndGet()
val left = runningCount.decrementAndGet()
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $count")
onEvent(RealPingEvent.Progress("$left / $count"))
}
}
}
@@ -52,9 +53,9 @@ class RealPingWorkerService(
scope.launch {
try {
joinAll(*jobs.toTypedArray())
onFinish("0")
onEvent(RealPingEvent.Finish("0"))
} catch (_: CancellationException) {
onFinish("-1")
onEvent(RealPingEvent.Finish("-1"))
} finally {
close()
}
@@ -75,11 +76,10 @@ class RealPingWorkerService(
private fun startRealPing(guid: String): Long {
val retFailure = -1L
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
val configResult = CoreConfigManager.getV2rayConfig4Speedtest(context, guid)
if (!configResult.status) {
return retFailure
}
return V2RayNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
return CoreNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
}
}
@@ -1,89 +0,0 @@
package com.v2ray.ang.service
import android.app.Service
import android.content.Intent
import android.os.IBinder
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.util.MessageUtil
import java.util.Collections
class V2RayTestService : Service() {
// manage active batch workers so each batch is independent and cancellable
private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
/**
* Initializes the V2Ray environment.
*/
override fun onCreate() {
super.onCreate()
V2RayNativeManager.initCoreEnv(this)
}
/**
* Binds the service.
* @param intent The intent.
* @return The binder.
*/
override fun onBind(intent: Intent?): IBinder? {
return null
}
/**
* Cleans up resources when the service is destroyed.
*/
override fun onDestroy() {
super.onDestroy()
// cancel any active workers
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
}
/**
* Handles the start command for the service.
* @param intent The intent.
* @param flags The flags.
* @param startId The start ID.
* @return The start mode.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val message = intent?.serializable<TestServiceMessage>("content") ?: return super.onStartCommand(intent, flags, startId)
when (message.key) {
MSG_MEASURE_CONFIG -> {
val guidsList = if (message.serverGuids.isNotEmpty()) {
message.serverGuids
} else if (message.subscriptionId.isNotEmpty()) {
MmkvManager.decodeServerList(message.subscriptionId)
} else {
MmkvManager.decodeAllServerList()
}
if (guidsList.isNotEmpty()) {
lateinit var worker: RealPingWorkerService
worker = RealPingWorkerService(this, guidsList) { status ->
// notify UI and remove the worker from active list when finished
MessageUtil.sendMsg2UI(this@V2RayTestService, AppConfig.MSG_MEASURE_CONFIG_FINISH, status)
activeWorkers.remove(worker)
}
activeWorkers.add(worker)
worker.start()
}
}
MSG_MEASURE_CONFIG_CANCEL -> {
// cancel all running batch workers independently
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
}
}
return super.onStartCommand(intent, flags, startId)
}
}
@@ -5,7 +5,7 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityAboutBinding
import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.util.Utils
class AboutActivity : BaseActivity() {
@@ -42,7 +42,7 @@ class AboutActivity : BaseActivity() {
Utils.openUri(this, AppConfig.APP_PRIVACY_POLICY)
}
"v${BuildConfig.VERSION_NAME} (${V2RayNativeManager.getLibVersion()})".also {
"v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})".also {
binding.tvVersion.text = it
}
BuildConfig.APPLICATION_ID.also {
@@ -13,7 +13,7 @@ import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.UpdateCheckerManager
import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.launch
@@ -36,7 +36,7 @@ class CheckUpdateActivity : BaseActivity() {
}
binding.checkPreRelease.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, false)
"v${BuildConfig.VERSION_NAME} (${V2RayNativeManager.getLibVersion()})".also {
"v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})".also {
binding.tvVersion.text = it
}
@@ -181,6 +181,10 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerGroupActivity::class.java))
}
EConfigType.PROXYCHAIN -> {
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerProxyChainActivity::class.java))
}
else -> {
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerActivity::class.java))
}
@@ -265,7 +269,9 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
}
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
val isCustom = profile.configType == EConfigType.CUSTOM
|| profile.configType == EConfigType.POLICYGROUP
|| profile.configType == EConfigType.PROXYCHAIN
val (shareOptions, skip) = if (more) {
val options = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
@@ -32,7 +32,7 @@ import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.SubscriptionUpdater
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.MainViewModel
@@ -137,7 +137,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
applyRunningState(isLoading = true, isRunning = false)
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this)
CoreServiceManager.stopVService(this)
} else if (SettingsManager.isVpnMode()) {
val intent = VpnService.prepare(this)
if (intent == null) {
@@ -164,12 +164,12 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
toast(R.string.title_file_chooser)
return
}
V2RayServiceManager.startVService(this)
CoreServiceManager.startVService(this)
}
fun restartV2Ray() {
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this)
CoreServiceManager.stopVService(this)
}
lifecycleScope.launch {
delay(500)
@@ -254,6 +254,11 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
true
}
R.id.import_manually_proxy_chain -> {
importManually(EConfigType.PROXYCHAIN.value)
true
}
R.id.import_manually_vmess -> {
importManually(EConfigType.VMESS.value)
true
@@ -356,6 +361,12 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
.putExtra("subscriptionId", mainViewModel.subscriptionId)
.setClass(this, ServerGroupActivity::class.java)
)
} else if (createConfigType == EConfigType.PROXYCHAIN.value) {
startActivity(
Intent()
.putExtra("subscriptionId", mainViewModel.subscriptionId)
.setClass(this, ServerProxyChainActivity::class.java)
)
} else {
startActivity(
Intent()
@@ -2,7 +2,7 @@ package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
class ScStartActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -11,8 +11,8 @@ class ScStartActivity : BaseActivity() {
setContentView(R.layout.activity_none)
if (!V2RayServiceManager.isRunning()) {
V2RayServiceManager.startVServiceFromToggle(this)
if (!CoreServiceManager.isRunning()) {
CoreServiceManager.startVServiceFromToggle(this)
}
finish()
}
@@ -2,7 +2,7 @@ package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
class ScStopActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -11,8 +11,8 @@ class ScStopActivity : BaseActivity() {
setContentView(R.layout.activity_none)
if (V2RayServiceManager.isRunning()) {
V2RayServiceManager.stopVService(this)
if (CoreServiceManager.isRunning()) {
CoreServiceManager.stopVService(this)
}
finish()
}
@@ -2,7 +2,7 @@ package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.core.CoreServiceManager
class ScSwitchActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -11,10 +11,10 @@ class ScSwitchActivity : BaseActivity() {
setContentView(R.layout.activity_none)
if (V2RayServiceManager.isRunning()) {
V2RayServiceManager.stopVService(this)
if (CoreServiceManager.isRunning()) {
CoreServiceManager.stopVService(this)
} else {
V2RayServiceManager.startVServiceFromToggle(this)
CoreServiceManager.startVServiceFromToggle(this)
}
finish()
}
@@ -148,14 +148,12 @@ class ServerActivity : BaseActivity() {
val layoutId = when (config?.configType ?: createConfigType) {
EConfigType.VMESS -> R.layout.activity_server_vmess
EConfigType.CUSTOM -> null
EConfigType.SHADOWSOCKS -> R.layout.activity_server_shadowsocks
EConfigType.SOCKS, EConfigType.HTTP -> R.layout.activity_server_socks
EConfigType.VLESS -> R.layout.activity_server_vless
EConfigType.TROJAN -> R.layout.activity_server_trojan
EConfigType.WIREGUARD -> R.layout.activity_server_wireguard
EConfigType.HYSTERIA2 -> R.layout.activity_server_hysteria2
EConfigType.POLICYGROUP -> null
else -> null
} ?: return
setContentViewWithToolbar(layoutId, showHomeAsUp = true, title = (config?.configType ?: createConfigType).toString())
@@ -0,0 +1,228 @@
package com.v2ray.ang.ui
import android.os.Bundle
import android.text.TextUtils
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ActivityServerProxyChainBinding
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.Utils
class ServerProxyChainActivity : BaseActivity() {
private val binding by lazy { ActivityServerProxyChainBinding.inflate(layoutInflater) }
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
private val isRunning by lazy {
intent.getBooleanExtra("isRunning", false)
&& editGuid.isNotEmpty()
&& editGuid == MmkvManager.getSelectServer()
}
private val subscriptionId by lazy {
intent.getStringExtra("subscriptionId")
}
private lateinit var memberAdapter: ServerProxyChainMemberAdapter
private var allRemarks: List<String> = emptyList()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = EConfigType.PROXYCHAIN.toString())
loadAvailableRemarks()
setupRecycler()
binding.fabAddProxyChainMember.setOnClickListener {
addMemberRow()
}
val config = MmkvManager.decodeServerConfig(editGuid)
if (config != null) {
bindingServer(config)
} else {
clearServer()
}
}
private fun loadAvailableRemarks() {
allRemarks = MmkvManager.decodeAllServerList()
.asSequence()
.mapNotNull { guid -> MmkvManager.decodeServerConfig(guid) }
.filter { profile ->
profile.configType != EConfigType.CUSTOM
&& profile.configType != EConfigType.POLICYGROUP
&& profile.configType != EConfigType.PROXYCHAIN
}
.map { it.remarks.trim() }
.filter { it.isNotEmpty() }
.distinct()
.toList()
}
private fun setupRecycler() {
memberAdapter = ServerProxyChainMemberAdapter(
members = mutableListOf(),
suggestions = allRemarks,
adapterListener = ActivityAdapterListener()
)
binding.recyclerProxyChainMembers.layoutManager = LinearLayoutManager(this)
binding.recyclerProxyChainMembers.adapter = memberAdapter
ItemTouchHelper(SimpleItemTouchHelperCallback(memberAdapter)).attachToRecyclerView(binding.recyclerProxyChainMembers)
}
private fun bindingServer(config: ProfileItem): Boolean {
binding.etRemarks.text = Utils.getEditable(config.remarks)
val rows = parseChainMembers(config.proxyChainProfiles)
memberAdapter.replaceAll(rows)
if (rows.isEmpty()) {
memberAdapter.addRow()
}
return true
}
private fun clearServer(): Boolean {
binding.etRemarks.text = null
memberAdapter.replaceAll(listOf("", ""))
return true
}
private fun saveServer(): Boolean {
if (TextUtils.isEmpty(binding.etRemarks.text.toString())) {
toast(R.string.server_lab_remarks)
return false
}
val chainMembers = memberAdapter.getMembers().map { it.trim() }.filter { it.isNotEmpty() }
if (chainMembers.size != memberAdapter.getMembers().size) {
toast(R.string.server_proxy_chain_members_unselected)
return false
}
if (chainMembers.size < 2) {
toast(R.string.server_proxy_chain_members_insufficient)
return false
}
val invalidMembers = chainMembers.filter { member ->
val profile = SettingsManager.getServerViaRemarks(member)
profile == null
|| profile.configType == EConfigType.CUSTOM
|| profile.configType == EConfigType.POLICYGROUP
|| profile.configType == EConfigType.PROXYCHAIN
}
if (invalidMembers.isNotEmpty()) {
toast(getString(R.string.server_proxy_chain_members_invalid, invalidMembers.joinToString(", ")))
return false
}
val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.PROXYCHAIN)
config.remarks = binding.etRemarks.text.toString().trim()
config.proxyChainProfiles = chainMembers.joinToString(",")
config.description = chainMembers.joinToString(" -> ")
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
config.subscriptionId = subscriptionId.orEmpty()
}
MmkvManager.encodeServerConfig(editGuid, config)
toastSuccess(R.string.toast_success)
finish()
return true
}
private fun deleteServer(): Boolean {
if (editGuid.isNotEmpty()) {
if (editGuid != MmkvManager.getSelectServer()) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
MmkvManager.removeServer(editGuid)
finish()
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
// do nothing
}
.show()
} else {
MmkvManager.removeServer(editGuid)
finish()
}
} else {
toast(R.string.toast_action_not_allowed)
}
}
return true
}
private fun addMemberRow() {
if (allRemarks.isEmpty()) {
toast(R.string.toast_none_data)
return
}
memberAdapter.addRow()
}
private fun parseChainMembers(raw: String?): List<String> {
return raw.orEmpty()
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
}
private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onEdit(guid: String, position: Int) {
// Row selection is handled directly by AutoCompleteTextView in the adapter.
}
override fun onRemove(guid: String, position: Int) {
memberAdapter.removeRow(position)
}
override fun onShare(url: String) {
}
override fun onRefreshData() {
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_server, menu)
val delButton = menu.findItem(R.id.del_config)
val saveButton = menu.findItem(R.id.save_config)
if (editGuid.isNotEmpty()) {
if (isRunning) {
delButton?.isVisible = false
saveButton?.isVisible = false
}
} else {
delButton?.isVisible = false
}
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.del_config -> {
deleteServer()
true
}
R.id.save_config -> {
saveServer()
true
}
else -> super.onOptionsItemSelected(item)
}
}
@@ -0,0 +1,136 @@
package com.v2ray.ang.ui
import android.graphics.Color
import android.widget.ArrayAdapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerProxyChainMemberBinding
import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import java.util.Collections
class ServerProxyChainMemberAdapter(
private val members: MutableList<String>,
private val suggestions: List<String>,
private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<ServerProxyChainMemberAdapter.MemberViewHolder>(), ItemTouchHelperAdapter {
override fun getItemCount(): Int = members.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MemberViewHolder {
return MemberViewHolder(
ItemRecyclerProxyChainMemberBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: MemberViewHolder, position: Int) {
val adapterPos = holder.bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: position
val value = members[position]
holder.binding.tvMemberIndex.text = (position + 1).toString()
val dropdownAdapter = ArrayAdapter(
holder.itemView.context,
android.R.layout.simple_dropdown_item_1line,
suggestions
)
holder.binding.spMemberRemark.setAdapter(dropdownAdapter)
holder.binding.spMemberRemark.threshold = 0
holder.binding.spMemberRemark.setText(value, false)
holder.binding.spMemberRemark.setOnItemClickListener { _, _, selectedIndex, _ ->
if (adapterPos in members.indices) {
members[adapterPos] = suggestions[selectedIndex].trim()
adapterListener?.onRefreshData()
}
}
holder.binding.spMemberRemark.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
if (hasFocus) return@OnFocusChangeListener
val text = holder.binding.spMemberRemark.text?.toString().orEmpty().trim()
if (adapterPos in members.indices && members[adapterPos] != text) {
members[adapterPos] = text
adapterListener?.onRefreshData()
}
}
holder.binding.spMemberRemark.setOnClickListener { holder.binding.spMemberRemark.showDropDown() }
holder.binding.btnMemberDropdown.setOnClickListener {
holder.binding.spMemberRemark.requestFocus()
holder.binding.spMemberRemark.showDropDown()
}
holder.itemView.setOnClickListener { holder.binding.spMemberRemark.showDropDown() }
holder.binding.layoutRemove.setOnClickListener {
val removePos = holder.bindingAdapterPosition
if (removePos != RecyclerView.NO_POSITION) {
adapterListener?.onRemove("", removePos)
}
}
}
fun addRow() {
members.add("")
notifyItemInserted(members.lastIndex)
adapterListener?.onRefreshData()
}
fun removeRow(position: Int) {
if (position < 0 || position >= members.size) return
members.removeAt(position)
notifyItemRemoved(position)
notifyItemRangeChanged(position, members.size - position)
adapterListener?.onRefreshData()
}
fun setRemark(position: Int, remark: String) {
if (position < 0 || position >= members.size) return
members[position] = remark
notifyItemChanged(position)
adapterListener?.onRefreshData()
}
fun replaceAll(newMembers: List<String>) {
members.clear()
members.addAll(newMembers)
notifyDataSetChanged()
adapterListener?.onRefreshData()
}
fun getMembers(): List<String> = members.toList()
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
if (fromPosition == toPosition) return true
Collections.swap(members, fromPosition, toPosition)
notifyItemMoved(fromPosition, toPosition)
notifyItemChanged(fromPosition)
notifyItemChanged(toPosition)
return true
}
override fun onItemMoveCompleted() {
adapterListener?.onRefreshData()
}
override fun onItemDismiss(position: Int) {
// Swipe-to-dismiss disabled for this adapter.
}
class MemberViewHolder(val binding: ItemRecyclerProxyChainMemberBinding) :
BaseViewHolder(binding.root), ItemTouchHelperViewHolder
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun onItemSelected() {
itemView.setBackgroundColor(Color.LTGRAY)
}
fun onItemClear() {
itemView.setBackgroundColor(Color.TRANSPARENT)
}
}
}
@@ -3,9 +3,11 @@ package com.v2ray.ang.util
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.service.V2RayTestService
import com.v2ray.ang.service.CoreTestService
import java.io.Serializable
object MessageUtil {
@@ -42,9 +44,26 @@ object MessageUtil {
fun sendMsg2TestService(ctx: Context, message: TestServiceMessage) {
try {
val intent = Intent()
intent.component = ComponentName(ctx, V2RayTestService::class.java)
intent.component = ComponentName(ctx, CoreTestService::class.java)
intent.putExtra("content", message)
ctx.startService(intent)
when (message.key) {
AppConfig.MSG_MEASURE_CONFIG_START -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ContextCompat.startForegroundService(ctx, intent)
} else {
ctx.startService(intent)
}
}
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> {
// Do not wake up service just to cancel; stop only if it is already running.
ctx.stopService(intent)
}
else -> {
ctx.startService(intent)
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to send message to test service", e)
}
@@ -0,0 +1,162 @@
package com.v2ray.ang.util
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import com.v2ray.ang.R
import com.v2ray.ang.enums.NotificationChannelType
/**
* Unified notification helper for different notification channels.
* Supports both regular notifications and foreground service notifications.
*
* Performance: NotificationManager is cached. Builder is created once per update.
* Safe for high-frequency updates (100+ times/second).
*/
object NotificationHelper {
// Cached instances for performance
private var cachedNotificationManager: NotificationManager? = null
private val builderCache = mutableMapOf<Int, NotificationCompat.Builder>()
/**
* Notify with a regular notification (non-foreground).
*
* @param channelType The notification channel type (defines channelId, notificationId, etc.)
* @param context The context for building the notification
* @param title The notification title
* @param content The notification content text
*/
fun notify(
channelType: NotificationChannelType,
context: Context,
title: String,
content: String
) {
ensureChannelCreated(channelType, context)
val notificationManager = getNotificationManager(context)
val builder = buildNotificationBuilder(channelType, context, title, content)
notificationManager.notify(channelType.notificationId, builder.build())
}
/**
* Update an existing notification's content.
* Optimized for high-frequency updates (100+/sec).
* Reuses cached Builder to minimize allocation overhead.
*
* @param channelType The notification channel type
* @param context The context
* @param content The new content text
*/
fun updateNotification(
channelType: NotificationChannelType,
context: Context,
content: String
) {
val notificationManager = getNotificationManager(context)
// Get or create builder from cache
val builder = builderCache.getOrPut(channelType.notificationId) {
buildNotificationBuilder(channelType, context, "", content)
}
// Update only the content text (fast operation)
builder.setContentText(content)
notificationManager.notify(channelType.notificationId, builder.build())
}
/**
* Start a foreground service with a notification.
*
* @param service The service to set as foreground
* @param channelType The notification channel type
* @param title The notification title
* @param content The notification content text
*/
fun startForeground(
service: Service,
channelType: NotificationChannelType,
title: String,
content: String
) {
ensureChannelCreated(channelType, service)
val builder = buildNotificationBuilder(channelType, service, title, content)
service.startForeground(channelType.notificationId, builder.build())
}
/**
* Stop the foreground notification for a service.
*
* @param service The service to stop foreground on
*/
fun stopForeground(service: Service) {
service.stopForeground(Service.STOP_FOREGROUND_REMOVE)
}
/**
* Cancel a notification and clean up cached builder.
*
* @param channelType The notification channel type
* @param context The context
*/
fun cancel(
channelType: NotificationChannelType,
context: Context
) {
getNotificationManager(context).cancel(channelType.notificationId)
builderCache.remove(channelType.notificationId) // Clean up cache
}
// ====== Private helper methods ======
private fun getNotificationManager(context: Context): NotificationManager {
if (cachedNotificationManager == null) {
cachedNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
return cachedNotificationManager!!
}
private fun ensureChannelCreated(channelType: NotificationChannelType, context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (notificationManager.getNotificationChannel(channelType.channelId) != null) return
val channel = NotificationChannel(
channelType.channelId,
channelType.channelName,
NotificationManager.IMPORTANCE_LOW
).apply {
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
}
notificationManager.createNotificationChannel(channel)
}
private fun buildNotificationBuilder(
channelType: NotificationChannelType,
context: Context,
title: String,
content: String
): NotificationCompat.Builder {
val channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
channelType.channelId
} else {
""
}
val displayTitle = title.ifEmpty { context.getString(R.string.app_name) }
return NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_stat_name)
.setContentTitle(displayTitle)
.setContentText(content)
.setOngoing(false)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
}
}
@@ -222,7 +222,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
MessageUtil.sendMsg2TestService(
getApplication(),
TestServiceMessage(
key = AppConfig.MSG_MEASURE_CONFIG,
key = AppConfig.MSG_MEASURE_CONFIG_START,
subscriptionId = subscriptionId,
serverGuids = if (keywordFilter.isNotEmpty()) serversCache.map { it.guid } else emptyList()
)
@@ -470,9 +470,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> {
val resultPair = intent.serializable<Pair<String, Long>>("content") ?: return
MmkvManager.encodeServerTestDelayMillis(resultPair.first, resultPair.second)
updateListAction.value = getPosition(resultPair.first)
val content = intent.getStringExtra("content")
updateListAction.value = getPosition(content?: "")
}
AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> {
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".ui.ServerProxyChainActivity">
<androidx.core.widget.NestedScrollView
android:id="@+id/main_content"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_remarks"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
<EditText
android:id="@+id/et_remarks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:hint="@string/server_lab_remarks"
android:inputType="text" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_proxy_chain_members"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_proxy_chain_members"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
tools:listitem="@layout/item_recycler_proxy_chain_member" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_add_proxy_chain_member"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_margin="@dimen/padding_spacing_dp16"
android:contentDescription="@string/menu_item_add_config"
android:src="@drawable/ic_add_24dp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</RelativeLayout>
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/item_bg"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical">
<LinearLayout
android:id="@+id/info_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp8">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp8">
<TextView
android:id="@+id/tv_member_index"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/padding_spacing_dp8"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
<AutoCompleteTextView
android:id="@+id/sp_member_remark"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:hint="@string/server_proxy_chain_member_unselected"
android:imeOptions="actionDone"
android:inputType="text"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
tools:ignore="NestedWeights" />
<ImageView
android:id="@+id/btn_member_dropdown"
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:layout_marginStart="@dimen/padding_spacing_dp8"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/server_proxy_chain_pick_members"
android:focusable="true"
app:srcCompat="@drawable/ic_arrow_drop_down" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_remove"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/menu_item_del_config"
android:focusable="true"
android:gravity="center"
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp8">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_delete_24dp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
@@ -28,6 +28,10 @@
android:id="@+id/import_manually_policy_group"
android:title="@string/menu_item_import_config_policy_group"
app:showAsAction="never" />
<item
android:id="@+id/import_manually_proxy_chain"
android:title="@string/menu_item_import_config_proxy_chain"
app:showAsAction="never" />
<item
android:id="@+id/import_manually_vmess"
android:title="@string/menu_item_import_config_manually_vmess"
@@ -30,6 +30,7 @@
<string name="menu_item_import_config_clipboard">استيراد التكوين من الحافظة</string>
<string name="menu_item_import_config_local">Import config from locally</string>
<string name="menu_item_import_config_policy_group">Add [Policy group]</string>
<string name="menu_item_import_config_proxy_chain">Add [Proxy chain]</string>
<string name="menu_item_import_config_manually_vmess">الكتابة يدويًا [VMess]</string>
<string name="menu_item_import_config_manually_vless">الكتابة يدويًا [VLESS]</string>
<string name="menu_item_import_config_manually_ss">الكتابة يدويًا [Shadowsocks]</string>
@@ -210,6 +211,7 @@
<string name="title_pref_delay_test_url">True delay test url </string>
<string name="summary_pref_delay_test_url">Url</string>
<string name="title_pref_real_ping_concurrency">True delay test concurrency</string>
<string name="title_pref_ip_api_url">Current connection info test url</string>
<string name="summary_pref_ip_api_url">Url</string>
@@ -376,6 +378,12 @@
<string name="title_policy_group_type">Policy group type</string>
<string name="title_policy_group_subscription_id">From subscription group</string>
<string name="title_policy_group_subscription_filter">Remarks regular filter</string>
<string name="server_proxy_chain_members">اعضاء سلسلة الوكيل</string>
<string name="server_proxy_chain_pick_members">اضغط هنا لاختيار عضو</string>
<string name="server_proxy_chain_member_unselected">اختر عضوا</string>
<string name="server_proxy_chain_members_unselected">يرجى اختيار remark لكل صف في السلسلة</string>
<string name="server_proxy_chain_members_insufficient">عدد الاعضاء غير كاف</string>
<string name="server_proxy_chain_members_invalid">اعضاء سلسلة غير صالحين: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Backup &amp; Restore</string>
@@ -30,6 +30,7 @@
<string name="menu_item_import_config_clipboard">ক্লিপবোর্ড থেকে কনফিগারেশন আমদানি করুন</string>
<string name="menu_item_import_config_local">Import config from locally</string>
<string name="menu_item_import_config_policy_group">Add [Policy group]</string>
<string name="menu_item_import_config_proxy_chain">Add [Proxy chain]</string>
<string name="menu_item_import_config_manually_vmess">ম্যানুয়ালি টাইপ করুন [VMess]</string>
<string name="menu_item_import_config_manually_vless">ম্যানুয়ালি টাইপ করুন [VLESS]</string>
<string name="menu_item_import_config_manually_ss">ম্যানুয়ালি টাইপ করুন [Shadowsocks]</string>
@@ -210,6 +211,7 @@
<string name="title_pref_delay_test_url">সঠিক বিলম্ব পরীক্ষা ইউআরএল </string>
<string name="summary_pref_delay_test_url">ইউআরএল</string>
<string name="title_pref_real_ping_concurrency">True delay test concurrency</string>
<string name="title_pref_ip_api_url">Current connection info test url</string>
<string name="summary_pref_ip_api_url">Url</string>
@@ -375,6 +377,12 @@
<string name="title_policy_group_type">Policy group type</string>
<string name="title_policy_group_subscription_id">From subscription group</string>
<string name="title_policy_group_subscription_filter">Remarks regular filter</string>
<string name="server_proxy_chain_members">Proxy chain members</string>
<string name="server_proxy_chain_pick_members">Tap here to pick member</string>
<string name="server_proxy_chain_member_unselected">Select a member</string>
<string name="server_proxy_chain_members_unselected">Please select member for each chain row</string>
<string name="server_proxy_chain_members_insufficient">Insufficient members</string>
<string name="server_proxy_chain_members_invalid">Invalid chain members: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Backup &amp; Restore</string>
@@ -30,6 +30,7 @@
<string name="menu_item_import_config_clipboard">و من ٱووردن کانفیگ ز کلیپ بورد</string>
<string name="menu_item_import_config_local">و من ٱووردن کانفیگ ز مهلی</string>
<string name="menu_item_import_config_policy_group">ٱووردن [بونکۊ سیاست]</string>
<string name="menu_item_import_config_proxy_chain">Add [Proxy chain]</string>
<string name="menu_item_import_config_manually_vmess">هؽل دستی[VMess]</string>
<string name="menu_item_import_config_manually_vless">هؽل دستی[VLESS]</string>
<string name="menu_item_import_config_manually_ss">هؽل دستی[Shadowsocks]</string>
@@ -210,6 +211,7 @@
<string name="title_pref_delay_test_url">نشۊوی اینترنتی آزمایش تئخیر واقعی</string>
<string name="summary_pref_delay_test_url">نشۊوی اینترنتی</string>
<string name="title_pref_real_ping_concurrency">هموورگی آزمایش تئخیر واقعی</string>
<string name="title_pref_ip_api_url">نشۊوی اینترنتی آزمایش دووسمندیا منپیز هیم سکویی</string>
<string name="summary_pref_ip_api_url">نشۊوی اینترنتی</string>
@@ -375,6 +377,12 @@
<string name="title_policy_group_type">نوع بونکۊ سیاست</string>
<string name="title_policy_group_subscription_id">ز بونکۊ اشتراک</string>
<string name="title_policy_group_subscription_filter">توزیهات فیلتر معمۊلی</string>
<string name="server_proxy_chain_members">اعضای زنجیره پروکسی</string>
<string name="server_proxy_chain_pick_members">اینجا بزنین تا عضو اِنتخاب اکه</string>
<string name="server_proxy_chain_member_unselected">یه عضو اِنتخاب کۊنین</string>
<string name="server_proxy_chain_members_unselected">لطفا سی هر ردیو زنجیره، remark اِنتخاب کۊنین</string>
<string name="server_proxy_chain_members_insufficient">تعداد عضوها کافی نیس</string>
<string name="server_proxy_chain_members_invalid">اعضای نامعتبر زنجیره: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">لادراری گرؽڌن &amp; وورگندن</string>
@@ -30,6 +30,7 @@
<string name="menu_item_import_config_clipboard">کانفیگ را از کلیپ ‌بورد وارد کنید</string>
<string name="menu_item_import_config_local">کانفیگ را از محلی وارد کنید</string>
<string name="menu_item_import_config_policy_group">اضافه کردن [گروه خط مشی]</string>
<string name="menu_item_import_config_proxy_chain">Add [Proxy chain]</string>
<string name="menu_item_import_config_manually_vmess">تایپ دستی[VMESS]</string>
<string name="menu_item_import_config_manually_vless">تایپ دستی[VLESS]</string>
<string name="menu_item_import_config_manually_ss">تایپ دستی[SHADOWSOCKS]</string>
@@ -208,6 +209,7 @@
<string name="title_pref_delay_test_url">آدرس اینترنتی آزمایش تاخیر واقعی کانفیگ ها </string>
<string name="summary_pref_delay_test_url">URL</string>
<string name="title_pref_real_ping_concurrency">تعداد همزمانی تست تأخیر واقعی</string>
<string name="title_pref_ip_api_url">تست اطلاعات اتصال فعلی url</string>
<string name="summary_pref_ip_api_url">Url</string>
@@ -372,6 +374,12 @@
<string name="title_policy_group_type">نوع گروه خط مشی</string>
<string name="title_policy_group_subscription_id">از گروه اشتراک</string>
<string name="title_policy_group_subscription_filter">توضیحات فیلتر معمولی</string>
<string name="server_proxy_chain_members">اعضای زنجیره پروکسی</string>
<string name="server_proxy_chain_pick_members">برای انتخاب عضو اینجا بزنید</string>
<string name="server_proxy_chain_member_unselected">یک عضو را انتخاب کنید</string>
<string name="server_proxy_chain_members_unselected">لطفا برای هر ردیف زنجیره remark انتخاب کنید</string>
<string name="server_proxy_chain_members_insufficient">تعداد اعضا کافی نیست</string>
<string name="server_proxy_chain_members_invalid">اعضای نامعتبر زنجیره: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">پشتیبان‌گیری و بازیابی</string>
@@ -30,6 +30,7 @@
<string name="menu_item_import_config_clipboard">Импорт из буфера обмена</string>
<string name="menu_item_import_config_local">Импорт из файла</string>
<string name="menu_item_import_config_policy_group">Добавить группу политик</string>
<string name="menu_item_import_config_proxy_chain">Add [Proxy chain]</string>
<string name="menu_item_import_config_manually_vmess">Ручной ввод VMess</string>
<string name="menu_item_import_config_manually_vless">Ручной ввод VLESS</string>
<string name="menu_item_import_config_manually_ss">Ручной ввод Shadowsocks</string>
@@ -212,6 +213,7 @@
<string name="title_pref_delay_test_url">Сервис проверки задержки</string>
<string name="summary_pref_delay_test_url">URL</string>
<string name="title_pref_real_ping_concurrency">Параллельность теста задержки</string>
<string name="title_pref_ip_api_url">Сервис проверки текущего соединения</string>
<string name="summary_pref_ip_api_url">URL</string>
@@ -378,6 +380,12 @@
<string name="title_policy_group_type">Тип группы политик</string>
<string name="title_policy_group_subscription_id">Из группы подписки</string>
<string name="title_policy_group_subscription_filter">Название фильтра</string>
<string name="server_proxy_chain_members">Участники цепочки прокси</string>
<string name="server_proxy_chain_pick_members">Нажмите здесь, чтобы выбрать участника</string>
<string name="server_proxy_chain_member_unselected">Выберите участника</string>
<string name="server_proxy_chain_members_unselected">Пожалуйста, выберите участника для каждой строки цепочки</string>
<string name="server_proxy_chain_members_insufficient">Недостаточно участников</string>
<string name="server_proxy_chain_members_invalid">Недопустимые участники цепочки: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Резервное копирование</string>
@@ -30,6 +30,7 @@
<string name="menu_item_import_config_clipboard">Nhập cấu hình từ Clipboard</string>
<string name="menu_item_import_config_local">Import config from locally</string>
<string name="menu_item_import_config_policy_group">Add [Policy group]</string>
<string name="menu_item_import_config_proxy_chain">Add [Proxy chain]</string>
<string name="menu_item_import_config_manually_vmess">Nhập thủ công [VMess]</string>
<string name="menu_item_import_config_manually_vless">Nhập thủ công [VLESS]</string>
<string name="menu_item_import_config_manually_ss">Nhập thủ công [ShadowSocks]</string>
@@ -210,6 +211,7 @@
<string name="title_pref_delay_test_url">URL kiểm tra độ trễ thực </string>
<string name="summary_pref_delay_test_url">URL</string>
<string name="title_pref_real_ping_concurrency">Số luồng kiểm tra độ trễ thực</string>
<string name="title_pref_ip_api_url">Current connection info test url</string>
<string name="summary_pref_ip_api_url">Url</string>
@@ -377,6 +379,12 @@
<string name="title_policy_group_type">Policy group type</string>
<string name="title_policy_group_subscription_id">From subscription group</string>
<string name="title_policy_group_subscription_filter">Remarks regular filter</string>
<string name="server_proxy_chain_members">Proxy chain members</string>
<string name="server_proxy_chain_pick_members">Tap here to pick member</string>
<string name="server_proxy_chain_member_unselected">Select a member</string>
<string name="server_proxy_chain_members_unselected">Please select member for each chain row</string>
<string name="server_proxy_chain_members_insufficient">Insufficient members</string>
<string name="server_proxy_chain_members_invalid">Invalid chain members: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Backup &amp; Restore</string>
@@ -30,6 +30,7 @@
<string name="menu_item_import_config_clipboard">从剪贴板导入</string>
<string name="menu_item_import_config_local">从本地导入</string>
<string name="menu_item_import_config_policy_group">添加 [策略组]</string>
<string name="menu_item_import_config_proxy_chain">添加 [链式代理]</string>
<string name="menu_item_import_config_manually_vmess">添加 [VMess]</string>
<string name="menu_item_import_config_manually_vless">添加 [VLESS]</string>
<string name="menu_item_import_config_manually_ss">添加 [Shadowsocks]</string>
@@ -154,9 +155,9 @@
<string name="summary_pref_is_booted">开机时自动连接选择的服务器,可能会不成功</string>
<string name="title_pref_auto_remove_invalid_after_test">测试后自动删除无效配置</string>
<string name="summary_pref_auto_remove_invalid_after_test">测试结果可能不准确已删除的配置无法恢复</string>
<string name="summary_pref_auto_remove_invalid_after_test">测试结果可能不准确已删除的配置无法恢复</string>
<string name="title_pref_auto_sort_after_test">测试后自动排序</string>
<string name="summary_pref_auto_sort_after_test">测试结果可能不准确</string>
<string name="summary_pref_auto_sort_after_test">测试结果可能不准确</string>
<string name="title_mux_settings">Mux 多路复用 设置</string>
<string name="title_pref_mux_enabled">启用 Mux 多路复用</string>
@@ -207,6 +208,7 @@
<string name="title_pref_delay_test_url">真连接延迟测试网址 </string>
<string name="summary_pref_delay_test_url">Url</string>
<string name="title_pref_real_ping_concurrency">真连接延迟测试并发数量</string>
<string name="title_pref_ip_api_url">当前连接信息测试网址</string>
<string name="summary_pref_ip_api_url">URL</string>
@@ -273,7 +275,7 @@
<string name="title_mode_help">点此查看更多帮助</string>
<string name="title_language">语言</string>
<string name="title_ui_settings">用户界面设置</string>
<string name="title_pref_ui_mode_night">界面颜色设置</string>
<string name="title_pref_ui_mode_night">深色模式</string>
<string name="title_pref_use_hev_tunnel">启用 Hev TUN 功能</string>
<string name="summary_pref_use_hev_tunnel">选择启用后 TUN 将使用 hev-socks5-tunnel 否则使用 xray-core</string>
<string name="title_pref_hev_tunnel_loglevel">HevTun 日志级别</string>
@@ -376,6 +378,12 @@
<string name="title_policy_group_type">策略组类型</string>
<string name="title_policy_group_subscription_id">来自订阅分组</string>
<string name="title_policy_group_subscription_filter">别名正则过滤</string>
<string name="server_proxy_chain_members">代理链成员</string>
<string name="server_proxy_chain_pick_members">点此选择成员</string>
<string name="server_proxy_chain_member_unselected">请选择成员</string>
<string name="server_proxy_chain_members_unselected">请为链路中的每一行选择成员</string>
<string name="server_proxy_chain_members_insufficient">成员数量不足</string>
<string name="server_proxy_chain_members_invalid">无效的链路成员: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">备份 &amp; 还原</string>
@@ -30,6 +30,7 @@
<string name="menu_item_import_config_clipboard">從剪貼簿匯入設定</string>
<string name="menu_item_import_config_local">從本地匯入</string>
<string name="menu_item_import_config_policy_group">新增 [策略組]</string>
<string name="menu_item_import_config_proxy_chain">新增 [鍊式代理]</string>
<string name="menu_item_import_config_manually_vmess">新增 [VMess]</string>
<string name="menu_item_import_config_manually_vless">新增 [VLESS]</string>
<string name="menu_item_import_config_manually_ss">新增 [Shadowsocks]</string>
@@ -155,9 +156,9 @@
<string name="summary_pref_is_booted">開機時自動連線選擇的伺服器,可能會不成功</string>
<string name="title_pref_auto_remove_invalid_after_test">測試後自動刪除無效配置</string>
<string name="summary_pref_auto_remove_invalid_after_test">測試結果可能不準確已刪除的配置無法復原</string>
<string name="summary_pref_auto_remove_invalid_after_test">測試結果可能不準確已刪除的配置無法復原</string>
<string name="title_pref_auto_sort_after_test">測試後自動排序</string>
<string name="summary_pref_auto_sort_after_test">測試結果可能不準確</string>
<string name="summary_pref_auto_sort_after_test">測試結果可能不準確</string>
<string name="title_mux_settings">Mux 設定</string>
<string name="title_pref_mux_enabled">啟用 Mux 多路復用</string>
@@ -208,6 +209,7 @@
<string name="title_pref_delay_test_url">真連線延遲測試網址 </string>
<string name="summary_pref_delay_test_url">Url</string>
<string name="title_pref_real_ping_concurrency">真連線延遲測試並發數量</string>
<string name="title_pref_ip_api_url">目前連線資訊測試網址</string>
<string name="summary_pref_ip_api_url">URL</string>
@@ -273,8 +275,8 @@
<string name="title_mode">模式</string>
<string name="title_mode_help">輕觸以檢視說明</string>
<string name="title_language">語言</string>
<string name="title_ui_settings">介面顏色設定</string>
<string name="title_pref_ui_mode_night">介面顯示模式</string>
<string name="title_ui_settings">介面顯示設定</string>
<string name="title_pref_ui_mode_night">深色模式</string>
<string name="title_pref_use_hev_tunnel">啟用 Hev TUN 功能</string>
<string name="summary_pref_use_hev_tunnel">選擇啟用後 TUN 將使用 hev-socks5-tunnel 否則使用 xray-core</string>
<string name="title_pref_hev_tunnel_loglevel">HevTun 日誌級別</string>
@@ -315,8 +317,8 @@
<string name="title_update_subscription_result">更新了 %1$d 個配置(%2$d 個成功,%3$d 個失敗,%4$d 個跳過)</string>
<string name="title_update_subscription_no_subscription">無訂閱</string>
<string name="toast_server_not_found_in_group">當前分組中未找到選中的伺服器</string>
<string name="toast_fragment_not_available">定位所選配置</string>
<string name="title_locate_selected_config">Locate the selected config</string>
<string name="toast_fragment_not_available">無法定位當前視圖</string>
<string name="title_locate_selected_config">定位所選配置</string>
<string name="tasker_start_service">啟動服務</string>
<string name="tasker_setting_confirm">確定</string>
@@ -376,9 +378,15 @@
<string name="title_policy_group_type">策略群組類型</string>
<string name="title_policy_group_subscription_id">來自訂閱分組</string>
<string name="title_policy_group_subscription_filter">別名正規過濾</string>
<string name="server_proxy_chain_members">代理鏈成員</string>
<string name="server_proxy_chain_pick_members">點此選擇成員</string>
<string name="server_proxy_chain_member_unselected">請選擇成員</string>
<string name="server_proxy_chain_members_unselected">請為鏈路中的每一列選擇成員</string>
<string name="server_proxy_chain_members_insufficient">成員數量不足</string>
<string name="server_proxy_chain_members_invalid">無效的鏈路成員: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Backup &amp; Restore</string>
<string name="title_configuration_backup_restore">備份與恢復</string>
<string name="title_configuration_backup">備份設定</string>
<string name="title_configuration_restore">還原設定</string>
<string name="title_configuration_share">分享設定</string>
@@ -410,7 +418,7 @@
<string-array name="mode_entries">
<item>VPN</item>
<item> Proxy</item>
<item>代理</item>
</string-array>
<string-array name="ui_mode_night">
@@ -31,6 +31,7 @@
<string name="menu_item_import_config_clipboard">Import from Clipboard</string>
<string name="menu_item_import_config_local">Import from locally</string>
<string name="menu_item_import_config_policy_group">Add [Policy group]</string>
<string name="menu_item_import_config_proxy_chain">Add [Proxy chain]</string>
<string name="menu_item_import_config_manually_vmess">Add [VMess]</string>
<string name="menu_item_import_config_manually_vless">Add [VLESS]</string>
<string name="menu_item_import_config_manually_ss">Add [Shadowsocks]</string>
@@ -214,6 +215,7 @@
<string name="title_pref_delay_test_url">True delay test url </string>
<string name="summary_pref_delay_test_url">Url</string>
<string name="title_pref_real_ping_concurrency">True delay test concurrency</string>
<string name="title_pref_ip_api_url">Current connection info test url</string>
<string name="summary_pref_ip_api_url">Url</string>
@@ -382,6 +384,13 @@
<string name="title_policy_group_subscription_id">From subscription group</string>
<string name="title_policy_group_subscription_filter">Remarks regular filter</string>
<string name="server_proxy_chain_members">Proxy chain members</string>
<string name="server_proxy_chain_pick_members">Tap here to pick member</string>
<string name="server_proxy_chain_member_unselected">Select a member</string>
<string name="server_proxy_chain_members_unselected">Please select member for each chain row</string>
<string name="server_proxy_chain_members_insufficient">Insufficient members</string>
<string name="server_proxy_chain_members_invalid">Invalid chain members: %1$s</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Backup &amp; Restore</string>
<string name="title_configuration_backup">Backup config</string>
@@ -303,6 +303,12 @@
android:summary="@string/summary_pref_delay_test_url"
android:title="@string/title_pref_delay_test_url" />
<EditTextPreference
android:inputType="number"
android:key="pref_real_ping_concurrency"
android:summary="16"
android:title="@string/title_pref_real_ping_concurrency" />
<EditTextPreference
android:key="pref_ip_api_url"
android:summary="@string/summary_pref_ip_api_url"