Compare commits

..

15 Commits

Author SHA1 Message Date
2dust 39d2513df1 up 2.2.1 2026-05-27 19:59:55 +08:00
2dust 3cb4df474b Keep selected server when replacing subscription
https://github.com/2dust/v2rayNG/pull/5676
2026-05-27 19:53:18 +08:00
fuilloi b64bb92c8c Update MainRecyclerAdapter.kt (#5692)
* Update MainRecyclerAdapter.kt

Add text display for flow, network, and transport layer security.

* Update MainRecyclerAdapter.kt

remove flow
2026-05-27 17:30:22 +08:00
Tanbir Hossen 7f0e2f801d fix: migrate wsSettings host from deprecated headers.Host to independent host field (#5686)
* fix: migrate wsSettings host from deprecated headers.Host to independent host field

fix: migrate wsSettings host out of deprecated headers field

- Add independent `host` field to WsSettingsBean
- Replace HeadersBean with Map<String, String>? to match Xray docs
- Update CoreOutboundBuilder to use wssetting.host instead of wssetting.headers.Host

* fix: migrate wsSettings host from deprecated headers.Host to independent host field

fix: migrate wsSettings host out of deprecated headers field

- Add independent `host` field to WsSettingsBean
- Replace HeadersBean with Map<String, String>? to match Xray docs
- Update CoreOutboundBuilder to use wssetting.host instead of wssetting.headers.Host
2026-05-25 18:01:21 +08:00
2dust 0f7cee0613 Use isComplexType() to simplify config checks 2026-05-25 10:03:37 +08:00
2dust b2e10fc60b Skip complex config types when deduplicating servers
https://github.com/2dust/v2rayNG/issues/5675
2026-05-25 09:55:30 +08:00
name321467 aa3f17fb51 Add share log action (#5682)
Add a new "Share log" menu item in Logcat.The action creates a temporary .txt file from the current log output and opens the Android share sheet, allowing users to save or send logs to other apps.
2026-05-25 09:46:39 +08:00
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
43 changed files with 911 additions and 643 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 = 731
versionName = "2.1.8" versionName = "2.2.1"
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"
@@ -6,6 +6,7 @@ import com.v2ray.ang.dto.CoreConfigContext
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
import com.v2ray.ang.enums.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
@@ -13,58 +14,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()
@@ -93,17 +164,14 @@ object CoreConfigContextBuilder {
} }
.filter { it.server.isNotNullEmpty() } .filter { it.server.isNotNullEmpty() }
.filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) } .filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { it.configType != EConfigType.CUSTOM } .filter { !it.configType.isComplexType() }
.filter { it.configType != EConfigType.POLICYGROUP }
.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)
@@ -115,25 +183,21 @@ object CoreConfigContextBuilder {
.mapNotNull { remark -> SettingsManager.getServerViaRemarks(remark) } .mapNotNull { remark -> SettingsManager.getServerViaRemarks(remark) }
.filter { it.server.isNotNullEmpty() } .filter { it.server.isNotNullEmpty() }
.filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) } .filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
.filter { it.configType != EConfigType.CUSTOM } .filter { !it.configType.isComplexType() }
.filter { it.configType != EConfigType.POLICYGROUP }
.filter { it.configType != EConfigType.PROXYCHAIN }
.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 +205,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
@@ -410,7 +410,7 @@ object CoreOutboundBuilder {
NetworkType.WS.type -> { NetworkType.WS.type -> {
val wssetting = OutboundBean.StreamSettingsBean.WsSettingsBean() val wssetting = OutboundBean.StreamSettingsBean.WsSettingsBean()
wssetting.headers.Host = host.orEmpty() wssetting.host = host.orEmpty()
sni = host sni = host
wssetting.path = path ?: "/" wssetting.path = path ?: "/"
streamSettings.wsSettings = wssetting streamSettings.wsSettings = wssetting
@@ -685,4 +685,4 @@ object CoreOutboundBuilder {
} }
return resolvedIps.first() return resolvedIps.first()
} }
} }
@@ -16,6 +16,7 @@ import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.dto.OutboundTrafficStat import com.v2ray.ang.dto.OutboundTrafficStat
import com.v2ray.ang.dto.entities.ProfileItem import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.NotificationManager import com.v2ray.ang.handler.NotificationManager
@@ -145,9 +146,7 @@ object CoreServiceManager {
error(context.getString(R.string.toast_config_file_invalid)) error(context.getString(R.string.toast_config_file_invalid))
} }
if (config.configType != EConfigType.CUSTOM if (!config.configType.isComplexType()
&& config.configType != EConfigType.POLICYGROUP
&& config.configType != EConfigType.PROXYCHAIN
&& !Utils.isValidUrl(config.server) && !Utils.isValidUrl(config.server)
&& !Utils.isPureIpAddress(config.server.orEmpty()) && !Utils.isPureIpAddress(config.server.orEmpty())
) { ) {
@@ -176,7 +175,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*/
@@ -199,13 +197,12 @@ data class V2rayConfig(
data class WsSettingsBean( data class WsSettingsBean(
var path: String? = null, var path: String? = null,
var headers: HeadersBean = HeadersBean(), var host: String? = null,
var headers: Map<String, String>? = null,
val maxEarlyData: Int? = null, val maxEarlyData: Int? = null,
val useBrowserForwarding: Boolean? = null, val useBrowserForwarding: Boolean? = null,
val acceptProxyProtocol: Boolean? = null val acceptProxyProtocol: Boolean? = null
) { )
data class HeadersBean(var Host: String = "")
}
data class HttpupgradeSettingsBean( data class HttpupgradeSettingsBean(
var path: String? = null, var path: String? = null,
@@ -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"
} }
} }
@@ -8,6 +8,7 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import com.v2ray.ang.AngApplication import com.v2ray.ang.AngApplication
import com.v2ray.ang.enums.EConfigType
import es.dmoral.toasty.Toasty import es.dmoral.toasty.Toasty
import java.io.Serializable import java.io.Serializable
import java.net.URI import java.net.URI
@@ -200,4 +201,22 @@ fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean =
} }
return regex?.containsMatchIn(this) return regex?.containsMatchIn(this)
?: this.contains(keyword, ignoreCase = ignoreCase) ?: this.contains(keyword, ignoreCase = ignoreCase)
} }
/**
* Checks if the config type is a group type (PolicyGroup or ProxyChain).
*
* @return True if the config type is PolicyGroup or ProxyChain, false otherwise.
*/
fun EConfigType.isGroupType(): Boolean {
return this == EConfigType.POLICYGROUP || this == EConfigType.PROXYCHAIN
}
/**
* Checks if the config type is a complex type (Custom, PolicyGroup, or ProxyChain).
*
* @return True if the config type is Custom, PolicyGroup, or ProxyChain, false otherwise.
*/
fun EConfigType.isComplexType(): Boolean {
return this == EConfigType.CUSTOM || this == EConfigType.POLICYGROUP || this == EConfigType.PROXYCHAIN
}
@@ -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
@@ -234,15 +234,8 @@ object AngConfigManager {
if (servers == null) { if (servers == null) {
return 0 return 0
} }
// Find the currently selected server that matches the subscription ID // Find the currently selected server that belongs to the same subscription before replacement.
val removedSelected = if (subid.isNotBlank() && !append) { val removedSelected = getRemovedSelectedProfile(subid, append)
MmkvManager.getSelectServer()
.takeIf { it?.isNotBlank() == true }
?.let { MmkvManager.decodeServerConfig(it) }
?.takeIf { it.subscriptionId == subid }
} else {
null
}
val subItem = MmkvManager.decodeSubscription(subid) val subItem = MmkvManager.decodeSubscription(subid)
@@ -288,7 +281,6 @@ object AngConfigManager {
// Read serverList once // Read serverList once
val serverList = MmkvManager.decodeServerList(subid) val serverList = MmkvManager.decodeServerList(subid)
var needSetSelected = MmkvManager.getSelectServer().isNullOrBlank()
configs.forEach { config -> configs.forEach { config ->
val key = Utils.getUuid() val key = Utils.getUuid()
@@ -297,10 +289,6 @@ object AngConfigManager {
if (!serverList.contains(key)) { if (!serverList.contains(key)) {
serverList.add(0, key) serverList.add(0, key)
if (needSetSelected) {
MmkvManager.setSelectServer(key)
needSetSelected = false
}
} }
keyToProfile[key] = config keyToProfile[key] = config
} }
@@ -323,7 +311,8 @@ object AngConfigManager {
* @return Matched key or null * @return Matched key or null
*/ */
private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? { private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? {
if (keyToProfile.isEmpty() || target == null) return null if (keyToProfile.isEmpty()) return null
if (target == null) return null
// Level 0: Full match (remarks + server + port + password) // Level 0: Full match (remarks + server + port + password)
if (target.remarks.isNotBlank()) { if (target.remarks.isNotBlank()) {
@@ -360,7 +349,20 @@ object AngConfigManager {
isSameText(saved.server, target.server) isSameText(saved.server, target.server)
}?.key?.let { return it } }?.key?.let { return it }
return null // If old selected node cannot be matched, fall back to the first imported config.
return keyToProfile.keys.firstOrNull()
}
/**
* Returns the currently selected profile if it belongs to the target subscription and will be replaced.
*/
private fun getRemovedSelectedProfile(subid: String, append: Boolean): ProfileItem? {
if (subid.isBlank() || append) return null
return MmkvManager.getSelectServer()
.takeIf { it?.isNotBlank() == true }
?.let { MmkvManager.decodeServerConfig(it) }
?.takeIf { it.subscriptionId == subid }
} }
/** /**
@@ -396,18 +398,25 @@ object AngConfigManager {
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf() JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
if (serverList.isNotEmpty()) { if (serverList.isNotEmpty()) {
val removedSelected = getRemovedSelectedProfile(subid, append)
if (!append) { if (!append) {
MmkvManager.removeServerViaSubid(subid) MmkvManager.removeServerViaSubid(subid)
} }
var count = 0 var count = 0
val keyToProfile = mutableMapOf<String, ProfileItem>()
for (srv in serverList.reversed()) { for (srv in serverList.reversed()) {
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
config.subscriptionId = subid config.subscriptionId = subid
config.description = generateDescription(config) config.description = generateDescription(config)
val key = MmkvManager.encodeServerConfig("", config) val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "") MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
keyToProfile[key] = config
count += 1 count += 1
} }
if (count > 0) {
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
matchKey?.let { MmkvManager.setSelectServer(it) }
}
return count return count
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -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)
@@ -19,6 +19,7 @@ import com.v2ray.ang.databinding.FragmentGroupServerBinding
import com.v2ray.ang.databinding.ItemQrcodeBinding import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.dto.entities.ProfileItem import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
@@ -269,9 +270,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
} }
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) { override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
val isCustom = profile.configType == EConfigType.CUSTOM val isCustom = profile.configType.isComplexType()
|| profile.configType == EConfigType.POLICYGROUP
|| profile.configType == EConfigType.PROXYCHAIN
val (shareOptions, skip) = if (more) { val (shareOptions, skip) = if (more) {
val options = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList() val options = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
@@ -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)
}
} }
/** /**
@@ -1,6 +1,8 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ClipData
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
@@ -18,6 +20,11 @@ import com.v2ray.ang.viewmodel.LogcatViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.core.content.FileProvider
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener { class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) } private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
@@ -45,6 +52,55 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
return true return true
} }
private fun shareLogcat() {
lifecycleScope.launch(Dispatchers.IO) {
val logText = viewModel.getAll().joinToString("\n")
val result = try {
val shareDir = File(cacheDir, "shared_logs").apply {
mkdirs()
}
shareDir.listFiles()?.forEach { it.delete() }
val timestamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
val logFile = File(shareDir, "v2rayNG_logcat_$timestamp.txt")
logFile.writeText(logText, Charsets.UTF_8)
val uri = FileProvider.getUriForFile(
this@LogcatActivity,
"${packageName}.cache",
logFile
)
uri to logFile.name
} catch (e: Exception) {
withContext(Dispatchers.Main) {
toast(e.localizedMessage ?: e.toString())
}
return@launch
}
withContext(Dispatchers.Main) {
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, result.first)
putExtra(Intent.EXTRA_SUBJECT, result.second)
putExtra(Intent.EXTRA_TITLE, result.second)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
clipData = ClipData.newUri(contentResolver, result.second, result.first)
}
startActivity(
Intent.createChooser(
shareIntent,
getString(R.string.logcat_share)
)
)
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_logcat, menu) menuInflater.inflate(R.menu.menu_logcat, menu)
@@ -78,6 +134,11 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
true true
} }
R.id.share_all -> {
shareLogcat()
true
}
R.id.clear_all -> { R.id.clear_all -> {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
viewModel.clearLogcat() viewModel.clearLogcat()
@@ -1,3 +1,4 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
@@ -14,6 +15,7 @@ import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
import com.v2ray.ang.databinding.ItemRecyclerMainBinding import com.v2ray.ang.databinding.ItemRecyclerMainBinding
import com.v2ray.ang.dto.entities.ProfileItem import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.ServersCache import com.v2ray.ang.dto.entities.ServersCache
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.nullIfBlank import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
@@ -58,7 +60,7 @@ class MainRecyclerAdapter(
//Name address //Name address
holder.itemMainBinding.tvName.text = profile.remarks holder.itemMainBinding.tvName.text = profile.remarks
holder.itemMainBinding.tvStatistics.text = getAddress(profile) holder.itemMainBinding.tvStatistics.text = getAddress(profile)
holder.itemMainBinding.tvType.text = profile.configType.name holder.itemMainBinding.tvType.text = getProtocolDescription(profile)
//TestResult //TestResult
val aff = MmkvManager.decodeServerAffiliationInfo(guid) val aff = MmkvManager.decodeServerAffiliationInfo(guid)
@@ -140,6 +142,31 @@ class MainRecyclerAdapter(
return subRemarks?.toString() ?: "" return subRemarks?.toString() ?: ""
} }
private fun getProtocolDescription(profile: ProfileItem): String {
if (profile.configType.isComplexType()) {
return profile.configType.name
}
val parts = mutableListOf<String>()
parts.add(profile.configType.name)
// Transport: hide tcp or blank
profile.network?.let { net ->
if (net.isNotBlank() && !net.equals("tcp", ignoreCase = true)) {
parts.add(net)
}
}
// Security: hide blank or tls
profile.security?.let { sec ->
if (sec.isNotBlank() && !sec.equals("tls", ignoreCase = true)) {
parts.add(sec)
}
}
return parts.joinToString(" / ")
}
fun removeServerSub(guid: String, position: Int) { fun removeServerSub(guid: String, position: Int) {
val idx = data.indexOfFirst { it.guid == guid } val idx = data.indexOfFirst { it.guid == guid }
if (idx >= 0) { if (idx >= 0) {
@@ -203,4 +230,4 @@ class MainRecyclerAdapter(
override fun onItemDismiss(position: Int) { override fun onItemDismiss(position: Int) {
} }
} }
@@ -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)
@@ -13,6 +13,7 @@ import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ActivityServerProxyChainBinding import com.v2ray.ang.databinding.ActivityServerProxyChainBinding
import com.v2ray.ang.dto.entities.ProfileItem import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType
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
@@ -55,18 +56,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() {
@@ -114,10 +110,7 @@ class ServerProxyChainActivity : BaseActivity() {
val invalidMembers = chainMembers.filter { member -> val invalidMembers = chainMembers.filter { member ->
val profile = SettingsManager.getServerViaRemarks(member) val profile = SettingsManager.getServerViaRemarks(member)
profile == null profile == null || profile.configType.isComplexType()
|| profile.configType == EConfigType.CUSTOM
|| profile.configType == EConfigType.POLICYGROUP
|| profile.configType == EConfigType.PROXYCHAIN
} }
if (invalidMembers.isNotEmpty()) { if (invalidMembers.isNotEmpty()) {
toast(getString(R.string.server_proxy_chain_members_invalid, invalidMembers.joinToString(", "))) toast(getString(R.string.server_proxy_chain_members_invalid, invalidMembers.joinToString(", ")))
@@ -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,11 @@ 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.isComplexType
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
@@ -296,16 +297,28 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
/** /**
* Removes duplicate servers. * Removes duplicate servers.
* Excludes servers with complex types (Custom, PolicyGroup, or ProxyChain) from duplicate comparison.
* @return The number of removed servers. * @return The number of removed servers.
*/ */
fun removeDuplicateServer(): Int { fun removeDuplicateServer(): Int {
val serversCacheCopy = serversCache.toList().toMutableList() val serversCacheCopy = serversCache.toList().toMutableList()
val deleteServer = mutableListOf<String>() val deleteServer = mutableListOf<String>()
serversCacheCopy.forEachIndexed { index, sc -> serversCacheCopy.forEachIndexed { index, sc ->
val profile = sc.profile val profile = sc.profile
// Skip if this profile has a complex config type
if (profile.configType.isComplexType()) {
return@forEachIndexed
}
serversCacheCopy.forEachIndexed { index2, sc2 -> serversCacheCopy.forEachIndexed { index2, sc2 ->
if (index2 > index) { if (index2 > index) {
val profile2 = sc2.profile val profile2 = sc2.profile
// Skip if the second profile has a complex config type
if (profile2.configType.isComplexType()) {
return@forEachIndexed
}
if (profile == profile2 && !deleteServer.contains(sc2.guid)) { if (profile == profile2 && !deleteServer.contains(sc2.guid)) {
deleteServer.add(sc2.guid) deleteServer.add(sc2.guid)
} }
@@ -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
@@ -17,4 +17,9 @@
android:icon="@drawable/ic_copy" android:icon="@drawable/ic_copy"
android:title="@string/logcat_copy" android:title="@string/logcat_copy"
app:showAsAction="ifRoom" /> app:showAsAction="ifRoom" />
<item
android:id="@+id/share_all"
android:icon="@drawable/ic_share_24dp"
android:title="@string/logcat_share"
app:showAsAction="ifRoom" />
</menu> </menu>
@@ -286,6 +286,7 @@
<string name="title_logcat">Logcat</string> <string name="title_logcat">Logcat</string>
<string name="logcat_copy">نسخ</string> <string name="logcat_copy">نسخ</string>
<string name="logcat_clear">مسح</string> <string name="logcat_clear">مسح</string>
<string name="logcat_share">مشاركة السجل</string>
<string name="title_service_restart">إعادة تشغيل الخدمة</string> <string name="title_service_restart">إعادة تشغيل الخدمة</string>
<string name="title_del_all_config">حذف جميع الإعدادات (قبل أول خطوة)</string> <string name="title_del_all_config">حذف جميع الإعدادات (قبل أول خطوة)</string>
<string name="title_del_duplicate_config">حذف الإعدادات المكررة (2)</string> <string name="title_del_duplicate_config">حذف الإعدادات المكررة (2)</string>
@@ -286,6 +286,7 @@
<string name="title_logcat">লগক্যাট</string> <string name="title_logcat">লগক্যাট</string>
<string name="logcat_copy">কপি করুন</string> <string name="logcat_copy">কপি করুন</string>
<string name="logcat_clear">স্পষ্ট করুন</string> <string name="logcat_clear">স্পষ্ট করুন</string>
<string name="logcat_share">লগ শেয়ার করুন</string>
<string name="title_service_restart">সার্ভিস পুনরায় চালু করুন</string> <string name="title_service_restart">সার্ভিস পুনরায় চালু করুন</string>
<string name="title_del_all_config">সব কনফিগারেশন মুছে ফেলুন</string> <string name="title_del_all_config">সব কনফিগারেশন মুছে ফেলুন</string>
<string name="title_del_duplicate_config">ডুপ্লিকেট কনফিগারেশন মুছে ফেলুন</string> <string name="title_del_duplicate_config">ডুপ্লিকেট কনফিগারেশন মুছে ফেলুন</string>
@@ -286,6 +286,7 @@
<string name="title_logcat">گوزارشا</string> <string name="title_logcat">گوزارشا</string>
<string name="logcat_copy">لف گیری</string> <string name="logcat_copy">لف گیری</string>
<string name="logcat_clear">روفتن</string> <string name="logcat_clear">روفتن</string>
<string name="logcat_share">Share log</string>
<string name="title_service_restart">ره وندن دووارته خدمات</string> <string name="title_service_restart">ره وندن دووارته خدمات</string>
<string name="title_del_all_config">پاک کردن پوی کانفیگا بونکۊ سکویی</string> <string name="title_del_all_config">پاک کردن پوی کانفیگا بونکۊ سکویی</string>
<string name="title_del_duplicate_config">پاک کردن کانفیگا تکراری بونکۊ سکویی</string> <string name="title_del_duplicate_config">پاک کردن کانفیگا تکراری بونکۊ سکویی</string>
@@ -283,6 +283,7 @@
<string name="title_logcat">گزارشات</string> <string name="title_logcat">گزارشات</string>
<string name="logcat_copy">کپی</string> <string name="logcat_copy">کپی</string>
<string name="logcat_clear">پاک کردن</string> <string name="logcat_clear">پاک کردن</string>
<string name="logcat_share">اشتراک‌گذاری گزارش</string>
<string name="title_service_restart">راه‌اندازی مجدد خدمات</string> <string name="title_service_restart">راه‌اندازی مجدد خدمات</string>
<string name="title_del_all_config">حذف تمام کانفیگ های گروه فعلی</string> <string name="title_del_all_config">حذف تمام کانفیگ های گروه فعلی</string>
<string name="title_del_duplicate_config">حذف کانفیگ های تکراری گروه فعلی</string> <string name="title_del_duplicate_config">حذف کانفیگ های تکراری گروه فعلی</string>
@@ -291,6 +291,7 @@
<string name="title_logcat">Журнал</string> <string name="title_logcat">Журнал</string>
<string name="logcat_copy">Копировать</string> <string name="logcat_copy">Копировать</string>
<string name="logcat_clear">Очистить</string> <string name="logcat_clear">Очистить</string>
<string name="logcat_share">Поделиться логом</string>
<string name="title_service_restart">Перезапуск службы</string> <string name="title_service_restart">Перезапуск службы</string>
<string name="title_del_all_config">Удалить профили</string> <string name="title_del_all_config">Удалить профили</string>
<string name="title_del_duplicate_config">Удалить дубликаты профилей</string> <string name="title_del_duplicate_config">Удалить дубликаты профилей</string>
@@ -286,6 +286,7 @@
<string name="title_logcat">Logcat</string> <string name="title_logcat">Logcat</string>
<string name="logcat_copy">Sao chép</string> <string name="logcat_copy">Sao chép</string>
<string name="logcat_clear">Xoá</string> <string name="logcat_clear">Xoá</string>
<string name="logcat_share">Chia sẻ nhật ký</string>
<string name="title_service_restart">Khởi động lại dịch vụ lõi</string> <string name="title_service_restart">Khởi động lại dịch vụ lõi</string>
<string name="title_del_all_config">Xoá tất cả cấu hình</string> <string name="title_del_all_config">Xoá tất cả cấu hình</string>
<string name="title_del_duplicate_config">Xoá cấu hình trùng lặp</string> <string name="title_del_duplicate_config">Xoá cấu hình trùng lặp</string>
@@ -286,6 +286,7 @@
<string name="title_logcat">Logcat</string> <string name="title_logcat">Logcat</string>
<string name="logcat_copy">复制</string> <string name="logcat_copy">复制</string>
<string name="logcat_clear">清除</string> <string name="logcat_clear">清除</string>
<string name="logcat_share">分享日志</string>
<string name="title_service_restart">服务重启</string> <string name="title_service_restart">服务重启</string>
<string name="title_del_all_config">删除配置</string> <string name="title_del_all_config">删除配置</string>
<string name="title_del_duplicate_config">删除重复配置</string> <string name="title_del_duplicate_config">删除重复配置</string>
@@ -285,6 +285,7 @@
<string name="title_logcat">Logcat</string> <string name="title_logcat">Logcat</string>
<string name="logcat_copy">複製</string> <string name="logcat_copy">複製</string>
<string name="logcat_clear">清除</string> <string name="logcat_clear">清除</string>
<string name="logcat_share">分享日誌</string>
<string name="title_service_restart">重啟服務</string> <string name="title_service_restart">重啟服務</string>
<string name="title_del_all_config">刪除設定</string> <string name="title_del_all_config">刪除設定</string>
<string name="title_del_duplicate_config">刪除重複設定</string> <string name="title_del_duplicate_config">刪除重複設定</string>
@@ -293,6 +293,7 @@
<string name="title_logcat">Logcat</string> <string name="title_logcat">Logcat</string>
<string name="logcat_copy">Copy</string> <string name="logcat_copy">Copy</string>
<string name="logcat_clear">Clear</string> <string name="logcat_clear">Clear</string>
<string name="logcat_share">Share log</string>
<string name="title_service_restart">Service restart</string> <string name="title_service_restart">Service restart</string>
<string name="title_del_all_config">Delete config</string> <string name="title_del_all_config">Delete config</string>
<string name="title_del_duplicate_config">Delete duplicate config</string> <string name="title_del_duplicate_config">Delete duplicate config</string>