Compare commits

...

8 Commits

Author SHA1 Message Date
2dust d64fc627bb up 2.2.0 2026-05-23 15:36:30 +08:00
2dust 6934ab3e5d Bump SDK to 37
Update compileSdk and targetSdk to 37. Adjust AndroidManifest: remove legacy storage read permissions, add tools:ignore for FOREGROUND_SERVICE policy and keep FOREGROUND_SERVICE_SPECIAL_USE with minSdk 34. Wrap ContextCompat.startForegroundService with SecurityException handling and special-case ForegroundServiceStartNotAllowedException (logged and rethrown as IllegalStateException) to surface permission/OS restrictions. Remove the READ_STORAGE PermissionType and related runtime permission request in HelperBaseActivity (file chooser now launches directly). These changes align permissions and service startup behavior with newer Android SDK requirements.
2026-05-23 14:56:45 +08:00
2dust 028d13aef8 Add SettingsManager.getProfileRemarks and use it 2026-05-23 11:09:46 +08:00
2dust 4a78c165bc Refactor core config, routing and balancer logic
Rework CoreConfigContextBuilder and CoreConfigManager to centralize and simplify outbound resolution and runtime config assembly. Key changes: handle CUSTOM profiles early, introduce ResolvedOutbound and resolvedOutbounds list, resolve routing-target outbounds separately, and unify normal/group/chain build flows into buildUnifiedConfig. Routing, DNS, inbounds, outbounds and balancer logic were modularized (configureInbounds/configureRouting/configureDns/configureFakeDns/configureLocalDns/etc.), balancer strategies and observability are consolidated, and domain pre-resolution/hosts handling preserved. Also added new balancer tag naming (TAG_BALANCER_PRE) and small API/utility adjustments across CoreOutboundBuilder, DTOs and enums to support the new flow. These changes improve clarity of the build pipeline, make custom/routing outbounds handling explicit, and prepare for richer policy-group balancing behavior.
2026-05-23 10:49:20 +08:00
2dust c1033a006f Use safe JSON parsing and add fromJsonSafe 2026-05-21 14:35:34 +08:00
DHR60 e8a42013c0 Remove freedom.domainStrategy (#5656) 2026-05-20 14:10:20 +08:00
vemneyy 94e5263250 Added QUIC sniffig option in destOverride to default config (#5639) 2026-05-20 14:06:56 +08:00
2dust 7c68aa9d83 Parse SOCKS userInfo without unnecessary decoding
https://github.com/2dust/v2rayNG/issues/5657
2026-05-20 13:59:54 +08:00
29 changed files with 735 additions and 602 deletions
+4 -4
View File
@@ -6,14 +6,14 @@ plugins {
android { android {
namespace = "com.v2ray.ang" namespace = "com.v2ray.ang"
compileSdk = 36 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "com.v2ray.ang" applicationId = "com.v2ray.ang"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 37
versionCode = 728 versionCode = 730
versionName = "2.1.8" versionName = "2.2.0"
multiDexEnabled = true multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';') val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
+2 -3
View File
@@ -30,15 +30,14 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" /> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"
tools:ignore="ForegroundServicesPolicy" />
<uses-permission <uses-permission
android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
android:minSdkVersion="34" /> android:minSdkVersion="34" />
<!-- <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> --> <!-- <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application <application
@@ -30,7 +30,8 @@
"enabled": true, "enabled": true,
"destOverride": [ "destOverride": [
"http", "http",
"tls" "tls",
"quic"
] ]
} }
} }
@@ -73,8 +74,10 @@
}, },
{ {
"protocol": "freedom", "protocol": "freedom",
"settings": { "streamSettings": {
"domainStrategy": "UseIP" "sockopt": {
"domainStrategy": "UseIP"
}
}, },
"tag": "direct" "tag": "direct"
}, },
@@ -30,7 +30,8 @@
"enabled": true, "enabled": true,
"destOverride": [ "destOverride": [
"http", "http",
"tls" "tls",
"quic"
] ]
} }
}, },
@@ -46,7 +47,8 @@
"enabled": true, "enabled": true,
"destOverride": [ "destOverride": [
"http", "http",
"tls" "tls",
"quic"
] ]
} }
} }
@@ -89,8 +91,10 @@
}, },
{ {
"protocol": "freedom", "protocol": "freedom",
"settings": { "streamSettings": {
"domainStrategy": "UseIP" "sockopt": {
"domainStrategy": "UseIP"
}
}, },
"tag": "direct" "tag": "direct"
}, },
@@ -105,7 +105,8 @@ object AppConfig {
const val TAG_FRAGMENT = "fragment" const val TAG_FRAGMENT = "fragment"
const val TAG_DNS = "dns-module" const val TAG_DNS = "dns-module"
const val TAG_DOMESTIC_DNS = "domestic-dns" const val TAG_DOMESTIC_DNS = "domestic-dns"
const val TAG_BALANCER = "proxy-round" const val TAG_BALANCER = "balancer-main"
const val TAG_BALANCER_PRE = "balancer"
/** Network-related constants. */ /** Network-related constants. */
const val UPLINK = "uplink" const val UPLINK = "uplink"
@@ -13,58 +13,128 @@ import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
/** /**
* Builds [com.v2ray.ang.dto.CoreConfigContext] from the selected profile. * Build runtime context from the selected profile.
* Keeps parsing and resolution logic out of [CoreConfigManager]. *
* All outbound type analysis is completed here for both the selected profile
* and routing targets. Custom profiles are returned immediately without
* entering the normal analysis flow.
*/ */
object CoreConfigContextBuilder { object CoreConfigContextBuilder {
/** Loads profile by guid and returns a resolved runtime context. */ /**
* Load one profile and produce a fully analyzed context.
*
* Null is returned only when the selected profile cannot be loaded.
*/
fun build(context: Context, guid: String): CoreConfigContext? { fun build(context: Context, guid: String): CoreConfigContext? {
val config = MmkvManager.decodeServerConfig(guid) ?: return null val config = MmkvManager.decodeServerConfig(guid) ?: return null
// CUSTOM: return immediately — CoreConfigManager handles this path on its own.
if (config.configType == EConfigType.CUSTOM) { if (config.configType == EConfigType.CUSTOM) {
return CoreConfigContext( return CoreConfigContext(context = context, guid = guid, isCustom = true)
context = context,
guid = guid,
selectedProfile = config,
resolvedProfiles = listOf(config),
resolvedType = CoreResolvedType.CUSTOM,
)
} }
// Pre-resolve custom outbound profiles from routing rulesets // Step 1: Resolve the main outbound (always tag = TAG_PROXY).
val customOutbounds = resolveCustomOutbounds() val primaryResolvedOutbound = resolveOutbound(AppConfig.TAG_PROXY, config) ?: run {
LogUtil.e(AppConfig.TAG, "Failed to resolve main outbound for '${config.remarks}'")
return null
}
// Determine resolved profiles and type based on config type // Step 2: Resolve all non-builtin routing outbound tags.
val (resolvedProfiles, resolvedType) = when (config.configType) { val routingResolvedOutbounds = resolveRoutingOutbounds()
EConfigType.POLICYGROUP -> {
val profiles = resolvePolicyGroupProfiles(config) return CoreConfigContext(
Pair(profiles, CoreResolvedType.POLICYGROUP) context = context,
} guid = guid,
resolvedOutbounds = listOf(primaryResolvedOutbound) + routingResolvedOutbounds,
)
}
/**
* Resolve one outbound target into a normalized outbound entry.
*
* Custom profiles are ignored at this stage and produce no entry.
*/
private fun resolveOutbound(tag: String, profile: ProfileItem): CoreConfigContext.ResolvedOutbound? {
if (profile.configType == EConfigType.CUSTOM) {
return null
}
val (resolvedProfiles, resolvedType) = when (profile.configType) {
EConfigType.POLICYGROUP -> Pair(
resolvePolicyGroupProfiles(profile),
CoreResolvedType.POLICYGROUP,
)
EConfigType.PROXYCHAIN -> { EConfigType.PROXYCHAIN -> {
val profiles = resolveProxyChainProfiles(config) val chainProfiles = resolveProxyChainProfiles(profile)
Pair(profiles, CoreResolvedType.PROXYCHAIN) val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
Pair(chainProfiles, type)
} }
else -> { else -> {
val chainProfiles = resolveProxyChainProfilesFromGroup(config) val chainProfiles = resolveProxyChainProfilesFromGroup(profile)
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
Pair(chainProfiles, type) Pair(chainProfiles, type)
} }
} }
// Create context with common fields return CoreConfigContext.ResolvedOutbound(
return CoreConfigContext( tag = tag,
context = context, profile = profile,
guid = guid,
selectedProfile = config,
resolvedProfiles = resolvedProfiles, resolvedProfiles = resolvedProfiles,
resolvedType = resolvedType, resolvedType = resolvedType,
customOutboundProfiles = customOutbounds,
) )
} }
/** Resolves policy-group members with the same filters as runtime build. */ /**
* Collect and resolve non-builtin routing targets from enabled rules.
*
* Invalid or empty targets are skipped and handled by fallback logic later.
*/
private fun resolveRoutingOutbounds(): List<CoreConfigContext.ResolvedOutbound> {
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return emptyList()
val resolvedOutbounds = mutableListOf<CoreConfigContext.ResolvedOutbound>()
val processedTags = mutableSetOf<String>()
try {
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 {
LogUtil.w(AppConfig.TAG, "Routing tag '$tag' has no matching profile — will fall back to proxy at routing time")
return@forEach
}
val resolvedOutbound = resolveOutbound(tag, profile) ?: run {
LogUtil.w(AppConfig.TAG, "Cannot use CUSTOM profile as routing outbound for tag '$tag', skipping")
return@forEach
}
if (resolvedOutbound.resolvedProfiles.isEmpty()) {
LogUtil.w(AppConfig.TAG, "Routing outbound '$tag' resolved to empty list, skipping")
return@forEach
}
resolvedOutbounds.add(resolvedOutbound)
LogUtil.d(AppConfig.TAG, "Resolved routing outbound: tag='$tag', type='${resolvedOutbound.resolvedType}', profiles=${resolvedOutbound.resolvedProfiles.size}")
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve routing outbound for tag '$tag', skipping", e)
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve routing outbounds from rulesets", e)
}
return resolvedOutbounds
}
private fun resolvePolicyGroupProfiles(config: ProfileItem): List<ProfileItem> { private fun resolvePolicyGroupProfiles(config: ProfileItem): List<ProfileItem> {
try { try {
val serverList = MmkvManager.decodeAllServerList() val serverList = MmkvManager.decodeAllServerList()
@@ -98,12 +168,11 @@ object CoreConfigContextBuilder {
.filter { it.configType != EConfigType.PROXYCHAIN } .filter { it.configType != EConfigType.PROXYCHAIN }
.toList() .toList()
} catch (e: Exception) { } catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve policy group profiles for config '${config.remarks}'", e) LogUtil.e(AppConfig.TAG, "Failed to resolve policy group profiles for '${config.remarks}'", e)
return listOf(config) return listOf(config)
} }
} }
/** Resolves proxy-chain members with the same filters as runtime build. */
private fun resolveProxyChainProfiles(config: ProfileItem): List<ProfileItem> { private fun resolveProxyChainProfiles(config: ProfileItem): List<ProfileItem> {
if (config.proxyChainProfiles.isNullOrBlank()) { if (config.proxyChainProfiles.isNullOrBlank()) {
return listOf(config) return listOf(config)
@@ -121,19 +190,17 @@ object CoreConfigContextBuilder {
.toList() .toList()
.reversed() .reversed()
} catch (e: Exception) { } catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles for config '${config.remarks}'", e) LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles for '${config.remarks}'", e)
return listOf(config) return listOf(config)
} }
} }
/** /**
* Resolves chain nodes in fixed order: next -> current -> prev. * Resolve chain nodes from subscription neighbors in order: next, current, prev.
* If chain cannot be built, caller treats result as normal mode. *
* When no chain is available, return a single-node result.
*/ */
private fun resolveProxyChainProfilesFromGroup(config: ProfileItem): List<ProfileItem> { private fun resolveProxyChainProfilesFromGroup(config: ProfileItem): List<ProfileItem> {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == true) {
return listOf(config)
}
if (config.subscriptionId.isEmpty()) { if (config.subscriptionId.isEmpty()) {
return listOf(config) return listOf(config)
} }
@@ -141,56 +208,13 @@ object CoreConfigContextBuilder {
try { try {
val subItem = MmkvManager.decodeSubscription(config.subscriptionId) ?: return listOf(config) val subItem = MmkvManager.decodeSubscription(config.subscriptionId) ?: return listOf(config)
val resolved = mutableListOf<ProfileItem>() 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) } SettingsManager.getServerViaRemarks(subItem.nextProfile)?.let { resolved.add(it) }
resolved.add(config) resolved.add(config)
SettingsManager.getServerViaRemarks(subItem.prevProfile)?.let { resolved.add(it) } SettingsManager.getServerViaRemarks(subItem.prevProfile)?.let { resolved.add(it) }
return resolved return resolved
} catch (e: Exception) { } catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles from group for config '${config.remarks}'", e) LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain from group for '${config.remarks}'", e)
return listOf(config) 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
}
} }
File diff suppressed because it is too large Load Diff
@@ -3,8 +3,8 @@ package com.v2ray.ang.core
import com.google.gson.JsonArray import com.google.gson.JsonArray
import com.google.gson.JsonObject import com.google.gson.JsonObject
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
@@ -176,7 +176,20 @@ object CoreServiceManager {
Intent(context.applicationContext, CoreProxyOnlyService::class.java) Intent(context.applicationContext, CoreProxyOnlyService::class.java)
} }
ContextCompat.startForegroundService(context, intent) try {
ContextCompat.startForegroundService(context, intent)
} catch (e: SecurityException) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Missing permission to start foreground service", e)
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
} catch (e: RuntimeException) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
e.javaClass.name == "android.app.ForegroundServiceStartNotAllowedException"
) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Foreground service start not allowed", e)
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
}
throw e
}
} }
/** /**
@@ -4,14 +4,16 @@ import android.content.Context
import com.v2ray.ang.dto.entities.ProfileItem import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.CoreResolvedType import com.v2ray.ang.enums.CoreResolvedType
/**
* Runtime context produced by the builder and consumed by CoreConfigManager.
*/
data class CoreConfigContext( data class CoreConfigContext(
val context: Context, val context: Context,
val guid: String, val guid: String,
val selectedProfile: ProfileItem, val isCustom: Boolean = false,
val resolvedProfiles: List<ProfileItem>, val resolvedOutbounds: List<ResolvedOutbound> = emptyList(),
val resolvedType: CoreResolvedType, ) {
val customOutboundProfiles: Map<String, ProfileItem> = emptyMap(), data class ResolvedOutbound(
) val tag: String,
val profile: ProfileItem,
val resolvedProfiles: List<ProfileItem>,
val resolvedType: CoreResolvedType,
)
}
@@ -68,7 +68,6 @@ data class V2rayConfig(
var protocol: String, var protocol: String,
var settings: OutSettingsBean? = null, var settings: OutSettingsBean? = null,
var streamSettings: StreamSettingsBean? = null, var streamSettings: StreamSettingsBean? = null,
val proxySettings: Any? = null,
val sendThrough: String? = null, val sendThrough: String? = null,
var mux: MuxBean? = MuxBean(false) var mux: MuxBean? = MuxBean(false)
) { ) {
@@ -82,7 +81,6 @@ data class V2rayConfig(
var address: Any? = null, var address: Any? = null,
var port: Int? = null, var port: Int? = null,
/*Freedom*/ /*Freedom*/
var domainStrategy: String? = null,
val redirect: String? = null, val redirect: String? = null,
val userLevel: Int? = null, val userLevel: Int? = null,
/*Loopback*/ /*Loopback*/
@@ -0,0 +1,26 @@
package com.v2ray.ang.enums
/**
* Load-balancing strategy types supported by xray balancer.
*
* @param policyGroupType The string value written into the xray config JSON.
* @param policyGroupTypeValue The numeric string stored in [com.v2ray.ang.dto.entities.ProfileItem.policyGroupType].
* @param requiresBurstObservatory Whether this strategy needs a burstObservatory (leastLoad).
* @param requiresObservatory Whether this strategy needs an observatory (leastPing).
*/
enum class BalancerStrategyType(
val policyGroupType: String,
val policyGroupTypeValue: String,
val requiresBurstObservatory: Boolean = false,
val requiresObservatory: Boolean = false,
) {
LEAST_LOAD("leastLoad", "1", requiresBurstObservatory = true),
RANDOM("random", "2"),
ROUND_ROBIN("roundRobin", "3"),
LEAST_PING("leastPing", "", requiresObservatory = true); // default / else
companion object {
fun from(policyGroupType: String?): BalancerStrategyType =
entries.firstOrNull { it.policyGroupTypeValue == policyGroupType } ?: LEAST_PING
}
}
@@ -5,5 +5,4 @@ enum class CoreResolvedType {
NORMAL, NORMAL,
POLICYGROUP, POLICYGROUP,
PROXYCHAIN, PROXYCHAIN,
CUSTOM,
} }
@@ -13,17 +13,6 @@ enum class PermissionType {
override fun getPermission(): String = Manifest.permission.CAMERA override fun getPermission(): String = Manifest.permission.CAMERA
}, },
/** Read storage / media permission (adapts to Android version) */
READ_STORAGE {
override fun getPermission(): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
}
},
/** Notification permission (Android 13+) */ /** Notification permission (Android 13+) */
POST_NOTIFICATIONS { POST_NOTIFICATIONS {
@RequiresApi(Build.VERSION_CODES.TIRAMISU) @RequiresApi(Build.VERSION_CODES.TIRAMISU)
@@ -37,7 +26,6 @@ enum class PermissionType {
fun getLabel(): String { fun getLabel(): String {
return when (this) { return when (this) {
CAMERA -> "Camera" CAMERA -> "Camera"
READ_STORAGE -> "Storage"
POST_NOTIFICATIONS -> "Notification" POST_NOTIFICATIONS -> "Notification"
} }
} }
@@ -1,7 +1,7 @@
package com.v2ray.ang.fmt package com.v2ray.ang.fmt
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.V2rayConfig import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.JsonUtil import com.v2ray.ang.util.JsonUtil
@@ -26,7 +26,11 @@ object SocksFmt : FmtBase() {
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
if (uri.userInfo?.isEmpty() == false) { if (uri.userInfo?.isEmpty() == false) {
val result = Utils.decode(uri.userInfo).split(":", limit = 2) val result = if (uri.userInfo.contains(":")) {
uri.userInfo.split(":", limit = 2)
} else {
Utils.decode(uri.userInfo).split(":", limit = 2)
}
if (result.count() == 2) { if (result.count() == 2) {
config.username = result.first() config.username = result.first()
config.password = result.last() config.password = result.last()
@@ -2,8 +2,8 @@ package com.v2ray.ang.fmt
import android.text.TextUtils import android.text.TextUtils
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.VmessQRCode import com.v2ray.ang.dto.VmessQRCode
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost import com.v2ray.ang.extension.idnHost
@@ -6,11 +6,11 @@ import android.text.TextUtils
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.core.CoreConfigManager import com.v2ray.ang.core.CoreConfigManager
import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.dto.entities.ProfileItem import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.SubscriptionCache import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.dto.entities.SubscriptionItem import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.enums.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.fmt.CustomFmt import com.v2ray.ang.fmt.CustomFmt
@@ -102,7 +102,7 @@ object MmkvManager {
return if (json.isNullOrBlank()) { return if (json.isNullOrBlank()) {
mutableListOf() mutableListOf()
} else { } else {
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf() JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
} }
} }
@@ -144,7 +144,7 @@ object MmkvManager {
if (json.isNullOrBlank()) { if (json.isNullOrBlank()) {
return null return null
} }
return JsonUtil.fromJson(json, ProfileItem::class.java) return JsonUtil.fromJsonSafe(json, ProfileItem::class.java)
} }
@@ -247,7 +247,7 @@ object MmkvManager {
if (json.isNullOrBlank()) { if (json.isNullOrBlank()) {
return null return null
} }
return JsonUtil.fromJson(json, ServerAffiliationInfo::class.java) return JsonUtil.fromJsonSafe(json, ServerAffiliationInfo::class.java)
} }
/** /**
@@ -374,7 +374,7 @@ object MmkvManager {
decodeSubsList().forEach { key -> decodeSubsList().forEach { key ->
val json = subStorage.decodeString(key) val json = subStorage.decodeString(key)
if (!json.isNullOrBlank()) { if (!json.isNullOrBlank()) {
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java) ?: SubscriptionItem() val item = JsonUtil.fromJsonSafe(json, SubscriptionItem::class.java) ?: SubscriptionItem()
subscriptions.add(SubscriptionCache(key, item)) subscriptions.add(SubscriptionCache(key, item))
} }
} }
@@ -420,7 +420,7 @@ object MmkvManager {
*/ */
fun decodeSubscription(subscriptionId: String): SubscriptionItem? { fun decodeSubscription(subscriptionId: String): SubscriptionItem? {
val json = subStorage.decodeString(subscriptionId) ?: return null val json = subStorage.decodeString(subscriptionId) ?: return null
return JsonUtil.fromJson(json, SubscriptionItem::class.java) return JsonUtil.fromJsonSafe(json, SubscriptionItem::class.java)
} }
/** /**
@@ -442,7 +442,7 @@ object MmkvManager {
return if (json.isNullOrBlank()) { return if (json.isNullOrBlank()) {
mutableListOf() mutableListOf()
} else { } else {
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf() JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
} }
} }
@@ -460,7 +460,7 @@ object MmkvManager {
assetStorage.allKeys()?.forEach { key -> assetStorage.allKeys()?.forEach { key ->
val json = assetStorage.decodeString(key) val json = assetStorage.decodeString(key)
if (!json.isNullOrBlank()) { if (!json.isNullOrBlank()) {
val item = JsonUtil.fromJson(json, AssetUrlItem::class.java) ?: AssetUrlItem() val item = JsonUtil.fromJsonSafe(json, AssetUrlItem::class.java) ?: AssetUrlItem()
assetUrlItems.add(AssetUrlCache(key, item)) assetUrlItems.add(AssetUrlCache(key, item))
} }
} }
@@ -495,7 +495,7 @@ object MmkvManager {
*/ */
fun decodeAsset(assetid: String): AssetUrlItem? { fun decodeAsset(assetid: String): AssetUrlItem? {
val json = assetStorage.decodeString(assetid) ?: return null val json = assetStorage.decodeString(assetid) ?: return null
return JsonUtil.fromJson(json, AssetUrlItem::class.java) return JsonUtil.fromJsonSafe(json, AssetUrlItem::class.java)
} }
//endregion //endregion
@@ -510,7 +510,7 @@ object MmkvManager {
fun decodeRoutingRulesets(): MutableList<RulesetItem>? { fun decodeRoutingRulesets(): MutableList<RulesetItem>? {
val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET) val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
if (ruleset.isNullOrEmpty()) return null if (ruleset.isNullOrEmpty()) return null
return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java)?.toMutableList() ?: mutableListOf() return JsonUtil.fromJsonSafe(ruleset, Array<RulesetItem>::class.java)?.toMutableList() ?: mutableListOf()
} }
/** /**
@@ -714,7 +714,7 @@ object MmkvManager {
*/ */
fun decodeWebDavConfig(): WebDavConfig? { fun decodeWebDavConfig(): WebDavConfig? {
val json = mainStorage.decodeString(KEY_WEBDAV_CONFIG) ?: return null val json = mainStorage.decodeString(KEY_WEBDAV_CONFIG) ?: return null
return JsonUtil.fromJson(json, WebDavConfig::class.java) return JsonUtil.fromJsonSafe(json, WebDavConfig::class.java)
} }
//endregion //endregion
@@ -12,10 +12,10 @@ import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
import com.v2ray.ang.AppConfig.TAG_DIRECT import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.VPN import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.dto.entities.ProfileItem import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.RulesetItem import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.dto.entities.SubscriptionItem import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.enums.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.Language import com.v2ray.ang.enums.Language
import com.v2ray.ang.enums.RoutingType import com.v2ray.ang.enums.RoutingType
@@ -73,7 +73,7 @@ object SettingsManager {
return null return null
} }
return JsonUtil.fromJson(assets, Array<RulesetItem>::class.java)?.toMutableList() return JsonUtil.fromJsonSafe(assets, Array<RulesetItem>::class.java)?.toMutableList()
} }
/** /**
@@ -97,7 +97,7 @@ object SettingsManager {
} }
try { try {
val rulesetList = JsonUtil.fromJson(content, Array<RulesetItem>::class.java)?.toMutableList() val rulesetList = JsonUtil.fromJsonSafe(content, Array<RulesetItem>::class.java)?.toMutableList()
if (rulesetList.isNullOrEmpty()) { if (rulesetList.isNullOrEmpty()) {
return false return false
} }
@@ -191,7 +191,7 @@ object SettingsManager {
val config = decodeServerConfig(guid) ?: return false val config = decodeServerConfig(guid) ?: return false
if (config.configType == EConfigType.CUSTOM) { if (config.configType == EConfigType.CUSTOM) {
val raw = MmkvManager.decodeServerRaw(guid) ?: return false val raw = MmkvManager.decodeServerRaw(guid) ?: return false
val v2rayConfig = JsonUtil.fromJson(raw, V2rayConfig::class.java) val v2rayConfig = JsonUtil.fromJsonSafe(raw, V2rayConfig::class.java)
val exist = v2rayConfig?.routing?.rules?.filter { it.outboundTag == TAG_DIRECT }?.any { val exist = v2rayConfig?.routing?.rules?.filter { it.outboundTag == TAG_DIRECT }?.any {
it.domain?.contains(GEOSITE_PRIVATE) == true || it.ip?.contains(GEOIP_PRIVATE) == true it.domain?.contains(GEOSITE_PRIVATE) == true || it.ip?.contains(GEOIP_PRIVATE) == true
} }
@@ -246,6 +246,20 @@ object SettingsManager {
.firstOrNull { it.remarks == remarks } .firstOrNull { it.remarks == remarks }
} }
/**
* Collects non-empty profile remarks while excluding specific config types.
*/
fun getProfileRemarks(excludeConfigTypes: Set<EConfigType> = setOf(EConfigType.CUSTOM)): List<String> {
return decodeAllServerList()
.asSequence()
.mapNotNull { guid -> decodeServerConfig(guid) }
.filter { profile -> profile.configType !in excludeConfigTypes }
.map { it.remarks.trim() }
.filter { it.isNotEmpty() }
.distinct()
.toList()
}
/** /**
* Removes the subscription. * Removes the subscription.
* If there are no remaining subscriptions, * If there are no remaining subscriptions,
@@ -571,7 +585,7 @@ object SettingsManager {
return return
} }
val guids = JsonUtil.fromJson(oldJson, Array<String>::class.java) ?: run { val guids = JsonUtil.fromJsonSafe(oldJson, Array<String>::class.java) ?: run {
MmkvManager.encodeSettings(migrationKey, true) MmkvManager.encodeSettings(migrationKey, true)
return return
} }
@@ -98,7 +98,7 @@ object SpeedtestManager {
proxyPassword = proxyPassword proxyPassword = proxyPassword
) )
) ?: return null ) ?: return null
val ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null val ipInfo = JsonUtil.fromJsonSafe(content, IPAPIInfo::class.java) ?: return null
val ip = listOf( val ip = listOf(
ipInfo.ip, ipInfo.ip,
@@ -45,11 +45,11 @@ object UpdateCheckerManager {
} }
val latestRelease = if (includePreRelease) { val latestRelease = if (includePreRelease) {
JsonUtil.fromJson(response, Array<GitHubRelease>::class.java) JsonUtil.fromJsonSafe(response, Array<GitHubRelease>::class.java)
?.firstOrNull() ?.firstOrNull()
?: throw IllegalStateException("No pre-release found") ?: throw IllegalStateException("No pre-release found")
} else { } else {
JsonUtil.fromJson(response, GitHubRelease::class.java) JsonUtil.fromJsonSafe(response, GitHubRelease::class.java)
} }
if (latestRelease == null) { if (latestRelease == null) {
return@withContext CheckUpdateResult(hasUpdate = false) return@withContext CheckUpdateResult(hasUpdate = false)
@@ -56,9 +56,7 @@ abstract class HelperBaseActivity : BaseActivity() {
mimeType: String = "*/*", mimeType: String = "*/*",
onResult: (Uri?) -> Unit onResult: (Uri?) -> Unit
) { ) {
checkAndRequestPermission(PermissionType.READ_STORAGE) { fileChooser.launch(mimeType, onResult)
fileChooser.launch(mimeType, onResult)
}
} }
/** /**
@@ -15,7 +15,6 @@ import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.extension.nullIfBlank import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -78,9 +77,7 @@ class RoutingEditActivity : BaseActivity() {
* The dropdown button triggers showing the full list without typing. * The dropdown button triggers showing the full list without typing.
*/ */
private fun setupOutboundTagInput() { private fun setupOutboundTagInput() {
val profileRemarks = MmkvManager.decodeAllServerList() val profileRemarks = SettingsManager.getProfileRemarks()
.mapNotNull { id -> MmkvManager.decodeServerConfig(id)?.remarks }
.filter { it.isNotBlank() }
val suggestions = (BUILTIN_OUTBOUND_TAGS.toList() + profileRemarks).distinct() val suggestions = (BUILTIN_OUTBOUND_TAGS.toList() + profileRemarks).distinct()
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions) val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
@@ -55,18 +55,13 @@ class ServerProxyChainActivity : BaseActivity() {
} }
private fun loadAvailableRemarks() { private fun loadAvailableRemarks() {
allRemarks = MmkvManager.decodeAllServerList() allRemarks = SettingsManager.getProfileRemarks(
.asSequence() excludeConfigTypes = setOf(
.mapNotNull { guid -> MmkvManager.decodeServerConfig(guid) } EConfigType.CUSTOM,
.filter { profile -> EConfigType.POLICYGROUP,
profile.configType != EConfigType.CUSTOM EConfigType.PROXYCHAIN,
&& profile.configType != EConfigType.POLICYGROUP )
&& profile.configType != EConfigType.PROXYCHAIN )
}
.map { it.remarks.trim() }
.filter { it.isNotEmpty() }
.distinct()
.toList()
} }
private fun setupRecycler() { private fun setupRecycler() {
@@ -13,6 +13,7 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivitySubEditBinding import com.v2ray.ang.databinding.ActivitySubEditBinding
import com.v2ray.ang.dto.entities.SubscriptionItem import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
@@ -78,10 +79,13 @@ class SubEditActivity : BaseActivity() {
} }
private fun setupProfileRemarkInputs() { private fun setupProfileRemarkInputs() {
val suggestions = MmkvManager.decodeAllServerList() val suggestions = SettingsManager.getProfileRemarks(
.mapNotNull { id -> MmkvManager.decodeServerConfig(id)?.remarks } excludeConfigTypes = setOf(
.filter { it.isNotBlank() } EConfigType.CUSTOM,
.distinct() EConfigType.POLICYGROUP,
EConfigType.PROXYCHAIN,
)
)
setupProfileRemarkInput(binding.etPreProfile, binding.btnPreProfileDropdown, suggestions) setupProfileRemarkInput(binding.etPreProfile, binding.btnPreProfileDropdown, suggestions)
setupProfileRemarkInput(binding.etNextProfile, binding.btnNextProfileDropdown, suggestions) setupProfileRemarkInput(binding.etNextProfile, binding.btnNextProfileDropdown, suggestions)
@@ -35,6 +35,23 @@ object JsonUtil {
return gson.fromJson(src, cls) return gson.fromJson(src, cls)
} }
/**
* Safely parses a JSON string into an object of the specified class.
* Returns null if parsing fails instead of throwing an exception.
*
* @param src The JSON string to parse.
* @param cls The class of the object to parse into.
* @return The parsed object, or null if parsing fails.
*/
fun <T> fromJsonSafe(src: String, cls: Class<T>): T? {
return try {
gson.fromJson(src, cls)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse JSON", e)
null
}
}
/** /**
* Converts an object to its pretty-printed JSON representation. * Converts an object to its pretty-printed JSON representation.
* *
@@ -14,10 +14,10 @@ import com.v2ray.ang.AngApplication
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.dto.GroupMapItem import com.v2ray.ang.dto.GroupMapItem
import com.v2ray.ang.dto.entities.ServersCache
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionUpdateResult import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.dto.TestServiceMessage import com.v2ray.ang.dto.TestServiceMessage
import com.v2ray.ang.dto.entities.ServersCache
import com.v2ray.ang.dto.entities.SubscriptionCache
import com.v2ray.ang.extension.matchesPattern import com.v2ray.ang.extension.matchesPattern
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
@@ -2,9 +2,9 @@ package com.v2ray.ang.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.dto.entities.AssetUrlCache import com.v2ray.ang.dto.entities.AssetUrlCache
import com.v2ray.ang.dto.entities.AssetUrlItem import com.v2ray.ang.dto.entities.AssetUrlItem
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.extension.concatUrl import com.v2ray.ang.extension.concatUrl
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.HttpUtil