Compare commits
129 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| e9332168bf | |||
| 15fcfec4c8 | |||
| a4baeeb0fa | |||
| 093e51186d | |||
| 14a29884e7 | |||
| 30b0bd8d0d | |||
| ae84df7367 | |||
| b673167731 | |||
| 659f2ded09 | |||
| 585d64b0be | |||
| 2ae2e747e2 | |||
| 4199cee4e5 | |||
| 8be8a912c4 | |||
| 851f22680a | |||
| 12abc99bfc | |||
| 07e50637a2 | |||
| c1824c7598 | |||
| 18eebf999d | |||
| e37d6b7523 | |||
| 8c5375ec62 | |||
| 30792299e8 | |||
| e6db0f6f9e | |||
| 04478ab490 | |||
| f371da490a | |||
| 0a0ef6e6b5 | |||
| fd57019e73 | |||
| 04ac6cb2d7 | |||
| 29a0be9629 | |||
| 7aa27fd3de | |||
| fcc478ff63 | |||
| 54d5ad2342 | |||
| ba25e05536 | |||
| 5a5a81fa0b | |||
| 322c4b305e | |||
| 590f11e671 | |||
| 872ec80292 | |||
| f19f153f9c | |||
| d7abb8f49e | |||
| e54e713db7 | |||
| de91fb27db | |||
| db8f46eae5 | |||
| 42e7f95e4f | |||
| 0a52a79fa0 | |||
| 9dc1b1f2cf | |||
| b61cad5a5f | |||
| 66cd746ad2 | |||
| ae4a92b66b | |||
| 55c0ccf1b0 | |||
| deedd26fac | |||
| e6c8307b96 | |||
| d6d5753283 | |||
| 014d510774 | |||
| 42f89e69a7 | |||
| d5a0997775 | |||
| af7db6f86b | |||
| 7b7f43e12e | |||
| 8dd1df1451 | |||
| d4a73493b5 | |||
| 9fce03d0ed | |||
| a12ca3ea04 | |||
| 4e9df84d3e | |||
| d3118b53f2 | |||
| cd45b51487 | |||
| 6aa26d2621 | |||
| 0b452707c3 | |||
| 303b642ade |
@@ -22,7 +22,7 @@ jobs:
|
||||
fetch-depth: '0'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3.2.0
|
||||
uses: android-actions/setup-android@v4.0.1
|
||||
with:
|
||||
log-accepted-android-sdk-licenses: false
|
||||
cmdline-tools-version: '13114758'
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
popd
|
||||
|
||||
- name: Download libv2ray
|
||||
uses: robinraju/release-downloader@v1.12
|
||||
uses: robinraju/release-downloader@v1.13
|
||||
with:
|
||||
repository: '2dust/AndroidLibXrayLite'
|
||||
tag: ${{ env.CURRENT_TAG }}
|
||||
@@ -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: c0a0231614...4c3a3cd051
@@ -6,14 +6,14 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.v2ray.ang"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 714
|
||||
versionName = "2.0.14"
|
||||
targetSdk = 37
|
||||
versionCode = 731
|
||||
versionName = "2.2.1"
|
||||
multiDexEnabled = true
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
|
||||
@@ -30,4 +30,34 @@
|
||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_qu_start_24dp"
|
||||
android:shortcutDisabledMessage="@string/toast_services_start"
|
||||
android:shortcutId="shortcuts_start"
|
||||
android:shortcutLongLabel="@string/toast_services_start"
|
||||
android:shortcutShortLabel="@string/toast_services_start">
|
||||
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetClass="com.v2ray.ang.ui.ScStartActivity"
|
||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_qu_stop_24dp"
|
||||
android:shortcutDisabledMessage="@string/toast_services_stop"
|
||||
android:shortcutId="shortcuts_stop"
|
||||
android:shortcutLongLabel="@string/toast_services_stop"
|
||||
android:shortcutShortLabel="@string/toast_services_stop">
|
||||
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetClass="com.v2ray.ang.ui.ScStopActivity"
|
||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
</shortcuts>
|
||||
@@ -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,12 +81,19 @@
|
||||
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" />
|
||||
<activity
|
||||
android:name=".ui.PerAppProxyActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.AppPickerActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.ScannerActivity"
|
||||
android:exported="false" />
|
||||
@@ -122,6 +128,18 @@
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
<activity
|
||||
android:name=".ui.ScStartActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
<activity
|
||||
android:name=".ui.ScStopActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.UrlSchemeActivity"
|
||||
@@ -153,7 +171,7 @@
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".service.V2RayVpnService"
|
||||
android:name=".service.CoreVpnService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
@@ -173,7 +191,7 @@
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".service.V2RayProxyOnlyService"
|
||||
android:name=".service.CoreProxyOnlyService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:label="@string/app_name"
|
||||
@@ -184,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"
|
||||
@@ -258,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,13 +30,13 @@
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls"
|
||||
"tls",
|
||||
"quic"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "tun",
|
||||
"port": 0,
|
||||
"protocol": "tun",
|
||||
"settings": {
|
||||
"name": "xray0",
|
||||
@@ -47,7 +47,8 @@
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls"
|
||||
"tls",
|
||||
"quic"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -90,8 +91,10 @@
|
||||
},
|
||||
{
|
||||
"protocol": "freedom",
|
||||
"settings": {
|
||||
"domainStrategy": "UseIP"
|
||||
"streamSettings": {
|
||||
"sockopt": {
|
||||
"domainStrategy": "UseIP"
|
||||
}
|
||||
},
|
||||
"tag": "direct"
|
||||
},
|
||||
|
||||
@@ -10,8 +10,8 @@ object AppConfig {
|
||||
/** Directory names used in the app's file system. */
|
||||
const val DIR_ASSETS = "assets"
|
||||
|
||||
const val WEBDAV_BACKUP_DIR = "backups"
|
||||
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
|
||||
const val WEBDAV_BACKUP_DIR = "backups"
|
||||
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
|
||||
|
||||
/** Legacy configuration keys. */
|
||||
const val ANG_CONFIG = "ang_config"
|
||||
@@ -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"
|
||||
@@ -54,10 +52,16 @@ object AppConfig {
|
||||
const val PREF_GROUP_ALL_DISPLAY = "pref_group_all_display"
|
||||
const val PREF_LANGUAGE = "pref_language"
|
||||
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
|
||||
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"
|
||||
const val PREF_SOCKS_USERNAME = "pref_socks_username"
|
||||
const val PREF_SOCKS_PASSWORD = "pref_socks_password"
|
||||
const val PREF_SOCKS_ENABLE_UDP = "pref_socks_enable_udp"
|
||||
const val PREF_REMOTE_DNS = "pref_remote_dns"
|
||||
const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
|
||||
const val PREF_DNS_HOSTS = "pref_dns_hosts"
|
||||
@@ -74,6 +78,7 @@ object AppConfig {
|
||||
const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout_v2"
|
||||
const val PREF_AUTO_REMOVE_INVALID_AFTER_TEST = "pref_auto_remove_invalid_after_test"
|
||||
const val PREF_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
|
||||
const val PREF_REAL_PING_CONCURRENCY = "pref_real_ping_concurrency"
|
||||
|
||||
/** Cache keys. */
|
||||
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
|
||||
@@ -100,7 +105,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"
|
||||
@@ -120,7 +126,8 @@ object AppConfig {
|
||||
const val TG_CHANNEL_URL = "https://t.me/github_2dust"
|
||||
const val DELAY_TEST_URL = "https://www.gstatic.com/generate_204"
|
||||
const val DELAY_TEST_URL2 = "https://www.google.com/generate_204"
|
||||
// const val IP_API_URL = "https://speed.cloudflare.com/meta"
|
||||
|
||||
// const val IP_API_URL = "https://speed.cloudflare.com/meta"
|
||||
const val IP_API_URL = "https://api.ip.sb/geoip"
|
||||
|
||||
/** DNS server addresses. */
|
||||
@@ -159,23 +166,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://"
|
||||
@@ -214,6 +221,8 @@ object AppConfig {
|
||||
const val REALITY = "reality"
|
||||
const val HEADER_TYPE_HTTP = "http"
|
||||
|
||||
const val UNIDENTIFIED_PACKAGE = "__unknown_app__"
|
||||
|
||||
val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
|
||||
val DNS_CLOUDFLARE_ONE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
|
||||
val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5")
|
||||
@@ -274,4 +283,9 @@ object AppConfig {
|
||||
"Chocolate4U/Iran-v2ray-rules"
|
||||
)
|
||||
|
||||
val BUILTIN_OUTBOUND_TAGS = setOf(
|
||||
TAG_PROXY,
|
||||
TAG_DIRECT,
|
||||
TAG_BLOCKED,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
|
||||
interface MainAdapterListener :BaseAdapterListener {
|
||||
interface MainAdapterListener : BaseAdapterListener {
|
||||
|
||||
fun onEdit(guid: String, position: Int, profile: ProfileItem)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ interface Tun2SocksControl {
|
||||
* This initializes the VPN tunnel and connects it to the SOCKS proxy.
|
||||
*/
|
||||
fun startTun2Socks()
|
||||
|
||||
|
||||
/**
|
||||
* Stops the tun2socks process and cleans up resources.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+19
-10
@@ -1,8 +1,8 @@
|
||||
package com.v2ray.ang.handler
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import go.Seq
|
||||
import libv2ray.CoreCallbackHandler
|
||||
@@ -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)
|
||||
|
||||
/**
|
||||
@@ -32,14 +32,23 @@ object V2RayNativeManager {
|
||||
val assetPath = Utils.userAssetPath(context)
|
||||
val deviceId = Utils.getDeviceIdForXUDPBaseKey()
|
||||
Libv2ray.initCoreEnv(assetPath, deviceId)
|
||||
Log.i(AppConfig.TAG, "V2Ray core environment initialized successfully")
|
||||
LogUtil.i(AppConfig.TAG, "V2Ray core environment initialized successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to initialize V2Ray core environment", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to initialize V2Ray core environment", e)
|
||||
initialized.set(false)
|
||||
throw e
|
||||
}
|
||||
} else {
|
||||
Log.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping")
|
||||
LogUtil.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +62,7 @@ object V2RayNativeManager {
|
||||
return try {
|
||||
Libv2ray.checkVersionX()
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to check V2Ray version", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to check V2Ray version", e)
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
@@ -69,7 +78,7 @@ object V2RayNativeManager {
|
||||
return try {
|
||||
Libv2ray.measureOutboundDelay(config, testUrl)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to measure outbound delay", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to measure outbound delay", e)
|
||||
-1L
|
||||
}
|
||||
}
|
||||
@@ -84,8 +93,8 @@ object V2RayNativeManager {
|
||||
return try {
|
||||
Libv2ray.newCoreController(handler)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to create core controller", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to create core controller", e)
|
||||
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
|
||||
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()
|
||||
}
|
||||
}
|
||||
+210
-95
@@ -1,21 +1,33 @@
|
||||
package com.v2ray.ang.handler
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import android.system.OsConstants
|
||||
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.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
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -23,18 +35,27 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import libv2ray.CoreCallbackHandler
|
||||
import libv2ray.CoreController
|
||||
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
|
||||
V2RayNativeManager.initCoreEnv(value?.get()?.getService())
|
||||
val service = value?.get()?.getService()
|
||||
CoreNativeManager.initCoreEnv(service)
|
||||
if (service != null && processFinder == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
processFinder = XrayProcessFinder(service)
|
||||
coreController.registerProcessFinder(processFinder)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,7 +68,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
|
||||
}
|
||||
|
||||
@@ -57,13 +84,18 @@ object V2RayServiceManager {
|
||||
* @param guid The GUID of the server configuration to use (optional).
|
||||
*/
|
||||
fun startVService(context: Context, guid: String? = null) {
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
|
||||
|
||||
if (guid != null) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,35 +123,42 @@ 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) {
|
||||
Log.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||
return
|
||||
}
|
||||
|
||||
val guid = MmkvManager.getSelectServer()
|
||||
if (guid == null) {
|
||||
Log.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) {
|
||||
Log.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())
|
||||
) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
|
||||
return
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
|
||||
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 (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) {
|
||||
context.toast(R.string.toast_warning_pref_proxysharing_short)
|
||||
@@ -129,17 +168,26 @@ object V2RayServiceManager {
|
||||
|
||||
val isVpnMode = SettingsManager.isVpnMode()
|
||||
val intent = if (isVpnMode) {
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
|
||||
Intent(context.applicationContext, V2RayVpnService::class.java)
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
|
||||
Intent(context.applicationContext, CoreVpnService::class.java)
|
||||
} else {
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
|
||||
Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
|
||||
Intent(context.applicationContext, CoreProxyOnlyService::class.java)
|
||||
}
|
||||
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} catch (e: Exception) {
|
||||
Log.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,76 +198,80 @@ object V2RayServiceManager {
|
||||
*/
|
||||
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
|
||||
if (coreController.isRunning) {
|
||||
Log.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||
return false
|
||||
}
|
||||
|
||||
val service = getService()
|
||||
if (service == null) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Service is null")
|
||||
return false
|
||||
}
|
||||
|
||||
val guid = MmkvManager.getSelectServer()
|
||||
if (guid == null) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: No server selected")
|
||||
return false
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(guid)
|
||||
if (config == null) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
|
||||
return false
|
||||
}
|
||||
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Starting core loop for ${config.remarks}")
|
||||
val result = V2rayConfigManager.getV2rayConfig(service, guid)
|
||||
if (!result.status) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to get V2Ray config")
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Service is null")
|
||||
return false
|
||||
}
|
||||
|
||||
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())
|
||||
doStartCoreLoop(service, vpnInterface)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to register receiver", e)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@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 = CoreConfigManager.getV2rayConfig(service, guid)
|
||||
LogUtil.d(AppConfig.TAG, result.content)
|
||||
if (!result.status) {
|
||||
error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
|
||||
}
|
||||
|
||||
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) {
|
||||
Log.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) {
|
||||
Log.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)
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.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")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,31 +287,57 @@ object V2RayServiceManager {
|
||||
try {
|
||||
coreController.stopLoop()
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to stop V2Ray loop", e)
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop V2Ray loop", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close existing browser dialer
|
||||
CoreNativeManager.reconcileBrowserDialer("")
|
||||
if (browserDialer != null) {
|
||||
browserDialer!!.stop()
|
||||
browserDialer = null
|
||||
}
|
||||
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
|
||||
NotificationManager.cancelNotification()
|
||||
|
||||
try {
|
||||
service.unregisterReceiver(mMsgReceive)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to unregister receiver", e)
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to unregister receiver", e)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,14 +358,14 @@ object V2RayServiceManager {
|
||||
try {
|
||||
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
||||
}
|
||||
if (time == -1L) {
|
||||
try {
|
||||
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
||||
}
|
||||
}
|
||||
@@ -339,7 +417,7 @@ object V2RayServiceManager {
|
||||
serviceControl.stopService()
|
||||
0
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
|
||||
-1
|
||||
}
|
||||
}
|
||||
@@ -355,6 +433,43 @@ object V2RayServiceManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process finder implementation for Xray core.
|
||||
* Uses ConnectivityManager to find the owning UID of a connection based on network parameters.
|
||||
*/
|
||||
private class XrayProcessFinder(context: Context) : ProcessFinder {
|
||||
private val cm: ConnectivityManager? = context.getSystemService(ConnectivityManager::class.java)
|
||||
|
||||
override fun findProcessByConnection(network: String, srcIP: String, srcPort: Long, destIP: String, destPort: Long): Long {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return -1L
|
||||
if (cm == null) return -1L
|
||||
val proto = when (network) {
|
||||
"tcp" -> OsConstants.IPPROTO_TCP
|
||||
"udp" -> OsConstants.IPPROTO_UDP
|
||||
else -> return -1L
|
||||
}
|
||||
|
||||
if (destIP.isBlank() || destPort == 0L) {
|
||||
LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to :$destPort, (no dest)")
|
||||
return -1L
|
||||
}
|
||||
|
||||
return try {
|
||||
val uid = cm.getConnectionOwnerUid(
|
||||
proto,
|
||||
InetSocketAddress(srcIP, srcPort.toInt()),
|
||||
InetSocketAddress(destIP, destPort.toInt())
|
||||
).toLong()
|
||||
LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to $destIP:$destPort, uid=$uid")
|
||||
//LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to $destIP:$destPort, uid=$uid,${PackageUidResolver.uidToPackageName(uid.toString())}")
|
||||
|
||||
uid
|
||||
} catch (_: Exception) {
|
||||
-1L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast receiver for handling messages sent to the service.
|
||||
* Handles registration, service control, and screen events.
|
||||
@@ -386,12 +501,12 @@ object V2RayServiceManager {
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_STOP -> {
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Stop service")
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Stop service")
|
||||
serviceControl.stopService()
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_RESTART -> {
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Restart service")
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Restart service")
|
||||
serviceControl.stopService()
|
||||
Thread.sleep(500L)
|
||||
startVService(serviceControl.getService())
|
||||
@@ -404,13 +519,13 @@ object V2RayServiceManager {
|
||||
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SCREEN_OFF -> {
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Screen off")
|
||||
NotificationManager.stopSpeedNotification(currentConfig)
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen off")
|
||||
NotificationManager.stopSpeedNotification()
|
||||
}
|
||||
|
||||
Intent.ACTION_SCREEN_ON -> {
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Screen on")
|
||||
NotificationManager.startSpeedNotification(currentConfig)
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen on")
|
||||
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
|
||||
)
|
||||
@@ -31,7 +31,7 @@ data class V2rayConfig(
|
||||
|
||||
data class InboundBean(
|
||||
var tag: String,
|
||||
var port: Int,
|
||||
var port: Int?,
|
||||
var protocol: String,
|
||||
var listen: String? = null,
|
||||
var settings: InSettingsBean? = null,
|
||||
@@ -44,10 +44,16 @@ data class V2rayConfig(
|
||||
var auth: String? = null,
|
||||
var udp: Boolean? = null,
|
||||
var userLevel: Int? = null,
|
||||
var accounts: List<SocksAccountBean>? = null,
|
||||
var name: String? = null,
|
||||
@SerializedName("MTU")
|
||||
|
||||
var mtu: Int? = null
|
||||
)
|
||||
) {
|
||||
data class SocksAccountBean(
|
||||
var user: String = "",
|
||||
var pass: String = ""
|
||||
)
|
||||
}
|
||||
|
||||
data class SniffingBean(
|
||||
var enabled: Boolean,
|
||||
@@ -62,14 +68,11 @@ 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)
|
||||
) {
|
||||
data class OutSettingsBean(
|
||||
var vnext: List<VnextBean>? = null,
|
||||
var fragment: FragmentBean? = null,
|
||||
var noises: List<NoiseBean>? = null,
|
||||
var servers: List<ServersBean>? = null,
|
||||
/*Blackhole*/
|
||||
var response: Response? = null,
|
||||
@@ -78,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*/
|
||||
@@ -108,18 +110,6 @@ data class V2rayConfig(
|
||||
)
|
||||
}
|
||||
|
||||
data class FragmentBean(
|
||||
var packets: String? = null,
|
||||
var length: String? = null,
|
||||
var interval: String? = null
|
||||
)
|
||||
|
||||
data class NoiseBean(
|
||||
var type: String? = null,
|
||||
var packet: String? = null,
|
||||
var delay: String? = null
|
||||
)
|
||||
|
||||
data class ServersBean(
|
||||
var address: String = "",
|
||||
var method: String? = null,
|
||||
@@ -162,7 +152,7 @@ data class V2rayConfig(
|
||||
var realitySettings: TlsSettingsBean? = null,
|
||||
var grpcSettings: GrpcSettingsBean? = null,
|
||||
var hysteriaSettings: HysteriaSettingsBean? = null,
|
||||
var finalmask: FinalMaskBean? = null,
|
||||
var finalmask: Any? = null,
|
||||
val dsSettings: Any? = null,
|
||||
var sockopt: SockoptBean? = null
|
||||
) {
|
||||
@@ -189,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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -207,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,
|
||||
@@ -264,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,
|
||||
@@ -292,28 +280,51 @@ data class V2rayConfig(
|
||||
|
||||
data class HysteriaSettingsBean(
|
||||
var version: Int,
|
||||
var auth: String? = null,
|
||||
var up: String? = null,
|
||||
var down: String? = null,
|
||||
var udphop: HysteriaUdpHopBean? = null
|
||||
) {
|
||||
data class HysteriaUdpHopBean(
|
||||
var port: String? = null,
|
||||
var interval: Int? = null
|
||||
)
|
||||
}
|
||||
var auth: String? = null
|
||||
)
|
||||
|
||||
//https://xtls.github.io/config/transport.html#finalmaskobject
|
||||
data class FinalMaskBean(
|
||||
var tcp: List<MaskBean>? = null,
|
||||
var udp: List<MaskBean>? = null
|
||||
var udp: List<MaskBean>? = null,
|
||||
var quicParams: QuicParamsBean? = null
|
||||
) {
|
||||
data class MaskBean(
|
||||
var type: String,
|
||||
var settings: MaskSettingsBean? = null
|
||||
) {
|
||||
data class MaskSettingsBean(
|
||||
var password: String? = null,
|
||||
var domain: String? = null
|
||||
val password: String? = null,
|
||||
val domain: String? = null,
|
||||
// fragment
|
||||
val packets: String? = null,
|
||||
val length: String? = null,
|
||||
val delay: String? = null,
|
||||
// val maxSplit: String? = null,
|
||||
// noise
|
||||
val reset: Int? = null,
|
||||
val noise: List<NoiseMaskBean>? = null
|
||||
) {
|
||||
data class NoiseMaskBean(
|
||||
val rand: String? = null,
|
||||
// val randRange: String? = null,
|
||||
// val type: String? = null,
|
||||
// val packet: String? = null,
|
||||
val delay: String? = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class QuicParamsBean(
|
||||
var congestion: String? = null,
|
||||
var brutalUp: String? = null,
|
||||
var brutalDown: String? = null,
|
||||
var udpHop: UdpHopBean? = null,
|
||||
) {
|
||||
// Nested data class for the udpHop JSON object
|
||||
data class UdpHopBean(
|
||||
var ports: String? = null,
|
||||
var interval: String? = null
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -330,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
|
||||
}
|
||||
@@ -351,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)
|
||||
) {
|
||||
@@ -387,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(
|
||||
@@ -409,8 +421,9 @@ data class V2rayConfig(
|
||||
|
||||
data class RulesBean(
|
||||
var type: String = "field",
|
||||
var ip: ArrayList<String>? = null,
|
||||
var domain: ArrayList<String>? = null,
|
||||
var ip: List<String>? = null,
|
||||
var domain: List<String>? = null,
|
||||
var process: List<String>? = null,
|
||||
var outboundTag: String? = null,
|
||||
var balancerTag: String? = null,
|
||||
var port: String? = null,
|
||||
|
||||
+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 = "",
|
||||
+13
-13
@@ -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
|
||||
|
||||
@@ -29,6 +25,9 @@ data class ProfileItem(
|
||||
var host: String? = null,
|
||||
var path: String? = null,
|
||||
var seed: String? = null,
|
||||
var kcpMtu: Int? = null,
|
||||
var kcpTti: Int? = null,
|
||||
|
||||
var quicSecurity: String? = null,
|
||||
var quicKey: String? = null,
|
||||
var mode: String? = null,
|
||||
@@ -36,14 +35,13 @@ data class ProfileItem(
|
||||
var authority: String? = null,
|
||||
var xhttpMode: String? = null,
|
||||
var xhttpExtra: String? = null,
|
||||
|
||||
var finalMask: String? = null,
|
||||
var security: String? = null,
|
||||
var sni: String? = null,
|
||||
var alpn: String? = null,
|
||||
var fingerPrint: String? = null,
|
||||
var insecure: Boolean? = null,
|
||||
var echConfigList: String? = null,
|
||||
var echForceQuery: String? = null,
|
||||
var pinnedCA256: String? = null,
|
||||
|
||||
var publicKey: String? = null,
|
||||
@@ -67,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 {
|
||||
@@ -75,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
|
||||
}
|
||||
@@ -102,6 +99,8 @@ data class ProfileItem(
|
||||
&& this.host == obj.host
|
||||
&& this.path == obj.path
|
||||
&& this.seed == obj.seed
|
||||
&& this.kcpMtu == obj.kcpMtu
|
||||
&& this.kcpTti == obj.kcpTti
|
||||
&& this.quicSecurity == obj.quicSecurity
|
||||
&& this.quicKey == obj.quicKey
|
||||
&& this.mode == obj.mode
|
||||
@@ -125,6 +124,7 @@ data class ProfileItem(
|
||||
&& this.portHopping == obj.portHopping
|
||||
&& this.portHoppingInterval == obj.portHoppingInterval
|
||||
&& this.pinnedCA256 == obj.pinnedCA256
|
||||
&& this.proxyChainProfiles == obj.proxyChainProfiles
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class RulesetItem(
|
||||
var remarks: String? = "",
|
||||
var ip: List<String>? = null,
|
||||
var domain: List<String>? = null,
|
||||
var process: List<String>? = null,
|
||||
var outboundTag: String = "",
|
||||
var port: String? = null,
|
||||
var network: String? = null,
|
||||
+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
|
||||
@@ -189,4 +190,33 @@ fun String.concatUrl(vararg paths: String): String {
|
||||
}
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to match text either by Regex or literal string.
|
||||
*/
|
||||
fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean = true): Boolean {
|
||||
if (keyword.isNullOrEmpty()) {
|
||||
return true
|
||||
}
|
||||
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
|
||||
|
||||
@@ -12,7 +12,7 @@ object CustomFmt : FmtBase() {
|
||||
* @param str the JSON string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem? {
|
||||
fun parse(str: String): ProfileItem {
|
||||
val config = ProfileItem.create(EConfigType.CUSTOM)
|
||||
|
||||
val fullConfig = JsonUtil.fromJson(str, V2rayConfig::class.java)
|
||||
@@ -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
|
||||
@@ -60,6 +59,8 @@ open class FmtBase {
|
||||
config.path = queryParam["path"]
|
||||
|
||||
config.seed = queryParam["seed"]
|
||||
config.kcpMtu = queryParam["mtu"]?.toIntOrNull()
|
||||
config.kcpTti = queryParam["tti"]?.toIntOrNull()
|
||||
config.quicSecurity = queryParam["quicSecurity"]
|
||||
config.quicKey = queryParam["key"]
|
||||
config.mode = queryParam["mode"]
|
||||
@@ -67,6 +68,7 @@ open class FmtBase {
|
||||
config.authority = queryParam["authority"]
|
||||
config.xhttpMode = queryParam["mode"]
|
||||
config.xhttpExtra = queryParam["extra"]
|
||||
config.finalMask = queryParam["fm"]
|
||||
|
||||
config.security = queryParam["security"]
|
||||
if (config.security != AppConfig.TLS && config.security != AppConfig.REALITY) {
|
||||
@@ -110,6 +112,9 @@ open class FmtBase {
|
||||
config.spiderX?.nullIfBlank()?.let { dicQuery["spx"] = it }
|
||||
config.mldsa65Verify?.nullIfBlank()?.let { dicQuery["pqv"] = it }
|
||||
config.flow?.nullIfBlank()?.let { dicQuery["flow"] = it }
|
||||
config.finalMask?.nullIfBlank()?.let { dicQuery["fm"] = it }
|
||||
config.kcpMtu?.let { dicQuery["mtu"] = it.toString() }
|
||||
config.kcpTti?.let { dicQuery["tti"] = it.toString() }
|
||||
// Add two keys for compatibility: "insecure" and "allowInsecure"
|
||||
if (config.security == AppConfig.TLS) {
|
||||
val insecureFlag = if (config.insecure == true) "1" else "0"
|
||||
@@ -166,21 +171,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,16 +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.V2rayConfig.OutboundBean.StreamSettingsBean.FinalMaskBean
|
||||
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 +18,7 @@ object Hysteria2Fmt : FmtBase() {
|
||||
* @param str the Hysteria2 URI string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem? {
|
||||
fun parse(str: String): ProfileItem {
|
||||
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
||||
val config = ProfileItem.create(EConfigType.HYSTERIA2)
|
||||
|
||||
@@ -73,7 +70,37 @@ object Hysteria2Fmt : FmtBase() {
|
||||
dicQuery["mport"] = config.portHopping.orEmpty()
|
||||
}
|
||||
if (config.portHoppingInterval.isNotNullEmpty()) {
|
||||
dicQuery["mportHopInt"] = config.portHoppingInterval.orEmpty()
|
||||
val rawInterval = config.portHoppingInterval?.trim().nullIfBlank()
|
||||
val interval = if (rawInterval == null) {
|
||||
null
|
||||
} else {
|
||||
val singleValue = rawInterval.toIntOrNull()
|
||||
if (singleValue != null) {
|
||||
if (singleValue < 5) {
|
||||
null
|
||||
} 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) / 2
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (interval != null) {
|
||||
dicQuery["mportHopInt"] = interval.toString()
|
||||
}
|
||||
}
|
||||
if (config.pinnedCA256.isNotNullEmpty()) {
|
||||
dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
|
||||
@@ -81,44 +108,4 @@ object Hysteria2Fmt : FmtBase() {
|
||||
|
||||
return toUri(config, config.password, dicQuery)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HYSTERIA2) ?: return null
|
||||
profileItem.network = NetworkType.HYSTERIA.type
|
||||
profileItem.alpn = "h3"
|
||||
|
||||
outboundBean.settings?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.version = 2
|
||||
}
|
||||
|
||||
val sni = outboundBean.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
if (profileItem.obfsPassword.isNotNullEmpty()) {
|
||||
outboundBean.streamSettings?.finalmask = FinalMaskBean(
|
||||
udp = listOf(
|
||||
FinalMaskBean.MaskBean(
|
||||
type = "salamander",
|
||||
settings = FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
password = profileItem.obfsPassword
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
return outboundBean
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import android.util.Log
|
||||
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
|
||||
|
||||
@@ -85,7 +83,7 @@ object ShadowsocksFmt : FmtBase() {
|
||||
config.remarks =
|
||||
Utils.decodeURIComponent(result.substring(indexSplit + 1, result.length))
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
|
||||
}
|
||||
|
||||
result = result.substring(0, indexSplit)
|
||||
@@ -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
|
||||
|
||||
@@ -18,7 +16,7 @@ object TrojanFmt : FmtBase() {
|
||||
* @param str the Trojan URI string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem? {
|
||||
fun parse(str: String): ProfileItem {
|
||||
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
||||
val config = ProfileItem.create(EConfigType.TROJAN)
|
||||
|
||||
@@ -53,31 +51,4 @@ object TrojanFmt : FmtBase() {
|
||||
|
||||
return toUri(config, config.password, dicQuery)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.TROJAN)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.password = profileItem.password
|
||||
server.flow = profileItem.flow
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -50,31 +48,4 @@ object VlessFmt : FmtBase() {
|
||||
return toUri(config, config.password, dicQuery)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VLESS)
|
||||
|
||||
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||
vnext.address = getServerAddress(profileItem)
|
||||
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
||||
vnext.users[0].id = profileItem.password.orEmpty()
|
||||
vnext.users[0].encryption = profileItem.method
|
||||
vnext.users[0].flow = profileItem.flow
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
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
|
||||
import java.net.URI
|
||||
|
||||
@@ -34,7 +32,7 @@ object VmessFmt : FmtBase() {
|
||||
var result = str.replace(EConfigType.VMESS.protocolScheme, "")
|
||||
result = Utils.decode(result)
|
||||
if (TextUtils.isEmpty(result)) {
|
||||
Log.w(AppConfig.TAG, "Toast decoding failed")
|
||||
LogUtil.w(AppConfig.TAG, "Toast decoding failed")
|
||||
return null
|
||||
}
|
||||
val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java) ?: return null
|
||||
@@ -44,7 +42,7 @@ object VmessFmt : FmtBase() {
|
||||
|| TextUtils.isEmpty(vmessQRCode.id)
|
||||
|| TextUtils.isEmpty(vmessQRCode.net)
|
||||
) {
|
||||
Log.w(AppConfig.TAG, "Toast incorrect protocol")
|
||||
LogUtil.w(AppConfig.TAG, "Toast incorrect protocol")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -174,31 +172,5 @@ object VmessFmt : FmtBase() {
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VMESS)
|
||||
|
||||
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||
vnext.address = getServerAddress(profileItem)
|
||||
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
||||
vnext.users[0].id = profileItem.password.orEmpty()
|
||||
vnext.users[0].security = profileItem.method
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.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)
|
||||
@@ -46,7 +43,7 @@ object WireguardFmt : FmtBase() {
|
||||
* @param str the Wireguard configuration file string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parseWireguardConfFile(str: String): ProfileItem? {
|
||||
fun parseWireguardConfFile(str: String): ProfileItem {
|
||||
val config = ProfileItem.create(EConfigType.WIREGUARD)
|
||||
|
||||
val interfaceParams: MutableMap<String, String> = mutableMapOf()
|
||||
@@ -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.
|
||||
|
||||
@@ -3,14 +3,14 @@ package com.v2ray.ang.handler
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
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
|
||||
@@ -23,12 +23,28 @@ import com.v2ray.ang.fmt.VmessFmt
|
||||
import com.v2ray.ang.fmt.WireguardFmt
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.QRCodeDecoder
|
||||
import com.v2ray.ang.util.Utils
|
||||
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.
|
||||
@@ -47,7 +63,7 @@ object AngConfigManager {
|
||||
Utils.setClipboard(context, conf)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to share config to clipboard", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to share config to clipboard", e)
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
@@ -76,7 +92,7 @@ object AngConfigManager {
|
||||
}
|
||||
return sb.lines().count() - 1
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to share non-custom configs to clipboard", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to share non-custom configs to clipboard", e)
|
||||
return -1
|
||||
}
|
||||
}
|
||||
@@ -96,7 +112,7 @@ object AngConfigManager {
|
||||
return QRCodeDecoder.createQRCode(conf)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to share config as QR code", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to share config as QR code", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -111,14 +127,14 @@ 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 {
|
||||
return -1
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to share full content to clipboard", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to share full content to clipboard", e)
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
@@ -136,19 +152,16 @@ 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) {
|
||||
Log.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -167,7 +180,7 @@ object AngConfigManager {
|
||||
count = parseBatchConfig(server, subid, append)
|
||||
}
|
||||
if (count <= 0) {
|
||||
count = parseCustomConfigServer(server, subid)
|
||||
count = parseCustomConfigServer(server, subid, append)
|
||||
}
|
||||
|
||||
var countSub = parseBatchSubscription(server)
|
||||
@@ -203,7 +216,7 @@ object AngConfigManager {
|
||||
}
|
||||
return count
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse batch subscription", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse batch subscription", e)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -221,22 +234,8 @@ object AngConfigManager {
|
||||
if (servers == null) {
|
||||
return 0
|
||||
}
|
||||
val removedSelectedServer =
|
||||
if (!TextUtils.isEmpty(subid) && !append) {
|
||||
MmkvManager.decodeServerConfig(
|
||||
MmkvManager.getSelectServer().orEmpty()
|
||||
)?.let {
|
||||
if (it.subscriptionId == subid) {
|
||||
return@let it
|
||||
}
|
||||
return@let null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
// Find the currently selected server that belongs to the same subscription before replacement.
|
||||
val removedSelected = getRemovedSelectedProfile(subid, append)
|
||||
|
||||
val subItem = MmkvManager.decodeSubscription(subid)
|
||||
|
||||
@@ -254,23 +253,17 @@ object AngConfigManager {
|
||||
|
||||
// Batch save all parsed configs (only one serverList read/write)
|
||||
if (configs.isNotEmpty()) {
|
||||
val keys = batchSaveConfigs(configs, subid)
|
||||
|
||||
// Handle removed selected server
|
||||
removedSelectedServer?.let { removed ->
|
||||
val matchKey = keys.find { key ->
|
||||
val savedConfig = MmkvManager.decodeServerConfig(key)
|
||||
savedConfig != null &&
|
||||
savedConfig.server == removed.server &&
|
||||
savedConfig.serverPort == removed.serverPort
|
||||
}
|
||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val keyToProfile = batchSaveConfigs(configs, subid)
|
||||
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||
}
|
||||
|
||||
return configs.size
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse batch config", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse batch config", e)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -281,14 +274,13 @@ object AngConfigManager {
|
||||
*
|
||||
* @param configs The list of ProfileItem to save.
|
||||
* @param subid The subscription ID.
|
||||
* @return The list of generated keys.
|
||||
* @return Map of generated keys to their corresponding ProfileItem.
|
||||
*/
|
||||
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): List<String> {
|
||||
val keys = mutableListOf<String>()
|
||||
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): Map<String, ProfileItem> {
|
||||
val keyToProfile = mutableMapOf<String, ProfileItem>()
|
||||
|
||||
// Read serverList once
|
||||
val serverList = MmkvManager.decodeServerList(subid)
|
||||
var needSetSelected = MmkvManager.getSelectServer().isNullOrBlank()
|
||||
|
||||
configs.forEach { config ->
|
||||
val key = Utils.getUuid()
|
||||
@@ -297,17 +289,92 @@ object AngConfigManager {
|
||||
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
if (needSetSelected) {
|
||||
MmkvManager.setSelectServer(key)
|
||||
needSetSelected = false
|
||||
}
|
||||
}
|
||||
keys.add(key)
|
||||
keyToProfile[key] = config
|
||||
}
|
||||
|
||||
// Write serverList once
|
||||
MmkvManager.encodeServerList(serverList, subid)
|
||||
return keys
|
||||
return keyToProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a matched profile key from the given key-profile map using multi-level matching.
|
||||
* Matching priority (from highest to lowest):
|
||||
* 1. Exact match: server + port + password
|
||||
* 2. Match by remarks (exact match)
|
||||
* 3. Match by server + port
|
||||
* 4. Match by server only
|
||||
*
|
||||
* @param keyToProfile Map of server keys to their ProfileItem
|
||||
* @param target Target profile to match
|
||||
* @return Matched key or null
|
||||
*/
|
||||
private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? {
|
||||
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()) {
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.remarks, target.remarks)
|
||||
}?.key?.let { return it }
|
||||
}
|
||||
|
||||
// Level 2: Exact match (server + port + password)
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.server, target.server) &&
|
||||
isSameText(saved.serverPort, target.serverPort) &&
|
||||
isSameText(saved.password, target.password)
|
||||
}?.key?.let { return it }
|
||||
|
||||
// Level 3: Match by server + port
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.server, target.server) &&
|
||||
isSameText(saved.serverPort, target.serverPort)
|
||||
}?.key?.let { return it }
|
||||
|
||||
// Level 4: Match by server only
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.server, target.server)
|
||||
}?.key?.let { return it }
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive trimmed string comparison.
|
||||
*
|
||||
* @param left First string
|
||||
* @param right Second string
|
||||
* @return True if both are non-empty and equal (case-insensitive, trimmed)
|
||||
*/
|
||||
private fun isSameText(left: String?, right: String?): Boolean {
|
||||
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
|
||||
return left.trim().equals(right.trim(), ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -315,9 +382,10 @@ object AngConfigManager {
|
||||
*
|
||||
* @param server The server string.
|
||||
* @param subid The subscription ID.
|
||||
* @param append Whether to append the configurations.
|
||||
* @return The number of configurations parsed.
|
||||
*/
|
||||
private fun parseCustomConfigServer(server: String?, subid: String): Int {
|
||||
private fun parseCustomConfigServer(server: String?, subid: String, append: Boolean): Int {
|
||||
if (server == null) {
|
||||
return 0
|
||||
}
|
||||
@@ -330,19 +398,29 @@ 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) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -350,22 +428,28 @@ object AngConfigManager {
|
||||
val config = CustomFmt.parse(server) ?: return 0
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
return 1
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
|
||||
}
|
||||
return 0
|
||||
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
||||
try {
|
||||
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
|
||||
config.description = generateDescription(config)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
return 1
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
|
||||
}
|
||||
return 0
|
||||
} else {
|
||||
@@ -392,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) {
|
||||
@@ -426,7 +496,7 @@ object AngConfigManager {
|
||||
|
||||
return config
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse config", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse config", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -443,7 +513,7 @@ object AngConfigManager {
|
||||
acc + updateConfigViaSub(subscription)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
|
||||
SubscriptionUpdateResult()
|
||||
}
|
||||
}
|
||||
@@ -478,21 +548,37 @@ object AngConfigManager {
|
||||
return SubscriptionUpdateResult(failureCount = 1)
|
||||
}
|
||||
}
|
||||
Log.i(AppConfig.TAG, url)
|
||||
LogUtil.i(AppConfig.TAG, url)
|
||||
val userAgent = it.subscription.userAgent
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
|
||||
var configText = try {
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort)
|
||||
HttpUtil.getUrlContentWithUserAgent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
userAgent = userAgent,
|
||||
timeout = 15000,
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
proxyPassword = proxyPassword
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
|
||||
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) {
|
||||
Log.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
|
||||
LogUtil.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
|
||||
""
|
||||
}
|
||||
}
|
||||
@@ -504,7 +590,7 @@ object AngConfigManager {
|
||||
if (count > 0) {
|
||||
it.subscription.lastUpdated = System.currentTimeMillis()
|
||||
MmkvManager.encodeSubscription(it.guid, it.subscription)
|
||||
Log.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
|
||||
LogUtil.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
|
||||
return SubscriptionUpdateResult(
|
||||
configCount = count,
|
||||
successCount = 1
|
||||
@@ -514,7 +600,7 @@ object AngConfigManager {
|
||||
return SubscriptionUpdateResult(failureCount = 1)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to update config via subscription", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to update config via subscription", e)
|
||||
return SubscriptionUpdateResult(failureCount = 1)
|
||||
}
|
||||
}
|
||||
@@ -533,7 +619,7 @@ object AngConfigManager {
|
||||
count = parseBatchConfig(server, subid, append)
|
||||
}
|
||||
if (count <= 0) {
|
||||
count = parseCustomConfigServer(server, subid)
|
||||
count = parseCustomConfigServer(server, subid, append)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,7 +344,7 @@ object MmkvManager {
|
||||
|
||||
//region Subscriptions
|
||||
|
||||
private fun getSubscriptionId(subscriptionId: String?):String {
|
||||
private fun getSubscriptionId(subscriptionId: String?): String {
|
||||
return subscriptionId?.ifEmpty { DEFAULT_SUBSCRIPTION_ID } ?: DEFAULT_SUBSCRIPTION_ID
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,14 +9,15 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
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
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -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) {
|
||||
Log.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()
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ package com.v2ray.ang.handler
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.AssetManager
|
||||
import android.os.Build
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
@@ -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
|
||||
@@ -27,14 +27,19 @@ import com.v2ray.ang.handler.MmkvManager.decodeSubscription
|
||||
import com.v2ray.ang.handler.MmkvManager.encodeSubscription
|
||||
import com.v2ray.ang.handler.MmkvManager.removeSubscription
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.Collections
|
||||
import java.util.Locale
|
||||
import kotlin.random.Random
|
||||
|
||||
object SettingsManager {
|
||||
|
||||
@Volatile
|
||||
private var runtimeSocksPort: Int? = null
|
||||
|
||||
fun initApp(context: Context) {
|
||||
ensureDefaultSettings()
|
||||
//ensureDefaultSubscription()
|
||||
@@ -68,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()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,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
|
||||
}
|
||||
@@ -100,7 +105,7 @@ object SettingsManager {
|
||||
resetRoutingRulesetsCommon(rulesetList)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.e(ANG_PACKAGE, "Failed to reset routing rulesets", e)
|
||||
LogUtil.e(ANG_PACKAGE, "Failed to reset routing rulesets", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -186,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
|
||||
}
|
||||
@@ -219,7 +224,7 @@ object SettingsManager {
|
||||
* @param toPosition The position to swap to.
|
||||
*/
|
||||
fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
|
||||
val subsList = MmkvManager.decodeSubsList()
|
||||
val subsList = decodeSubsList()
|
||||
if (subsList.isEmpty()) return
|
||||
|
||||
Collections.swap(subsList, fromPosition, toPosition)
|
||||
@@ -241,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) {
|
||||
// Log.i(ANG_PACKAGE,"Attempted to remove the only existing default subscription, operation ignored.")
|
||||
// return
|
||||
// }
|
||||
|
||||
SubscriptionUpdater.cancelOne(subId = subid)
|
||||
// Remove the subscription
|
||||
removeSubscription(subid)
|
||||
|
||||
@@ -273,7 +287,30 @@ object SettingsManager {
|
||||
* @return The SOCKS port.
|
||||
*/
|
||||
fun getSocksPort(): Int {
|
||||
return Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
|
||||
val port =
|
||||
if (IsDynamicSocksPort()) {
|
||||
runtimeSocksPort ?: refreshRuntimeSocksPort()
|
||||
} else {
|
||||
Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
|
||||
}
|
||||
return port ?: AppConfig.PORT_SOCKS.toInt()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun refreshRuntimeSocksPort(): Int? {
|
||||
if (IsDynamicSocksPort()) {
|
||||
runtimeSocksPort = generateRandomSocksPort()
|
||||
return runtimeSocksPort
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getSocksUsername(): String? {
|
||||
return MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_USERNAME)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun getSocksPassword(): String? {
|
||||
return MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PASSWORD)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,6 +321,14 @@ object SettingsManager {
|
||||
return getSocksPort() + if (Utils.isXray()) 0 else 1
|
||||
}
|
||||
|
||||
private fun IsDynamicSocksPort(): Boolean {
|
||||
return MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
|
||||
}
|
||||
|
||||
private fun generateRandomSocksPort(): Int {
|
||||
return Random.nextInt(10000, 65535)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize assets.
|
||||
* @param context The application context.
|
||||
@@ -304,10 +349,10 @@ object SettingsManager {
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
Log.i(AppConfig.TAG, "Copied from apk assets folder to ${target.absolutePath}")
|
||||
LogUtil.i(AppConfig.TAG, "Copied from apk assets folder to ${target.absolutePath}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(ANG_PACKAGE, "asset copy failed", e)
|
||||
LogUtil.e(ANG_PACKAGE, "asset copy failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,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.
|
||||
@@ -433,15 +487,36 @@ object SettingsManager {
|
||||
return mode == null || mode == VPN
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if process routing can be used.
|
||||
*/
|
||||
fun canUseProcessRouting(): Boolean {
|
||||
// Android 10+
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must xray tun
|
||||
if (isUsingHevTun()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have route only enabled
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false) == false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure default settings are present in MMKV.
|
||||
*/
|
||||
private fun ensureDefaultSettings() {
|
||||
// Write defaults in the exact order requested by the user
|
||||
ensureDefaultValue(AppConfig.PREF_MODE, AppConfig.VPN)
|
||||
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)
|
||||
@@ -510,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,13 +1,11 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
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
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.io.IOException
|
||||
@@ -62,11 +60,11 @@ object SpeedtestManager {
|
||||
socket.close()
|
||||
return time
|
||||
} catch (e: UnknownHostException) {
|
||||
Log.e(AppConfig.TAG, "Unknown host: $url", e)
|
||||
LogUtil.e(AppConfig.TAG, "Unknown host: $url", e)
|
||||
} catch (e: IOException) {
|
||||
Log.e(AppConfig.TAG, "socketConnectTime IOException: $e")
|
||||
LogUtil.e(AppConfig.TAG, "socketConnectTime IOException: $e")
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -83,50 +81,24 @@ object SpeedtestManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the connection to a given URL and port.
|
||||
*
|
||||
* @param context The Context in which the test is running.
|
||||
* @param port The port to connect to.
|
||||
* @return A pair containing the elapsed time in milliseconds and the result message.
|
||||
*/
|
||||
fun testConnection(context: Context, port: Int): Pair<Long, String> {
|
||||
var result: String
|
||||
var elapsed = -1L
|
||||
|
||||
val conn = HttpUtil.createProxyConnection(SettingsManager.getDelayTestUrl(), port, 15000, 15000) ?: return Pair(elapsed, "")
|
||||
try {
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
val code = conn.responseCode
|
||||
elapsed = SystemClock.elapsedRealtime() - start
|
||||
|
||||
result = when (code) {
|
||||
204 -> context.getString(R.string.connection_test_available, elapsed)
|
||||
200 if conn.contentLengthLong == 0L -> context.getString(R.string.connection_test_available, elapsed)
|
||||
else -> throw IOException(
|
||||
context.getString(R.string.connection_test_error_status_code, code)
|
||||
)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(AppConfig.TAG, "Connection test IOException", e)
|
||||
result = context.getString(R.string.connection_test_error, e.message)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Connection test Exception", e)
|
||||
result = context.getString(R.string.connection_test_error, e.message)
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
|
||||
return Pair(elapsed, result)
|
||||
}
|
||||
|
||||
fun getRemoteIPInfo(): String? {
|
||||
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
|
||||
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
|
||||
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
val content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: return null
|
||||
val ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
|
||||
if (httpPort == 0) 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 android.util.Log
|
||||
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 {
|
||||
Log.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())
|
||||
Log.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 android.util.Log
|
||||
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) {
|
||||
@@ -23,26 +21,42 @@ object UpdateCheckerManager {
|
||||
AppConfig.APP_API_URL.concatUrl("latest")
|
||||
}
|
||||
|
||||
var response = HttpUtil.getUrlContent(url, 5000)
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
|
||||
var response = HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000
|
||||
)
|
||||
)
|
||||
if (response.isNullOrEmpty()) {
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
response = HttpUtil.getUrlContent(url, 5000, httpPort)
|
||||
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)
|
||||
}
|
||||
|
||||
val latestVersion = latestRelease.tagName.removePrefix("v")
|
||||
Log.i(
|
||||
LogUtil.i(
|
||||
AppConfig.TAG,
|
||||
"Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})"
|
||||
)
|
||||
@@ -61,39 +75,6 @@ object UpdateCheckerManager {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun downloadApk(context: Context, downloadUrl: String): File? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
val connection = HttpUtil.createProxyConnection(downloadUrl, httpPort, 10000, 10000, true)
|
||||
?: throw IllegalStateException("Failed to create connection")
|
||||
|
||||
try {
|
||||
val apkFile = File(context.cacheDir, "update.apk")
|
||||
Log.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath}")
|
||||
|
||||
FileOutputStream(apkFile).use { outputStream ->
|
||||
connection.inputStream.use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
Log.i(AppConfig.TAG, "APK download completed")
|
||||
return@withContext apkFile
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to download APK: ${e.message}")
|
||||
return@withContext null
|
||||
} finally {
|
||||
try {
|
||||
connection.disconnect()
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Error closing connection: ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to initiate download: ${e.message}")
|
||||
return@withContext null
|
||||
}
|
||||
}
|
||||
|
||||
private fun compareVersions(version1: String, version2: String): Int {
|
||||
val v1 = version1.split(".")
|
||||
val v2 = version2.split(".")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.util.Log
|
||||
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
|
||||
import okhttp3.Credentials
|
||||
@@ -67,14 +67,14 @@ object WebDavManager {
|
||||
cl.newCall(req).execute().use { resp ->
|
||||
val success = resp.isSuccessful
|
||||
if (success) {
|
||||
Log.i(AppConfig.TAG, "WebDAV upload success: $remote")
|
||||
LogUtil.i(AppConfig.TAG, "WebDAV upload success: $remote")
|
||||
} else {
|
||||
Log.e(AppConfig.TAG, "WebDAV upload failed: $remote (HTTP ${resp.code})")
|
||||
LogUtil.e(AppConfig.TAG, "WebDAV upload failed: $remote (HTTP ${resp.code})")
|
||||
}
|
||||
return@withContext success
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
|
||||
LogUtil.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ object WebDavManager {
|
||||
val req = applyAuth(Request.Builder().url(remote).get()).build()
|
||||
cl.newCall(req).execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
Log.e(AppConfig.TAG, "WebDAV download failed: $remote (HTTP ${resp.code})")
|
||||
LogUtil.e(AppConfig.TAG, "WebDAV download failed: $remote (HTTP ${resp.code})")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
@@ -104,11 +104,11 @@ object WebDavManager {
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(AppConfig.TAG, "WebDAV download success: $remote")
|
||||
LogUtil.i(AppConfig.TAG, "WebDAV download success: $remote")
|
||||
return@withContext true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
|
||||
LogUtil.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,7 @@ object WebDavManager {
|
||||
cl.newCall(req).execute().use { resp ->
|
||||
// 201 Created or 405 Method Not Allowed (already exists) are acceptable
|
||||
if (resp.code != 201 && resp.code != 405 && resp.code != 409) {
|
||||
Log.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}")
|
||||
LogUtil.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}")
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
@@ -177,7 +177,7 @@ object WebDavManager {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "WebDAV ensureRemoteDirs error", e)
|
||||
LogUtil.e(AppConfig.TAG, "WebDAV ensureRemoteDirs error", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,13 @@ package com.v2ray.ang.helper
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
/**
|
||||
* Helper for choosing and creating files using Android Storage Access Framework.
|
||||
@@ -58,7 +58,7 @@ class FileChooserHelper(private val activity: AppCompatActivity) {
|
||||
Intent.createChooser(intent, activity.getString(R.string.title_file_chooser))
|
||||
)
|
||||
} catch (ex: ActivityNotFoundException) {
|
||||
Log.e(AppConfig.TAG, "File chooser activity not found", ex)
|
||||
LogUtil.e(AppConfig.TAG, "File chooser activity not found", ex)
|
||||
activity.toast(R.string.toast_require_file_manager)
|
||||
fileChooserCallback?.invoke(null)
|
||||
fileChooserCallback = null
|
||||
@@ -79,7 +79,7 @@ class FileChooserHelper(private val activity: AppCompatActivity) {
|
||||
try {
|
||||
documentCreateLauncher.launch(fileName)
|
||||
} catch (ex: ActivityNotFoundException) {
|
||||
Log.e(AppConfig.TAG, "Document creator activity not found", ex)
|
||||
LogUtil.e(AppConfig.TAG, "Document creator activity not found", ex)
|
||||
activity.toast(R.string.toast_require_file_manager)
|
||||
documentCreateCallback?.invoke(null)
|
||||
documentCreateCallback = null
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
/**
|
||||
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
|
||||
@@ -73,6 +74,10 @@ class MmkvPreferenceDataStore : PreferenceDataStore() {
|
||||
|
||||
// Internal helper: notify other modules about setting changes
|
||||
private fun notifySettingChanged(key: String) {
|
||||
if (key == AppConfig.PREF_LOGLEVEL) {
|
||||
LogUtil.refreshLogLevel()
|
||||
}
|
||||
|
||||
// Call SettingsManager.setNightMode if UI mode changed
|
||||
if (key == AppConfig.PREF_UI_MODE_NIGHT) {
|
||||
SettingsManager.setNightMode()
|
||||
|
||||
@@ -3,10 +3,11 @@ package com.v2ray.ang.receiver
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
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() {
|
||||
/**
|
||||
@@ -18,24 +19,25 @@ class BootReceiver : BroadcastReceiver() {
|
||||
* @param intent The Intent being received.
|
||||
*/
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
Log.i(AppConfig.TAG, "BootReceiver received: ${intent?.action}")
|
||||
LogUtil.i(AppConfig.TAG, "BootReceiver received: ${intent?.action}")
|
||||
|
||||
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) {
|
||||
Log.w(AppConfig.TAG, "BootReceiver: Invalid context or action")
|
||||
LogUtil.w(AppConfig.TAG, "BootReceiver: Invalid context or action")
|
||||
return
|
||||
}
|
||||
|
||||
if (!MmkvManager.decodeStartOnBoot()) {
|
||||
Log.i(AppConfig.TAG, "BootReceiver: Auto-start on boot is disabled")
|
||||
LogUtil.i(AppConfig.TAG, "BootReceiver: Auto-start on boot is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
|
||||
Log.w(AppConfig.TAG, "BootReceiver: No server selected")
|
||||
LogUtil.w(AppConfig.TAG, "BootReceiver: No server selected")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
|
||||
V2RayServiceManager.startVService(context)
|
||||
LogUtil.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
|
||||
CoreServiceManager.startVService(context)
|
||||
SubscriptionUpdater.sync(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ 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() {
|
||||
|
||||
@@ -27,15 +28,15 @@ 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) {
|
||||
android.util.Log.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
|
||||
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 ->
|
||||
|
||||
+8
-8
@@ -4,22 +4,22 @@ import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
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()
|
||||
Log.i(AppConfig.TAG, "StartCore-Proxy: Service created")
|
||||
V2RayServiceManager.serviceControl = SoftReference(this)
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service created")
|
||||
CoreServiceManager.serviceControl = SoftReference(this)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,8 +30,8 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
|
||||
* @return The start mode.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
|
||||
V2RayServiceManager.startCoreLoop(null)
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
|
||||
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()
|
||||
}
|
||||
}
|
||||
+48
-22
@@ -14,23 +14,23 @@ import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.StrictMode
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.v2ray.ang.AppConfig
|
||||
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
|
||||
@@ -74,14 +74,14 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(AppConfig.TAG, "StartCore-VPN: Service created")
|
||||
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() {
|
||||
Log.w(AppConfig.TAG, "StartCore-VPN: Permission revoked")
|
||||
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Permission revoked")
|
||||
stopAllService()
|
||||
}
|
||||
|
||||
@@ -92,12 +92,28 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Log.i(AppConfig.TAG, "StartCore-VPN: Service destroyed")
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service destroyed")
|
||||
|
||||
// Ensure VPN interface is properly closed when the service is destroyed without
|
||||
// going through stopAllService() (e.g. when killed unexpectedly). isRunning is
|
||||
// set to false at the start of stopAllService(), so this guard prevents a double-close.
|
||||
if (isRunning) {
|
||||
try {
|
||||
if (::mInterface.isInitialized) {
|
||||
mInterface.close()
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-VPN: VPN interface closed in onDestroy")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface in onDestroy", e)
|
||||
}
|
||||
}
|
||||
|
||||
NotificationManager.cancelNotification()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.i(AppConfig.TAG, "StartCore-VPN: Service command received")
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service command received")
|
||||
NotificationManager.showNotification(null)
|
||||
setupVpnService()
|
||||
startService()
|
||||
return START_STICKY
|
||||
@@ -110,11 +126,11 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
|
||||
override fun startService() {
|
||||
if (!::mInterface.isInitialized) {
|
||||
Log.e(AppConfig.TAG, "StartCore-VPN: Interface not initialized")
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Interface not initialized")
|
||||
return
|
||||
}
|
||||
if (!V2RayServiceManager.startCoreLoop(mInterface)) {
|
||||
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to start core loop")
|
||||
if (!CoreServiceManager.startCoreLoop(mInterface)) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to start core loop")
|
||||
stopAllService()
|
||||
return
|
||||
}
|
||||
@@ -142,13 +158,13 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
private fun setupVpnService() {
|
||||
val prepare = prepare(this)
|
||||
if (prepare != null) {
|
||||
Log.e(AppConfig.TAG, "StartCore-VPN: Permission not granted")
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Permission not granted")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
if (configureVpnService() != true) {
|
||||
Log.e(AppConfig.TAG, "StartCore-VPN: Configuration failed")
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Configuration failed")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
@@ -175,7 +191,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
mInterface.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(AppConfig.TAG, "Failed to close old interface", e)
|
||||
LogUtil.w(AppConfig.TAG, "Failed to close old interface", e)
|
||||
}
|
||||
|
||||
// Configure platform-specific features
|
||||
@@ -187,7 +203,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
isRunning = true
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to establish VPN interface", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to establish VPN interface", e)
|
||||
stopAllService()
|
||||
}
|
||||
return false
|
||||
@@ -218,7 +234,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
}
|
||||
|
||||
// Configure IPv6 if enabled
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
|
||||
builder.addAddress(vpnConfig.ipv6Client, 126)
|
||||
if (bypassLan) {
|
||||
builder.addRoute("2000::", 3) // Currently only 1/8 of total IPv6 is in use
|
||||
@@ -238,7 +254,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
}
|
||||
}
|
||||
|
||||
builder.setSession(V2RayServiceManager.getRunningServerName())
|
||||
//builder.setSession(V2RayServiceManager.getRunningServerName())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,7 +268,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
try {
|
||||
connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to request network", e)
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to request network", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +321,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
builder.addAllowedApplication(it)
|
||||
}
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to configure app", e)
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to configure app", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,14 +355,14 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
try {
|
||||
connectivity.unregisterNetworkCallback(defaultNetworkCallback)
|
||||
} catch (e: Exception) {
|
||||
Log.w(AppConfig.TAG, "StartCore-VPN: Failed to unregister callback", e)
|
||||
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Failed to unregister callback", e)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -356,12 +372,22 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
//which means the first v2ray core somehow failed to stop and release the port.
|
||||
stopSelf()
|
||||
|
||||
// Add a small delay to allow the async core stop operation to complete
|
||||
// before closing the VPN interface, preventing a race condition that can
|
||||
// leave the VPN icon in the status bar after stopping the service.
|
||||
try {
|
||||
Thread.sleep(100)
|
||||
} catch (e: InterruptedException) {
|
||||
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Sleep interrupted", e)
|
||||
}
|
||||
|
||||
try {
|
||||
if (::mInterface.isInitialized) {
|
||||
mInterface.close()
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-VPN: VPN interface closed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface", e)
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -16,7 +16,7 @@ class ProcessService {
|
||||
* @param cmd The command to run.
|
||||
*/
|
||||
fun runProcess(context: Context, cmd: MutableList<String>) {
|
||||
Log.i(AppConfig.TAG, cmd.toString())
|
||||
LogUtil.i(AppConfig.TAG, cmd.toString())
|
||||
|
||||
try {
|
||||
val proBuilder = ProcessBuilder(cmd)
|
||||
@@ -27,14 +27,14 @@ class ProcessService {
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
Thread.sleep(50L)
|
||||
Log.i(AppConfig.TAG, "runProcess check")
|
||||
LogUtil.i(AppConfig.TAG, "runProcess check")
|
||||
process?.waitFor()
|
||||
Log.i(AppConfig.TAG, "runProcess exited")
|
||||
LogUtil.i(AppConfig.TAG, "runProcess exited")
|
||||
}
|
||||
Log.i(AppConfig.TAG, process.toString())
|
||||
LogUtil.i(AppConfig.TAG, process.toString())
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, e.toString(), e)
|
||||
LogUtil.e(AppConfig.TAG, e.toString(), e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,10 @@ class ProcessService {
|
||||
*/
|
||||
fun stopProcess() {
|
||||
try {
|
||||
Log.i(AppConfig.TAG, "runProcess destroy")
|
||||
LogUtil.i(AppConfig.TAG, "runProcess destroy")
|
||||
process?.destroy()
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to destroy process", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to destroy process", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ import android.content.IntentFilter
|
||||
import android.graphics.drawable.Icon
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import android.util.Log
|
||||
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
|
||||
import java.lang.ref.SoftReference
|
||||
@@ -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)
|
||||
@@ -63,7 +63,7 @@ class QSTileService : TileService() {
|
||||
applicationContext.unregisterReceiver(mMsgReceive)
|
||||
mMsgReceive = null
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to unregister receiver", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to unregister receiver", e)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ package com.v2ray.ang.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.contracts.Tun2SocksControl
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -22,9 +22,11 @@ class TProxyService(
|
||||
@JvmStatic
|
||||
@Suppress("FunctionName")
|
||||
private external fun TProxyStartService(configPath: String, fd: Int)
|
||||
|
||||
@JvmStatic
|
||||
@Suppress("FunctionName")
|
||||
private external fun TProxyStopService()
|
||||
|
||||
@JvmStatic
|
||||
@Suppress("FunctionName")
|
||||
private external fun TProxyGetStats(): LongArray?
|
||||
@@ -38,32 +40,36 @@ class TProxyService(
|
||||
* Starts the tun2socks process with the appropriate parameters.
|
||||
*/
|
||||
override fun startTun2Socks() {
|
||||
// Log.i(AppConfig.TAG, "Starting HevSocks5Tunnel via JNI")
|
||||
// LogUtil.i(AppConfig.TAG, "Starting HevSocks5Tunnel via JNI")
|
||||
|
||||
val configContent = buildConfig()
|
||||
val configFile = File(context.filesDir, "hev-socks5-tunnel.yaml").apply {
|
||||
writeText(configContent)
|
||||
}
|
||||
// Log.i(AppConfig.TAG, "Config file created: ${configFile.absolutePath}")
|
||||
Log.d(AppConfig.TAG, "HevSocks5Tunnel Config content:\n$configContent")
|
||||
// LogUtil.i(AppConfig.TAG, "Config file created: ${configFile.absolutePath}")
|
||||
LogUtil.d(AppConfig.TAG, "HevSocks5Tunnel Config content:\n$configContent")
|
||||
|
||||
try {
|
||||
// Log.i(AppConfig.TAG, "TProxyStartService...")
|
||||
// LogUtil.i(AppConfig.TAG, "TProxyStartService...")
|
||||
TProxyStartService(configFile.absolutePath, vpnInterface.fd)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "HevSocks5Tunnel exception: ${e.message}")
|
||||
LogUtil.e(AppConfig.TAG, "HevSocks5Tunnel exception: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildConfig(): String {
|
||||
val socksPort = SettingsManager.getSocksPort()
|
||||
val socksUsername = SettingsManager.getSocksUsername()
|
||||
val socksPassword = SettingsManager.getSocksPassword()
|
||||
val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
|
||||
val escapedSocksUsername = socksUsername?.replace("'", "''")
|
||||
val escapedSocksPassword = socksPassword?.replace("'", "''")
|
||||
return buildString {
|
||||
appendLine("tunnel:")
|
||||
appendLine(" mtu: ${SettingsManager.getVpnMtu()}")
|
||||
appendLine(" ipv4: ${vpnConfig.ipv4Client}")
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6)) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED)) {
|
||||
appendLine(" ipv6: '${vpnConfig.ipv6Client}'")
|
||||
}
|
||||
|
||||
@@ -71,6 +77,10 @@ class TProxyService(
|
||||
appendLine(" port: ${socksPort}")
|
||||
appendLine(" address: ${AppConfig.LOOPBACK}")
|
||||
appendLine(" udp: 'udp'")
|
||||
if (escapedSocksUsername != null && escapedSocksPassword != null) {
|
||||
appendLine(" username: '${escapedSocksUsername}'")
|
||||
appendLine(" password: '${escapedSocksPassword}'")
|
||||
}
|
||||
|
||||
// Read-write timeout settings
|
||||
val timeoutSetting = MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) ?: AppConfig.HEVTUN_RW_TIMEOUT
|
||||
@@ -92,10 +102,10 @@ class TProxyService(
|
||||
*/
|
||||
override fun stopTun2Socks() {
|
||||
try {
|
||||
Log.i(AppConfig.TAG, "TProxyStopService...")
|
||||
LogUtil.i(AppConfig.TAG, "TProxyStopService...")
|
||||
TProxyStopService()
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to stop hev-socks5-tunnel", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to stop hev-socks5-tunnel", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.Collator
|
||||
|
||||
class AppPickerActivity : BaseActivity() {
|
||||
companion object {
|
||||
private const val EXTRA_SELECTED_PACKAGES = "selected_packages"
|
||||
private const val EXTRA_PICKER_TITLE = "picker_title"
|
||||
|
||||
fun createIntent(
|
||||
context: Context,
|
||||
selectedPackages: Collection<String> = emptyList(),
|
||||
title: String? = null
|
||||
): Intent = Intent(context, AppPickerActivity::class.java).apply {
|
||||
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, ArrayList(selectedPackages))
|
||||
title?.let { putExtra(EXTRA_PICKER_TITLE, it) }
|
||||
}
|
||||
|
||||
fun getSelectedPackages(intent: Intent?): List<String> {
|
||||
return intent?.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private val binding by lazy { ActivityAppPickerBinding.inflate(layoutInflater) }
|
||||
private val initialSelectedPackages by lazy {
|
||||
intent.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
|
||||
}
|
||||
private val selectedPackages = LinkedHashSet<String>()
|
||||
private var appsAll: List<AppInfo> = emptyList()
|
||||
private val adapter = AppSelectorAdapter(selectedPackages)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = resolveScreenTitle())
|
||||
|
||||
selectedPackages.addAll(initialSelectedPackages)
|
||||
setupRecyclerView()
|
||||
loadApps()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_app_picker, menu)
|
||||
|
||||
val searchItem = menu.findItem(R.id.search_view)
|
||||
if (searchItem != null) {
|
||||
val searchView = searchItem.actionView as SearchView
|
||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean = false
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
filterApps(newText.orEmpty())
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.select_all -> {
|
||||
selectAllVisible()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.invert_selection -> {
|
||||
invertVisibleSelection()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
setResult(
|
||||
RESULT_OK,
|
||||
Intent().apply {
|
||||
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, getSelectedPackages())
|
||||
}
|
||||
)
|
||||
super.finish()
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
binding.recyclerView.adapter = adapter
|
||||
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
||||
}
|
||||
|
||||
@SuppressLint("UseCompatLoadingForDrawables")
|
||||
private fun createSpecialItemUnidentified(): AppInfo {
|
||||
val icon = requireNotNull(
|
||||
getDrawable(android.R.drawable.ic_menu_help)
|
||||
?: getDrawable(android.R.drawable.sym_def_app_icon)
|
||||
) { "No fallback drawable available" }
|
||||
return AppInfo(
|
||||
appName = getString(R.string.app_picker_unknown_app),
|
||||
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
|
||||
appIcon = icon,
|
||||
isSystemApp = false,
|
||||
isSelected = 0
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadApps() {
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val apps = withContext(Dispatchers.IO) {
|
||||
val appsList = AppManagerUtil.loadNetworkAppList(this@AppPickerActivity)
|
||||
val sortedApps = sortApps(appsList)
|
||||
listOf(createSpecialItemUnidentified()) + sortedApps
|
||||
}
|
||||
|
||||
appsAll = apps
|
||||
updateDisplayedApps(apps)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e("AppPickerActivity", "Failed to load app list", e)
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterApps(content: String) {
|
||||
val key = content.uppercase()
|
||||
val filteredApps = appsAll.filter { app ->
|
||||
key.isEmpty() || matchesSearch(app, key)
|
||||
}
|
||||
updateDisplayedApps(filteredApps)
|
||||
}
|
||||
|
||||
private fun sortApps(apps: List<AppInfo>): List<AppInfo> {
|
||||
val collator = Collator.getInstance()
|
||||
return apps.sortedWith { p1, p2 ->
|
||||
val p1Selected = selectedPackages.contains(p1.packageName)
|
||||
val p2Selected = selectedPackages.contains(p2.packageName)
|
||||
when {
|
||||
p1Selected && !p2Selected -> -1
|
||||
!p1Selected && p2Selected -> 1
|
||||
p1.isSystemApp && !p2.isSystemApp -> 1
|
||||
!p1.isSystemApp && p2.isSystemApp -> -1
|
||||
else -> collator.compare(p1.appName, p2.appName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesSearch(app: AppInfo, keyword: String): Boolean {
|
||||
return app.appName.uppercase().contains(keyword) || app.packageName.uppercase().contains(keyword)
|
||||
}
|
||||
|
||||
private fun updateDisplayedApps(apps: List<AppInfo>) {
|
||||
adapter.submitList(apps)
|
||||
}
|
||||
|
||||
private fun selectAllVisible() {
|
||||
adapter.apps.forEach { app -> selectedPackages.add(app.packageName) }
|
||||
adapter.refreshSelection()
|
||||
}
|
||||
|
||||
private fun invertVisibleSelection() {
|
||||
adapter.apps.forEach { app ->
|
||||
if (selectedPackages.contains(app.packageName)) {
|
||||
selectedPackages.remove(app.packageName)
|
||||
} else {
|
||||
selectedPackages.add(app.packageName)
|
||||
}
|
||||
}
|
||||
adapter.refreshSelection()
|
||||
}
|
||||
|
||||
private fun getSelectedPackages(): ArrayList<String> {
|
||||
return ArrayList(selectedPackages.sorted())
|
||||
}
|
||||
|
||||
private fun resolveScreenTitle(): String {
|
||||
return intent.getStringExtra(EXTRA_PICKER_TITLE) ?: getString(R.string.per_app_proxy_settings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
|
||||
class AppSelectorAdapter(
|
||||
private val selectedPackages: MutableSet<String>
|
||||
) : RecyclerView.Adapter<AppSelectorAdapter.AppViewHolder>() {
|
||||
|
||||
var apps: List<AppInfo> = emptyList()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder {
|
||||
val binding = ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return AppViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = apps.size
|
||||
|
||||
override fun onBindViewHolder(holder: AppViewHolder, position: Int) {
|
||||
holder.bind(apps[position])
|
||||
}
|
||||
|
||||
fun submitList(newApps: List<AppInfo>) {
|
||||
apps = newApps
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun refreshSelection() {
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
inner class AppViewHolder(private val binding: ItemRecyclerBypassListBinding) : RecyclerView.ViewHolder(binding.root),
|
||||
View.OnClickListener {
|
||||
private lateinit var appInfo: AppInfo
|
||||
|
||||
fun bind(item: AppInfo) {
|
||||
appInfo = item
|
||||
binding.icon.setImageDrawable(item.appIcon)
|
||||
binding.name.text = if (item.isSystemApp) {
|
||||
String.format("** %s", item.appName)
|
||||
} else {
|
||||
item.appName
|
||||
}
|
||||
binding.packageName.text = item.packageName
|
||||
binding.checkBox.isChecked = selectedPackages.contains(item.packageName)
|
||||
itemView.setOnClickListener(this)
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
val packageName = appInfo.packageName
|
||||
if (selectedPackages.contains(packageName)) {
|
||||
selectedPackages.remove(packageName)
|
||||
} else {
|
||||
selectedPackages.add(packageName)
|
||||
}
|
||||
binding.checkBox.isChecked = selectedPackages.contains(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.tencent.mmkv.MMKV
|
||||
@@ -13,13 +12,14 @@ 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
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.WebDavManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.ZipUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -38,7 +38,7 @@ class BackupActivity : HelperBaseActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_configuration_backup_restore))
|
||||
|
||||
binding.layoutBackup.setOnClickListener {
|
||||
@@ -149,7 +149,7 @@ class BackupActivity : HelperBaseActivity() {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Error during file restore", e)
|
||||
LogUtil.e(AppConfig.TAG, "Error during file restore", e)
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,7 @@ class BackupActivity : HelperBaseActivity() {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to backup configuration", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to backup configuration", e)
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ class BackupActivity : HelperBaseActivity() {
|
||||
val ok = try {
|
||||
WebDavManager.uploadFile(tempFile, WEBDAV_BACKUP_FILE_NAME)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "WebDAV upload error", e)
|
||||
LogUtil.e(AppConfig.TAG, "WebDAV upload error", e)
|
||||
false
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ class BackupActivity : HelperBaseActivity() {
|
||||
if (ok) toastSuccess(R.string.toast_success) else toastError(R.string.toast_failure)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "WebDAV backup error", e)
|
||||
LogUtil.e(AppConfig.TAG, "WebDAV backup error", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
@@ -272,7 +272,7 @@ class BackupActivity : HelperBaseActivity() {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "WebDAV download error", e)
|
||||
LogUtil.e(AppConfig.TAG, "WebDAV download error", e)
|
||||
withContext(Dispatchers.Main) { toastError(R.string.toast_failure) }
|
||||
} finally {
|
||||
try {
|
||||
|
||||
@@ -39,7 +39,7 @@ abstract class BaseFragment<VB : ViewBinding> : Fragment() {
|
||||
* @param drawableResId The resource ID of the drawable to be used as the divider.
|
||||
* @param orientation The orientation of the divider (DividerItemDecoration.VERTICAL or DividerItemDecoration.HORIZONTAL).
|
||||
*/
|
||||
fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
|
||||
fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
|
||||
// Get the drawable from resources
|
||||
val drawable = ContextCompat.getDrawable(requireContext(), drawableResId)
|
||||
requireNotNull(drawable) { "Drawable resource not found" }
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
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
|
||||
@@ -14,7 +14,7 @@ import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.UpdateCheckerManager
|
||||
import com.v2ray.ang.handler.V2RayNativeManager
|
||||
import com.v2ray.ang.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
|
||||
}
|
||||
|
||||
@@ -56,10 +56,9 @@ class CheckUpdateActivity : BaseActivity() {
|
||||
toastSuccess(R.string.update_already_latest_version)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to check for updates: ${e.message}")
|
||||
LogUtil.e(AppConfig.TAG, "Failed to check for updates: ${e.message}")
|
||||
toastError(e.message ?: getString(R.string.toast_failure))
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,33 +2,38 @@ package com.v2ray.ang.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
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
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.v2ray.ang.AppConfig
|
||||
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
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
||||
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
|
||||
SwipeRefreshLayout.OnRefreshListener {
|
||||
private val ownerActivity: MainActivity
|
||||
get() = requireActivity() as MainActivity
|
||||
private val mainViewModel: MainViewModel by activityViewModels()
|
||||
@@ -42,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"
|
||||
@@ -68,15 +78,20 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
||||
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
|
||||
itemTouchHelper?.attachToRecyclerView(binding.recyclerView)
|
||||
|
||||
binding.refreshLayout.isEnabled = false
|
||||
// binding.refreshLayout.setOnRefreshListener(this)
|
||||
// // Set the distance to trigger sync to 160dp
|
||||
// binding.refreshLayout.setDistanceToTriggerSync((160 * resources.displayMetrics.density).toInt())
|
||||
|
||||
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
|
||||
if (mainViewModel.subscriptionId != subId) {
|
||||
return@observe
|
||||
}
|
||||
// Log.d(TAG, "GroupServerFragment updateListAction subId=$subId")
|
||||
// LogUtil.d(TAG, "GroupServerFragment updateListAction subId=$subId")
|
||||
adapter.setData(mainViewModel.serversCache, index)
|
||||
}
|
||||
|
||||
// Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
|
||||
// LogUtil.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -105,7 +120,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
||||
else -> ownerActivity.toast("else")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Error when sharing server", e)
|
||||
LogUtil.e(AppConfig.TAG, "Error when sharing server", e)
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
@@ -142,7 +157,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
||||
* @param guid The server unique identifier
|
||||
*/
|
||||
private fun shareFullContent(guid: String) {
|
||||
ownerActivity.lifecycleScope.launch(Dispatchers.IO) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = AngConfigManager.shareFullContent2Clipboard(ownerActivity, guid)
|
||||
launch(Dispatchers.Main) {
|
||||
if (result == 0) {
|
||||
@@ -161,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,7 +224,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
||||
* @param position The position in the list
|
||||
*/
|
||||
private fun removeServerSub(guid: String, position: Int) {
|
||||
ownerActivity.mainViewModel.removeServer(guid)
|
||||
mainViewModel.removeServer(guid)
|
||||
adapter.removeServerSub(guid, position)
|
||||
}
|
||||
|
||||
@@ -258,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()
|
||||
@@ -271,4 +283,43 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
||||
shareServer(guid, profile, position, shareOptions, skip)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
ownerActivity.importConfigViaSub()
|
||||
//binding.refreshLayout.isRefreshing = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to the currently selected server in the RecyclerView
|
||||
*/
|
||||
fun scrollToSelectedServer() {
|
||||
val selectedGuid = MmkvManager.getSelectServer()
|
||||
if (selectedGuid.isNullOrEmpty()) {
|
||||
ownerActivity.toast(R.string.title_file_chooser)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the position of the selected server
|
||||
val serversCache = mainViewModel.serversCache
|
||||
val position = serversCache.indexOfFirst { it.guid == selectedGuid }
|
||||
val recyclerView = binding.recyclerView
|
||||
|
||||
if (position >= 0) {
|
||||
// Get the layout manager
|
||||
val layoutManager = recyclerView.layoutManager as? GridLayoutManager
|
||||
|
||||
if (layoutManager != null) {
|
||||
// Scroll to position with offset to center it on screen
|
||||
// First scroll to position, then adjust to center
|
||||
recyclerView.post {
|
||||
layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 3)
|
||||
}
|
||||
} else {
|
||||
// Fallback to smooth scroll if layout manager is not GridLayoutManager
|
||||
recyclerView.smoothScrollToPosition(position)
|
||||
}
|
||||
} else {
|
||||
ownerActivity.toast(R.string.toast_server_not_found_in_group)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,9 @@ import com.v2ray.ang.helper.QRCodeScannerHelper
|
||||
* - Provide QR code scanning helpers with camera permission handling.
|
||||
*/
|
||||
abstract class HelperBaseActivity : BaseActivity() {
|
||||
private lateinit var fileChooser : FileChooserHelper
|
||||
private lateinit var permissionRequester : PermissionHelper
|
||||
private lateinit var qrCodeScanner : QRCodeScannerHelper
|
||||
private lateinit var fileChooser: FileChooserHelper
|
||||
private lateinit var permissionRequester: PermissionHelper
|
||||
private lateinit var qrCodeScanner: QRCodeScannerHelper
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -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()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.viewmodel.LogcatViewModel
|
||||
|
||||
class LogcatRecyclerAdapter(
|
||||
@@ -33,7 +33,7 @@ class LogcatRecyclerAdapter(
|
||||
onLongClick?.invoke(log) ?: false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Error binding log view data", e)
|
||||
LogUtil.e(AppConfig.TAG, "Error binding log view data", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.res.ColorStateList
|
||||
import android.net.Uri
|
||||
import android.net.VpnService
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
@@ -23,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
|
||||
@@ -32,7 +32,8 @@ 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
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -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)
|
||||
@@ -179,7 +181,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
binding.tvTestState.text = content
|
||||
}
|
||||
|
||||
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
|
||||
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
|
||||
if (isLoading) {
|
||||
binding.fab.setImageResource(R.drawable.ic_fab_check)
|
||||
return
|
||||
@@ -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
|
||||
@@ -339,6 +346,10 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
true
|
||||
}
|
||||
|
||||
R.id.locate_selected_config -> {
|
||||
locateSelectedServer()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
@@ -350,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()
|
||||
@@ -381,7 +398,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
val clipboard = Utils.getClipboard(this)
|
||||
importBatchConfig(clipboard)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to import config from clipboard", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to import config from clipboard", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -411,7 +428,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
toastError(R.string.toast_failure)
|
||||
hideLoading()
|
||||
}
|
||||
Log.e(AppConfig.TAG, "Failed to import batch config", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to import batch config", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,7 +440,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
try {
|
||||
showFileChooser()
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to import config from local file", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to import config from local file", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -433,7 +450,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
/**
|
||||
* import config from sub
|
||||
*/
|
||||
private fun importConfigViaSub(): Boolean {
|
||||
fun importConfigViaSub(): Boolean {
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
@@ -565,7 +582,48 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
importBatchConfig(input?.bufferedReader()?.readText())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to read content from URI", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to read content from URI", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates and scrolls to the currently selected server.
|
||||
* If the selected server is in a different group, automatically switches to that group first.
|
||||
*/
|
||||
private fun locateSelectedServer() {
|
||||
val targetSubscriptionId = mainViewModel.findSubscriptionIdBySelect()
|
||||
if (targetSubscriptionId.isNullOrEmpty()) {
|
||||
toast(R.string.title_file_chooser)
|
||||
return
|
||||
}
|
||||
|
||||
val targetGroupIndex = groupPagerAdapter.groups.indexOfFirst { it.id == targetSubscriptionId }
|
||||
if (targetGroupIndex < 0) {
|
||||
toast(R.string.toast_server_not_found_in_group)
|
||||
return
|
||||
}
|
||||
|
||||
// Switch to target group if needed, then scroll to the server
|
||||
if (binding.viewPager.currentItem != targetGroupIndex) {
|
||||
binding.viewPager.setCurrentItem(targetGroupIndex, true)
|
||||
binding.viewPager.postDelayed({ scrollToSelectedServer(targetGroupIndex) }, 1000)
|
||||
} else {
|
||||
scrollToSelectedServer(targetGroupIndex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to the selected server in the specified fragment.
|
||||
* @param groupIndex The index of the group/fragment to scroll in
|
||||
*/
|
||||
private fun scrollToSelectedServer(groupIndex: Int) {
|
||||
val itemId = groupPagerAdapter.getItemId(groupIndex)
|
||||
val fragment = supportFragmentManager.findFragmentByTag("f$itemId") as? GroupServerFragment
|
||||
|
||||
if (fragment?.isAdded == true && fragment.view != null) {
|
||||
fragment.scrollToSelectedServer()
|
||||
} else {
|
||||
toast(R.string.toast_fragment_not_available)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -113,7 +115,7 @@ class MainRecyclerAdapter(
|
||||
adapterListener?.onSelectServer(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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.Toast
|
||||
@@ -15,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
|
||||
@@ -23,6 +23,7 @@ import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.AppManagerUtil
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
|
||||
import es.dmoral.toasty.Toasty
|
||||
@@ -99,7 +100,7 @@ class PerAppProxyActivity : BaseActivity() {
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(ANG_PACKAGE, "Error loading apps", e)
|
||||
LogUtil.e(ANG_PACKAGE, "Error loading apps", e)
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
@@ -133,6 +134,7 @@ class PerAppProxyActivity : BaseActivity() {
|
||||
allowPerAppProxy()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.invert_selection -> {
|
||||
invertSelection()
|
||||
allowPerAppProxy()
|
||||
@@ -188,13 +190,28 @@ 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) ?: ""
|
||||
content = HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000,
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
proxyPassword = proxyPassword
|
||||
)
|
||||
) ?: ""
|
||||
}
|
||||
launch(Dispatchers.Main) {
|
||||
//Log.i(AppConfig.TAG, content)
|
||||
//LogUtil.i(AppConfig.TAG, content)
|
||||
selectProxyApp(content, true)
|
||||
toastSuccess(R.string.toast_success)
|
||||
hideLoading()
|
||||
@@ -258,7 +275,7 @@ class PerAppProxyActivity : BaseActivity() {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Error selecting proxy app", e)
|
||||
LogUtil.e(AppConfig.TAG, "Error selecting proxy app", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -11,7 +11,7 @@ import com.v2ray.ang.viewmodel.PerAppProxyViewModel
|
||||
class PerAppProxyAdapter(
|
||||
val apps: List<AppInfo>,
|
||||
val viewModel: PerAppProxyViewModel
|
||||
) :RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
|
||||
) : RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
|
||||
|
||||
companion object {
|
||||
private const val VIEW_TYPE_HEADER = 0
|
||||
|
||||
@@ -3,11 +3,15 @@ package com.v2ray.ang.ui
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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
|
||||
@@ -19,22 +23,77 @@ import kotlinx.coroutines.launch
|
||||
class RoutingEditActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) }
|
||||
private val position by lazy { intent.getIntExtra("position", -1) }
|
||||
|
||||
private val outbound_tag: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.outbound_tag)
|
||||
private val processPickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
val selectedPackages = AppPickerActivity.getSelectedPackages(result.data)
|
||||
binding.etProcess.text = Utils.getEditable(selectedPackages.joinToString(","))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title))
|
||||
|
||||
setupOutboundTagInput()
|
||||
setupProcessPicker()
|
||||
|
||||
val rulesetItem = SettingsManager.getRoutingRuleset(position)
|
||||
if (rulesetItem != null) {
|
||||
bindingServer(rulesetItem)
|
||||
} else {
|
||||
clearServer()
|
||||
}
|
||||
|
||||
SettingsManager.canUseProcessRouting().let { canUse ->
|
||||
binding.etProcess.isEnabled = canUse
|
||||
binding.btnProcessPicker.isEnabled = canUse
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupProcessPicker() {
|
||||
binding.btnProcessPicker.setOnClickListener {
|
||||
processPickerLauncher.launch(
|
||||
AppPickerActivity.createIntent(
|
||||
context = this,
|
||||
selectedPackages = getSelectedProcessPackages(),
|
||||
title = getString(R.string.routing_settings_process)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSelectedProcessPackages(): List<String> {
|
||||
return binding.etProcess.text
|
||||
.toString()
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the AutoCompleteTextView for outbound tag:
|
||||
* suggestions = built-in tags (proxy/direct/block) + all existing profile remarks.
|
||||
* The dropdown button triggers showing the full list without typing.
|
||||
*/
|
||||
private fun setupOutboundTagInput() {
|
||||
val profileRemarks = SettingsManager.getProfileRemarks()
|
||||
|
||||
val suggestions = (BUILTIN_OUTBOUND_TAGS.toList() + profileRemarks).distinct()
|
||||
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
|
||||
binding.spOutboundTag.setAdapter(adapter)
|
||||
// threshold=0 means show all suggestions even before typing; still need focus+request
|
||||
binding.spOutboundTag.threshold = 0
|
||||
|
||||
// Dropdown arrow button shows the full suggestion list
|
||||
binding.btnOutboundTagDropdown.setOnClickListener {
|
||||
binding.spOutboundTag.requestFocus()
|
||||
binding.spOutboundTag.showDropDown()
|
||||
}
|
||||
// Also show on field click when it already has focus
|
||||
binding.spOutboundTag.setOnClickListener {
|
||||
binding.spOutboundTag.showDropDown()
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindingServer(rulesetItem: RulesetItem): Boolean {
|
||||
@@ -42,18 +101,18 @@ class RoutingEditActivity : BaseActivity() {
|
||||
binding.chkLocked.isChecked = rulesetItem.locked == true
|
||||
binding.etDomain.text = Utils.getEditable(rulesetItem.domain?.joinToString(","))
|
||||
binding.etIp.text = Utils.getEditable(rulesetItem.ip?.joinToString(","))
|
||||
binding.etProcess.text = Utils.getEditable(rulesetItem.process?.joinToString(","))
|
||||
binding.etPort.text = Utils.getEditable(rulesetItem.port)
|
||||
binding.etProtocol.text = Utils.getEditable(rulesetItem.protocol?.joinToString(","))
|
||||
binding.etNetwork.text = Utils.getEditable(rulesetItem.network)
|
||||
val outbound = Utils.arrayFind(outbound_tag, rulesetItem.outboundTag)
|
||||
binding.spOutboundTag.setSelection(outbound)
|
||||
|
||||
// Set text directly; filter won't fire because we're not using setText(filter=true)
|
||||
binding.spOutboundTag.setText(rulesetItem.outboundTag, false)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun clearServer(): Boolean {
|
||||
binding.etRemarks.text = null
|
||||
binding.spOutboundTag.setSelection(0)
|
||||
binding.spOutboundTag.setText(BUILTIN_OUTBOUND_TAGS.first(), false)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -65,10 +124,11 @@ class RoutingEditActivity : BaseActivity() {
|
||||
locked = binding.chkLocked.isChecked
|
||||
domain = binding.etDomain.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
ip = binding.etIp.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
process = binding.etProcess.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
protocol = binding.etProtocol.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||
port = binding.etPort.text.toString().nullIfBlank()
|
||||
network = binding.etNetwork.text.toString().nullIfBlank()
|
||||
outboundTag = outbound_tag[binding.spOutboundTag.selectedItemPosition]
|
||||
outboundTag = binding.spOutboundTag.text.toString().trim().ifEmpty { TAG_PROXY }
|
||||
}
|
||||
|
||||
if (rulesetItem.remarks.isNullOrEmpty()) {
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.viewModels
|
||||
@@ -21,6 +20,7 @@ import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -92,7 +92,7 @@ class RoutingSettingActivity : HelperBaseActivity() {
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
|
||||
binding.tvDomainStrategySummary.text = value
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to set domain strategy", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to set domain strategy", e)
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
@@ -110,7 +110,7 @@ class RoutingSettingActivity : HelperBaseActivity() {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to import predefined ruleset", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to import predefined ruleset", e)
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
@@ -126,7 +126,7 @@ class RoutingSettingActivity : HelperBaseActivity() {
|
||||
val clipboard = try {
|
||||
Utils.getClipboard(this)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to get clipboard content", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to get clipboard content", e)
|
||||
toastError(R.string.toast_failure)
|
||||
return@setPositiveButton
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class RoutingSettingRecyclerAdapter(
|
||||
val ruleset = rulesets[position]
|
||||
|
||||
holder.itemRoutingSettingBinding.remarks.text = ruleset.remarks
|
||||
holder.itemRoutingSettingBinding.domainIp.text = (ruleset.domain ?: ruleset.ip ?: ruleset.port)?.toString()
|
||||
holder.itemRoutingSettingBinding.domainIp.text = (ruleset.domain ?: ruleset.ip ?: ruleset.process ?: ruleset.port)?.toString()
|
||||
holder.itemRoutingSettingBinding.outboundTag.text = ruleset.outboundTag
|
||||
holder.itemRoutingSettingBinding.chkEnable.isChecked = ruleset.enabled
|
||||
holder.itemRoutingSettingBinding.imgLocked.isVisible = ruleset.locked == true
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
|
||||
class ScStartActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
moveTaskToBack(true)
|
||||
|
||||
setContentView(R.layout.activity_none)
|
||||
|
||||
if (!CoreServiceManager.isRunning()) {
|
||||
CoreServiceManager.startVServiceFromToggle(this)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
|
||||
class ScStopActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
moveTaskToBack(true)
|
||||
|
||||
setContentView(R.layout.activity_none)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import com.v2ray.ang.AppConfig
|
||||
@@ -11,6 +10,7 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityNoneBinding
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.QRCodeDecoder
|
||||
import io.github.g00fy2.quickie.QRResult
|
||||
import io.github.g00fy2.quickie.ScanCustomCode
|
||||
@@ -18,7 +18,7 @@ import io.github.g00fy2.quickie.config.BarcodeFormat
|
||||
import io.github.g00fy2.quickie.config.ScannerConfig
|
||||
|
||||
class ScannerActivity : HelperBaseActivity() {
|
||||
private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) }
|
||||
private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) }
|
||||
|
||||
private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult)
|
||||
|
||||
@@ -95,7 +95,7 @@ class ScannerActivity : HelperBaseActivity() {
|
||||
finished(text)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to decode QR code from file", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to decode QR code from file", e)
|
||||
toast(R.string.toast_decoding_failed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +20,16 @@ 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
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
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
|
||||
|
||||
@@ -83,8 +85,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -131,14 +133,18 @@ class ServerActivity : BaseActivity() {
|
||||
private val et_port_hop_interval: EditText? by lazy { findViewById(R.id.et_port_hop_interval) }
|
||||
private val et_bandwidth_down: EditText? by lazy { findViewById(R.id.et_bandwidth_down) }
|
||||
private val et_bandwidth_up: EditText? by lazy { findViewById(R.id.et_bandwidth_up) }
|
||||
private val et_kcp_mtu: EditText? by lazy { findViewById(R.id.et_kcp_mtu) }
|
||||
private val et_kcp_tti: EditText? by lazy { findViewById(R.id.et_kcp_tti) }
|
||||
private val layout_kcp: LinearLayout? by lazy { findViewById(R.id.layout_kcp) }
|
||||
private val et_extra: EditText? by lazy { findViewById(R.id.et_extra) }
|
||||
private val et_fm: EditText? by lazy { findViewById(R.id.et_fm) }
|
||||
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?) {
|
||||
@@ -148,14 +154,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())
|
||||
@@ -241,12 +245,28 @@ class ServerActivity : BaseActivity() {
|
||||
else -> null
|
||||
}.orEmpty()
|
||||
)
|
||||
et_fm?.text = Utils.getEditable(config?.finalMask)
|
||||
|
||||
layout_kcp?.visibility =
|
||||
when (networks[position]) {
|
||||
NetworkType.KCP.type -> View.VISIBLE
|
||||
else -> View.GONE
|
||||
}
|
||||
et_kcp_mtu?.text = Utils.getEditable(config?.kcpMtu?.toString().orEmpty())
|
||||
et_kcp_tti?.text = Utils.getEditable(config?.kcpTti?.toString().orEmpty())
|
||||
|
||||
layout_extra?.visibility =
|
||||
when (networks[position]) {
|
||||
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<*>?) {
|
||||
@@ -276,7 +296,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 }
|
||||
}
|
||||
@@ -289,7 +308,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(
|
||||
@@ -310,7 +328,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(
|
||||
@@ -394,10 +411,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())
|
||||
@@ -411,6 +424,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
|
||||
}
|
||||
|
||||
@@ -439,6 +458,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
|
||||
}
|
||||
|
||||
@@ -489,6 +509,13 @@ class ServerActivity : BaseActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
if (et_fm?.text?.toString().isNotNullEmpty()) {
|
||||
if (JsonUtil.parseString(et_fm?.text?.toString()) == null) {
|
||||
toast(R.string.server_lab_final_mask)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
saveCommon(config)
|
||||
saveStreamSettings(config)
|
||||
saveTls(config)
|
||||
@@ -498,8 +525,11 @@ class ServerActivity : BaseActivity() {
|
||||
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
||||
config.subscriptionId = subscriptionId.orEmpty()
|
||||
}
|
||||
//Log.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "")
|
||||
//LogUtil.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "")
|
||||
MmkvManager.encodeServerConfig(editGuid, config)
|
||||
if (isRunning) {
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
toastSuccess(R.string.toast_success)
|
||||
finish()
|
||||
return true
|
||||
@@ -533,7 +563,7 @@ class ServerActivity : BaseActivity() {
|
||||
} else if (config.configType == EConfigType.HYSTERIA2) {
|
||||
config.obfsPassword = et_obfs_password?.text?.toString()
|
||||
config.portHopping = et_port_hop?.text?.toString()
|
||||
config.portHoppingInterval = et_port_hop_interval?.text?.toString()
|
||||
config.portHoppingInterval = et_port_hop_interval?.text?.toString()?.trim()
|
||||
config.bandwidthDown = et_bandwidth_down?.text?.toString()
|
||||
config.bandwidthUp = et_bandwidth_up?.text?.toString()
|
||||
}
|
||||
@@ -557,7 +587,20 @@ class ServerActivity : BaseActivity() {
|
||||
profileItem.serviceName = path
|
||||
profileItem.authority = requestHost
|
||||
profileItem.xhttpMode = transportTypes(networks[network])[type]
|
||||
profileItem.xhttpExtra = et_extra?.text?.toString()?.trim()
|
||||
profileItem.xhttpExtra = et_extra?.text?.toString()?.trim().nullIfBlank()
|
||||
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) {
|
||||
@@ -571,7 +614,6 @@ 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 =
|
||||
@@ -591,7 +633,6 @@ class ServerActivity : BaseActivity() {
|
||||
config.spiderX = spiderX
|
||||
config.mldsa65Verify = mldsa65Verify
|
||||
config.echConfigList = echConfigList
|
||||
config.echForceQuery = echForceQuerys[echForceQueryIndex]
|
||||
config.pinnedCA256 = pinnedCA256
|
||||
}
|
||||
|
||||
@@ -648,17 +689,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)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
@@ -11,13 +10,15 @@ 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
|
||||
|
||||
class ServerCustomConfigActivity : BaseActivity() {
|
||||
@@ -79,7 +80,7 @@ class ServerCustomConfigActivity : BaseActivity() {
|
||||
val profileItem = try {
|
||||
CustomFmt.parse(binding.editor.text.toString())
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse custom configuration", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse custom configuration", e)
|
||||
toast("${getString(R.string.toast_malformed_josn)} ${e.cause?.message}")
|
||||
return false
|
||||
}
|
||||
@@ -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() {
|
||||
@@ -97,9 +98,12 @@ class ServerGroupActivity : BaseActivity() {
|
||||
config.subscriptionId = subscriptionId.orEmpty()
|
||||
}
|
||||
|
||||
config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}"
|
||||
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,14 +41,20 @@ 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) }
|
||||
private val hevTunRwTimeout by lazy { findPreference<EditTextPreference>(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) }
|
||||
private val useHevTun by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_USE_HEV_TUNNEL) }
|
||||
|
||||
private val enableLocalProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_ENABLE_LOCAL_PROXY) }
|
||||
private val socksPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PORT) }
|
||||
private val dynamicSocksPort by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_DYNAMIC_SOCKS_PORT) }
|
||||
private val socksUsername by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_USERNAME) }
|
||||
private val socksPassword by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PASSWORD) }
|
||||
private val socksEnableUdp by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_SOCKS_ENABLE_UDP) }
|
||||
private val proxySharing by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_PROXY_SHARING) }
|
||||
|
||||
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
|
||||
// Use MMKV as the storage backend for all Preferences
|
||||
// This prevents inconsistencies between SharedPreferences and MMKV
|
||||
@@ -88,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 ->
|
||||
@@ -112,15 +102,33 @@ class SettingsActivity : BaseActivity() {
|
||||
updateHevTunSettings(newValue as Boolean)
|
||||
true
|
||||
}
|
||||
|
||||
enableLocalProxy?.setOnPreferenceChangeListener { _, newValue ->
|
||||
updateEnableLocalProxy(newValue as Boolean)
|
||||
true
|
||||
}
|
||||
|
||||
dynamicSocksPort?.setOnPreferenceChangeListener { _, newValue ->
|
||||
updateDynamicSocksPort(newValue as Boolean)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun initPreferenceSummaries() {
|
||||
fun updateSummary(pref: androidx.preference.Preference) {
|
||||
when (pref) {
|
||||
is EditTextPreference -> {
|
||||
pref.summary = pref.text.orEmpty()
|
||||
if (pref.key == AppConfig.PREF_SOCKS_PASSWORD) {
|
||||
pref.summary = if (pref.text.isNullOrEmpty()) "" else "******"
|
||||
} else {
|
||||
pref.summary = pref.text.orEmpty()
|
||||
}
|
||||
pref.setOnPreferenceChangeListener { p, newValue ->
|
||||
p.summary = (newValue as? String).orEmpty()
|
||||
if (p.key == AppConfig.PREF_SOCKS_PASSWORD) {
|
||||
p.summary = if ((newValue as? String).isNullOrEmpty()) "" else "******"
|
||||
} else {
|
||||
p.summary = (newValue as? String).orEmpty()
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -159,14 +167,16 @@ class SettingsActivity : BaseActivity() {
|
||||
// Initialize mode-dependent UI states
|
||||
updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN))
|
||||
|
||||
// Initialize local proxy state
|
||||
updateEnableLocalProxy(MmkvManager.decodeSettingsBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true))
|
||||
|
||||
// Initialize mux-dependent UI states
|
||||
updateMux(MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false))
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
private fun updateMode(value: String?) {
|
||||
@@ -203,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
|
||||
@@ -258,9 +245,45 @@ class SettingsActivity : BaseActivity() {
|
||||
fragmentInterval?.isEnabled = enabled
|
||||
}
|
||||
|
||||
private fun updateDynamicSocksPort(enabled: Boolean) {
|
||||
socksPort?.isEnabled = (enableLocalProxy?.isChecked == true) && !enabled
|
||||
}
|
||||
|
||||
private fun updateEnableLocalProxy(enabled: Boolean) {
|
||||
val dynamic = MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
|
||||
socksPort?.isEnabled = enabled && !dynamic
|
||||
dynamicSocksPort?.isEnabled = enabled
|
||||
socksUsername?.isEnabled = enabled
|
||||
socksPassword?.isEnabled = enabled
|
||||
socksEnableUdp?.isEnabled = enabled
|
||||
proxySharing?.isEnabled = enabled
|
||||
|
||||
if (!enabled) {
|
||||
if (appendHttpProxy?.isChecked == true) {
|
||||
appendHttpProxy?.isChecked = false
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_APPEND_HTTP_PROXY, false)
|
||||
}
|
||||
appendHttpProxy?.isEnabled = false
|
||||
} else {
|
||||
val vpn = MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) == VPN
|
||||
appendHttpProxy?.isEnabled = vpn
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateHevTunSettings(enabled: Boolean) {
|
||||
hevTunLogLevel?.isEnabled = enabled
|
||||
hevTunRwTimeout?.isEnabled = enabled
|
||||
|
||||
if (enabled) {
|
||||
if (enableLocalProxy?.isChecked == false) {
|
||||
enableLocalProxy?.isChecked = true
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_ENABLE_LOCAL_PROXY, true)
|
||||
}
|
||||
enableLocalProxy?.isEnabled = false
|
||||
} else {
|
||||
enableLocalProxy?.isEnabled = true
|
||||
}
|
||||
updateEnableLocalProxy(enableLocalProxy?.isChecked == true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
@@ -21,6 +20,7 @@ import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.QRCodeDecoder
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
|
||||
@@ -91,6 +91,7 @@ class SubSettingActivity : BaseActivity() {
|
||||
)
|
||||
}
|
||||
hideLoading()
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +156,7 @@ class SubSettingActivity : BaseActivity() {
|
||||
else -> ownerActivity.toast("else")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Share subscription failed", e)
|
||||
LogUtil.e(AppConfig.TAG, "Share subscription failed", e)
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user