Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b103e3aec9 | |||
| 19fc65d128 | |||
| 27cafd1052 | |||
| a155b24c1e | |||
| e805fcf088 | |||
| 95e37db42e | |||
| fe812c4456 | |||
| d5af0fe735 | |||
| 9126aa9c9e | |||
| 4a763d2ec5 | |||
| 08d6de4504 | |||
| 629d410189 | |||
| 39d2513df1 | |||
| 3cb4df474b | |||
| b64bb92c8c | |||
| 7f0e2f801d | |||
| 0f7cee0613 | |||
| b2e10fc60b | |||
| aa3f17fb51 | |||
| d64fc627bb | |||
| 6934ab3e5d | |||
| 028d13aef8 | |||
| 4a78c165bc | |||
| c1033a006f | |||
| e8a42013c0 | |||
| 94e5263250 | |||
| 7c68aa9d83 | |||
| ef13e336da | |||
| d16cb21059 | |||
| 237837e7be | |||
| 2a6edd98be | |||
| 9238b80675 | |||
| bb5e6f1959 | |||
| 1cc0865411 | |||
| cb8d979f23 | |||
| 5932b0db29 | |||
| dbe4fe78b5 | |||
| b5bcd81ae4 | |||
| d45ccd7953 | |||
| 6815563902 | |||
| 7c38ad7c57 | |||
| 885aeb384e | |||
| 57f01412e7 | |||
| fd84ea4114 | |||
| a4e2e6d6a2 | |||
| b8cfe9bc82 | |||
| b945f4a3c7 | |||
| cfb6776e8f | |||
| ebaa5088b6 | |||
| 209679f098 | |||
| 19ad248cfe | |||
| 35f1f58476 | |||
| 4c86e7c26e | |||
| 5d0897cd94 | |||
| fc4ab733c8 | |||
| d606453b83 | |||
| c6c8eccd51 | |||
| 03074fba61 | |||
| ac02d23083 | |||
| 5c6aaf285b | |||
| d3f9809486 | |||
| a12909e33d | |||
| 3e823a7440 | |||
| dc1d3a27dd | |||
| d0f1e21f49 | |||
| 3b8615fad0 | |||
| d9d00f4137 | |||
| 2aefbd43eb | |||
| cf33391785 | |||
| a35ba5dfbd | |||
| 3b4d895c1d | |||
| e8585a82c6 | |||
| 087c08f95a | |||
| 6612ca8256 | |||
| 487692deb7 |
@@ -22,25 +22,25 @@ jobs:
|
||||
fetch-depth: '0'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v4.0.1
|
||||
uses: android-actions/setup-android@v4
|
||||
with:
|
||||
log-accepted-android-sdk-licenses: false
|
||||
cmdline-tools-version: '13114758'
|
||||
packages: 'platforms;android-36.1 build-tools;36.1.0 platform-tools'
|
||||
cmdline-tools-version: '14742923'
|
||||
packages: 'platforms;android-37.0 build-tools;37.0.0 platform-tools'
|
||||
|
||||
- name: Install NDK
|
||||
run: |
|
||||
echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
||||
--channel=0 \
|
||||
--install "ndk;28.2.13676358"
|
||||
echo "NDK_HOME=$ANDROID_HOME/ndk/28.2.13676358" >> $GITHUB_ENV
|
||||
--install "ndk;29.0.14206865"
|
||||
echo "NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
|
||||
sed -i '10i\
|
||||
\
|
||||
ndkVersion = "28.2.13676358"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
|
||||
ndkVersion = "29.0.14206865"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
|
||||
|
||||
- name: Restore cached libhevtun
|
||||
id: cache-libhevtun-restore
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/libs
|
||||
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
- name: Save libhevtun
|
||||
if: steps.cache-libhevtun-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/libs
|
||||
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
java-version: '21'
|
||||
|
||||
- name: Decode Keystore
|
||||
uses: timheuer/base64-to-file@v1.2.4
|
||||
uses: timheuer/base64-to-file@v2.0.0
|
||||
id: android_keystore
|
||||
with:
|
||||
fileName: "android_keystore.jks"
|
||||
|
||||
+1
-1
Submodule AndroidLibXrayLite updated: d8bfc35e93...e194d22cf1
@@ -3,7 +3,7 @@
|
||||
A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-core) and [v2fly core](https://github.com/v2fly/v2ray-core)
|
||||
|
||||
[](https://developer.android.com/about/versions/lollipop)
|
||||
[](https://kotlinlang.org)
|
||||
[](https://kotlinlang.org)
|
||||
[](https://github.com/2dust/v2rayNG/commits/master)
|
||||
[](https://www.codefactor.io/repository/github/2dust/v2rayng)
|
||||
[](https://github.com/2dust/v2rayNG/releases)
|
||||
|
||||
@@ -6,14 +6,14 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.v2ray.ang"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 723
|
||||
versionName = "2.1.3"
|
||||
targetSdk = 37
|
||||
versionCode = 733
|
||||
versionName = "2.2.3"
|
||||
multiDexEnabled = true
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
|
||||
@@ -30,15 +30,14 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<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.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"
|
||||
tools:ignore="ForegroundServicesPolicy" />
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
|
||||
android:minSdkVersion="34" />
|
||||
<!-- <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> -->
|
||||
<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" />
|
||||
|
||||
<application
|
||||
@@ -82,6 +81,10 @@
|
||||
android:name=".ui.ServerGroupActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged" />
|
||||
<activity
|
||||
android:name=".ui.ServerProxyChainActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged" />
|
||||
<activity
|
||||
android:name=".ui.SettingsActivity"
|
||||
android:exported="false" />
|
||||
@@ -168,7 +171,7 @@
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".service.V2RayVpnService"
|
||||
android:name=".service.CoreVpnService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
@@ -188,7 +191,7 @@
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".service.V2RayProxyOnlyService"
|
||||
android:name=".service.CoreProxyOnlyService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:label="@string/app_name"
|
||||
@@ -199,9 +202,14 @@
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".service.V2RayTestService"
|
||||
android:name=".service.CoreTestService"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon" />
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="test" />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".receiver.WidgetProvider"
|
||||
@@ -273,6 +281,11 @@
|
||||
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name="androidx.work.multiprocess.RemoteWorkManagerService"
|
||||
android:exported="false"
|
||||
android:process=":bg" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.cache"
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls"
|
||||
"tls",
|
||||
"quic"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -73,8 +74,10 @@
|
||||
},
|
||||
{
|
||||
"protocol": "freedom",
|
||||
"settings": {
|
||||
"domainStrategy": "UseIP"
|
||||
"streamSettings": {
|
||||
"sockopt": {
|
||||
"domainStrategy": "UseIP"
|
||||
}
|
||||
},
|
||||
"tag": "direct"
|
||||
},
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls"
|
||||
"tls",
|
||||
"quic"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -46,7 +47,8 @@
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls"
|
||||
"tls",
|
||||
"quic"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -89,8 +91,10 @@
|
||||
},
|
||||
{
|
||||
"protocol": "freedom",
|
||||
"settings": {
|
||||
"domainStrategy": "UseIP"
|
||||
"streamSettings": {
|
||||
"sockopt": {
|
||||
"domainStrategy": "UseIP"
|
||||
}
|
||||
},
|
||||
"tag": "direct"
|
||||
},
|
||||
|
||||
@@ -43,10 +43,8 @@ object AppConfig {
|
||||
const val PREF_FRAGMENT_PACKETS = "pref_fragment_packets"
|
||||
const val PREF_FRAGMENT_LENGTH = "pref_fragment_length"
|
||||
const val PREF_FRAGMENT_INTERVAL = "pref_fragment_interval"
|
||||
const val SUBSCRIPTION_AUTO_UPDATE = "pref_auto_update_subscription"
|
||||
const val SUBSCRIPTION_AUTO_UPDATE_INTERVAL = "pref_auto_update_interval"
|
||||
const val SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL = "1440" // Default is 24 hours
|
||||
const val SUBSCRIPTION_UPDATE_TASK_NAME = "subscription_updater"
|
||||
const val SUBSCRIPTION_MIN_INTERVAL_MINUTES = 15L
|
||||
const val PREF_SPEED_ENABLED = "pref_speed_enabled"
|
||||
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
|
||||
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
|
||||
@@ -57,7 +55,6 @@ object AppConfig {
|
||||
const val PREF_IPV6_ENABLED = "pref_ipv6_enabled"
|
||||
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
|
||||
const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
|
||||
const val PREF_ALLOW_INSECURE = "pref_allow_insecure"
|
||||
const val PREF_ENABLE_LOCAL_PROXY = "pref_enable_local_proxy"
|
||||
const val PREF_SOCKS_PORT = "pref_socks_port"
|
||||
const val PREF_DYNAMIC_SOCKS_PORT = "pref_dynamic_socks_port"
|
||||
@@ -80,6 +77,7 @@ object AppConfig {
|
||||
const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout_v2"
|
||||
const val PREF_AUTO_REMOVE_INVALID_AFTER_TEST = "pref_auto_remove_invalid_after_test"
|
||||
const val PREF_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
|
||||
const val PREF_REAL_PING_CONCURRENCY = "pref_real_ping_concurrency"
|
||||
|
||||
/** Cache keys. */
|
||||
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
|
||||
@@ -106,7 +104,8 @@ object AppConfig {
|
||||
const val TAG_FRAGMENT = "fragment"
|
||||
const val TAG_DNS = "dns-module"
|
||||
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. */
|
||||
const val UPLINK = "uplink"
|
||||
@@ -166,23 +165,23 @@ object AppConfig {
|
||||
const val MSG_STATE_RESTART = 5
|
||||
const val MSG_MEASURE_DELAY = 6
|
||||
const val MSG_MEASURE_DELAY_SUCCESS = 61
|
||||
const val MSG_MEASURE_CONFIG = 7
|
||||
const val MSG_MEASURE_CONFIG_SUCCESS = 71
|
||||
const val MSG_MEASURE_CONFIG_CANCEL = 72
|
||||
const val MSG_MEASURE_CONFIG_START = 7
|
||||
const val MSG_MEASURE_CONFIG_CANCEL = 71
|
||||
const val MSG_MEASURE_CONFIG_SUCCESS = 72
|
||||
const val MSG_MEASURE_CONFIG_NOTIFY = 73
|
||||
const val MSG_MEASURE_CONFIG_FINISH = 74
|
||||
|
||||
/** Notification channel IDs and names. */
|
||||
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
|
||||
const val RAY_NG_CHANNEL_NAME = "v2rayNG Background Service"
|
||||
const val SUBSCRIPTION_UPDATE_CHANNEL = "subscription_update_channel"
|
||||
const val SUBSCRIPTION_UPDATE_CHANNEL_NAME = "Subscription Update Service"
|
||||
|
||||
/** Protocols Scheme **/
|
||||
const val VMESS = "vmess://"
|
||||
const val CUSTOM = ""
|
||||
const val SHADOWSOCKS = "ss://"
|
||||
const val SOCKS = "socks://"
|
||||
const val SOCKS4 = "socks4://"
|
||||
const val SOCKS5 = "socks5://"
|
||||
const val HTTP = "http://"
|
||||
const val VLESS = "vless://"
|
||||
const val TROJAN = "trojan://"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
|
||||
interface MainAdapterListener : BaseAdapterListener {
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.content.Context
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.CoreConfigContext
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.CoreResolvedType
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
/**
|
||||
* Build runtime context from the selected profile.
|
||||
*
|
||||
* 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 {
|
||||
|
||||
/**
|
||||
* 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? {
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return null
|
||||
|
||||
// CUSTOM: return immediately — CoreConfigManager handles this path on its own.
|
||||
if (config.configType == EConfigType.CUSTOM) {
|
||||
return CoreConfigContext(context = context, guid = guid, isCustom = true)
|
||||
}
|
||||
|
||||
// Step 1: Resolve the main outbound (always tag = TAG_PROXY).
|
||||
val primaryResolvedOutbound = resolveOutbound(AppConfig.TAG_PROXY, config) ?: run {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve main outbound for '${config.remarks}'")
|
||||
return null
|
||||
}
|
||||
|
||||
// Step 2: Resolve all non-builtin routing outbound tags.
|
||||
val routingResolvedOutbounds = resolveRoutingOutbounds()
|
||||
|
||||
return CoreConfigContext(
|
||||
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 -> {
|
||||
val chainProfiles = resolveProxyChainProfiles(profile)
|
||||
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
|
||||
Pair(chainProfiles, type)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val chainProfiles = resolveProxyChainProfilesFromGroup(profile)
|
||||
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
|
||||
Pair(chainProfiles, type)
|
||||
}
|
||||
}
|
||||
|
||||
return CoreConfigContext.ResolvedOutbound(
|
||||
tag = tag,
|
||||
profile = profile,
|
||||
resolvedProfiles = resolvedProfiles,
|
||||
resolvedType = resolvedType,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
try {
|
||||
val serverList = MmkvManager.decodeAllServerList()
|
||||
return serverList
|
||||
.asSequence()
|
||||
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
|
||||
.filter { profile ->
|
||||
val subscriptionId = config.policyGroupSubscriptionId
|
||||
if (subscriptionId.isNullOrBlank()) {
|
||||
true
|
||||
} else {
|
||||
profile.subscriptionId == subscriptionId
|
||||
}
|
||||
}
|
||||
.filter { profile ->
|
||||
val filter = config.policyGroupFilter
|
||||
if (filter.isNullOrBlank()) {
|
||||
true
|
||||
} else {
|
||||
try {
|
||||
Regex(filter).containsMatchIn(profile.remarks)
|
||||
} catch (_: Exception) {
|
||||
profile.remarks.contains(filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
.filter { it.server.isNotNullEmpty() }
|
||||
.filter { Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
|
||||
.filter { !it.configType.isComplexType() }
|
||||
.toList()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve policy group profiles for '${config.remarks}'", e)
|
||||
return listOf(config)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveProxyChainProfiles(config: ProfileItem): List<ProfileItem> {
|
||||
if (config.proxyChainProfiles.isNullOrBlank()) {
|
||||
return listOf(config)
|
||||
}
|
||||
|
||||
try {
|
||||
return config.proxyChainProfiles.orEmpty().split(",")
|
||||
.asSequence()
|
||||
.mapNotNull { remark -> SettingsManager.getServerViaRemarks(remark) }
|
||||
.filter { it.server.isNotNullEmpty() }
|
||||
.filter { Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
|
||||
.filter { !it.configType.isComplexType() }
|
||||
.toList()
|
||||
.reversed()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles for '${config.remarks}'", e)
|
||||
return listOf(config)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve chain nodes from subscription neighbors in order: next, current, prev.
|
||||
*
|
||||
* When no chain is available, return a single-node result.
|
||||
*/
|
||||
private fun resolveProxyChainProfilesFromGroup(config: ProfileItem): List<ProfileItem> {
|
||||
if (config.subscriptionId.isEmpty()) {
|
||||
return listOf(config)
|
||||
}
|
||||
|
||||
try {
|
||||
val subItem = MmkvManager.decodeSubscription(config.subscriptionId) ?: return listOf(config)
|
||||
val resolved = mutableListOf<ProfileItem>()
|
||||
SettingsManager.getServerViaRemarks(subItem.nextProfile)?.let { resolved.add(it) }
|
||||
resolved.add(config)
|
||||
SettingsManager.getServerViaRemarks(subItem.prevProfile)?.let { resolved.add(it) }
|
||||
return resolved
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain from group for '${config.remarks}'", e)
|
||||
return listOf(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.content.Context
|
||||
import android.text.TextUtils
|
||||
import com.google.gson.JsonArray
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.ConfigResult
|
||||
import com.v2ray.ang.dto.CoreConfigContext
|
||||
import com.v2ray.ang.dto.V2rayConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.RulesetItem
|
||||
import com.v2ray.ang.enums.BalancerStrategyType
|
||||
import com.v2ray.ang.enums.CoreResolvedType
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.PackageUidResolver
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
object CoreConfigManager {
|
||||
private var initConfigCache: String? = null
|
||||
private var initConfigCacheWithTun: String? = null
|
||||
|
||||
//region get config function
|
||||
|
||||
/**
|
||||
* Build the runtime configuration for normal startup.
|
||||
*/
|
||||
fun getV2rayConfig(context: Context, guid: String): ConfigResult {
|
||||
try {
|
||||
val configContext = CoreConfigContextBuilder.build(context, guid)
|
||||
?: return ConfigResult(status = false, guid = guid, errorMessage = "Failed to build config context")
|
||||
if (configContext.isCustom) {
|
||||
return buildV2rayCustomConfig(configContext)
|
||||
}
|
||||
return toConfigResult(configContext, buildUnifiedConfig(configContext))
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config", e)
|
||||
return ConfigResult(
|
||||
status = false,
|
||||
guid = guid,
|
||||
errorMessage = "Failed to get V2ray config: ${e.message ?: e.javaClass.simpleName}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a lightweight configuration for latency testing.
|
||||
*
|
||||
* The core flow is reused, then non-essential sections are removed.
|
||||
*/
|
||||
fun getV2rayConfig4Speedtest(context: Context, guid: String): ConfigResult {
|
||||
try {
|
||||
val configContext = CoreConfigContextBuilder.build(context, guid)
|
||||
?: return ConfigResult(status = false, guid = guid, errorMessage = "Failed to build config context")
|
||||
if (configContext.isCustom) {
|
||||
return buildV2rayCustomConfig(configContext)
|
||||
}
|
||||
val v2rayConfig = buildUnifiedConfig(configContext)
|
||||
postProcessForSpeedtest(v2rayConfig)
|
||||
|
||||
return toConfigResult(configContext, v2rayConfig)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config for speedtest", e)
|
||||
return ConfigResult(
|
||||
status = false,
|
||||
guid = guid,
|
||||
errorMessage = "Failed to get V2ray config for speedtest: ${e.message ?: e.javaClass.simpleName}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build configuration for custom profiles.
|
||||
*/
|
||||
private fun buildV2rayCustomConfig(configContext: CoreConfigContext): ConfigResult {
|
||||
val context = configContext.context
|
||||
val raw = MmkvManager.decodeServerRaw(configContext.guid)
|
||||
?: return ConfigResult(status = false, guid = configContext.guid, errorMessage = "Custom config is empty")
|
||||
val result = ConfigResult(true, configContext.guid, raw)
|
||||
if (!needTun()) {
|
||||
return result
|
||||
}
|
||||
|
||||
val json = JsonUtil.parseString(raw)?.takeIf { it.isJsonObject }?.asJsonObject ?: return result
|
||||
|
||||
// Check whether package names need to be replaced with UIDs
|
||||
if (SettingsManager.canUseProcessRouting()) {
|
||||
val rulesJson = json.get("routing")?.takeIf { it.isJsonObject }?.asJsonObject
|
||||
?.get("rules")?.takeIf { it.isJsonArray }?.asJsonArray
|
||||
?: JsonArray()
|
||||
|
||||
for (elem in rulesJson) {
|
||||
val rule = elem.takeIf { it.isJsonObject }?.asJsonObject ?: continue
|
||||
val process = rule.get("process")?.takeIf { it.isJsonArray }?.asJsonArray ?: continue
|
||||
val packages = process.mapNotNull {
|
||||
it.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }?.asString
|
||||
}.takeIf { it.isNotEmpty() } ?: continue
|
||||
val uids = PackageUidResolver.packageNamesToUids(context, packages).takeIf { it.isNotEmpty() } ?: continue
|
||||
|
||||
rule.add("process", JsonArray().apply { uids.forEach { add(it) } })
|
||||
}
|
||||
}
|
||||
|
||||
// check if tun inbound exists
|
||||
val inboundsJson = json.get("inbounds")?.takeIf { it.isJsonArray }?.asJsonArray
|
||||
?: JsonArray().also { json.add("inbounds", it) }
|
||||
val tunNotExists = inboundsJson.none { elem ->
|
||||
elem.isJsonObject && elem.asJsonObject.get("protocol")
|
||||
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
|
||||
?.asString == "tun"
|
||||
}
|
||||
|
||||
if (tunNotExists) {
|
||||
// add tun inbound from template
|
||||
val templateConfig = initV2rayConfig(configContext)
|
||||
templateConfig.inbounds.firstOrNull { it.tag == "tun" }?.let { inboundTun ->
|
||||
inboundTun.settings?.mtu = SettingsManager.getVpnMtu()
|
||||
inboundsJson.add(JsonUtil.parseString(JsonUtil.toJson(inboundTun)))
|
||||
}
|
||||
}
|
||||
|
||||
return JsonUtil.toJsonPretty(json)?.let { ConfigResult(true, configContext.guid, it) } ?: result
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one unified configuration for every non-custom profile type.
|
||||
*
|
||||
* The analyzed outbound plan is consumed in order and converted to concrete
|
||||
* outbounds before routing, DNS, and runtime extras are assembled.
|
||||
*/
|
||||
private fun buildUnifiedConfig(configContext: CoreConfigContext): V2rayConfig {
|
||||
require(configContext.resolvedOutbounds.isNotEmpty()) { "resolvedOutbounds must not be empty for a non-CUSTOM context" }
|
||||
val primaryResolvedOutbound = configContext.resolvedOutbounds.first()
|
||||
|
||||
val v2rayConfig = initV2rayConfig(configContext)
|
||||
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
|
||||
v2rayConfig.remarks = primaryResolvedOutbound.profile.remarks
|
||||
|
||||
configureInbounds(v2rayConfig)
|
||||
|
||||
if (v2rayConfig.outbounds.isNotEmpty()) {
|
||||
v2rayConfig.outbounds.removeAt(0)
|
||||
}
|
||||
val existingTags = v2rayConfig.outbounds.mapTo(mutableSetOf()) { it.tag }
|
||||
val policyGroupBalancerTags = mutableMapOf<String, String>()
|
||||
val balancerStrategies = mutableListOf<BalancerStrategy>()
|
||||
|
||||
// resolvedOutbounds is a single ordered plan: index 0 is primary and must be prepended,
|
||||
// the rest are routing outbounds and can be appended.
|
||||
configContext.resolvedOutbounds.forEachIndexed { index, spec ->
|
||||
buildOutbounds(
|
||||
resolvedOutbound = spec,
|
||||
prepend = index == 0,
|
||||
existingTags = existingTags,
|
||||
v2rayConfig = v2rayConfig,
|
||||
policyGroupBalancerTags = policyGroupBalancerTags,
|
||||
balancerStrategies = balancerStrategies,
|
||||
)
|
||||
}
|
||||
|
||||
// User routing rules (policyGroupBalancerTags rewrites TAG_PROXY→balancer when main is POLICYGROUP).
|
||||
configureRouting(configContext, v2rayConfig, policyGroupBalancerTags)
|
||||
configureFakeDns(v2rayConfig)
|
||||
configureDns(v2rayConfig, policyGroupBalancerTags)
|
||||
configureLocalDns(v2rayConfig)
|
||||
|
||||
// (added by getDns / getCustomLocalDns) to use the balancer, then add
|
||||
// the catch-all balancer rule.
|
||||
if (primaryResolvedOutbound.resolvedType == CoreResolvedType.POLICYGROUP) {
|
||||
if (v2rayConfig.routing.domainStrategy == "IPIfNonMatch") {
|
||||
v2rayConfig.routing.rules.add(
|
||||
V2rayConfig.RoutingBean.RulesBean(
|
||||
ip = arrayListOf("0.0.0.0/0", "::/0"),
|
||||
balancerTag = AppConfig.TAG_BALANCER,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
v2rayConfig.routing.rules.add(
|
||||
V2rayConfig.RoutingBean.RulesBean(
|
||||
network = "tcp,udp",
|
||||
balancerTag = AppConfig.TAG_BALANCER,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
applyObservability(v2rayConfig, balancerStrategies)
|
||||
applySpeedDisabled(v2rayConfig)
|
||||
resolveOutboundDomainsToHosts(v2rayConfig)
|
||||
|
||||
return v2rayConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one analyzed outbound entry into concrete outbounds and register
|
||||
* them to the runtime configuration.
|
||||
*/
|
||||
private fun buildOutbounds(
|
||||
resolvedOutbound: CoreConfigContext.ResolvedOutbound,
|
||||
prepend: Boolean,
|
||||
existingTags: MutableSet<String>,
|
||||
v2rayConfig: V2rayConfig,
|
||||
policyGroupBalancerTags: MutableMap<String, String>,
|
||||
balancerStrategies: MutableList<BalancerStrategy>,
|
||||
) {
|
||||
if (resolvedOutbound.tag in existingTags) {
|
||||
LogUtil.w(AppConfig.TAG, "Resolved outbound tag '${resolvedOutbound.tag}' already exists, skipping duplicated entry")
|
||||
return
|
||||
}
|
||||
|
||||
when (resolvedOutbound.resolvedType) {
|
||||
CoreResolvedType.NORMAL -> handleNormalResolvedOutbound(
|
||||
resolvedOutbound = resolvedOutbound,
|
||||
prepend = prepend,
|
||||
existingTags = existingTags,
|
||||
v2rayConfig = v2rayConfig,
|
||||
)
|
||||
|
||||
CoreResolvedType.PROXYCHAIN -> handleProxyChainResolvedOutbound(
|
||||
resolvedOutbound = resolvedOutbound,
|
||||
prepend = prepend,
|
||||
existingTags = existingTags,
|
||||
v2rayConfig = v2rayConfig,
|
||||
)
|
||||
|
||||
CoreResolvedType.POLICYGROUP -> handlePolicyGroupResolvedOutbound(
|
||||
resolvedOutbound = resolvedOutbound,
|
||||
prepend = prepend,
|
||||
existingTags = existingTags,
|
||||
v2rayConfig = v2rayConfig,
|
||||
policyGroupBalancerTags = policyGroupBalancerTags,
|
||||
balancerStrategies = balancerStrategies,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and insert a single-node outbound entry.
|
||||
*/
|
||||
private fun handleNormalResolvedOutbound(
|
||||
resolvedOutbound: CoreConfigContext.ResolvedOutbound,
|
||||
prepend: Boolean,
|
||||
existingTags: MutableSet<String>,
|
||||
v2rayConfig: V2rayConfig,
|
||||
) {
|
||||
val profile = resolvedOutbound.resolvedProfiles.firstOrNull() ?: run {
|
||||
LogUtil.w(AppConfig.TAG, "NORMAL resolved outbound '${resolvedOutbound.tag}' has empty resolvedProfiles, skipping")
|
||||
return
|
||||
}
|
||||
val outbound = convertProfile2Outbound(profile) ?: run {
|
||||
LogUtil.w(AppConfig.TAG, "Could not convert NORMAL resolved outbound '${resolvedOutbound.tag}' profile to outbound, skipping")
|
||||
return
|
||||
}
|
||||
outbound.tag = resolvedOutbound.tag
|
||||
if (prepend) {
|
||||
v2rayConfig.outbounds.add(0, outbound)
|
||||
} else {
|
||||
v2rayConfig.outbounds.add(outbound)
|
||||
}
|
||||
existingTags.add(resolvedOutbound.tag)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and insert a multi-hop chain entry.
|
||||
*/
|
||||
private fun handleProxyChainResolvedOutbound(
|
||||
resolvedOutbound: CoreConfigContext.ResolvedOutbound,
|
||||
prepend: Boolean,
|
||||
existingTags: MutableSet<String>,
|
||||
v2rayConfig: V2rayConfig,
|
||||
) {
|
||||
val chainOutbounds = resolvedOutbound.resolvedProfiles
|
||||
.mapNotNull { convertProfile2Outbound(it) }
|
||||
.toMutableList()
|
||||
if (chainOutbounds.isEmpty()) {
|
||||
LogUtil.w(AppConfig.TAG, "PROXYCHAIN resolved outbound '${resolvedOutbound.tag}' has no valid profiles, skipping")
|
||||
return
|
||||
}
|
||||
if (chainOutbounds.size == 1) {
|
||||
val outbound = chainOutbounds.first()
|
||||
outbound.tag = resolvedOutbound.tag
|
||||
if (prepend) {
|
||||
v2rayConfig.outbounds.add(0, outbound)
|
||||
} else {
|
||||
v2rayConfig.outbounds.add(outbound)
|
||||
}
|
||||
existingTags.add(resolvedOutbound.tag)
|
||||
return
|
||||
}
|
||||
|
||||
val chainTags = chainOutbounds.mapIndexed { index, _ ->
|
||||
if (index == 0) {
|
||||
resolvedOutbound.tag
|
||||
} else {
|
||||
"${AppConfig.TAG_PROXY}-${resolvedOutbound.tag}-$index"
|
||||
}
|
||||
}
|
||||
if (chainTags.any { it in existingTags }) {
|
||||
LogUtil.w(
|
||||
AppConfig.TAG,
|
||||
"PROXYCHAIN resolved outbound '${resolvedOutbound.tag}' has colliding hop tags, skipping"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
chainOutbounds.forEachIndexed { index, outbound ->
|
||||
outbound.tag = chainTags[index]
|
||||
}
|
||||
for (i in 0 until chainOutbounds.size - 1) {
|
||||
chainOutbounds[i].ensureSockopt().dialerProxy = chainOutbounds[i + 1].tag
|
||||
}
|
||||
|
||||
if (prepend) {
|
||||
v2rayConfig.outbounds.addAll(0, chainOutbounds)
|
||||
} else {
|
||||
v2rayConfig.outbounds.addAll(chainOutbounds)
|
||||
}
|
||||
chainOutbounds.forEach { existingTags.add(it.tag) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and insert a policy-group entry and its balancer metadata.
|
||||
*/
|
||||
private fun handlePolicyGroupResolvedOutbound(
|
||||
resolvedOutbound: CoreConfigContext.ResolvedOutbound,
|
||||
prepend: Boolean,
|
||||
existingTags: MutableSet<String>,
|
||||
v2rayConfig: V2rayConfig,
|
||||
policyGroupBalancerTags: MutableMap<String, String>,
|
||||
balancerStrategies: MutableList<BalancerStrategy>,
|
||||
) {
|
||||
val memberPairs = resolvedOutbound.resolvedProfiles.mapNotNull { profile ->
|
||||
convertProfile2Outbound(profile)?.let { ob -> ob to profile }
|
||||
}
|
||||
if (memberPairs.isEmpty()) {
|
||||
LogUtil.w(AppConfig.TAG, "POLICYGROUP resolved outbound '${resolvedOutbound.tag}' has no valid member outbounds, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
val memberTagPrefix = "${AppConfig.TAG_PROXY}-${resolvedOutbound.tag}-"
|
||||
val membersToAdd = mutableListOf<V2rayConfig.OutboundBean>()
|
||||
memberPairs.forEachIndexed { index, (outbound, profile) ->
|
||||
val memberTag = "$memberTagPrefix${index + 1}-${profile.remarks.trim()}"
|
||||
if (memberTag in existingTags) {
|
||||
return@forEachIndexed
|
||||
}
|
||||
outbound.tag = memberTag
|
||||
membersToAdd.add(outbound)
|
||||
existingTags.add(memberTag)
|
||||
}
|
||||
|
||||
if (membersToAdd.isEmpty()) {
|
||||
LogUtil.w(
|
||||
AppConfig.TAG,
|
||||
"POLICYGROUP resolved outbound '${resolvedOutbound.tag}' produced no unique member tags, skipping"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (prepend) {
|
||||
v2rayConfig.outbounds.addAll(0, membersToAdd)
|
||||
} else {
|
||||
v2rayConfig.outbounds.addAll(membersToAdd)
|
||||
}
|
||||
|
||||
val balancerTag = if (resolvedOutbound.tag == AppConfig.TAG_PROXY) {
|
||||
AppConfig.TAG_BALANCER
|
||||
} else {
|
||||
"${AppConfig.TAG_BALANCER_PRE}-${resolvedOutbound.tag}"
|
||||
}
|
||||
val strategy = buildBalancerStrategy(
|
||||
policyGroupType = resolvedOutbound.profile.policyGroupType,
|
||||
selector = listOf(memberTagPrefix),
|
||||
balancerTag = balancerTag,
|
||||
)
|
||||
val existingBalancers = v2rayConfig.routing.balancers?.toMutableList() ?: mutableListOf()
|
||||
if (existingBalancers.none { it.tag == balancerTag }) {
|
||||
existingBalancers.add(strategy.balancer)
|
||||
v2rayConfig.routing.balancers = existingBalancers
|
||||
}
|
||||
balancerStrategies.add(strategy)
|
||||
policyGroupBalancerTags[resolvedOutbound.tag] = balancerTag
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim runtime sections that are not needed for latency testing.
|
||||
*/
|
||||
private fun postProcessForSpeedtest(v2rayConfig: V2rayConfig) {
|
||||
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
|
||||
v2rayConfig.inbounds.clear()
|
||||
v2rayConfig.routing.rules.clear()
|
||||
v2rayConfig.dns = null
|
||||
v2rayConfig.fakedns = null
|
||||
v2rayConfig.stats = null
|
||||
v2rayConfig.policy = null
|
||||
v2rayConfig.outbounds.forEach { key -> key.mux = null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a runtime configuration into a standard result object.
|
||||
*/
|
||||
private fun toConfigResult(configContext: CoreConfigContext, v2rayConfig: V2rayConfig): ConfigResult {
|
||||
return ConfigResult(
|
||||
status = true,
|
||||
guid = configContext.guid,
|
||||
content = JsonUtil.toJsonPretty(v2rayConfig) ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the base template from cache or assets and parse it.
|
||||
*/
|
||||
private fun initV2rayConfig(configContext: CoreConfigContext): V2rayConfig {
|
||||
val context = configContext.context
|
||||
val assets: String
|
||||
if (needTun()) {
|
||||
assets = initConfigCacheWithTun ?: Utils.readTextFromAssets(context, "v2ray_config_with_tun.json")
|
||||
if (TextUtils.isEmpty(assets)) {
|
||||
error("Missing asset: v2ray_config_with_tun.json")
|
||||
}
|
||||
initConfigCacheWithTun = assets
|
||||
} else {
|
||||
assets = initConfigCache ?: Utils.readTextFromAssets(context, "v2ray_config.json")
|
||||
if (TextUtils.isEmpty(assets)) {
|
||||
error("Missing asset: v2ray_config.json")
|
||||
}
|
||||
initConfigCache = assets
|
||||
}
|
||||
return JsonUtil.fromJson(assets, V2rayConfig::class.java)
|
||||
?: error("Failed to parse config template")
|
||||
}
|
||||
|
||||
|
||||
//endregion
|
||||
|
||||
|
||||
//region some sub function
|
||||
|
||||
private fun needTun(): Boolean {
|
||||
return SettingsManager.isVpnMode() && !SettingsManager.isUsingHevTun()
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure inbound listeners and related runtime options.
|
||||
*/
|
||||
private fun configureInbounds(v2rayConfig: V2rayConfig) {
|
||||
val vpn = SettingsManager.isVpnMode()
|
||||
val useHev = SettingsManager.isUsingHevTun()
|
||||
val forcedByHev = vpn && useHev
|
||||
|
||||
val enableLocalProxy = forcedByHev || MmkvManager.decodeSettingsBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true)
|
||||
|
||||
val socksPort = SettingsManager.getSocksPort()
|
||||
val socksUsername = SettingsManager.getSocksUsername()
|
||||
val socksPassword = SettingsManager.getSocksPassword()
|
||||
val inbound1 = v2rayConfig.inbounds[0]
|
||||
if (inbound1.settings == null) {
|
||||
inbound1.settings = V2rayConfig.InboundBean.InSettingsBean()
|
||||
}
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING) != true) {
|
||||
inbound1.listen = AppConfig.LOOPBACK
|
||||
}
|
||||
inbound1.port = socksPort
|
||||
inbound1.settings?.udp = MmkvManager.decodeSettingsBool(AppConfig.PREF_SOCKS_ENABLE_UDP, true)
|
||||
if (socksUsername != null && socksPassword != null) {
|
||||
inbound1.settings?.auth = "password"
|
||||
inbound1.settings?.accounts = listOf(
|
||||
V2rayConfig.InboundBean.InSettingsBean.SocksAccountBean(
|
||||
user = socksUsername,
|
||||
pass = socksPassword
|
||||
)
|
||||
)
|
||||
} else {
|
||||
inbound1.settings?.auth = "noauth"
|
||||
inbound1.settings?.accounts = null
|
||||
}
|
||||
val fakedns = MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
|
||||
val sniffAllTlsAndHttp =
|
||||
MmkvManager.decodeSettingsBool(AppConfig.PREF_SNIFFING_ENABLED, true) != false
|
||||
inbound1.sniffing?.enabled = fakedns || sniffAllTlsAndHttp
|
||||
inbound1.sniffing?.routeOnly =
|
||||
MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false)
|
||||
if (!sniffAllTlsAndHttp) {
|
||||
inbound1.sniffing?.destOverride?.clear()
|
||||
}
|
||||
if (fakedns) {
|
||||
inbound1.sniffing?.destOverride?.add("fakedns")
|
||||
}
|
||||
|
||||
if (!Utils.isXray()) {
|
||||
val inbound2 = JsonUtil.fromJson(JsonUtil.toJson(inbound1), V2rayConfig.InboundBean::class.java)
|
||||
?: error("Failed to clone inbound template")
|
||||
inbound2.tag = EConfigType.HTTP.name.lowercase()
|
||||
inbound2.port = SettingsManager.getHttpPort()
|
||||
inbound2.protocol = EConfigType.HTTP.name.lowercase()
|
||||
inbound2.settings?.auth = null
|
||||
inbound2.settings?.udp = null
|
||||
v2rayConfig.inbounds.add(inbound2)
|
||||
}
|
||||
|
||||
if (!enableLocalProxy) {
|
||||
v2rayConfig.inbounds.removeIf { it.protocol == "socks" || it.protocol == "http" }
|
||||
}
|
||||
|
||||
if (needTun()) {
|
||||
val inboundTun = v2rayConfig.inbounds.firstOrNull { e -> e.tag == "tun" }
|
||||
inboundTun?.settings?.mtu = SettingsManager.getVpnMtu()
|
||||
inboundTun?.sniffing = inbound1.sniffing
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable fake DNS when local DNS and fake DNS are both enabled.
|
||||
*/
|
||||
private fun configureFakeDns(v2rayConfig: V2rayConfig) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true
|
||||
&& MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
|
||||
) {
|
||||
v2rayConfig.fakedns = listOf(V2rayConfig.FakednsBean())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect domain rules that target one outbound tag.
|
||||
*/
|
||||
private fun collectUserRuleDomainsByTag(tag: String): ArrayList<String> {
|
||||
val domain = ArrayList<String>()
|
||||
|
||||
val rulesetItems = MmkvManager.decodeRoutingRulesets()
|
||||
rulesetItems?.forEach { key ->
|
||||
if (key.enabled && key.outboundTag == tag && !key.domain.isNullOrEmpty()) {
|
||||
key.domain?.forEach {
|
||||
domain.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return domain
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect domain rules that target non-builtin outbound tags.
|
||||
*/
|
||||
private fun collectCustomOutboundDomains(): ArrayList<String> {
|
||||
val domain = ArrayList<String>()
|
||||
|
||||
val rulesetItems = MmkvManager.decodeRoutingRulesets()
|
||||
rulesetItems?.forEach { key ->
|
||||
if (key.enabled && !AppConfig.BUILTIN_OUTBOUND_TAGS.contains(key.outboundTag)
|
||||
&& !key.domain.isNullOrEmpty()
|
||||
) {
|
||||
key.domain?.forEach {
|
||||
domain.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return domain
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure local DNS inbounds, outbounds, and routing rules.
|
||||
*/
|
||||
private fun configureLocalDns(v2rayConfig: V2rayConfig) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) != true) {
|
||||
return
|
||||
}
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true) {
|
||||
val geositeCn = arrayListOf(AppConfig.GEOSITE_CN)
|
||||
val proxyDomain = collectUserRuleDomainsByTag(AppConfig.TAG_PROXY)
|
||||
val directDomain = collectUserRuleDomainsByTag(AppConfig.TAG_DIRECT)
|
||||
val finalDomain = geositeCn.plus(proxyDomain).plus(directDomain).distinct()
|
||||
// fakedns with all domains to make it always top priority
|
||||
v2rayConfig.dns?.servers?.add(
|
||||
0,
|
||||
V2rayConfig.DnsBean.ServersBean(
|
||||
address = "fakedns",
|
||||
domains = finalDomain
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (SettingsManager.isVpnMode()) {
|
||||
if (SettingsManager.isUsingHevTun()) {
|
||||
//hev-socks5-tunnel dns routing
|
||||
v2rayConfig.routing.rules.add(
|
||||
0, V2rayConfig.RoutingBean.RulesBean(
|
||||
inboundTag = arrayListOf("socks"),
|
||||
outboundTag = "dns-out",
|
||||
port = "53",
|
||||
)
|
||||
)
|
||||
} else {
|
||||
v2rayConfig.routing.rules.add(
|
||||
0, V2rayConfig.RoutingBean.RulesBean(
|
||||
inboundTag = arrayListOf("tun"),
|
||||
outboundTag = "dns-out",
|
||||
port = "53",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DNS outbound
|
||||
if (v2rayConfig.outbounds.none { e -> e.protocol == "dns" && e.tag == "dns-out" }) {
|
||||
v2rayConfig.outbounds.add(
|
||||
V2rayConfig.OutboundBean(
|
||||
protocol = "dns",
|
||||
tag = "dns-out",
|
||||
settings = null,
|
||||
streamSettings = null,
|
||||
mux = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove speed-test runtime sections when the feature is disabled.
|
||||
*/
|
||||
private fun applySpeedDisabled(v2rayConfig: V2rayConfig) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
|
||||
v2rayConfig.stats = null
|
||||
v2rayConfig.policy = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure DNS servers, hosts, and DNS routing rules.
|
||||
*/
|
||||
private fun configureDns(
|
||||
v2rayConfig: V2rayConfig,
|
||||
policyGroupBalancerTags: Map<String, String>,
|
||||
) {
|
||||
val hosts = mutableMapOf<String, Any>()
|
||||
val servers = ArrayList<Any>()
|
||||
|
||||
//remote Dns
|
||||
val remoteDns = SettingsManager.getRemoteDnsServers()
|
||||
val proxyDomain = (collectUserRuleDomainsByTag(AppConfig.TAG_PROXY) + collectCustomOutboundDomains()).distinct()
|
||||
remoteDns.forEach {
|
||||
servers.add(it)
|
||||
}
|
||||
if (proxyDomain.isNotEmpty()) {
|
||||
servers.add(
|
||||
V2rayConfig.DnsBean.ServersBean(
|
||||
address = remoteDns.first(),
|
||||
domains = proxyDomain,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// domestic DNS
|
||||
val domesticDns = SettingsManager.getDomesticDnsServers()
|
||||
val directDomain = collectUserRuleDomainsByTag(AppConfig.TAG_DIRECT)
|
||||
val isCnRoutingMode = directDomain.contains(AppConfig.GEOSITE_CN)
|
||||
val cnRegionFilter = { domain: String ->
|
||||
domain.startsWith("geosite:") && (domain.endsWith("-cn") || domain.endsWith("@cn"))
|
||||
|| domain == AppConfig.GEOSITE_CN
|
||||
}
|
||||
val finalDirectDomain = if (isCnRoutingMode) directDomain.filterNot {
|
||||
cnRegionFilter(it)
|
||||
} else directDomain
|
||||
val domesticDnsTags = mutableListOf<String>()
|
||||
domesticDns.forEachIndexed { index, element ->
|
||||
val tag = AppConfig.TAG_DOMESTIC_DNS + index
|
||||
servers.add(
|
||||
V2rayConfig.DnsBean.ServersBean(
|
||||
address = element,
|
||||
domains = finalDirectDomain,
|
||||
skipFallback = true,
|
||||
tag = tag
|
||||
)
|
||||
)
|
||||
domesticDnsTags.add(tag)
|
||||
}
|
||||
if (isCnRoutingMode) {
|
||||
val geoipCn = arrayListOf(AppConfig.GEOIP_CN)
|
||||
val cnRegionDomain = directDomain.filter { cnRegionFilter(it) }
|
||||
domesticDns.forEachIndexed { index, element ->
|
||||
val geositeCnDnsTag = AppConfig.TAG_DOMESTIC_DNS + index + "_cn_expect"
|
||||
servers.add(
|
||||
V2rayConfig.DnsBean.ServersBean(
|
||||
address = element,
|
||||
domains = cnRegionDomain,
|
||||
expectIPs = geoipCn,
|
||||
skipFallback = true,
|
||||
tag = geositeCnDnsTag
|
||||
)
|
||||
)
|
||||
domesticDnsTags.add(geositeCnDnsTag)
|
||||
}
|
||||
}
|
||||
|
||||
//block dns
|
||||
val blkDomain = collectUserRuleDomainsByTag(AppConfig.TAG_BLOCKED)
|
||||
if (blkDomain.isNotEmpty()) {
|
||||
hosts.putAll(blkDomain.map { it to AppConfig.LOOPBACK })
|
||||
}
|
||||
|
||||
// hardcode googleapi rule to fix play store problems
|
||||
hosts[AppConfig.GOOGLEAPIS_CN_DOMAIN] = AppConfig.GOOGLEAPIS_COM_DOMAIN
|
||||
|
||||
// hardcode popular Android Private DNS rule to fix localhost DNS problem
|
||||
hosts[AppConfig.DNS_ALIDNS_DOMAIN] = AppConfig.DNS_ALIDNS_ADDRESSES
|
||||
hosts[AppConfig.DNS_CLOUDFLARE_ONE_DOMAIN] = AppConfig.DNS_CLOUDFLARE_ONE_ADDRESSES
|
||||
hosts[AppConfig.DNS_CLOUDFLARE_DNS_COM_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_COM_ADDRESSES
|
||||
hosts[AppConfig.DNS_CLOUDFLARE_DNS_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_ADDRESSES
|
||||
hosts[AppConfig.DNS_DNSPOD_DOMAIN] = AppConfig.DNS_DNSPOD_ADDRESSES
|
||||
hosts[AppConfig.DNS_GOOGLE_DOMAIN] = AppConfig.DNS_GOOGLE_ADDRESSES
|
||||
hosts[AppConfig.DNS_QUAD9_DOMAIN] = AppConfig.DNS_QUAD9_ADDRESSES
|
||||
hosts[AppConfig.DNS_YANDEX_DOMAIN] = AppConfig.DNS_YANDEX_ADDRESSES
|
||||
|
||||
//User DNS hosts
|
||||
val userHosts = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
|
||||
if (userHosts.isNotNullEmpty()) {
|
||||
val userHostsMap = userHosts?.split(",")
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.filter { it.contains(":") }
|
||||
?.associate { it.split(":").let { (k, v) -> k to v } }
|
||||
if (userHostsMap != null) {
|
||||
hosts.putAll(userHostsMap)
|
||||
}
|
||||
}
|
||||
|
||||
// DNS dns
|
||||
v2rayConfig.dns = V2rayConfig.DnsBean(
|
||||
servers = servers,
|
||||
hosts = hosts,
|
||||
tag = AppConfig.TAG_DNS,
|
||||
enableParallelQuery = if ((domesticDns.size + remoteDns.size) > 2) true else null
|
||||
)
|
||||
|
||||
// DNS routing
|
||||
v2rayConfig.routing.rules.add(
|
||||
V2rayConfig.RoutingBean.RulesBean(
|
||||
outboundTag = AppConfig.TAG_DIRECT,
|
||||
inboundTag = domesticDnsTags,
|
||||
domain = null
|
||||
)
|
||||
)
|
||||
val dnsProxyBalancerTag = policyGroupBalancerTags[AppConfig.TAG_PROXY]
|
||||
if (dnsProxyBalancerTag != null) {
|
||||
v2rayConfig.routing.rules.add(
|
||||
V2rayConfig.RoutingBean.RulesBean(
|
||||
balancerTag = dnsProxyBalancerTag,
|
||||
inboundTag = arrayListOf(AppConfig.TAG_DNS),
|
||||
domain = null
|
||||
)
|
||||
)
|
||||
} else {
|
||||
v2rayConfig.routing.rules.add(
|
||||
V2rayConfig.RoutingBean.RulesBean(
|
||||
outboundTag = AppConfig.TAG_PROXY,
|
||||
inboundTag = arrayListOf(AppConfig.TAG_DNS),
|
||||
domain = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//endregion
|
||||
|
||||
|
||||
//region outbound related functions
|
||||
|
||||
|
||||
/**
|
||||
* Resolve outbound domains to IPs and write resolved hosts to DNS map.
|
||||
*/
|
||||
private fun resolveOutboundDomainsToHosts(v2rayConfig: V2rayConfig) {
|
||||
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "1") {
|
||||
return
|
||||
}
|
||||
|
||||
val proxyOutboundList = v2rayConfig.getAllProxyOutbound()
|
||||
val dns = v2rayConfig.dns ?: return
|
||||
val newHosts = dns.hosts?.toMutableMap() ?: mutableMapOf()
|
||||
val preferIpv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true
|
||||
|
||||
for (item in proxyOutboundList) {
|
||||
val domain = item.getServerAddress()
|
||||
if (domain.isNullOrEmpty()) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (newHosts.containsKey(domain)) {
|
||||
item.ensureSockopt().domainStrategy = "UseIP"
|
||||
item.ensureSockopt().happyEyeballs = V2rayConfig.OutboundBean.StreamSettingsBean.HappyEyeballsBean(
|
||||
prioritizeIPv6 = preferIpv6,
|
||||
interleave = 2
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
val resolvedIps = HttpUtil.resolveHostToIP(domain, preferIpv6)
|
||||
if (resolvedIps.isNullOrEmpty()) {
|
||||
continue
|
||||
}
|
||||
|
||||
item.ensureSockopt().domainStrategy = "UseIP"
|
||||
item.ensureSockopt().happyEyeballs = V2rayConfig.OutboundBean.StreamSettingsBean.HappyEyeballsBean(
|
||||
prioritizeIPv6 = preferIpv6,
|
||||
interleave = 2
|
||||
)
|
||||
newHosts[domain] = if (resolvedIps.size == 1) {
|
||||
resolvedIps[0]
|
||||
} else {
|
||||
resolvedIps
|
||||
}
|
||||
}
|
||||
|
||||
dns.hosts = newHosts
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one profile object into one outbound object.
|
||||
*/
|
||||
private fun convertProfile2Outbound(profileItem: ProfileItem): V2rayConfig.OutboundBean? {
|
||||
return CoreOutboundBuilder.convert(profileItem)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
|
||||
//region routing related functions
|
||||
|
||||
|
||||
/**
|
||||
* Merge probe settings from all balancer strategies into the runtime config.
|
||||
*/
|
||||
private fun applyObservability(v2rayConfig: V2rayConfig, strategies: List<BalancerStrategy>) {
|
||||
val allObsSelectors = strategies
|
||||
.mapNotNull { it.observatory?.subjectSelector }
|
||||
.flatten()
|
||||
.distinct()
|
||||
val obsTemplate = strategies.firstNotNullOfOrNull { it.observatory }
|
||||
if (obsTemplate != null && allObsSelectors.isNotEmpty()) {
|
||||
v2rayConfig.observatory = V2rayConfig.ObservatoryObject(
|
||||
subjectSelector = allObsSelectors,
|
||||
probeUrl = obsTemplate.probeUrl,
|
||||
probeInterval = obsTemplate.probeInterval,
|
||||
enableConcurrency = obsTemplate.enableConcurrency
|
||||
)
|
||||
}
|
||||
|
||||
val allBurstSelectors = strategies
|
||||
.mapNotNull { it.burstObservatory?.subjectSelector }
|
||||
.flatten()
|
||||
.distinct()
|
||||
val burstTemplate = strategies.firstNotNullOfOrNull { it.burstObservatory }
|
||||
if (burstTemplate != null && allBurstSelectors.isNotEmpty()) {
|
||||
v2rayConfig.burstObservatory = V2rayConfig.BurstObservatoryObject(
|
||||
subjectSelector = allBurstSelectors,
|
||||
pingConfig = burstTemplate.pingConfig
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure routing domain strategy and append enabled user rules.
|
||||
*/
|
||||
private fun configureRouting(
|
||||
configContext: CoreConfigContext,
|
||||
v2rayConfig: V2rayConfig,
|
||||
policyGroupBalancerTags: Map<String, String>
|
||||
) {
|
||||
|
||||
v2rayConfig.routing.domainStrategy =
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY)
|
||||
?: "AsIs"
|
||||
|
||||
val rulesetItems = MmkvManager.decodeRoutingRulesets()
|
||||
rulesetItems?.forEach { key ->
|
||||
appendRoutingUserRule(configContext, key, v2rayConfig, policyGroupBalancerTags)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one rule item and append it to routing rules.
|
||||
*/
|
||||
private fun appendRoutingUserRule(
|
||||
configContext: CoreConfigContext,
|
||||
item: RulesetItem?,
|
||||
v2rayConfig: V2rayConfig,
|
||||
policyGroupBalancerTags: Map<String, String>
|
||||
) {
|
||||
val context = configContext.context
|
||||
if (item == null || !item.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
val rule = JsonUtil.fromJson(JsonUtil.toJson(item), V2rayConfig.RoutingBean.RulesBean::class.java) ?: return
|
||||
|
||||
// Replace specific geoip rules with ext versions
|
||||
rule.ip?.let { ipList ->
|
||||
val updatedIpList = ArrayList<String>()
|
||||
ipList.forEach { ip ->
|
||||
when (ip) {
|
||||
AppConfig.GEOIP_CN -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:cn")
|
||||
AppConfig.GEOIP_PRIVATE -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:private")
|
||||
else -> updatedIpList.add(ip)
|
||||
}
|
||||
}
|
||||
rule.ip = updatedIpList
|
||||
}
|
||||
|
||||
if (SettingsManager.canUseProcessRouting()) {
|
||||
// Convert process package names to UIDs
|
||||
rule.process?.let { processList ->
|
||||
if (processList.isNotEmpty()) {
|
||||
val uids = PackageUidResolver.packageNamesToUids(context, processList)
|
||||
rule.process = uids.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rule.process = null
|
||||
}
|
||||
|
||||
val outboundTag = rule.outboundTag
|
||||
|
||||
// Route rules targeting a custom policy-group tag should hit its balancer.
|
||||
policyGroupBalancerTags[outboundTag]?.let { balancerTag ->
|
||||
rule.outboundTag = null
|
||||
rule.balancerTag = balancerTag
|
||||
}
|
||||
|
||||
// If the outbound tag is a custom one that failed to inject, fall back to proxy
|
||||
if (!outboundTag.isNullOrBlank()
|
||||
&& outboundTag !in policyGroupBalancerTags
|
||||
&& outboundTag !in AppConfig.BUILTIN_OUTBOUND_TAGS
|
||||
&& v2rayConfig.outbounds.none { it.tag == outboundTag }
|
||||
) {
|
||||
LogUtil.w(AppConfig.TAG, "Outbound tag '$outboundTag' not found, falling back to '${AppConfig.TAG_PROXY}'")
|
||||
rule.outboundTag = AppConfig.TAG_PROXY
|
||||
}
|
||||
|
||||
v2rayConfig.routing.rules.add(rule)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build balancer and probe settings from one policy-group strategy value.
|
||||
*/
|
||||
private fun buildBalancerStrategy(
|
||||
policyGroupType: String?,
|
||||
selector: List<String>,
|
||||
balancerTag: String = AppConfig.TAG_BALANCER,
|
||||
): BalancerStrategy {
|
||||
val probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL
|
||||
val strategyType = BalancerStrategyType.from(policyGroupType)
|
||||
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
||||
tag = balancerTag,
|
||||
selector = selector,
|
||||
strategy = V2rayConfig.RoutingBean.StrategyObject(type = strategyType.policyGroupType)
|
||||
)
|
||||
val observatory = if (strategyType.requiresObservatory) {
|
||||
V2rayConfig.ObservatoryObject(
|
||||
subjectSelector = selector,
|
||||
probeUrl = probeUrl,
|
||||
probeInterval = "3m",
|
||||
enableConcurrency = true
|
||||
)
|
||||
} else null
|
||||
val burstObservatory = if (strategyType.requiresBurstObservatory) {
|
||||
V2rayConfig.BurstObservatoryObject(
|
||||
subjectSelector = selector,
|
||||
pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject(
|
||||
destination = probeUrl,
|
||||
interval = "5m",
|
||||
sampling = 2,
|
||||
timeout = "30s"
|
||||
)
|
||||
)
|
||||
} else null
|
||||
return BalancerStrategy(balancer, observatory, burstObservatory)
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry balancer data plus optional probe settings for later merge.
|
||||
*/
|
||||
private data class BalancerStrategy(
|
||||
val balancer: V2rayConfig.RoutingBean.BalancerBean,
|
||||
val observatory: V2rayConfig.ObservatoryObject? = null,
|
||||
val burstObservatory: V2rayConfig.BurstObservatoryObject? = null,
|
||||
)
|
||||
|
||||
//endregion
|
||||
}
|
||||
+12
-3
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.handler
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.content.Context
|
||||
import com.v2ray.ang.AppConfig
|
||||
@@ -16,7 +16,7 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
* Thread-safe singleton wrapper for Libv2ray native methods.
|
||||
* Provides initialization protection and unified API for V2Ray core operations.
|
||||
*/
|
||||
object V2RayNativeManager {
|
||||
object CoreNativeManager {
|
||||
private val initialized = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
@@ -43,6 +43,15 @@ object V2RayNativeManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun reconcileBrowserDialer(dialerAddr: String) {
|
||||
try {
|
||||
Libv2ray.reconcileBrowserDialer(dialerAddr)
|
||||
LogUtil.i(AppConfig.TAG, "Browser dialer reconciled successfully with address: $dialerAddr")
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to reconcile browser dialer with address: $dialerAddr", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get V2Ray core version.
|
||||
@@ -88,4 +97,4 @@ object V2RayNativeManager {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.v2ray.ang.AppConfig
|
||||
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.NetworkType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
/**
|
||||
* Centralizes ProfileItem -> OutboundBean conversion.
|
||||
* Most protocol builders mirror the previous *Fmt.toOutbound behavior.
|
||||
*/
|
||||
object CoreOutboundBuilder {
|
||||
|
||||
/** Dispatches a profile to protocol-specific outbound builder. */
|
||||
fun convert(profileItem: ProfileItem): OutboundBean? {
|
||||
val outbound = when (profileItem.configType) {
|
||||
EConfigType.VMESS -> toOutboundVmess(profileItem)
|
||||
EConfigType.SHADOWSOCKS -> toOutboundShadowsocks(profileItem)
|
||||
EConfigType.SOCKS -> toOutboundSocks(profileItem)
|
||||
EConfigType.VLESS -> toOutboundVless(profileItem)
|
||||
EConfigType.TROJAN -> toOutboundTrojan(profileItem)
|
||||
EConfigType.WIREGUARD -> toOutboundWireguard(profileItem)
|
||||
EConfigType.HYSTERIA2 -> toOutboundHysteria2(profileItem)
|
||||
EConfigType.HTTP -> toOutboundHttp(profileItem)
|
||||
else -> null
|
||||
}
|
||||
|
||||
outbound ?: return null
|
||||
val ret = updateOutboundWithGlobalSettings(outbound)
|
||||
if (!ret) return null
|
||||
return outbound
|
||||
}
|
||||
|
||||
/** Applies global outbound options (mux, protocol-specific tweaks, etc.). */
|
||||
private fun updateOutboundWithGlobalSettings(outbound: OutboundBean): Boolean {
|
||||
try {
|
||||
var muxEnabled = MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false)
|
||||
val protocol = outbound.protocol
|
||||
if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||
|| protocol.equals(EConfigType.WIREGUARD.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA.name, true)
|
||||
) {
|
||||
muxEnabled = false
|
||||
} else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) {
|
||||
muxEnabled = false
|
||||
}
|
||||
|
||||
if (muxEnabled) {
|
||||
outbound.mux?.enabled = true
|
||||
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
|
||||
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
|
||||
outbound.mux?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
|
||||
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.vnext?.first()?.users?.first()?.flow?.isNotEmpty() == true) {
|
||||
outbound.mux?.concurrency = -1
|
||||
}
|
||||
} else {
|
||||
outbound.mux?.enabled = false
|
||||
outbound.mux?.concurrency = -1
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to update outbound with global settings", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Creates an initial outbound template for a protocol type. */
|
||||
fun createInitOutbound(configType: EConfigType): OutboundBean? {
|
||||
return when (configType) {
|
||||
EConfigType.VMESS,
|
||||
EConfigType.VLESS ->
|
||||
return OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(
|
||||
vnext = listOf(
|
||||
OutboundBean.OutSettingsBean.VnextBean(
|
||||
users = listOf(OutboundBean.OutSettingsBean.VnextBean.UsersBean())
|
||||
)
|
||||
)
|
||||
),
|
||||
streamSettings = OutboundBean.StreamSettingsBean()
|
||||
)
|
||||
|
||||
EConfigType.SHADOWSOCKS,
|
||||
EConfigType.SOCKS,
|
||||
EConfigType.HTTP,
|
||||
EConfigType.TROJAN ->
|
||||
return OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(
|
||||
servers = listOf(OutboundBean.OutSettingsBean.ServersBean())
|
||||
),
|
||||
streamSettings = OutboundBean.StreamSettingsBean()
|
||||
)
|
||||
|
||||
EConfigType.WIREGUARD ->
|
||||
return OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(
|
||||
secretKey = "",
|
||||
peers = listOf(OutboundBean.OutSettingsBean.WireGuardBean())
|
||||
)
|
||||
)
|
||||
|
||||
EConfigType.HYSTERIA,
|
||||
EConfigType.HYSTERIA2 ->
|
||||
return OutboundBean(
|
||||
protocol = EConfigType.HYSTERIA.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(
|
||||
servers = null
|
||||
),
|
||||
streamSettings = OutboundBean.StreamSettingsBean()
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-protocol builders — implementations are identical to each *Fmt.toOutbound ──
|
||||
|
||||
private fun toOutboundVmess(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.VMESS)
|
||||
|
||||
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||
vnext.address = getServerAddress(profileItem)
|
||||
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
||||
vnext.users[0].id = profileItem.password.orEmpty()
|
||||
vnext.users[0].security = profileItem.method
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundVless(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.VLESS)
|
||||
|
||||
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||
vnext.address = getServerAddress(profileItem)
|
||||
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
||||
vnext.users[0].id = profileItem.password.orEmpty()
|
||||
vnext.users[0].encryption = profileItem.method
|
||||
vnext.users[0].flow = profileItem.flow
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundShadowsocks(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.SHADOWSOCKS)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.password = profileItem.password
|
||||
server.method = profileItem.method
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundTrojan(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.TROJAN)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.password = profileItem.password
|
||||
server.flow = profileItem.flow
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundSocks(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.SOCKS)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
if (profileItem.username.isNotNullEmpty()) {
|
||||
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
|
||||
socksUsersBean.user = profileItem.username.orEmpty()
|
||||
socksUsersBean.pass = profileItem.password.orEmpty()
|
||||
server.users = listOf(socksUsersBean)
|
||||
}
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundHttp(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.HTTP)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
if (profileItem.username.isNotNullEmpty()) {
|
||||
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
|
||||
socksUsersBean.user = profileItem.username.orEmpty()
|
||||
socksUsersBean.pass = profileItem.password.orEmpty()
|
||||
server.users = listOf(socksUsersBean)
|
||||
}
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundWireguard(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.WIREGUARD)
|
||||
|
||||
val rawAddresses = profileItem.localAddress
|
||||
?.split(",")
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.ifEmpty { null }
|
||||
?: listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4)
|
||||
|
||||
val addresses = if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
|
||||
rawAddresses
|
||||
} else {
|
||||
val ipv4Addresses = rawAddresses.filter { !it.contains(":") }
|
||||
ipv4Addresses.ifEmpty { listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4) }
|
||||
}
|
||||
|
||||
outboundBean?.settings?.let { wireguard ->
|
||||
wireguard.secretKey = profileItem.secretKey
|
||||
wireguard.address = addresses
|
||||
wireguard.peers?.firstOrNull()?.let { peer ->
|
||||
peer.publicKey = profileItem.publicKey.orEmpty()
|
||||
peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
|
||||
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
|
||||
}
|
||||
wireguard.mtu = profileItem.mtu
|
||||
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundHysteria2(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.HYSTERIA2) ?: return null
|
||||
profileItem.network = NetworkType.HYSTERIA.type
|
||||
profileItem.alpn = "h3"
|
||||
|
||||
outboundBean.settings?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.version = 2
|
||||
}
|
||||
|
||||
val sni = outboundBean.streamSettings?.let {
|
||||
populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean.streamSettings?.let {
|
||||
populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun createTcpHttpRequest(
|
||||
host: String?,
|
||||
path: String?
|
||||
): OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean {
|
||||
val requestString =
|
||||
"""{"version":"1.1","method":"GET","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.122 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate"],"Connection":["keep-alive"],"Pragma":"no-cache"}}"""
|
||||
val request = JsonUtil.fromJson(
|
||||
requestString,
|
||||
OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean::class.java
|
||||
) ?: OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean()
|
||||
|
||||
val parsedHost = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
request.headers.Host = parsedHost.ifEmpty { null }
|
||||
request.path = path.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }.ifEmpty { listOf("/") }
|
||||
return request
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures transport settings for an outbound connection.
|
||||
*
|
||||
* Sets up protocol-specific transport options based on the profile settings.
|
||||
*
|
||||
* @param streamSettings The stream settings to configure
|
||||
* @param profileItem The profile containing transport configuration
|
||||
* @return The Server Name Indication (SNI) value to use, or null if not applicable
|
||||
*/
|
||||
fun populateTransportSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem): String? {
|
||||
val transport = profileItem.network.orEmpty()
|
||||
val headerType = profileItem.headerType
|
||||
val host = profileItem.host
|
||||
val path = profileItem.path
|
||||
val seed = profileItem.seed
|
||||
// val quicSecurity = profileItem.quicSecurity
|
||||
// val key = profileItem.quicKey
|
||||
val mode = profileItem.mode
|
||||
val serviceName = profileItem.serviceName
|
||||
val authority = profileItem.authority
|
||||
val xhttpMode = profileItem.xhttpMode
|
||||
val xhttpExtra = profileItem.xhttpExtra
|
||||
val finalMask = profileItem.finalMask
|
||||
var sni: String? = null
|
||||
streamSettings.network = transport.ifEmpty { NetworkType.TCP.type }
|
||||
when (streamSettings.network) {
|
||||
NetworkType.TCP.type -> {
|
||||
val tcpSetting = OutboundBean.StreamSettingsBean.TcpSettingsBean()
|
||||
if (headerType == AppConfig.HEADER_TYPE_HTTP) {
|
||||
tcpSetting.header.type = AppConfig.HEADER_TYPE_HTTP
|
||||
val requestObj = createTcpHttpRequest(host, path)
|
||||
tcpSetting.header.request = requestObj
|
||||
sni = requestObj.headers.Host?.getOrNull(0)
|
||||
} else {
|
||||
tcpSetting.header.type = "none"
|
||||
sni = host
|
||||
}
|
||||
streamSettings.tcpSettings = tcpSetting
|
||||
}
|
||||
|
||||
NetworkType.KCP.type -> {
|
||||
val kcpSetting = OutboundBean.StreamSettingsBean.KcpSettingsBean()
|
||||
profileItem.kcpMtu?.let { kcpSetting.mtu = it }
|
||||
profileItem.kcpTti?.let { kcpSetting.tti = it }
|
||||
streamSettings.kcpSettings = kcpSetting
|
||||
val udpMaskList = mutableListOf<OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean>()
|
||||
if (!headerType.isNullOrEmpty() && headerType != "none") {
|
||||
val kcpHeaderType = when {
|
||||
headerType == "wechat-video" -> "header-wechat"
|
||||
else -> "header-$headerType"
|
||||
}
|
||||
udpMaskList.add(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = kcpHeaderType,
|
||||
settings = if (headerType == "dns" && !host.isNullOrEmpty()) {
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
domain = host
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
if (seed.isNullOrEmpty()) {
|
||||
udpMaskList.add(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "mkcp-original"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
udpMaskList.add(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "mkcp-aes128gcm",
|
||||
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
password = seed
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
streamSettings.finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
|
||||
udp = udpMaskList.toList()
|
||||
)
|
||||
}
|
||||
|
||||
NetworkType.WS.type -> {
|
||||
val wssetting = OutboundBean.StreamSettingsBean.WsSettingsBean()
|
||||
wssetting.host = host.orEmpty()
|
||||
sni = host
|
||||
wssetting.path = path ?: "/"
|
||||
streamSettings.wsSettings = wssetting
|
||||
}
|
||||
|
||||
NetworkType.HTTP_UPGRADE.type -> {
|
||||
val httpupgradeSetting = OutboundBean.StreamSettingsBean.HttpupgradeSettingsBean()
|
||||
httpupgradeSetting.host = host.orEmpty()
|
||||
sni = host
|
||||
httpupgradeSetting.path = path ?: "/"
|
||||
streamSettings.httpupgradeSettings = httpupgradeSetting
|
||||
}
|
||||
|
||||
NetworkType.XHTTP.type -> {
|
||||
val xhttpSetting = OutboundBean.StreamSettingsBean.XhttpSettingsBean()
|
||||
xhttpSetting.host = host.orEmpty()
|
||||
sni = host
|
||||
xhttpSetting.path = path ?: "/"
|
||||
xhttpSetting.mode = xhttpMode
|
||||
xhttpSetting.extra = JsonUtil.parseString(xhttpExtra)
|
||||
streamSettings.xhttpSettings = xhttpSetting
|
||||
}
|
||||
|
||||
NetworkType.H2.type, NetworkType.HTTP.type -> {
|
||||
streamSettings.network = NetworkType.H2.type
|
||||
val h2Setting = OutboundBean.StreamSettingsBean.HttpSettingsBean()
|
||||
h2Setting.host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
sni = h2Setting.host.getOrNull(0)
|
||||
h2Setting.path = path ?: "/"
|
||||
streamSettings.httpSettings = h2Setting
|
||||
}
|
||||
|
||||
// "quic" -> {
|
||||
// val quicsetting = QuicSettingBean()
|
||||
// quicsetting.security = quicSecurity ?: "none"
|
||||
// quicsetting.key = key.orEmpty()
|
||||
// quicsetting.header.type = headerType ?: "none"
|
||||
// quicSettings = quicsetting
|
||||
// }
|
||||
|
||||
NetworkType.GRPC.type -> {
|
||||
val grpcSetting = OutboundBean.StreamSettingsBean.GrpcSettingsBean()
|
||||
grpcSetting.multiMode = mode == "multi"
|
||||
grpcSetting.serviceName = serviceName.orEmpty()
|
||||
grpcSetting.authority = authority.orEmpty()
|
||||
grpcSetting.idle_timeout = 60
|
||||
grpcSetting.health_check_timeout = 20
|
||||
sni = authority
|
||||
streamSettings.grpcSettings = grpcSetting
|
||||
}
|
||||
|
||||
NetworkType.HYSTERIA.type -> {
|
||||
val hysteriaSetting = OutboundBean.StreamSettingsBean.HysteriaSettingsBean(
|
||||
version = 2,
|
||||
auth = profileItem.password.orEmpty(),
|
||||
)
|
||||
val quicParams = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean(
|
||||
brutalUp = profileItem.bandwidthUp?.nullIfBlank(),
|
||||
brutalDown = profileItem.bandwidthDown?.nullIfBlank(),
|
||||
)
|
||||
quicParams.congestion = if (quicParams.brutalUp != null || quicParams.brutalDown != null) "brutal" else null
|
||||
if (profileItem.portHopping.isNotNullEmpty()) {
|
||||
val rawInterval = profileItem.portHoppingInterval?.trim().nullIfBlank()
|
||||
val interval = if (rawInterval == null) {
|
||||
"30"
|
||||
} else {
|
||||
val singleValue = rawInterval.toIntOrNull()
|
||||
if (singleValue != null) {
|
||||
if (singleValue < 5) {
|
||||
"30"
|
||||
} else {
|
||||
rawInterval
|
||||
}
|
||||
} else {
|
||||
val parts = rawInterval.split('-')
|
||||
if (parts.size == 2) {
|
||||
val start = parts[0].trim().toIntOrNull()
|
||||
val end = parts[1].trim().toIntOrNull()
|
||||
if (start != null && end != null) {
|
||||
val minStart = maxOf(5, start)
|
||||
val minEnd = maxOf(minStart, end)
|
||||
"$minStart-$minEnd"
|
||||
} else {
|
||||
"30"
|
||||
}
|
||||
} else {
|
||||
"30"
|
||||
}
|
||||
}
|
||||
}
|
||||
quicParams.udpHop = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean.UdpHopBean(
|
||||
ports = profileItem.portHopping,
|
||||
interval = interval
|
||||
)
|
||||
}
|
||||
val finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
|
||||
quicParams = quicParams
|
||||
)
|
||||
if (profileItem.obfsPassword.isNotNullEmpty()) {
|
||||
finalmask.udp = listOf(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "salamander",
|
||||
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
password = profileItem.obfsPassword.orEmpty()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
streamSettings.hysteriaSettings = hysteriaSetting
|
||||
streamSettings.finalmask = finalmask
|
||||
}
|
||||
}
|
||||
finalMask?.let {
|
||||
val parsedFinalMask = JsonUtil.parseString(finalMask)
|
||||
if (parsedFinalMask != null) {
|
||||
streamSettings.finalmask = parsedFinalMask
|
||||
} else {
|
||||
LogUtil.w("V2rayConfigManager", "Invalid finalMask JSON, keeping previously generated finalmask")
|
||||
}
|
||||
}
|
||||
return sni
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures TLS or REALITY security settings for an outbound connection.
|
||||
*
|
||||
* Sets up security-related parameters like certificates, fingerprints, and SNI.
|
||||
*
|
||||
* @param streamSettings The stream settings to configure
|
||||
* @param profileItem The profile containing security configuration
|
||||
* @param sniExt An external SNI value to use if the profile doesn't specify one
|
||||
*/
|
||||
fun populateTlsSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem, sniExt: String?) {
|
||||
val streamSecurity = profileItem.security.orEmpty()
|
||||
val allowInsecure = profileItem.insecure == true && profileItem.pinnedCA256.isNullOrEmpty()
|
||||
val sni = if (profileItem.sni.isNullOrEmpty()) {
|
||||
when {
|
||||
sniExt.isNotNullEmpty() && Utils.isDomainName(sniExt) -> sniExt
|
||||
profileItem.server.isNotNullEmpty() && Utils.isDomainName(profileItem.server) -> profileItem.server
|
||||
else -> sniExt
|
||||
}
|
||||
} else {
|
||||
profileItem.sni
|
||||
}
|
||||
|
||||
streamSettings.security = streamSecurity.nullIfBlank()
|
||||
if (streamSettings.security == null) return
|
||||
val tlsSetting = OutboundBean.StreamSettingsBean.TlsSettingsBean(
|
||||
allowInsecure = allowInsecure,
|
||||
serverName = sni.nullIfBlank(),
|
||||
fingerprint = profileItem.fingerPrint.nullIfBlank(),
|
||||
alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() },
|
||||
echConfigList = profileItem.echConfigList.nullIfBlank(),
|
||||
pinnedPeerCertSha256 = profileItem.pinnedCA256.nullIfBlank(),
|
||||
publicKey = profileItem.publicKey.nullIfBlank(),
|
||||
shortId = profileItem.shortId.nullIfBlank(),
|
||||
spiderX = profileItem.spiderX.nullIfBlank(),
|
||||
mldsa65Verify = profileItem.mldsa65Verify.nullIfBlank(),
|
||||
)
|
||||
if (streamSettings.security == AppConfig.TLS) {
|
||||
streamSettings.tlsSettings = tlsSetting
|
||||
streamSettings.realitySettings = null
|
||||
} else if (streamSettings.security == AppConfig.REALITY) {
|
||||
streamSettings.tlsSettings = null
|
||||
streamSettings.realitySettings = tlsSetting
|
||||
}
|
||||
|
||||
if (profileItem.finalMask.isNullOrEmpty()) {
|
||||
updateOutboundFragment(streamSettings)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the outbound with fragment settings for traffic optimization.
|
||||
*
|
||||
* Configures packet fragmentation for TLS and REALITY protocols if enabled.
|
||||
*
|
||||
* @param streamSettings The streamSettings object to be modified
|
||||
* @return true if fragment configuration was successful, false otherwise
|
||||
*/
|
||||
private fun updateOutboundFragment(streamSettings: OutboundBean.StreamSettingsBean): Boolean {
|
||||
try {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == false) {
|
||||
return true
|
||||
}
|
||||
if (streamSettings.security != AppConfig.TLS
|
||||
&& streamSettings.security != AppConfig.REALITY
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (streamSettings.sockopt?.dialerProxy.isNotNullEmpty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
var packets =
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS) ?: "tlshello"
|
||||
if (streamSettings.security == AppConfig.REALITY
|
||||
&& packets == "tlshello"
|
||||
) {
|
||||
packets = "1-3"
|
||||
} else if (streamSettings.security == AppConfig.TLS
|
||||
&& packets != "tlshello"
|
||||
) {
|
||||
packets = "tlshello"
|
||||
}
|
||||
|
||||
val fragmentMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "fragment",
|
||||
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
packets = packets,
|
||||
length = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH)
|
||||
?: "50-100",
|
||||
delay = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL)
|
||||
?: "10-20"
|
||||
)
|
||||
)
|
||||
val noiseMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "noise",
|
||||
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
noise = listOf(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean.NoiseMaskBean(
|
||||
rand = "10-20",
|
||||
delay = "10-16",
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val finalMaskObj = streamSettings.finalmask?.let { existingFinalMask ->
|
||||
JsonUtil.parseString(JsonUtil.toJson(existingFinalMask))
|
||||
} ?: JsonObject()
|
||||
|
||||
// finalmask.tcp / finalmask.udp are arrays; prepend mask at index 0.
|
||||
fun prependMask(scope: String, mask: OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean) {
|
||||
val current = finalMaskObj.get(scope)
|
||||
if (current != null && current.isJsonArray && current.asJsonArray.size() > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
val newArray = JsonArray()
|
||||
newArray.add(JsonUtil.parseString(JsonUtil.toJson(mask)))
|
||||
|
||||
if (current != null && current.isJsonArray) {
|
||||
current.asJsonArray.forEach { newArray.add(it) }
|
||||
}
|
||||
finalMaskObj.add(scope, newArray)
|
||||
}
|
||||
|
||||
prependMask("tcp", fragmentMask)
|
||||
prependMask("udp", noiseMask)
|
||||
streamSettings.finalmask = finalMaskObj
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to update outbound fragment", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun getServerAddress(profileItem: ProfileItem): String {
|
||||
if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
|
||||
return profileItem.server.orEmpty()
|
||||
}
|
||||
|
||||
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
|
||||
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
|
||||
return domain
|
||||
}
|
||||
//Resolve and replace domain
|
||||
val resolvedIps = HttpUtil.resolveHostToIP(domain, MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
|
||||
if (resolvedIps.isNullOrEmpty()) {
|
||||
return domain
|
||||
}
|
||||
return resolvedIps.first()
|
||||
}
|
||||
}
|
||||
+144
-75
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.handler
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
@@ -13,11 +13,21 @@ import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.ServiceControl
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.OutboundTrafficStat
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.service.V2RayProxyOnlyService
|
||||
import com.v2ray.ang.service.V2RayVpnService
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.NotificationManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.SpeedtestManager
|
||||
import com.v2ray.ang.service.CoreProxyOnlyService
|
||||
import com.v2ray.ang.service.CoreVpnService
|
||||
import com.v2ray.ang.service.DialerNativeService
|
||||
import com.v2ray.ang.service.DialerWebviewService
|
||||
import com.v2ray.ang.service.IDialerService
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -30,18 +40,19 @@ import libv2ray.ProcessFinder
|
||||
import java.lang.ref.SoftReference
|
||||
import java.net.InetSocketAddress
|
||||
|
||||
object V2RayServiceManager {
|
||||
object CoreServiceManager {
|
||||
|
||||
private val coreController: CoreController = V2RayNativeManager.newCoreController(CoreCallback())
|
||||
private val coreController: CoreController = CoreNativeManager.newCoreController(CoreCallback())
|
||||
private val mMsgReceive = ReceiveMessageHandler()
|
||||
private var currentConfig: ProfileItem? = null
|
||||
private var processFinder: XrayProcessFinder? = null
|
||||
private var browserDialer: IDialerService? = null
|
||||
|
||||
var serviceControl: SoftReference<ServiceControl>? = null
|
||||
set(value) {
|
||||
field = value
|
||||
val service = value?.get()?.getService()
|
||||
V2RayNativeManager.initCoreEnv(service)
|
||||
CoreNativeManager.initCoreEnv(service)
|
||||
if (service != null && processFinder == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
processFinder = XrayProcessFinder(service)
|
||||
coreController.registerProcessFinder(processFinder)
|
||||
@@ -58,7 +69,13 @@ object V2RayServiceManager {
|
||||
context.toast(R.string.app_tile_first_use)
|
||||
return false
|
||||
}
|
||||
startContextService(context)
|
||||
try {
|
||||
startContextService(context)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: ${e.message}", e)
|
||||
context.toast(e.message ?: e.javaClass.simpleName)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -74,7 +91,12 @@ object V2RayServiceManager {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
}
|
||||
|
||||
startContextService(context)
|
||||
try {
|
||||
startContextService(context)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: ${e.message}", e)
|
||||
context.toast(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +124,11 @@ object V2RayServiceManager {
|
||||
* Starts the context service for V2Ray.
|
||||
* Chooses between VPN service or Proxy-only service based on user settings.
|
||||
* @param context The context from which the service is started.
|
||||
* @throws IllegalStateException if the core is already running, no server is selected,
|
||||
* server config cannot be decoded, or server configuration is invalid.
|
||||
* @throws Exception if the foreground service fails to start.
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
private fun startContextService(context: Context) {
|
||||
if (coreController.isRunning) {
|
||||
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||
@@ -110,30 +136,35 @@ object V2RayServiceManager {
|
||||
}
|
||||
|
||||
val guid = MmkvManager.getSelectServer()
|
||||
if (guid == null) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
|
||||
return
|
||||
}
|
||||
?: run {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
|
||||
error(context.getString(R.string.app_tile_first_use))
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(guid)
|
||||
if (config == null) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
|
||||
return
|
||||
}
|
||||
?: run {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
|
||||
error(context.getString(R.string.toast_config_file_invalid))
|
||||
}
|
||||
|
||||
if (config.configType != EConfigType.CUSTOM
|
||||
&& config.configType != EConfigType.POLICYGROUP
|
||||
if (!config.configType.isComplexType()
|
||||
&& !Utils.isValidUrl(config.server)
|
||||
&& !Utils.isPureIpAddress(config.server.orEmpty())
|
||||
) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
|
||||
return
|
||||
error(context.getString(R.string.toast_config_file_invalid))
|
||||
}
|
||||
|
||||
// refresh socks port when enabled dynamic socks port
|
||||
SettingsManager.refreshRuntimeSocksPort()
|
||||
|
||||
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
|
||||
// if (!result.status) return
|
||||
// if (!result.status) error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
|
||||
|
||||
if (config.insecure == true) {
|
||||
context.toastError(R.string.toast_allow_insecure_deprecated)
|
||||
context.toastError(R.string.toast_allow_insecure_deprecated)
|
||||
}
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) {
|
||||
context.toast(R.string.toast_warning_pref_proxysharing_short)
|
||||
@@ -144,16 +175,25 @@ object V2RayServiceManager {
|
||||
val isVpnMode = SettingsManager.isVpnMode()
|
||||
val intent = if (isVpnMode) {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
|
||||
Intent(context.applicationContext, V2RayVpnService::class.java)
|
||||
Intent(context.applicationContext, CoreVpnService::class.java)
|
||||
} else {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
|
||||
Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
|
||||
Intent(context.applicationContext, CoreProxyOnlyService::class.java)
|
||||
}
|
||||
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to start service", e)
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,67 +214,70 @@ object V2RayServiceManager {
|
||||
return false
|
||||
}
|
||||
|
||||
val guid = MmkvManager.getSelectServer()
|
||||
if (guid == null) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
|
||||
try {
|
||||
doStartCoreLoop(service, vpnInterface)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
val message = e.message?.takeUnless { it.isBlank() } ?: e.javaClass.simpleName
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: $message", e)
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, message)
|
||||
NotificationManager.cancelNotification()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(guid)
|
||||
if (config == null) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
|
||||
return false
|
||||
}
|
||||
@Throws(Exception::class)
|
||||
private fun doStartCoreLoop(service: Service, vpnInterface: ParcelFileDescriptor?) {
|
||||
val guid = MmkvManager.getSelectServer() ?: error("No server selected")
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: error("Failed to decode server config")
|
||||
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting core loop for ${config.remarks}")
|
||||
val result = V2rayConfigManager.getV2rayConfig(service, guid)
|
||||
val result = CoreConfigManager.getV2rayConfig(service, guid)
|
||||
LogUtil.d(AppConfig.TAG, result.content)
|
||||
if (!result.status) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to get V2Ray config")
|
||||
return false
|
||||
error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
|
||||
}
|
||||
|
||||
try {
|
||||
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||
mFilter.addAction(Intent.ACTION_SCREEN_ON)
|
||||
mFilter.addAction(Intent.ACTION_SCREEN_OFF)
|
||||
mFilter.addAction(Intent.ACTION_USER_PRESENT)
|
||||
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to register receiver", e)
|
||||
return false
|
||||
}
|
||||
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||
mFilter.addAction(Intent.ACTION_SCREEN_ON)
|
||||
mFilter.addAction(Intent.ACTION_SCREEN_OFF)
|
||||
mFilter.addAction(Intent.ACTION_USER_PRESENT)
|
||||
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
|
||||
|
||||
currentConfig = config
|
||||
var tunFd = vpnInterface?.fd ?: 0
|
||||
val dialerAddr = if (currentConfig?.browserDialerMode.isNullOrEmpty()) {
|
||||
""
|
||||
} else {
|
||||
"127.0.0.1:${Utils.findRandomFreePort()}"
|
||||
}
|
||||
if (SettingsManager.isUsingHevTun()) {
|
||||
tunFd = 0
|
||||
}
|
||||
|
||||
try {
|
||||
NotificationManager.showNotification(currentConfig)
|
||||
coreController.startLoop(result.content, tunFd)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to start core loop", e)
|
||||
return false
|
||||
NotificationManager.showNotification(currentConfig)
|
||||
CoreNativeManager.reconcileBrowserDialer(dialerAddr)
|
||||
coreController.startLoop(result.content, tunFd)
|
||||
|
||||
if (!coreController.isRunning) {
|
||||
error("Core failed to start")
|
||||
}
|
||||
|
||||
if (coreController.isRunning == false) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Core failed to start")
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
|
||||
NotificationManager.cancelNotification()
|
||||
return false
|
||||
if (browserDialer != null) {
|
||||
browserDialer!!.stop()
|
||||
browserDialer = null
|
||||
}
|
||||
if (config.browserDialerMode == "OkHttp") {
|
||||
browserDialer = DialerNativeService()
|
||||
browserDialer!!.start(service, dialerAddr)
|
||||
} else if (config.browserDialerMode == "WebView") {
|
||||
browserDialer = DialerWebviewService()
|
||||
browserDialer!!.start(service, dialerAddr)
|
||||
}
|
||||
|
||||
try {
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
|
||||
NotificationManager.startSpeedNotification(currentConfig)
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to complete startup", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
|
||||
NotificationManager.startSpeedNotification()
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,6 +298,13 @@ object V2RayServiceManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Close existing browser dialer
|
||||
CoreNativeManager.reconcileBrowserDialer("")
|
||||
if (browserDialer != null) {
|
||||
browserDialer!!.stop()
|
||||
browserDialer = null
|
||||
}
|
||||
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
|
||||
NotificationManager.cancelNotification()
|
||||
|
||||
@@ -268,13 +318,32 @@ object V2RayServiceManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the statistics for a given tag and link.
|
||||
* @param tag The tag to query.
|
||||
* @param link The link to query.
|
||||
* @return The statistics value.
|
||||
* Queries and resets all outbound traffic counters in one core call.
|
||||
* Go side format: tag,direction,value;tag,direction,value;
|
||||
*/
|
||||
fun queryStats(tag: String, link: String): Long {
|
||||
return coreController.queryStats(tag, link)
|
||||
fun queryAllOutboundTrafficStats(): List<OutboundTrafficStat> {
|
||||
val payload = coreController.queryAllOutboundTrafficStats()
|
||||
|
||||
val result = ArrayList<OutboundTrafficStat>()
|
||||
|
||||
payload.split(';').forEach { entry ->
|
||||
if (entry.isBlank()) return@forEach
|
||||
|
||||
val parts = entry.split(',', limit = 3)
|
||||
if (parts.size != 3) return@forEach
|
||||
|
||||
val value = parts[2].toLongOrNull() ?: return@forEach
|
||||
|
||||
result.add(
|
||||
OutboundTrafficStat(
|
||||
tag = parts[0],
|
||||
direction = parts[1],
|
||||
value = value,
|
||||
)
|
||||
)
|
||||
}
|
||||
// LogUtil.d(AppConfig.TAG, "Queried outbound traffic stats: $result")
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -401,7 +470,7 @@ object V2RayServiceManager {
|
||||
//LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to $destIP:$destPort, uid=$uid,${PackageUidResolver.uidToPackageName(uid.toString())}")
|
||||
|
||||
uid
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
-1L
|
||||
}
|
||||
}
|
||||
@@ -457,12 +526,12 @@ object V2RayServiceManager {
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SCREEN_OFF -> {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen off")
|
||||
NotificationManager.stopSpeedNotification(currentConfig)
|
||||
NotificationManager.stopSpeedNotification()
|
||||
}
|
||||
|
||||
Intent.ACTION_SCREEN_ON -> {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen on")
|
||||
NotificationManager.startSpeedNotification(currentConfig)
|
||||
NotificationManager.startSpeedNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,6 @@ data class ConfigResult(
|
||||
var status: Boolean,
|
||||
var guid: String? = null,
|
||||
var content: String = "",
|
||||
var errorMessage: String = "",
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import android.content.Context
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.CoreResolvedType
|
||||
|
||||
data class CoreConfigContext(
|
||||
val context: Context,
|
||||
val guid: String,
|
||||
val isCustom: Boolean = false,
|
||||
val resolvedOutbounds: List<ResolvedOutbound> = emptyList(),
|
||||
) {
|
||||
data class ResolvedOutbound(
|
||||
val tag: String,
|
||||
val profile: ProfileItem,
|
||||
val resolvedProfiles: List<ProfileItem>,
|
||||
val resolvedType: CoreResolvedType,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class OutboundTrafficStat(
|
||||
val tag: String,
|
||||
val direction: String,
|
||||
val value: Long,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
sealed class RealPingEvent {
|
||||
|
||||
/** Periodic progress update while the batch is still running. */
|
||||
data class Progress(val text: String) : RealPingEvent()
|
||||
|
||||
/** A single server result is available. */
|
||||
data class Result(val guid: String, val delayMillis: Long) : RealPingEvent()
|
||||
|
||||
/** The entire batch has finished or been cancelled. */
|
||||
data class Finish(val status: String) : RealPingEvent()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class UrlContentRequest(
|
||||
val url: String?,
|
||||
val timeout: Int = 15000,
|
||||
val httpPort: Int = 0,
|
||||
val proxyUsername: String? = null,
|
||||
val proxyPassword: String? = null,
|
||||
val userAgent: String? = null
|
||||
)
|
||||
@@ -68,7 +68,6 @@ data class V2rayConfig(
|
||||
var protocol: String,
|
||||
var settings: OutSettingsBean? = null,
|
||||
var streamSettings: StreamSettingsBean? = null,
|
||||
val proxySettings: Any? = null,
|
||||
val sendThrough: String? = null,
|
||||
var mux: MuxBean? = MuxBean(false)
|
||||
) {
|
||||
@@ -82,7 +81,6 @@ data class V2rayConfig(
|
||||
var address: Any? = null,
|
||||
var port: Int? = null,
|
||||
/*Freedom*/
|
||||
var domainStrategy: String? = null,
|
||||
val redirect: String? = null,
|
||||
val userLevel: Int? = null,
|
||||
/*Loopback*/
|
||||
@@ -181,7 +179,7 @@ data class V2rayConfig(
|
||||
@SerializedName("Accept-Encoding")
|
||||
val acceptEncoding: List<String>? = null,
|
||||
val Connection: List<String>? = null,
|
||||
val Pragma: String? = null
|
||||
val Pragma: Any? = null
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -199,13 +197,12 @@ data class V2rayConfig(
|
||||
|
||||
data class WsSettingsBean(
|
||||
var path: String? = null,
|
||||
var headers: HeadersBean = HeadersBean(),
|
||||
var host: String? = null,
|
||||
var headers: Map<String, String>? = null,
|
||||
val maxEarlyData: Int? = null,
|
||||
val useBrowserForwarding: Boolean? = null,
|
||||
val acceptProxyProtocol: Boolean? = null
|
||||
) {
|
||||
data class HeadersBean(var Host: String = "")
|
||||
}
|
||||
)
|
||||
|
||||
data class HttpupgradeSettingsBean(
|
||||
var path: String? = null,
|
||||
@@ -256,7 +253,6 @@ data class V2rayConfig(
|
||||
val disableSystemRoot: Boolean? = null,
|
||||
val enableSessionResumption: Boolean? = null,
|
||||
var echConfigList: String? = null,
|
||||
var echForceQuery: String? = null,
|
||||
var pinnedPeerCertSha256: String? = null,
|
||||
// REALITY settings
|
||||
val show: Boolean = false,
|
||||
@@ -345,19 +341,19 @@ data class V2rayConfig(
|
||||
if (protocol.equals(EConfigType.VMESS.name, true)
|
||||
|| protocol.equals(EConfigType.VLESS.name, true)
|
||||
) {
|
||||
return settings?.vnext?.first()?.address
|
||||
return settings?.vnext?.firstOrNull()?.address ?: settings?.address as? String
|
||||
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||
) {
|
||||
return settings?.servers?.first()?.address
|
||||
return settings?.servers?.firstOrNull()?.address
|
||||
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
||||
return settings?.peers?.first()?.endpoint?.substringBeforeLast(":")
|
||||
return settings?.peers?.firstOrNull()?.endpoint?.substringBeforeLast(":")
|
||||
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA.name, true)
|
||||
) {
|
||||
return settings?.address as String?
|
||||
return settings?.address as? String
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -366,15 +362,15 @@ data class V2rayConfig(
|
||||
if (protocol.equals(EConfigType.VMESS.name, true)
|
||||
|| protocol.equals(EConfigType.VLESS.name, true)
|
||||
) {
|
||||
return settings?.vnext?.first()?.port
|
||||
return settings?.vnext?.firstOrNull()?.port ?: settings?.port
|
||||
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||
) {
|
||||
return settings?.servers?.first()?.port
|
||||
return settings?.servers?.firstOrNull()?.port
|
||||
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
||||
return settings?.peers?.first()?.endpoint?.substringAfterLast(":")?.toInt()
|
||||
return settings?.peers?.firstOrNull()?.endpoint?.substringAfterLast(":")?.toInt()
|
||||
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA.name, true)
|
||||
) {
|
||||
@@ -402,6 +398,7 @@ data class V2rayConfig(
|
||||
val clientIp: String? = null,
|
||||
val disableCache: Boolean? = null,
|
||||
val queryStrategy: String? = null,
|
||||
val enableParallelQuery: Boolean? = null,
|
||||
val tag: String? = null
|
||||
) {
|
||||
data class ServersBean(
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class AssetUrlCache(
|
||||
val guid: String,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class AssetUrlItem(
|
||||
var remarks: String = "",
|
||||
+7
-12
@@ -1,10 +1,6 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
import com.v2ray.ang.AppConfig.LOOPBACK
|
||||
import com.v2ray.ang.AppConfig.PORT_SOCKS
|
||||
import com.v2ray.ang.AppConfig.TAG_BLOCKED
|
||||
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
||||
import com.v2ray.ang.AppConfig.TAG_PROXY
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
@@ -46,7 +42,6 @@ data class ProfileItem(
|
||||
var fingerPrint: String? = null,
|
||||
var insecure: Boolean? = null,
|
||||
var echConfigList: String? = null,
|
||||
var echForceQuery: String? = null,
|
||||
var pinnedCA256: String? = null,
|
||||
|
||||
var publicKey: String? = null,
|
||||
@@ -70,6 +65,9 @@ data class ProfileItem(
|
||||
var policyGroupType: String? = null,
|
||||
var policyGroupSubscriptionId: String? = null,
|
||||
var policyGroupFilter: String? = null,
|
||||
var proxyChainProfiles: String? = null,
|
||||
|
||||
var browserDialerMode: String? = null,
|
||||
|
||||
) {
|
||||
companion object {
|
||||
@@ -78,13 +76,9 @@ data class ProfileItem(
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllOutboundTags(): MutableList<String> {
|
||||
return mutableListOf(TAG_PROXY, TAG_DIRECT, TAG_BLOCKED)
|
||||
}
|
||||
|
||||
fun getServerAddressAndPort(): String {
|
||||
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
|
||||
return "$LOOPBACK:$PORT_SOCKS"
|
||||
return "${AppConfig.LOOPBACK}:${AppConfig.PORT_SOCKS}"
|
||||
}
|
||||
return Utils.getIpv6Address(server) + ":" + serverPort
|
||||
}
|
||||
@@ -130,6 +124,7 @@ data class ProfileItem(
|
||||
&& this.portHopping == obj.portHopping
|
||||
&& this.portHoppingInterval == obj.portHoppingInterval
|
||||
&& this.pinnedCA256 == obj.pinnedCA256
|
||||
&& this.proxyChainProfiles == obj.proxyChainProfiles
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class RulesetItem(
|
||||
var remarks: String? = "",
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
|
||||
fun getTestDelayString(): String {
|
||||
@@ -7,4 +7,4 @@ data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
|
||||
}
|
||||
return testDelayMillis.toString() + "ms"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class ServersCache(
|
||||
val guid: String,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class SubscriptionCache(
|
||||
val guid: String,
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class SubscriptionItem(
|
||||
var remarks: String = "",
|
||||
@@ -7,7 +7,7 @@ data class SubscriptionItem(
|
||||
val addedTime: Long = System.currentTimeMillis(),
|
||||
var lastUpdated: Long = -1,
|
||||
var autoUpdate: Boolean = false,
|
||||
val updateInterval: Int? = null,
|
||||
var updateInterval: Long = 1440, // in minutes, default to 24 hours
|
||||
var prevProfile: String? = null,
|
||||
var nextProfile: String? = null,
|
||||
var filter: String? = null,
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class WebDavConfig(
|
||||
val baseUrl: String,
|
||||
@@ -6,4 +6,4 @@ data class WebDavConfig(
|
||||
val password: String? = null,
|
||||
val remoteBasePath: String = "/",
|
||||
val timeoutSeconds: Long = 30
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
/** Runtime type used during config assembly only. */
|
||||
enum class CoreResolvedType {
|
||||
NORMAL,
|
||||
POLICYGROUP,
|
||||
PROXYCHAIN,
|
||||
}
|
||||
@@ -15,7 +15,8 @@ enum class EConfigType(val value: Int, val protocolScheme: String) {
|
||||
HYSTERIA2(9, AppConfig.HYSTERIA2),
|
||||
HYSTERIA(900, AppConfig.HYSTERIA),
|
||||
HTTP(10, AppConfig.HTTP),
|
||||
POLICYGROUP(101, AppConfig.CUSTOM);
|
||||
POLICYGROUP(101, AppConfig.CUSTOM),
|
||||
PROXYCHAIN(102, AppConfig.CUSTOM);
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
/**
|
||||
* Enum defining different notification channels.
|
||||
* Each channel has a unique channelId, notificationId, and display name.
|
||||
*/
|
||||
enum class NotificationChannelType(
|
||||
val channelId: String,
|
||||
val channelName: String,
|
||||
val notificationId: Int
|
||||
) {
|
||||
SUBSCRIPTION_UPDATE(
|
||||
channelId = "subscription_update_channel",
|
||||
channelName = "Subscription Update Service",
|
||||
notificationId = 13
|
||||
),
|
||||
CORE_TEST(
|
||||
channelId = "core_test_channel",
|
||||
channelName = "Core Test Service",
|
||||
notificationId = 12
|
||||
)
|
||||
}
|
||||
@@ -13,17 +13,6 @@ enum class PermissionType {
|
||||
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+) */
|
||||
POST_NOTIFICATIONS {
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
@@ -37,7 +26,6 @@ enum class PermissionType {
|
||||
fun getLabel(): String {
|
||||
return when (this) {
|
||||
CAMERA -> "Camera"
|
||||
READ_STORAGE -> "Storage"
|
||||
POST_NOTIFICATIONS -> "Notification"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import com.v2ray.ang.AngApplication
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import es.dmoral.toasty.Toasty
|
||||
import java.io.Serializable
|
||||
import java.net.URI
|
||||
@@ -200,4 +201,22 @@ fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean =
|
||||
}
|
||||
return regex?.containsMatchIn(this)
|
||||
?: 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
|
||||
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
|
||||
@@ -20,7 +20,7 @@ object CustomFmt : FmtBase() {
|
||||
|
||||
config.remarks = fullConfig?.remarks ?: System.currentTimeMillis().toString()
|
||||
config.server = outbound?.getServerAddress()
|
||||
config.serverPort = outbound?.getServerPort().toString()
|
||||
config.serverPort = outbound?.getServerPort()?.toString()
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
@@ -51,9 +50,8 @@ open class FmtBase {
|
||||
*
|
||||
* @param config the ProfileItem object to populate
|
||||
* @param queryParam the query parameters to use for populating the ProfileItem
|
||||
* @param allowInsecure whether to allow insecure connections
|
||||
*/
|
||||
fun getItemFormQuery(config: ProfileItem, queryParam: Map<String, String>, allowInsecure: Boolean) {
|
||||
fun getItemFormQuery(config: ProfileItem, queryParam: Map<String, String>) {
|
||||
config.network = queryParam["type"] ?: NetworkType.TCP.type
|
||||
config.headerType = queryParam["headerType"]
|
||||
config.host = queryParam["host"]
|
||||
@@ -80,7 +78,7 @@ open class FmtBase {
|
||||
config.insecure = when {
|
||||
allowInsecureKeys.any { queryParam[it] == "1" } -> true
|
||||
allowInsecureKeys.any { queryParam[it] == "0" } -> false
|
||||
else -> allowInsecure
|
||||
else -> false
|
||||
}
|
||||
config.sni = queryParam["sni"]
|
||||
config.fingerPrint = queryParam["fp"]
|
||||
@@ -172,21 +170,4 @@ open class FmtBase {
|
||||
|
||||
return dicQuery
|
||||
}
|
||||
|
||||
fun getServerAddress(profileItem: ProfileItem): String {
|
||||
if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
|
||||
return profileItem.server.orEmpty()
|
||||
}
|
||||
|
||||
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
|
||||
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
|
||||
return domain
|
||||
}
|
||||
//Resolve and replace domain
|
||||
val resolvedIps = HttpUtil.resolveHostToIP(domain, MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
|
||||
if (resolvedIps.isNullOrEmpty()) {
|
||||
return domain
|
||||
}
|
||||
return resolvedIps.first()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
|
||||
object HttpFmt : FmtBase() {
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HTTP)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
if (profileItem.username.isNotNullEmpty()) {
|
||||
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
|
||||
socksUsersBean.user = profileItem.username.orEmpty()
|
||||
socksUsersBean.pass = profileItem.password.orEmpty()
|
||||
server.users = listOf(socksUsersBean)
|
||||
}
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -21,7 +19,6 @@ object Hysteria2Fmt : FmtBase() {
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem {
|
||||
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
||||
val config = ProfileItem.create(EConfigType.HYSTERIA2)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
@@ -35,7 +32,7 @@ object Hysteria2Fmt : FmtBase() {
|
||||
if (!uri.rawQuery.isNullOrEmpty()) {
|
||||
val queryParam = getQueryParam(uri)
|
||||
|
||||
getItemFormQuery(config, queryParam, allowInsecure)
|
||||
getItemFormQuery(config, queryParam)
|
||||
|
||||
config.security = queryParam["security"] ?: AppConfig.TLS
|
||||
config.obfsPassword = queryParam["obfs-password"]
|
||||
@@ -110,32 +107,4 @@ object Hysteria2Fmt : FmtBase() {
|
||||
|
||||
return toUri(config, config.password, dicQuery)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HYSTERIA2) ?: return null
|
||||
profileItem.network = NetworkType.HYSTERIA.type
|
||||
profileItem.alpn = "h3"
|
||||
|
||||
outboundBean.settings?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.version = 2
|
||||
}
|
||||
|
||||
val sni = outboundBean.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
@@ -124,31 +122,4 @@ object ShadowsocksFmt : FmtBase() {
|
||||
|
||||
return toUri(config, Utils.encode(pw, true), null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SHADOWSOCKS)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.password = profileItem.password
|
||||
server.method = profileItem.method
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -28,7 +26,11 @@ object SocksFmt : FmtBase() {
|
||||
config.serverPort = uri.port.toString()
|
||||
|
||||
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) {
|
||||
config.username = result.first()
|
||||
config.password = result.last()
|
||||
@@ -53,27 +55,4 @@ object SocksFmt : FmtBase() {
|
||||
|
||||
return toUri(config, Utils.encode(pw, true), null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SOCKS)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
if (profileItem.username.isNotNullEmpty()) {
|
||||
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
|
||||
socksUsersBean.user = profileItem.username.orEmpty()
|
||||
socksUsersBean.pass = profileItem.password.orEmpty()
|
||||
server.users = listOf(socksUsersBean)
|
||||
}
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -19,7 +17,6 @@ object TrojanFmt : FmtBase() {
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem {
|
||||
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
||||
val config = ProfileItem.create(EConfigType.TROJAN)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
@@ -31,11 +28,11 @@ object TrojanFmt : FmtBase() {
|
||||
if (uri.rawQuery.isNullOrEmpty()) {
|
||||
config.network = NetworkType.TCP.type
|
||||
config.security = AppConfig.TLS
|
||||
config.insecure = allowInsecure
|
||||
config.insecure = false
|
||||
} else {
|
||||
val queryParam = getQueryParam(uri)
|
||||
|
||||
getItemFormQuery(config, queryParam, allowInsecure)
|
||||
getItemFormQuery(config, queryParam)
|
||||
config.security = queryParam["security"] ?: AppConfig.TLS
|
||||
}
|
||||
|
||||
@@ -53,31 +50,4 @@ object TrojanFmt : FmtBase() {
|
||||
|
||||
return toUri(config, config.password, dicQuery)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.TROJAN)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.password = profileItem.password
|
||||
server.flow = profileItem.flow
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -19,7 +17,6 @@ object VlessFmt : FmtBase() {
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem? {
|
||||
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
||||
val config = ProfileItem.create(EConfigType.VLESS)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
@@ -32,7 +29,7 @@ object VlessFmt : FmtBase() {
|
||||
config.password = uri.userInfo
|
||||
config.method = queryParam["encryption"] ?: "none"
|
||||
|
||||
getItemFormQuery(config, queryParam, allowInsecure)
|
||||
getItemFormQuery(config, queryParam)
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -50,31 +47,4 @@ object VlessFmt : FmtBase() {
|
||||
return toUri(config, config.password, dicQuery)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VLESS)
|
||||
|
||||
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||
vnext.address = getServerAddress(profileItem)
|
||||
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
||||
vnext.users[0].id = profileItem.password.orEmpty()
|
||||
vnext.users[0].encryption = profileItem.method
|
||||
vnext.users[0].flow = profileItem.flow
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,13 @@ package com.v2ray.ang.fmt
|
||||
|
||||
import android.text.TextUtils
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.dto.VmessQRCode
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -28,7 +26,6 @@ object VmessFmt : FmtBase() {
|
||||
return parseVmessStd(str)
|
||||
}
|
||||
|
||||
val allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
||||
val config = ProfileItem.create(EConfigType.VMESS)
|
||||
|
||||
var result = str.replace(EConfigType.VMESS.protocolScheme, "")
|
||||
@@ -89,7 +86,7 @@ object VmessFmt : FmtBase() {
|
||||
config.insecure = when (vmessQRCode.insecure) {
|
||||
"1" -> true
|
||||
"0" -> false
|
||||
else -> allowInsecure
|
||||
else -> false
|
||||
}
|
||||
return config
|
||||
}
|
||||
@@ -156,7 +153,6 @@ object VmessFmt : FmtBase() {
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parseVmessStd(str: String): ProfileItem? {
|
||||
val allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
||||
val config = ProfileItem.create(EConfigType.VMESS)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
@@ -169,36 +165,10 @@ object VmessFmt : FmtBase() {
|
||||
config.password = uri.userInfo
|
||||
config.method = AppConfig.DEFAULT_SECURITY
|
||||
|
||||
getItemFormQuery(config, queryParam, allowInsecure)
|
||||
getItemFormQuery(config, queryParam)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VMESS)
|
||||
|
||||
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||
vnext.address = getServerAddress(profileItem)
|
||||
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
||||
vnext.users[0].id = profileItem.password.orEmpty()
|
||||
vnext.users[0].security = profileItem.method
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.extension.removeWhiteSpace
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -31,7 +28,7 @@ object WireguardFmt : FmtBase() {
|
||||
config.serverPort = uri.port.toString()
|
||||
|
||||
config.secretKey = uri.userInfo.orEmpty()
|
||||
config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
|
||||
config.localAddress = queryParam["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||
config.publicKey = queryParam["publickey"].orEmpty()
|
||||
config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
|
||||
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
||||
@@ -82,7 +79,7 @@ object WireguardFmt : FmtBase() {
|
||||
|
||||
config.secretKey = interfaceParams["privatekey"].orEmpty()
|
||||
config.remarks = System.currentTimeMillis().toString()
|
||||
config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
|
||||
config.localAddress = interfaceParams["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
||||
config.publicKey = peerParams["publickey"].orEmpty()
|
||||
config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
|
||||
@@ -100,29 +97,6 @@ object WireguardFmt : FmtBase() {
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.WIREGUARD)
|
||||
|
||||
outboundBean?.settings?.let { wireguard ->
|
||||
wireguard.secretKey = profileItem.secretKey
|
||||
wireguard.address = (profileItem.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4).split(",")
|
||||
wireguard.peers?.firstOrNull()?.let { peer ->
|
||||
peer.publicKey = profileItem.publicKey.orEmpty()
|
||||
peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
|
||||
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
|
||||
}
|
||||
wireguard.mtu = profileItem.mtu
|
||||
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to a URI string.
|
||||
|
||||
@@ -4,12 +4,13 @@ import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.text.TextUtils
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.HY2
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.SubscriptionCache
|
||||
import com.v2ray.ang.dto.SubscriptionItem
|
||||
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.SubscriptionCache
|
||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.fmt.CustomFmt
|
||||
@@ -29,6 +30,21 @@ import java.net.URI
|
||||
|
||||
object AngConfigManager {
|
||||
|
||||
// Parser mapping for different config types (lazy initialized)
|
||||
private val configFmtParsers: Map<String, (String) -> ProfileItem?> by lazy {
|
||||
mapOf(
|
||||
EConfigType.VMESS.protocolScheme to VmessFmt::parse,
|
||||
EConfigType.SHADOWSOCKS.protocolScheme to ShadowsocksFmt::parse,
|
||||
EConfigType.SOCKS.protocolScheme to SocksFmt::parse,
|
||||
AppConfig.SOCKS4 to SocksFmt::parse,
|
||||
AppConfig.SOCKS5 to SocksFmt::parse,
|
||||
EConfigType.TROJAN.protocolScheme to TrojanFmt::parse,
|
||||
EConfigType.VLESS.protocolScheme to VlessFmt::parse,
|
||||
EConfigType.WIREGUARD.protocolScheme to WireguardFmt::parse,
|
||||
EConfigType.HYSTERIA2.protocolScheme to Hysteria2Fmt::parse,
|
||||
AppConfig.HY2 to Hysteria2Fmt::parse
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares the configuration to the clipboard.
|
||||
@@ -111,7 +127,7 @@ object AngConfigManager {
|
||||
fun shareFullContent2Clipboard(context: Context, guid: String?): Int {
|
||||
try {
|
||||
if (guid == null) return -1
|
||||
val result = V2rayConfigManager.getV2rayConfig(context, guid)
|
||||
val result = CoreConfigManager.getV2rayConfig(context, guid)
|
||||
if (result.status) {
|
||||
Utils.setClipboard(context, result.content)
|
||||
} else {
|
||||
@@ -136,15 +152,12 @@ object AngConfigManager {
|
||||
|
||||
return config.configType.protocolScheme + when (config.configType) {
|
||||
EConfigType.VMESS -> VmessFmt.toUri(config)
|
||||
EConfigType.CUSTOM -> ""
|
||||
EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toUri(config)
|
||||
EConfigType.SOCKS -> SocksFmt.toUri(config)
|
||||
EConfigType.HTTP -> ""
|
||||
EConfigType.VLESS -> VlessFmt.toUri(config)
|
||||
EConfigType.TROJAN -> TrojanFmt.toUri(config)
|
||||
EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
|
||||
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
|
||||
EConfigType.POLICYGROUP -> ""
|
||||
else -> {}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -221,15 +234,8 @@ object AngConfigManager {
|
||||
if (servers == null) {
|
||||
return 0
|
||||
}
|
||||
// Find the currently selected server that matches the subscription ID
|
||||
val removedSelected = if (subid.isNotBlank() && !append) {
|
||||
MmkvManager.getSelectServer()
|
||||
.takeIf { it?.isNotBlank() == true }
|
||||
?.let { MmkvManager.decodeServerConfig(it) }
|
||||
?.takeIf { it.subscriptionId == subid }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// Find the currently selected server that belongs to the same subscription before replacement.
|
||||
val removedSelected = getRemovedSelectedProfile(subid, append)
|
||||
|
||||
val subItem = MmkvManager.decodeSubscription(subid)
|
||||
|
||||
@@ -275,7 +281,6 @@ object AngConfigManager {
|
||||
|
||||
// Read serverList once
|
||||
val serverList = MmkvManager.decodeServerList(subid)
|
||||
var needSetSelected = MmkvManager.getSelectServer().isNullOrBlank()
|
||||
|
||||
configs.forEach { config ->
|
||||
val key = Utils.getUuid()
|
||||
@@ -284,10 +289,6 @@ object AngConfigManager {
|
||||
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
if (needSetSelected) {
|
||||
MmkvManager.setSelectServer(key)
|
||||
needSetSelected = false
|
||||
}
|
||||
}
|
||||
keyToProfile[key] = config
|
||||
}
|
||||
@@ -310,7 +311,18 @@ object AngConfigManager {
|
||||
* @return Matched key or null
|
||||
*/
|
||||
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)
|
||||
if (target.remarks.isNotBlank()) {
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.remarks, target.remarks) &&
|
||||
isSameText(saved.server, target.server) &&
|
||||
isSameText(saved.serverPort, target.serverPort) &&
|
||||
isSameText(saved.password, target.password)
|
||||
}?.key?.let { return it }
|
||||
}
|
||||
|
||||
// Level 1: Match by remarks
|
||||
if (target.remarks.isNotBlank()) {
|
||||
@@ -337,7 +349,20 @@ object AngConfigManager {
|
||||
isSameText(saved.server, target.server)
|
||||
}?.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 }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -373,18 +398,25 @@ object AngConfigManager {
|
||||
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
|
||||
|
||||
if (serverList.isNotEmpty()) {
|
||||
val removedSelected = getRemovedSelectedProfile(subid, append)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
var count = 0
|
||||
val keyToProfile = mutableMapOf<String, ProfileItem>()
|
||||
for (srv in serverList.reversed()) {
|
||||
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
|
||||
keyToProfile[key] = config
|
||||
count += 1
|
||||
}
|
||||
if (count > 0) {
|
||||
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||
}
|
||||
return count
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -444,22 +476,8 @@ object AngConfigManager {
|
||||
return null
|
||||
}
|
||||
|
||||
val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
|
||||
VmessFmt.parse(str)
|
||||
} else if (str.startsWith(EConfigType.SHADOWSOCKS.protocolScheme)) {
|
||||
ShadowsocksFmt.parse(str)
|
||||
} else if (str.startsWith(EConfigType.SOCKS.protocolScheme)) {
|
||||
SocksFmt.parse(str)
|
||||
} else if (str.startsWith(EConfigType.TROJAN.protocolScheme)) {
|
||||
TrojanFmt.parse(str)
|
||||
} else if (str.startsWith(EConfigType.VLESS.protocolScheme)) {
|
||||
VlessFmt.parse(str)
|
||||
} else if (str.startsWith(EConfigType.WIREGUARD.protocolScheme)) {
|
||||
WireguardFmt.parse(str)
|
||||
} else if (str.startsWith(EConfigType.HYSTERIA2.protocolScheme) || str.startsWith(HY2)) {
|
||||
Hysteria2Fmt.parse(str)
|
||||
} else {
|
||||
null
|
||||
val config = configFmtParsers.firstNotNullOfOrNull { (scheme, parser) ->
|
||||
if (str.startsWith(scheme)) parser(str) else null
|
||||
}
|
||||
|
||||
if (config == null) {
|
||||
@@ -537,14 +555,28 @@ object AngConfigManager {
|
||||
|
||||
var configText = try {
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort, proxyUsername, proxyPassword)
|
||||
HttpUtil.getUrlContentWithUserAgent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
userAgent = userAgent,
|
||||
timeout = 15000,
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
proxyPassword = proxyPassword
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
|
||||
""
|
||||
}
|
||||
if (configText.isEmpty()) {
|
||||
configText = try {
|
||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent)
|
||||
HttpUtil.getUrlContentWithUserAgent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
userAgent = userAgent
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
|
||||
""
|
||||
|
||||
@@ -4,14 +4,14 @@ import com.tencent.mmkv.MMKV
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
|
||||
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
|
||||
import com.v2ray.ang.dto.AssetUrlCache
|
||||
import com.v2ray.ang.dto.AssetUrlItem
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.RulesetItem
|
||||
import com.v2ray.ang.dto.ServerAffiliationInfo
|
||||
import com.v2ray.ang.dto.SubscriptionCache
|
||||
import com.v2ray.ang.dto.SubscriptionItem
|
||||
import com.v2ray.ang.dto.WebDavConfig
|
||||
import com.v2ray.ang.dto.entities.AssetUrlCache
|
||||
import com.v2ray.ang.dto.entities.AssetUrlItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.RulesetItem
|
||||
import com.v2ray.ang.dto.entities.ServerAffiliationInfo
|
||||
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.dto.entities.WebDavConfig
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
@@ -102,7 +102,7 @@ object MmkvManager {
|
||||
return if (json.isNullOrBlank()) {
|
||||
mutableListOf()
|
||||
} 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()) {
|
||||
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()) {
|
||||
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 ->
|
||||
val json = subStorage.decodeString(key)
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -420,7 +420,7 @@ object MmkvManager {
|
||||
*/
|
||||
fun decodeSubscription(subscriptionId: String): SubscriptionItem? {
|
||||
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()) {
|
||||
mutableListOf()
|
||||
} 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 ->
|
||||
val json = assetStorage.decodeString(key)
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -495,7 +495,7 @@ object MmkvManager {
|
||||
*/
|
||||
fun decodeAsset(assetid: String): AssetUrlItem? {
|
||||
val json = assetStorage.decodeString(assetid) ?: return null
|
||||
return JsonUtil.fromJson(json, AssetUrlItem::class.java)
|
||||
return JsonUtil.fromJsonSafe(json, AssetUrlItem::class.java)
|
||||
}
|
||||
|
||||
//endregion
|
||||
@@ -510,7 +510,7 @@ object MmkvManager {
|
||||
fun decodeRoutingRulesets(): MutableList<RulesetItem>? {
|
||||
val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -679,6 +679,7 @@ object MmkvManager {
|
||||
return settingsStorage.decodeStringSet(key)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Encodes the start on boot setting.
|
||||
*
|
||||
@@ -713,7 +714,7 @@ object MmkvManager {
|
||||
*/
|
||||
fun decodeWebDavConfig(): WebDavConfig? {
|
||||
val json = mainStorage.decodeString(KEY_WEBDAV_CONFIG) ?: return null
|
||||
return JsonUtil.fromJson(json, WebDavConfig::class.java)
|
||||
return JsonUtil.fromJsonSafe(json, WebDavConfig::class.java)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
@@ -13,7 +13,8 @@ import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.extension.toSpeedString
|
||||
import com.v2ray.ang.ui.MainActivity
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -42,53 +43,15 @@ object NotificationManager {
|
||||
* Starts the speed notification.
|
||||
* @param currentConfig The current profile configuration.
|
||||
*/
|
||||
fun startSpeedNotification(currentConfig: ProfileItem?) {
|
||||
fun startSpeedNotification() {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
|
||||
if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return
|
||||
if (speedNotificationJob != null || CoreServiceManager.isRunning() == false) return
|
||||
|
||||
var lastZeroSpeed = false
|
||||
val outboundTags = currentConfig?.getAllOutboundTags()
|
||||
outboundTags?.remove(AppConfig.TAG_DIRECT)
|
||||
|
||||
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
while (isActive) {
|
||||
val queryTime = System.currentTimeMillis()
|
||||
val sinceLastQueryIn = (queryTime - lastQueryTime)
|
||||
|
||||
// If the query interval is too short, skip this round to avoid excessive CPU usage
|
||||
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
|
||||
LogUtil.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
|
||||
lastQueryTime = queryTime
|
||||
delay(QUERY_INTERVAL_MS)
|
||||
continue
|
||||
}
|
||||
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
|
||||
|
||||
var proxyTotal = 0L
|
||||
val text = StringBuilder()
|
||||
outboundTags?.forEach {
|
||||
val up = V2RayServiceManager.queryStats(it, AppConfig.UPLINK)
|
||||
val down = V2RayServiceManager.queryStats(it, AppConfig.DOWNLINK)
|
||||
if (up + down > 0) {
|
||||
appendSpeedString(text, it, up / sinceLastQueryInSeconds, down / sinceLastQueryInSeconds)
|
||||
proxyTotal += up + down
|
||||
}
|
||||
}
|
||||
val directUplink = V2RayServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.UPLINK)
|
||||
val directDownlink = V2RayServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.DOWNLINK)
|
||||
val zeroSpeed = proxyTotal == 0L && directUplink == 0L && directDownlink == 0L
|
||||
if (!zeroSpeed || !lastZeroSpeed) {
|
||||
if (proxyTotal == 0L) {
|
||||
appendSpeedString(text, outboundTags?.firstOrNull(), 0.0, 0.0)
|
||||
}
|
||||
appendSpeedString(
|
||||
text, AppConfig.TAG_DIRECT, directUplink / sinceLastQueryInSeconds,
|
||||
directDownlink / sinceLastQueryInSeconds
|
||||
)
|
||||
updateNotification(text.toString(), proxyTotal, directDownlink + directUplink)
|
||||
}
|
||||
lastZeroSpeed = zeroSpeed
|
||||
lastQueryTime = queryTime
|
||||
lastZeroSpeed = updateSpeedNotificationOnce(lastZeroSpeed)
|
||||
delay(QUERY_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
@@ -167,13 +130,12 @@ object NotificationManager {
|
||||
|
||||
/**
|
||||
* Stops the speed notification.
|
||||
* @param currentConfig The current profile configuration.
|
||||
*/
|
||||
fun stopSpeedNotification(currentConfig: ProfileItem?) {
|
||||
fun stopSpeedNotification() {
|
||||
speedNotificationJob?.let {
|
||||
it.cancel()
|
||||
speedNotificationJob = null
|
||||
updateNotification(currentConfig?.remarks, 0, 0)
|
||||
updateNotification("", 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,11 +208,74 @@ object NotificationManager {
|
||||
text.append("• ${up.toLong().toSpeedString()}↑ ${down.toLong().toSpeedString()}↓\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the speed notification once.
|
||||
* Queries traffic stats, separates proxy and direct, and updates the notification.
|
||||
* @param lastZeroSpeed The previous zero speed state.
|
||||
* @return The current zero speed state.
|
||||
*/
|
||||
private fun updateSpeedNotificationOnce(lastZeroSpeed: Boolean): Boolean {
|
||||
val queryTime = System.currentTimeMillis()
|
||||
val sinceLastQueryIn = (queryTime - lastQueryTime)
|
||||
|
||||
// If the query interval is too short, skip this round to avoid excessive CPU usage
|
||||
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
|
||||
LogUtil.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
|
||||
lastQueryTime = queryTime
|
||||
return lastZeroSpeed
|
||||
}
|
||||
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
|
||||
|
||||
var proxyUplink = 0L
|
||||
var proxyDownlink = 0L
|
||||
var directUplink = 0L
|
||||
var directDownlink = 0L
|
||||
|
||||
CoreServiceManager.queryAllOutboundTrafficStats().forEach { stat ->
|
||||
when {
|
||||
stat.tag == AppConfig.TAG_DIRECT -> {
|
||||
when (stat.direction) {
|
||||
AppConfig.UPLINK -> directUplink += stat.value
|
||||
AppConfig.DOWNLINK -> directDownlink += stat.value
|
||||
}
|
||||
}
|
||||
|
||||
stat.tag.startsWith(AppConfig.TAG_PROXY) -> {
|
||||
when (stat.direction) {
|
||||
AppConfig.UPLINK -> proxyUplink += stat.value
|
||||
AppConfig.DOWNLINK -> proxyDownlink += stat.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val proxyTotal = proxyUplink + proxyDownlink
|
||||
val directTotal = directUplink + directDownlink
|
||||
val zeroSpeed = proxyTotal + directTotal == 0L
|
||||
if (!zeroSpeed || !lastZeroSpeed) {
|
||||
val text = StringBuilder()
|
||||
appendSpeedString(
|
||||
text, AppConfig.TAG_PROXY,
|
||||
proxyUplink / sinceLastQueryInSeconds,
|
||||
proxyDownlink / sinceLastQueryInSeconds
|
||||
)
|
||||
|
||||
appendSpeedString(
|
||||
text, AppConfig.TAG_DIRECT,
|
||||
directUplink / sinceLastQueryInSeconds,
|
||||
directDownlink / sinceLastQueryInSeconds
|
||||
)
|
||||
updateNotification(text.toString(), proxyTotal, directTotal)
|
||||
}
|
||||
lastQueryTime = queryTime
|
||||
return zeroSpeed
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the service instance.
|
||||
* @return The service instance.
|
||||
*/
|
||||
private fun getService(): Service? {
|
||||
return V2RayServiceManager.serviceControl?.get()?.getService()
|
||||
return CoreServiceManager.serviceControl?.get()?.getService()
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,10 @@ import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
|
||||
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
|
||||
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
||||
import com.v2ray.ang.AppConfig.VPN
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.RulesetItem
|
||||
import com.v2ray.ang.dto.SubscriptionItem
|
||||
import com.v2ray.ang.dto.V2rayConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.RulesetItem
|
||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.Language
|
||||
import com.v2ray.ang.enums.RoutingType
|
||||
@@ -73,7 +73,7 @@ object SettingsManager {
|
||||
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 {
|
||||
val rulesetList = JsonUtil.fromJson(content, Array<RulesetItem>::class.java)?.toMutableList()
|
||||
val rulesetList = JsonUtil.fromJsonSafe(content, Array<RulesetItem>::class.java)?.toMutableList()
|
||||
if (rulesetList.isNullOrEmpty()) {
|
||||
return false
|
||||
}
|
||||
@@ -191,7 +191,7 @@ object SettingsManager {
|
||||
val config = decodeServerConfig(guid) ?: return false
|
||||
if (config.configType == EConfigType.CUSTOM) {
|
||||
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 {
|
||||
it.domain?.contains(GEOSITE_PRIVATE) == true || it.ip?.contains(GEOIP_PRIVATE) == true
|
||||
}
|
||||
@@ -246,18 +246,27 @@ object SettingsManager {
|
||||
.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.
|
||||
* If there are no remaining subscriptions,
|
||||
* it creates a new default subscription to ensure that ungroup
|
||||
**/
|
||||
fun removeSubscriptionWithDefault(subid: String) {
|
||||
// val subsList = decodeSubsList()
|
||||
// if (subsList.size == 1 && subsList.first() == DEFAULT_SUBSCRIPTION_ID) {
|
||||
// LogUtil.i(ANG_PACKAGE,"Attempted to remove the only existing default subscription, operation ignored.")
|
||||
// return
|
||||
// }
|
||||
|
||||
SubscriptionUpdater.cancelOne(subId = subid)
|
||||
// Remove the subscription
|
||||
removeSubscription(subid)
|
||||
|
||||
@@ -398,6 +407,15 @@ object SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real ping concurrency.
|
||||
* @return The number of concurrent real-ping tests (clamped to 1..64).
|
||||
*/
|
||||
fun getRealPingConcurrency(): Int {
|
||||
val value = MmkvManager.decodeSettingsString(AppConfig.PREF_REAL_PING_CONCURRENCY)?.toIntOrNull() ?: 16
|
||||
return value.coerceIn(1, 128)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the locale.
|
||||
* @return The locale.
|
||||
@@ -499,7 +517,6 @@ object SettingsManager {
|
||||
ensureDefaultValue(AppConfig.PREF_MODE, VPN)
|
||||
ensureDefaultValue(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
|
||||
ensureDefaultValue(AppConfig.PREF_VPN_MTU, AppConfig.VPN_MTU.toString())
|
||||
ensureDefaultValue(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL, AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL)
|
||||
ensureDefaultValue(AppConfig.PREF_SOCKS_PORT, AppConfig.PORT_SOCKS)
|
||||
ensureDefaultValue(AppConfig.PREF_REMOTE_DNS, AppConfig.DNS_PROXY)
|
||||
ensureDefaultValue(AppConfig.PREF_DOMESTIC_DNS, AppConfig.DNS_DIRECT)
|
||||
@@ -568,7 +585,7 @@ object SettingsManager {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.os.SystemClock
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.IPAPIInfo
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -90,8 +89,16 @@ object SpeedtestManager {
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
if (httpPort == 0) return null
|
||||
val content = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword) ?: return null
|
||||
val ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
|
||||
val content = HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000,
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
proxyPassword = proxyPassword
|
||||
)
|
||||
) ?: return null
|
||||
val ipInfo = JsonUtil.fromJsonSafe(content, IPAPIInfo::class.java) ?: return null
|
||||
|
||||
val ip = listOf(
|
||||
ipInfo.ip,
|
||||
|
||||
@@ -1,62 +1,192 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.multiprocess.RemoteWorkManager
|
||||
import androidx.work.workDataOf
|
||||
import com.v2ray.ang.AngApplication
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||
import com.v2ray.ang.enums.NotificationChannelType
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.NotificationHelper
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object SubscriptionUpdater {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API — the only methods external callers should ever use
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sync all subscription tasks with current settings.
|
||||
*
|
||||
* Startup/boot callers should use the default mode so existing periodic work is kept.
|
||||
* Use forceReschedule=true only when the next run time needs to be recalculated from
|
||||
* the latest persisted subscription state (for example after a manual refresh).
|
||||
* Call from: MainActivity.onCreate(), BootReceiver.onReceive().
|
||||
*/
|
||||
fun sync(
|
||||
context: Context = AngApplication.application,
|
||||
forceReschedule: Boolean = false
|
||||
) {
|
||||
val existingWorkPolicy =
|
||||
if (forceReschedule) {
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
} else {
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
}
|
||||
|
||||
MmkvManager.decodeSubscriptions().forEach { sub ->
|
||||
scheduleOne(
|
||||
context = context,
|
||||
subId = sub.guid,
|
||||
shouldRun = sub.subscription.autoUpdate,
|
||||
existingWorkPolicy = existingWorkPolicy
|
||||
)
|
||||
}
|
||||
LogUtil.i(
|
||||
AppConfig.TAG,
|
||||
"SubscriptionUpdater: sync complete forceReschedule=$forceReschedule"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a single subscription's task.
|
||||
* Call from: SubEditActivity after saving, after a manual update (to reset the timer).
|
||||
*/
|
||||
fun syncOne(context: Context = AngApplication.application, subId: String) {
|
||||
val subItem = MmkvManager.decodeSubscription(subId) ?: return
|
||||
scheduleOne(
|
||||
context = context,
|
||||
subId = subId,
|
||||
shouldRun = subItem.autoUpdate,
|
||||
existingWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the auto-update task for a single subscription.
|
||||
* Call from: when a subscription is deleted.
|
||||
*/
|
||||
fun cancelOne(context: Context = AngApplication.application, subId: String) {
|
||||
RemoteWorkManager.getInstance(context)
|
||||
.cancelUniqueWork(taskName(subId))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal scheduling logic
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun taskName(subId: String) = "${AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME}_$subId"
|
||||
|
||||
private fun scheduleOne(
|
||||
context: Context,
|
||||
subId: String,
|
||||
shouldRun: Boolean,
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy
|
||||
) {
|
||||
val rw = RemoteWorkManager.getInstance(context)
|
||||
if (!shouldRun) {
|
||||
rw.cancelUniqueWork(taskName(subId))
|
||||
LogUtil.d(AppConfig.TAG, "SubscriptionUpdater: cancelled task for $subId")
|
||||
return
|
||||
}
|
||||
|
||||
val subItem = MmkvManager.decodeSubscription(subId) ?: return
|
||||
|
||||
val intervalMinutes = maxOf(
|
||||
AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES,
|
||||
subItem.updateInterval
|
||||
)
|
||||
|
||||
// Base initial delay on the last successful update time persisted in subscription.
|
||||
val lastUpdated = subItem.lastUpdated
|
||||
val intervalMillis = intervalMinutes * 60 * 1000L
|
||||
val now = System.currentTimeMillis()
|
||||
val initialDelayMillis = if (lastUpdated <= 0L) {
|
||||
0L
|
||||
} else {
|
||||
maxOf(0L, lastUpdated + intervalMillis - now)
|
||||
}
|
||||
|
||||
val request = PeriodicWorkRequestBuilder<UpdateTask>(intervalMinutes, TimeUnit.MINUTES)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.setInputData(workDataOf(KEY_SUB_ID to subId))
|
||||
.setInitialDelay(initialDelayMillis, TimeUnit.MILLISECONDS)
|
||||
.addTag(AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME)
|
||||
.build()
|
||||
|
||||
rw.enqueueUniquePeriodicWork(
|
||||
taskName(subId),
|
||||
existingWorkPolicy,
|
||||
request
|
||||
)
|
||||
|
||||
LogUtil.i(
|
||||
AppConfig.TAG,
|
||||
"SubscriptionUpdater: scheduled [$subId] interval=${intervalMinutes}min " +
|
||||
"initialDelay=${initialDelayMillis / 1000}s policy=$existingWorkPolicy"
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Worker
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private const val KEY_SUB_ID = "subId"
|
||||
|
||||
class UpdateTask(context: Context, params: WorkerParameters) :
|
||||
CoroutineWorker(context, params) {
|
||||
|
||||
private val notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
private val notification =
|
||||
NotificationCompat.Builder(applicationContext, AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
|
||||
.setWhen(0)
|
||||
.setTicker("Update")
|
||||
.setContentTitle(context.getString(R.string.title_pref_auto_update_subscription))
|
||||
.setSmallIcon(R.drawable.ic_stat_name)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
|
||||
/**
|
||||
* Performs the subscription update work.
|
||||
* @return The result of the work.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun doWork(): Result {
|
||||
LogUtil.i(AppConfig.TAG, "subscription automatic update starting")
|
||||
val subId = inputData.getString(KEY_SUB_ID)
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater automatic update starting: $subId")
|
||||
|
||||
val subs = MmkvManager.decodeSubscriptions().filter { it.subscription.autoUpdate }
|
||||
|
||||
for (sub in subs) {
|
||||
val subItem = sub.subscription
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notification.setChannelId(AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
AppConfig.SUBSCRIPTION_UPDATE_CHANNEL,
|
||||
AppConfig.SUBSCRIPTION_UPDATE_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_MIN
|
||||
)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
notificationManager.notify(3, notification.build())
|
||||
LogUtil.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
|
||||
AngConfigManager.updateConfigViaSub(sub)
|
||||
notification.setContentText("Updating ${subItem.remarks}")
|
||||
if (subId.isNullOrEmpty()) {
|
||||
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: missing subId in worker input")
|
||||
return Result.success()
|
||||
}
|
||||
notificationManager.cancel(3)
|
||||
|
||||
val subItem = MmkvManager.decodeSubscription(subId)
|
||||
if (subItem == null) {
|
||||
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: no subscription found for $subId")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
if (!subItem.autoUpdate) {
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: auto-update disabled for $subId, skip")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val sub = SubscriptionCache(subId, subItem)
|
||||
|
||||
// Notify about update start
|
||||
NotificationHelper.notify(
|
||||
NotificationChannelType.SUBSCRIPTION_UPDATE,
|
||||
applicationContext,
|
||||
applicationContext.getString(R.string.title_pref_auto_update_subscription),
|
||||
"Updating ${sub.subscription.remarks}"
|
||||
)
|
||||
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater automatic update: ---${sub.subscription.remarks}")
|
||||
AngConfigManager.updateConfigViaSub(sub)
|
||||
|
||||
// Clear notification
|
||||
NotificationHelper.cancel(NotificationChannelType.SUBSCRIPTION_UPDATE, applicationContext)
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.dto.CheckUpdateResult
|
||||
import com.v2ray.ang.dto.GitHubRelease
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
import com.v2ray.ang.extension.concatUrl
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
object UpdateCheckerManager {
|
||||
suspend fun checkForUpdate(includePreRelease: Boolean = false): CheckUpdateResult = withContext(Dispatchers.IO) {
|
||||
@@ -26,19 +24,32 @@ object UpdateCheckerManager {
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
|
||||
var response = HttpUtil.getUrlContent(url, 5000)
|
||||
var response = HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000
|
||||
)
|
||||
)
|
||||
if (response.isNullOrEmpty()) {
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
response = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword)
|
||||
response = HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000,
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
proxyPassword = proxyPassword
|
||||
)
|
||||
)
|
||||
?: throw IllegalStateException("Failed to get response")
|
||||
}
|
||||
|
||||
val latestRelease = if (includePreRelease) {
|
||||
JsonUtil.fromJson(response, Array<GitHubRelease>::class.java)
|
||||
JsonUtil.fromJsonSafe(response, Array<GitHubRelease>::class.java)
|
||||
?.firstOrNull()
|
||||
?: throw IllegalStateException("No pre-release found")
|
||||
} else {
|
||||
JsonUtil.fromJson(response, GitHubRelease::class.java)
|
||||
JsonUtil.fromJsonSafe(response, GitHubRelease::class.java)
|
||||
}
|
||||
if (latestRelease == null) {
|
||||
return@withContext CheckUpdateResult(hasUpdate = false)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.WebDavConfig
|
||||
import com.v2ray.ang.dto.entities.WebDavConfig
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -4,8 +4,9 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
import com.v2ray.ang.handler.SubscriptionUpdater
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
@@ -36,6 +37,7 @@ class BootReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
LogUtil.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
|
||||
V2RayServiceManager.startVService(context)
|
||||
CoreServiceManager.startVService(context)
|
||||
SubscriptionUpdater.sync(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.text.TextUtils
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
class TaskerReceiver : BroadcastReceiver() {
|
||||
@@ -28,12 +28,12 @@ class TaskerReceiver : BroadcastReceiver() {
|
||||
return
|
||||
} else if (switch) {
|
||||
if (guid == AppConfig.TASKER_DEFAULT_GUID) {
|
||||
V2RayServiceManager.startVServiceFromToggle(context)
|
||||
CoreServiceManager.startVServiceFromToggle(context)
|
||||
} else {
|
||||
V2RayServiceManager.startVService(context, guid)
|
||||
CoreServiceManager.startVService(context, guid)
|
||||
}
|
||||
} else {
|
||||
V2RayServiceManager.stopVService(context)
|
||||
CoreServiceManager.stopVService(context)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
|
||||
|
||||
@@ -9,7 +9,7 @@ import android.content.Intent
|
||||
import android.widget.RemoteViews
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
|
||||
class WidgetProvider : AppWidgetProvider() {
|
||||
/**
|
||||
@@ -22,7 +22,7 @@ class WidgetProvider : AppWidgetProvider() {
|
||||
*/
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
updateWidgetBackground(context, appWidgetManager, appWidgetIds, V2RayServiceManager.isRunning())
|
||||
updateWidgetBackground(context, appWidgetManager, appWidgetIds, CoreServiceManager.isRunning())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,10 +67,10 @@ class WidgetProvider : AppWidgetProvider() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
super.onReceive(context, intent)
|
||||
if (AppConfig.BROADCAST_ACTION_WIDGET_CLICK == intent.action) {
|
||||
if (V2RayServiceManager.isRunning()) {
|
||||
V2RayServiceManager.stopVService(context)
|
||||
if (CoreServiceManager.isRunning()) {
|
||||
CoreServiceManager.stopVService(context)
|
||||
} else {
|
||||
V2RayServiceManager.startVServiceFromToggle(context)
|
||||
CoreServiceManager.startVServiceFromToggle(context)
|
||||
}
|
||||
} else if (AppConfig.BROADCAST_ACTION_ACTIVITY == intent.action) {
|
||||
AppWidgetManager.getInstance(context)?.let { manager ->
|
||||
|
||||
+5
-5
@@ -6,20 +6,20 @@ import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.contracts.ServiceControl
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.MyContextWrapper
|
||||
import java.lang.ref.SoftReference
|
||||
|
||||
class V2RayProxyOnlyService : Service(), ServiceControl {
|
||||
class CoreProxyOnlyService : Service(), ServiceControl {
|
||||
/**
|
||||
* Initializes the service.
|
||||
*/
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service created")
|
||||
V2RayServiceManager.serviceControl = SoftReference(this)
|
||||
CoreServiceManager.serviceControl = SoftReference(this)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +31,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
|
||||
V2RayServiceManager.startCoreLoop(null)
|
||||
CoreServiceManager.startCoreLoop(null)
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
V2RayServiceManager.stopCoreLoop()
|
||||
CoreServiceManager.stopCoreLoop()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
import com.v2ray.ang.dto.RealPingEvent
|
||||
import com.v2ray.ang.dto.TestServiceMessage
|
||||
import com.v2ray.ang.enums.NotificationChannelType
|
||||
import com.v2ray.ang.extension.serializable
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
import com.v2ray.ang.util.NotificationHelper
|
||||
import java.util.Collections
|
||||
|
||||
class CoreTestService : Service() {
|
||||
|
||||
// manage active batch workers so each batch is independent and cancellable
|
||||
private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
|
||||
|
||||
/**
|
||||
* Initializes the V2Ray environment.
|
||||
*/
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CoreNativeManager.initCoreEnv(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the service.
|
||||
* @param intent The intent.
|
||||
* @return The binder.
|
||||
*/
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up resources when the service is destroyed.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
LogUtil.i(AppConfig.TAG, "CoreTestService is being destroyed, cancelling ${activeWorkers.size} active workers")
|
||||
// cancel any active workers
|
||||
val snapshot = ArrayList(activeWorkers)
|
||||
snapshot.forEach { it.cancel() }
|
||||
activeWorkers.clear()
|
||||
NotificationHelper.stopForeground(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the start command for the service.
|
||||
* @param intent The intent.
|
||||
* @param flags The flags.
|
||||
* @param startId The start ID.
|
||||
* @return The start mode.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val message = intent?.serializable<TestServiceMessage>("content")
|
||||
if (message == null) {
|
||||
stopSelf(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
when (message.key) {
|
||||
AppConfig.MSG_MEASURE_CONFIG_START -> handleMeasureStart(message, startId)
|
||||
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> handleMeasureCancel()
|
||||
else -> {
|
||||
NotificationHelper.stopForeground(this); stopSelf(startId)
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun handleMeasureStart(message: TestServiceMessage, startId: Int) {
|
||||
LogUtil.i(AppConfig.TAG, "CoreTestService starting worker subscription ${message.subscriptionId}")
|
||||
|
||||
NotificationHelper.startForeground(
|
||||
this,
|
||||
NotificationChannelType.CORE_TEST,
|
||||
getString(R.string.app_name),
|
||||
getString(R.string.title_real_ping_all_server)
|
||||
)
|
||||
|
||||
val guidsList = when {
|
||||
message.serverGuids.isNotEmpty() -> message.serverGuids
|
||||
message.subscriptionId.isNotEmpty() -> MmkvManager.decodeServerList(message.subscriptionId)
|
||||
else -> MmkvManager.decodeAllServerList()
|
||||
}
|
||||
|
||||
if (guidsList.isNotEmpty()) {
|
||||
lateinit var worker: RealPingWorkerService
|
||||
worker = RealPingWorkerService(
|
||||
context = this,
|
||||
guids = guidsList,
|
||||
onEvent = { event -> handleWorkerEvent(event) { activeWorkers.remove(worker) } }
|
||||
)
|
||||
activeWorkers.add(worker)
|
||||
worker.start()
|
||||
} else {
|
||||
NotificationHelper.stopForeground(this)
|
||||
stopSelf(startId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWorkerEvent(event: RealPingEvent, onWorkerDone: () -> Unit) {
|
||||
when (event) {
|
||||
is RealPingEvent.Progress -> {
|
||||
NotificationHelper.updateNotification(
|
||||
channelType = NotificationChannelType.CORE_TEST,
|
||||
context = this,
|
||||
content = getString(R.string.connection_runing_task_left, event.text)
|
||||
)
|
||||
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text)
|
||||
}
|
||||
|
||||
is RealPingEvent.Result -> {
|
||||
MmkvManager.encodeServerTestDelayMillis(event.guid, event.delayMillis)
|
||||
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, event.guid)
|
||||
}
|
||||
|
||||
is RealPingEvent.Finish -> {
|
||||
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status)
|
||||
onWorkerDone()
|
||||
if (activeWorkers.isEmpty()) {
|
||||
NotificationHelper.stopForeground(this)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMeasureCancel() {
|
||||
LogUtil.i(AppConfig.TAG, "CoreTestService received cancel message, cancelling ${activeWorkers.size} active workers")
|
||||
val snapshot = ArrayList(activeWorkers)
|
||||
snapshot.forEach { it.cancel() }
|
||||
activeWorkers.clear()
|
||||
NotificationHelper.stopForeground(this)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -20,17 +20,17 @@ import com.v2ray.ang.AppConfig.LOOPBACK
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.contracts.ServiceControl
|
||||
import com.v2ray.ang.contracts.Tun2SocksControl
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.NotificationManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.MyContextWrapper
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.lang.ref.SoftReference
|
||||
|
||||
@SuppressLint("VpnServicePolicy")
|
||||
class V2RayVpnService : VpnService(), ServiceControl {
|
||||
class CoreVpnService : VpnService(), ServiceControl {
|
||||
private lateinit var mInterface: ParcelFileDescriptor
|
||||
private var isRunning = false
|
||||
private var tun2SocksService: Tun2SocksControl? = null
|
||||
@@ -77,7 +77,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service created")
|
||||
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
|
||||
StrictMode.setThreadPolicy(policy)
|
||||
V2RayServiceManager.serviceControl = SoftReference(this)
|
||||
CoreServiceManager.serviceControl = SoftReference(this)
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
@@ -113,6 +113,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service command received")
|
||||
NotificationManager.showNotification(null)
|
||||
setupVpnService()
|
||||
startService()
|
||||
return START_STICKY
|
||||
@@ -128,7 +129,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Interface not initialized")
|
||||
return
|
||||
}
|
||||
if (!V2RayServiceManager.startCoreLoop(mInterface)) {
|
||||
if (!CoreServiceManager.startCoreLoop(mInterface)) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to start core loop")
|
||||
stopAllService()
|
||||
return
|
||||
@@ -361,7 +362,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
tun2SocksService?.stopTun2Socks()
|
||||
tun2SocksService = null
|
||||
|
||||
V2RayServiceManager.stopCoreLoop()
|
||||
CoreServiceManager.stopCoreLoop()
|
||||
|
||||
if (isForced) {
|
||||
//stopSelf has to be called ahead of mInterface.close(). otherwise v2ray core cannot be stooped
|
||||
@@ -0,0 +1,774 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.content.Context
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Call
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.Buffer
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.EOFException
|
||||
import java.net.SocketException
|
||||
import java.net.URI
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
// This class is responsible for forwarding xray's HTTP/WS requests through okhttp,
|
||||
// so that its TLS and traffic characteristics are okhttp instead of golang/utls.
|
||||
// Only WS and xhttp package-up are supported for now.
|
||||
// ws:
|
||||
// DialerNativeService connects to the control WebSocket provided by the xray core.
|
||||
// Then xray sends a task message with method "WS" and the server URL to the control WebSocket.
|
||||
// DialerNativeService opens a WebSocket connection to the server URL, and forwards messages between the control WebSocket and the target WebSocket.
|
||||
// xhttp(package-up):
|
||||
// A task message with streaming down (method == "GET" and streamResponse == true), let's call it Task A.
|
||||
// A task message with unary down (streamResponse == false), let's call it Task B.
|
||||
// 1. DialerNativeService connects to the control WebSocket provided by the xray core.
|
||||
// 2. Xray sends Task A, DialerNativeService sends "ok" and connects to the target URL, then sends the "GET" request to the server; let's call this "Connection A".
|
||||
// 3. Xray sends a Task B, called B_1. DialerNativeService sends "ok" and sends the data body to the target URL. Whatever the response is, DialerNativeService sends "ok" or "fail" back to the control WebSocket and closes the connection for Task B_1.
|
||||
// 4. The server returns the response for Task B_1 through Connection A, and DialerNativeService forwards the response body to the control WebSocket.
|
||||
// 5. Xray sends another Task B, called B_2.
|
||||
// ...
|
||||
// Finally, the xray client core sends all data through the B_1, B_2, ... tasks.
|
||||
// The server closes Connection A, and DialerNativeService closes Task A.
|
||||
// The above is a complete cycle.
|
||||
class DialerNativeService : IDialerService {
|
||||
companion object {
|
||||
private const val DEBUG_LOG = false
|
||||
private val NEXT_SOCKET_ID = AtomicLong(0L)
|
||||
|
||||
private const val CONTROL_SOCKET_IDLE = 0
|
||||
private const val CONTROL_SOCKET_OPENING = 1
|
||||
private const val CONTROL_LOOP_DELAY_MS = 1000L
|
||||
private const val UNARY_BODY_WAIT_TIMEOUT_MS = 15_000L
|
||||
private val TOKEN_REGEX = Regex("""/websocket\?token=([^"'\s]+)""")
|
||||
private val METHODS_WITHOUT_BODY = setOf("GET", "HEAD")
|
||||
private val HEADERS_BLACKLIST = hashSetOf(
|
||||
// AI suggest:
|
||||
"host",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"content-encoding",
|
||||
"connection",
|
||||
"upgrade",
|
||||
"sec-websocket-key",
|
||||
"sec-websocket-version",
|
||||
"sec-websocket-protocol",
|
||||
"Timing-Allow-Origin",
|
||||
// xray
|
||||
"Set-Cookie",
|
||||
"Cookie",
|
||||
"Origin",
|
||||
"Sec-CH-UA",
|
||||
"Sec-CH-UA-Mobile",
|
||||
"Sec-CH-UA-Platform",
|
||||
"DNT",
|
||||
"User-Agent",
|
||||
"Accept-Language",
|
||||
"Cache-Control",
|
||||
"Upgrade-Insecure-Requests",
|
||||
"Sec-Fetch-Site",
|
||||
"Sec-Fetch-Mode",
|
||||
"Sec-Fetch-User",
|
||||
"Sec-Fetch-Dest",
|
||||
"Referer",
|
||||
"Accept",
|
||||
"Priority",
|
||||
"Pragma",
|
||||
"Access-Control-Allow-Origin",
|
||||
"Access-Control-Allow-Credentials",
|
||||
"Access-Control-Allow-Methods",
|
||||
"Access-Control-Allow-Headers",
|
||||
"Access-Control-Expose-Headers",
|
||||
"Access-Control-Max-Age",
|
||||
)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var serviceJob = SupervisorJob()
|
||||
|
||||
@Volatile
|
||||
private var scope = CoroutineScope(serviceJob + Dispatchers.IO)
|
||||
private val running = AtomicBoolean(false)
|
||||
private val controlSocketState = AtomicInteger(CONTROL_SOCKET_IDLE)
|
||||
private val controlSockets = ConcurrentHashMap.newKeySet<WebSocket>()
|
||||
|
||||
@Volatile
|
||||
private var controlUrl: String? = null
|
||||
private var loopJob: Job? = null
|
||||
private var client: OkHttpClient? = null
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
override fun start(context: Context, dialerAddr: String) {
|
||||
stop()
|
||||
serviceJob = SupervisorJob()
|
||||
scope = CoroutineScope(serviceJob + Dispatchers.IO)
|
||||
if (dialerAddr.isEmpty()) return
|
||||
|
||||
val nativeClient = OkHttpClient.Builder()
|
||||
.retryOnConnectionFailure(true)
|
||||
.pingInterval(25, TimeUnit.SECONDS)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.SECONDS) // Disable read timeout for long-running streams
|
||||
.build()
|
||||
|
||||
client = nativeClient
|
||||
running.set(true)
|
||||
loopJob = scope.launch {
|
||||
val resolvedControlUrl = resolveControlWsUrl(dialerAddr, nativeClient)
|
||||
if (resolvedControlUrl == null) {
|
||||
debug(
|
||||
"BrowserDialer: failed to resolve control url from dialer endpoint: $dialerAddr"
|
||||
)
|
||||
running.set(false)
|
||||
return@launch
|
||||
}
|
||||
controlUrl = resolvedControlUrl
|
||||
debug("BrowserDialer: started dialerAddr=$dialerAddr controlUrl=$resolvedControlUrl idleGate=${controlSocketState.get()}")
|
||||
maintainControlSocketPool()
|
||||
while (isActive && running.get()) {
|
||||
maintainControlSocketPool()
|
||||
delay(CONTROL_LOOP_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
running.set(false)
|
||||
loopJob?.cancel()
|
||||
loopJob = null
|
||||
serviceJob.cancel()
|
||||
|
||||
debug("BrowserDialer: stopping ${poolState()} controlUrl=$controlUrl")
|
||||
|
||||
controlSockets.toTypedArray().forEach { socket ->
|
||||
runCatching { socket.close(1000, "stopped") }
|
||||
}
|
||||
controlSockets.clear()
|
||||
|
||||
controlSocketState.set(CONTROL_SOCKET_IDLE)
|
||||
controlUrl = null
|
||||
|
||||
serviceJob = SupervisorJob()
|
||||
scope = CoroutineScope(serviceJob + Dispatchers.IO)
|
||||
|
||||
val oldClient = client
|
||||
client = null
|
||||
oldClient?.dispatcher?.cancelAll()
|
||||
oldClient?.connectionPool?.evictAll()
|
||||
}
|
||||
|
||||
private fun maintainControlSocketPool() {
|
||||
debug("BrowserDialer: maintaining single idle control socket ${poolState()}")
|
||||
openControlSocket()
|
||||
}
|
||||
|
||||
private fun openControlSocket(): Boolean {
|
||||
val localClient = client ?: return false
|
||||
if (!running.get()) return false
|
||||
val url = controlUrl ?: return false
|
||||
if (!controlSocketState.compareAndSet(
|
||||
CONTROL_SOCKET_IDLE,
|
||||
CONTROL_SOCKET_OPENING
|
||||
)
|
||||
) return false
|
||||
|
||||
val request = Request.Builder().url(url).build()
|
||||
return runCatching {
|
||||
val socket = localClient.newWebSocket(request, ControlSocketListener(url))
|
||||
controlSockets.add(socket)
|
||||
debug("BrowserDialer: opening control socket url=$url ${poolState()}")
|
||||
true
|
||||
}.getOrElse {
|
||||
controlSocketState.set(CONTROL_SOCKET_IDLE)
|
||||
debug("BrowserDialer: failed to open control socket", it)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun poolState(): String {
|
||||
return "idleGate=${controlSocketState.get()} liveSockets=${controlSockets.size}"
|
||||
}
|
||||
|
||||
private fun debug(message: String, throwable: Throwable? = null) {
|
||||
@Suppress("KotlinConstantConditions")
|
||||
if (!DEBUG_LOG) return
|
||||
if (throwable == null) {
|
||||
LogUtil.d(AppConfig.TAG, message)
|
||||
} else {
|
||||
LogUtil.d(AppConfig.TAG, message, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveControlWsUrl(rawAddr: String, probeClient: OkHttpClient): String? {
|
||||
val uri = parseDialerUri(rawAddr) ?: return null
|
||||
val probeUrl = buildDialerProbeUrl(rawAddr) ?: return null
|
||||
val request = Request.Builder().url(probeUrl).get().build()
|
||||
val token = runCatching {
|
||||
probeClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return null
|
||||
extractControlToken(response.body.string())
|
||||
}
|
||||
}.getOrNull() ?: return null
|
||||
val host = uri.host ?: return null
|
||||
return URI(
|
||||
"ws",
|
||||
uri.userInfo,
|
||||
host,
|
||||
uri.port,
|
||||
"/websocket",
|
||||
"token=$token",
|
||||
null
|
||||
).toString()
|
||||
}
|
||||
|
||||
private fun buildDialerProbeUrl(rawAddr: String): String? {
|
||||
val normalized = rawAddr.trim()
|
||||
if (normalized.isEmpty()) return null
|
||||
|
||||
val uri = parseDialerUri(normalized) ?: return null
|
||||
val host = uri.host ?: return null
|
||||
val probeScheme = when (uri.scheme?.lowercase()) {
|
||||
"https", "wss" -> "https"
|
||||
else -> "http"
|
||||
}
|
||||
return URI(probeScheme, uri.userInfo, host, uri.port, "/", null, null).toString()
|
||||
}
|
||||
|
||||
private fun parseDialerUri(rawAddr: String): URI? {
|
||||
val normalized = rawAddr.trim()
|
||||
if (normalized.isEmpty()) return null
|
||||
return runCatching {
|
||||
if (normalized.contains("://")) URI(normalized) else URI("http://$normalized")
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun extractControlToken(html: String): String? {
|
||||
val match = TOKEN_REGEX.find(html) ?: return null
|
||||
return match.groupValues.getOrNull(1)?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private inner class ControlSocketListener(
|
||||
private val controlUrl: String
|
||||
) : WebSocketListener() {
|
||||
private val socketId = if (DEBUG_LOG) NEXT_SOCKET_ID.incrementAndGet() else 0L
|
||||
private val taskAccepted = AtomicBoolean(false)
|
||||
private val closed = AtomicBoolean(false)
|
||||
private val taskStartedAtMs = AtomicLong(0L)
|
||||
|
||||
@Volatile
|
||||
private var taskKind = "none"
|
||||
private var upstreamSocket: WebSocket? = null
|
||||
private var upstreamCall: Call? = null
|
||||
private var timeoutJob: Job? = null
|
||||
private var binaryHandler: ((ByteArray) -> Unit)? = null
|
||||
private var textHandler: ((String) -> Unit)? = null
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
debug("BrowserDialer: control socket opened socketId=$socketId url=$controlUrl ${poolState()}")
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (taskAccepted.compareAndSet(false, true)) {
|
||||
controlSocketState.set(CONTROL_SOCKET_IDLE)
|
||||
debug(
|
||||
"BrowserDialer: control socket accepted task socketId=$socketId url=$controlUrl textSize=${text.length} ${poolState()}"
|
||||
)
|
||||
tryOpenNextControlSocket()
|
||||
handleTask(webSocket, BrowserDialerTask.parse(text))
|
||||
return
|
||||
}
|
||||
textHandler?.invoke(text)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
if (!taskAccepted.get()) {
|
||||
failAndClose(webSocket, 1002, "task must be text json")
|
||||
return
|
||||
}
|
||||
binaryHandler?.invoke(bytes.toByteArray())
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
cleanup(webSocket)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
val status = response?.code?.toString() ?: "no-http-response"
|
||||
val stateText = "${poolState()} accepted=${taskAccepted.get()} closed=${closed.get()}"
|
||||
if (isExpectedControlFailure(t, status)) {
|
||||
debug(
|
||||
"BrowserDialer: control socket closed socketId=$socketId url=$controlUrl status=$status cause=${t.javaClass.simpleName} $stateText"
|
||||
)
|
||||
debug(
|
||||
"BrowserDialer: control socket failure detail socketId=$socketId url=$controlUrl status=$status $stateText",
|
||||
t
|
||||
)
|
||||
} else {
|
||||
debug(
|
||||
"BrowserDialer: control socket failure socketId=$socketId url=$controlUrl status=$status $stateText",
|
||||
t
|
||||
)
|
||||
}
|
||||
cleanup(webSocket)
|
||||
}
|
||||
|
||||
private fun isExpectedControlFailure(t: Throwable, status: String): Boolean {
|
||||
if (!running.get() || closed.get()) return true
|
||||
if (status != "no-http-response") return false
|
||||
if (t is EOFException) return true
|
||||
if (t is SocketException) {
|
||||
val message = t.message.orEmpty().lowercase()
|
||||
if ("socket closed" in message || "software caused connection abort" in message) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
val message = t.message.orEmpty().lowercase()
|
||||
return "canceled" in message || "cancelled" in message
|
||||
}
|
||||
|
||||
|
||||
private fun tryOpenNextControlSocket() {
|
||||
scope.launch {
|
||||
if (running.get()) maintainControlSocketPool()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanup(webSocket: WebSocket) {
|
||||
if (!closed.compareAndSet(false, true)) return
|
||||
val removed = controlSockets.remove(webSocket)
|
||||
if (!taskAccepted.get()) {
|
||||
controlSocketState.set(CONTROL_SOCKET_IDLE)
|
||||
tryOpenNextControlSocket()
|
||||
}
|
||||
val started = taskStartedAtMs.get()
|
||||
val duration =
|
||||
if (started > 0L) (System.currentTimeMillis() - started).coerceAtLeast(0L) else -1L
|
||||
debug(
|
||||
"BrowserDialer: cleanup socketId=$socketId url=$controlUrl task=$taskKind taskAccepted=${taskAccepted.get()} removed=$removed durationMs=$duration ${poolState()}"
|
||||
)
|
||||
binaryHandler = null
|
||||
textHandler = null
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob = null
|
||||
upstreamCall?.cancel()
|
||||
upstreamCall = null
|
||||
upstreamSocket?.close(1000, "control closed")
|
||||
upstreamSocket = null
|
||||
taskKind = "closed"
|
||||
taskStartedAtMs.set(0L)
|
||||
}
|
||||
|
||||
private fun handleTask(webSocket: WebSocket, task: BrowserDialerTask?) {
|
||||
if (task == null) {
|
||||
failAndClose(webSocket, 1007, "invalid task")
|
||||
return
|
||||
}
|
||||
|
||||
taskStartedAtMs.set(System.currentTimeMillis())
|
||||
taskKind = when {
|
||||
task.method == "WS" -> "ws"
|
||||
task.method == "GET" && task.streamResponse -> "streaming_get"
|
||||
!task.streamResponse -> "unary_${task.method.lowercase()}"
|
||||
else -> "unsupported"
|
||||
}
|
||||
|
||||
when {
|
||||
task.method == "WS" -> handleWsTask(webSocket, task)
|
||||
task.method == "GET" && task.streamResponse -> handleStreamingGetTask(
|
||||
webSocket,
|
||||
task
|
||||
)
|
||||
|
||||
!task.streamResponse -> handleUnaryTask(webSocket, task)
|
||||
else -> {
|
||||
failAndClose(webSocket, 1003, "unsupported task")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWsTask(controlSocket: WebSocket, task: BrowserDialerTask) {
|
||||
val localClient = client ?: run {
|
||||
failAndClose(controlSocket, 1011, "client unavailable")
|
||||
return
|
||||
}
|
||||
debug("BrowserDialer: handling WS task socketId=$socketId url=${task.url} protocols=${task.extra.protocols.size}")
|
||||
val requestBuilder = Request.Builder().url(task.url)
|
||||
if (task.extra.protocols.isNotEmpty()) {
|
||||
requestBuilder.header(
|
||||
"Sec-WebSocket-Protocol",
|
||||
task.extra.protocols.joinToString(",")
|
||||
)
|
||||
}
|
||||
|
||||
val opened = AtomicBoolean(false)
|
||||
upstreamSocket =
|
||||
localClient.newWebSocket(requestBuilder.build(), object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
opened.set(true)
|
||||
try {
|
||||
controlSocket.send("ok")
|
||||
} catch (e: Exception) {
|
||||
debug(
|
||||
"BrowserDialer: failed to send ok for WS task",
|
||||
e
|
||||
)
|
||||
webSocket.close(1000, "control failed")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
try {
|
||||
controlSocket.send(text)
|
||||
} catch (e: Exception) {
|
||||
debug(
|
||||
"BrowserDialer: control socket closed during WS message transfer socketId=$socketId",
|
||||
e
|
||||
)
|
||||
webSocket.close(1000, "control closed")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
try {
|
||||
controlSocket.send(bytes)
|
||||
} catch (e: Exception) {
|
||||
debug(
|
||||
"BrowserDialer: control socket closed during WS binary transfer socketId=$socketId",
|
||||
e
|
||||
)
|
||||
webSocket.close(1000, "control closed")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
try {
|
||||
controlSocket.close(1000, "upstream closed")
|
||||
} catch (_: Exception) {
|
||||
debug("BrowserDialer: control socket already closed")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
webSocket: WebSocket,
|
||||
t: Throwable,
|
||||
response: Response?
|
||||
) {
|
||||
if (!opened.get()) {
|
||||
try {
|
||||
controlSocket.send("fail")
|
||||
} catch (e: Exception) {
|
||||
debug(
|
||||
"BrowserDialer: control socket send failed socketId=$socketId",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
try {
|
||||
controlSocket.close(1011, "upstream failure")
|
||||
} catch (_: Exception) {
|
||||
debug(
|
||||
"BrowserDialer: control socket already closed socketId=$socketId"
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
textHandler = { message ->
|
||||
try {
|
||||
upstreamSocket?.send(message)
|
||||
} catch (e: Exception) {
|
||||
debug(
|
||||
"BrowserDialer: upstream socket send failed socketId=$socketId",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
binaryHandler = { data ->
|
||||
try {
|
||||
upstreamSocket?.send(data.toByteString())
|
||||
} catch (e: Exception) {
|
||||
debug(
|
||||
"BrowserDialer: upstream socket binary send failed socketId=$socketId",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStreamingGetTask(controlSocket: WebSocket, task: BrowserDialerTask) {
|
||||
val localClient = client ?: run {
|
||||
failAndClose(controlSocket, 1011, "client unavailable")
|
||||
return
|
||||
}
|
||||
debug("BrowserDialer: handling streaming GET task socketId=$socketId url=${task.url}")
|
||||
val request = buildRequest(task, null)
|
||||
|
||||
try {
|
||||
controlSocket.send("ok")
|
||||
} catch (e: Exception) {
|
||||
debug(
|
||||
"BrowserDialer: failed to send ok for streaming GET socketId=$socketId",
|
||||
e
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
val call = localClient.newCall(request)
|
||||
upstreamCall = call
|
||||
try {
|
||||
call.execute().use { response ->
|
||||
val source = response.body.source()
|
||||
val buffer = Buffer()
|
||||
while (running.get() && !closed.get()) {
|
||||
try {
|
||||
val read = source.read(buffer, DEFAULT_BUFFER_SIZE.toLong())
|
||||
if (read < 0) break
|
||||
|
||||
// Send data, catch exception if WebSocket is closed
|
||||
try {
|
||||
controlSocket.send(buffer.readByteString())
|
||||
} catch (e: Exception) {
|
||||
debug(
|
||||
"BrowserDialer: WebSocket send failed during streaming, stopping stream socketId=$socketId",
|
||||
e
|
||||
)
|
||||
break
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Error reading from source, stop streaming
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
debug(
|
||||
"BrowserDialer: streaming GET failed socketId=$socketId",
|
||||
e
|
||||
)
|
||||
try {
|
||||
controlSocket.send("fail")
|
||||
} catch (_: Exception) {
|
||||
// WebSocket may already be closed
|
||||
}
|
||||
} finally {
|
||||
upstreamCall = null
|
||||
try {
|
||||
controlSocket.close(1000, "streaming done")
|
||||
} catch (_: Exception) {
|
||||
debug(
|
||||
"BrowserDialer: WebSocket already closed socketId=$socketId"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUnaryTask(controlSocket: WebSocket, task: BrowserDialerTask) {
|
||||
val localClient = client ?: run {
|
||||
failAndClose(controlSocket, 1011, "client unavailable")
|
||||
return
|
||||
}
|
||||
debug("BrowserDialer: handling unary task socketId=$socketId method=${task.method} url=${task.url}")
|
||||
|
||||
try {
|
||||
controlSocket.send("ok")
|
||||
} catch (e: Exception) {
|
||||
debug(
|
||||
"BrowserDialer: failed to send ok for unary task socketId=$socketId",
|
||||
e
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val done = AtomicBoolean(false)
|
||||
|
||||
timeoutJob = scope.launch {
|
||||
delay(UNARY_BODY_WAIT_TIMEOUT_MS)
|
||||
if (done.compareAndSet(false, true)) {
|
||||
binaryHandler = null
|
||||
textHandler = null
|
||||
debug("BrowserDialer: unary task timed out waiting for payload socketId=$socketId method=${task.method} url=${task.url}")
|
||||
failAndClose(controlSocket, 1000, "unary payload timeout")
|
||||
}
|
||||
}
|
||||
|
||||
val executeRequest: (ByteArray?) -> Unit = { payload ->
|
||||
if (done.compareAndSet(false, true)) {
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob = null
|
||||
binaryHandler = null
|
||||
textHandler = null
|
||||
scope.launch {
|
||||
val request = buildRequest(task, payload)
|
||||
val call = localClient.newCall(request)
|
||||
upstreamCall = call
|
||||
try {
|
||||
call.execute().use { response ->
|
||||
try {
|
||||
controlSocket.send(if (response.isSuccessful) "ok" else "fail")
|
||||
} catch (e: Exception) {
|
||||
debug(
|
||||
"BrowserDialer: WebSocket send failed for unary response socketId=$socketId",
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
debug(
|
||||
"BrowserDialer: unary request failed socketId=$socketId",
|
||||
e
|
||||
)
|
||||
try {
|
||||
controlSocket.send("fail")
|
||||
} catch (_: Exception) {
|
||||
// WebSocket may already be closed
|
||||
}
|
||||
} finally {
|
||||
upstreamCall = null
|
||||
try {
|
||||
controlSocket.close(1000, "request done")
|
||||
} catch (_: Exception) {
|
||||
debug(
|
||||
"BrowserDialer: WebSocket already closed socketId=$socketId"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binaryHandler = { body -> executeRequest(body.takeIf { it.isNotEmpty() }) }
|
||||
textHandler = { body -> executeRequest(body.toByteArray().takeIf { it.isNotEmpty() }) }
|
||||
}
|
||||
|
||||
private fun buildRequest(task: BrowserDialerTask, payload: ByteArray?): Request {
|
||||
val requestBuilder = Request.Builder().url(task.url)
|
||||
// task.extra.headers.forEach { (key, value) -> requestBuilder.header(key, value) }
|
||||
// Just set no cache headers
|
||||
requestBuilder.header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
task.extra.referrer?.takeIf { it.isNotBlank() }
|
||||
?.let { requestBuilder.header("Referer", it) }
|
||||
val taskHeaders = task.extra.headers.filterKeys { key ->
|
||||
val lowerKey = key.lowercase()
|
||||
!HEADERS_BLACKLIST.any { blackKey -> blackKey.equals(lowerKey, ignoreCase = true) }
|
||||
}
|
||||
taskHeaders.forEach { (key, value) -> requestBuilder.header(key, value) }
|
||||
|
||||
val method = task.method.uppercase()
|
||||
val methodAllowsBody = method !in METHODS_WITHOUT_BODY
|
||||
val body = when {
|
||||
methodAllowsBody && payload != null && payload.isNotEmpty() -> payload.toRequestBody(null)
|
||||
methodAllowsBody -> ByteArray(0).toRequestBody(null)
|
||||
else -> null
|
||||
}
|
||||
requestBuilder.method(method, body)
|
||||
return requestBuilder.build()
|
||||
}
|
||||
|
||||
private fun failAndClose(socket: WebSocket, code: Int, reason: String) {
|
||||
try {
|
||||
socket.send("fail")
|
||||
} catch (e: Exception) {
|
||||
debug("BrowserDialer: failed to send fail message", e)
|
||||
}
|
||||
try {
|
||||
socket.close(code, reason)
|
||||
} catch (e: Exception) {
|
||||
debug("BrowserDialer: failed to close socket", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class BrowserDialerTask(
|
||||
val method: String,
|
||||
val url: String,
|
||||
val streamResponse: Boolean,
|
||||
val extra: Extra
|
||||
) {
|
||||
data class Extra(
|
||||
val headers: Map<String, String> = emptyMap(),
|
||||
// val cookies: Map<String, String> = emptyMap(),
|
||||
val protocols: List<String> = emptyList(),
|
||||
val referrer: String? = null
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun parse(payload: String): BrowserDialerTask? {
|
||||
return runCatching {
|
||||
val root = JSONObject(payload)
|
||||
val method = root.optString("method")
|
||||
val url = root.optString("url")
|
||||
if (method.isBlank() || url.isBlank()) return null
|
||||
|
||||
val streamResponse = root.optBoolean("streamResponse", false)
|
||||
val extraObject = root.optJSONObject("extra")
|
||||
val headers = extraObject.optStringMap("headers")
|
||||
// val cookies = extraObject.optStringMap("cookies")
|
||||
val referrer = extraObject?.optString("referrer")?.takeIf { it.isNotBlank() }
|
||||
val protocols = extraObject.optProtocols()
|
||||
|
||||
BrowserDialerTask(
|
||||
method = method,
|
||||
url = url,
|
||||
streamResponse = streamResponse,
|
||||
extra = Extra(
|
||||
headers = headers,
|
||||
// cookies = cookies,
|
||||
protocols = protocols,
|
||||
referrer = referrer
|
||||
)
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun JSONObject?.optStringMap(name: String): Map<String, String> {
|
||||
val child = this?.optJSONObject(name) ?: return emptyMap()
|
||||
val map = LinkedHashMap<String, String>()
|
||||
val iter = child.keys()
|
||||
while (iter.hasNext()) {
|
||||
val key = iter.next()
|
||||
map[key] = child.optString(key)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
private fun JSONObject?.optProtocols(): List<String> {
|
||||
val raw = this?.opt("protocol") ?: return emptyList()
|
||||
return when (raw) {
|
||||
is String -> raw.takeIf { it.isNotBlank() }?.let { listOf(it) } ?: emptyList()
|
||||
is JSONArray -> buildList {
|
||||
for (i in 0 until raw.length()) {
|
||||
val item = raw.optString(i)
|
||||
if (item.isNotBlank()) {
|
||||
add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
|
||||
class DialerWebviewService : IDialerService {
|
||||
private var webView: WebView? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val keepAliveInterval = 30_000L // 30 seconds
|
||||
|
||||
private val keepAliveRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
webView?.let {
|
||||
it.resumeTimers()
|
||||
it.onResume()
|
||||
}
|
||||
handler.postDelayed(this, keepAliveInterval)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Starts the WebView.
|
||||
* @param context Service context
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun start(context: Context, dialerAddr: String) {
|
||||
if (webView != null) stop()
|
||||
if (dialerAddr.isEmpty()) return
|
||||
val dialerUrl = "http://$dialerAddr/"
|
||||
|
||||
webView = WebView(context.applicationContext).apply {
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
// Allow JS to run even if not triggered by user
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
// Prevent aggressive caching issues
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
}
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
view?.onResume()
|
||||
view?.resumeTimers()
|
||||
}
|
||||
}
|
||||
|
||||
loadUrl(dialerUrl)
|
||||
}
|
||||
|
||||
handler.post(keepAliveRunnable)
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
handler.removeCallbacks(keepAliveRunnable)
|
||||
webView?.apply {
|
||||
stopLoading()
|
||||
pauseTimers()
|
||||
// Important to call onPause to stop internal Chromium threads properly
|
||||
onPause()
|
||||
destroy()
|
||||
}
|
||||
webView = null
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.content.Context
|
||||
|
||||
interface IDialerService {
|
||||
fun start(context: Context, dialerAddr: String)
|
||||
fun stop()
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import android.service.quicksettings.TileService
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -29,7 +29,7 @@ class QSTileService : TileService() {
|
||||
qsTile?.label = getString(R.string.app_name)
|
||||
} else if (state == Tile.STATE_ACTIVE) {
|
||||
qsTile?.state = Tile.STATE_ACTIVE
|
||||
qsTile?.label = V2RayServiceManager.getRunningServerName()
|
||||
qsTile?.label = CoreServiceManager.getRunningServerName()
|
||||
}
|
||||
|
||||
qsTile?.updateTile()
|
||||
@@ -42,7 +42,7 @@ class QSTileService : TileService() {
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
|
||||
if (V2RayServiceManager.isRunning()) {
|
||||
if (CoreServiceManager.isRunning()) {
|
||||
setState(Tile.STATE_ACTIVE)
|
||||
} else {
|
||||
setState(Tile.STATE_INACTIVE)
|
||||
@@ -75,11 +75,11 @@ class QSTileService : TileService() {
|
||||
super.onClick()
|
||||
when (qsTile.state) {
|
||||
Tile.STATE_INACTIVE -> {
|
||||
V2RayServiceManager.startVServiceFromToggle(this)
|
||||
CoreServiceManager.startVServiceFromToggle(this)
|
||||
}
|
||||
|
||||
Tile.STATE_ACTIVE -> {
|
||||
V2RayServiceManager.stopVService(this)
|
||||
CoreServiceManager.stopVService(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.content.Context
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.core.CoreConfigManager
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
import com.v2ray.ang.dto.RealPingEvent
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.V2RayNativeManager
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineName
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -23,11 +22,11 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
class RealPingWorkerService(
|
||||
private val context: Context,
|
||||
private val guids: List<String>,
|
||||
private val onFinish: (status: String) -> Unit = {}
|
||||
private val onEvent: (RealPingEvent) -> Unit = {}
|
||||
) {
|
||||
private val job = SupervisorJob()
|
||||
private val cpu = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
|
||||
private val dispatcher = Executors.newFixedThreadPool(cpu * 4).asCoroutineDispatcher()
|
||||
private val concurrency = SettingsManager.getRealPingConcurrency()
|
||||
private val dispatcher = Executors.newFixedThreadPool(concurrency).asCoroutineDispatcher()
|
||||
private val scope = CoroutineScope(job + dispatcher + CoroutineName("RealPingBatchWorker"))
|
||||
|
||||
private val runningCount = AtomicInteger(0)
|
||||
@@ -40,11 +39,13 @@ class RealPingWorkerService(
|
||||
runningCount.incrementAndGet()
|
||||
try {
|
||||
val result = startRealPing(guid)
|
||||
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
|
||||
onEvent(RealPingEvent.Result(guid, result))
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
} finally {
|
||||
val count = totalCount.decrementAndGet()
|
||||
val left = runningCount.decrementAndGet()
|
||||
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $count")
|
||||
onEvent(RealPingEvent.Progress("$left / $count"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,9 +53,9 @@ class RealPingWorkerService(
|
||||
scope.launch {
|
||||
try {
|
||||
joinAll(*jobs.toTypedArray())
|
||||
onFinish("0")
|
||||
onEvent(RealPingEvent.Finish("0"))
|
||||
} catch (_: CancellationException) {
|
||||
onFinish("-1")
|
||||
onEvent(RealPingEvent.Finish("-1"))
|
||||
} finally {
|
||||
close()
|
||||
}
|
||||
@@ -75,11 +76,10 @@ class RealPingWorkerService(
|
||||
|
||||
private fun startRealPing(guid: String): Long {
|
||||
val retFailure = -1L
|
||||
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
|
||||
val configResult = CoreConfigManager.getV2rayConfig4Speedtest(context, guid)
|
||||
if (!configResult.status) {
|
||||
return retFailure
|
||||
}
|
||||
return V2RayNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
|
||||
return CoreNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
|
||||
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL
|
||||
import com.v2ray.ang.dto.TestServiceMessage
|
||||
import com.v2ray.ang.extension.serializable
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2RayNativeManager
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
import java.util.Collections
|
||||
|
||||
class V2RayTestService : Service() {
|
||||
|
||||
// manage active batch workers so each batch is independent and cancellable
|
||||
private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
|
||||
|
||||
/**
|
||||
* Initializes the V2Ray environment.
|
||||
*/
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
V2RayNativeManager.initCoreEnv(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the service.
|
||||
* @param intent The intent.
|
||||
* @return The binder.
|
||||
*/
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up resources when the service is destroyed.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// cancel any active workers
|
||||
val snapshot = ArrayList(activeWorkers)
|
||||
snapshot.forEach { it.cancel() }
|
||||
activeWorkers.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the start command for the service.
|
||||
* @param intent The intent.
|
||||
* @param flags The flags.
|
||||
* @param startId The start ID.
|
||||
* @return The start mode.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val message = intent?.serializable<TestServiceMessage>("content") ?: return super.onStartCommand(intent, flags, startId)
|
||||
when (message.key) {
|
||||
MSG_MEASURE_CONFIG -> {
|
||||
val guidsList = if (message.serverGuids.isNotEmpty()) {
|
||||
message.serverGuids
|
||||
} else if (message.subscriptionId.isNotEmpty()) {
|
||||
MmkvManager.decodeServerList(message.subscriptionId)
|
||||
} else {
|
||||
MmkvManager.decodeAllServerList()
|
||||
}
|
||||
|
||||
if (guidsList.isNotEmpty()) {
|
||||
lateinit var worker: RealPingWorkerService
|
||||
worker = RealPingWorkerService(this, guidsList) { status ->
|
||||
// notify UI and remove the worker from active list when finished
|
||||
MessageUtil.sendMsg2UI(this@V2RayTestService, AppConfig.MSG_MEASURE_CONFIG_FINISH, status)
|
||||
activeWorkers.remove(worker)
|
||||
}
|
||||
activeWorkers.add(worker)
|
||||
worker.start()
|
||||
}
|
||||
}
|
||||
|
||||
MSG_MEASURE_CONFIG_CANCEL -> {
|
||||
// cancel all running batch workers independently
|
||||
val snapshot = ArrayList(activeWorkers)
|
||||
snapshot.forEach { it.cancel() }
|
||||
activeWorkers.clear()
|
||||
}
|
||||
}
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import android.os.Bundle
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
import com.v2ray.ang.databinding.ActivityAboutBinding
|
||||
import com.v2ray.ang.handler.V2RayNativeManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
class AboutActivity : BaseActivity() {
|
||||
@@ -42,7 +42,7 @@ class AboutActivity : BaseActivity() {
|
||||
Utils.openUri(this, AppConfig.APP_PRIVACY_POLICY)
|
||||
}
|
||||
|
||||
"v${BuildConfig.VERSION_NAME} (${V2RayNativeManager.getLibVersion()})".also {
|
||||
"v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})".also {
|
||||
binding.tvVersion.text = it
|
||||
}
|
||||
BuildConfig.APPLICATION_ID.also {
|
||||
|
||||
@@ -14,7 +14,6 @@ import com.v2ray.ang.databinding.ActivityAppPickerBinding
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
import com.v2ray.ang.util.AppManagerUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.PackageUidResolver
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -12,7 +12,7 @@ import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityBackupBinding
|
||||
import com.v2ray.ang.databinding.DialogWebdavBinding
|
||||
import com.v2ray.ang.dto.WebDavConfig
|
||||
import com.v2ray.ang.dto.entities.WebDavConfig
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.lifecycleScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
import com.v2ray.ang.databinding.ActivityCheckUpdateBinding
|
||||
import com.v2ray.ang.dto.CheckUpdateResult
|
||||
import com.v2ray.ang.extension.toast
|
||||
@@ -13,7 +14,6 @@ import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.UpdateCheckerManager
|
||||
import com.v2ray.ang.handler.V2RayNativeManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -36,7 +36,7 @@ class CheckUpdateActivity : BaseActivity() {
|
||||
}
|
||||
binding.checkPreRelease.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, false)
|
||||
|
||||
"v${BuildConfig.VERSION_NAME} (${V2RayNativeManager.getLibVersion()})".also {
|
||||
"v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})".also {
|
||||
binding.tvVersion.text = it
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
@@ -16,13 +17,15 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.MainAdapterListener
|
||||
import com.v2ray.ang.databinding.FragmentGroupServerBinding
|
||||
import com.v2ray.ang.databinding.ItemQrcodeBinding
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.viewmodel.MainViewModel
|
||||
@@ -44,6 +47,11 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
|
||||
private val share_method_more: Array<out String> by lazy {
|
||||
ownerActivity.resources.getStringArray(R.array.share_method_more)
|
||||
}
|
||||
private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (SettingsChangeManager.consumeRestartService() && mainViewModel.isRunning.value == true) {
|
||||
ownerActivity.restartV2Ray()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_SUB_ID = "subscriptionId"
|
||||
@@ -168,23 +176,20 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
|
||||
* @param profile The server configuration
|
||||
*/
|
||||
private fun editServer(guid: String, profile: ProfileItem) {
|
||||
val intent = Intent().putExtra("guid", guid)
|
||||
val activityClass = when (profile.configType) {
|
||||
EConfigType.CUSTOM -> ServerCustomConfigActivity::class.java
|
||||
EConfigType.POLICYGROUP -> ServerGroupActivity::class.java
|
||||
EConfigType.PROXYCHAIN -> ServerProxyChainActivity::class.java
|
||||
else -> ServerActivity::class.java
|
||||
}
|
||||
|
||||
val intent = Intent(ownerActivity, activityClass)
|
||||
.putExtra("guid", guid)
|
||||
.putExtra("isRunning", mainViewModel.isRunning.value)
|
||||
.putExtra("createConfigType", profile.configType.value)
|
||||
.putExtra("subscriptionId", subId)
|
||||
when (profile.configType) {
|
||||
EConfigType.CUSTOM -> {
|
||||
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerCustomConfigActivity::class.java))
|
||||
}
|
||||
|
||||
EConfigType.POLICYGROUP -> {
|
||||
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerGroupActivity::class.java))
|
||||
}
|
||||
|
||||
else -> {
|
||||
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerActivity::class.java))
|
||||
}
|
||||
}
|
||||
launcher.launch(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,7 +270,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
|
||||
}
|
||||
|
||||
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
|
||||
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
|
||||
val isCustom = profile.configType.isComplexType()
|
||||
|
||||
val (shareOptions, skip) = if (more) {
|
||||
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 = "*/*",
|
||||
onResult: (Uri?) -> Unit
|
||||
) {
|
||||
checkAndRequestPermission(PermissionType.READ_STORAGE) {
|
||||
fileChooser.launch(mimeType, onResult)
|
||||
}
|
||||
fileChooser.launch(mimeType, onResult)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
@@ -18,6 +20,11 @@ import com.v2ray.ang.viewmodel.LogcatViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
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 {
|
||||
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
|
||||
@@ -45,6 +52,55 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
||||
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 {
|
||||
menuInflater.inflate(R.menu.menu_logcat, menu)
|
||||
|
||||
@@ -78,6 +134,11 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
||||
true
|
||||
}
|
||||
|
||||
R.id.share_all -> {
|
||||
shareLogcat()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.clear_all -> {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
viewModel.clearLogcat()
|
||||
|
||||
@@ -22,6 +22,7 @@ import com.google.android.material.navigation.NavigationView
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.databinding.ActivityMainBinding
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.PermissionType
|
||||
@@ -31,7 +32,7 @@ import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
import com.v2ray.ang.handler.SubscriptionUpdater
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.MainViewModel
|
||||
@@ -98,6 +99,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
|
||||
setupGroupTab()
|
||||
setupViewModel()
|
||||
SubscriptionUpdater.sync()
|
||||
mainViewModel.reloadServerList()
|
||||
|
||||
checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) {
|
||||
@@ -135,7 +137,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
applyRunningState(isLoading = true, isRunning = false)
|
||||
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
V2RayServiceManager.stopVService(this)
|
||||
CoreServiceManager.stopVService(this)
|
||||
} else if (SettingsManager.isVpnMode()) {
|
||||
val intent = VpnService.prepare(this)
|
||||
if (intent == null) {
|
||||
@@ -162,12 +164,12 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
toast(R.string.title_file_chooser)
|
||||
return
|
||||
}
|
||||
V2RayServiceManager.startVService(this)
|
||||
CoreServiceManager.startVService(this)
|
||||
}
|
||||
|
||||
fun restartV2Ray() {
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
V2RayServiceManager.stopVService(this)
|
||||
CoreServiceManager.stopVService(this)
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
delay(500)
|
||||
@@ -252,6 +254,11 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
true
|
||||
}
|
||||
|
||||
R.id.import_manually_proxy_chain -> {
|
||||
importManually(EConfigType.PROXYCHAIN.value)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.import_manually_vmess -> {
|
||||
importManually(EConfigType.VMESS.value)
|
||||
true
|
||||
@@ -354,6 +361,12 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
.putExtra("subscriptionId", mainViewModel.subscriptionId)
|
||||
.setClass(this, ServerGroupActivity::class.java)
|
||||
)
|
||||
} else if (createConfigType == EConfigType.PROXYCHAIN.value) {
|
||||
startActivity(
|
||||
Intent()
|
||||
.putExtra("subscriptionId", mainViewModel.subscriptionId)
|
||||
.setClass(this, ServerProxyChainActivity::class.java)
|
||||
)
|
||||
} else {
|
||||
startActivity(
|
||||
Intent()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
@@ -12,8 +13,9 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.MainAdapterListener
|
||||
import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
|
||||
import com.v2ray.ang.databinding.ItemRecyclerMainBinding
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.ServersCache
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ServersCache
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
@@ -58,7 +60,7 @@ class MainRecyclerAdapter(
|
||||
//Name address
|
||||
holder.itemMainBinding.tvName.text = profile.remarks
|
||||
holder.itemMainBinding.tvStatistics.text = getAddress(profile)
|
||||
holder.itemMainBinding.tvType.text = profile.configType.name
|
||||
holder.itemMainBinding.tvType.text = getProtocolDescription(profile)
|
||||
|
||||
//TestResult
|
||||
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
|
||||
@@ -140,6 +142,31 @@ class MainRecyclerAdapter(
|
||||
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) {
|
||||
val idx = data.indexOfFirst { it.guid == guid }
|
||||
if (idx >= 0) {
|
||||
@@ -203,4 +230,4 @@ class MainRecyclerAdapter(
|
||||
|
||||
override fun onItemDismiss(position: Int) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityBypassListBinding
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.extension.v2RayApplication
|
||||
@@ -189,12 +190,25 @@ class PerAppProxyActivity : BaseActivity() {
|
||||
|
||||
val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
var content = HttpUtil.getUrlContent(url, 5000)
|
||||
var content = HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000
|
||||
)
|
||||
)
|
||||
if (content.isNullOrEmpty()) {
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
content = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword) ?: ""
|
||||
content = HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000,
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
proxyPassword = proxyPassword
|
||||
)
|
||||
) ?: ""
|
||||
}
|
||||
launch(Dispatchers.Main) {
|
||||
//LogUtil.i(AppConfig.TAG, content)
|
||||
|
||||
@@ -11,11 +11,10 @@ import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS
|
||||
import com.v2ray.ang.AppConfig.TAG_PROXY
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityRoutingEditBinding
|
||||
import com.v2ray.ang.dto.RulesetItem
|
||||
import com.v2ray.ang.dto.entities.RulesetItem
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -78,9 +77,7 @@ class RoutingEditActivity : BaseActivity() {
|
||||
* The dropdown button triggers showing the full list without typing.
|
||||
*/
|
||||
private fun setupOutboundTagInput() {
|
||||
val profileRemarks = MmkvManager.decodeAllServerList()
|
||||
.mapNotNull { id -> MmkvManager.decodeServerConfig(id)?.remarks }
|
||||
.filter { it.isNotBlank() }
|
||||
val profileRemarks = SettingsManager.getProfileRemarks()
|
||||
|
||||
val suggestions = (BUILTIN_OUTBOUND_TAGS.toList() + profileRemarks).distinct()
|
||||
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
|
||||
class ScStartActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -11,8 +11,8 @@ class ScStartActivity : BaseActivity() {
|
||||
|
||||
setContentView(R.layout.activity_none)
|
||||
|
||||
if (!V2RayServiceManager.isRunning()) {
|
||||
V2RayServiceManager.startVServiceFromToggle(this)
|
||||
if (!CoreServiceManager.isRunning()) {
|
||||
CoreServiceManager.startVServiceFromToggle(this)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
|
||||
class ScStopActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -11,8 +11,8 @@ class ScStopActivity : BaseActivity() {
|
||||
|
||||
setContentView(R.layout.activity_none)
|
||||
|
||||
if (V2RayServiceManager.isRunning()) {
|
||||
V2RayServiceManager.stopVService(this)
|
||||
if (CoreServiceManager.isRunning()) {
|
||||
CoreServiceManager.stopVService(this)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
|
||||
class ScSwitchActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -11,10 +11,10 @@ class ScSwitchActivity : BaseActivity() {
|
||||
|
||||
setContentView(R.layout.activity_none)
|
||||
|
||||
if (V2RayServiceManager.isRunning()) {
|
||||
V2RayServiceManager.stopVService(this)
|
||||
if (CoreServiceManager.isRunning()) {
|
||||
CoreServiceManager.stopVService(this)
|
||||
} else {
|
||||
V2RayServiceManager.startVServiceFromToggle(this)
|
||||
CoreServiceManager.startVServiceFromToggle(this)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
@@ -14,13 +14,12 @@ import android.widget.TextView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_PORT
|
||||
import com.v2ray.ang.AppConfig.PREF_ALLOW_INSECURE
|
||||
import com.v2ray.ang.AppConfig.REALITY
|
||||
import com.v2ray.ang.AppConfig.TLS
|
||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
@@ -29,6 +28,7 @@ import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
@@ -84,8 +84,8 @@ class ServerActivity : BaseActivity() {
|
||||
private val xhttpMode: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.xhttp_mode)
|
||||
}
|
||||
private val echForceQuerys: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.ech_force_query_value)
|
||||
private val browserDialerModes: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.browser_dialer_mode)
|
||||
}
|
||||
|
||||
|
||||
@@ -140,10 +140,10 @@ class ServerActivity : BaseActivity() {
|
||||
private val layout_extra: LinearLayout? by lazy { findViewById(R.id.layout_extra) }
|
||||
private val et_ech_config_list: EditText? by lazy { findViewById(R.id.et_ech_config_list) }
|
||||
private val container_ech_config_list: LinearLayout? by lazy { findViewById(R.id.lay_ech_config_list) }
|
||||
private val sp_ech_force_query: Spinner? by lazy { findViewById(R.id.sp_ech_force_query) }
|
||||
private val container_ech_force_query: LinearLayout? by lazy { findViewById(R.id.lay_ech_force_query) }
|
||||
private val et_pinned_ca256: EditText? by lazy { findViewById(R.id.et_pinned_ca256) }
|
||||
private val container_pinned_ca256: LinearLayout? by lazy { findViewById(R.id.lay_pinned_ca256) }
|
||||
private val layout_browser_dialer: LinearLayout? by lazy { findViewById(R.id.layout_browser_dialer) }
|
||||
private val sp_browser_dialer_mode: Spinner? by lazy { findViewById(R.id.sp_browser_dialer_mode) }
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -153,14 +153,12 @@ class ServerActivity : BaseActivity() {
|
||||
|
||||
val layoutId = when (config?.configType ?: createConfigType) {
|
||||
EConfigType.VMESS -> R.layout.activity_server_vmess
|
||||
EConfigType.CUSTOM -> null
|
||||
EConfigType.SHADOWSOCKS -> R.layout.activity_server_shadowsocks
|
||||
EConfigType.SOCKS, EConfigType.HTTP -> R.layout.activity_server_socks
|
||||
EConfigType.VLESS -> R.layout.activity_server_vless
|
||||
EConfigType.TROJAN -> R.layout.activity_server_trojan
|
||||
EConfigType.WIREGUARD -> R.layout.activity_server_wireguard
|
||||
EConfigType.HYSTERIA2 -> R.layout.activity_server_hysteria2
|
||||
EConfigType.POLICYGROUP -> null
|
||||
else -> null
|
||||
} ?: return
|
||||
setContentViewWithToolbar(layoutId, showHomeAsUp = true, title = (config?.configType ?: createConfigType).toString())
|
||||
@@ -261,6 +259,13 @@ class ServerActivity : BaseActivity() {
|
||||
NetworkType.XHTTP.type -> View.VISIBLE
|
||||
else -> View.GONE
|
||||
}
|
||||
|
||||
layout_browser_dialer?.visibility =
|
||||
when (networks[position]) {
|
||||
NetworkType.WS.type -> View.VISIBLE
|
||||
NetworkType.XHTTP.type -> View.VISIBLE
|
||||
else -> View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||
@@ -290,7 +295,6 @@ class ServerActivity : BaseActivity() {
|
||||
container_spider_x,
|
||||
container_mldsa65_verify,
|
||||
container_ech_config_list,
|
||||
container_ech_force_query,
|
||||
container_pinned_ca256
|
||||
).forEach { it?.visibility = View.GONE }
|
||||
}
|
||||
@@ -303,7 +307,6 @@ class ServerActivity : BaseActivity() {
|
||||
container_alpn,
|
||||
container_allow_insecure,
|
||||
container_ech_config_list,
|
||||
container_ech_force_query,
|
||||
container_pinned_ca256
|
||||
).forEach { it?.visibility = View.VISIBLE }
|
||||
listOf(
|
||||
@@ -324,7 +327,6 @@ class ServerActivity : BaseActivity() {
|
||||
container_alpn,
|
||||
container_allow_insecure,
|
||||
container_ech_config_list,
|
||||
container_ech_force_query,
|
||||
container_pinned_ca256
|
||||
).forEach { it?.visibility = View.GONE }
|
||||
listOf(
|
||||
@@ -408,10 +410,6 @@ class ServerActivity : BaseActivity() {
|
||||
sp_allow_insecure?.setSelection(allowinsecure)
|
||||
}
|
||||
et_ech_config_list?.text = Utils.getEditable(config.echConfigList)
|
||||
config.echForceQuery?.let { it ->
|
||||
val index = Utils.arrayFind(echForceQuerys, it)
|
||||
index.let { sp_ech_force_query?.setSelection(if (it >= 0) it else 0) }
|
||||
}
|
||||
et_pinned_ca256?.text = Utils.getEditable(config.pinnedCA256)
|
||||
} else if (config.security == REALITY) {
|
||||
et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty())
|
||||
@@ -425,6 +423,12 @@ class ServerActivity : BaseActivity() {
|
||||
if (network >= 0) {
|
||||
sp_network?.setSelection(network)
|
||||
}
|
||||
|
||||
val browserDialerMode = Utils.arrayFind(browserDialerModes, config.browserDialerMode.orEmpty())
|
||||
if (browserDialerMode >= 0) {
|
||||
sp_browser_dialer_mode?.setSelection(browserDialerMode)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -453,6 +457,7 @@ class ServerActivity : BaseActivity() {
|
||||
et_local_address?.text =
|
||||
Utils.getEditable(WIREGUARD_LOCAL_ADDRESS_V4)
|
||||
et_local_mtu?.text = Utils.getEditable(WIREGUARD_LOCAL_MTU)
|
||||
sp_browser_dialer_mode?.setSelection(0)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -521,6 +526,9 @@ class ServerActivity : BaseActivity() {
|
||||
}
|
||||
//LogUtil.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "")
|
||||
MmkvManager.encodeServerConfig(editGuid, config)
|
||||
if (isRunning) {
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
toastSuccess(R.string.toast_success)
|
||||
finish()
|
||||
return true
|
||||
@@ -582,6 +590,16 @@ class ServerActivity : BaseActivity() {
|
||||
profileItem.finalMask = et_fm?.text?.toString()?.trim()?.nullIfBlank()
|
||||
profileItem.kcpMtu = et_kcp_mtu?.text?.toString()?.toIntOrNull()
|
||||
profileItem.kcpTti = et_kcp_tti?.text?.toString()?.toIntOrNull()
|
||||
if (networks[network] == NetworkType.WS.type || networks[network] == NetworkType.XHTTP.type) {
|
||||
val browserDialerMode = browserDialerModes[sp_browser_dialer_mode?.selectedItemPosition ?: 0]
|
||||
if (browserDialerMode != browserDialerModes[0]) {
|
||||
profileItem.browserDialerMode = browserDialerMode
|
||||
} else {
|
||||
profileItem.browserDialerMode = null
|
||||
}
|
||||
} else {
|
||||
profileItem.browserDialerMode = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveTls(config: ProfileItem) {
|
||||
@@ -595,12 +613,11 @@ class ServerActivity : BaseActivity() {
|
||||
val spiderX = et_spider_x?.text?.toString()
|
||||
val mldsa65Verify = et_mldsa65_verify?.text?.toString()
|
||||
val echConfigList = et_ech_config_list?.text?.toString()
|
||||
val echForceQueryIndex = sp_ech_force_query?.selectedItemPosition ?: 0
|
||||
val pinnedCA256 = et_pinned_ca256?.text?.toString()
|
||||
|
||||
val allowInsecure =
|
||||
if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) {
|
||||
MmkvManager.decodeSettingsBool(PREF_ALLOW_INSECURE)
|
||||
false
|
||||
} else {
|
||||
allowinsecures[allowInsecureField].toBoolean()
|
||||
}
|
||||
@@ -615,7 +632,6 @@ class ServerActivity : BaseActivity() {
|
||||
config.spiderX = spiderX
|
||||
config.mldsa65Verify = mldsa65Verify
|
||||
config.echConfigList = echConfigList
|
||||
config.echForceQuery = echForceQuerys[echForceQueryIndex]
|
||||
config.pinnedCA256 = pinnedCA256
|
||||
}
|
||||
|
||||
@@ -672,17 +688,9 @@ class ServerActivity : BaseActivity() {
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.action_server, menu)
|
||||
val delButton = menu.findItem(R.id.del_config)
|
||||
val saveButton = menu.findItem(R.id.save_config)
|
||||
|
||||
if (editGuid.isNotEmpty()) {
|
||||
if (isRunning) {
|
||||
delButton?.isVisible = false
|
||||
saveButton?.isVisible = false
|
||||
}
|
||||
} else {
|
||||
delButton?.isVisible = false
|
||||
}
|
||||
val delButton = menu.findItem(R.id.del_config)
|
||||
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
|
||||
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
@@ -10,13 +10,14 @@ import com.blacksquircle.ui.language.json.JsonLanguage
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.fmt.CustomFmt
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
@@ -94,6 +95,9 @@ class ServerCustomConfigActivity : BaseActivity() {
|
||||
|
||||
MmkvManager.encodeServerConfig(editGuid, config)
|
||||
MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString())
|
||||
if (isRunning) {
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
toastSuccess(R.string.toast_success)
|
||||
finish()
|
||||
return true
|
||||
@@ -119,17 +123,9 @@ class ServerCustomConfigActivity : BaseActivity() {
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.action_server, menu)
|
||||
val delButton = menu.findItem(R.id.del_config)
|
||||
val saveButton = menu.findItem(R.id.save_config)
|
||||
|
||||
if (editGuid.isNotEmpty()) {
|
||||
if (isRunning) {
|
||||
delButton?.isVisible = false
|
||||
saveButton?.isVisible = false
|
||||
}
|
||||
} else {
|
||||
delButton?.isVisible = false
|
||||
}
|
||||
val delButton = menu.findItem(R.id.del_config)
|
||||
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
|
||||
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ import android.widget.ArrayAdapter
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityServerGroupBinding
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
class ServerGroupActivity : BaseActivity() {
|
||||
@@ -100,6 +101,9 @@ class ServerGroupActivity : BaseActivity() {
|
||||
config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}"
|
||||
|
||||
MmkvManager.encodeServerConfig(editGuid, config)
|
||||
if (isRunning) {
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
toastSuccess(R.string.toast_success)
|
||||
finish()
|
||||
return true
|
||||
@@ -143,17 +147,9 @@ class ServerGroupActivity : BaseActivity() {
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.action_server, menu)
|
||||
val delButton = menu.findItem(R.id.del_config)
|
||||
val saveButton = menu.findItem(R.id.save_config)
|
||||
|
||||
if (editGuid.isNotEmpty()) {
|
||||
if (isRunning) {
|
||||
delButton?.isVisible = false
|
||||
saveButton?.isVisible = false
|
||||
}
|
||||
} else {
|
||||
delButton?.isVisible = false
|
||||
}
|
||||
val delButton = menu.findItem(R.id.del_config)
|
||||
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
|
||||
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ActivityServerProxyChainBinding
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
class ServerProxyChainActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivityServerProxyChainBinding.inflate(layoutInflater) }
|
||||
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
|
||||
private val isRunning by lazy {
|
||||
intent.getBooleanExtra("isRunning", false)
|
||||
&& editGuid.isNotEmpty()
|
||||
&& editGuid == MmkvManager.getSelectServer()
|
||||
}
|
||||
private val subscriptionId by lazy {
|
||||
intent.getStringExtra("subscriptionId")
|
||||
}
|
||||
private lateinit var memberAdapter: ServerProxyChainMemberAdapter
|
||||
|
||||
private var allRemarks: List<String> = emptyList()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = EConfigType.PROXYCHAIN.toString())
|
||||
|
||||
loadAvailableRemarks()
|
||||
setupRecycler()
|
||||
binding.fabAddProxyChainMember.setOnClickListener {
|
||||
addMemberRow()
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(editGuid)
|
||||
if (config != null) {
|
||||
bindingServer(config)
|
||||
} else {
|
||||
clearServer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAvailableRemarks() {
|
||||
allRemarks = SettingsManager.getProfileRemarks(
|
||||
excludeConfigTypes = setOf(
|
||||
EConfigType.CUSTOM,
|
||||
EConfigType.POLICYGROUP,
|
||||
EConfigType.PROXYCHAIN,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupRecycler() {
|
||||
memberAdapter = ServerProxyChainMemberAdapter(
|
||||
members = mutableListOf(),
|
||||
suggestions = allRemarks,
|
||||
adapterListener = ActivityAdapterListener()
|
||||
)
|
||||
binding.recyclerProxyChainMembers.layoutManager = LinearLayoutManager(this)
|
||||
binding.recyclerProxyChainMembers.adapter = memberAdapter
|
||||
ItemTouchHelper(SimpleItemTouchHelperCallback(memberAdapter)).attachToRecyclerView(binding.recyclerProxyChainMembers)
|
||||
}
|
||||
|
||||
private fun bindingServer(config: ProfileItem): Boolean {
|
||||
binding.etRemarks.text = Utils.getEditable(config.remarks)
|
||||
val rows = parseChainMembers(config.proxyChainProfiles)
|
||||
memberAdapter.replaceAll(rows)
|
||||
if (rows.isEmpty()) {
|
||||
memberAdapter.addRow()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun clearServer(): Boolean {
|
||||
binding.etRemarks.text = null
|
||||
memberAdapter.replaceAll(listOf("", ""))
|
||||
return true
|
||||
}
|
||||
|
||||
private fun saveServer(): Boolean {
|
||||
if (TextUtils.isEmpty(binding.etRemarks.text.toString())) {
|
||||
toast(R.string.server_lab_remarks)
|
||||
return false
|
||||
}
|
||||
|
||||
val chainMembers = memberAdapter.getMembers().map { it.trim() }.filter { it.isNotEmpty() }
|
||||
if (chainMembers.size != memberAdapter.getMembers().size) {
|
||||
toast(R.string.server_proxy_chain_members_unselected)
|
||||
return false
|
||||
}
|
||||
if (chainMembers.size < 2) {
|
||||
toast(R.string.server_proxy_chain_members_insufficient)
|
||||
return false
|
||||
}
|
||||
|
||||
val invalidMembers = chainMembers.filter { member ->
|
||||
val profile = SettingsManager.getServerViaRemarks(member)
|
||||
profile == null || profile.configType.isComplexType()
|
||||
}
|
||||
if (invalidMembers.isNotEmpty()) {
|
||||
toast(getString(R.string.server_proxy_chain_members_invalid, invalidMembers.joinToString(", ")))
|
||||
return false
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.PROXYCHAIN)
|
||||
config.remarks = binding.etRemarks.text.toString().trim()
|
||||
config.proxyChainProfiles = chainMembers.joinToString(",")
|
||||
config.description = chainMembers.joinToString(" -> ")
|
||||
|
||||
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
||||
config.subscriptionId = subscriptionId.orEmpty()
|
||||
}
|
||||
|
||||
MmkvManager.encodeServerConfig(editGuid, config)
|
||||
if (isRunning) {
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
toastSuccess(R.string.toast_success)
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun deleteServer(): Boolean {
|
||||
if (editGuid.isNotEmpty()) {
|
||||
if (editGuid != MmkvManager.getSelectServer()) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
|
||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
MmkvManager.removeServer(editGuid)
|
||||
finish()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
// do nothing
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
MmkvManager.removeServer(editGuid)
|
||||
finish()
|
||||
}
|
||||
} else {
|
||||
toast(R.string.toast_action_not_allowed)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun addMemberRow() {
|
||||
if (allRemarks.isEmpty()) {
|
||||
toast(R.string.toast_none_data)
|
||||
return
|
||||
}
|
||||
memberAdapter.addRow()
|
||||
}
|
||||
|
||||
private fun parseChainMembers(raw: String?): List<String> {
|
||||
return raw.orEmpty()
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private inner class ActivityAdapterListener : BaseAdapterListener {
|
||||
override fun onEdit(guid: String, position: Int) {
|
||||
// Row selection is handled directly by AutoCompleteTextView in the adapter.
|
||||
}
|
||||
|
||||
override fun onRemove(guid: String, position: Int) {
|
||||
memberAdapter.removeRow(position)
|
||||
}
|
||||
|
||||
override fun onShare(url: String) {
|
||||
}
|
||||
|
||||
override fun onRefreshData() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.action_server, menu)
|
||||
|
||||
val delButton = menu.findItem(R.id.del_config)
|
||||
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
|
||||
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.del_config -> {
|
||||
deleteServer()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.save_config -> {
|
||||
saveServer()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.graphics.Color
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ItemRecyclerProxyChainMemberBinding
|
||||
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
||||
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
||||
import java.util.Collections
|
||||
|
||||
class ServerProxyChainMemberAdapter(
|
||||
private val members: MutableList<String>,
|
||||
private val suggestions: List<String>,
|
||||
private val adapterListener: BaseAdapterListener?
|
||||
) : RecyclerView.Adapter<ServerProxyChainMemberAdapter.MemberViewHolder>(), ItemTouchHelperAdapter {
|
||||
|
||||
override fun getItemCount(): Int = members.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MemberViewHolder {
|
||||
return MemberViewHolder(
|
||||
ItemRecyclerProxyChainMemberBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: MemberViewHolder, position: Int) {
|
||||
val adapterPos = holder.bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: position
|
||||
val value = members[position]
|
||||
holder.binding.tvMemberIndex.text = (position + 1).toString()
|
||||
|
||||
val dropdownAdapter = ArrayAdapter(
|
||||
holder.itemView.context,
|
||||
android.R.layout.simple_dropdown_item_1line,
|
||||
suggestions
|
||||
)
|
||||
holder.binding.spMemberRemark.setAdapter(dropdownAdapter)
|
||||
holder.binding.spMemberRemark.threshold = 0
|
||||
holder.binding.spMemberRemark.setText(value, false)
|
||||
|
||||
holder.binding.spMemberRemark.setOnItemClickListener { _, _, selectedIndex, _ ->
|
||||
if (adapterPos in members.indices) {
|
||||
members[adapterPos] = suggestions[selectedIndex].trim()
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
}
|
||||
holder.binding.spMemberRemark.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
|
||||
if (hasFocus) return@OnFocusChangeListener
|
||||
val text = holder.binding.spMemberRemark.text?.toString().orEmpty().trim()
|
||||
if (adapterPos in members.indices && members[adapterPos] != text) {
|
||||
members[adapterPos] = text
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
}
|
||||
holder.binding.spMemberRemark.setOnClickListener { holder.binding.spMemberRemark.showDropDown() }
|
||||
holder.binding.btnMemberDropdown.setOnClickListener {
|
||||
holder.binding.spMemberRemark.requestFocus()
|
||||
holder.binding.spMemberRemark.showDropDown()
|
||||
}
|
||||
holder.itemView.setOnClickListener { holder.binding.spMemberRemark.showDropDown() }
|
||||
|
||||
holder.binding.layoutRemove.setOnClickListener {
|
||||
val removePos = holder.bindingAdapterPosition
|
||||
if (removePos != RecyclerView.NO_POSITION) {
|
||||
adapterListener?.onRemove("", removePos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addRow() {
|
||||
members.add("")
|
||||
notifyItemInserted(members.lastIndex)
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
fun removeRow(position: Int) {
|
||||
if (position < 0 || position >= members.size) return
|
||||
members.removeAt(position)
|
||||
notifyItemRemoved(position)
|
||||
notifyItemRangeChanged(position, members.size - position)
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
fun setRemark(position: Int, remark: String) {
|
||||
if (position < 0 || position >= members.size) return
|
||||
members[position] = remark
|
||||
notifyItemChanged(position)
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
fun replaceAll(newMembers: List<String>) {
|
||||
members.clear()
|
||||
members.addAll(newMembers)
|
||||
notifyDataSetChanged()
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
fun getMembers(): List<String> = members.toList()
|
||||
|
||||
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
|
||||
if (fromPosition == toPosition) return true
|
||||
Collections.swap(members, fromPosition, toPosition)
|
||||
notifyItemMoved(fromPosition, toPosition)
|
||||
notifyItemChanged(fromPosition)
|
||||
notifyItemChanged(toPosition)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onItemMoveCompleted() {
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
override fun onItemDismiss(position: Int) {
|
||||
// Swipe-to-dismiss disabled for this adapter.
|
||||
}
|
||||
|
||||
class MemberViewHolder(val binding: ItemRecyclerProxyChainMemberBinding) :
|
||||
BaseViewHolder(binding.root), ItemTouchHelperViewHolder
|
||||
|
||||
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
fun onItemSelected() {
|
||||
itemView.setBackgroundColor(Color.LTGRAY)
|
||||
}
|
||||
|
||||
fun onItemClear() {
|
||||
itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,12 @@ import androidx.preference.CheckBoxPreference
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.multiprocess.RemoteWorkManager
|
||||
import com.v2ray.ang.AngApplication
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.VPN
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.extension.toLongEx
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SubscriptionUpdater
|
||||
import com.v2ray.ang.helper.MmkvPreferenceDataStore
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SettingsActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -48,8 +41,6 @@ class SettingsActivity : BaseActivity() {
|
||||
private val fragmentLength by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_LENGTH) }
|
||||
private val fragmentInterval by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_INTERVAL) }
|
||||
|
||||
private val autoUpdateCheck by lazy { findPreference<CheckBoxPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE) }
|
||||
private val autoUpdateInterval by lazy { findPreference<EditTextPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) }
|
||||
private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) }
|
||||
|
||||
private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) }
|
||||
@@ -96,15 +87,6 @@ class SettingsActivity : BaseActivity() {
|
||||
true
|
||||
}
|
||||
|
||||
autoUpdateCheck?.setOnPreferenceChangeListener { _, newValue ->
|
||||
val value = newValue as Boolean
|
||||
autoUpdateCheck?.isChecked = value
|
||||
autoUpdateInterval?.isEnabled = value
|
||||
autoUpdateInterval?.text?.toLongEx()?.let {
|
||||
if (newValue) configureUpdateTask(it) else cancelUpdateTask()
|
||||
}
|
||||
true
|
||||
}
|
||||
mode?.setOnPreferenceChangeListener { pref, newValue ->
|
||||
val valueStr = newValue.toString()
|
||||
(pref as? ListPreference)?.let { lp ->
|
||||
@@ -194,9 +176,6 @@ class SettingsActivity : BaseActivity() {
|
||||
// Initialize fragment-dependent UI states
|
||||
updateFragment(MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false))
|
||||
|
||||
// Initialize auto-update interval state
|
||||
autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
|
||||
|
||||
updateDynamicSocksPort(MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false))
|
||||
}
|
||||
|
||||
@@ -234,29 +213,6 @@ class SettingsActivity : BaseActivity() {
|
||||
vpnDns?.isEnabled = !enabled
|
||||
}
|
||||
|
||||
private fun configureUpdateTask(interval: Long) {
|
||||
val rw = RemoteWorkManager.getInstance(AngApplication.application)
|
||||
rw.cancelUniqueWork(AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME)
|
||||
rw.enqueueUniquePeriodicWork(
|
||||
AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
PeriodicWorkRequest.Builder(
|
||||
SubscriptionUpdater.UpdateTask::class.java,
|
||||
interval,
|
||||
TimeUnit.MINUTES
|
||||
)
|
||||
.apply {
|
||||
setInitialDelay(interval, TimeUnit.MINUTES)
|
||||
}
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun cancelUpdateTask() {
|
||||
val rw = RemoteWorkManager.getInstance(AngApplication.application)
|
||||
rw.cancelUniqueWork(AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME)
|
||||
}
|
||||
|
||||
private fun updateMux(enabled: Boolean) {
|
||||
muxConcurrency?.isEnabled = enabled
|
||||
muxXudpConcurrency?.isEnabled = enabled
|
||||
|
||||
@@ -4,17 +4,22 @@ import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.AutoCompleteTextView
|
||||
import android.widget.ImageButton
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivitySubEditBinding
|
||||
import com.v2ray.ang.dto.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.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.SubscriptionUpdater
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -32,6 +37,7 @@ class SubEditActivity : BaseActivity() {
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
|
||||
|
||||
setupProfileRemarkInputs()
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
val subItem = MmkvManager.decodeSubscription(editSubId)
|
||||
if (subItem != null) {
|
||||
@@ -51,6 +57,7 @@ class SubEditActivity : BaseActivity() {
|
||||
binding.etFilter.text = Utils.getEditable(subItem.filter)
|
||||
binding.chkEnable.isChecked = subItem.enabled
|
||||
binding.autoUpdateCheck.isChecked = subItem.autoUpdate
|
||||
binding.etUpdateInterval.text = Utils.getEditable(subItem.updateInterval.toString())
|
||||
binding.allowInsecureUrl.isChecked = subItem.allowInsecureUrl
|
||||
binding.etPreProfile.text = Utils.getEditable(subItem.prevProfile)
|
||||
binding.etNextProfile.text = Utils.getEditable(subItem.nextProfile)
|
||||
@@ -65,11 +72,43 @@ class SubEditActivity : BaseActivity() {
|
||||
binding.etUrl.text = null
|
||||
binding.etFilter.text = null
|
||||
binding.chkEnable.isChecked = true
|
||||
binding.etUpdateInterval.text = null
|
||||
binding.etPreProfile.text = null
|
||||
binding.etNextProfile.text = null
|
||||
return true
|
||||
}
|
||||
|
||||
private fun setupProfileRemarkInputs() {
|
||||
val suggestions = SettingsManager.getProfileRemarks(
|
||||
excludeConfigTypes = setOf(
|
||||
EConfigType.CUSTOM,
|
||||
EConfigType.POLICYGROUP,
|
||||
EConfigType.PROXYCHAIN,
|
||||
)
|
||||
)
|
||||
|
||||
setupProfileRemarkInput(binding.etPreProfile, binding.btnPreProfileDropdown, suggestions)
|
||||
setupProfileRemarkInput(binding.etNextProfile, binding.btnNextProfileDropdown, suggestions)
|
||||
}
|
||||
|
||||
private fun setupProfileRemarkInput(
|
||||
input: AutoCompleteTextView,
|
||||
dropdownButton: ImageButton,
|
||||
suggestions: List<String>
|
||||
) {
|
||||
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
|
||||
input.setAdapter(adapter)
|
||||
input.threshold = 0
|
||||
|
||||
dropdownButton.setOnClickListener {
|
||||
input.requestFocus()
|
||||
input.showDropDown()
|
||||
}
|
||||
input.setOnClickListener {
|
||||
input.showDropDown()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* save server config
|
||||
*/
|
||||
@@ -82,6 +121,27 @@ class SubEditActivity : BaseActivity() {
|
||||
subItem.filter = binding.etFilter.text.toString()
|
||||
subItem.enabled = binding.chkEnable.isChecked
|
||||
subItem.autoUpdate = binding.autoUpdateCheck.isChecked
|
||||
|
||||
val intervalInput = binding.etUpdateInterval.text.toString().trim()
|
||||
val intervalMinutes = intervalInput.toLongOrNull()
|
||||
if (subItem.autoUpdate) {
|
||||
// autoUpdate is enabled: interval must be valid
|
||||
if (intervalMinutes == null) {
|
||||
// field is empty, reset to default
|
||||
subItem.updateInterval = SubscriptionItem().updateInterval
|
||||
} else if (intervalMinutes < AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES) {
|
||||
toast(R.string.toast_invalid_update_interval)
|
||||
return false
|
||||
} else {
|
||||
subItem.updateInterval = intervalMinutes
|
||||
}
|
||||
} else {
|
||||
// autoUpdate is disabled: save only if the value is valid, otherwise keep the existing value
|
||||
if (intervalMinutes != null && intervalMinutes >= AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES) {
|
||||
subItem.updateInterval = intervalMinutes
|
||||
}
|
||||
}
|
||||
|
||||
subItem.prevProfile = binding.etPreProfile.text.toString()
|
||||
subItem.nextProfile = binding.etNextProfile.text.toString()
|
||||
subItem.allowInsecureUrl = binding.allowInsecureUrl.isChecked
|
||||
@@ -105,6 +165,7 @@ class SubEditActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
MmkvManager.encodeSubscription(editSubId, subItem)
|
||||
SubscriptionUpdater.syncOne(subId = editSubId)
|
||||
toastSuccess(R.string.toast_success)
|
||||
finish()
|
||||
return true
|
||||
|
||||
@@ -15,7 +15,7 @@ import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ActivityUserAssetBinding
|
||||
import com.v2ray.ang.dto.AssetUrlItem
|
||||
import com.v2ray.ang.dto.entities.AssetUrlItem
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
|
||||
@@ -8,7 +8,7 @@ import androidx.appcompat.app.AlertDialog
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityUserAssetUrlBinding
|
||||
import com.v2ray.ang.dto.AssetUrlItem
|
||||
import com.v2ray.ang.dto.entities.AssetUrlItem
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
|
||||
@@ -3,6 +3,10 @@ package com.v2ray.ang.util
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.LOOPBACK
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.IDN
|
||||
@@ -14,9 +18,6 @@ import java.net.Proxy
|
||||
import java.net.URI
|
||||
import java.net.URL
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
object HttpUtil {
|
||||
|
||||
@@ -108,20 +109,15 @@ object HttpUtil {
|
||||
* @param httpPort The HTTP port to use.
|
||||
* @return The content of the URL as a string.
|
||||
*/
|
||||
fun getUrlContent(
|
||||
url: String,
|
||||
timeout: Int,
|
||||
httpPort: Int = 0,
|
||||
proxyUsername: String? = null,
|
||||
proxyPassword: String? = null
|
||||
): String? {
|
||||
val client = buildOkHttpClient(timeout, httpPort, proxyUsername, proxyPassword, followRedirects = true)
|
||||
fun getUrlContent(request: UrlContentRequest): String? {
|
||||
val url = request.url ?: return null
|
||||
val client = buildOkHttpClient(request.timeout, request.httpPort, request.proxyUsername, request.proxyPassword, followRedirects = true)
|
||||
val requestBuilder = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header("Connection", "close")
|
||||
if (httpPort != 0 && !proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
|
||||
requestBuilder.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
|
||||
if (request.httpPort != 0 && !request.proxyUsername.isNullOrBlank() && !request.proxyPassword.isNullOrBlank()) {
|
||||
requestBuilder.header("Proxy-Authorization", Credentials.basic(request.proxyUsername, request.proxyPassword))
|
||||
}
|
||||
try {
|
||||
client.newCall(requestBuilder.build()).execute().use { response ->
|
||||
@@ -147,33 +143,29 @@ object HttpUtil {
|
||||
* @throws IOException If an I/O error occurs.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun getUrlContentWithUserAgent(
|
||||
url: String?,
|
||||
userAgent: String?,
|
||||
timeout: Int = 15000,
|
||||
httpPort: Int = 0,
|
||||
proxyUsername: String? = null,
|
||||
proxyPassword: String? = null
|
||||
): String {
|
||||
var currentUrl = url
|
||||
fun getUrlContentWithUserAgent(request: UrlContentRequest): String {
|
||||
var currentUrl = request.url
|
||||
var redirects = 0
|
||||
val maxRedirects = 3
|
||||
|
||||
while (redirects++ < maxRedirects) {
|
||||
if (currentUrl == null) continue
|
||||
val client = buildOkHttpClient(timeout, httpPort, proxyUsername, proxyPassword, followRedirects = false)
|
||||
val finalUserAgent = if (userAgent.isNullOrBlank()) {
|
||||
val client = buildOkHttpClient(request.timeout, request.httpPort, request.proxyUsername, request.proxyPassword, followRedirects = false)
|
||||
val finalUserAgent = if (request.userAgent.isNullOrBlank()) {
|
||||
"v2rayNG/${BuildConfig.VERSION_NAME}"
|
||||
} else {
|
||||
userAgent
|
||||
request.userAgent
|
||||
}
|
||||
val requestBuilder = Request.Builder()
|
||||
.url(currentUrl)
|
||||
.get()
|
||||
.header("User-agent", finalUserAgent)
|
||||
.header("Connection", "close")
|
||||
if (httpPort != 0 && !proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
|
||||
requestBuilder.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
|
||||
|
||||
applyEmbeddedBasicAuthHeader(currentUrl, requestBuilder)
|
||||
|
||||
if (request.httpPort != 0 && !request.proxyUsername.isNullOrBlank() && !request.proxyPassword.isNullOrBlank()) {
|
||||
requestBuilder.header("Proxy-Authorization", Credentials.basic(request.proxyUsername, request.proxyPassword))
|
||||
}
|
||||
|
||||
client.newCall(requestBuilder.build()).execute().use { response ->
|
||||
@@ -203,6 +195,20 @@ object HttpUtil {
|
||||
throw IOException("Too many redirects")
|
||||
}
|
||||
|
||||
private fun applyEmbeddedBasicAuthHeader(rawUrl: String, requestBuilder: Request.Builder) {
|
||||
val parsed = runCatching { URL(rawUrl) }.getOrNull() ?: return
|
||||
parsed.userInfo?.let { userInfo ->
|
||||
val colon = userInfo.indexOf(':')
|
||||
val user = runCatching {
|
||||
Utils.decodeURIComponent(if (colon >= 0) userInfo.substring(0, colon) else userInfo)
|
||||
}.getOrDefault(if (colon >= 0) userInfo.substring(0, colon) else userInfo)
|
||||
val pass = runCatching {
|
||||
Utils.decodeURIComponent(if (colon >= 0) userInfo.substring(colon + 1) else "")
|
||||
}.getOrDefault(if (colon >= 0) userInfo.substring(colon + 1) else "")
|
||||
requestBuilder.header("Authorization", Credentials.basic(user, pass))
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildOkHttpClient(
|
||||
timeout: Int,
|
||||
httpPort: Int,
|
||||
@@ -254,20 +260,17 @@ object HttpUtil {
|
||||
}
|
||||
|
||||
fun downloadToFile(
|
||||
url: String,
|
||||
targetFile: File,
|
||||
timeout: Int = 15000,
|
||||
httpPort: Int = 0,
|
||||
proxyUsername: String? = null,
|
||||
proxyPassword: String? = null
|
||||
request: UrlContentRequest,
|
||||
targetFile: File
|
||||
): Boolean {
|
||||
val client = buildOkHttpClient(timeout, httpPort, proxyUsername, proxyPassword, followRedirects = true)
|
||||
val url = request.url ?: return false
|
||||
val client = buildOkHttpClient(request.timeout, request.httpPort, request.proxyUsername, request.proxyPassword, followRedirects = true)
|
||||
val requestBuilder = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header("Connection", "close")
|
||||
if (httpPort != 0 && !proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
|
||||
requestBuilder.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
|
||||
if (request.httpPort != 0 && !request.proxyUsername.isNullOrBlank() && !request.proxyPassword.isNullOrBlank()) {
|
||||
requestBuilder.header("Proxy-Authorization", Credentials.basic(request.proxyUsername, request.proxyPassword))
|
||||
}
|
||||
|
||||
return try {
|
||||
@@ -290,4 +293,3 @@ object HttpUtil {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,23 @@ object JsonUtil {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -3,9 +3,11 @@ package com.v2ray.ang.util
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.TestServiceMessage
|
||||
import com.v2ray.ang.service.V2RayTestService
|
||||
import com.v2ray.ang.service.CoreTestService
|
||||
import java.io.Serializable
|
||||
|
||||
object MessageUtil {
|
||||
@@ -42,9 +44,26 @@ object MessageUtil {
|
||||
fun sendMsg2TestService(ctx: Context, message: TestServiceMessage) {
|
||||
try {
|
||||
val intent = Intent()
|
||||
intent.component = ComponentName(ctx, V2RayTestService::class.java)
|
||||
intent.component = ComponentName(ctx, CoreTestService::class.java)
|
||||
intent.putExtra("content", message)
|
||||
ctx.startService(intent)
|
||||
when (message.key) {
|
||||
AppConfig.MSG_MEASURE_CONFIG_START -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ContextCompat.startForegroundService(ctx, intent)
|
||||
} else {
|
||||
ctx.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> {
|
||||
// Do not wake up service just to cancel; stop only if it is already running.
|
||||
ctx.stopService(intent)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ctx.startService(intent)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to send message to test service", e)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.v2ray.ang.util
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.enums.NotificationChannelType
|
||||
|
||||
/**
|
||||
* Unified notification helper for different notification channels.
|
||||
* Supports both regular notifications and foreground service notifications.
|
||||
*
|
||||
* Performance: NotificationManager is cached. Builder is created once per update.
|
||||
* Safe for high-frequency updates (100+ times/second).
|
||||
*/
|
||||
object NotificationHelper {
|
||||
|
||||
// Cached instances for performance
|
||||
private var cachedNotificationManager: NotificationManager? = null
|
||||
private val builderCache = mutableMapOf<Int, NotificationCompat.Builder>()
|
||||
|
||||
/**
|
||||
* Notify with a regular notification (non-foreground).
|
||||
*
|
||||
* @param channelType The notification channel type (defines channelId, notificationId, etc.)
|
||||
* @param context The context for building the notification
|
||||
* @param title The notification title
|
||||
* @param content The notification content text
|
||||
*/
|
||||
fun notify(
|
||||
channelType: NotificationChannelType,
|
||||
context: Context,
|
||||
title: String,
|
||||
content: String
|
||||
) {
|
||||
ensureChannelCreated(channelType, context)
|
||||
val notificationManager = getNotificationManager(context)
|
||||
val builder = buildNotificationBuilder(channelType, context, title, content)
|
||||
notificationManager.notify(channelType.notificationId, builder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing notification's content.
|
||||
* Optimized for high-frequency updates (100+/sec).
|
||||
* Reuses cached Builder to minimize allocation overhead.
|
||||
*
|
||||
* @param channelType The notification channel type
|
||||
* @param context The context
|
||||
* @param content The new content text
|
||||
*/
|
||||
fun updateNotification(
|
||||
channelType: NotificationChannelType,
|
||||
context: Context,
|
||||
content: String
|
||||
) {
|
||||
val notificationManager = getNotificationManager(context)
|
||||
|
||||
// Get or create builder from cache
|
||||
val builder = builderCache.getOrPut(channelType.notificationId) {
|
||||
buildNotificationBuilder(channelType, context, "", content)
|
||||
}
|
||||
|
||||
// Update only the content text (fast operation)
|
||||
builder.setContentText(content)
|
||||
notificationManager.notify(channelType.notificationId, builder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a foreground service with a notification.
|
||||
*
|
||||
* @param service The service to set as foreground
|
||||
* @param channelType The notification channel type
|
||||
* @param title The notification title
|
||||
* @param content The notification content text
|
||||
*/
|
||||
fun startForeground(
|
||||
service: Service,
|
||||
channelType: NotificationChannelType,
|
||||
title: String,
|
||||
content: String
|
||||
) {
|
||||
ensureChannelCreated(channelType, service)
|
||||
val builder = buildNotificationBuilder(channelType, service, title, content)
|
||||
service.startForeground(channelType.notificationId, builder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the foreground notification for a service.
|
||||
*
|
||||
* @param service The service to stop foreground on
|
||||
*/
|
||||
fun stopForeground(service: Service) {
|
||||
service.stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a notification and clean up cached builder.
|
||||
*
|
||||
* @param channelType The notification channel type
|
||||
* @param context The context
|
||||
*/
|
||||
fun cancel(
|
||||
channelType: NotificationChannelType,
|
||||
context: Context
|
||||
) {
|
||||
getNotificationManager(context).cancel(channelType.notificationId)
|
||||
builderCache.remove(channelType.notificationId) // Clean up cache
|
||||
}
|
||||
|
||||
// ====== Private helper methods ======
|
||||
|
||||
private fun getNotificationManager(context: Context): NotificationManager {
|
||||
if (cachedNotificationManager == null) {
|
||||
cachedNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
}
|
||||
return cachedNotificationManager!!
|
||||
}
|
||||
|
||||
private fun ensureChannelCreated(channelType: NotificationChannelType, context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
|
||||
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (notificationManager.getNotificationChannel(channelType.channelId) != null) return
|
||||
|
||||
val channel = NotificationChannel(
|
||||
channelType.channelId,
|
||||
channelType.channelName,
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun buildNotificationBuilder(
|
||||
channelType: NotificationChannelType,
|
||||
context: Context,
|
||||
title: String,
|
||||
content: String
|
||||
): NotificationCompat.Builder {
|
||||
val channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
channelType.channelId
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val displayTitle = title.ifEmpty { context.getString(R.string.app_name) }
|
||||
return NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_stat_name)
|
||||
.setContentTitle(displayTitle)
|
||||
.setContentText(content)
|
||||
.setOngoing(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ object PackageUidResolver {
|
||||
private fun resolveUid(context: Context, packageName: String): String? {
|
||||
// Special token for connections whose UID cannot be resolved (mapped to -1)
|
||||
if (packageName == AppConfig.UNIDENTIFIED_PACKAGE) {
|
||||
val uid = "-1"
|
||||
val uid = "-1"
|
||||
LogUtil.d(AppConfig.TAG, "Special package: $packageName -> UID: $uid")
|
||||
return uid
|
||||
}
|
||||
|
||||
@@ -488,6 +488,16 @@ object Utils {
|
||||
throw IOException("no free port found")
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a random free port.
|
||||
*
|
||||
* @return A random free port.
|
||||
* @throws IOException If no free port is found.
|
||||
*/
|
||||
fun findRandomFreePort(): Int {
|
||||
return ServerSocket(0).use { it.localPort }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid subscription URL.
|
||||
*
|
||||
|
||||
@@ -14,12 +14,12 @@ import com.v2ray.ang.AngApplication
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.GroupMapItem
|
||||
import com.v2ray.ang.dto.ServersCache
|
||||
import com.v2ray.ang.dto.SubscriptionCache
|
||||
import com.v2ray.ang.dto.SubscriptionUpdateResult
|
||||
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.serializable
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
@@ -222,7 +222,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
MessageUtil.sendMsg2TestService(
|
||||
getApplication(),
|
||||
TestServiceMessage(
|
||||
key = AppConfig.MSG_MEASURE_CONFIG,
|
||||
key = AppConfig.MSG_MEASURE_CONFIG_START,
|
||||
subscriptionId = subscriptionId,
|
||||
serverGuids = if (keywordFilter.isNotEmpty()) serversCache.map { it.guid } else emptyList()
|
||||
)
|
||||
@@ -297,16 +297,28 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
/**
|
||||
* Removes duplicate servers.
|
||||
* Excludes servers with complex types (Custom, PolicyGroup, or ProxyChain) from duplicate comparison.
|
||||
* @return The number of removed servers.
|
||||
*/
|
||||
fun removeDuplicateServer(): Int {
|
||||
val serversCacheCopy = serversCache.toList().toMutableList()
|
||||
val deleteServer = mutableListOf<String>()
|
||||
|
||||
serversCacheCopy.forEachIndexed { index, sc ->
|
||||
val profile = sc.profile
|
||||
// Skip if this profile has a complex config type
|
||||
if (profile.configType.isComplexType()) {
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
serversCacheCopy.forEachIndexed { index2, sc2 ->
|
||||
if (index2 > index) {
|
||||
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)) {
|
||||
deleteServer.add(sc2.guid)
|
||||
}
|
||||
@@ -457,7 +469,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_START_FAILURE -> {
|
||||
getApplication<AngApplication>().toastError(R.string.toast_services_failure)
|
||||
val errorMessage = intent.getStringExtra("content")
|
||||
if (!errorMessage.isNullOrBlank()) {
|
||||
getApplication<AngApplication>().toastError(errorMessage)
|
||||
} else {
|
||||
getApplication<AngApplication>().toastError(R.string.toast_services_failure)
|
||||
}
|
||||
isRunning.value = false
|
||||
}
|
||||
|
||||
@@ -470,9 +487,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> {
|
||||
val resultPair = intent.serializable<Pair<String, Long>>("content") ?: return
|
||||
MmkvManager.encodeServerTestDelayMillis(resultPair.first, resultPair.second)
|
||||
updateListAction.value = getPosition(resultPair.first)
|
||||
val content = intent.getStringExtra("content")
|
||||
updateListAction.value = getPosition(content ?: "")
|
||||
}
|
||||
|
||||
AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.v2ray.ang.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.v2ray.ang.dto.RulesetItem
|
||||
import com.v2ray.ang.dto.entities.RulesetItem
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.v2ray.ang.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.v2ray.ang.dto.SubscriptionCache
|
||||
import com.v2ray.ang.dto.SubscriptionItem
|
||||
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
|
||||
@@ -2,8 +2,9 @@ package com.v2ray.ang.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.AssetUrlCache
|
||||
import com.v2ray.ang.dto.AssetUrlItem
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
import com.v2ray.ang.dto.entities.AssetUrlCache
|
||||
import com.v2ray.ang.dto.entities.AssetUrlItem
|
||||
import com.v2ray.ang.extension.concatUrl
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
@@ -92,7 +93,18 @@ class UserAssetViewModel : ViewModel() {
|
||||
val targetTemp = File(extDir, item.remarks + "_temp")
|
||||
val target = File(extDir, item.remarks)
|
||||
try {
|
||||
if (HttpUtil.downloadToFile(item.url, targetTemp, 15000, httpPort, proxyUsername, proxyPassword)) {
|
||||
if (
|
||||
HttpUtil.downloadToFile(
|
||||
UrlContentRequest(
|
||||
url = item.url,
|
||||
timeout = 15000,
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
proxyPassword = proxyPassword
|
||||
),
|
||||
targetTemp
|
||||
)
|
||||
) {
|
||||
targetTemp.renameTo(target)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context=".ui.ServerProxyChainActivity">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/main_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_lab_remarks"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_remarks"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp8"
|
||||
android:hint="@string/server_lab_remarks"
|
||||
android:inputType="text" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_proxy_chain_members"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_proxy_chain_members"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:nestedScrollingEnabled="false"
|
||||
tools:listitem="@layout/item_recycler_proxy_chain_member" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fab_add_proxy_chain_member"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end"
|
||||
android:layout_margin="@dimen/padding_spacing_dp16"
|
||||
android:contentDescription="@string/menu_item_add_config"
|
||||
android:src="@drawable/ic_add_24dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</RelativeLayout>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user