Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c86e7c26e | |||
| 5d0897cd94 | |||
| fc4ab733c8 | |||
| d606453b83 | |||
| c6c8eccd51 | |||
| 03074fba61 | |||
| ac02d23083 | |||
| 5c6aaf285b | |||
| d3f9809486 | |||
| a12909e33d | |||
| 3e823a7440 | |||
| dc1d3a27dd | |||
| d0f1e21f49 | |||
| 3b8615fad0 | |||
| d9d00f4137 | |||
| 2aefbd43eb |
+1
-1
Submodule AndroidLibXrayLite updated: d8bfc35e93...1b0ec8e111
@@ -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(';')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+3
-3
@@ -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()
|
||||
}
|
||||
}
|
||||
+14
-9
@@ -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 ->
|
||||
|
||||
+5
-5
@@ -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()
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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 & 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 & 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">لادراری گرؽڌن & وورگندن</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 & 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">备份 & 还原</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 & 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 & 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"
|
||||
|
||||
Reference in New Issue
Block a user