Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 80eec036dd | |||
| f9a85366ea | |||
| 378c891399 | |||
| d04b5eee37 | |||
| 405cd7f55e | |||
| 850096789b | |||
| 3d42ac9dba | |||
| d0265265f3 | |||
| 2d6dd33a7b | |||
| 3a32373abe | |||
| befb2937d1 | |||
| 0a375bec67 | |||
| 029f8b67e7 | |||
| 7475d1757b | |||
| 5a16614a7b | |||
| edb06b466a | |||
| 30b5391895 | |||
| 6cd04b831f | |||
| 1e616198f0 | |||
| a710146e4f | |||
| 9cd530d223 | |||
| fc89f25591 | |||
| 84ab54fea2 | |||
| d642d09844 | |||
| 6ae46c50e2 | |||
| fe4b451988 | |||
| 5c9b634039 | |||
| 09461663cb | |||
| 50c1145815 | |||
| edc4856cb8 | |||
| 4dd7eee8fe | |||
| 516637954c | |||
| 9e714bfcb3 | |||
| 858dc5237a | |||
| 2b4a4f7ecc |
@@ -22,7 +22,7 @@ jobs:
|
|||||||
fetch-depth: '0'
|
fetch-depth: '0'
|
||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3.2.0
|
uses: android-actions/setup-android@v4.0.1
|
||||||
with:
|
with:
|
||||||
log-accepted-android-sdk-licenses: false
|
log-accepted-android-sdk-licenses: false
|
||||||
cmdline-tools-version: '13114758'
|
cmdline-tools-version: '13114758'
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
popd
|
popd
|
||||||
|
|
||||||
- name: Download libv2ray
|
- name: Download libv2ray
|
||||||
uses: robinraju/release-downloader@v1.12
|
uses: robinraju/release-downloader@v1.13
|
||||||
with:
|
with:
|
||||||
repository: '2dust/AndroidLibXrayLite'
|
repository: '2dust/AndroidLibXrayLite'
|
||||||
tag: ${{ env.CURRENT_TAG }}
|
tag: ${{ env.CURRENT_TAG }}
|
||||||
@@ -84,7 +84,7 @@ jobs:
|
|||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Decode Keystore
|
- name: Decode Keystore
|
||||||
uses: timheuer/base64-to-file@v1.2.4
|
uses: timheuer/base64-to-file@v2.0.0
|
||||||
id: android_keystore
|
id: android_keystore
|
||||||
with:
|
with:
|
||||||
fileName: "android_keystore.jks"
|
fileName: "android_keystore.jks"
|
||||||
@@ -99,21 +99,21 @@ jobs:
|
|||||||
./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }}
|
./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }}
|
||||||
|
|
||||||
- name: Upload arm64-v8a APK
|
- name: Upload arm64-v8a APK
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
if: ${{ success() }}
|
if: ${{ success() }}
|
||||||
with:
|
with:
|
||||||
name: arm64-v8a
|
name: arm64-v8a
|
||||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk
|
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk
|
||||||
|
|
||||||
- name: Upload armeabi-v7a APK
|
- name: Upload armeabi-v7a APK
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
if: ${{ success() }}
|
if: ${{ success() }}
|
||||||
with:
|
with:
|
||||||
name: armeabi-v7a
|
name: armeabi-v7a
|
||||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk
|
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk
|
||||||
|
|
||||||
- name: Upload x86 APK
|
- name: Upload x86 APK
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
if: ${{ success() }}
|
if: ${{ success() }}
|
||||||
with:
|
with:
|
||||||
name: x86-apk
|
name: x86-apk
|
||||||
|
|||||||
+1
-1
Submodule AndroidLibXrayLite updated: d783dc8ea7...1b0ec8e111
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "com.v2ray.ang"
|
applicationId = "com.v2ray.ang"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 709
|
versionCode = 725
|
||||||
versionName = "2.0.9"
|
versionName = "2.1.5"
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
|
|
||||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||||
|
|||||||
@@ -30,4 +30,34 @@
|
|||||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||||
<categories android:name="android.shortcut.conversation" />
|
<categories android:name="android.shortcut.conversation" />
|
||||||
</shortcut>
|
</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>
|
</shortcuts>
|
||||||
@@ -82,12 +82,19 @@
|
|||||||
android:name=".ui.ServerGroupActivity"
|
android:name=".ui.ServerGroupActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="stateUnchanged" />
|
android:windowSoftInputMode="stateUnchanged" />
|
||||||
|
<activity
|
||||||
|
android:name=".ui.ServerProxyChainActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:windowSoftInputMode="stateUnchanged" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.SettingsActivity"
|
android:name=".ui.SettingsActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.PerAppProxyActivity"
|
android:name=".ui.PerAppProxyActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
<activity
|
||||||
|
android:name=".ui.AppPickerActivity"
|
||||||
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.ScannerActivity"
|
android:name=".ui.ScannerActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
@@ -122,6 +129,18 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:process=":RunSoLibV2RayDaemon"
|
android:process=":RunSoLibV2RayDaemon"
|
||||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
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
|
<activity
|
||||||
android:name=".ui.UrlSchemeActivity"
|
android:name=".ui.UrlSchemeActivity"
|
||||||
@@ -153,7 +172,7 @@
|
|||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".service.V2RayVpnService"
|
android:name=".service.CoreVpnService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="specialUse"
|
android:foregroundServiceType="specialUse"
|
||||||
@@ -173,7 +192,7 @@
|
|||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".service.V2RayProxyOnlyService"
|
android:name=".service.CoreProxyOnlyService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="specialUse"
|
android:foregroundServiceType="specialUse"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -184,9 +203,14 @@
|
|||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".service.V2RayTestService"
|
android:name=".service.CoreTestService"
|
||||||
android:exported="false"
|
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
|
<receiver
|
||||||
android:name=".receiver.WidgetProvider"
|
android:name=".receiver.WidgetProvider"
|
||||||
@@ -258,6 +282,11 @@
|
|||||||
|
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="androidx.work.multiprocess.RemoteWorkManagerService"
|
||||||
|
android:exported="false"
|
||||||
|
android:process=":bg" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.cache"
|
android:authorities="${applicationId}.cache"
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"tag": "tun",
|
"tag": "tun",
|
||||||
"port": 0,
|
|
||||||
"protocol": "tun",
|
"protocol": "tun",
|
||||||
"settings": {
|
"settings": {
|
||||||
"name": "xray0",
|
"name": "xray0",
|
||||||
|
|||||||
@@ -34,17 +34,15 @@ class AngApplication : MultiDexApplication() {
|
|||||||
|
|
||||||
MMKV.initialize(this)
|
MMKV.initialize(this)
|
||||||
|
|
||||||
// Ensure critical preference defaults are present in MMKV early
|
|
||||||
SettingsManager.ensureDefaultSettings()
|
|
||||||
SettingsManager.setNightMode()
|
|
||||||
// Initialize WorkManager with the custom configuration
|
// Initialize WorkManager with the custom configuration
|
||||||
WorkManager.initialize(this, workManagerConfiguration)
|
WorkManager.initialize(this, workManagerConfiguration)
|
||||||
|
|
||||||
SettingsManager.initRoutingRulesets(this)
|
// Ensure critical preference defaults are present in MMKV early
|
||||||
SettingsManager.migrateHysteria2PinSHA256()
|
SettingsManager.initApp(this)
|
||||||
|
SettingsManager.setNightMode()
|
||||||
|
|
||||||
es.dmoral.toasty.Toasty.Config.getInstance()
|
es.dmoral.toasty.Toasty.Config.getInstance()
|
||||||
.setGravity(android.view.Gravity.BOTTOM, 0, 200)
|
.setGravity(android.view.Gravity.BOTTOM, 0, 300)
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ object AppConfig {
|
|||||||
/** Directory names used in the app's file system. */
|
/** Directory names used in the app's file system. */
|
||||||
const val DIR_ASSETS = "assets"
|
const val DIR_ASSETS = "assets"
|
||||||
|
|
||||||
const val WEBDAV_BACKUP_DIR = "backups"
|
const val WEBDAV_BACKUP_DIR = "backups"
|
||||||
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
|
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
|
||||||
|
|
||||||
/** Legacy configuration keys. */
|
/** Legacy configuration keys. */
|
||||||
const val ANG_CONFIG = "ang_config"
|
const val ANG_CONFIG = "ang_config"
|
||||||
|
|
||||||
|
// Default subscription ID for ungrouped servers
|
||||||
|
const val DEFAULT_SUBSCRIPTION_ID = "__default_subscription__"
|
||||||
|
|
||||||
/** Preferences mapped to MMKV storage. */
|
/** Preferences mapped to MMKV storage. */
|
||||||
const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
|
const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
|
||||||
const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled"
|
const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled"
|
||||||
@@ -40,20 +43,25 @@ object AppConfig {
|
|||||||
const val PREF_FRAGMENT_PACKETS = "pref_fragment_packets"
|
const val PREF_FRAGMENT_PACKETS = "pref_fragment_packets"
|
||||||
const val PREF_FRAGMENT_LENGTH = "pref_fragment_length"
|
const val PREF_FRAGMENT_LENGTH = "pref_fragment_length"
|
||||||
const val PREF_FRAGMENT_INTERVAL = "pref_fragment_interval"
|
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_UPDATE_TASK_NAME = "subscription_updater"
|
||||||
|
const val SUBSCRIPTION_MIN_INTERVAL_MINUTES = 15L
|
||||||
const val PREF_SPEED_ENABLED = "pref_speed_enabled"
|
const val PREF_SPEED_ENABLED = "pref_speed_enabled"
|
||||||
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
|
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
|
||||||
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
|
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
|
||||||
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
|
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
|
||||||
|
const val PREF_GROUP_ALL_DISPLAY = "pref_group_all_display"
|
||||||
const val PREF_LANGUAGE = "pref_language"
|
const val PREF_LANGUAGE = "pref_language"
|
||||||
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
|
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_PREFER_IPV6 = "pref_prefer_ipv6"
|
||||||
const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
|
const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
|
||||||
const val PREF_ALLOW_INSECURE = "pref_allow_insecure"
|
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_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_REMOTE_DNS = "pref_remote_dns"
|
||||||
const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
|
const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
|
||||||
const val PREF_DNS_HOSTS = "pref_dns_hosts"
|
const val PREF_DNS_HOSTS = "pref_dns_hosts"
|
||||||
@@ -70,10 +78,10 @@ object AppConfig {
|
|||||||
const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout_v2"
|
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_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_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
|
||||||
|
const val PREF_REAL_PING_CONCURRENCY = "pref_real_ping_concurrency"
|
||||||
|
|
||||||
/** Cache keys. */
|
/** Cache keys. */
|
||||||
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
|
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
|
||||||
const val CACHE_KEYWORD_FILTER = "cache_keyword_filter"
|
|
||||||
|
|
||||||
/** Protocol identifiers. */
|
/** Protocol identifiers. */
|
||||||
const val PROTOCOL_FREEDOM = "freedom"
|
const val PROTOCOL_FREEDOM = "freedom"
|
||||||
@@ -117,7 +125,8 @@ object AppConfig {
|
|||||||
const val TG_CHANNEL_URL = "https://t.me/github_2dust"
|
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_URL = "https://www.gstatic.com/generate_204"
|
||||||
const val DELAY_TEST_URL2 = "https://www.google.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"
|
const val IP_API_URL = "https://api.ip.sb/geoip"
|
||||||
|
|
||||||
/** DNS server addresses. */
|
/** DNS server addresses. */
|
||||||
@@ -129,6 +138,12 @@ object AppConfig {
|
|||||||
const val GEOIP_PRIVATE = "geoip:private"
|
const val GEOIP_PRIVATE = "geoip:private"
|
||||||
const val GEOIP_CN = "geoip:cn"
|
const val GEOIP_CN = "geoip:cn"
|
||||||
|
|
||||||
|
/** Geo data file names. */
|
||||||
|
const val GEOSITE_DAT = "geosite.dat"
|
||||||
|
const val GEOIP_DAT = "geoip.dat"
|
||||||
|
const val GEOIP_ONLY_CN_PRIVATE_DAT = "geoip-only-cn-private.dat"
|
||||||
|
const val GEOIP_ONLY_CN_PRIVATE_URL = "$GITHUB_RAW_URL/Loyalsoldier/geoip/release/$GEOIP_ONLY_CN_PRIVATE_DAT"
|
||||||
|
|
||||||
/** Ports and addresses for various services. */
|
/** Ports and addresses for various services. */
|
||||||
const val PORT_LOCAL_DNS = "10853"
|
const val PORT_LOCAL_DNS = "10853"
|
||||||
const val PORT_SOCKS = "10808"
|
const val PORT_SOCKS = "10808"
|
||||||
@@ -150,17 +165,15 @@ object AppConfig {
|
|||||||
const val MSG_STATE_RESTART = 5
|
const val MSG_STATE_RESTART = 5
|
||||||
const val MSG_MEASURE_DELAY = 6
|
const val MSG_MEASURE_DELAY = 6
|
||||||
const val MSG_MEASURE_DELAY_SUCCESS = 61
|
const val MSG_MEASURE_DELAY_SUCCESS = 61
|
||||||
const val MSG_MEASURE_CONFIG = 7
|
const val MSG_MEASURE_CONFIG_START = 7
|
||||||
const val MSG_MEASURE_CONFIG_SUCCESS = 71
|
const val MSG_MEASURE_CONFIG_CANCEL = 71
|
||||||
const val MSG_MEASURE_CONFIG_CANCEL = 72
|
const val MSG_MEASURE_CONFIG_SUCCESS = 72
|
||||||
const val MSG_MEASURE_CONFIG_NOTIFY = 73
|
const val MSG_MEASURE_CONFIG_NOTIFY = 73
|
||||||
const val MSG_MEASURE_CONFIG_FINISH = 74
|
const val MSG_MEASURE_CONFIG_FINISH = 74
|
||||||
|
|
||||||
/** Notification channel IDs and names. */
|
/** Notification channel IDs and names. */
|
||||||
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
|
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
|
||||||
const val RAY_NG_CHANNEL_NAME = "v2rayNG Background Service"
|
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 **/
|
/** Protocols Scheme **/
|
||||||
const val VMESS = "vmess://"
|
const val VMESS = "vmess://"
|
||||||
@@ -205,6 +218,8 @@ object AppConfig {
|
|||||||
const val REALITY = "reality"
|
const val REALITY = "reality"
|
||||||
const val HEADER_TYPE_HTTP = "http"
|
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_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_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")
|
val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5")
|
||||||
@@ -265,4 +280,9 @@ object AppConfig {
|
|||||||
"Chocolate4U/Iran-v2ray-rules"
|
"Chocolate4U/Iran-v2ray-rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val BUILTIN_OUTBOUND_TAGS = setOf(
|
||||||
|
TAG_PROXY,
|
||||||
|
TAG_DIRECT,
|
||||||
|
TAG_BLOCKED,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.v2ray.ang.contracts
|
|||||||
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
|
||||||
interface MainAdapterListener :BaseAdapterListener {
|
interface MainAdapterListener : BaseAdapterListener {
|
||||||
|
|
||||||
fun onEdit(guid: String, position: Int, profile: ProfileItem)
|
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.
|
* This initializes the VPN tunnel and connects it to the SOCKS proxy.
|
||||||
*/
|
*/
|
||||||
fun startTun2Socks()
|
fun startTun2Socks()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops the tun2socks process and cleans up resources.
|
* Stops the tun2socks process and cleans up resources.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package com.v2ray.ang.core
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.dto.CoreConfigContext
|
||||||
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
import com.v2ray.ang.enums.CoreResolvedType
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
import com.v2ray.ang.util.Utils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds [com.v2ray.ang.dto.CoreConfigContext] from the selected profile.
|
||||||
|
* Keeps parsing and resolution logic out of [CoreConfigManager].
|
||||||
|
*/
|
||||||
|
object CoreConfigContextBuilder {
|
||||||
|
|
||||||
|
/** Loads profile by guid and returns a resolved runtime context. */
|
||||||
|
fun build(context: Context, guid: String): CoreConfigContext? {
|
||||||
|
val config = MmkvManager.decodeServerConfig(guid) ?: return null
|
||||||
|
if (config.configType == EConfigType.CUSTOM) {
|
||||||
|
return CoreConfigContext(
|
||||||
|
context = context,
|
||||||
|
guid = guid,
|
||||||
|
selectedProfile = config,
|
||||||
|
resolvedProfiles = listOf(config),
|
||||||
|
resolvedType = CoreResolvedType.CUSTOM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-resolve custom outbound profiles from routing rulesets
|
||||||
|
val customOutbounds = resolveCustomOutbounds()
|
||||||
|
|
||||||
|
// Determine resolved profiles and type based on config type
|
||||||
|
val (resolvedProfiles, resolvedType) = when (config.configType) {
|
||||||
|
EConfigType.POLICYGROUP -> {
|
||||||
|
val profiles = resolvePolicyGroupProfiles(config)
|
||||||
|
Pair(profiles, CoreResolvedType.POLICYGROUP)
|
||||||
|
}
|
||||||
|
|
||||||
|
EConfigType.PROXYCHAIN -> {
|
||||||
|
val profiles = resolveProxyChainProfiles(config)
|
||||||
|
Pair(profiles, CoreResolvedType.PROXYCHAIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val chainProfiles = resolveProxyChainProfilesFromGroup(config)
|
||||||
|
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
|
||||||
|
Pair(chainProfiles, type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context with common fields
|
||||||
|
return CoreConfigContext(
|
||||||
|
context = context,
|
||||||
|
guid = guid,
|
||||||
|
selectedProfile = config,
|
||||||
|
resolvedProfiles = resolvedProfiles,
|
||||||
|
resolvedType = resolvedType,
|
||||||
|
customOutboundProfiles = customOutbounds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves policy-group members with the same filters as runtime build. */
|
||||||
|
private fun resolvePolicyGroupProfiles(config: ProfileItem): List<ProfileItem> {
|
||||||
|
try {
|
||||||
|
val serverList = MmkvManager.decodeAllServerList()
|
||||||
|
return serverList
|
||||||
|
.asSequence()
|
||||||
|
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
|
||||||
|
.filter { profile ->
|
||||||
|
val subscriptionId = config.policyGroupSubscriptionId
|
||||||
|
if (subscriptionId.isNullOrBlank()) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
profile.subscriptionId == subscriptionId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.filter { profile ->
|
||||||
|
val filter = config.policyGroupFilter
|
||||||
|
if (filter.isNullOrBlank()) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
Regex(filter).containsMatchIn(profile.remarks)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
profile.remarks.contains(filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.filter { it.server.isNotNullEmpty() }
|
||||||
|
.filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
|
||||||
|
.filter { it.configType != EConfigType.CUSTOM }
|
||||||
|
.filter { it.configType != EConfigType.POLICYGROUP }
|
||||||
|
.filter { it.configType != EConfigType.PROXYCHAIN }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to resolve policy group profiles for config '${config.remarks}'", e)
|
||||||
|
return listOf(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves proxy-chain members with the same filters as runtime build. */
|
||||||
|
private fun resolveProxyChainProfiles(config: ProfileItem): List<ProfileItem> {
|
||||||
|
if (config.proxyChainProfiles.isNullOrBlank()) {
|
||||||
|
return listOf(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return config.proxyChainProfiles.orEmpty().split(",")
|
||||||
|
.asSequence()
|
||||||
|
.mapNotNull { remark -> SettingsManager.getServerViaRemarks(remark) }
|
||||||
|
.filter { it.server.isNotNullEmpty() }
|
||||||
|
.filter { !Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
|
||||||
|
.filter { it.configType != EConfigType.CUSTOM }
|
||||||
|
.filter { it.configType != EConfigType.POLICYGROUP }
|
||||||
|
.filter { it.configType != EConfigType.PROXYCHAIN }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles for config '${config.remarks}'", e)
|
||||||
|
return listOf(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves chain nodes in fixed order: next -> current -> prev.
|
||||||
|
* If chain cannot be built, caller treats result as normal mode.
|
||||||
|
*/
|
||||||
|
private fun resolveProxyChainProfilesFromGroup(config: ProfileItem): List<ProfileItem> {
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == true) {
|
||||||
|
return listOf(config)
|
||||||
|
}
|
||||||
|
if (config.subscriptionId.isEmpty()) {
|
||||||
|
return listOf(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val subItem = MmkvManager.decodeSubscription(config.subscriptionId) ?: return listOf(config)
|
||||||
|
val resolved = mutableListOf<ProfileItem>()
|
||||||
|
|
||||||
|
// Keep the same practical chain order as current runtime assembly:
|
||||||
|
// next -> current -> prev
|
||||||
|
SettingsManager.getServerViaRemarks(subItem.nextProfile)?.let { resolved.add(it) }
|
||||||
|
resolved.add(config)
|
||||||
|
SettingsManager.getServerViaRemarks(subItem.prevProfile)?.let { resolved.add(it) }
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles from group for config '${config.remarks}'", e)
|
||||||
|
return listOf(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves custom outbound profiles from routing rulesets.
|
||||||
|
* Scans rulesets for non-builtin outbound tags and looks up matching profiles by remarks.
|
||||||
|
* Returns a map of tag -> ProfileItem for independent processing.
|
||||||
|
*/
|
||||||
|
private fun resolveCustomOutbounds(): Map<String, ProfileItem> {
|
||||||
|
val customMap = mutableMapOf<String, ProfileItem>()
|
||||||
|
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return customMap
|
||||||
|
|
||||||
|
try {
|
||||||
|
val processedTags = mutableSetOf<String>()
|
||||||
|
|
||||||
|
rulesetItems
|
||||||
|
.filter { it.enabled }
|
||||||
|
.mapNotNull { it.outboundTag.takeIf { tag -> tag.isNotBlank() } }
|
||||||
|
.filter { tag -> tag !in AppConfig.BUILTIN_OUTBOUND_TAGS }
|
||||||
|
.distinct()
|
||||||
|
.forEach { tag ->
|
||||||
|
if (tag in processedTags) return@forEach
|
||||||
|
processedTags.add(tag)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val profile = SettingsManager.getServerViaRemarks(tag) ?: run {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
customMap[tag] = profile
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to resolve custom outbound for tag '$tag', skipping", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to resolve custom outbound profiles", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return customMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,991 @@
|
|||||||
|
package com.v2ray.ang.core
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.text.TextUtils
|
||||||
|
import com.google.gson.JsonArray
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.dto.ConfigResult
|
||||||
|
import com.v2ray.ang.dto.CoreConfigContext
|
||||||
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
import com.v2ray.ang.dto.RulesetItem
|
||||||
|
import com.v2ray.ang.dto.V2rayConfig
|
||||||
|
import com.v2ray.ang.enums.CoreResolvedType
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
|
import com.v2ray.ang.util.HttpUtil
|
||||||
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
import com.v2ray.ang.util.PackageUidResolver
|
||||||
|
import com.v2ray.ang.util.Utils
|
||||||
|
|
||||||
|
object CoreConfigManager {
|
||||||
|
private var initConfigCache: String? = null
|
||||||
|
private var initConfigCacheWithTun: String? = null
|
||||||
|
|
||||||
|
//region get config function
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds normal runtime config JSON for the selected profile.
|
||||||
|
*
|
||||||
|
* @param context The context of the caller.
|
||||||
|
* @param guid The unique identifier for the V2ray configuration.
|
||||||
|
* @return A ConfigResult object containing the configuration details or indicating failure.
|
||||||
|
*/
|
||||||
|
fun getV2rayConfig(context: Context, guid: String): ConfigResult {
|
||||||
|
try {
|
||||||
|
val configContext = CoreConfigContextBuilder.build(context, guid) ?: return ConfigResult(false)
|
||||||
|
if (configContext.resolvedType == CoreResolvedType.CUSTOM) {
|
||||||
|
return getV2rayCustomConfig(configContext)
|
||||||
|
}
|
||||||
|
val v2rayConfig = when (configContext.resolvedType) {
|
||||||
|
CoreResolvedType.POLICYGROUP -> buildGroupConfig(configContext)
|
||||||
|
CoreResolvedType.PROXYCHAIN -> buildProxyChainConfig(configContext)
|
||||||
|
CoreResolvedType.NORMAL -> buildNormalConfig(configContext)
|
||||||
|
CoreResolvedType.CUSTOM -> null
|
||||||
|
} ?: return ConfigResult(false)
|
||||||
|
|
||||||
|
return toConfigResult(configContext, v2rayConfig)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config", e)
|
||||||
|
return ConfigResult(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds speedtest config for the selected profile.
|
||||||
|
*
|
||||||
|
* It reuses the same build flow as normal config, then removes
|
||||||
|
* unnecessary sections for delay testing.
|
||||||
|
*
|
||||||
|
* @param context The context of the caller.
|
||||||
|
* @param guid The unique identifier for the V2ray configuration.
|
||||||
|
* @return A ConfigResult object containing the configuration details or indicating failure.
|
||||||
|
*/
|
||||||
|
fun getV2rayConfig4Speedtest(context: Context, guid: String): ConfigResult {
|
||||||
|
try {
|
||||||
|
val configContext = CoreConfigContextBuilder.build(context, guid) ?: return ConfigResult(false)
|
||||||
|
if (configContext.resolvedType == CoreResolvedType.CUSTOM) {
|
||||||
|
return getV2rayCustomConfig(configContext)
|
||||||
|
}
|
||||||
|
val v2rayConfig = when (configContext.resolvedType) {
|
||||||
|
CoreResolvedType.POLICYGROUP -> buildGroupConfig(configContext)
|
||||||
|
CoreResolvedType.PROXYCHAIN -> buildProxyChainConfig(configContext)
|
||||||
|
CoreResolvedType.NORMAL -> buildNormalConfig(configContext)
|
||||||
|
CoreResolvedType.CUSTOM -> null
|
||||||
|
} ?: return ConfigResult(false)
|
||||||
|
|
||||||
|
postProcessForSpeedtest(v2rayConfig)
|
||||||
|
|
||||||
|
return toConfigResult(configContext, v2rayConfig)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config for speedtest", e)
|
||||||
|
return ConfigResult(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds config result for CUSTOM profiles.
|
||||||
|
*/
|
||||||
|
private fun getV2rayCustomConfig(configContext: CoreConfigContext): ConfigResult {
|
||||||
|
val context = configContext.context
|
||||||
|
val raw = MmkvManager.decodeServerRaw(configContext.guid) ?: return ConfigResult(false)
|
||||||
|
val result = ConfigResult(true, configContext.guid, raw)
|
||||||
|
if (!needTun()) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
val json = JsonUtil.parseString(raw)?.takeIf { it.isJsonObject }?.asJsonObject ?: return result
|
||||||
|
|
||||||
|
// Check whether package names need to be replaced with UIDs
|
||||||
|
if (SettingsManager.canUseProcessRouting()) {
|
||||||
|
val rulesJson = json.get("routing")?.takeIf { it.isJsonObject }?.asJsonObject
|
||||||
|
?.get("rules")?.takeIf { it.isJsonArray }?.asJsonArray
|
||||||
|
?: JsonArray()
|
||||||
|
|
||||||
|
for (elem in rulesJson) {
|
||||||
|
val rule = elem.takeIf { it.isJsonObject }?.asJsonObject ?: continue
|
||||||
|
val process = rule.get("process")?.takeIf { it.isJsonArray }?.asJsonArray ?: continue
|
||||||
|
val packages = process.mapNotNull {
|
||||||
|
it.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }?.asString
|
||||||
|
}.takeIf { it.isNotEmpty() } ?: continue
|
||||||
|
val uids = PackageUidResolver.packageNamesToUids(context, packages).takeIf { it.isNotEmpty() } ?: continue
|
||||||
|
|
||||||
|
rule.add("process", JsonArray().apply { uids.forEach { add(it) } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if tun inbound exists
|
||||||
|
val inboundsJson = json.get("inbounds")?.takeIf { it.isJsonArray }?.asJsonArray
|
||||||
|
?: JsonArray().also { json.add("inbounds", it) }
|
||||||
|
val tunNotExists = inboundsJson.none { elem ->
|
||||||
|
elem.isJsonObject && elem.asJsonObject.get("protocol")
|
||||||
|
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
|
||||||
|
?.asString == "tun"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tunNotExists) {
|
||||||
|
// add tun inbound from template
|
||||||
|
initV2rayConfig(configContext)?.let { templateConfig ->
|
||||||
|
templateConfig.inbounds.firstOrNull { it.tag == "tun" }?.let { inboundTun ->
|
||||||
|
inboundTun.settings?.mtu = SettingsManager.getVpnMtu()
|
||||||
|
inboundsJson.add(JsonUtil.parseString(JsonUtil.toJson(inboundTun)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonUtil.toJsonPretty(json)?.let { ConfigResult(true, configContext.guid, it) } ?: result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds full config for policy-group mode. */
|
||||||
|
private fun buildGroupConfig(configContext: CoreConfigContext): V2rayConfig? {
|
||||||
|
val config = configContext.selectedProfile
|
||||||
|
val validConfigs = configContext.resolvedProfiles
|
||||||
|
|
||||||
|
if (validConfigs.isEmpty()) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "All configs are invalid")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val v2rayConfig = initV2rayConfig(configContext) ?: return null
|
||||||
|
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
|
||||||
|
v2rayConfig.remarks = config.remarks
|
||||||
|
|
||||||
|
getInbounds(v2rayConfig)
|
||||||
|
|
||||||
|
v2rayConfig.outbounds.removeAt(0)
|
||||||
|
val outboundsList = mutableListOf<V2rayConfig.OutboundBean>()
|
||||||
|
var index = 0
|
||||||
|
for (item in validConfigs) {
|
||||||
|
index++
|
||||||
|
val outbound = convertProfile2Outbound(item) ?: continue
|
||||||
|
outbound.tag = "proxy-$index-${item.remarks.trim()}"
|
||||||
|
outboundsList.add(outbound)
|
||||||
|
}
|
||||||
|
outboundsList.addAll(v2rayConfig.outbounds)
|
||||||
|
v2rayConfig.outbounds = ArrayList(outboundsList)
|
||||||
|
|
||||||
|
getRouting(configContext, v2rayConfig)
|
||||||
|
getFakeDns(v2rayConfig)
|
||||||
|
getDns(v2rayConfig)
|
||||||
|
getBalance(configContext, v2rayConfig)
|
||||||
|
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED)) {
|
||||||
|
getCustomLocalDns(v2rayConfig)
|
||||||
|
}
|
||||||
|
if (!MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED)) {
|
||||||
|
v2rayConfig.stats = null
|
||||||
|
v2rayConfig.policy = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve and add to DNS Hosts
|
||||||
|
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") == "1") {
|
||||||
|
resolveOutboundDomainsToHosts(v2rayConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
return v2rayConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds full V2Ray config for proxy-chain mode.
|
||||||
|
*
|
||||||
|
* Uses resolvedProfiles as an ordered chain and links each hop
|
||||||
|
* with dialerProxy.
|
||||||
|
*/
|
||||||
|
private fun buildProxyChainConfig(configContext: CoreConfigContext): V2rayConfig? {
|
||||||
|
val config = configContext.selectedProfile
|
||||||
|
val resolvedProfiles = configContext.resolvedProfiles
|
||||||
|
|
||||||
|
val v2rayConfig = initV2rayConfig(configContext) ?: return null
|
||||||
|
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
|
||||||
|
v2rayConfig.remarks = config.remarks
|
||||||
|
|
||||||
|
getInbounds(v2rayConfig)
|
||||||
|
|
||||||
|
// Build and link the whole chain directly from resolvedProfiles.
|
||||||
|
val chainOutbounds = resolvedProfiles.mapNotNull { profile ->
|
||||||
|
convertProfile2Outbound(profile)
|
||||||
|
}.toMutableList()
|
||||||
|
if (chainOutbounds.size < 2) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "Proxy chain requires at least 2 valid profiles, but only ${chainOutbounds.size} found")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
chainOutbounds.forEachIndexed { index, outbound ->
|
||||||
|
outbound.tag = if (index == 0) AppConfig.TAG_PROXY else AppConfig.TAG_PROXY + index
|
||||||
|
}
|
||||||
|
for (index in 0 until chainOutbounds.size - 1) {
|
||||||
|
chainOutbounds[index].ensureSockopt().dialerProxy = chainOutbounds[index + 1].tag
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep built-in outbounds and place the chain before them.
|
||||||
|
val builtinOutbounds = if (v2rayConfig.outbounds.size > 1) {
|
||||||
|
v2rayConfig.outbounds.drop(1)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
v2rayConfig.outbounds = ArrayList(chainOutbounds + builtinOutbounds)
|
||||||
|
|
||||||
|
getRouting(configContext, v2rayConfig)
|
||||||
|
getFakeDns(v2rayConfig)
|
||||||
|
getDns(v2rayConfig)
|
||||||
|
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
|
||||||
|
getCustomLocalDns(v2rayConfig)
|
||||||
|
}
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
|
||||||
|
v2rayConfig.stats = null
|
||||||
|
v2rayConfig.policy = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve and add to DNS Hosts
|
||||||
|
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") == "1") {
|
||||||
|
resolveOutboundDomainsToHosts(v2rayConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
return v2rayConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds full config for normal single-node mode. */
|
||||||
|
private fun buildNormalConfig(configContext: CoreConfigContext): V2rayConfig? {
|
||||||
|
val config = configContext.selectedProfile
|
||||||
|
|
||||||
|
val address = config.server ?: return null
|
||||||
|
if (!Utils.isPureIpAddress(address) && !Utils.isValidUrl(address)) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "$address is an invalid ip or domain")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val v2rayConfig = initV2rayConfig(configContext) ?: return null
|
||||||
|
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
|
||||||
|
v2rayConfig.remarks = config.remarks
|
||||||
|
|
||||||
|
getInbounds(v2rayConfig)
|
||||||
|
getOutbounds(configContext, v2rayConfig) ?: return null
|
||||||
|
getRouting(configContext, v2rayConfig)
|
||||||
|
getFakeDns(v2rayConfig)
|
||||||
|
getDns(v2rayConfig)
|
||||||
|
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
|
||||||
|
getCustomLocalDns(v2rayConfig)
|
||||||
|
}
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
|
||||||
|
v2rayConfig.stats = null
|
||||||
|
v2rayConfig.policy = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve and add to DNS Hosts
|
||||||
|
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") == "1") {
|
||||||
|
resolveOutboundDomainsToHosts(v2rayConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
return v2rayConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes non-essential sections for speedtest use.
|
||||||
|
*/
|
||||||
|
private fun postProcessForSpeedtest(v2rayConfig: V2rayConfig) {
|
||||||
|
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
|
||||||
|
v2rayConfig.inbounds.clear()
|
||||||
|
v2rayConfig.routing.rules.clear()
|
||||||
|
v2rayConfig.dns = null
|
||||||
|
v2rayConfig.fakedns = null
|
||||||
|
v2rayConfig.stats = null
|
||||||
|
v2rayConfig.policy = null
|
||||||
|
v2rayConfig.outbounds.forEach { key -> key.mux = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts a built config object into a unified result payload. */
|
||||||
|
private fun toConfigResult(configContext: CoreConfigContext, v2rayConfig: V2rayConfig): ConfigResult {
|
||||||
|
return ConfigResult(
|
||||||
|
status = true,
|
||||||
|
guid = configContext.guid,
|
||||||
|
content = JsonUtil.toJsonPretty(v2rayConfig) ?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes V2ray configuration.
|
||||||
|
*
|
||||||
|
* This function loads the V2ray configuration from assets or from a cached value.
|
||||||
|
* It first attempts to use the cached configuration if available, otherwise reads
|
||||||
|
* the configuration from the "v2ray_config.json" asset file.
|
||||||
|
*
|
||||||
|
* @param configContext Runtime context used to access app assets and profile data
|
||||||
|
* @return V2rayConfig object parsed from the JSON configuration, or null if the configuration is empty
|
||||||
|
*/
|
||||||
|
private fun initV2rayConfig(configContext: CoreConfigContext): V2rayConfig? {
|
||||||
|
val context = configContext.context
|
||||||
|
var assets = ""
|
||||||
|
if (needTun()) {
|
||||||
|
assets = initConfigCacheWithTun ?: Utils.readTextFromAssets(context, "v2ray_config_with_tun.json")
|
||||||
|
if (TextUtils.isEmpty(assets)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
initConfigCacheWithTun = assets
|
||||||
|
} else {
|
||||||
|
assets = initConfigCache ?: Utils.readTextFromAssets(context, "v2ray_config.json")
|
||||||
|
if (TextUtils.isEmpty(assets)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
initConfigCache = assets
|
||||||
|
}
|
||||||
|
val config = JsonUtil.fromJson(assets, V2rayConfig::class.java)
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
|
||||||
|
//region some sub function
|
||||||
|
|
||||||
|
private fun needTun(): Boolean {
|
||||||
|
return SettingsManager.isVpnMode() && !SettingsManager.isUsingHevTun()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures the inbound settings for V2ray.
|
||||||
|
*
|
||||||
|
* This function sets up the listening ports, sniffing options, and other inbound-related configurations.
|
||||||
|
*
|
||||||
|
* @param v2rayConfig The V2ray configuration object to be modified
|
||||||
|
* @return true if inbound configuration was successful, false otherwise
|
||||||
|
*/
|
||||||
|
private fun getInbounds(v2rayConfig: V2rayConfig): Boolean {
|
||||||
|
try {
|
||||||
|
val vpn = SettingsManager.isVpnMode()
|
||||||
|
val useHev = SettingsManager.isUsingHevTun()
|
||||||
|
val forcedByHev = vpn && useHev
|
||||||
|
|
||||||
|
val enableLocalProxy = forcedByHev || MmkvManager.decodeSettingsBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true)
|
||||||
|
|
||||||
|
val socksPort = SettingsManager.getSocksPort()
|
||||||
|
val socksUsername = SettingsManager.getSocksUsername()
|
||||||
|
val socksPassword = SettingsManager.getSocksPassword()
|
||||||
|
val inbound1 = v2rayConfig.inbounds[0]
|
||||||
|
if (inbound1.settings == null) {
|
||||||
|
inbound1.settings = V2rayConfig.InboundBean.InSettingsBean()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING) != true) {
|
||||||
|
inbound1.listen = AppConfig.LOOPBACK
|
||||||
|
}
|
||||||
|
inbound1.port = socksPort
|
||||||
|
inbound1.settings?.udp = MmkvManager.decodeSettingsBool(AppConfig.PREF_SOCKS_ENABLE_UDP, true)
|
||||||
|
if (socksUsername != null && socksPassword != null) {
|
||||||
|
inbound1.settings?.auth = "password"
|
||||||
|
inbound1.settings?.accounts = listOf(
|
||||||
|
V2rayConfig.InboundBean.InSettingsBean.SocksAccountBean(
|
||||||
|
user = socksUsername,
|
||||||
|
pass = socksPassword
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
inbound1.settings?.auth = "noauth"
|
||||||
|
inbound1.settings?.accounts = null
|
||||||
|
}
|
||||||
|
val fakedns = MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
|
||||||
|
val sniffAllTlsAndHttp =
|
||||||
|
MmkvManager.decodeSettingsBool(AppConfig.PREF_SNIFFING_ENABLED, true) != false
|
||||||
|
inbound1.sniffing?.enabled = fakedns || sniffAllTlsAndHttp
|
||||||
|
inbound1.sniffing?.routeOnly =
|
||||||
|
MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false)
|
||||||
|
if (!sniffAllTlsAndHttp) {
|
||||||
|
inbound1.sniffing?.destOverride?.clear()
|
||||||
|
}
|
||||||
|
if (fakedns) {
|
||||||
|
inbound1.sniffing?.destOverride?.add("fakedns")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Utils.isXray()) {
|
||||||
|
val inbound2 = JsonUtil.fromJson(JsonUtil.toJson(inbound1), V2rayConfig.InboundBean::class.java) ?: return false
|
||||||
|
inbound2.tag = EConfigType.HTTP.name.lowercase()
|
||||||
|
inbound2.port = SettingsManager.getHttpPort()
|
||||||
|
inbound2.protocol = EConfigType.HTTP.name.lowercase()
|
||||||
|
inbound2.settings?.auth = null
|
||||||
|
inbound2.settings?.udp = null
|
||||||
|
v2rayConfig.inbounds.add(inbound2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enableLocalProxy) {
|
||||||
|
v2rayConfig.inbounds.removeIf { it.protocol == "socks" || it.protocol == "http" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needTun()) {
|
||||||
|
val inboundTun = v2rayConfig.inbounds.firstOrNull { e -> e.tag == "tun" }
|
||||||
|
inboundTun?.settings?.mtu = SettingsManager.getVpnMtu()
|
||||||
|
inboundTun?.sniffing = inbound1.sniffing
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to configure inbounds", e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures the fake DNS settings if enabled.
|
||||||
|
*
|
||||||
|
* Adds FakeDNS configuration to v2rayConfig if both local DNS and fake DNS are enabled.
|
||||||
|
*
|
||||||
|
* @param v2rayConfig The V2ray configuration object to be modified
|
||||||
|
*/
|
||||||
|
private fun getFakeDns(v2rayConfig: V2rayConfig) {
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true
|
||||||
|
&& MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
|
||||||
|
) {
|
||||||
|
v2rayConfig.fakedns = listOf(V2rayConfig.FakednsBean())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects custom outbound profiles into the V2ray configuration.
|
||||||
|
* Uses pre-resolved profiles from the context instead of re-parsing.
|
||||||
|
*
|
||||||
|
* @param v2rayConfig The V2ray configuration to modify
|
||||||
|
* @param customOutbounds Pre-resolved custom outbound profiles (tag -> ProfileItem mapping)
|
||||||
|
*/
|
||||||
|
private fun injectCustomOutbounds(v2rayConfig: V2rayConfig, customOutbounds: Map<String, ProfileItem>) {
|
||||||
|
val existingTags = v2rayConfig.outbounds.mapTo(mutableSetOf()) { it.tag }
|
||||||
|
|
||||||
|
customOutbounds.forEach { (tag, profile) ->
|
||||||
|
if (tag in existingTags) return@forEach
|
||||||
|
try {
|
||||||
|
val outbound = convertProfile2Outbound(profile) ?: run {
|
||||||
|
LogUtil.w(AppConfig.TAG, "Could not convert profile '$tag' to outbound, skipping")
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
outbound.tag = tag
|
||||||
|
v2rayConfig.outbounds.add(outbound)
|
||||||
|
existingTags.add(tag)
|
||||||
|
LogUtil.d(AppConfig.TAG, "Injected custom outbound: tag='$tag'")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to inject custom outbound for tag '$tag', skipping", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures routing settings for V2ray.
|
||||||
|
*
|
||||||
|
* Sets up the domain strategy, injects custom outbounds from rulesets, and adds routing rules.
|
||||||
|
*
|
||||||
|
* @param configContext Configuration context with custom outbound profiles
|
||||||
|
* @param v2rayConfig The V2ray configuration object to be modified
|
||||||
|
* @return true if routing configuration was successful, false otherwise
|
||||||
|
*/
|
||||||
|
private fun getRouting(configContext: CoreConfigContext, v2rayConfig: V2rayConfig): Boolean {
|
||||||
|
try {
|
||||||
|
|
||||||
|
v2rayConfig.routing.domainStrategy =
|
||||||
|
MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY)
|
||||||
|
?: "AsIs"
|
||||||
|
|
||||||
|
// Inject custom outbound profiles from routing rulesets
|
||||||
|
injectCustomOutbounds(v2rayConfig, configContext.customOutboundProfiles)
|
||||||
|
|
||||||
|
val rulesetItems = MmkvManager.decodeRoutingRulesets()
|
||||||
|
rulesetItems?.forEach { key ->
|
||||||
|
getRoutingUserRule(configContext, key, v2rayConfig)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to configure routing", e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a specific ruleset item to the routing configuration.
|
||||||
|
*
|
||||||
|
* @param configContext Runtime context used by routing helpers
|
||||||
|
* @param item The ruleset item to add
|
||||||
|
* @param v2rayConfig The V2ray configuration object to be modified
|
||||||
|
*/
|
||||||
|
private fun getRoutingUserRule(configContext: CoreConfigContext, item: RulesetItem?, v2rayConfig: V2rayConfig) {
|
||||||
|
val context = configContext.context
|
||||||
|
try {
|
||||||
|
if (item == null || !item.enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val rule = JsonUtil.fromJson(JsonUtil.toJson(item), V2rayConfig.RoutingBean.RulesBean::class.java) ?: return
|
||||||
|
|
||||||
|
// Replace specific geoip rules with ext versions
|
||||||
|
rule.ip?.let { ipList ->
|
||||||
|
val updatedIpList = ArrayList<String>()
|
||||||
|
ipList.forEach { ip ->
|
||||||
|
when (ip) {
|
||||||
|
AppConfig.GEOIP_CN -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:cn")
|
||||||
|
AppConfig.GEOIP_PRIVATE -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:private")
|
||||||
|
else -> updatedIpList.add(ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.ip = updatedIpList
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SettingsManager.canUseProcessRouting()) {
|
||||||
|
// Convert process package names to UIDs
|
||||||
|
rule.process?.let { processList ->
|
||||||
|
if (processList.isNotEmpty()) {
|
||||||
|
val uids = PackageUidResolver.packageNamesToUids(context, processList)
|
||||||
|
rule.process = uids.ifEmpty { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rule.process = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the outbound tag is a custom one that failed to inject, fall back to proxy
|
||||||
|
val outboundTag = rule.outboundTag
|
||||||
|
if (!outboundTag.isNullOrBlank()
|
||||||
|
&& outboundTag !in AppConfig.BUILTIN_OUTBOUND_TAGS
|
||||||
|
&& v2rayConfig.outbounds.none { it.tag == outboundTag }
|
||||||
|
) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "Outbound tag '$outboundTag' not found, falling back to '${AppConfig.TAG_PROXY}'")
|
||||||
|
rule.outboundTag = AppConfig.TAG_PROXY
|
||||||
|
}
|
||||||
|
|
||||||
|
v2rayConfig.routing.rules.add(rule)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to apply routing user rule", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves domain rules for a specific outbound tag.
|
||||||
|
*
|
||||||
|
* Searches through all rulesets to find domains targeting the specified tag.
|
||||||
|
*
|
||||||
|
* @param tag The outbound tag to search for
|
||||||
|
* @return ArrayList of domain rules matching the tag
|
||||||
|
*/
|
||||||
|
private fun getUserRule2Domain(tag: String): ArrayList<String> {
|
||||||
|
val domain = ArrayList<String>()
|
||||||
|
|
||||||
|
val rulesetItems = MmkvManager.decodeRoutingRulesets()
|
||||||
|
rulesetItems?.forEach { key ->
|
||||||
|
if (key.enabled && key.outboundTag == tag && !key.domain.isNullOrEmpty()) {
|
||||||
|
key.domain?.forEach {
|
||||||
|
domain.add(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves domain rules for custom outbound tags.
|
||||||
|
*
|
||||||
|
* Searches through all rulesets to find domains targeting any custom outbound tags.
|
||||||
|
*
|
||||||
|
* @return ArrayList of domain rules matching custom outbound tags
|
||||||
|
*/
|
||||||
|
private fun getCustomOutboundUserRule2Domain(): ArrayList<String> {
|
||||||
|
val domain = ArrayList<String>()
|
||||||
|
|
||||||
|
val rulesetItems = MmkvManager.decodeRoutingRulesets()
|
||||||
|
rulesetItems?.forEach { key ->
|
||||||
|
if (key.enabled && !AppConfig.BUILTIN_OUTBOUND_TAGS.contains(key.outboundTag)
|
||||||
|
&& !key.domain.isNullOrEmpty()
|
||||||
|
) {
|
||||||
|
key.domain?.forEach {
|
||||||
|
domain.add(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures custom local DNS settings.
|
||||||
|
*
|
||||||
|
* Sets up DNS inbound, outbound, and routing rules for local DNS resolution.
|
||||||
|
*
|
||||||
|
* @param v2rayConfig The V2ray configuration object to be modified
|
||||||
|
* @return true if custom local DNS configuration was successful, false otherwise
|
||||||
|
*/
|
||||||
|
private fun getCustomLocalDns(v2rayConfig: V2rayConfig): Boolean {
|
||||||
|
try {
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true) {
|
||||||
|
val geositeCn = arrayListOf(AppConfig.GEOSITE_CN)
|
||||||
|
val proxyDomain = getUserRule2Domain(AppConfig.TAG_PROXY)
|
||||||
|
val directDomain = getUserRule2Domain(AppConfig.TAG_DIRECT)
|
||||||
|
val finalDomain = geositeCn.plus(proxyDomain).plus(directDomain).distinct()
|
||||||
|
// fakedns with all domains to make it always top priority
|
||||||
|
v2rayConfig.dns?.servers?.add(
|
||||||
|
0,
|
||||||
|
V2rayConfig.DnsBean.ServersBean(
|
||||||
|
address = "fakedns",
|
||||||
|
domains = finalDomain
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SettingsManager.isVpnMode()) {
|
||||||
|
if (SettingsManager.isUsingHevTun()) {
|
||||||
|
//hev-socks5-tunnel dns routing
|
||||||
|
v2rayConfig.routing.rules.add(
|
||||||
|
0, V2rayConfig.RoutingBean.RulesBean(
|
||||||
|
inboundTag = arrayListOf("socks"),
|
||||||
|
outboundTag = "dns-out",
|
||||||
|
port = "53",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
v2rayConfig.routing.rules.add(
|
||||||
|
0, V2rayConfig.RoutingBean.RulesBean(
|
||||||
|
inboundTag = arrayListOf("tun"),
|
||||||
|
outboundTag = "dns-out",
|
||||||
|
port = "53",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS outbound
|
||||||
|
if (v2rayConfig.outbounds.none { e -> e.protocol == "dns" && e.tag == "dns-out" }) {
|
||||||
|
v2rayConfig.outbounds.add(
|
||||||
|
V2rayConfig.OutboundBean(
|
||||||
|
protocol = "dns",
|
||||||
|
tag = "dns-out",
|
||||||
|
settings = null,
|
||||||
|
streamSettings = null,
|
||||||
|
mux = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to configure custom local DNS", e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures the DNS settings for V2ray.
|
||||||
|
*
|
||||||
|
* Sets up DNS servers, hosts, and routing rules for DNS resolution.
|
||||||
|
*
|
||||||
|
* @param v2rayConfig The V2ray configuration object to be modified
|
||||||
|
* @return true if DNS configuration was successful, false otherwise
|
||||||
|
*/
|
||||||
|
private fun getDns(v2rayConfig: V2rayConfig): Boolean {
|
||||||
|
try {
|
||||||
|
val hosts = mutableMapOf<String, Any>()
|
||||||
|
val servers = ArrayList<Any>()
|
||||||
|
|
||||||
|
//remote Dns
|
||||||
|
val remoteDns = SettingsManager.getRemoteDnsServers()
|
||||||
|
val proxyDomain = (getUserRule2Domain(AppConfig.TAG_PROXY) + getCustomOutboundUserRule2Domain()).distinct()
|
||||||
|
remoteDns.forEach {
|
||||||
|
servers.add(it)
|
||||||
|
}
|
||||||
|
if (proxyDomain.isNotEmpty()) {
|
||||||
|
servers.add(
|
||||||
|
V2rayConfig.DnsBean.ServersBean(
|
||||||
|
address = remoteDns.first(),
|
||||||
|
domains = proxyDomain,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// domestic DNS
|
||||||
|
val domesticDns = SettingsManager.getDomesticDnsServers()
|
||||||
|
val directDomain = getUserRule2Domain(AppConfig.TAG_DIRECT)
|
||||||
|
val isCnRoutingMode = directDomain.contains(AppConfig.GEOSITE_CN)
|
||||||
|
val cnRegionFilter = { domain: String ->
|
||||||
|
domain.startsWith("geosite:") && (domain.endsWith("-cn") || domain.endsWith("@cn"))
|
||||||
|
|| domain == AppConfig.GEOSITE_CN
|
||||||
|
}
|
||||||
|
val finalDirectDomain = if (isCnRoutingMode) directDomain.filterNot {
|
||||||
|
cnRegionFilter(it)
|
||||||
|
} else directDomain
|
||||||
|
val domesticDnsTags = mutableListOf<String>()
|
||||||
|
domesticDns.forEachIndexed { index, element ->
|
||||||
|
val tag = AppConfig.TAG_DOMESTIC_DNS + index
|
||||||
|
servers.add(
|
||||||
|
V2rayConfig.DnsBean.ServersBean(
|
||||||
|
address = element,
|
||||||
|
domains = finalDirectDomain,
|
||||||
|
skipFallback = true,
|
||||||
|
tag = tag
|
||||||
|
)
|
||||||
|
)
|
||||||
|
domesticDnsTags.add(tag)
|
||||||
|
}
|
||||||
|
if (isCnRoutingMode) {
|
||||||
|
val geoipCn = arrayListOf(AppConfig.GEOIP_CN)
|
||||||
|
val cnRegionDomain = directDomain.filter { cnRegionFilter(it) }
|
||||||
|
domesticDns.forEachIndexed { index, element ->
|
||||||
|
val geositeCnDnsTag = AppConfig.TAG_DOMESTIC_DNS + index + "_cn_expect"
|
||||||
|
servers.add(
|
||||||
|
V2rayConfig.DnsBean.ServersBean(
|
||||||
|
address = element,
|
||||||
|
domains = cnRegionDomain,
|
||||||
|
expectIPs = geoipCn,
|
||||||
|
skipFallback = true,
|
||||||
|
tag = geositeCnDnsTag
|
||||||
|
)
|
||||||
|
)
|
||||||
|
domesticDnsTags.add(geositeCnDnsTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//block dns
|
||||||
|
val blkDomain = getUserRule2Domain(AppConfig.TAG_BLOCKED)
|
||||||
|
if (blkDomain.isNotEmpty()) {
|
||||||
|
hosts.putAll(blkDomain.map { it to AppConfig.LOOPBACK })
|
||||||
|
}
|
||||||
|
|
||||||
|
// hardcode googleapi rule to fix play store problems
|
||||||
|
hosts[AppConfig.GOOGLEAPIS_CN_DOMAIN] = AppConfig.GOOGLEAPIS_COM_DOMAIN
|
||||||
|
|
||||||
|
// hardcode popular Android Private DNS rule to fix localhost DNS problem
|
||||||
|
hosts[AppConfig.DNS_ALIDNS_DOMAIN] = AppConfig.DNS_ALIDNS_ADDRESSES
|
||||||
|
hosts[AppConfig.DNS_CLOUDFLARE_ONE_DOMAIN] = AppConfig.DNS_CLOUDFLARE_ONE_ADDRESSES
|
||||||
|
hosts[AppConfig.DNS_CLOUDFLARE_DNS_COM_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_COM_ADDRESSES
|
||||||
|
hosts[AppConfig.DNS_CLOUDFLARE_DNS_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_ADDRESSES
|
||||||
|
hosts[AppConfig.DNS_DNSPOD_DOMAIN] = AppConfig.DNS_DNSPOD_ADDRESSES
|
||||||
|
hosts[AppConfig.DNS_GOOGLE_DOMAIN] = AppConfig.DNS_GOOGLE_ADDRESSES
|
||||||
|
hosts[AppConfig.DNS_QUAD9_DOMAIN] = AppConfig.DNS_QUAD9_ADDRESSES
|
||||||
|
hosts[AppConfig.DNS_YANDEX_DOMAIN] = AppConfig.DNS_YANDEX_ADDRESSES
|
||||||
|
|
||||||
|
//User DNS hosts
|
||||||
|
try {
|
||||||
|
val userHosts = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
|
||||||
|
if (userHosts.isNotNullEmpty()) {
|
||||||
|
var userHostsMap = userHosts?.split(",")
|
||||||
|
?.filter { it.isNotEmpty() }
|
||||||
|
?.filter { it.contains(":") }
|
||||||
|
?.associate { it.split(":").let { (k, v) -> k to v } }
|
||||||
|
if (userHostsMap != null) hosts.putAll(userHostsMap)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to configure user DNS hosts", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS dns
|
||||||
|
v2rayConfig.dns = V2rayConfig.DnsBean(
|
||||||
|
servers = servers,
|
||||||
|
hosts = hosts,
|
||||||
|
tag = AppConfig.TAG_DNS,
|
||||||
|
enableParallelQuery = if ((domesticDns.size + remoteDns.size) > 2) true else null
|
||||||
|
)
|
||||||
|
|
||||||
|
// DNS routing
|
||||||
|
v2rayConfig.routing.rules.add(
|
||||||
|
V2rayConfig.RoutingBean.RulesBean(
|
||||||
|
outboundTag = AppConfig.TAG_DIRECT,
|
||||||
|
inboundTag = domesticDnsTags,
|
||||||
|
domain = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
v2rayConfig.routing.rules.add(
|
||||||
|
V2rayConfig.RoutingBean.RulesBean(
|
||||||
|
outboundTag = AppConfig.TAG_PROXY,
|
||||||
|
inboundTag = arrayListOf(AppConfig.TAG_DNS),
|
||||||
|
domain = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to configure DNS", e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
|
||||||
|
//region outbound related functions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures the primary outbound connection.
|
||||||
|
*
|
||||||
|
* Converts the profile to an outbound configuration and applies global settings.
|
||||||
|
*
|
||||||
|
* @param configContext Runtime context containing the selected profile
|
||||||
|
* @param v2rayConfig The V2ray configuration object to be modified
|
||||||
|
* @return true if outbound configuration was successful, null if there was an error
|
||||||
|
*/
|
||||||
|
private fun getOutbounds(configContext: CoreConfigContext, v2rayConfig: V2rayConfig): Boolean? {
|
||||||
|
val outbound = convertProfile2Outbound(configContext.selectedProfile) ?: return null
|
||||||
|
|
||||||
|
if (v2rayConfig.outbounds.isNotEmpty()) {
|
||||||
|
v2rayConfig.outbounds[0] = outbound
|
||||||
|
} else {
|
||||||
|
v2rayConfig.outbounds.add(outbound)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures load balancing settings for the V2ray configuration.
|
||||||
|
*
|
||||||
|
* @param configContext Runtime context containing policy group settings
|
||||||
|
* @param v2rayConfig The V2ray configuration object to be modified with balancing settings
|
||||||
|
*/
|
||||||
|
private fun getBalance(configContext: CoreConfigContext, v2rayConfig: V2rayConfig) {
|
||||||
|
val config = configContext.selectedProfile
|
||||||
|
try {
|
||||||
|
v2rayConfig.routing.rules.forEach { rule ->
|
||||||
|
if (rule.outboundTag == AppConfig.TAG_PROXY) {
|
||||||
|
rule.outboundTag = null
|
||||||
|
rule.balancerTag = AppConfig.TAG_BALANCER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val lstSelector = listOf("proxy-")
|
||||||
|
when (config.policyGroupType) {
|
||||||
|
// Least Ping goto else
|
||||||
|
"1" -> {
|
||||||
|
// Least Load
|
||||||
|
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
||||||
|
tag = AppConfig.TAG_BALANCER,
|
||||||
|
selector = lstSelector,
|
||||||
|
strategy = V2rayConfig.RoutingBean.StrategyObject(
|
||||||
|
type = "leastLoad"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
v2rayConfig.routing.balancers = listOf(balancer)
|
||||||
|
v2rayConfig.burstObservatory = V2rayConfig.BurstObservatoryObject(
|
||||||
|
subjectSelector = lstSelector,
|
||||||
|
pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject(
|
||||||
|
destination = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
|
||||||
|
interval = "5m",
|
||||||
|
sampling = 2,
|
||||||
|
timeout = "30s"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"2" -> {
|
||||||
|
// Random
|
||||||
|
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
||||||
|
tag = AppConfig.TAG_BALANCER,
|
||||||
|
selector = lstSelector,
|
||||||
|
strategy = V2rayConfig.RoutingBean.StrategyObject(
|
||||||
|
type = "random"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
v2rayConfig.routing.balancers = listOf(balancer)
|
||||||
|
}
|
||||||
|
|
||||||
|
"3" -> {
|
||||||
|
// Round Robin
|
||||||
|
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
||||||
|
tag = AppConfig.TAG_BALANCER,
|
||||||
|
selector = lstSelector,
|
||||||
|
strategy = V2rayConfig.RoutingBean.StrategyObject(
|
||||||
|
type = "roundRobin"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
v2rayConfig.routing.balancers = listOf(balancer)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
// Default: Least Ping
|
||||||
|
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
||||||
|
tag = AppConfig.TAG_BALANCER,
|
||||||
|
selector = lstSelector,
|
||||||
|
strategy = V2rayConfig.RoutingBean.StrategyObject(
|
||||||
|
type = "leastPing"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
v2rayConfig.routing.balancers = listOf(balancer)
|
||||||
|
v2rayConfig.observatory = V2rayConfig.ObservatoryObject(
|
||||||
|
subjectSelector = lstSelector,
|
||||||
|
probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL,
|
||||||
|
probeInterval = "3m",
|
||||||
|
enableConcurrency = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v2rayConfig.routing.domainStrategy == "IPIfNonMatch") {
|
||||||
|
v2rayConfig.routing.rules.add(
|
||||||
|
V2rayConfig.RoutingBean.RulesBean(
|
||||||
|
ip = arrayListOf("0.0.0.0/0", "::/0"),
|
||||||
|
balancerTag = AppConfig.TAG_BALANCER,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
v2rayConfig.routing.rules.add(
|
||||||
|
V2rayConfig.RoutingBean.RulesBean(
|
||||||
|
network = "tcp,udp",
|
||||||
|
balancerTag = AppConfig.TAG_BALANCER,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to configure balance", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves domain names to IP addresses in outbound connections.
|
||||||
|
*
|
||||||
|
* Pre-resolves domains to improve connection speed and reliability.
|
||||||
|
*
|
||||||
|
* @param v2rayConfig The V2ray configuration object to be modified
|
||||||
|
*/
|
||||||
|
private fun resolveOutboundDomainsToHosts(v2rayConfig: V2rayConfig) {
|
||||||
|
val proxyOutboundList = v2rayConfig.getAllProxyOutbound()
|
||||||
|
val dns = v2rayConfig.dns ?: return
|
||||||
|
val newHosts = dns.hosts?.toMutableMap() ?: mutableMapOf()
|
||||||
|
val preferIpv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true
|
||||||
|
|
||||||
|
for (item in proxyOutboundList) {
|
||||||
|
val domain = item.getServerAddress()
|
||||||
|
if (domain.isNullOrEmpty()) continue
|
||||||
|
|
||||||
|
if (newHosts.containsKey(domain)) {
|
||||||
|
item.ensureSockopt().domainStrategy = "UseIP"
|
||||||
|
item.ensureSockopt().happyEyeballs = V2rayConfig.OutboundBean.StreamSettingsBean.HappyEyeballsBean(
|
||||||
|
prioritizeIPv6 = preferIpv6,
|
||||||
|
interleave = 2
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val resolvedIps = HttpUtil.resolveHostToIP(domain, preferIpv6)
|
||||||
|
if (resolvedIps.isNullOrEmpty()) continue
|
||||||
|
|
||||||
|
item.ensureSockopt().domainStrategy = "UseIP"
|
||||||
|
item.ensureSockopt().happyEyeballs = V2rayConfig.OutboundBean.StreamSettingsBean.HappyEyeballsBean(
|
||||||
|
prioritizeIPv6 = preferIpv6,
|
||||||
|
interleave = 2
|
||||||
|
)
|
||||||
|
newHosts[domain] = if (resolvedIps.size == 1) {
|
||||||
|
resolvedIps[0]
|
||||||
|
} else {
|
||||||
|
resolvedIps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dns.hosts = newHosts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a profile item to an outbound configuration.
|
||||||
|
*
|
||||||
|
* Delegates to [CoreOutboundBuilder] which owns all per-protocol
|
||||||
|
* conversion logic, keeping this manager focused on config orchestration.
|
||||||
|
*
|
||||||
|
* @param profileItem The profile item to convert
|
||||||
|
* @return OutboundBean configuration for the profile, or null if not supported
|
||||||
|
*/
|
||||||
|
private fun convertProfile2Outbound(profileItem: ProfileItem): V2rayConfig.OutboundBean? {
|
||||||
|
return CoreOutboundBuilder.convert(profileItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
//endregion
|
||||||
|
}
|
||||||
+10
-10
@@ -1,8 +1,8 @@
|
|||||||
package com.v2ray.ang.handler
|
package com.v2ray.ang.core
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import go.Seq
|
import go.Seq
|
||||||
import libv2ray.CoreCallbackHandler
|
import libv2ray.CoreCallbackHandler
|
||||||
@@ -16,7 +16,7 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|||||||
* Thread-safe singleton wrapper for Libv2ray native methods.
|
* Thread-safe singleton wrapper for Libv2ray native methods.
|
||||||
* Provides initialization protection and unified API for V2Ray core operations.
|
* Provides initialization protection and unified API for V2Ray core operations.
|
||||||
*/
|
*/
|
||||||
object V2RayNativeManager {
|
object CoreNativeManager {
|
||||||
private val initialized = AtomicBoolean(false)
|
private val initialized = AtomicBoolean(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,14 +32,14 @@ object V2RayNativeManager {
|
|||||||
val assetPath = Utils.userAssetPath(context)
|
val assetPath = Utils.userAssetPath(context)
|
||||||
val deviceId = Utils.getDeviceIdForXUDPBaseKey()
|
val deviceId = Utils.getDeviceIdForXUDPBaseKey()
|
||||||
Libv2ray.initCoreEnv(assetPath, deviceId)
|
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) {
|
} 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)
|
initialized.set(false)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Log.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping")
|
LogUtil.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ object V2RayNativeManager {
|
|||||||
return try {
|
return try {
|
||||||
Libv2ray.checkVersionX()
|
Libv2ray.checkVersionX()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to check V2Ray version", e)
|
LogUtil.e(AppConfig.TAG, "Failed to check V2Ray version", e)
|
||||||
"Unknown"
|
"Unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ object V2RayNativeManager {
|
|||||||
return try {
|
return try {
|
||||||
Libv2ray.measureOutboundDelay(config, testUrl)
|
Libv2ray.measureOutboundDelay(config, testUrl)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to measure outbound delay", e)
|
LogUtil.e(AppConfig.TAG, "Failed to measure outbound delay", e)
|
||||||
-1L
|
-1L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,8 +84,8 @@ object V2RayNativeManager {
|
|||||||
return try {
|
return try {
|
||||||
Libv2ray.newCoreController(handler)
|
Libv2ray.newCoreController(handler)
|
||||||
} catch (e: Exception) {
|
} 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
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
package com.v2ray.ang.core
|
||||||
|
|
||||||
|
import android.text.TextUtils
|
||||||
|
import com.google.gson.JsonArray
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.enums.NetworkType
|
||||||
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.util.HttpUtil
|
||||||
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
import com.v2ray.ang.util.Utils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralizes ProfileItem -> OutboundBean conversion.
|
||||||
|
* Most protocol builders mirror the previous *Fmt.toOutbound behavior.
|
||||||
|
*/
|
||||||
|
object CoreOutboundBuilder {
|
||||||
|
|
||||||
|
/** Dispatches a profile to protocol-specific outbound builder. */
|
||||||
|
fun convert(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outbound = when (profileItem.configType) {
|
||||||
|
EConfigType.VMESS -> toOutboundVmess(profileItem)
|
||||||
|
EConfigType.SHADOWSOCKS -> toOutboundShadowsocks(profileItem)
|
||||||
|
EConfigType.SOCKS -> toOutboundSocks(profileItem)
|
||||||
|
EConfigType.VLESS -> toOutboundVless(profileItem)
|
||||||
|
EConfigType.TROJAN -> toOutboundTrojan(profileItem)
|
||||||
|
EConfigType.WIREGUARD -> toOutboundWireguard(profileItem)
|
||||||
|
EConfigType.HYSTERIA2 -> toOutboundHysteria2(profileItem)
|
||||||
|
EConfigType.HTTP -> toOutboundHttp(profileItem)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
outbound ?: return null
|
||||||
|
val ret = updateOutboundWithGlobalSettings(outbound)
|
||||||
|
if (!ret) return null
|
||||||
|
return outbound
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies global outbound options (mux, protocol-specific tweaks, etc.). */
|
||||||
|
private fun updateOutboundWithGlobalSettings(outbound: OutboundBean): Boolean {
|
||||||
|
try {
|
||||||
|
var muxEnabled = MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false)
|
||||||
|
val protocol = outbound.protocol
|
||||||
|
if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||||
|
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||||
|
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||||
|
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||||
|
|| protocol.equals(EConfigType.WIREGUARD.name, true)
|
||||||
|
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||||
|
|| protocol.equals(EConfigType.HYSTERIA.name, true)
|
||||||
|
) {
|
||||||
|
muxEnabled = false
|
||||||
|
} else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) {
|
||||||
|
muxEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (muxEnabled) {
|
||||||
|
outbound.mux?.enabled = true
|
||||||
|
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
|
||||||
|
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
|
||||||
|
outbound.mux?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
|
||||||
|
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.vnext?.first()?.users?.first()?.flow?.isNotEmpty() == true) {
|
||||||
|
outbound.mux?.concurrency = -1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
outbound.mux?.enabled = false
|
||||||
|
outbound.mux?.concurrency = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
||||||
|
var localTunAddr = if (outbound.settings?.address == null) {
|
||||||
|
listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4)
|
||||||
|
} else {
|
||||||
|
outbound.settings?.address as List<*>
|
||||||
|
}
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) != true) {
|
||||||
|
localTunAddr = listOf(localTunAddr.first())
|
||||||
|
}
|
||||||
|
outbound.settings?.address = localTunAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outbound.streamSettings?.network == AppConfig.DEFAULT_NETWORK
|
||||||
|
&& outbound.streamSettings?.tcpSettings?.header?.type == AppConfig.HEADER_TYPE_HTTP
|
||||||
|
) {
|
||||||
|
val path = outbound.streamSettings?.tcpSettings?.header?.request?.path
|
||||||
|
val host = outbound.streamSettings?.tcpSettings?.header?.request?.headers?.Host
|
||||||
|
|
||||||
|
val requestString: String by lazy {
|
||||||
|
"""{"version":"1.1","method":"GET","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.122 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate"],"Connection":["keep-alive"],"Pragma":["no-cache"]}}"""
|
||||||
|
}
|
||||||
|
outbound.streamSettings?.tcpSettings?.header?.request = JsonUtil.fromJson(
|
||||||
|
requestString,
|
||||||
|
OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean::class.java
|
||||||
|
)
|
||||||
|
outbound.streamSettings?.tcpSettings?.header?.request?.path =
|
||||||
|
if (path.isNullOrEmpty()) {
|
||||||
|
listOf("/")
|
||||||
|
} else {
|
||||||
|
path
|
||||||
|
}
|
||||||
|
outbound.streamSettings?.tcpSettings?.header?.request?.headers?.Host = host
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to update outbound with global settings", e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates an initial outbound template for a protocol type. */
|
||||||
|
fun createInitOutbound(configType: EConfigType): OutboundBean? {
|
||||||
|
return when (configType) {
|
||||||
|
EConfigType.VMESS,
|
||||||
|
EConfigType.VLESS ->
|
||||||
|
return OutboundBean(
|
||||||
|
protocol = configType.name.lowercase(),
|
||||||
|
settings = OutboundBean.OutSettingsBean(
|
||||||
|
vnext = listOf(
|
||||||
|
OutboundBean.OutSettingsBean.VnextBean(
|
||||||
|
users = listOf(OutboundBean.OutSettingsBean.VnextBean.UsersBean())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
streamSettings = OutboundBean.StreamSettingsBean()
|
||||||
|
)
|
||||||
|
|
||||||
|
EConfigType.SHADOWSOCKS,
|
||||||
|
EConfigType.SOCKS,
|
||||||
|
EConfigType.HTTP,
|
||||||
|
EConfigType.TROJAN ->
|
||||||
|
return OutboundBean(
|
||||||
|
protocol = configType.name.lowercase(),
|
||||||
|
settings = OutboundBean.OutSettingsBean(
|
||||||
|
servers = listOf(OutboundBean.OutSettingsBean.ServersBean())
|
||||||
|
),
|
||||||
|
streamSettings = OutboundBean.StreamSettingsBean()
|
||||||
|
)
|
||||||
|
|
||||||
|
EConfigType.WIREGUARD ->
|
||||||
|
return OutboundBean(
|
||||||
|
protocol = configType.name.lowercase(),
|
||||||
|
settings = OutboundBean.OutSettingsBean(
|
||||||
|
secretKey = "",
|
||||||
|
peers = listOf(OutboundBean.OutSettingsBean.WireGuardBean())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
EConfigType.HYSTERIA,
|
||||||
|
EConfigType.HYSTERIA2 ->
|
||||||
|
return OutboundBean(
|
||||||
|
protocol = EConfigType.HYSTERIA.name.lowercase(),
|
||||||
|
settings = OutboundBean.OutSettingsBean(
|
||||||
|
servers = null
|
||||||
|
),
|
||||||
|
streamSettings = OutboundBean.StreamSettingsBean()
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-protocol builders — implementations are identical to each *Fmt.toOutbound ──
|
||||||
|
|
||||||
|
private fun toOutboundVmess(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outboundBean = createInitOutbound(EConfigType.VMESS)
|
||||||
|
|
||||||
|
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||||
|
vnext.address = getServerAddress(profileItem)
|
||||||
|
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
||||||
|
vnext.users[0].id = profileItem.password.orEmpty()
|
||||||
|
vnext.users[0].security = profileItem.method
|
||||||
|
}
|
||||||
|
|
||||||
|
val sni = outboundBean?.streamSettings?.let {
|
||||||
|
populateTransportSettings(it, profileItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
outboundBean?.streamSettings?.let {
|
||||||
|
populateTlsSettings(it, profileItem, sni)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outboundBean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toOutboundVless(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outboundBean = createInitOutbound(EConfigType.VLESS)
|
||||||
|
|
||||||
|
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||||
|
vnext.address = getServerAddress(profileItem)
|
||||||
|
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
||||||
|
vnext.users[0].id = profileItem.password.orEmpty()
|
||||||
|
vnext.users[0].encryption = profileItem.method
|
||||||
|
vnext.users[0].flow = profileItem.flow
|
||||||
|
}
|
||||||
|
|
||||||
|
val sni = outboundBean?.streamSettings?.let {
|
||||||
|
populateTransportSettings(it, profileItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
outboundBean?.streamSettings?.let {
|
||||||
|
populateTlsSettings(it, profileItem, sni)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outboundBean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toOutboundShadowsocks(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outboundBean = createInitOutbound(EConfigType.SHADOWSOCKS)
|
||||||
|
|
||||||
|
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||||
|
server.address = getServerAddress(profileItem)
|
||||||
|
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||||
|
server.password = profileItem.password
|
||||||
|
server.method = profileItem.method
|
||||||
|
}
|
||||||
|
|
||||||
|
val sni = outboundBean?.streamSettings?.let {
|
||||||
|
populateTransportSettings(it, profileItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
outboundBean?.streamSettings?.let {
|
||||||
|
populateTlsSettings(it, profileItem, sni)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outboundBean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toOutboundTrojan(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outboundBean = createInitOutbound(EConfigType.TROJAN)
|
||||||
|
|
||||||
|
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||||
|
server.address = getServerAddress(profileItem)
|
||||||
|
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||||
|
server.password = profileItem.password
|
||||||
|
server.flow = profileItem.flow
|
||||||
|
}
|
||||||
|
|
||||||
|
val sni = outboundBean?.streamSettings?.let {
|
||||||
|
populateTransportSettings(it, profileItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
outboundBean?.streamSettings?.let {
|
||||||
|
populateTlsSettings(it, profileItem, sni)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outboundBean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toOutboundSocks(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outboundBean = createInitOutbound(EConfigType.SOCKS)
|
||||||
|
|
||||||
|
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||||
|
server.address = getServerAddress(profileItem)
|
||||||
|
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||||
|
if (profileItem.username.isNotNullEmpty()) {
|
||||||
|
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
|
||||||
|
socksUsersBean.user = profileItem.username.orEmpty()
|
||||||
|
socksUsersBean.pass = profileItem.password.orEmpty()
|
||||||
|
server.users = listOf(socksUsersBean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outboundBean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toOutboundHttp(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outboundBean = createInitOutbound(EConfigType.HTTP)
|
||||||
|
|
||||||
|
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||||
|
server.address = getServerAddress(profileItem)
|
||||||
|
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||||
|
if (profileItem.username.isNotNullEmpty()) {
|
||||||
|
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
|
||||||
|
socksUsersBean.user = profileItem.username.orEmpty()
|
||||||
|
socksUsersBean.pass = profileItem.password.orEmpty()
|
||||||
|
server.users = listOf(socksUsersBean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outboundBean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toOutboundWireguard(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outboundBean = createInitOutbound(EConfigType.WIREGUARD)
|
||||||
|
|
||||||
|
outboundBean?.settings?.let { wireguard ->
|
||||||
|
wireguard.secretKey = profileItem.secretKey
|
||||||
|
wireguard.address = (profileItem.localAddress ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4).split(",")
|
||||||
|
wireguard.peers?.firstOrNull()?.let { peer ->
|
||||||
|
peer.publicKey = profileItem.publicKey.orEmpty()
|
||||||
|
peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
|
||||||
|
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
|
||||||
|
}
|
||||||
|
wireguard.mtu = profileItem.mtu
|
||||||
|
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
|
||||||
|
}
|
||||||
|
|
||||||
|
return outboundBean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toOutboundHysteria2(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outboundBean = createInitOutbound(EConfigType.HYSTERIA2) ?: return null
|
||||||
|
profileItem.network = NetworkType.HYSTERIA.type
|
||||||
|
profileItem.alpn = "h3"
|
||||||
|
|
||||||
|
outboundBean.settings?.let { server ->
|
||||||
|
server.address = getServerAddress(profileItem)
|
||||||
|
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||||
|
server.version = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
val sni = outboundBean.streamSettings?.let {
|
||||||
|
populateTransportSettings(it, profileItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
outboundBean.streamSettings?.let {
|
||||||
|
populateTlsSettings(it, profileItem, sni)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outboundBean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures transport settings for an outbound connection.
|
||||||
|
*
|
||||||
|
* Sets up protocol-specific transport options based on the profile settings.
|
||||||
|
*
|
||||||
|
* @param streamSettings The stream settings to configure
|
||||||
|
* @param profileItem The profile containing transport configuration
|
||||||
|
* @return The Server Name Indication (SNI) value to use, or null if not applicable
|
||||||
|
*/
|
||||||
|
fun populateTransportSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem): String? {
|
||||||
|
val transport = profileItem.network.orEmpty()
|
||||||
|
val headerType = profileItem.headerType
|
||||||
|
val host = profileItem.host
|
||||||
|
val path = profileItem.path
|
||||||
|
val seed = profileItem.seed
|
||||||
|
// val quicSecurity = profileItem.quicSecurity
|
||||||
|
// val key = profileItem.quicKey
|
||||||
|
val mode = profileItem.mode
|
||||||
|
val serviceName = profileItem.serviceName
|
||||||
|
val authority = profileItem.authority
|
||||||
|
val xhttpMode = profileItem.xhttpMode
|
||||||
|
val xhttpExtra = profileItem.xhttpExtra
|
||||||
|
val finalMask = profileItem.finalMask
|
||||||
|
var sni: String? = null
|
||||||
|
streamSettings.network = transport.ifEmpty { NetworkType.TCP.type }
|
||||||
|
when (streamSettings.network) {
|
||||||
|
NetworkType.TCP.type -> {
|
||||||
|
val tcpSetting = OutboundBean.StreamSettingsBean.TcpSettingsBean()
|
||||||
|
if (headerType == AppConfig.HEADER_TYPE_HTTP) {
|
||||||
|
tcpSetting.header.type = AppConfig.HEADER_TYPE_HTTP
|
||||||
|
if (!TextUtils.isEmpty(host) || !TextUtils.isEmpty(path)) {
|
||||||
|
val requestObj = OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean()
|
||||||
|
requestObj.headers.Host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
requestObj.path = path.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
tcpSetting.header.request = requestObj
|
||||||
|
sni = requestObj.headers.Host?.getOrNull(0)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tcpSetting.header.type = "none"
|
||||||
|
sni = host
|
||||||
|
}
|
||||||
|
streamSettings.tcpSettings = tcpSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkType.KCP.type -> {
|
||||||
|
val kcpSetting = OutboundBean.StreamSettingsBean.KcpSettingsBean()
|
||||||
|
profileItem.kcpMtu?.let { kcpSetting.mtu = it }
|
||||||
|
profileItem.kcpTti?.let { kcpSetting.tti = it }
|
||||||
|
streamSettings.kcpSettings = kcpSetting
|
||||||
|
val udpMaskList = mutableListOf<OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean>()
|
||||||
|
if (!headerType.isNullOrEmpty() && headerType != "none") {
|
||||||
|
val kcpHeaderType = when {
|
||||||
|
headerType == "wechat-video" -> "header-wechat"
|
||||||
|
else -> "header-$headerType"
|
||||||
|
}
|
||||||
|
udpMaskList.add(
|
||||||
|
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||||
|
type = kcpHeaderType,
|
||||||
|
settings = if (headerType == "dns" && !host.isNullOrEmpty()) {
|
||||||
|
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||||
|
domain = host
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (seed.isNullOrEmpty()) {
|
||||||
|
udpMaskList.add(
|
||||||
|
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||||
|
type = "mkcp-original"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
udpMaskList.add(
|
||||||
|
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||||
|
type = "mkcp-aes128gcm",
|
||||||
|
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||||
|
password = seed
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
streamSettings.finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
|
||||||
|
udp = udpMaskList.toList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkType.WS.type -> {
|
||||||
|
val wssetting = OutboundBean.StreamSettingsBean.WsSettingsBean()
|
||||||
|
wssetting.headers.Host = host.orEmpty()
|
||||||
|
sni = host
|
||||||
|
wssetting.path = path ?: "/"
|
||||||
|
streamSettings.wsSettings = wssetting
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkType.HTTP_UPGRADE.type -> {
|
||||||
|
val httpupgradeSetting = OutboundBean.StreamSettingsBean.HttpupgradeSettingsBean()
|
||||||
|
httpupgradeSetting.host = host.orEmpty()
|
||||||
|
sni = host
|
||||||
|
httpupgradeSetting.path = path ?: "/"
|
||||||
|
streamSettings.httpupgradeSettings = httpupgradeSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkType.XHTTP.type -> {
|
||||||
|
val xhttpSetting = OutboundBean.StreamSettingsBean.XhttpSettingsBean()
|
||||||
|
xhttpSetting.host = host.orEmpty()
|
||||||
|
sni = host
|
||||||
|
xhttpSetting.path = path ?: "/"
|
||||||
|
xhttpSetting.mode = xhttpMode
|
||||||
|
xhttpSetting.extra = JsonUtil.parseString(xhttpExtra)
|
||||||
|
streamSettings.xhttpSettings = xhttpSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkType.H2.type, NetworkType.HTTP.type -> {
|
||||||
|
streamSettings.network = NetworkType.H2.type
|
||||||
|
val h2Setting = OutboundBean.StreamSettingsBean.HttpSettingsBean()
|
||||||
|
h2Setting.host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
sni = h2Setting.host.getOrNull(0)
|
||||||
|
h2Setting.path = path ?: "/"
|
||||||
|
streamSettings.httpSettings = h2Setting
|
||||||
|
}
|
||||||
|
|
||||||
|
// "quic" -> {
|
||||||
|
// val quicsetting = QuicSettingBean()
|
||||||
|
// quicsetting.security = quicSecurity ?: "none"
|
||||||
|
// quicsetting.key = key.orEmpty()
|
||||||
|
// quicsetting.header.type = headerType ?: "none"
|
||||||
|
// quicSettings = quicsetting
|
||||||
|
// }
|
||||||
|
|
||||||
|
NetworkType.GRPC.type -> {
|
||||||
|
val grpcSetting = OutboundBean.StreamSettingsBean.GrpcSettingsBean()
|
||||||
|
grpcSetting.multiMode = mode == "multi"
|
||||||
|
grpcSetting.serviceName = serviceName.orEmpty()
|
||||||
|
grpcSetting.authority = authority.orEmpty()
|
||||||
|
grpcSetting.idle_timeout = 60
|
||||||
|
grpcSetting.health_check_timeout = 20
|
||||||
|
sni = authority
|
||||||
|
streamSettings.grpcSettings = grpcSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkType.HYSTERIA.type -> {
|
||||||
|
val hysteriaSetting = OutboundBean.StreamSettingsBean.HysteriaSettingsBean(
|
||||||
|
version = 2,
|
||||||
|
auth = profileItem.password.orEmpty(),
|
||||||
|
)
|
||||||
|
val quicParams = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean(
|
||||||
|
brutalUp = profileItem.bandwidthUp?.nullIfBlank(),
|
||||||
|
brutalDown = profileItem.bandwidthDown?.nullIfBlank(),
|
||||||
|
)
|
||||||
|
quicParams.congestion = if (quicParams.brutalUp != null || quicParams.brutalDown != null) "brutal" else null
|
||||||
|
if (profileItem.portHopping.isNotNullEmpty()) {
|
||||||
|
val rawInterval = profileItem.portHoppingInterval?.trim().nullIfBlank()
|
||||||
|
val interval = if (rawInterval == null) {
|
||||||
|
"30"
|
||||||
|
} else {
|
||||||
|
val singleValue = rawInterval.toIntOrNull()
|
||||||
|
if (singleValue != null) {
|
||||||
|
if (singleValue < 5) {
|
||||||
|
"30"
|
||||||
|
} else {
|
||||||
|
rawInterval
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val parts = rawInterval.split('-')
|
||||||
|
if (parts.size == 2) {
|
||||||
|
val start = parts[0].trim().toIntOrNull()
|
||||||
|
val end = parts[1].trim().toIntOrNull()
|
||||||
|
if (start != null && end != null) {
|
||||||
|
val minStart = maxOf(5, start)
|
||||||
|
val minEnd = maxOf(minStart, end)
|
||||||
|
"$minStart-$minEnd"
|
||||||
|
} else {
|
||||||
|
"30"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"30"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quicParams.udpHop = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean.UdpHopBean(
|
||||||
|
ports = profileItem.portHopping,
|
||||||
|
interval = interval
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
|
||||||
|
quicParams = quicParams
|
||||||
|
)
|
||||||
|
if (profileItem.obfsPassword.isNotNullEmpty()) {
|
||||||
|
finalmask.udp = listOf(
|
||||||
|
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||||
|
type = "salamander",
|
||||||
|
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||||
|
password = profileItem.obfsPassword.orEmpty()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
streamSettings.hysteriaSettings = hysteriaSetting
|
||||||
|
streamSettings.finalmask = finalmask
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalMask?.let {
|
||||||
|
val parsedFinalMask = JsonUtil.parseString(finalMask)
|
||||||
|
if (parsedFinalMask != null) {
|
||||||
|
streamSettings.finalmask = parsedFinalMask
|
||||||
|
} else {
|
||||||
|
LogUtil.w("V2rayConfigManager", "Invalid finalMask JSON, keeping previously generated finalmask")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sni
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures TLS or REALITY security settings for an outbound connection.
|
||||||
|
*
|
||||||
|
* Sets up security-related parameters like certificates, fingerprints, and SNI.
|
||||||
|
*
|
||||||
|
* @param streamSettings The stream settings to configure
|
||||||
|
* @param profileItem The profile containing security configuration
|
||||||
|
* @param sniExt An external SNI value to use if the profile doesn't specify one
|
||||||
|
*/
|
||||||
|
fun populateTlsSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem, sniExt: String?) {
|
||||||
|
val streamSecurity = profileItem.security.orEmpty()
|
||||||
|
val allowInsecure = profileItem.insecure == true
|
||||||
|
val sni = if (profileItem.sni.isNullOrEmpty()) {
|
||||||
|
when {
|
||||||
|
sniExt.isNotNullEmpty() && Utils.isDomainName(sniExt) -> sniExt
|
||||||
|
profileItem.server.isNotNullEmpty() && Utils.isDomainName(profileItem.server) -> profileItem.server
|
||||||
|
else -> sniExt
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
profileItem.sni
|
||||||
|
}
|
||||||
|
|
||||||
|
streamSettings.security = streamSecurity.nullIfBlank()
|
||||||
|
if (streamSettings.security == null) return
|
||||||
|
val tlsSetting = OutboundBean.StreamSettingsBean.TlsSettingsBean(
|
||||||
|
allowInsecure = allowInsecure,
|
||||||
|
serverName = sni.nullIfBlank(),
|
||||||
|
fingerprint = profileItem.fingerPrint.nullIfBlank(),
|
||||||
|
alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() },
|
||||||
|
echConfigList = profileItem.echConfigList.nullIfBlank(),
|
||||||
|
pinnedPeerCertSha256 = profileItem.pinnedCA256.nullIfBlank(),
|
||||||
|
publicKey = profileItem.publicKey.nullIfBlank(),
|
||||||
|
shortId = profileItem.shortId.nullIfBlank(),
|
||||||
|
spiderX = profileItem.spiderX.nullIfBlank(),
|
||||||
|
mldsa65Verify = profileItem.mldsa65Verify.nullIfBlank(),
|
||||||
|
)
|
||||||
|
if (streamSettings.security == AppConfig.TLS) {
|
||||||
|
streamSettings.tlsSettings = tlsSetting
|
||||||
|
streamSettings.realitySettings = null
|
||||||
|
} else if (streamSettings.security == AppConfig.REALITY) {
|
||||||
|
streamSettings.tlsSettings = null
|
||||||
|
streamSettings.realitySettings = tlsSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileItem.finalMask.isNullOrEmpty()) {
|
||||||
|
updateOutboundFragment(streamSettings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the outbound with fragment settings for traffic optimization.
|
||||||
|
*
|
||||||
|
* Configures packet fragmentation for TLS and REALITY protocols if enabled.
|
||||||
|
*
|
||||||
|
* @param streamSettings The streamSettings object to be modified
|
||||||
|
* @return true if fragment configuration was successful, false otherwise
|
||||||
|
*/
|
||||||
|
private fun updateOutboundFragment(streamSettings: OutboundBean.StreamSettingsBean): Boolean {
|
||||||
|
try {
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == false) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (streamSettings.security != AppConfig.TLS
|
||||||
|
&& streamSettings.security != AppConfig.REALITY
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (streamSettings.sockopt?.dialerProxy.isNotNullEmpty()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var packets =
|
||||||
|
MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS) ?: "tlshello"
|
||||||
|
if (streamSettings.security == AppConfig.REALITY
|
||||||
|
&& packets == "tlshello"
|
||||||
|
) {
|
||||||
|
packets = "1-3"
|
||||||
|
} else if (streamSettings.security == AppConfig.TLS
|
||||||
|
&& packets != "tlshello"
|
||||||
|
) {
|
||||||
|
packets = "tlshello"
|
||||||
|
}
|
||||||
|
|
||||||
|
val fragmentMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||||
|
type = "fragment",
|
||||||
|
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||||
|
packets = packets,
|
||||||
|
length = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH)
|
||||||
|
?: "50-100",
|
||||||
|
delay = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL)
|
||||||
|
?: "10-20"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val noiseMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||||
|
type = "noise",
|
||||||
|
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||||
|
noise = listOf(
|
||||||
|
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean.NoiseMaskBean(
|
||||||
|
rand = "10-20",
|
||||||
|
delay = "10-16",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val finalMaskObj = streamSettings.finalmask?.let { existingFinalMask ->
|
||||||
|
JsonUtil.parseString(JsonUtil.toJson(existingFinalMask))
|
||||||
|
} ?: JsonObject()
|
||||||
|
|
||||||
|
// finalmask.tcp / finalmask.udp are arrays; prepend mask at index 0.
|
||||||
|
fun prependMask(scope: String, mask: OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean) {
|
||||||
|
val current = finalMaskObj.get(scope)
|
||||||
|
if (current != null && current.isJsonArray && current.asJsonArray.size() > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val newArray = JsonArray()
|
||||||
|
newArray.add(JsonUtil.parseString(JsonUtil.toJson(mask)))
|
||||||
|
|
||||||
|
if (current != null && current.isJsonArray) {
|
||||||
|
current.asJsonArray.forEach { newArray.add(it) }
|
||||||
|
}
|
||||||
|
finalMaskObj.add(scope, newArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
prependMask("tcp", fragmentMask)
|
||||||
|
prependMask("udp", noiseMask)
|
||||||
|
streamSettings.finalmask = finalMaskObj
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to update outbound fragment", e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getServerAddress(profileItem: ProfileItem): String {
|
||||||
|
if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
|
||||||
|
return profileItem.server.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
|
||||||
|
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
//Resolve and replace domain
|
||||||
|
val resolvedIps = HttpUtil.resolveHostToIP(domain, MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
|
||||||
|
if (resolvedIps.isNullOrEmpty()) {
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
return resolvedIps.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
-36
@@ -1,21 +1,28 @@
|
|||||||
package com.v2ray.ang.handler
|
package com.v2ray.ang.core
|
||||||
|
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.system.OsConstants
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.enums.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
|
||||||
import com.v2ray.ang.extension.toast
|
|
||||||
import com.v2ray.ang.contracts.ServiceControl
|
import com.v2ray.ang.contracts.ServiceControl
|
||||||
import com.v2ray.ang.service.V2RayProxyOnlyService
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.service.V2RayVpnService
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.extension.toast
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.handler.NotificationManager
|
||||||
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
|
import com.v2ray.ang.handler.SpeedtestManager
|
||||||
|
import com.v2ray.ang.service.CoreProxyOnlyService
|
||||||
|
import com.v2ray.ang.service.CoreVpnService
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.MessageUtil
|
import com.v2ray.ang.util.MessageUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -23,18 +30,26 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import libv2ray.CoreCallbackHandler
|
import libv2ray.CoreCallbackHandler
|
||||||
import libv2ray.CoreController
|
import libv2ray.CoreController
|
||||||
|
import libv2ray.ProcessFinder
|
||||||
import java.lang.ref.SoftReference
|
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 val mMsgReceive = ReceiveMessageHandler()
|
||||||
private var currentConfig: ProfileItem? = null
|
private var currentConfig: ProfileItem? = null
|
||||||
|
private var processFinder: XrayProcessFinder? = null
|
||||||
|
|
||||||
var serviceControl: SoftReference<ServiceControl>? = null
|
var serviceControl: SoftReference<ServiceControl>? = null
|
||||||
set(value) {
|
set(value) {
|
||||||
field = 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,9 +72,12 @@ object V2RayServiceManager {
|
|||||||
* @param guid The GUID of the server configuration to use (optional).
|
* @param guid The GUID of the server configuration to use (optional).
|
||||||
*/
|
*/
|
||||||
fun startVService(context: Context, guid: String? = null) {
|
fun startVService(context: Context, guid: String? = null) {
|
||||||
|
LogUtil.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
|
||||||
|
|
||||||
if (guid != null) {
|
if (guid != null) {
|
||||||
MmkvManager.setSelectServer(guid)
|
MmkvManager.setSelectServer(guid)
|
||||||
}
|
}
|
||||||
|
|
||||||
startContextService(context)
|
startContextService(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,15 +109,34 @@ object V2RayServiceManager {
|
|||||||
*/
|
*/
|
||||||
private fun startContextService(context: Context) {
|
private fun startContextService(context: Context) {
|
||||||
if (coreController.isRunning) {
|
if (coreController.isRunning) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val guid = MmkvManager.getSelectServer() ?: return
|
|
||||||
val config = MmkvManager.decodeServerConfig(guid) ?: return
|
val guid = MmkvManager.getSelectServer()
|
||||||
|
if (guid == null) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = MmkvManager.decodeServerConfig(guid)
|
||||||
|
if (config == null) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (config.configType != EConfigType.CUSTOM
|
if (config.configType != EConfigType.CUSTOM
|
||||||
&& config.configType != EConfigType.POLICYGROUP
|
&& config.configType != EConfigType.POLICYGROUP
|
||||||
|
&& config.configType != EConfigType.PROXYCHAIN
|
||||||
&& !Utils.isValidUrl(config.server)
|
&& !Utils.isValidUrl(config.server)
|
||||||
&& !Utils.isPureIpAddress(config.server.orEmpty())
|
&& !Utils.isPureIpAddress(config.server.orEmpty())
|
||||||
) return
|
) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// refresh socks port when enabled dynamic socks port
|
||||||
|
SettingsManager.refreshRuntimeSocksPort()
|
||||||
|
|
||||||
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
|
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
|
||||||
// if (!result.status) return
|
// if (!result.status) return
|
||||||
|
|
||||||
@@ -108,12 +145,21 @@ object V2RayServiceManager {
|
|||||||
} else {
|
} else {
|
||||||
context.toast(R.string.toast_services_start)
|
context.toast(R.string.toast_services_start)
|
||||||
}
|
}
|
||||||
val intent = if (SettingsManager.isVpnMode()) {
|
|
||||||
Intent(context.applicationContext, V2RayVpnService::class.java)
|
val isVpnMode = SettingsManager.isVpnMode()
|
||||||
|
val intent = if (isVpnMode) {
|
||||||
|
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
|
||||||
|
Intent(context.applicationContext, CoreVpnService::class.java)
|
||||||
} else {
|
} else {
|
||||||
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) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to start service", e)
|
||||||
}
|
}
|
||||||
ContextCompat.startForegroundService(context, intent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,15 +169,35 @@ object V2RayServiceManager {
|
|||||||
*/
|
*/
|
||||||
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
|
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
|
||||||
if (coreController.isRunning) {
|
if (coreController.isRunning) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val service = getService() ?: return false
|
val service = getService()
|
||||||
val guid = MmkvManager.getSelectServer() ?: return false
|
if (service == null) {
|
||||||
val config = MmkvManager.decodeServerConfig(guid) ?: return false
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Service is null")
|
||||||
val result = V2rayConfigManager.getV2rayConfig(service, guid)
|
|
||||||
if (!result.status)
|
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val guid = MmkvManager.getSelectServer()
|
||||||
|
if (guid == null) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = MmkvManager.decodeServerConfig(guid)
|
||||||
|
if (config == null) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to get V2Ray config")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
|
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||||
@@ -140,7 +206,7 @@ object V2RayServiceManager {
|
|||||||
mFilter.addAction(Intent.ACTION_USER_PRESENT)
|
mFilter.addAction(Intent.ACTION_USER_PRESENT)
|
||||||
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
|
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to register broadcast receiver", e)
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to register receiver", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,11 +220,12 @@ object V2RayServiceManager {
|
|||||||
NotificationManager.showNotification(currentConfig)
|
NotificationManager.showNotification(currentConfig)
|
||||||
coreController.startLoop(result.content, tunFd)
|
coreController.startLoop(result.content, tunFd)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to start Core loop", e)
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to start core loop", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coreController.isRunning == false) {
|
if (coreController.isRunning == false) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Core failed to start")
|
||||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
|
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
|
||||||
NotificationManager.cancelNotification()
|
NotificationManager.cancelNotification()
|
||||||
return false
|
return false
|
||||||
@@ -166,11 +233,10 @@ object V2RayServiceManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
|
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
|
||||||
//NotificationManager.showNotification(currentConfig)
|
|
||||||
NotificationManager.startSpeedNotification(currentConfig)
|
NotificationManager.startSpeedNotification(currentConfig)
|
||||||
|
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to startup service", e)
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to complete startup", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -189,7 +255,7 @@ object V2RayServiceManager {
|
|||||||
try {
|
try {
|
||||||
coreController.stopLoop()
|
coreController.stopLoop()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to stop V2Ray loop", e)
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop V2Ray loop", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +266,7 @@ object V2RayServiceManager {
|
|||||||
try {
|
try {
|
||||||
service.unregisterReceiver(mMsgReceive)
|
service.unregisterReceiver(mMsgReceive)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to unregister broadcast receiver", e)
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to unregister receiver", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -234,14 +300,14 @@ object V2RayServiceManager {
|
|||||||
try {
|
try {
|
||||||
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
|
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to measure delay with primary URL", e)
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||||
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
||||||
}
|
}
|
||||||
if (time == -1L) {
|
if (time == -1L) {
|
||||||
try {
|
try {
|
||||||
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
|
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to measure delay with alternative URL", e)
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||||
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,7 +359,7 @@ object V2RayServiceManager {
|
|||||||
serviceControl.stopService()
|
serviceControl.stopService()
|
||||||
0
|
0
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to stop service in callback", e)
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
|
||||||
-1
|
-1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,6 +375,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 (e: Exception) {
|
||||||
|
-1L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcast receiver for handling messages sent to the service.
|
* Broadcast receiver for handling messages sent to the service.
|
||||||
* Handles registration, service control, and screen events.
|
* Handles registration, service control, and screen events.
|
||||||
@@ -340,12 +443,12 @@ object V2RayServiceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AppConfig.MSG_STATE_STOP -> {
|
AppConfig.MSG_STATE_STOP -> {
|
||||||
Log.i(AppConfig.TAG, "Stop Service")
|
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Stop service")
|
||||||
serviceControl.stopService()
|
serviceControl.stopService()
|
||||||
}
|
}
|
||||||
|
|
||||||
AppConfig.MSG_STATE_RESTART -> {
|
AppConfig.MSG_STATE_RESTART -> {
|
||||||
Log.i(AppConfig.TAG, "Restart Service")
|
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Restart service")
|
||||||
serviceControl.stopService()
|
serviceControl.stopService()
|
||||||
Thread.sleep(500L)
|
Thread.sleep(500L)
|
||||||
startVService(serviceControl.getService())
|
startVService(serviceControl.getService())
|
||||||
@@ -358,12 +461,12 @@ object V2RayServiceManager {
|
|||||||
|
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
Intent.ACTION_SCREEN_OFF -> {
|
Intent.ACTION_SCREEN_OFF -> {
|
||||||
Log.i(AppConfig.TAG, "SCREEN_OFF, stop querying stats")
|
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen off")
|
||||||
NotificationManager.stopSpeedNotification(currentConfig)
|
NotificationManager.stopSpeedNotification(currentConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
Intent.ACTION_SCREEN_ON -> {
|
Intent.ACTION_SCREEN_ON -> {
|
||||||
Log.i(AppConfig.TAG, "SCREEN_ON, start querying stats")
|
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen on")
|
||||||
NotificationManager.startSpeedNotification(currentConfig)
|
NotificationManager.startSpeedNotification(currentConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.v2ray.ang.enums.CoreResolvedType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime context produced by the builder and consumed by CoreConfigManager.
|
||||||
|
*/
|
||||||
|
data class CoreConfigContext(
|
||||||
|
val context: Context,
|
||||||
|
val guid: String,
|
||||||
|
val selectedProfile: ProfileItem,
|
||||||
|
val resolvedProfiles: List<ProfileItem>,
|
||||||
|
val resolvedType: CoreResolvedType,
|
||||||
|
val customOutboundProfiles: Map<String, ProfileItem> = emptyMap(),
|
||||||
|
)
|
||||||
@@ -29,6 +29,9 @@ data class ProfileItem(
|
|||||||
var host: String? = null,
|
var host: String? = null,
|
||||||
var path: String? = null,
|
var path: String? = null,
|
||||||
var seed: String? = null,
|
var seed: String? = null,
|
||||||
|
var kcpMtu: Int? = null,
|
||||||
|
var kcpTti: Int? = null,
|
||||||
|
|
||||||
var quicSecurity: String? = null,
|
var quicSecurity: String? = null,
|
||||||
var quicKey: String? = null,
|
var quicKey: String? = null,
|
||||||
var mode: String? = null,
|
var mode: String? = null,
|
||||||
@@ -36,14 +39,13 @@ data class ProfileItem(
|
|||||||
var authority: String? = null,
|
var authority: String? = null,
|
||||||
var xhttpMode: String? = null,
|
var xhttpMode: String? = null,
|
||||||
var xhttpExtra: String? = null,
|
var xhttpExtra: String? = null,
|
||||||
|
var finalMask: String? = null,
|
||||||
var security: String? = null,
|
var security: String? = null,
|
||||||
var sni: String? = null,
|
var sni: String? = null,
|
||||||
var alpn: String? = null,
|
var alpn: String? = null,
|
||||||
var fingerPrint: String? = null,
|
var fingerPrint: String? = null,
|
||||||
var insecure: Boolean? = null,
|
var insecure: Boolean? = null,
|
||||||
var echConfigList: String? = null,
|
var echConfigList: String? = null,
|
||||||
var echForceQuery: String? = null,
|
|
||||||
var pinnedCA256: String? = null,
|
var pinnedCA256: String? = null,
|
||||||
|
|
||||||
var publicKey: String? = null,
|
var publicKey: String? = null,
|
||||||
@@ -67,6 +69,7 @@ data class ProfileItem(
|
|||||||
var policyGroupType: String? = null,
|
var policyGroupType: String? = null,
|
||||||
var policyGroupSubscriptionId: String? = null,
|
var policyGroupSubscriptionId: String? = null,
|
||||||
var policyGroupFilter: String? = null,
|
var policyGroupFilter: String? = null,
|
||||||
|
var proxyChainProfiles: String? = null,
|
||||||
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -102,6 +105,8 @@ data class ProfileItem(
|
|||||||
&& this.host == obj.host
|
&& this.host == obj.host
|
||||||
&& this.path == obj.path
|
&& this.path == obj.path
|
||||||
&& this.seed == obj.seed
|
&& this.seed == obj.seed
|
||||||
|
&& this.kcpMtu == obj.kcpMtu
|
||||||
|
&& this.kcpTti == obj.kcpTti
|
||||||
&& this.quicSecurity == obj.quicSecurity
|
&& this.quicSecurity == obj.quicSecurity
|
||||||
&& this.quicKey == obj.quicKey
|
&& this.quicKey == obj.quicKey
|
||||||
&& this.mode == obj.mode
|
&& this.mode == obj.mode
|
||||||
@@ -125,6 +130,7 @@ data class ProfileItem(
|
|||||||
&& this.portHopping == obj.portHopping
|
&& this.portHopping == obj.portHopping
|
||||||
&& this.portHoppingInterval == obj.portHoppingInterval
|
&& this.portHoppingInterval == obj.portHoppingInterval
|
||||||
&& this.pinnedCA256 == obj.pinnedCA256
|
&& this.pinnedCA256 == obj.pinnedCA256
|
||||||
|
&& this.proxyChainProfiles == obj.proxyChainProfiles
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
sealed class RealPingEvent {
|
||||||
|
|
||||||
|
/** Periodic progress update while the batch is still running. */
|
||||||
|
data class Progress(val text: String) : RealPingEvent()
|
||||||
|
|
||||||
|
/** A single server result is available. */
|
||||||
|
data class Result(val guid: String, val delayMillis: Long) : RealPingEvent()
|
||||||
|
|
||||||
|
/** The entire batch has finished or been cancelled. */
|
||||||
|
data class Finish(val status: String) : RealPingEvent()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ data class RulesetItem(
|
|||||||
var remarks: String? = "",
|
var remarks: String? = "",
|
||||||
var ip: List<String>? = null,
|
var ip: List<String>? = null,
|
||||||
var domain: List<String>? = null,
|
var domain: List<String>? = null,
|
||||||
|
var process: List<String>? = null,
|
||||||
var outboundTag: String = "",
|
var outboundTag: String = "",
|
||||||
var port: String? = null,
|
var port: String? = null,
|
||||||
var network: String? = null,
|
var network: String? = null,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ data class SubscriptionItem(
|
|||||||
val addedTime: Long = System.currentTimeMillis(),
|
val addedTime: Long = System.currentTimeMillis(),
|
||||||
var lastUpdated: Long = -1,
|
var lastUpdated: Long = -1,
|
||||||
var autoUpdate: Boolean = false,
|
var autoUpdate: Boolean = false,
|
||||||
val updateInterval: Int? = null,
|
var updateInterval: Long = 1440, // in minutes, default to 24 hours
|
||||||
var prevProfile: String? = null,
|
var prevProfile: String? = null,
|
||||||
var nextProfile: String? = null,
|
var nextProfile: String? = null,
|
||||||
var filter: String? = null,
|
var filter: String? = null,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of subscription update operation
|
||||||
|
*/
|
||||||
|
data class SubscriptionUpdateResult(
|
||||||
|
val configCount: Int = 0, // Total configs updated
|
||||||
|
val successCount: Int = 0, // Subscriptions updated successfully
|
||||||
|
val failureCount: Int = 0, // Subscriptions failed to update
|
||||||
|
val skipCount: Int = 0 // Subscriptions skipped (disabled)
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Combine two results by adding their counts
|
||||||
|
*/
|
||||||
|
operator fun plus(other: SubscriptionUpdateResult): SubscriptionUpdateResult {
|
||||||
|
return SubscriptionUpdateResult(
|
||||||
|
configCount = this.configCount + other.configCount,
|
||||||
|
successCount = this.successCount + other.successCount,
|
||||||
|
failureCount = this.failureCount + other.failureCount,
|
||||||
|
skipCount = this.skipCount + other.skipCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
import java.io.Serializable
|
||||||
|
|
||||||
|
data class TestServiceMessage(
|
||||||
|
val key: Int,
|
||||||
|
val subscriptionId: String = "",
|
||||||
|
val serverGuids: List<String> = emptyList()
|
||||||
|
) : Serializable
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ data class V2rayConfig(
|
|||||||
|
|
||||||
data class InboundBean(
|
data class InboundBean(
|
||||||
var tag: String,
|
var tag: String,
|
||||||
var port: Int,
|
var port: Int?,
|
||||||
var protocol: String,
|
var protocol: String,
|
||||||
var listen: String? = null,
|
var listen: String? = null,
|
||||||
var settings: InSettingsBean? = null,
|
var settings: InSettingsBean? = null,
|
||||||
@@ -44,10 +44,16 @@ data class V2rayConfig(
|
|||||||
var auth: String? = null,
|
var auth: String? = null,
|
||||||
var udp: Boolean? = null,
|
var udp: Boolean? = null,
|
||||||
var userLevel: Int? = null,
|
var userLevel: Int? = null,
|
||||||
|
var accounts: List<SocksAccountBean>? = null,
|
||||||
var name: String? = null,
|
var name: String? = null,
|
||||||
@SerializedName("MTU")
|
|
||||||
var mtu: Int? = null
|
var mtu: Int? = null
|
||||||
)
|
) {
|
||||||
|
data class SocksAccountBean(
|
||||||
|
var user: String = "",
|
||||||
|
var pass: String = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
data class SniffingBean(
|
data class SniffingBean(
|
||||||
var enabled: Boolean,
|
var enabled: Boolean,
|
||||||
@@ -68,8 +74,6 @@ data class V2rayConfig(
|
|||||||
) {
|
) {
|
||||||
data class OutSettingsBean(
|
data class OutSettingsBean(
|
||||||
var vnext: List<VnextBean>? = null,
|
var vnext: List<VnextBean>? = null,
|
||||||
var fragment: FragmentBean? = null,
|
|
||||||
var noises: List<NoiseBean>? = null,
|
|
||||||
var servers: List<ServersBean>? = null,
|
var servers: List<ServersBean>? = null,
|
||||||
/*Blackhole*/
|
/*Blackhole*/
|
||||||
var response: Response? = null,
|
var response: Response? = null,
|
||||||
@@ -108,18 +112,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(
|
data class ServersBean(
|
||||||
var address: String = "",
|
var address: String = "",
|
||||||
var method: String? = null,
|
var method: String? = null,
|
||||||
@@ -162,7 +154,7 @@ data class V2rayConfig(
|
|||||||
var realitySettings: TlsSettingsBean? = null,
|
var realitySettings: TlsSettingsBean? = null,
|
||||||
var grpcSettings: GrpcSettingsBean? = null,
|
var grpcSettings: GrpcSettingsBean? = null,
|
||||||
var hysteriaSettings: HysteriaSettingsBean? = null,
|
var hysteriaSettings: HysteriaSettingsBean? = null,
|
||||||
var finalmask: FinalMaskBean? = null,
|
var finalmask: Any? = null,
|
||||||
val dsSettings: Any? = null,
|
val dsSettings: Any? = null,
|
||||||
var sockopt: SockoptBean? = null
|
var sockopt: SockoptBean? = null
|
||||||
) {
|
) {
|
||||||
@@ -264,7 +256,6 @@ data class V2rayConfig(
|
|||||||
val disableSystemRoot: Boolean? = null,
|
val disableSystemRoot: Boolean? = null,
|
||||||
val enableSessionResumption: Boolean? = null,
|
val enableSessionResumption: Boolean? = null,
|
||||||
var echConfigList: String? = null,
|
var echConfigList: String? = null,
|
||||||
var echForceQuery: String? = null,
|
|
||||||
var pinnedPeerCertSha256: String? = null,
|
var pinnedPeerCertSha256: String? = null,
|
||||||
// REALITY settings
|
// REALITY settings
|
||||||
val show: Boolean = false,
|
val show: Boolean = false,
|
||||||
@@ -292,28 +283,51 @@ data class V2rayConfig(
|
|||||||
|
|
||||||
data class HysteriaSettingsBean(
|
data class HysteriaSettingsBean(
|
||||||
var version: Int,
|
var version: Int,
|
||||||
var auth: String? = null,
|
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
//https://xtls.github.io/config/transport.html#finalmaskobject
|
||||||
data class FinalMaskBean(
|
data class FinalMaskBean(
|
||||||
var tcp: List<MaskBean>? = null,
|
var tcp: List<MaskBean>? = null,
|
||||||
var udp: List<MaskBean>? = null
|
var udp: List<MaskBean>? = null,
|
||||||
|
var quicParams: QuicParamsBean? = null
|
||||||
) {
|
) {
|
||||||
data class MaskBean(
|
data class MaskBean(
|
||||||
var type: String,
|
var type: String,
|
||||||
var settings: MaskSettingsBean? = null
|
var settings: MaskSettingsBean? = null
|
||||||
) {
|
) {
|
||||||
data class MaskSettingsBean(
|
data class MaskSettingsBean(
|
||||||
var password: String? = null,
|
val password: String? = null,
|
||||||
var domain: 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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,6 +401,7 @@ data class V2rayConfig(
|
|||||||
val clientIp: String? = null,
|
val clientIp: String? = null,
|
||||||
val disableCache: Boolean? = null,
|
val disableCache: Boolean? = null,
|
||||||
val queryStrategy: String? = null,
|
val queryStrategy: String? = null,
|
||||||
|
val enableParallelQuery: Boolean? = null,
|
||||||
val tag: String? = null
|
val tag: String? = null
|
||||||
) {
|
) {
|
||||||
data class ServersBean(
|
data class ServersBean(
|
||||||
@@ -409,8 +424,9 @@ data class V2rayConfig(
|
|||||||
|
|
||||||
data class RulesBean(
|
data class RulesBean(
|
||||||
var type: String = "field",
|
var type: String = "field",
|
||||||
var ip: ArrayList<String>? = null,
|
var ip: List<String>? = null,
|
||||||
var domain: ArrayList<String>? = null,
|
var domain: List<String>? = null,
|
||||||
|
var process: List<String>? = null,
|
||||||
var outboundTag: String? = null,
|
var outboundTag: String? = null,
|
||||||
var balancerTag: String? = null,
|
var balancerTag: String? = null,
|
||||||
var port: String? = null,
|
var port: String? = null,
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.v2ray.ang.enums
|
||||||
|
|
||||||
|
/** Runtime type used during config assembly only. */
|
||||||
|
enum class CoreResolvedType {
|
||||||
|
NORMAL,
|
||||||
|
POLICYGROUP,
|
||||||
|
PROXYCHAIN,
|
||||||
|
CUSTOM,
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ enum class EConfigType(val value: Int, val protocolScheme: String) {
|
|||||||
HYSTERIA2(9, AppConfig.HYSTERIA2),
|
HYSTERIA2(9, AppConfig.HYSTERIA2),
|
||||||
HYSTERIA(900, AppConfig.HYSTERIA),
|
HYSTERIA(900, AppConfig.HYSTERIA),
|
||||||
HTTP(10, AppConfig.HTTP),
|
HTTP(10, AppConfig.HTTP),
|
||||||
POLICYGROUP (101, AppConfig.CUSTOM);
|
POLICYGROUP(101, AppConfig.CUSTOM),
|
||||||
|
PROXYCHAIN(102, AppConfig.CUSTOM);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -189,4 +189,15 @@ fun String.concatUrl(vararg paths: String): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return builder.toString()
|
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)
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.enums.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.V2rayConfig
|
import com.v2ray.ang.dto.V2rayConfig
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.util.JsonUtil
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
|
||||||
object CustomFmt : FmtBase() {
|
object CustomFmt : FmtBase() {
|
||||||
@@ -12,7 +12,7 @@ object CustomFmt : FmtBase() {
|
|||||||
* @param str the JSON string to parse
|
* @param str the JSON string to parse
|
||||||
* @return the parsed ProfileItem object, or null if parsing fails
|
* @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 config = ProfileItem.create(EConfigType.CUSTOM)
|
||||||
|
|
||||||
val fullConfig = JsonUtil.fromJson(str, V2rayConfig::class.java)
|
val fullConfig = JsonUtil.fromJson(str, V2rayConfig::class.java)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.enums.NetworkType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.extension.nullIfBlank
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.util.HttpUtil
|
import com.v2ray.ang.util.HttpUtil
|
||||||
@@ -60,6 +60,8 @@ open class FmtBase {
|
|||||||
config.path = queryParam["path"]
|
config.path = queryParam["path"]
|
||||||
|
|
||||||
config.seed = queryParam["seed"]
|
config.seed = queryParam["seed"]
|
||||||
|
config.kcpMtu = queryParam["mtu"]?.toIntOrNull()
|
||||||
|
config.kcpTti = queryParam["tti"]?.toIntOrNull()
|
||||||
config.quicSecurity = queryParam["quicSecurity"]
|
config.quicSecurity = queryParam["quicSecurity"]
|
||||||
config.quicKey = queryParam["key"]
|
config.quicKey = queryParam["key"]
|
||||||
config.mode = queryParam["mode"]
|
config.mode = queryParam["mode"]
|
||||||
@@ -67,6 +69,7 @@ open class FmtBase {
|
|||||||
config.authority = queryParam["authority"]
|
config.authority = queryParam["authority"]
|
||||||
config.xhttpMode = queryParam["mode"]
|
config.xhttpMode = queryParam["mode"]
|
||||||
config.xhttpExtra = queryParam["extra"]
|
config.xhttpExtra = queryParam["extra"]
|
||||||
|
config.finalMask = queryParam["fm"]
|
||||||
|
|
||||||
config.security = queryParam["security"]
|
config.security = queryParam["security"]
|
||||||
if (config.security != AppConfig.TLS && config.security != AppConfig.REALITY) {
|
if (config.security != AppConfig.TLS && config.security != AppConfig.REALITY) {
|
||||||
@@ -110,6 +113,9 @@ open class FmtBase {
|
|||||||
config.spiderX?.nullIfBlank()?.let { dicQuery["spx"] = it }
|
config.spiderX?.nullIfBlank()?.let { dicQuery["spx"] = it }
|
||||||
config.mldsa65Verify?.nullIfBlank()?.let { dicQuery["pqv"] = it }
|
config.mldsa65Verify?.nullIfBlank()?.let { dicQuery["pqv"] = it }
|
||||||
config.flow?.nullIfBlank()?.let { dicQuery["flow"] = 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"
|
// Add two keys for compatibility: "insecure" and "allowInsecure"
|
||||||
if (config.security == AppConfig.TLS) {
|
if (config.security == AppConfig.TLS) {
|
||||||
val insecureFlag = if (config.insecure == true) "1" else "0"
|
val insecureFlag = if (config.insecure == true) "1" else "0"
|
||||||
@@ -166,21 +172,4 @@ open class FmtBase {
|
|||||||
|
|
||||||
return dicQuery
|
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.enums.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
|
||||||
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
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.enums.EConfigType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.enums.NetworkType
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean.FinalMaskBean
|
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
import com.v2ray.ang.extension.nullIfBlank
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.V2rayConfigManager
|
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
@@ -21,7 +18,7 @@ object Hysteria2Fmt : FmtBase() {
|
|||||||
* @param str the Hysteria2 URI string to parse
|
* @param str the Hysteria2 URI string to parse
|
||||||
* @return the parsed ProfileItem object, or null if parsing fails
|
* @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)
|
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
||||||
val config = ProfileItem.create(EConfigType.HYSTERIA2)
|
val config = ProfileItem.create(EConfigType.HYSTERIA2)
|
||||||
|
|
||||||
@@ -73,7 +70,37 @@ object Hysteria2Fmt : FmtBase() {
|
|||||||
dicQuery["mport"] = config.portHopping.orEmpty()
|
dicQuery["mport"] = config.portHopping.orEmpty()
|
||||||
}
|
}
|
||||||
if (config.portHoppingInterval.isNotNullEmpty()) {
|
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()) {
|
if (config.pinnedCA256.isNotNullEmpty()) {
|
||||||
dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
|
dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
|
||||||
@@ -81,44 +108,4 @@ object Hysteria2Fmt : FmtBase() {
|
|||||||
|
|
||||||
return toUri(config, config.password, dicQuery)
|
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
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.enums.EConfigType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.enums.NetworkType
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
|
||||||
import com.v2ray.ang.extension.idnHost
|
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 com.v2ray.ang.util.Utils
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
@@ -85,7 +83,7 @@ object ShadowsocksFmt : FmtBase() {
|
|||||||
config.remarks =
|
config.remarks =
|
||||||
Utils.decodeURIComponent(result.substring(indexSplit + 1, result.length))
|
Utils.decodeURIComponent(result.substring(indexSplit + 1, result.length))
|
||||||
} catch (e: Exception) {
|
} 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)
|
result = result.substring(0, indexSplit)
|
||||||
@@ -124,31 +122,4 @@ object ShadowsocksFmt : FmtBase() {
|
|||||||
|
|
||||||
return toUri(config, Utils.encode(pw, true), null)
|
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
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.enums.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
import com.v2ray.ang.handler.V2rayConfigManager
|
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
@@ -53,27 +51,4 @@ object SocksFmt : FmtBase() {
|
|||||||
|
|
||||||
return toUri(config, Utils.encode(pw, true), null)
|
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
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.enums.EConfigType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.enums.NetworkType
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.V2rayConfigManager
|
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
@@ -18,7 +16,7 @@ object TrojanFmt : FmtBase() {
|
|||||||
* @param str the Trojan URI string to parse
|
* @param str the Trojan URI string to parse
|
||||||
* @return the parsed ProfileItem object, or null if parsing fails
|
* @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)
|
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
||||||
val config = ProfileItem.create(EConfigType.TROJAN)
|
val config = ProfileItem.create(EConfigType.TROJAN)
|
||||||
|
|
||||||
@@ -53,31 +51,4 @@ object TrojanFmt : FmtBase() {
|
|||||||
|
|
||||||
return toUri(config, config.password, dicQuery)
|
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
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.enums.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.V2rayConfigManager
|
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
@@ -50,31 +48,4 @@ object VlessFmt : FmtBase() {
|
|||||||
return toUri(config, config.password, dicQuery)
|
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
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
import com.v2ray.ang.dto.VmessQRCode
|
||||||
import com.v2ray.ang.enums.EConfigType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.enums.NetworkType
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
|
||||||
import com.v2ray.ang.dto.VmessQRCode
|
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.extension.nullIfBlank
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.V2rayConfigManager
|
|
||||||
import com.v2ray.ang.util.JsonUtil
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
@@ -34,7 +32,7 @@ object VmessFmt : FmtBase() {
|
|||||||
var result = str.replace(EConfigType.VMESS.protocolScheme, "")
|
var result = str.replace(EConfigType.VMESS.protocolScheme, "")
|
||||||
result = Utils.decode(result)
|
result = Utils.decode(result)
|
||||||
if (TextUtils.isEmpty(result)) {
|
if (TextUtils.isEmpty(result)) {
|
||||||
Log.w(AppConfig.TAG, "Toast decoding failed")
|
LogUtil.w(AppConfig.TAG, "Toast decoding failed")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java) ?: 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.id)
|
||||||
|| TextUtils.isEmpty(vmessQRCode.net)
|
|| TextUtils.isEmpty(vmessQRCode.net)
|
||||||
) {
|
) {
|
||||||
Log.w(AppConfig.TAG, "Toast incorrect protocol")
|
LogUtil.w(AppConfig.TAG, "Toast incorrect protocol")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,31 +172,5 @@ object VmessFmt : FmtBase() {
|
|||||||
return config
|
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
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
|
||||||
import com.v2ray.ang.enums.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.extension.nullIfBlank
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.extension.removeWhiteSpace
|
import com.v2ray.ang.extension.removeWhiteSpace
|
||||||
import com.v2ray.ang.handler.V2rayConfigManager
|
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
@@ -31,7 +28,7 @@ object WireguardFmt : FmtBase() {
|
|||||||
config.serverPort = uri.port.toString()
|
config.serverPort = uri.port.toString()
|
||||||
|
|
||||||
config.secretKey = uri.userInfo.orEmpty()
|
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.publicKey = queryParam["publickey"].orEmpty()
|
||||||
config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
|
config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
|
||||||
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
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
|
* @param str the Wireguard configuration file string to parse
|
||||||
* @return the parsed ProfileItem object, or null if parsing fails
|
* @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 config = ProfileItem.create(EConfigType.WIREGUARD)
|
||||||
|
|
||||||
val interfaceParams: MutableMap<String, String> = mutableMapOf()
|
val interfaceParams: MutableMap<String, String> = mutableMapOf()
|
||||||
@@ -82,7 +79,7 @@ object WireguardFmt : FmtBase() {
|
|||||||
|
|
||||||
config.secretKey = interfaceParams["privatekey"].orEmpty()
|
config.secretKey = interfaceParams["privatekey"].orEmpty()
|
||||||
config.remarks = System.currentTimeMillis().toString()
|
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.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
||||||
config.publicKey = peerParams["publickey"].orEmpty()
|
config.publicKey = peerParams["publickey"].orEmpty()
|
||||||
config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
|
config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
|
||||||
@@ -100,29 +97,6 @@ object WireguardFmt : FmtBase() {
|
|||||||
return config
|
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.
|
* Converts a ProfileItem object to a URI string.
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ package com.v2ray.ang.handler
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.HY2
|
import com.v2ray.ang.AppConfig.HY2
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.enums.EConfigType
|
import com.v2ray.ang.core.CoreConfigManager
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.dto.SubscriptionCache
|
import com.v2ray.ang.dto.SubscriptionCache
|
||||||
import com.v2ray.ang.dto.SubscriptionItem
|
import com.v2ray.ang.dto.SubscriptionItem
|
||||||
|
import com.v2ray.ang.dto.SubscriptionUpdateResult
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
import com.v2ray.ang.fmt.CustomFmt
|
import com.v2ray.ang.fmt.CustomFmt
|
||||||
import com.v2ray.ang.fmt.Hysteria2Fmt
|
import com.v2ray.ang.fmt.Hysteria2Fmt
|
||||||
import com.v2ray.ang.fmt.ShadowsocksFmt
|
import com.v2ray.ang.fmt.ShadowsocksFmt
|
||||||
@@ -21,6 +23,7 @@ import com.v2ray.ang.fmt.VmessFmt
|
|||||||
import com.v2ray.ang.fmt.WireguardFmt
|
import com.v2ray.ang.fmt.WireguardFmt
|
||||||
import com.v2ray.ang.util.HttpUtil
|
import com.v2ray.ang.util.HttpUtil
|
||||||
import com.v2ray.ang.util.JsonUtil
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.QRCodeDecoder
|
import com.v2ray.ang.util.QRCodeDecoder
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
@@ -45,7 +48,7 @@ object AngConfigManager {
|
|||||||
Utils.setClipboard(context, conf)
|
Utils.setClipboard(context, conf)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} 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 -1
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
@@ -74,7 +77,7 @@ object AngConfigManager {
|
|||||||
}
|
}
|
||||||
return sb.lines().count() - 1
|
return sb.lines().count() - 1
|
||||||
} catch (e: Exception) {
|
} 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
|
return -1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +97,7 @@ object AngConfigManager {
|
|||||||
return QRCodeDecoder.createQRCode(conf)
|
return QRCodeDecoder.createQRCode(conf)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} 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
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,14 +112,14 @@ object AngConfigManager {
|
|||||||
fun shareFullContent2Clipboard(context: Context, guid: String?): Int {
|
fun shareFullContent2Clipboard(context: Context, guid: String?): Int {
|
||||||
try {
|
try {
|
||||||
if (guid == null) return -1
|
if (guid == null) return -1
|
||||||
val result = V2rayConfigManager.getV2rayConfig(context, guid)
|
val result = CoreConfigManager.getV2rayConfig(context, guid)
|
||||||
if (result.status) {
|
if (result.status) {
|
||||||
Utils.setClipboard(context, result.content)
|
Utils.setClipboard(context, result.content)
|
||||||
} else {
|
} else {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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 -1
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
@@ -134,19 +137,16 @@ object AngConfigManager {
|
|||||||
|
|
||||||
return config.configType.protocolScheme + when (config.configType) {
|
return config.configType.protocolScheme + when (config.configType) {
|
||||||
EConfigType.VMESS -> VmessFmt.toUri(config)
|
EConfigType.VMESS -> VmessFmt.toUri(config)
|
||||||
EConfigType.CUSTOM -> ""
|
|
||||||
EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toUri(config)
|
EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toUri(config)
|
||||||
EConfigType.SOCKS -> SocksFmt.toUri(config)
|
EConfigType.SOCKS -> SocksFmt.toUri(config)
|
||||||
EConfigType.HTTP -> ""
|
|
||||||
EConfigType.VLESS -> VlessFmt.toUri(config)
|
EConfigType.VLESS -> VlessFmt.toUri(config)
|
||||||
EConfigType.TROJAN -> TrojanFmt.toUri(config)
|
EConfigType.TROJAN -> TrojanFmt.toUri(config)
|
||||||
EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
|
EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
|
||||||
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
|
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
|
||||||
EConfigType.POLICYGROUP -> ""
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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 ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,7 +165,7 @@ object AngConfigManager {
|
|||||||
count = parseBatchConfig(server, subid, append)
|
count = parseBatchConfig(server, subid, append)
|
||||||
}
|
}
|
||||||
if (count <= 0) {
|
if (count <= 0) {
|
||||||
count = parseCustomConfigServer(server, subid)
|
count = parseCustomConfigServer(server, subid, append)
|
||||||
}
|
}
|
||||||
|
|
||||||
var countSub = parseBatchSubscription(server)
|
var countSub = parseBatchSubscription(server)
|
||||||
@@ -201,7 +201,7 @@ object AngConfigManager {
|
|||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
} catch (e: Exception) {
|
} 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
|
return 0
|
||||||
}
|
}
|
||||||
@@ -219,49 +219,146 @@ object AngConfigManager {
|
|||||||
if (servers == null) {
|
if (servers == null) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
val removedSelectedServer =
|
// Find the currently selected server that matches the subscription ID
|
||||||
if (!TextUtils.isEmpty(subid) && !append) {
|
val removedSelected = if (subid.isNotBlank() && !append) {
|
||||||
MmkvManager.decodeServerConfig(
|
MmkvManager.getSelectServer()
|
||||||
MmkvManager.getSelectServer().orEmpty()
|
.takeIf { it?.isNotBlank() == true }
|
||||||
)?.let {
|
?.let { MmkvManager.decodeServerConfig(it) }
|
||||||
if (it.subscriptionId == subid) {
|
?.takeIf { it.subscriptionId == subid }
|
||||||
return@let it
|
} else {
|
||||||
}
|
null
|
||||||
return@let null
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (!append) {
|
|
||||||
MmkvManager.removeServerViaSubid(subid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val subItem = MmkvManager.decodeSubscription(subid)
|
val subItem = MmkvManager.decodeSubscription(subid)
|
||||||
var count = 0
|
|
||||||
|
// Parse all configs first (no I/O during parsing)
|
||||||
|
val configs = mutableListOf<ProfileItem>()
|
||||||
servers.lines()
|
servers.lines()
|
||||||
.distinct()
|
.distinct()
|
||||||
.reversed()
|
.reversed()
|
||||||
.forEach {
|
.forEach {
|
||||||
val resId = parseConfig(it, subid, subItem, removedSelectedServer)
|
val config = parseConfig(it, subid, subItem)
|
||||||
if (resId == 0) {
|
if (config != null) {
|
||||||
count++
|
configs.add(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return count
|
|
||||||
|
// Batch save all parsed configs (only one serverList read/write)
|
||||||
|
if (configs.isNotEmpty()) {
|
||||||
|
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) {
|
} 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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch save configurations to reduce serverList read/write operations.
|
||||||
|
* Reads serverList once, saves all configs, then writes serverList once.
|
||||||
|
*
|
||||||
|
* @param configs The list of ProfileItem to save.
|
||||||
|
* @param subid The subscription ID.
|
||||||
|
* @return Map of generated keys to their corresponding ProfileItem.
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
// Save profile directly without updating serverList
|
||||||
|
MmkvManager.encodeProfileDirect(key, JsonUtil.toJson(config))
|
||||||
|
|
||||||
|
if (!serverList.contains(key)) {
|
||||||
|
serverList.add(0, key)
|
||||||
|
if (needSetSelected) {
|
||||||
|
MmkvManager.setSelectServer(key)
|
||||||
|
needSetSelected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keyToProfile[key] = config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write serverList once
|
||||||
|
MmkvManager.encodeServerList(serverList, subid)
|
||||||
|
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() || target == null) return null
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a custom configuration server.
|
* Parses a custom configuration server.
|
||||||
*
|
*
|
||||||
* @param server The server string.
|
* @param server The server string.
|
||||||
* @param subid The subscription ID.
|
* @param subid The subscription ID.
|
||||||
|
* @param append Whether to append the configurations.
|
||||||
* @return The number of configurations parsed.
|
* @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) {
|
if (server == null) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -274,6 +371,9 @@ object AngConfigManager {
|
|||||||
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
|
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
|
||||||
|
|
||||||
if (serverList.isNotEmpty()) {
|
if (serverList.isNotEmpty()) {
|
||||||
|
if (!append) {
|
||||||
|
MmkvManager.removeServerViaSubid(subid)
|
||||||
|
}
|
||||||
var count = 0
|
var count = 0
|
||||||
for (srv in serverList.reversed()) {
|
for (srv in serverList.reversed()) {
|
||||||
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
|
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
|
||||||
@@ -286,7 +386,7 @@ object AngConfigManager {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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 {
|
try {
|
||||||
@@ -294,22 +394,28 @@ object AngConfigManager {
|
|||||||
val config = CustomFmt.parse(server) ?: return 0
|
val config = CustomFmt.parse(server) ?: return 0
|
||||||
config.subscriptionId = subid
|
config.subscriptionId = subid
|
||||||
config.description = generateDescription(config)
|
config.description = generateDescription(config)
|
||||||
|
if (!append) {
|
||||||
|
MmkvManager.removeServerViaSubid(subid)
|
||||||
|
}
|
||||||
val key = MmkvManager.encodeServerConfig("", config)
|
val key = MmkvManager.encodeServerConfig("", config)
|
||||||
MmkvManager.encodeServerRaw(key, server)
|
MmkvManager.encodeServerRaw(key, server)
|
||||||
return 1
|
return 1
|
||||||
} catch (e: Exception) {
|
} 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
|
return 0
|
||||||
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
||||||
try {
|
try {
|
||||||
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
|
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
|
||||||
config.description = generateDescription(config)
|
config.description = generateDescription(config)
|
||||||
|
if (!append) {
|
||||||
|
MmkvManager.removeServerViaSubid(subid)
|
||||||
|
}
|
||||||
val key = MmkvManager.encodeServerConfig("", config)
|
val key = MmkvManager.encodeServerConfig("", config)
|
||||||
MmkvManager.encodeServerRaw(key, server)
|
MmkvManager.encodeServerRaw(key, server)
|
||||||
return 1
|
return 1
|
||||||
} catch (e: Exception) {
|
} 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
|
return 0
|
||||||
} else {
|
} else {
|
||||||
@@ -319,22 +425,21 @@ object AngConfigManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the configuration from a QR code or string.
|
* Parses the configuration from a QR code or string.
|
||||||
|
* Only parses and returns ProfileItem, does not save.
|
||||||
*
|
*
|
||||||
* @param str The configuration string.
|
* @param str The configuration string.
|
||||||
* @param subid The subscription ID.
|
* @param subid The subscription ID.
|
||||||
* @param subItem The subscription item.
|
* @param subItem The subscription item.
|
||||||
* @param removedSelectedServer The removed selected server.
|
* @return The parsed ProfileItem or null if parsing fails or filtered out.
|
||||||
* @return The result code.
|
|
||||||
*/
|
*/
|
||||||
private fun parseConfig(
|
private fun parseConfig(
|
||||||
str: String?,
|
str: String?,
|
||||||
subid: String,
|
subid: String,
|
||||||
subItem: SubscriptionItem?,
|
subItem: SubscriptionItem?
|
||||||
removedSelectedServer: ProfileItem?
|
): ProfileItem? {
|
||||||
): Int {
|
|
||||||
try {
|
try {
|
||||||
if (str == null || TextUtils.isEmpty(str)) {
|
if (str == null || TextUtils.isEmpty(str)) {
|
||||||
return R.string.toast_none_data
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
|
val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
|
||||||
@@ -356,105 +461,113 @@ object AngConfigManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
return R.string.toast_incorrect_protocol
|
return null
|
||||||
}
|
}
|
||||||
//filter
|
|
||||||
if (subItem?.filter != null && subItem.filter?.isNotEmpty() == true && config.remarks.isNotEmpty()) {
|
// Apply filter
|
||||||
val matched = Regex(pattern = subItem.filter ?: "")
|
if (subItem?.filter.isNotNullEmpty() && config.remarks.isNotNullEmpty()) {
|
||||||
|
val matched = Regex(pattern = subItem?.filter.orEmpty())
|
||||||
.containsMatchIn(input = config.remarks)
|
.containsMatchIn(input = config.remarks)
|
||||||
if (!matched) return -1
|
if (!matched) return null
|
||||||
}
|
}
|
||||||
|
|
||||||
config.subscriptionId = subid
|
config.subscriptionId = subid
|
||||||
config.description = generateDescription(config)
|
config.description = generateDescription(config)
|
||||||
val guid = MmkvManager.encodeServerConfig("", config)
|
|
||||||
if (removedSelectedServer != null &&
|
return config
|
||||||
config.server == removedSelectedServer.server && config.serverPort == removedSelectedServer.serverPort
|
|
||||||
) {
|
|
||||||
MmkvManager.setSelectServer(guid)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to parse config", e)
|
LogUtil.e(AppConfig.TAG, "Failed to parse config", e)
|
||||||
return -1
|
return null
|
||||||
}
|
}
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the configuration via all subscriptions.
|
* Updates the configuration via all subscriptions.
|
||||||
*
|
*
|
||||||
* @return The number of configurations updated.
|
* @return Detailed result of the subscription update operation.
|
||||||
*/
|
*/
|
||||||
fun updateConfigViaSubAll(): Int {
|
fun updateConfigViaSubAll(): SubscriptionUpdateResult {
|
||||||
var count = 0
|
return try {
|
||||||
try {
|
val subscriptions = MmkvManager.decodeSubscriptions()
|
||||||
MmkvManager.decodeSubscriptions().forEach {
|
subscriptions.fold(SubscriptionUpdateResult()) { acc, subscription ->
|
||||||
count += updateConfigViaSub(it)
|
acc + updateConfigViaSub(subscription)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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)
|
||||||
return 0
|
SubscriptionUpdateResult()
|
||||||
}
|
}
|
||||||
return count
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the configuration via a subscription.
|
* Updates the configuration via a subscription.
|
||||||
*
|
*
|
||||||
* @param it The subscription item.
|
* @param it The subscription item.
|
||||||
* @return The number of configurations updated.
|
* @return Subscription update result.
|
||||||
*/
|
*/
|
||||||
fun updateConfigViaSub(it: SubscriptionCache): Int {
|
fun updateConfigViaSub(it: SubscriptionCache): SubscriptionUpdateResult {
|
||||||
try {
|
try {
|
||||||
|
// Check if disabled
|
||||||
|
if (!it.subscription.enabled) {
|
||||||
|
return SubscriptionUpdateResult(skipCount = 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate subscription info
|
||||||
if (TextUtils.isEmpty(it.guid)
|
if (TextUtils.isEmpty(it.guid)
|
||||||
|| TextUtils.isEmpty(it.subscription.remarks)
|
|| TextUtils.isEmpty(it.subscription.remarks)
|
||||||
|| TextUtils.isEmpty(it.subscription.url)
|
|| TextUtils.isEmpty(it.subscription.url)
|
||||||
) {
|
) {
|
||||||
return 0
|
return SubscriptionUpdateResult(skipCount = 1)
|
||||||
}
|
|
||||||
if (!it.subscription.enabled) {
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val url = HttpUtil.toIdnUrl(it.subscription.url)
|
val url = HttpUtil.toIdnUrl(it.subscription.url)
|
||||||
if (!Utils.isValidUrl(url)) {
|
if (!Utils.isValidUrl(url)) {
|
||||||
return 0
|
return SubscriptionUpdateResult(failureCount = 1)
|
||||||
}
|
}
|
||||||
if (!it.subscription.allowInsecureUrl) {
|
if (!it.subscription.allowInsecureUrl) {
|
||||||
if (!Utils.isValidSubUrl(url)) {
|
if (!Utils.isValidSubUrl(url)) {
|
||||||
return 0
|
return SubscriptionUpdateResult(failureCount = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.i(AppConfig.TAG, url)
|
LogUtil.i(AppConfig.TAG, url)
|
||||||
val userAgent = it.subscription.userAgent
|
val userAgent = it.subscription.userAgent
|
||||||
|
val proxyUsername = SettingsManager.getSocksUsername()
|
||||||
|
val proxyPassword = SettingsManager.getSocksPassword()
|
||||||
|
|
||||||
var configText = try {
|
var configText = try {
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
val httpPort = SettingsManager.getHttpPort()
|
||||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort)
|
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort, proxyUsername, proxyPassword)
|
||||||
} catch (e: Exception) {
|
} 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()) {
|
if (configText.isEmpty()) {
|
||||||
configText = try {
|
configText = try {
|
||||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent)
|
HttpUtil.getUrlContentWithUserAgent(url, userAgent)
|
||||||
} catch (e: Exception) {
|
} 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)
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (configText.isEmpty()) {
|
if (configText.isEmpty()) {
|
||||||
return 0
|
return SubscriptionUpdateResult(failureCount = 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
val count = parseConfigViaSub(configText, it.guid, false)
|
val count = parseConfigViaSub(configText, it.guid, false)
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
it.subscription.lastUpdated = System.currentTimeMillis()
|
it.subscription.lastUpdated = System.currentTimeMillis()
|
||||||
MmkvManager.encodeSubscription(it.guid, it.subscription)
|
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
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Got response but no valid configs parsed
|
||||||
|
return SubscriptionUpdateResult(failureCount = 1)
|
||||||
}
|
}
|
||||||
return count
|
|
||||||
} catch (e: Exception) {
|
} 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 0
|
return SubscriptionUpdateResult(failureCount = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,7 +585,7 @@ object AngConfigManager {
|
|||||||
count = parseBatchConfig(server, subid, append)
|
count = parseBatchConfig(server, subid, append)
|
||||||
}
|
}
|
||||||
if (count <= 0) {
|
if (count <= 0) {
|
||||||
count = parseCustomConfigServer(server, subid)
|
count = parseCustomConfigServer(server, subid, append)
|
||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.v2ray.ang.handler
|
package com.v2ray.ang.handler
|
||||||
|
|
||||||
import com.tencent.mmkv.MMKV
|
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_IS_BOOTED
|
||||||
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
|
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
|
||||||
import com.v2ray.ang.dto.AssetUrlCache
|
import com.v2ray.ang.dto.AssetUrlCache
|
||||||
@@ -18,7 +19,6 @@ object MmkvManager {
|
|||||||
|
|
||||||
//region private
|
//region private
|
||||||
|
|
||||||
//private const val ID_PROFILE_CONFIG = "PROFILE_CONFIG"
|
|
||||||
private const val ID_MAIN = "MAIN"
|
private const val ID_MAIN = "MAIN"
|
||||||
private const val ID_PROFILE_FULL_CONFIG = "PROFILE_FULL_CONFIG"
|
private const val ID_PROFILE_FULL_CONFIG = "PROFILE_FULL_CONFIG"
|
||||||
private const val ID_SERVER_RAW = "SERVER_RAW"
|
private const val ID_SERVER_RAW = "SERVER_RAW"
|
||||||
@@ -28,10 +28,10 @@ object MmkvManager {
|
|||||||
private const val ID_SETTING = "SETTING"
|
private const val ID_SETTING = "SETTING"
|
||||||
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
|
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
|
||||||
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
|
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
|
||||||
|
private const val KEY_SUB_SERVER_PREFIX = "SUB_SERVERS_"
|
||||||
private const val KEY_SUB_IDS = "SUB_IDS"
|
private const val KEY_SUB_IDS = "SUB_IDS"
|
||||||
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
|
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
|
||||||
|
|
||||||
//private val profileStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
|
||||||
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
|
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
|
||||||
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
||||||
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
|
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
|
||||||
@@ -44,6 +44,17 @@ object MmkvManager {
|
|||||||
|
|
||||||
//region Server
|
//region Server
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the legacy server list from KEY_ANG_CONFIGS for migration.
|
||||||
|
* This method is for migration purposes only.
|
||||||
|
*
|
||||||
|
* @return The JSON string of legacy server list, or null if not exists.
|
||||||
|
*/
|
||||||
|
fun readLegacyServerList(): String? {
|
||||||
|
return mainStorage.decodeString(KEY_ANG_CONFIGS)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the selected server GUID.
|
* Gets the selected server GUID.
|
||||||
*
|
*
|
||||||
@@ -63,21 +74,31 @@ object MmkvManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes the server list.
|
* Encodes the server list for a given subscription.
|
||||||
|
* Saves to the subscription's serverList (including default subscription for ungrouped servers).
|
||||||
*
|
*
|
||||||
* @param serverList The list of server GUIDs.
|
* @param serverList The list of server GUIDs.
|
||||||
|
* @param subscriptionId The subscription ID.
|
||||||
*/
|
*/
|
||||||
fun encodeServerList(serverList: MutableList<String>) {
|
fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
|
||||||
mainStorage.encode(KEY_ANG_CONFIGS, JsonUtil.toJson(serverList))
|
val subId = getSubscriptionId(subscriptionId)
|
||||||
|
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
||||||
|
mainStorage.encode(key, JsonUtil.toJson(serverList))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes the server list.
|
* Decodes the server list for a given subscription.
|
||||||
|
* If subscriptionId is empty, returns ungrouped servers.
|
||||||
|
* Otherwise, returns servers from the specified subscription's serverList.
|
||||||
*
|
*
|
||||||
|
* @param subscriptionId The subscription ID.
|
||||||
* @return The list of server GUIDs.
|
* @return The list of server GUIDs.
|
||||||
*/
|
*/
|
||||||
fun decodeServerList(): MutableList<String> {
|
fun decodeServerList(subscriptionId: String): MutableList<String> {
|
||||||
val json = mainStorage.decodeString(KEY_ANG_CONFIGS)
|
val subId = getSubscriptionId(subscriptionId)
|
||||||
|
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
||||||
|
val json = mainStorage.decodeString(key)
|
||||||
return if (json.isNullOrBlank()) {
|
return if (json.isNullOrBlank()) {
|
||||||
mutableListOf()
|
mutableListOf()
|
||||||
} else {
|
} else {
|
||||||
@@ -85,6 +106,30 @@ object MmkvManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes all server list (merged from all subscriptions including default subscription).
|
||||||
|
* Use this when you need the complete server list.
|
||||||
|
*
|
||||||
|
* @return The list of all server GUIDs.
|
||||||
|
*/
|
||||||
|
fun decodeAllServerList(): MutableList<String> {
|
||||||
|
val allServers = mutableListOf<String>()
|
||||||
|
val subsList = decodeSubsList()
|
||||||
|
|
||||||
|
// If DEFAULT_SUBSCRIPTION_ID is not in the subscriptions list, add its servers
|
||||||
|
if (!subsList.contains(DEFAULT_SUBSCRIPTION_ID)) {
|
||||||
|
allServers.addAll(decodeServerList(DEFAULT_SUBSCRIPTION_ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add servers from all subscriptions
|
||||||
|
subsList.forEach { guid ->
|
||||||
|
allServers.addAll(decodeServerList(guid))
|
||||||
|
}
|
||||||
|
|
||||||
|
return allServers
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes the server configuration.
|
* Decodes the server configuration.
|
||||||
*
|
*
|
||||||
@@ -102,16 +147,6 @@ object MmkvManager {
|
|||||||
return JsonUtil.fromJson(json, ProfileItem::class.java)
|
return JsonUtil.fromJson(json, ProfileItem::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fun decodeProfileConfig(guid: String): ProfileLiteItem? {
|
|
||||||
// if (guid.isBlank()) {
|
|
||||||
// return null
|
|
||||||
// }
|
|
||||||
// val json = profileStorage.decodeString(guid)
|
|
||||||
// if (json.isNullOrBlank()) {
|
|
||||||
// return null
|
|
||||||
// }
|
|
||||||
// return JsonUtil.fromJson(json, ProfileLiteItem::class.java)
|
|
||||||
// }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes the server configuration.
|
* Encodes the server configuration.
|
||||||
@@ -123,25 +158,32 @@ object MmkvManager {
|
|||||||
fun encodeServerConfig(guid: String, config: ProfileItem): String {
|
fun encodeServerConfig(guid: String, config: ProfileItem): String {
|
||||||
val key = guid.ifBlank { Utils.getUuid() }
|
val key = guid.ifBlank { Utils.getUuid() }
|
||||||
profileFullStorage.encode(key, JsonUtil.toJson(config))
|
profileFullStorage.encode(key, JsonUtil.toJson(config))
|
||||||
val serverList = decodeServerList()
|
|
||||||
|
// Use default subscription for servers without subscription
|
||||||
|
val subId = getSubscriptionId(config.subscriptionId)
|
||||||
|
val serverList = decodeServerList(subId)
|
||||||
|
|
||||||
if (!serverList.contains(key)) {
|
if (!serverList.contains(key)) {
|
||||||
serverList.add(0, key)
|
serverList.add(0, key)
|
||||||
encodeServerList(serverList)
|
encodeServerList(serverList, subId)
|
||||||
if (getSelectServer().isNullOrBlank()) {
|
if (getSelectServer().isNullOrBlank()) {
|
||||||
mainStorage.encode(KEY_SELECTED_SERVER, key)
|
mainStorage.encode(KEY_SELECTED_SERVER, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// val profile = ProfileLiteItem(
|
|
||||||
// configType = config.configType,
|
|
||||||
// subscriptionId = config.subscriptionId,
|
|
||||||
// remarks = config.remarks,
|
|
||||||
// server = config.getProxyOutbound()?.getServerAddress(),
|
|
||||||
// serverPort = config.getProxyOutbound()?.getServerPort(),
|
|
||||||
// )
|
|
||||||
// profileStorage.encode(key, JsonUtil.toJson(profile))
|
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes the server configuration directly without updating serverList.
|
||||||
|
*
|
||||||
|
* @param key The server GUID.
|
||||||
|
* @param configJson The server configuration JSON string.
|
||||||
|
*/
|
||||||
|
fun encodeProfileDirect(key: String, configJson: String) {
|
||||||
|
profileFullStorage.encode(key, configJson)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the server configuration.
|
* Removes the server configuration.
|
||||||
*
|
*
|
||||||
@@ -151,33 +193,44 @@ object MmkvManager {
|
|||||||
if (guid.isBlank()) {
|
if (guid.isBlank()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get config to determine which subscription to update
|
||||||
|
val config = decodeServerConfig(guid)
|
||||||
|
val subId = getSubscriptionId(config?.subscriptionId)
|
||||||
|
|
||||||
|
// Remove from appropriate server list
|
||||||
|
val serverList = decodeServerList(subId)
|
||||||
|
serverList.remove(guid)
|
||||||
|
encodeServerList(serverList, subId)
|
||||||
|
|
||||||
|
// Clean up storage
|
||||||
if (getSelectServer() == guid) {
|
if (getSelectServer() == guid) {
|
||||||
mainStorage.remove(KEY_SELECTED_SERVER)
|
mainStorage.remove(KEY_SELECTED_SERVER)
|
||||||
}
|
}
|
||||||
val serverList = decodeServerList()
|
|
||||||
serverList.remove(guid)
|
|
||||||
encodeServerList(serverList)
|
|
||||||
profileFullStorage.remove(guid)
|
profileFullStorage.remove(guid)
|
||||||
//profileStorage.remove(guid)
|
|
||||||
serverAffStorage.remove(guid)
|
serverAffStorage.remove(guid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the server configurations via subscription ID.
|
* Removes the server configurations via subscription ID.
|
||||||
*
|
*
|
||||||
* @param subid The subscription ID.
|
* @param subscriptionId The subscription ID.
|
||||||
*/
|
*/
|
||||||
fun removeServerViaSubid(subid: String) {
|
fun removeServerViaSubid(subscriptionId: String?) {
|
||||||
if (subid.isBlank()) {
|
val subId = getSubscriptionId(subscriptionId)
|
||||||
return
|
val serverList = decodeServerList(subId)
|
||||||
}
|
|
||||||
profileFullStorage.allKeys()?.forEach { key ->
|
// Remove all servers in the list
|
||||||
decodeServerConfig(key)?.let { config ->
|
serverList.forEach { guid ->
|
||||||
if (config.subscriptionId == subid) {
|
if (getSelectServer() == guid) {
|
||||||
removeServer(key)
|
mainStorage.remove(KEY_SELECTED_SERVER)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
profileFullStorage.remove(guid)
|
||||||
|
serverAffStorage.remove(guid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
serverList.clear()
|
||||||
|
encodeServerList(serverList, subId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -235,7 +288,6 @@ object MmkvManager {
|
|||||||
val count = profileFullStorage.allKeys()?.count() ?: 0
|
val count = profileFullStorage.allKeys()?.count() ?: 0
|
||||||
mainStorage.clearAll()
|
mainStorage.clearAll()
|
||||||
profileFullStorage.clearAll()
|
profileFullStorage.clearAll()
|
||||||
//profileStorage.clearAll()
|
|
||||||
serverAffStorage.clearAll()
|
serverAffStorage.clearAll()
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
@@ -292,6 +344,10 @@ object MmkvManager {
|
|||||||
|
|
||||||
//region Subscriptions
|
//region Subscriptions
|
||||||
|
|
||||||
|
private fun getSubscriptionId(subscriptionId: String?): String {
|
||||||
|
return subscriptionId?.ifEmpty { DEFAULT_SUBSCRIPTION_ID } ?: DEFAULT_SUBSCRIPTION_ID
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the subscription list.
|
* Initializes the subscription list.
|
||||||
*/
|
*/
|
||||||
@@ -318,7 +374,7 @@ object MmkvManager {
|
|||||||
decodeSubsList().forEach { key ->
|
decodeSubsList().forEach { key ->
|
||||||
val json = subStorage.decodeString(key)
|
val json = subStorage.decodeString(key)
|
||||||
if (!json.isNullOrBlank()) {
|
if (!json.isNullOrBlank()) {
|
||||||
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java)?: SubscriptionItem()
|
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java) ?: SubscriptionItem()
|
||||||
subscriptions.add(SubscriptionCache(key, item))
|
subscriptions.add(SubscriptionCache(key, item))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,7 +442,7 @@ object MmkvManager {
|
|||||||
return if (json.isNullOrBlank()) {
|
return if (json.isNullOrBlank()) {
|
||||||
mutableListOf()
|
mutableListOf()
|
||||||
} else {
|
} else {
|
||||||
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList()?: mutableListOf()
|
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +460,7 @@ object MmkvManager {
|
|||||||
assetStorage.allKeys()?.forEach { key ->
|
assetStorage.allKeys()?.forEach { key ->
|
||||||
val json = assetStorage.decodeString(key)
|
val json = assetStorage.decodeString(key)
|
||||||
if (!json.isNullOrBlank()) {
|
if (!json.isNullOrBlank()) {
|
||||||
val item = JsonUtil.fromJson(json, AssetUrlItem::class.java)?: AssetUrlItem()
|
val item = JsonUtil.fromJson(json, AssetUrlItem::class.java) ?: AssetUrlItem()
|
||||||
assetUrlItems.add(AssetUrlCache(key, item))
|
assetUrlItems.add(AssetUrlCache(key, item))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -454,7 +510,7 @@ object MmkvManager {
|
|||||||
fun decodeRoutingRulesets(): MutableList<RulesetItem>? {
|
fun decodeRoutingRulesets(): MutableList<RulesetItem>? {
|
||||||
val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
|
val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
|
||||||
if (ruleset.isNullOrEmpty()) return null
|
if (ruleset.isNullOrEmpty()) return null
|
||||||
return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java)?.toMutableList()?: mutableListOf()
|
return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java)?.toMutableList() ?: mutableListOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -623,6 +679,7 @@ object MmkvManager {
|
|||||||
return settingsStorage.decodeStringSet(key)
|
return settingsStorage.decodeStringSet(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes the start on boot setting.
|
* Encodes the start on boot setting.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import androidx.annotation.RequiresApi
|
|||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.core.CoreServiceManager
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.extension.toSpeedString
|
import com.v2ray.ang.extension.toSpeedString
|
||||||
import com.v2ray.ang.ui.MainActivity
|
import com.v2ray.ang.ui.MainActivity
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -30,6 +32,7 @@ object NotificationManager {
|
|||||||
private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1
|
private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1
|
||||||
private const val NOTIFICATION_PENDING_INTENT_RESTART_V2RAY = 2
|
private const val NOTIFICATION_PENDING_INTENT_RESTART_V2RAY = 2
|
||||||
private const val NOTIFICATION_ICON_THRESHOLD = 3000
|
private const val NOTIFICATION_ICON_THRESHOLD = 3000
|
||||||
|
private const val QUERY_INTERVAL_MS = 3000L
|
||||||
|
|
||||||
private var lastQueryTime = 0L
|
private var lastQueryTime = 0L
|
||||||
private var mBuilder: NotificationCompat.Builder? = null
|
private var mBuilder: NotificationCompat.Builder? = null
|
||||||
@@ -42,9 +45,8 @@ object NotificationManager {
|
|||||||
*/
|
*/
|
||||||
fun startSpeedNotification(currentConfig: ProfileItem?) {
|
fun startSpeedNotification(currentConfig: ProfileItem?) {
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
|
||||||
if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return
|
if (speedNotificationJob != null || CoreServiceManager.isRunning() == false) return
|
||||||
|
|
||||||
lastQueryTime = System.currentTimeMillis()
|
|
||||||
var lastZeroSpeed = false
|
var lastZeroSpeed = false
|
||||||
val outboundTags = currentConfig?.getAllOutboundTags()
|
val outboundTags = currentConfig?.getAllOutboundTags()
|
||||||
outboundTags?.remove(AppConfig.TAG_DIRECT)
|
outboundTags?.remove(AppConfig.TAG_DIRECT)
|
||||||
@@ -52,19 +54,29 @@ object NotificationManager {
|
|||||||
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
|
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val queryTime = System.currentTimeMillis()
|
val queryTime = System.currentTimeMillis()
|
||||||
val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0
|
val sinceLastQueryIn = (queryTime - lastQueryTime)
|
||||||
|
|
||||||
|
// If the query interval is too short, skip this round to avoid excessive CPU usage
|
||||||
|
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
|
||||||
|
lastQueryTime = queryTime
|
||||||
|
delay(QUERY_INTERVAL_MS)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
|
||||||
|
|
||||||
var proxyTotal = 0L
|
var proxyTotal = 0L
|
||||||
val text = StringBuilder()
|
val text = StringBuilder()
|
||||||
outboundTags?.forEach {
|
outboundTags?.forEach {
|
||||||
val up = V2RayServiceManager.queryStats(it, AppConfig.UPLINK)
|
val up = CoreServiceManager.queryStats(it, AppConfig.UPLINK)
|
||||||
val down = V2RayServiceManager.queryStats(it, AppConfig.DOWNLINK)
|
val down = CoreServiceManager.queryStats(it, AppConfig.DOWNLINK)
|
||||||
if (up + down > 0) {
|
if (up + down > 0) {
|
||||||
appendSpeedString(text, it, up / sinceLastQueryInSeconds, down / sinceLastQueryInSeconds)
|
appendSpeedString(text, it, up / sinceLastQueryInSeconds, down / sinceLastQueryInSeconds)
|
||||||
proxyTotal += up + down
|
proxyTotal += up + down
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val directUplink = V2RayServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.UPLINK)
|
val directUplink = CoreServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.UPLINK)
|
||||||
val directDownlink = V2RayServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.DOWNLINK)
|
val directDownlink = CoreServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.DOWNLINK)
|
||||||
val zeroSpeed = proxyTotal == 0L && directUplink == 0L && directDownlink == 0L
|
val zeroSpeed = proxyTotal == 0L && directUplink == 0L && directDownlink == 0L
|
||||||
if (!zeroSpeed || !lastZeroSpeed) {
|
if (!zeroSpeed || !lastZeroSpeed) {
|
||||||
if (proxyTotal == 0L) {
|
if (proxyTotal == 0L) {
|
||||||
@@ -78,7 +90,7 @@ object NotificationManager {
|
|||||||
}
|
}
|
||||||
lastZeroSpeed = zeroSpeed
|
lastZeroSpeed = zeroSpeed
|
||||||
lastQueryTime = queryTime
|
lastQueryTime = queryTime
|
||||||
delay(3000)
|
delay(QUERY_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,6 +101,10 @@ object NotificationManager {
|
|||||||
*/
|
*/
|
||||||
fun showNotification(currentConfig: ProfileItem?) {
|
fun showNotification(currentConfig: ProfileItem?) {
|
||||||
val service = getService() ?: return
|
val service = getService() ?: return
|
||||||
|
|
||||||
|
// Reset last query time to avoid querying stats too soon after showing the notification
|
||||||
|
lastQueryTime = System.currentTimeMillis()
|
||||||
|
|
||||||
val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
|
||||||
val startMainIntent = Intent(service, MainActivity::class.java)
|
val startMainIntent = Intent(service, MainActivity::class.java)
|
||||||
@@ -236,6 +252,6 @@ object NotificationManager {
|
|||||||
* @return The service instance.
|
* @return The service instance.
|
||||||
*/
|
*/
|
||||||
private fun getService(): Service? {
|
private fun getService(): Service? {
|
||||||
return V2RayServiceManager.serviceControl?.get()?.getService()
|
return CoreServiceManager.serviceControl?.get()?.getService()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,39 +2,57 @@ package com.v2ray.ang.handler
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.AssetManager
|
import android.content.res.AssetManager
|
||||||
|
import android.os.Build
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||||
|
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||||
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
|
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
|
||||||
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
|
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
|
||||||
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
||||||
import com.v2ray.ang.AppConfig.VPN
|
import com.v2ray.ang.AppConfig.VPN
|
||||||
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
import com.v2ray.ang.dto.RulesetItem
|
||||||
|
import com.v2ray.ang.dto.SubscriptionItem
|
||||||
|
import com.v2ray.ang.dto.V2rayConfig
|
||||||
import com.v2ray.ang.enums.EConfigType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.enums.Language
|
import com.v2ray.ang.enums.Language
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
|
||||||
import com.v2ray.ang.enums.RoutingType
|
import com.v2ray.ang.enums.RoutingType
|
||||||
import com.v2ray.ang.dto.RulesetItem
|
|
||||||
import com.v2ray.ang.dto.ServersCache
|
|
||||||
import com.v2ray.ang.dto.V2rayConfig
|
|
||||||
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
|
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
|
||||||
|
import com.v2ray.ang.handler.MmkvManager.decodeAllServerList
|
||||||
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
|
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
|
||||||
import com.v2ray.ang.handler.MmkvManager.decodeServerList
|
import com.v2ray.ang.handler.MmkvManager.decodeSubsList
|
||||||
|
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.JsonUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
object SettingsManager {
|
object SettingsManager {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var runtimeSocksPort: Int? = null
|
||||||
|
|
||||||
|
fun initApp(context: Context) {
|
||||||
|
ensureDefaultSettings()
|
||||||
|
//ensureDefaultSubscription()
|
||||||
|
initRoutingRulesets(context)
|
||||||
|
migrateServerListToSubscriptions()
|
||||||
|
migrateHysteria2PinSHA256()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize routing rulesets.
|
* Initialize routing rulesets.
|
||||||
* @param context The application context.
|
* @param context The application context.
|
||||||
*/
|
*/
|
||||||
fun initRoutingRulesets(context: Context) {
|
private fun initRoutingRulesets(context: Context) {
|
||||||
val exist = MmkvManager.decodeRoutingRulesets()
|
val exist = MmkvManager.decodeRoutingRulesets()
|
||||||
if (exist.isNullOrEmpty()) {
|
if (exist.isNullOrEmpty()) {
|
||||||
val rulesetList = getPresetRoutingRulesets(context)
|
val rulesetList = getPresetRoutingRulesets(context)
|
||||||
@@ -87,7 +105,7 @@ object SettingsManager {
|
|||||||
resetRoutingRulesetsCommon(rulesetList)
|
resetRoutingRulesetsCommon(rulesetList)
|
||||||
return true
|
return true
|
||||||
} catch (e: Exception) {
|
} 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
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +224,7 @@ object SettingsManager {
|
|||||||
* @param toPosition The position to swap to.
|
* @param toPosition The position to swap to.
|
||||||
*/
|
*/
|
||||||
fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
|
fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
|
||||||
val subsList = MmkvManager.decodeSubsList()
|
val subsList = decodeSubsList()
|
||||||
if (subsList.isEmpty()) return
|
if (subsList.isEmpty()) return
|
||||||
|
|
||||||
Collections.swap(subsList, fromPosition, toPosition)
|
Collections.swap(subsList, fromPosition, toPosition)
|
||||||
@@ -222,14 +240,32 @@ object SettingsManager {
|
|||||||
if (remarks.isNullOrEmpty()) {
|
if (remarks.isNullOrEmpty()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val serverList = decodeServerList()
|
val serverList = decodeAllServerList()
|
||||||
for (guid in serverList) {
|
return serverList
|
||||||
val profile = decodeServerConfig(guid)
|
.mapNotNull { guid -> decodeServerConfig(guid) }
|
||||||
if (profile != null && profile.remarks == remarks) {
|
.firstOrNull { it.remarks == remarks }
|
||||||
return profile
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Removes the subscription.
|
||||||
|
* If there are no remaining subscriptions,
|
||||||
|
* it creates a new default subscription to ensure that ungroup
|
||||||
|
**/
|
||||||
|
fun removeSubscriptionWithDefault(subid: String) {
|
||||||
|
SubscriptionUpdater.cancelOne(subId = subid)
|
||||||
|
// Remove the subscription
|
||||||
|
removeSubscription(subid)
|
||||||
|
|
||||||
|
// After removal, check if there are any subscriptions left. If not, create a default subscription.
|
||||||
|
val subsList2 = decodeSubsList()
|
||||||
|
if (subsList2.isNotEmpty()) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return null
|
|
||||||
|
val defaultSub = SubscriptionItem(
|
||||||
|
remarks = "Default",
|
||||||
|
)
|
||||||
|
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -237,7 +273,30 @@ object SettingsManager {
|
|||||||
* @return The SOCKS port.
|
* @return The SOCKS port.
|
||||||
*/
|
*/
|
||||||
fun getSocksPort(): Int {
|
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() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -248,6 +307,14 @@ object SettingsManager {
|
|||||||
return getSocksPort() + if (Utils.isXray()) 0 else 1
|
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.
|
* Initialize assets.
|
||||||
* @param context The application context.
|
* @param context The application context.
|
||||||
@@ -257,7 +324,7 @@ object SettingsManager {
|
|||||||
val extFolder = Utils.userAssetPath(context)
|
val extFolder = Utils.userAssetPath(context)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val geo = arrayOf("geosite.dat", "geoip.dat")
|
val geo = arrayOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
|
||||||
assets.list("")
|
assets.list("")
|
||||||
?.filter { geo.contains(it) }
|
?.filter { geo.contains(it) }
|
||||||
?.filter { !File(extFolder, it).exists() }
|
?.filter { !File(extFolder, it).exists() }
|
||||||
@@ -268,10 +335,10 @@ object SettingsManager {
|
|||||||
input.copyTo(output)
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.e(ANG_PACKAGE, "asset copy failed", e)
|
LogUtil.e(ANG_PACKAGE, "asset copy failed", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,6 +393,15 @@ object SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get real ping concurrency.
|
||||||
|
* @return The number of concurrent real-ping tests (clamped to 1..64).
|
||||||
|
*/
|
||||||
|
fun getRealPingConcurrency(): Int {
|
||||||
|
val value = MmkvManager.decodeSettingsString(AppConfig.PREF_REAL_PING_CONCURRENCY)?.toIntOrNull() ?: 16
|
||||||
|
return value.coerceIn(1, 128)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the locale.
|
* Get the locale.
|
||||||
* @return The locale.
|
* @return The locale.
|
||||||
@@ -397,15 +473,36 @@ object SettingsManager {
|
|||||||
return mode == null || mode == VPN
|
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.
|
* Ensure default settings are present in MMKV.
|
||||||
*/
|
*/
|
||||||
fun ensureDefaultSettings() {
|
private fun ensureDefaultSettings() {
|
||||||
// Write defaults in the exact order requested by the user
|
// 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_DNS, AppConfig.DNS_VPN)
|
||||||
ensureDefaultValue(AppConfig.PREF_VPN_MTU, AppConfig.VPN_MTU.toString())
|
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_SOCKS_PORT, AppConfig.PORT_SOCKS)
|
||||||
ensureDefaultValue(AppConfig.PREF_REMOTE_DNS, AppConfig.DNS_PROXY)
|
ensureDefaultValue(AppConfig.PREF_REMOTE_DNS, AppConfig.DNS_PROXY)
|
||||||
ensureDefaultValue(AppConfig.PREF_DOMESTIC_DNS, AppConfig.DNS_DIRECT)
|
ensureDefaultValue(AppConfig.PREF_DOMESTIC_DNS, AppConfig.DNS_DIRECT)
|
||||||
@@ -424,14 +521,14 @@ object SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun migrateHysteria2PinSHA256() {
|
private fun migrateHysteria2PinSHA256() {
|
||||||
// Check if migration has already been done
|
// Check if migration has already been done
|
||||||
val migrationKey = "hysteria2_pin_sha256_migrated"
|
val migrationKey = "hysteria2_pin_sha256_migrated"
|
||||||
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
|
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val serverList = decodeServerList()
|
val serverList = decodeAllServerList()
|
||||||
|
|
||||||
for (guid in serverList) {
|
for (guid in serverList) {
|
||||||
val profile = decodeServerConfig(guid) ?: continue
|
val profile = decodeServerConfig(guid) ?: continue
|
||||||
@@ -449,4 +546,74 @@ object SettingsManager {
|
|||||||
MmkvManager.encodeSettings(migrationKey, true)
|
MmkvManager.encodeSettings(migrationKey, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates server list from legacy KEY_ANG_CONFIGS to subscription-based storage.
|
||||||
|
* This method should be called once during app initialization after the storage structure change.
|
||||||
|
* Servers are grouped by their subscriptionId into respective subscription's serverList.
|
||||||
|
* Servers without subscription are moved to the default subscription.
|
||||||
|
* After migration, KEY_ANG_CONFIGS is removed.
|
||||||
|
*/
|
||||||
|
private fun migrateServerListToSubscriptions() {
|
||||||
|
// Check if migration has already been done
|
||||||
|
val migrationKey = "server_list_to_subscriptions_migrated"
|
||||||
|
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure default subscription exists before migration
|
||||||
|
ensureDefaultSubscription()
|
||||||
|
|
||||||
|
// Read existing server list from legacy KEY_ANG_CONFIGS
|
||||||
|
val oldJson = MmkvManager.readLegacyServerList()
|
||||||
|
if (oldJson.isNullOrBlank()) {
|
||||||
|
// No data to migrate, mark as done
|
||||||
|
MmkvManager.encodeSettings(migrationKey, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val guids = JsonUtil.fromJson(oldJson, Array<String>::class.java) ?: run {
|
||||||
|
MmkvManager.encodeSettings(migrationKey, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val subscriptionServerMap = mutableMapOf<String, MutableList<String>>()
|
||||||
|
|
||||||
|
// Group servers by subscription (use default subscription for empty subscriptionId)
|
||||||
|
guids.forEach { guid ->
|
||||||
|
val config = decodeServerConfig(guid) ?: return@forEach
|
||||||
|
val subId = config.subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
|
||||||
|
|
||||||
|
subscriptionServerMap.getOrPut(subId) { mutableListOf() }.add(guid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update each subscription's serverList (including default subscription)
|
||||||
|
subscriptionServerMap.forEach { (subId, serverGuids) ->
|
||||||
|
MmkvManager.encodeServerList(serverGuids, subId)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Mark migration as complete
|
||||||
|
MmkvManager.encodeSettings(migrationKey, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the default subscription exists for ungrouped servers.
|
||||||
|
* This subscription is used internally to store servers without a subscription.
|
||||||
|
* Made public for migration in SettingsManager.
|
||||||
|
*/
|
||||||
|
private fun ensureDefaultSubscription() {
|
||||||
|
if (decodeSubscription(DEFAULT_SUBSCRIPTION_ID) == null) {
|
||||||
|
val defaultSub = SubscriptionItem(
|
||||||
|
remarks = "Default",
|
||||||
|
)
|
||||||
|
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
|
||||||
|
|
||||||
|
// Move top
|
||||||
|
val subsList = decodeSubsList()
|
||||||
|
if (subsList.count() > 1) {
|
||||||
|
swapSubscriptions(0, subsList.count() - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
package com.v2ray.ang.handler
|
package com.v2ray.ang.handler
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.dto.IPAPIInfo
|
import com.v2ray.ang.dto.IPAPIInfo
|
||||||
import com.v2ray.ang.util.HttpUtil
|
import com.v2ray.ang.util.HttpUtil
|
||||||
import com.v2ray.ang.util.JsonUtil
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -62,11 +61,11 @@ object SpeedtestManager {
|
|||||||
socket.close()
|
socket.close()
|
||||||
return time
|
return time
|
||||||
} catch (e: UnknownHostException) {
|
} catch (e: UnknownHostException) {
|
||||||
Log.e(AppConfig.TAG, "Unknown host: $url", e)
|
LogUtil.e(AppConfig.TAG, "Unknown host: $url", e)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(AppConfig.TAG, "socketConnectTime IOException: $e")
|
LogUtil.e(AppConfig.TAG, "socketConnectTime IOException: $e")
|
||||||
} catch (e: Exception) {
|
} 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
|
return -1
|
||||||
}
|
}
|
||||||
@@ -83,49 +82,15 @@ 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? {
|
fun getRemoteIPInfo(): String? {
|
||||||
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
|
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
|
||||||
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
|
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
|
||||||
|
|
||||||
|
val proxyUsername = SettingsManager.getSocksUsername()
|
||||||
|
val proxyPassword = SettingsManager.getSocksPassword()
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
val httpPort = SettingsManager.getHttpPort()
|
||||||
val content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: return null
|
if (httpPort == 0) return null
|
||||||
|
val content = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword) ?: return null
|
||||||
val ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
|
val ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
|
||||||
|
|
||||||
val ip = listOf(
|
val ip = listOf(
|
||||||
|
|||||||
@@ -1,62 +1,192 @@
|
|||||||
package com.v2ray.ang.handler
|
package com.v2ray.ang.handler
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import androidx.work.Constraints
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
import androidx.work.WorkerParameters
|
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.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.dto.SubscriptionCache
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
import com.v2ray.ang.util.NotificationHelper
|
||||||
|
import com.v2ray.ang.enums.NotificationChannelType
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
object SubscriptionUpdater {
|
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) :
|
class UpdateTask(context: Context, params: WorkerParameters) :
|
||||||
CoroutineWorker(context, params) {
|
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")
|
@SuppressLint("MissingPermission")
|
||||||
override suspend fun doWork(): Result {
|
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 }
|
if (subId.isNullOrEmpty()) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: missing subId in worker input")
|
||||||
for (sub in subs) {
|
return Result.success()
|
||||||
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}")
|
|
||||||
}
|
}
|
||||||
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()
|
return Result.success()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.v2ray.ang.handler
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.BuildConfig
|
import com.v2ray.ang.BuildConfig
|
||||||
import com.v2ray.ang.dto.CheckUpdateResult
|
import com.v2ray.ang.dto.CheckUpdateResult
|
||||||
@@ -10,6 +9,7 @@ import com.v2ray.ang.dto.GitHubRelease
|
|||||||
import com.v2ray.ang.extension.concatUrl
|
import com.v2ray.ang.extension.concatUrl
|
||||||
import com.v2ray.ang.util.HttpUtil
|
import com.v2ray.ang.util.HttpUtil
|
||||||
import com.v2ray.ang.util.JsonUtil
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -23,10 +23,13 @@ object UpdateCheckerManager {
|
|||||||
AppConfig.APP_API_URL.concatUrl("latest")
|
AppConfig.APP_API_URL.concatUrl("latest")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val proxyUsername = SettingsManager.getSocksUsername()
|
||||||
|
val proxyPassword = SettingsManager.getSocksPassword()
|
||||||
|
|
||||||
var response = HttpUtil.getUrlContent(url, 5000)
|
var response = HttpUtil.getUrlContent(url, 5000)
|
||||||
if (response.isNullOrEmpty()) {
|
if (response.isNullOrEmpty()) {
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
val httpPort = SettingsManager.getHttpPort()
|
||||||
response = HttpUtil.getUrlContent(url, 5000, httpPort)
|
response = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword)
|
||||||
?: throw IllegalStateException("Failed to get response")
|
?: throw IllegalStateException("Failed to get response")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ object UpdateCheckerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val latestVersion = latestRelease.tagName.removePrefix("v")
|
val latestVersion = latestRelease.tagName.removePrefix("v")
|
||||||
Log.i(
|
LogUtil.i(
|
||||||
AppConfig.TAG,
|
AppConfig.TAG,
|
||||||
"Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})"
|
"Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})"
|
||||||
)
|
)
|
||||||
@@ -61,39 +64,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 {
|
private fun compareVersions(version1: String, version2: String): Int {
|
||||||
val v1 = version1.split(".")
|
val v1 = version1.split(".")
|
||||||
val v2 = version2.split(".")
|
val v2 = version2.split(".")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
|||||||
package com.v2ray.ang.handler
|
package com.v2ray.ang.handler
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.dto.WebDavConfig
|
import com.v2ray.ang.dto.WebDavConfig
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.Credentials
|
import okhttp3.Credentials
|
||||||
@@ -67,14 +67,14 @@ object WebDavManager {
|
|||||||
cl.newCall(req).execute().use { resp ->
|
cl.newCall(req).execute().use { resp ->
|
||||||
val success = resp.isSuccessful
|
val success = resp.isSuccessful
|
||||||
if (success) {
|
if (success) {
|
||||||
Log.i(AppConfig.TAG, "WebDAV upload success: $remote")
|
LogUtil.i(AppConfig.TAG, "WebDAV upload success: $remote")
|
||||||
} else {
|
} 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
|
return@withContext success
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
|
LogUtil.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
|
||||||
return@withContext false
|
return@withContext false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ object WebDavManager {
|
|||||||
val req = applyAuth(Request.Builder().url(remote).get()).build()
|
val req = applyAuth(Request.Builder().url(remote).get()).build()
|
||||||
cl.newCall(req).execute().use { resp ->
|
cl.newCall(req).execute().use { resp ->
|
||||||
if (!resp.isSuccessful) {
|
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
|
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
|
return@withContext true
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
|
LogUtil.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
|
||||||
return@withContext false
|
return@withContext false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +169,7 @@ object WebDavManager {
|
|||||||
cl.newCall(req).execute().use { resp ->
|
cl.newCall(req).execute().use { resp ->
|
||||||
// 201 Created or 405 Method Not Allowed (already exists) are acceptable
|
// 201 Created or 405 Method Not Allowed (already exists) are acceptable
|
||||||
if (resp.code != 201 && resp.code != 405 && resp.code != 409) {
|
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) {
|
} catch (_: Exception) {
|
||||||
@@ -177,7 +177,7 @@ object WebDavManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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.ActivityNotFoundException
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper for choosing and creating files using Android Storage Access Framework.
|
* 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))
|
Intent.createChooser(intent, activity.getString(R.string.title_file_chooser))
|
||||||
)
|
)
|
||||||
} catch (ex: ActivityNotFoundException) {
|
} 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)
|
activity.toast(R.string.toast_require_file_manager)
|
||||||
fileChooserCallback?.invoke(null)
|
fileChooserCallback?.invoke(null)
|
||||||
fileChooserCallback = null
|
fileChooserCallback = null
|
||||||
@@ -79,7 +79,7 @@ class FileChooserHelper(private val activity: AppCompatActivity) {
|
|||||||
try {
|
try {
|
||||||
documentCreateLauncher.launch(fileName)
|
documentCreateLauncher.launch(fileName)
|
||||||
} catch (ex: ActivityNotFoundException) {
|
} 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)
|
activity.toast(R.string.toast_require_file_manager)
|
||||||
documentCreateCallback?.invoke(null)
|
documentCreateCallback?.invoke(null)
|
||||||
documentCreateCallback = null
|
documentCreateCallback = null
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.v2ray.ang.AppConfig
|
|||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsChangeManager
|
import com.v2ray.ang.handler.SettingsChangeManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
|
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
|
||||||
@@ -73,11 +74,16 @@ class MmkvPreferenceDataStore : PreferenceDataStore() {
|
|||||||
|
|
||||||
// Internal helper: notify other modules about setting changes
|
// Internal helper: notify other modules about setting changes
|
||||||
private fun notifySettingChanged(key: String) {
|
private fun notifySettingChanged(key: String) {
|
||||||
|
if (key == AppConfig.PREF_LOGLEVEL) {
|
||||||
|
LogUtil.refreshLogLevel()
|
||||||
|
}
|
||||||
|
|
||||||
// Call SettingsManager.setNightMode if UI mode changed
|
// Call SettingsManager.setNightMode if UI mode changed
|
||||||
if (key == AppConfig.PREF_UI_MODE_NIGHT) {
|
if (key == AppConfig.PREF_UI_MODE_NIGHT) {
|
||||||
SettingsManager.setNightMode()
|
SettingsManager.setNightMode()
|
||||||
}
|
}
|
||||||
// Notify listeners that require service restart or reinit
|
// Notify listeners that require service restart or reinit
|
||||||
SettingsChangeManager.makeRestartService()
|
SettingsChangeManager.makeRestartService()
|
||||||
|
SettingsChangeManager.makeSetupGroupTab()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,11 @@ package com.v2ray.ang.receiver
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.V2RayServiceManager
|
import com.v2ray.ang.handler.SubscriptionUpdater
|
||||||
|
import com.v2ray.ang.core.CoreServiceManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
|
||||||
class BootReceiver : BroadcastReceiver() {
|
class BootReceiver : BroadcastReceiver() {
|
||||||
/**
|
/**
|
||||||
@@ -16,8 +19,25 @@ class BootReceiver : BroadcastReceiver() {
|
|||||||
* @param intent The Intent being received.
|
* @param intent The Intent being received.
|
||||||
*/
|
*/
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) return
|
LogUtil.i(AppConfig.TAG, "BootReceiver received: ${intent?.action}")
|
||||||
if (!MmkvManager.decodeStartOnBoot() || MmkvManager.getSelectServer().isNullOrEmpty()) return
|
|
||||||
V2RayServiceManager.startVService(context)
|
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "BootReceiver: Invalid context or action")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!MmkvManager.decodeStartOnBoot()) {
|
||||||
|
LogUtil.i(AppConfig.TAG, "BootReceiver: Auto-start on boot is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "BootReceiver: No server selected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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.content.Intent
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import com.v2ray.ang.AppConfig
|
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() {
|
class TaskerReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
@@ -27,15 +28,15 @@ class TaskerReceiver : BroadcastReceiver() {
|
|||||||
return
|
return
|
||||||
} else if (switch) {
|
} else if (switch) {
|
||||||
if (guid == AppConfig.TASKER_DEFAULT_GUID) {
|
if (guid == AppConfig.TASKER_DEFAULT_GUID) {
|
||||||
V2RayServiceManager.startVServiceFromToggle(context)
|
CoreServiceManager.startVServiceFromToggle(context)
|
||||||
} else {
|
} else {
|
||||||
V2RayServiceManager.startVService(context, guid)
|
CoreServiceManager.startVService(context, guid)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
V2RayServiceManager.stopVService(context)
|
CoreServiceManager.stopVService(context)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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 android.widget.RemoteViews
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.handler.V2RayServiceManager
|
import com.v2ray.ang.core.CoreServiceManager
|
||||||
|
|
||||||
class WidgetProvider : AppWidgetProvider() {
|
class WidgetProvider : AppWidgetProvider() {
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +22,7 @@ class WidgetProvider : AppWidgetProvider() {
|
|||||||
*/
|
*/
|
||||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
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) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
super.onReceive(context, intent)
|
super.onReceive(context, intent)
|
||||||
if (AppConfig.BROADCAST_ACTION_WIDGET_CLICK == intent.action) {
|
if (AppConfig.BROADCAST_ACTION_WIDGET_CLICK == intent.action) {
|
||||||
if (V2RayServiceManager.isRunning()) {
|
if (CoreServiceManager.isRunning()) {
|
||||||
V2RayServiceManager.stopVService(context)
|
CoreServiceManager.stopVService(context)
|
||||||
} else {
|
} else {
|
||||||
V2RayServiceManager.startVServiceFromToggle(context)
|
CoreServiceManager.startVServiceFromToggle(context)
|
||||||
}
|
}
|
||||||
} else if (AppConfig.BROADCAST_ACTION_ACTIVITY == intent.action) {
|
} else if (AppConfig.BROADCAST_ACTION_ACTIVITY == intent.action) {
|
||||||
AppWidgetManager.getInstance(context)?.let { manager ->
|
AppWidgetManager.getInstance(context)?.let { manager ->
|
||||||
|
|||||||
+9
-5
@@ -4,19 +4,22 @@ import android.app.Service
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.contracts.ServiceControl
|
import com.v2ray.ang.contracts.ServiceControl
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.handler.V2RayServiceManager
|
import com.v2ray.ang.core.CoreServiceManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.MyContextWrapper
|
import com.v2ray.ang.util.MyContextWrapper
|
||||||
import java.lang.ref.SoftReference
|
import java.lang.ref.SoftReference
|
||||||
|
|
||||||
class V2RayProxyOnlyService : Service(), ServiceControl {
|
class CoreProxyOnlyService : Service(), ServiceControl {
|
||||||
/**
|
/**
|
||||||
* Initializes the service.
|
* Initializes the service.
|
||||||
*/
|
*/
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
V2RayServiceManager.serviceControl = SoftReference(this)
|
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service created")
|
||||||
|
CoreServiceManager.serviceControl = SoftReference(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +30,8 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
|
|||||||
* @return The start mode.
|
* @return The start mode.
|
||||||
*/
|
*/
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
V2RayServiceManager.startCoreLoop(null)
|
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
|
||||||
|
CoreServiceManager.startCoreLoop(null)
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +40,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
|
|||||||
*/
|
*/
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
V2RayServiceManager.stopCoreLoop()
|
CoreServiceManager.stopCoreLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.v2ray.ang.service
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.dto.RealPingEvent
|
||||||
|
import com.v2ray.ang.dto.TestServiceMessage
|
||||||
|
import com.v2ray.ang.extension.serializable
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.core.CoreNativeManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
import com.v2ray.ang.util.MessageUtil
|
||||||
|
import com.v2ray.ang.util.NotificationHelper
|
||||||
|
import com.v2ray.ang.enums.NotificationChannelType
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
|
class CoreTestService : Service() {
|
||||||
|
|
||||||
|
// manage active batch workers so each batch is independent and cancellable
|
||||||
|
private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the V2Ray environment.
|
||||||
|
*/
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
CoreNativeManager.initCoreEnv(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds the service.
|
||||||
|
* @param intent The intent.
|
||||||
|
* @return The binder.
|
||||||
|
*/
|
||||||
|
override fun onBind(intent: Intent?): IBinder? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up resources when the service is destroyed.
|
||||||
|
*/
|
||||||
|
override fun onDestroy() {
|
||||||
|
LogUtil.i(AppConfig.TAG, "CoreTestService is being destroyed, cancelling ${activeWorkers.size} active workers")
|
||||||
|
// cancel any active workers
|
||||||
|
val snapshot = ArrayList(activeWorkers)
|
||||||
|
snapshot.forEach { it.cancel() }
|
||||||
|
activeWorkers.clear()
|
||||||
|
NotificationHelper.stopForeground(this)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the start command for the service.
|
||||||
|
* @param intent The intent.
|
||||||
|
* @param flags The flags.
|
||||||
|
* @param startId The start ID.
|
||||||
|
* @return The start mode.
|
||||||
|
*/
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val message = intent?.serializable<TestServiceMessage>("content")
|
||||||
|
if (message == null) {
|
||||||
|
stopSelf(startId)
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
when (message.key) {
|
||||||
|
AppConfig.MSG_MEASURE_CONFIG_START -> handleMeasureStart(message, startId)
|
||||||
|
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> handleMeasureCancel()
|
||||||
|
else -> { NotificationHelper.stopForeground(this); stopSelf(startId) }
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMeasureStart(message: TestServiceMessage, startId: Int) {
|
||||||
|
LogUtil.i(AppConfig.TAG, "CoreTestService starting worker subscription ${message.subscriptionId}")
|
||||||
|
|
||||||
|
NotificationHelper.startForeground(
|
||||||
|
this,
|
||||||
|
NotificationChannelType.CORE_TEST,
|
||||||
|
getString(R.string.app_name),
|
||||||
|
getString(R.string.title_real_ping_all_server)
|
||||||
|
)
|
||||||
|
|
||||||
|
val guidsList = when {
|
||||||
|
message.serverGuids.isNotEmpty() -> message.serverGuids
|
||||||
|
message.subscriptionId.isNotEmpty() -> MmkvManager.decodeServerList(message.subscriptionId)
|
||||||
|
else -> MmkvManager.decodeAllServerList()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guidsList.isNotEmpty()) {
|
||||||
|
lateinit var worker: RealPingWorkerService
|
||||||
|
worker = RealPingWorkerService(
|
||||||
|
context = this,
|
||||||
|
guids = guidsList,
|
||||||
|
onEvent = { event -> handleWorkerEvent(event) { activeWorkers.remove(worker) } }
|
||||||
|
)
|
||||||
|
activeWorkers.add(worker)
|
||||||
|
worker.start()
|
||||||
|
} else {
|
||||||
|
NotificationHelper.stopForeground(this)
|
||||||
|
stopSelf(startId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleWorkerEvent(event: RealPingEvent, onWorkerDone: () -> Unit) {
|
||||||
|
when (event) {
|
||||||
|
is RealPingEvent.Progress -> {
|
||||||
|
NotificationHelper.updateNotification(
|
||||||
|
channelType = NotificationChannelType.CORE_TEST,
|
||||||
|
context = this,
|
||||||
|
content = getString(R.string.connection_runing_task_left, event.text)
|
||||||
|
)
|
||||||
|
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, event.text)
|
||||||
|
}
|
||||||
|
is RealPingEvent.Result -> {
|
||||||
|
MmkvManager.encodeServerTestDelayMillis(event.guid, event.delayMillis)
|
||||||
|
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, event.guid)
|
||||||
|
}
|
||||||
|
is RealPingEvent.Finish -> {
|
||||||
|
MessageUtil.sendMsg2UI(this, AppConfig.MSG_MEASURE_CONFIG_FINISH, event.status)
|
||||||
|
onWorkerDone()
|
||||||
|
if (activeWorkers.isEmpty()) {
|
||||||
|
NotificationHelper.stopForeground(this)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMeasureCancel() {
|
||||||
|
LogUtil.i(AppConfig.TAG, "CoreTestService received cancel message, cancelling ${activeWorkers.size} active workers")
|
||||||
|
val snapshot = ArrayList(activeWorkers)
|
||||||
|
snapshot.forEach { it.cancel() }
|
||||||
|
activeWorkers.clear()
|
||||||
|
NotificationHelper.stopForeground(this)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-23
@@ -1,5 +1,6 @@
|
|||||||
package com.v2ray.ang.service
|
package com.v2ray.ang.service
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -13,7 +14,6 @@ import android.net.VpnService
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
import android.util.Log
|
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.LOOPBACK
|
import com.v2ray.ang.AppConfig.LOOPBACK
|
||||||
@@ -23,12 +23,14 @@ import com.v2ray.ang.contracts.Tun2SocksControl
|
|||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.NotificationManager
|
import com.v2ray.ang.handler.NotificationManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.handler.V2RayServiceManager
|
import com.v2ray.ang.core.CoreServiceManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.MyContextWrapper
|
import com.v2ray.ang.util.MyContextWrapper
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.lang.ref.SoftReference
|
import java.lang.ref.SoftReference
|
||||||
|
|
||||||
class V2RayVpnService : VpnService(), ServiceControl {
|
@SuppressLint("VpnServicePolicy")
|
||||||
|
class CoreVpnService : VpnService(), ServiceControl {
|
||||||
private lateinit var mInterface: ParcelFileDescriptor
|
private lateinit var mInterface: ParcelFileDescriptor
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
private var tun2SocksService: Tun2SocksControl? = null
|
private var tun2SocksService: Tun2SocksControl? = null
|
||||||
@@ -72,12 +74,14 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service created")
|
||||||
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
|
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
|
||||||
StrictMode.setThreadPolicy(policy)
|
StrictMode.setThreadPolicy(policy)
|
||||||
V2RayServiceManager.serviceControl = SoftReference(this)
|
CoreServiceManager.serviceControl = SoftReference(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRevoke() {
|
override fun onRevoke() {
|
||||||
|
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Permission revoked")
|
||||||
stopAllService()
|
stopAllService()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,10 +92,27 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
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()
|
NotificationManager.cancelNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service command received")
|
||||||
setupVpnService()
|
setupVpnService()
|
||||||
startService()
|
startService()
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
@@ -103,12 +124,12 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun startService() {
|
override fun startService() {
|
||||||
if (mInterface == null) {
|
if (!::mInterface.isInitialized) {
|
||||||
Log.e(AppConfig.TAG, "Failed to create VPN interface")
|
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Interface not initialized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!V2RayServiceManager.startCoreLoop(mInterface)) {
|
if (!CoreServiceManager.startCoreLoop(mInterface)) {
|
||||||
Log.e(AppConfig.TAG, "Failed to start V2Ray core loop")
|
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to start core loop")
|
||||||
stopAllService()
|
stopAllService()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -136,13 +157,13 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
private fun setupVpnService() {
|
private fun setupVpnService() {
|
||||||
val prepare = prepare(this)
|
val prepare = prepare(this)
|
||||||
if (prepare != null) {
|
if (prepare != null) {
|
||||||
Log.e(AppConfig.TAG, "VPN preparation failed")
|
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Permission not granted")
|
||||||
stopSelf()
|
stopSelf()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configureVpnService() != true) {
|
if (configureVpnService() != true) {
|
||||||
Log.e(AppConfig.TAG, "VPN configuration failed")
|
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Configuration failed")
|
||||||
stopSelf()
|
stopSelf()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -165,9 +186,11 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
|
|
||||||
// Close the old interface since the parameters have been changed
|
// Close the old interface since the parameters have been changed
|
||||||
try {
|
try {
|
||||||
mInterface.close()
|
if (::mInterface.isInitialized) {
|
||||||
} catch (ignored: Exception) {
|
mInterface.close()
|
||||||
// ignored
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "Failed to close old interface", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure platform-specific features
|
// Configure platform-specific features
|
||||||
@@ -179,7 +202,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
isRunning = true
|
isRunning = true
|
||||||
return true
|
return true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to establish VPN interface", e)
|
LogUtil.e(AppConfig.TAG, "Failed to establish VPN interface", e)
|
||||||
stopAllService()
|
stopAllService()
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -210,7 +233,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Configure IPv6 if enabled
|
// 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)
|
builder.addAddress(vpnConfig.ipv6Client, 126)
|
||||||
if (bypassLan) {
|
if (bypassLan) {
|
||||||
builder.addRoute("2000::", 3) // Currently only 1/8 of total IPv6 is in use
|
builder.addRoute("2000::", 3) // Currently only 1/8 of total IPv6 is in use
|
||||||
@@ -230,7 +253,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
builder.setSession(V2RayServiceManager.getRunningServerName())
|
//builder.setSession(V2RayServiceManager.getRunningServerName())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -244,7 +267,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
try {
|
try {
|
||||||
connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
|
connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to request default network", e)
|
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to request network", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +320,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
builder.addAllowedApplication(it)
|
builder.addAllowedApplication(it)
|
||||||
}
|
}
|
||||||
} catch (e: PackageManager.NameNotFoundException) {
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
Log.e(AppConfig.TAG, "Failed to configure app in VPN: ${e.localizedMessage}", e)
|
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to configure app", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,15 +353,15 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
try {
|
try {
|
||||||
connectivity.unregisterNetworkCallback(defaultNetworkCallback)
|
connectivity.unregisterNetworkCallback(defaultNetworkCallback)
|
||||||
} catch (ignored: Exception) {
|
} catch (e: Exception) {
|
||||||
// ignored
|
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Failed to unregister callback", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tun2SocksService?.stopTun2Socks()
|
tun2SocksService?.stopTun2Socks()
|
||||||
tun2SocksService = null
|
tun2SocksService = null
|
||||||
|
|
||||||
V2RayServiceManager.stopCoreLoop()
|
CoreServiceManager.stopCoreLoop()
|
||||||
|
|
||||||
if (isForced) {
|
if (isForced) {
|
||||||
//stopSelf has to be called ahead of mInterface.close(). otherwise v2ray core cannot be stooped
|
//stopSelf has to be called ahead of mInterface.close(). otherwise v2ray core cannot be stooped
|
||||||
@@ -348,10 +371,22 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
|||||||
//which means the first v2ray core somehow failed to stop and release the port.
|
//which means the first v2ray core somehow failed to stop and release the port.
|
||||||
stopSelf()
|
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 {
|
try {
|
||||||
mInterface.close()
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to close VPN interface", e)
|
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.v2ray.ang.service
|
package com.v2ray.ang.service
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -16,7 +16,7 @@ class ProcessService {
|
|||||||
* @param cmd The command to run.
|
* @param cmd The command to run.
|
||||||
*/
|
*/
|
||||||
fun runProcess(context: Context, cmd: MutableList<String>) {
|
fun runProcess(context: Context, cmd: MutableList<String>) {
|
||||||
Log.i(AppConfig.TAG, cmd.toString())
|
LogUtil.i(AppConfig.TAG, cmd.toString())
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val proBuilder = ProcessBuilder(cmd)
|
val proBuilder = ProcessBuilder(cmd)
|
||||||
@@ -27,14 +27,14 @@ class ProcessService {
|
|||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
Thread.sleep(50L)
|
Thread.sleep(50L)
|
||||||
Log.i(AppConfig.TAG, "runProcess check")
|
LogUtil.i(AppConfig.TAG, "runProcess check")
|
||||||
process?.waitFor()
|
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) {
|
} 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() {
|
fun stopProcess() {
|
||||||
try {
|
try {
|
||||||
Log.i(AppConfig.TAG, "runProcess destroy")
|
LogUtil.i(AppConfig.TAG, "runProcess destroy")
|
||||||
process?.destroy()
|
process?.destroy()
|
||||||
} catch (e: Exception) {
|
} 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.graphics.drawable.Icon
|
||||||
import android.service.quicksettings.Tile
|
import android.service.quicksettings.Tile
|
||||||
import android.service.quicksettings.TileService
|
import android.service.quicksettings.TileService
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
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.MessageUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.lang.ref.SoftReference
|
import java.lang.ref.SoftReference
|
||||||
@@ -29,7 +29,7 @@ class QSTileService : TileService() {
|
|||||||
qsTile?.label = getString(R.string.app_name)
|
qsTile?.label = getString(R.string.app_name)
|
||||||
} else if (state == Tile.STATE_ACTIVE) {
|
} else if (state == Tile.STATE_ACTIVE) {
|
||||||
qsTile?.state = Tile.STATE_ACTIVE
|
qsTile?.state = Tile.STATE_ACTIVE
|
||||||
qsTile?.label = V2RayServiceManager.getRunningServerName()
|
qsTile?.label = CoreServiceManager.getRunningServerName()
|
||||||
}
|
}
|
||||||
|
|
||||||
qsTile?.updateTile()
|
qsTile?.updateTile()
|
||||||
@@ -42,7 +42,7 @@ class QSTileService : TileService() {
|
|||||||
override fun onStartListening() {
|
override fun onStartListening() {
|
||||||
super.onStartListening()
|
super.onStartListening()
|
||||||
|
|
||||||
if (V2RayServiceManager.isRunning()) {
|
if (CoreServiceManager.isRunning()) {
|
||||||
setState(Tile.STATE_ACTIVE)
|
setState(Tile.STATE_ACTIVE)
|
||||||
} else {
|
} else {
|
||||||
setState(Tile.STATE_INACTIVE)
|
setState(Tile.STATE_INACTIVE)
|
||||||
@@ -63,7 +63,7 @@ class QSTileService : TileService() {
|
|||||||
applicationContext.unregisterReceiver(mMsgReceive)
|
applicationContext.unregisterReceiver(mMsgReceive)
|
||||||
mMsgReceive = null
|
mMsgReceive = null
|
||||||
} catch (e: Exception) {
|
} 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()
|
super.onClick()
|
||||||
when (qsTile.state) {
|
when (qsTile.state) {
|
||||||
Tile.STATE_INACTIVE -> {
|
Tile.STATE_INACTIVE -> {
|
||||||
V2RayServiceManager.startVServiceFromToggle(this)
|
CoreServiceManager.startVServiceFromToggle(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
Tile.STATE_ACTIVE -> {
|
Tile.STATE_ACTIVE -> {
|
||||||
V2RayServiceManager.stopVService(this)
|
CoreServiceManager.stopVService(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
package com.v2ray.ang.service
|
package com.v2ray.ang.service
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.dto.RealPingEvent
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.handler.V2RayNativeManager
|
import com.v2ray.ang.core.CoreNativeManager
|
||||||
import com.v2ray.ang.handler.V2rayConfigManager
|
import com.v2ray.ang.core.CoreConfigManager
|
||||||
import com.v2ray.ang.util.MessageUtil
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineName
|
import kotlinx.coroutines.CoroutineName
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -23,11 +22,11 @@ import java.util.concurrent.atomic.AtomicInteger
|
|||||||
class RealPingWorkerService(
|
class RealPingWorkerService(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val guids: List<String>,
|
private val guids: List<String>,
|
||||||
private val onFinish: (status: String) -> Unit = {}
|
private val onEvent: (RealPingEvent) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
private val job = SupervisorJob()
|
private val job = SupervisorJob()
|
||||||
private val cpu = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
|
private val concurrency = SettingsManager.getRealPingConcurrency()
|
||||||
private val dispatcher = Executors.newFixedThreadPool(cpu * 4).asCoroutineDispatcher()
|
private val dispatcher = Executors.newFixedThreadPool(concurrency).asCoroutineDispatcher()
|
||||||
private val scope = CoroutineScope(job + dispatcher + CoroutineName("RealPingBatchWorker"))
|
private val scope = CoroutineScope(job + dispatcher + CoroutineName("RealPingBatchWorker"))
|
||||||
|
|
||||||
private val runningCount = AtomicInteger(0)
|
private val runningCount = AtomicInteger(0)
|
||||||
@@ -40,11 +39,13 @@ class RealPingWorkerService(
|
|||||||
runningCount.incrementAndGet()
|
runningCount.incrementAndGet()
|
||||||
try {
|
try {
|
||||||
val result = startRealPing(guid)
|
val result = startRealPing(guid)
|
||||||
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
|
onEvent(RealPingEvent.Result(guid, result))
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
} finally {
|
} finally {
|
||||||
val count = totalCount.decrementAndGet()
|
val count = totalCount.decrementAndGet()
|
||||||
val left = runningCount.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 {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
joinAll(*jobs.toTypedArray())
|
joinAll(*jobs.toTypedArray())
|
||||||
onFinish("0")
|
onEvent(RealPingEvent.Finish("0"))
|
||||||
} catch (_: CancellationException) {
|
} catch (_: CancellationException) {
|
||||||
onFinish("-1")
|
onEvent(RealPingEvent.Finish("-1"))
|
||||||
} finally {
|
} finally {
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
@@ -75,11 +76,10 @@ class RealPingWorkerService(
|
|||||||
|
|
||||||
private fun startRealPing(guid: String): Long {
|
private fun startRealPing(guid: String): Long {
|
||||||
val retFailure = -1L
|
val retFailure = -1L
|
||||||
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
|
val configResult = CoreConfigManager.getV2rayConfig4Speedtest(context, guid)
|
||||||
if (!configResult.status) {
|
if (!configResult.status) {
|
||||||
return retFailure
|
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.content.Context
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.contracts.Tun2SocksControl
|
import com.v2ray.ang.contracts.Tun2SocksControl
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,9 +22,11 @@ class TProxyService(
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
@Suppress("FunctionName")
|
@Suppress("FunctionName")
|
||||||
private external fun TProxyStartService(configPath: String, fd: Int)
|
private external fun TProxyStartService(configPath: String, fd: Int)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
@Suppress("FunctionName")
|
@Suppress("FunctionName")
|
||||||
private external fun TProxyStopService()
|
private external fun TProxyStopService()
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
@Suppress("FunctionName")
|
@Suppress("FunctionName")
|
||||||
private external fun TProxyGetStats(): LongArray?
|
private external fun TProxyGetStats(): LongArray?
|
||||||
@@ -38,32 +40,36 @@ class TProxyService(
|
|||||||
* Starts the tun2socks process with the appropriate parameters.
|
* Starts the tun2socks process with the appropriate parameters.
|
||||||
*/
|
*/
|
||||||
override fun startTun2Socks() {
|
override fun startTun2Socks() {
|
||||||
// Log.i(AppConfig.TAG, "Starting HevSocks5Tunnel via JNI")
|
// LogUtil.i(AppConfig.TAG, "Starting HevSocks5Tunnel via JNI")
|
||||||
|
|
||||||
val configContent = buildConfig()
|
val configContent = buildConfig()
|
||||||
val configFile = File(context.filesDir, "hev-socks5-tunnel.yaml").apply {
|
val configFile = File(context.filesDir, "hev-socks5-tunnel.yaml").apply {
|
||||||
writeText(configContent)
|
writeText(configContent)
|
||||||
}
|
}
|
||||||
// Log.i(AppConfig.TAG, "Config file created: ${configFile.absolutePath}")
|
// LogUtil.i(AppConfig.TAG, "Config file created: ${configFile.absolutePath}")
|
||||||
Log.d(AppConfig.TAG, "HevSocks5Tunnel Config content:\n$configContent")
|
LogUtil.d(AppConfig.TAG, "HevSocks5Tunnel Config content:\n$configContent")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Log.i(AppConfig.TAG, "TProxyStartService...")
|
// LogUtil.i(AppConfig.TAG, "TProxyStartService...")
|
||||||
TProxyStartService(configFile.absolutePath, vpnInterface.fd)
|
TProxyStartService(configFile.absolutePath, vpnInterface.fd)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "HevSocks5Tunnel exception: ${e.message}")
|
LogUtil.e(AppConfig.TAG, "HevSocks5Tunnel exception: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildConfig(): String {
|
private fun buildConfig(): String {
|
||||||
val socksPort = SettingsManager.getSocksPort()
|
val socksPort = SettingsManager.getSocksPort()
|
||||||
|
val socksUsername = SettingsManager.getSocksUsername()
|
||||||
|
val socksPassword = SettingsManager.getSocksPassword()
|
||||||
val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
|
val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
|
||||||
|
val escapedSocksUsername = socksUsername?.replace("'", "''")
|
||||||
|
val escapedSocksPassword = socksPassword?.replace("'", "''")
|
||||||
return buildString {
|
return buildString {
|
||||||
appendLine("tunnel:")
|
appendLine("tunnel:")
|
||||||
appendLine(" mtu: ${SettingsManager.getVpnMtu()}")
|
appendLine(" mtu: ${SettingsManager.getVpnMtu()}")
|
||||||
appendLine(" ipv4: ${vpnConfig.ipv4Client}")
|
appendLine(" ipv4: ${vpnConfig.ipv4Client}")
|
||||||
|
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6)) {
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED)) {
|
||||||
appendLine(" ipv6: '${vpnConfig.ipv6Client}'")
|
appendLine(" ipv6: '${vpnConfig.ipv6Client}'")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +77,10 @@ class TProxyService(
|
|||||||
appendLine(" port: ${socksPort}")
|
appendLine(" port: ${socksPort}")
|
||||||
appendLine(" address: ${AppConfig.LOOPBACK}")
|
appendLine(" address: ${AppConfig.LOOPBACK}")
|
||||||
appendLine(" udp: 'udp'")
|
appendLine(" udp: 'udp'")
|
||||||
|
if (escapedSocksUsername != null && escapedSocksPassword != null) {
|
||||||
|
appendLine(" username: '${escapedSocksUsername}'")
|
||||||
|
appendLine(" password: '${escapedSocksPassword}'")
|
||||||
|
}
|
||||||
|
|
||||||
// Read-write timeout settings
|
// Read-write timeout settings
|
||||||
val timeoutSetting = MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) ?: AppConfig.HEVTUN_RW_TIMEOUT
|
val timeoutSetting = MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) ?: AppConfig.HEVTUN_RW_TIMEOUT
|
||||||
@@ -92,10 +102,10 @@ class TProxyService(
|
|||||||
*/
|
*/
|
||||||
override fun stopTun2Socks() {
|
override fun stopTun2Socks() {
|
||||||
try {
|
try {
|
||||||
Log.i(AppConfig.TAG, "TProxyStopService...")
|
LogUtil.i(AppConfig.TAG, "TProxyStopService...")
|
||||||
TProxyStopService()
|
TProxyStopService()
|
||||||
} catch (e: Exception) {
|
} 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,79 +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.extension.serializable
|
|
||||||
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 {
|
|
||||||
when (intent?.getIntExtra("key", 0)) {
|
|
||||||
MSG_MEASURE_CONFIG -> {
|
|
||||||
val guidsList = intent.serializable<ArrayList<String>>("content")
|
|
||||||
if (guidsList != null && guidsList.isNotEmpty()) {
|
|
||||||
lateinit var worker: RealPingWorkerService
|
|
||||||
worker = RealPingWorkerService(this, guidsList) { status ->
|
|
||||||
// notify UI and remove the worker from active list when finished
|
|
||||||
MessageUtil.sendMsg2UI(this@V2RayTestService, AppConfig.MSG_MEASURE_CONFIG_FINISH, status)
|
|
||||||
activeWorkers.remove(worker)
|
|
||||||
}
|
|
||||||
activeWorkers.add(worker)
|
|
||||||
worker.start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MSG_MEASURE_CONFIG_CANCEL -> {
|
|
||||||
// cancel all running batch workers independently
|
|
||||||
val snapshot = ArrayList(activeWorkers)
|
|
||||||
snapshot.forEach { it.cancel() }
|
|
||||||
activeWorkers.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return super.onStartCommand(intent, flags, startId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import com.v2ray.ang.AppConfig
|
|||||||
import com.v2ray.ang.BuildConfig
|
import com.v2ray.ang.BuildConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.databinding.ActivityAboutBinding
|
import com.v2ray.ang.databinding.ActivityAboutBinding
|
||||||
import com.v2ray.ang.handler.V2RayNativeManager
|
import com.v2ray.ang.core.CoreNativeManager
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
|
|
||||||
class AboutActivity : BaseActivity() {
|
class AboutActivity : BaseActivity() {
|
||||||
@@ -42,7 +42,7 @@ class AboutActivity : BaseActivity() {
|
|||||||
Utils.openUri(this, AppConfig.APP_PRIVACY_POLICY)
|
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
|
binding.tvVersion.text = it
|
||||||
}
|
}
|
||||||
BuildConfig.APPLICATION_ID.also {
|
BuildConfig.APPLICATION_ID.also {
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
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 com.v2ray.ang.util.PackageUidResolver
|
||||||
|
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.app.AlertDialog
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.tencent.mmkv.MMKV
|
import com.tencent.mmkv.MMKV
|
||||||
@@ -18,7 +17,9 @@ import com.v2ray.ang.extension.toastError
|
|||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsChangeManager
|
import com.v2ray.ang.handler.SettingsChangeManager
|
||||||
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.handler.WebDavManager
|
import com.v2ray.ang.handler.WebDavManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.ZipUtil
|
import com.v2ray.ang.util.ZipUtil
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -37,7 +38,7 @@ class BackupActivity : HelperBaseActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
//setContentView(binding.root)
|
//setContentView(binding.root)
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_configuration_backup_restore))
|
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_configuration_backup_restore))
|
||||||
|
|
||||||
binding.layoutBackup.setOnClickListener {
|
binding.layoutBackup.setOnClickListener {
|
||||||
@@ -124,6 +125,8 @@ class BackupActivity : HelperBaseActivity() {
|
|||||||
val count = MMKV.restoreAllFromDirectory(backupDir)
|
val count = MMKV.restoreAllFromDirectory(backupDir)
|
||||||
SettingsChangeManager.makeSetupGroupTab()
|
SettingsChangeManager.makeSetupGroupTab()
|
||||||
SettingsChangeManager.makeRestartService()
|
SettingsChangeManager.makeRestartService()
|
||||||
|
|
||||||
|
SettingsManager.initApp(this)
|
||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +149,7 @@ class BackupActivity : HelperBaseActivity() {
|
|||||||
toastError(R.string.toast_failure)
|
toastError(R.string.toast_failure)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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)
|
toastError(R.string.toast_failure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,7 +180,7 @@ class BackupActivity : HelperBaseActivity() {
|
|||||||
toastError(R.string.toast_failure)
|
toastError(R.string.toast_failure)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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)
|
toastError(R.string.toast_failure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +217,7 @@ class BackupActivity : HelperBaseActivity() {
|
|||||||
val ok = try {
|
val ok = try {
|
||||||
WebDavManager.uploadFile(tempFile, WEBDAV_BACKUP_FILE_NAME)
|
WebDavManager.uploadFile(tempFile, WEBDAV_BACKUP_FILE_NAME)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "WebDAV upload error", e)
|
LogUtil.e(AppConfig.TAG, "WebDAV upload error", e)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +225,7 @@ class BackupActivity : HelperBaseActivity() {
|
|||||||
if (ok) toastSuccess(R.string.toast_success) else toastError(R.string.toast_failure)
|
if (ok) toastSuccess(R.string.toast_success) else toastError(R.string.toast_failure)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "WebDAV backup error", e)
|
LogUtil.e(AppConfig.TAG, "WebDAV backup error", e)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
toastError(R.string.toast_failure)
|
toastError(R.string.toast_failure)
|
||||||
}
|
}
|
||||||
@@ -269,7 +272,7 @@ class BackupActivity : HelperBaseActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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) }
|
withContext(Dispatchers.Main) { toastError(R.string.toast_failure) }
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
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 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).
|
* @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
|
// Get the drawable from resources
|
||||||
val drawable = ContextCompat.getDrawable(requireContext(), drawableResId)
|
val drawable = ContextCompat.getDrawable(requireContext(), drawableResId)
|
||||||
requireNotNull(drawable) { "Drawable resource not found" }
|
requireNotNull(drawable) { "Drawable resource not found" }
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
@@ -14,7 +13,8 @@ import com.v2ray.ang.extension.toastError
|
|||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.UpdateCheckerManager
|
import com.v2ray.ang.handler.UpdateCheckerManager
|
||||||
import com.v2ray.ang.handler.V2RayNativeManager
|
import com.v2ray.ang.core.CoreNativeManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ class CheckUpdateActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
binding.checkPreRelease.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, false)
|
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
|
binding.tvVersion.text = it
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,10 +56,9 @@ class CheckUpdateActivity : BaseActivity() {
|
|||||||
toastSuccess(R.string.update_already_latest_version)
|
toastSuccess(R.string.update_already_latest_version)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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))
|
toastError(e.message ?: getString(R.string.toast_failure))
|
||||||
}
|
} finally {
|
||||||
finally {
|
|
||||||
hideLoading()
|
hideLoading()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.v2ray.ang.ui
|
|||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -11,24 +10,27 @@ import androidx.fragment.app.activityViewModels
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.contracts.MainAdapterListener
|
import com.v2ray.ang.contracts.MainAdapterListener
|
||||||
import com.v2ray.ang.databinding.FragmentGroupServerBinding
|
import com.v2ray.ang.databinding.FragmentGroupServerBinding
|
||||||
import com.v2ray.ang.databinding.ItemQrcodeBinding
|
import com.v2ray.ang.databinding.ItemQrcodeBinding
|
||||||
import com.v2ray.ang.enums.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastError
|
import com.v2ray.ang.extension.toastError
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.AngConfigManager
|
import com.v2ray.ang.handler.AngConfigManager
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.viewmodel.MainViewModel
|
import com.v2ray.ang.viewmodel.MainViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
|
||||||
|
SwipeRefreshLayout.OnRefreshListener {
|
||||||
private val ownerActivity: MainActivity
|
private val ownerActivity: MainActivity
|
||||||
get() = requireActivity() as MainActivity
|
get() = requireActivity() as MainActivity
|
||||||
private val mainViewModel: MainViewModel by activityViewModels()
|
private val mainViewModel: MainViewModel by activityViewModels()
|
||||||
@@ -68,15 +70,20 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
|||||||
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
|
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
|
||||||
itemTouchHelper?.attachToRecyclerView(binding.recyclerView)
|
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 ->
|
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
|
||||||
if (mainViewModel.subscriptionId != subId) {
|
if (mainViewModel.subscriptionId != subId) {
|
||||||
return@observe
|
return@observe
|
||||||
}
|
}
|
||||||
// Log.d(TAG, "GroupServerFragment updateListAction subId=$subId")
|
// LogUtil.d(TAG, "GroupServerFragment updateListAction subId=$subId")
|
||||||
adapter.setData(mainViewModel.serversCache, index)
|
adapter.setData(mainViewModel.serversCache, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
|
// LogUtil.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
@@ -105,7 +112,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
|||||||
else -> ownerActivity.toast("else")
|
else -> ownerActivity.toast("else")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Error when sharing server", e)
|
LogUtil.e(AppConfig.TAG, "Error when sharing server", e)
|
||||||
}
|
}
|
||||||
}.show()
|
}.show()
|
||||||
}
|
}
|
||||||
@@ -142,7 +149,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
|||||||
* @param guid The server unique identifier
|
* @param guid The server unique identifier
|
||||||
*/
|
*/
|
||||||
private fun shareFullContent(guid: String) {
|
private fun shareFullContent(guid: String) {
|
||||||
ownerActivity.lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val result = AngConfigManager.shareFullContent2Clipboard(ownerActivity, guid)
|
val result = AngConfigManager.shareFullContent2Clipboard(ownerActivity, guid)
|
||||||
launch(Dispatchers.Main) {
|
launch(Dispatchers.Main) {
|
||||||
if (result == 0) {
|
if (result == 0) {
|
||||||
@@ -164,6 +171,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
|||||||
val intent = Intent().putExtra("guid", guid)
|
val intent = Intent().putExtra("guid", guid)
|
||||||
.putExtra("isRunning", mainViewModel.isRunning.value)
|
.putExtra("isRunning", mainViewModel.isRunning.value)
|
||||||
.putExtra("createConfigType", profile.configType.value)
|
.putExtra("createConfigType", profile.configType.value)
|
||||||
|
.putExtra("subscriptionId", subId)
|
||||||
when (profile.configType) {
|
when (profile.configType) {
|
||||||
EConfigType.CUSTOM -> {
|
EConfigType.CUSTOM -> {
|
||||||
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerCustomConfigActivity::class.java))
|
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerCustomConfigActivity::class.java))
|
||||||
@@ -173,6 +181,10 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
|||||||
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerGroupActivity::class.java))
|
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerGroupActivity::class.java))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EConfigType.PROXYCHAIN -> {
|
||||||
|
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerProxyChainActivity::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerActivity::class.java))
|
ownerActivity.startActivity(intent.setClass(ownerActivity, ServerActivity::class.java))
|
||||||
}
|
}
|
||||||
@@ -211,7 +223,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
|||||||
* @param position The position in the list
|
* @param position The position in the list
|
||||||
*/
|
*/
|
||||||
private fun removeServerSub(guid: String, position: Int) {
|
private fun removeServerSub(guid: String, position: Int) {
|
||||||
ownerActivity.mainViewModel.removeServer(guid)
|
mainViewModel.removeServer(guid)
|
||||||
adapter.removeServerSub(guid, position)
|
adapter.removeServerSub(guid, position)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +269,9 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
|
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
|
||||||
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
|
val isCustom = profile.configType == EConfigType.CUSTOM
|
||||||
|
|| profile.configType == EConfigType.POLICYGROUP
|
||||||
|
|| profile.configType == EConfigType.PROXYCHAIN
|
||||||
|
|
||||||
val (shareOptions, skip) = if (more) {
|
val (shareOptions, skip) = if (more) {
|
||||||
val options = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
|
val options = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
|
||||||
@@ -270,4 +284,43 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
|||||||
shareServer(guid, profile, position, shareOptions, skip)
|
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.
|
* - Provide QR code scanning helpers with camera permission handling.
|
||||||
*/
|
*/
|
||||||
abstract class HelperBaseActivity : BaseActivity() {
|
abstract class HelperBaseActivity : BaseActivity() {
|
||||||
private lateinit var fileChooser : FileChooserHelper
|
private lateinit var fileChooser: FileChooserHelper
|
||||||
private lateinit var permissionRequester : PermissionHelper
|
private lateinit var permissionRequester: PermissionHelper
|
||||||
private lateinit var qrCodeScanner : QRCodeScannerHelper
|
private lateinit var qrCodeScanner: QRCodeScannerHelper
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
|
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.viewmodel.LogcatViewModel
|
import com.v2ray.ang.viewmodel.LogcatViewModel
|
||||||
|
|
||||||
class LogcatRecyclerAdapter(
|
class LogcatRecyclerAdapter(
|
||||||
@@ -33,7 +33,7 @@ class LogcatRecyclerAdapter(
|
|||||||
onLongClick?.invoke(log) ?: false
|
onLongClick?.invoke(log) ?: false
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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.Uri
|
||||||
import android.net.VpnService
|
import android.net.VpnService
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
@@ -32,7 +31,9 @@ import com.v2ray.ang.handler.AngConfigManager
|
|||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsChangeManager
|
import com.v2ray.ang.handler.SettingsChangeManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.handler.V2RayServiceManager
|
import com.v2ray.ang.handler.SubscriptionUpdater
|
||||||
|
import com.v2ray.ang.core.CoreServiceManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import com.v2ray.ang.viewmodel.MainViewModel
|
import com.v2ray.ang.viewmodel.MainViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -98,6 +99,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
|||||||
|
|
||||||
setupGroupTab()
|
setupGroupTab()
|
||||||
setupViewModel()
|
setupViewModel()
|
||||||
|
SubscriptionUpdater.sync()
|
||||||
mainViewModel.reloadServerList()
|
mainViewModel.reloadServerList()
|
||||||
|
|
||||||
checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) {
|
checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) {
|
||||||
@@ -135,7 +137,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
|||||||
applyRunningState(isLoading = true, isRunning = false)
|
applyRunningState(isLoading = true, isRunning = false)
|
||||||
|
|
||||||
if (mainViewModel.isRunning.value == true) {
|
if (mainViewModel.isRunning.value == true) {
|
||||||
V2RayServiceManager.stopVService(this)
|
CoreServiceManager.stopVService(this)
|
||||||
} else if (SettingsManager.isVpnMode()) {
|
} else if (SettingsManager.isVpnMode()) {
|
||||||
val intent = VpnService.prepare(this)
|
val intent = VpnService.prepare(this)
|
||||||
if (intent == null) {
|
if (intent == null) {
|
||||||
@@ -162,12 +164,12 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
|||||||
toast(R.string.title_file_chooser)
|
toast(R.string.title_file_chooser)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
V2RayServiceManager.startVService(this)
|
CoreServiceManager.startVService(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun restartV2Ray() {
|
fun restartV2Ray() {
|
||||||
if (mainViewModel.isRunning.value == true) {
|
if (mainViewModel.isRunning.value == true) {
|
||||||
V2RayServiceManager.stopVService(this)
|
CoreServiceManager.stopVService(this)
|
||||||
}
|
}
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
delay(500)
|
delay(500)
|
||||||
@@ -179,7 +181,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
|||||||
binding.tvTestState.text = content
|
binding.tvTestState.text = content
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
|
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
binding.fab.setImageResource(R.drawable.ic_fab_check)
|
binding.fab.setImageResource(R.drawable.ic_fab_check)
|
||||||
return
|
return
|
||||||
@@ -252,6 +254,11 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
R.id.import_manually_proxy_chain -> {
|
||||||
|
importManually(EConfigType.PROXYCHAIN.value)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
R.id.import_manually_vmess -> {
|
R.id.import_manually_vmess -> {
|
||||||
importManually(EConfigType.VMESS.value)
|
importManually(EConfigType.VMESS.value)
|
||||||
true
|
true
|
||||||
@@ -339,6 +346,10 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
R.id.locate_selected_config -> {
|
||||||
|
locateSelectedServer()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
@@ -350,6 +361,12 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
|||||||
.putExtra("subscriptionId", mainViewModel.subscriptionId)
|
.putExtra("subscriptionId", mainViewModel.subscriptionId)
|
||||||
.setClass(this, ServerGroupActivity::class.java)
|
.setClass(this, ServerGroupActivity::class.java)
|
||||||
)
|
)
|
||||||
|
} else if (createConfigType == EConfigType.PROXYCHAIN.value) {
|
||||||
|
startActivity(
|
||||||
|
Intent()
|
||||||
|
.putExtra("subscriptionId", mainViewModel.subscriptionId)
|
||||||
|
.setClass(this, ServerProxyChainActivity::class.java)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
startActivity(
|
startActivity(
|
||||||
Intent()
|
Intent()
|
||||||
@@ -381,7 +398,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
|||||||
val clipboard = Utils.getClipboard(this)
|
val clipboard = Utils.getClipboard(this)
|
||||||
importBatchConfig(clipboard)
|
importBatchConfig(clipboard)
|
||||||
} catch (e: Exception) {
|
} 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 false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -411,7 +428,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
|||||||
toastError(R.string.toast_failure)
|
toastError(R.string.toast_failure)
|
||||||
hideLoading()
|
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 {
|
try {
|
||||||
showFileChooser()
|
showFileChooser()
|
||||||
} catch (e: Exception) {
|
} 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 false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -433,18 +450,27 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
|||||||
/**
|
/**
|
||||||
* import config from sub
|
* import config from sub
|
||||||
*/
|
*/
|
||||||
private fun importConfigViaSub(): Boolean {
|
fun importConfigViaSub(): Boolean {
|
||||||
showLoading()
|
showLoading()
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val count = mainViewModel.updateConfigViaSubAll()
|
val result = mainViewModel.updateConfigViaSubAll()
|
||||||
delay(500L)
|
delay(500L)
|
||||||
launch(Dispatchers.Main) {
|
launch(Dispatchers.Main) {
|
||||||
if (count > 0) {
|
if (result.successCount + result.failureCount + result.skipCount == 0) {
|
||||||
toast(getString(R.string.title_update_config_count, count))
|
toast(R.string.title_update_subscription_no_subscription)
|
||||||
mainViewModel.reloadServerList()
|
} else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
|
||||||
|
toast(getString(R.string.title_update_config_count, result.configCount))
|
||||||
} else {
|
} else {
|
||||||
toastError(R.string.toast_failure)
|
toast(
|
||||||
|
getString(
|
||||||
|
R.string.title_update_subscription_result,
|
||||||
|
result.configCount, result.successCount, result.failureCount, result.skipCount
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (result.configCount > 0) {
|
||||||
|
mainViewModel.reloadServerList()
|
||||||
}
|
}
|
||||||
hideLoading()
|
hideLoading()
|
||||||
}
|
}
|
||||||
@@ -556,7 +582,48 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
|||||||
importBatchConfig(input?.bufferedReader()?.readText())
|
importBatchConfig(input?.bufferedReader()?.readText())
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ class MainRecyclerAdapter(
|
|||||||
adapterListener?.onSelectServer(guid)
|
adapterListener?.onSelectServer(guid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
@@ -23,6 +22,7 @@ import com.v2ray.ang.handler.SettingsChangeManager
|
|||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.util.AppManagerUtil
|
import com.v2ray.ang.util.AppManagerUtil
|
||||||
import com.v2ray.ang.util.HttpUtil
|
import com.v2ray.ang.util.HttpUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
|
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
|
||||||
import es.dmoral.toasty.Toasty
|
import es.dmoral.toasty.Toasty
|
||||||
@@ -99,7 +99,7 @@ class PerAppProxyActivity : BaseActivity() {
|
|||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(ANG_PACKAGE, "Error loading apps", e)
|
LogUtil.e(ANG_PACKAGE, "Error loading apps", e)
|
||||||
} finally {
|
} finally {
|
||||||
hideLoading()
|
hideLoading()
|
||||||
}
|
}
|
||||||
@@ -133,6 +133,7 @@ class PerAppProxyActivity : BaseActivity() {
|
|||||||
allowPerAppProxy()
|
allowPerAppProxy()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
R.id.invert_selection -> {
|
R.id.invert_selection -> {
|
||||||
invertSelection()
|
invertSelection()
|
||||||
allowPerAppProxy()
|
allowPerAppProxy()
|
||||||
@@ -190,11 +191,13 @@ class PerAppProxyActivity : BaseActivity() {
|
|||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
var content = HttpUtil.getUrlContent(url, 5000)
|
var content = HttpUtil.getUrlContent(url, 5000)
|
||||||
if (content.isNullOrEmpty()) {
|
if (content.isNullOrEmpty()) {
|
||||||
|
val proxyUsername = SettingsManager.getSocksUsername()
|
||||||
|
val proxyPassword = SettingsManager.getSocksPassword()
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
val httpPort = SettingsManager.getHttpPort()
|
||||||
content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: ""
|
content = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword) ?: ""
|
||||||
}
|
}
|
||||||
launch(Dispatchers.Main) {
|
launch(Dispatchers.Main) {
|
||||||
//Log.i(AppConfig.TAG, content)
|
//LogUtil.i(AppConfig.TAG, content)
|
||||||
selectProxyApp(content, true)
|
selectProxyApp(content, true)
|
||||||
toastSuccess(R.string.toast_success)
|
toastSuccess(R.string.toast_success)
|
||||||
hideLoading()
|
hideLoading()
|
||||||
@@ -258,7 +261,7 @@ class PerAppProxyActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Error selecting proxy app", e)
|
LogUtil.e(AppConfig.TAG, "Error selecting proxy app", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import com.v2ray.ang.viewmodel.PerAppProxyViewModel
|
|||||||
class PerAppProxyAdapter(
|
class PerAppProxyAdapter(
|
||||||
val apps: List<AppInfo>,
|
val apps: List<AppInfo>,
|
||||||
val viewModel: PerAppProxyViewModel
|
val viewModel: PerAppProxyViewModel
|
||||||
) :RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
|
) : RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val VIEW_TYPE_HEADER = 0
|
private const val VIEW_TYPE_HEADER = 0
|
||||||
|
|||||||
@@ -3,14 +3,19 @@ package com.v2ray.ang.ui
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.lifecycle.lifecycleScope
|
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.R
|
||||||
import com.v2ray.ang.databinding.ActivityRoutingEditBinding
|
import com.v2ray.ang.databinding.ActivityRoutingEditBinding
|
||||||
import com.v2ray.ang.dto.RulesetItem
|
import com.v2ray.ang.dto.RulesetItem
|
||||||
import com.v2ray.ang.extension.nullIfBlank
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -19,22 +24,79 @@ import kotlinx.coroutines.launch
|
|||||||
class RoutingEditActivity : BaseActivity() {
|
class RoutingEditActivity : BaseActivity() {
|
||||||
private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) }
|
private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) }
|
||||||
private val position by lazy { intent.getIntExtra("position", -1) }
|
private val position by lazy { intent.getIntExtra("position", -1) }
|
||||||
|
private val processPickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
private val outbound_tag: Array<out String> by lazy {
|
if (result.resultCode == RESULT_OK) {
|
||||||
resources.getStringArray(R.array.outbound_tag)
|
val selectedPackages = AppPickerActivity.getSelectedPackages(result.data)
|
||||||
|
binding.etProcess.text = Utils.getEditable(selectedPackages.joinToString(","))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
//setContentView(binding.root)
|
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title))
|
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title))
|
||||||
|
|
||||||
|
setupOutboundTagInput()
|
||||||
|
setupProcessPicker()
|
||||||
|
|
||||||
val rulesetItem = SettingsManager.getRoutingRuleset(position)
|
val rulesetItem = SettingsManager.getRoutingRuleset(position)
|
||||||
if (rulesetItem != null) {
|
if (rulesetItem != null) {
|
||||||
bindingServer(rulesetItem)
|
bindingServer(rulesetItem)
|
||||||
} else {
|
} else {
|
||||||
clearServer()
|
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 = MmkvManager.decodeAllServerList()
|
||||||
|
.mapNotNull { id -> MmkvManager.decodeServerConfig(id)?.remarks }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
|
||||||
|
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 {
|
private fun bindingServer(rulesetItem: RulesetItem): Boolean {
|
||||||
@@ -42,18 +104,18 @@ class RoutingEditActivity : BaseActivity() {
|
|||||||
binding.chkLocked.isChecked = rulesetItem.locked == true
|
binding.chkLocked.isChecked = rulesetItem.locked == true
|
||||||
binding.etDomain.text = Utils.getEditable(rulesetItem.domain?.joinToString(","))
|
binding.etDomain.text = Utils.getEditable(rulesetItem.domain?.joinToString(","))
|
||||||
binding.etIp.text = Utils.getEditable(rulesetItem.ip?.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.etPort.text = Utils.getEditable(rulesetItem.port)
|
||||||
binding.etProtocol.text = Utils.getEditable(rulesetItem.protocol?.joinToString(","))
|
binding.etProtocol.text = Utils.getEditable(rulesetItem.protocol?.joinToString(","))
|
||||||
binding.etNetwork.text = Utils.getEditable(rulesetItem.network)
|
binding.etNetwork.text = Utils.getEditable(rulesetItem.network)
|
||||||
val outbound = Utils.arrayFind(outbound_tag, rulesetItem.outboundTag)
|
// Set text directly; filter won't fire because we're not using setText(filter=true)
|
||||||
binding.spOutboundTag.setSelection(outbound)
|
binding.spOutboundTag.setText(rulesetItem.outboundTag, false)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clearServer(): Boolean {
|
private fun clearServer(): Boolean {
|
||||||
binding.etRemarks.text = null
|
binding.etRemarks.text = null
|
||||||
binding.spOutboundTag.setSelection(0)
|
binding.spOutboundTag.setText(BUILTIN_OUTBOUND_TAGS.first(), false)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,10 +127,11 @@ class RoutingEditActivity : BaseActivity() {
|
|||||||
locked = binding.chkLocked.isChecked
|
locked = binding.chkLocked.isChecked
|
||||||
domain = binding.etDomain.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
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() }
|
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() }
|
protocol = binding.etProtocol.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
|
||||||
port = binding.etPort.text.toString().nullIfBlank()
|
port = binding.etPort.text.toString().nullIfBlank()
|
||||||
network = binding.etNetwork.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()) {
|
if (rulesetItem.remarks.isNullOrEmpty()) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
@@ -21,6 +20,7 @@ import com.v2ray.ang.handler.MmkvManager
|
|||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||||
import com.v2ray.ang.util.JsonUtil
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
|
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -92,7 +92,7 @@ class RoutingSettingActivity : HelperBaseActivity() {
|
|||||||
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
|
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
|
||||||
binding.tvDomainStrategySummary.text = value
|
binding.tvDomainStrategySummary.text = value
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to set domain strategy", e)
|
LogUtil.e(AppConfig.TAG, "Failed to set domain strategy", e)
|
||||||
}
|
}
|
||||||
}.show()
|
}.show()
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@ class RoutingSettingActivity : HelperBaseActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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) { _, _ ->
|
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||||
@@ -126,7 +126,7 @@ class RoutingSettingActivity : HelperBaseActivity() {
|
|||||||
val clipboard = try {
|
val clipboard = try {
|
||||||
Utils.getClipboard(this)
|
Utils.getClipboard(this)
|
||||||
} catch (e: Exception) {
|
} 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)
|
toastError(R.string.toast_failure)
|
||||||
return@setPositiveButton
|
return@setPositiveButton
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class RoutingSettingRecyclerAdapter(
|
|||||||
val ruleset = rulesets[position]
|
val ruleset = rulesets[position]
|
||||||
|
|
||||||
holder.itemRoutingSettingBinding.remarks.text = ruleset.remarks
|
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.outboundTag.text = ruleset.outboundTag
|
||||||
holder.itemRoutingSettingBinding.chkEnable.isChecked = ruleset.enabled
|
holder.itemRoutingSettingBinding.chkEnable.isChecked = ruleset.enabled
|
||||||
holder.itemRoutingSettingBinding.imgLocked.isVisible = ruleset.locked == true
|
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 android.os.Bundle
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.handler.V2RayServiceManager
|
import com.v2ray.ang.core.CoreServiceManager
|
||||||
|
|
||||||
class ScSwitchActivity : BaseActivity() {
|
class ScSwitchActivity : BaseActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -11,10 +11,10 @@ class ScSwitchActivity : BaseActivity() {
|
|||||||
|
|
||||||
setContentView(R.layout.activity_none)
|
setContentView(R.layout.activity_none)
|
||||||
|
|
||||||
if (V2RayServiceManager.isRunning()) {
|
if (CoreServiceManager.isRunning()) {
|
||||||
V2RayServiceManager.stopVService(this)
|
CoreServiceManager.stopVService(this)
|
||||||
} else {
|
} else {
|
||||||
V2RayServiceManager.startVServiceFromToggle(this)
|
CoreServiceManager.startVServiceFromToggle(this)
|
||||||
}
|
}
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
@@ -11,6 +10,7 @@ import com.v2ray.ang.R
|
|||||||
import com.v2ray.ang.databinding.ActivityNoneBinding
|
import com.v2ray.ang.databinding.ActivityNoneBinding
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.QRCodeDecoder
|
import com.v2ray.ang.util.QRCodeDecoder
|
||||||
import io.github.g00fy2.quickie.QRResult
|
import io.github.g00fy2.quickie.QRResult
|
||||||
import io.github.g00fy2.quickie.ScanCustomCode
|
import io.github.g00fy2.quickie.ScanCustomCode
|
||||||
@@ -18,7 +18,7 @@ import io.github.g00fy2.quickie.config.BarcodeFormat
|
|||||||
import io.github.g00fy2.quickie.config.ScannerConfig
|
import io.github.g00fy2.quickie.config.ScannerConfig
|
||||||
|
|
||||||
class ScannerActivity : HelperBaseActivity() {
|
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)
|
private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult)
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ class ScannerActivity : HelperBaseActivity() {
|
|||||||
finished(text)
|
finished(text)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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)
|
toast(R.string.toast_decoding_failed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ import com.v2ray.ang.AppConfig.TLS
|
|||||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
|
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
import com.v2ray.ang.enums.EConfigType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.enums.NetworkType
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.AngConfigManager
|
import com.v2ray.ang.handler.AngConfigManager
|
||||||
@@ -83,9 +84,6 @@ class ServerActivity : BaseActivity() {
|
|||||||
private val xhttpMode: Array<out String> by lazy {
|
private val xhttpMode: Array<out String> by lazy {
|
||||||
resources.getStringArray(R.array.xhttp_mode)
|
resources.getStringArray(R.array.xhttp_mode)
|
||||||
}
|
}
|
||||||
private val echForceQuerys: Array<out String> by lazy {
|
|
||||||
resources.getStringArray(R.array.ech_force_query_value)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Kotlin synthetics was used, but since it is removed in 1.8. We switch to old manual approach.
|
// Kotlin synthetics was used, but since it is removed in 1.8. We switch to old manual approach.
|
||||||
@@ -131,12 +129,14 @@ class ServerActivity : BaseActivity() {
|
|||||||
private val et_port_hop_interval: EditText? by lazy { findViewById(R.id.et_port_hop_interval) }
|
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_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_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_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 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 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 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 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 container_pinned_ca256: LinearLayout? by lazy { findViewById(R.id.lay_pinned_ca256) }
|
||||||
|
|
||||||
@@ -148,14 +148,12 @@ class ServerActivity : BaseActivity() {
|
|||||||
|
|
||||||
val layoutId = when (config?.configType ?: createConfigType) {
|
val layoutId = when (config?.configType ?: createConfigType) {
|
||||||
EConfigType.VMESS -> R.layout.activity_server_vmess
|
EConfigType.VMESS -> R.layout.activity_server_vmess
|
||||||
EConfigType.CUSTOM -> null
|
|
||||||
EConfigType.SHADOWSOCKS -> R.layout.activity_server_shadowsocks
|
EConfigType.SHADOWSOCKS -> R.layout.activity_server_shadowsocks
|
||||||
EConfigType.SOCKS, EConfigType.HTTP -> R.layout.activity_server_socks
|
EConfigType.SOCKS, EConfigType.HTTP -> R.layout.activity_server_socks
|
||||||
EConfigType.VLESS -> R.layout.activity_server_vless
|
EConfigType.VLESS -> R.layout.activity_server_vless
|
||||||
EConfigType.TROJAN -> R.layout.activity_server_trojan
|
EConfigType.TROJAN -> R.layout.activity_server_trojan
|
||||||
EConfigType.WIREGUARD -> R.layout.activity_server_wireguard
|
EConfigType.WIREGUARD -> R.layout.activity_server_wireguard
|
||||||
EConfigType.HYSTERIA2 -> R.layout.activity_server_hysteria2
|
EConfigType.HYSTERIA2 -> R.layout.activity_server_hysteria2
|
||||||
EConfigType.POLICYGROUP -> null
|
|
||||||
else -> null
|
else -> null
|
||||||
} ?: return
|
} ?: return
|
||||||
setContentViewWithToolbar(layoutId, showHomeAsUp = true, title = (config?.configType ?: createConfigType).toString())
|
setContentViewWithToolbar(layoutId, showHomeAsUp = true, title = (config?.configType ?: createConfigType).toString())
|
||||||
@@ -241,6 +239,15 @@ class ServerActivity : BaseActivity() {
|
|||||||
else -> null
|
else -> null
|
||||||
}.orEmpty()
|
}.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 =
|
layout_extra?.visibility =
|
||||||
when (networks[position]) {
|
when (networks[position]) {
|
||||||
@@ -276,7 +283,6 @@ class ServerActivity : BaseActivity() {
|
|||||||
container_spider_x,
|
container_spider_x,
|
||||||
container_mldsa65_verify,
|
container_mldsa65_verify,
|
||||||
container_ech_config_list,
|
container_ech_config_list,
|
||||||
container_ech_force_query,
|
|
||||||
container_pinned_ca256
|
container_pinned_ca256
|
||||||
).forEach { it?.visibility = View.GONE }
|
).forEach { it?.visibility = View.GONE }
|
||||||
}
|
}
|
||||||
@@ -289,7 +295,6 @@ class ServerActivity : BaseActivity() {
|
|||||||
container_alpn,
|
container_alpn,
|
||||||
container_allow_insecure,
|
container_allow_insecure,
|
||||||
container_ech_config_list,
|
container_ech_config_list,
|
||||||
container_ech_force_query,
|
|
||||||
container_pinned_ca256
|
container_pinned_ca256
|
||||||
).forEach { it?.visibility = View.VISIBLE }
|
).forEach { it?.visibility = View.VISIBLE }
|
||||||
listOf(
|
listOf(
|
||||||
@@ -310,7 +315,6 @@ class ServerActivity : BaseActivity() {
|
|||||||
container_alpn,
|
container_alpn,
|
||||||
container_allow_insecure,
|
container_allow_insecure,
|
||||||
container_ech_config_list,
|
container_ech_config_list,
|
||||||
container_ech_force_query,
|
|
||||||
container_pinned_ca256
|
container_pinned_ca256
|
||||||
).forEach { it?.visibility = View.GONE }
|
).forEach { it?.visibility = View.GONE }
|
||||||
listOf(
|
listOf(
|
||||||
@@ -394,10 +398,6 @@ class ServerActivity : BaseActivity() {
|
|||||||
sp_allow_insecure?.setSelection(allowinsecure)
|
sp_allow_insecure?.setSelection(allowinsecure)
|
||||||
}
|
}
|
||||||
et_ech_config_list?.text = Utils.getEditable(config.echConfigList)
|
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)
|
et_pinned_ca256?.text = Utils.getEditable(config.pinnedCA256)
|
||||||
} else if (config.security == REALITY) {
|
} else if (config.security == REALITY) {
|
||||||
et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty())
|
et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty())
|
||||||
@@ -489,6 +489,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)
|
saveCommon(config)
|
||||||
saveStreamSettings(config)
|
saveStreamSettings(config)
|
||||||
saveTls(config)
|
saveTls(config)
|
||||||
@@ -498,7 +505,7 @@ class ServerActivity : BaseActivity() {
|
|||||||
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
||||||
config.subscriptionId = subscriptionId.orEmpty()
|
config.subscriptionId = subscriptionId.orEmpty()
|
||||||
}
|
}
|
||||||
//Log.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "")
|
//LogUtil.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "")
|
||||||
MmkvManager.encodeServerConfig(editGuid, config)
|
MmkvManager.encodeServerConfig(editGuid, config)
|
||||||
toastSuccess(R.string.toast_success)
|
toastSuccess(R.string.toast_success)
|
||||||
finish()
|
finish()
|
||||||
@@ -533,7 +540,7 @@ class ServerActivity : BaseActivity() {
|
|||||||
} else if (config.configType == EConfigType.HYSTERIA2) {
|
} else if (config.configType == EConfigType.HYSTERIA2) {
|
||||||
config.obfsPassword = et_obfs_password?.text?.toString()
|
config.obfsPassword = et_obfs_password?.text?.toString()
|
||||||
config.portHopping = et_port_hop?.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.bandwidthDown = et_bandwidth_down?.text?.toString()
|
||||||
config.bandwidthUp = et_bandwidth_up?.text?.toString()
|
config.bandwidthUp = et_bandwidth_up?.text?.toString()
|
||||||
}
|
}
|
||||||
@@ -557,7 +564,10 @@ class ServerActivity : BaseActivity() {
|
|||||||
profileItem.serviceName = path
|
profileItem.serviceName = path
|
||||||
profileItem.authority = requestHost
|
profileItem.authority = requestHost
|
||||||
profileItem.xhttpMode = transportTypes(networks[network])[type]
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveTls(config: ProfileItem) {
|
private fun saveTls(config: ProfileItem) {
|
||||||
@@ -571,7 +581,6 @@ class ServerActivity : BaseActivity() {
|
|||||||
val spiderX = et_spider_x?.text?.toString()
|
val spiderX = et_spider_x?.text?.toString()
|
||||||
val mldsa65Verify = et_mldsa65_verify?.text?.toString()
|
val mldsa65Verify = et_mldsa65_verify?.text?.toString()
|
||||||
val echConfigList = et_ech_config_list?.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 pinnedCA256 = et_pinned_ca256?.text?.toString()
|
||||||
|
|
||||||
val allowInsecure =
|
val allowInsecure =
|
||||||
@@ -591,7 +600,6 @@ class ServerActivity : BaseActivity() {
|
|||||||
config.spiderX = spiderX
|
config.spiderX = spiderX
|
||||||
config.mldsa65Verify = mldsa65Verify
|
config.mldsa65Verify = mldsa65Verify
|
||||||
config.echConfigList = echConfigList
|
config.echConfigList = echConfigList
|
||||||
config.echForceQuery = echForceQuerys[echForceQueryIndex]
|
|
||||||
config.pinnedCA256 = pinnedCA256
|
config.pinnedCA256 = pinnedCA256
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.v2ray.ang.ui
|
|||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
@@ -11,13 +10,14 @@ import com.blacksquircle.ui.language.json.JsonLanguage
|
|||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
|
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
|
||||||
import com.v2ray.ang.enums.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.fmt.CustomFmt
|
import com.v2ray.ang.fmt.CustomFmt
|
||||||
import com.v2ray.ang.handler.AngConfigManager
|
import com.v2ray.ang.handler.AngConfigManager
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
|
|
||||||
class ServerCustomConfigActivity : BaseActivity() {
|
class ServerCustomConfigActivity : BaseActivity() {
|
||||||
@@ -79,7 +79,7 @@ class ServerCustomConfigActivity : BaseActivity() {
|
|||||||
val profileItem = try {
|
val profileItem = try {
|
||||||
CustomFmt.parse(binding.editor.text.toString())
|
CustomFmt.parse(binding.editor.text.toString())
|
||||||
} catch (e: Exception) {
|
} 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}")
|
toast("${getString(R.string.toast_malformed_josn)} ${e.cause?.message}")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import android.widget.ArrayAdapter
|
|||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.databinding.ActivityServerGroupBinding
|
import com.v2ray.ang.databinding.ActivityServerGroupBinding
|
||||||
import com.v2ray.ang.enums.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.AngConfigManager
|
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
|
|
||||||
@@ -98,7 +97,7 @@ class ServerGroupActivity : BaseActivity() {
|
|||||||
config.subscriptionId = subscriptionId.orEmpty()
|
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)
|
MmkvManager.encodeServerConfig(editGuid, config)
|
||||||
toastSuccess(R.string.toast_success)
|
toastSuccess(R.string.toast_success)
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.TextUtils
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuItem
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||||
|
import com.v2ray.ang.databinding.ActivityServerProxyChainBinding
|
||||||
|
import com.v2ray.ang.dto.ProfileItem
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.extension.toast
|
||||||
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
|
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
|
import com.v2ray.ang.util.Utils
|
||||||
|
|
||||||
|
class ServerProxyChainActivity : BaseActivity() {
|
||||||
|
private val binding by lazy { ActivityServerProxyChainBinding.inflate(layoutInflater) }
|
||||||
|
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
|
||||||
|
private val isRunning by lazy {
|
||||||
|
intent.getBooleanExtra("isRunning", false)
|
||||||
|
&& editGuid.isNotEmpty()
|
||||||
|
&& editGuid == MmkvManager.getSelectServer()
|
||||||
|
}
|
||||||
|
private val subscriptionId by lazy {
|
||||||
|
intent.getStringExtra("subscriptionId")
|
||||||
|
}
|
||||||
|
private lateinit var memberAdapter: ServerProxyChainMemberAdapter
|
||||||
|
|
||||||
|
private var allRemarks: List<String> = emptyList()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = EConfigType.PROXYCHAIN.toString())
|
||||||
|
|
||||||
|
loadAvailableRemarks()
|
||||||
|
setupRecycler()
|
||||||
|
binding.fabAddProxyChainMember.setOnClickListener {
|
||||||
|
addMemberRow()
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = MmkvManager.decodeServerConfig(editGuid)
|
||||||
|
if (config != null) {
|
||||||
|
bindingServer(config)
|
||||||
|
} else {
|
||||||
|
clearServer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadAvailableRemarks() {
|
||||||
|
allRemarks = MmkvManager.decodeAllServerList()
|
||||||
|
.asSequence()
|
||||||
|
.mapNotNull { guid -> MmkvManager.decodeServerConfig(guid) }
|
||||||
|
.filter { profile ->
|
||||||
|
profile.configType != EConfigType.CUSTOM
|
||||||
|
&& profile.configType != EConfigType.POLICYGROUP
|
||||||
|
&& profile.configType != EConfigType.PROXYCHAIN
|
||||||
|
}
|
||||||
|
.map { it.remarks.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.distinct()
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupRecycler() {
|
||||||
|
memberAdapter = ServerProxyChainMemberAdapter(
|
||||||
|
members = mutableListOf(),
|
||||||
|
suggestions = allRemarks,
|
||||||
|
adapterListener = ActivityAdapterListener()
|
||||||
|
)
|
||||||
|
binding.recyclerProxyChainMembers.layoutManager = LinearLayoutManager(this)
|
||||||
|
binding.recyclerProxyChainMembers.adapter = memberAdapter
|
||||||
|
ItemTouchHelper(SimpleItemTouchHelperCallback(memberAdapter)).attachToRecyclerView(binding.recyclerProxyChainMembers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindingServer(config: ProfileItem): Boolean {
|
||||||
|
binding.etRemarks.text = Utils.getEditable(config.remarks)
|
||||||
|
val rows = parseChainMembers(config.proxyChainProfiles)
|
||||||
|
memberAdapter.replaceAll(rows)
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
memberAdapter.addRow()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearServer(): Boolean {
|
||||||
|
binding.etRemarks.text = null
|
||||||
|
memberAdapter.replaceAll(listOf("", ""))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveServer(): Boolean {
|
||||||
|
if (TextUtils.isEmpty(binding.etRemarks.text.toString())) {
|
||||||
|
toast(R.string.server_lab_remarks)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val chainMembers = memberAdapter.getMembers().map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
if (chainMembers.size != memberAdapter.getMembers().size) {
|
||||||
|
toast(R.string.server_proxy_chain_members_unselected)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (chainMembers.size < 2) {
|
||||||
|
toast(R.string.server_proxy_chain_members_insufficient)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val invalidMembers = chainMembers.filter { member ->
|
||||||
|
val profile = SettingsManager.getServerViaRemarks(member)
|
||||||
|
profile == null
|
||||||
|
|| profile.configType == EConfigType.CUSTOM
|
||||||
|
|| profile.configType == EConfigType.POLICYGROUP
|
||||||
|
|| profile.configType == EConfigType.PROXYCHAIN
|
||||||
|
}
|
||||||
|
if (invalidMembers.isNotEmpty()) {
|
||||||
|
toast(getString(R.string.server_proxy_chain_members_invalid, invalidMembers.joinToString(", ")))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.PROXYCHAIN)
|
||||||
|
config.remarks = binding.etRemarks.text.toString().trim()
|
||||||
|
config.proxyChainProfiles = chainMembers.joinToString(",")
|
||||||
|
config.description = chainMembers.joinToString(" -> ")
|
||||||
|
|
||||||
|
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
||||||
|
config.subscriptionId = subscriptionId.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
MmkvManager.encodeServerConfig(editGuid, config)
|
||||||
|
toastSuccess(R.string.toast_success)
|
||||||
|
finish()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteServer(): Boolean {
|
||||||
|
if (editGuid.isNotEmpty()) {
|
||||||
|
if (editGuid != MmkvManager.getSelectServer()) {
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
|
||||||
|
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
MmkvManager.removeServer(editGuid)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
|
MmkvManager.removeServer(editGuid)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast(R.string.toast_action_not_allowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addMemberRow() {
|
||||||
|
if (allRemarks.isEmpty()) {
|
||||||
|
toast(R.string.toast_none_data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
memberAdapter.addRow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseChainMembers(raw: String?): List<String> {
|
||||||
|
return raw.orEmpty()
|
||||||
|
.split(",")
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class ActivityAdapterListener : BaseAdapterListener {
|
||||||
|
override fun onEdit(guid: String, position: Int) {
|
||||||
|
// Row selection is handled directly by AutoCompleteTextView in the adapter.
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRemove(guid: String, position: Int) {
|
||||||
|
memberAdapter.removeRow(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShare(url: String) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRefreshData() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
|
menuInflater.inflate(R.menu.action_server, menu)
|
||||||
|
val delButton = menu.findItem(R.id.del_config)
|
||||||
|
val saveButton = menu.findItem(R.id.save_config)
|
||||||
|
|
||||||
|
if (editGuid.isNotEmpty()) {
|
||||||
|
if (isRunning) {
|
||||||
|
delButton?.isVisible = false
|
||||||
|
saveButton?.isVisible = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delButton?.isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.onCreateOptionsMenu(menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||||
|
R.id.del_config -> {
|
||||||
|
deleteServer()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.save_config -> {
|
||||||
|
saveServer()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||||
|
import com.v2ray.ang.databinding.ItemRecyclerProxyChainMemberBinding
|
||||||
|
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
||||||
|
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
|
class ServerProxyChainMemberAdapter(
|
||||||
|
private val members: MutableList<String>,
|
||||||
|
private val suggestions: List<String>,
|
||||||
|
private val adapterListener: BaseAdapterListener?
|
||||||
|
) : RecyclerView.Adapter<ServerProxyChainMemberAdapter.MemberViewHolder>(), ItemTouchHelperAdapter {
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = members.size
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MemberViewHolder {
|
||||||
|
return MemberViewHolder(
|
||||||
|
ItemRecyclerProxyChainMemberBinding.inflate(
|
||||||
|
LayoutInflater.from(parent.context),
|
||||||
|
parent,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: MemberViewHolder, position: Int) {
|
||||||
|
val adapterPos = holder.bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: position
|
||||||
|
val value = members[position]
|
||||||
|
holder.binding.tvMemberIndex.text = (position + 1).toString()
|
||||||
|
|
||||||
|
val dropdownAdapter = ArrayAdapter(
|
||||||
|
holder.itemView.context,
|
||||||
|
android.R.layout.simple_dropdown_item_1line,
|
||||||
|
suggestions
|
||||||
|
)
|
||||||
|
holder.binding.spMemberRemark.setAdapter(dropdownAdapter)
|
||||||
|
holder.binding.spMemberRemark.threshold = 0
|
||||||
|
holder.binding.spMemberRemark.setText(value, false)
|
||||||
|
|
||||||
|
holder.binding.spMemberRemark.setOnItemClickListener { _, _, selectedIndex, _ ->
|
||||||
|
if (adapterPos in members.indices) {
|
||||||
|
members[adapterPos] = suggestions[selectedIndex].trim()
|
||||||
|
adapterListener?.onRefreshData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
holder.binding.spMemberRemark.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
|
||||||
|
if (hasFocus) return@OnFocusChangeListener
|
||||||
|
val text = holder.binding.spMemberRemark.text?.toString().orEmpty().trim()
|
||||||
|
if (adapterPos in members.indices && members[adapterPos] != text) {
|
||||||
|
members[adapterPos] = text
|
||||||
|
adapterListener?.onRefreshData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
holder.binding.spMemberRemark.setOnClickListener { holder.binding.spMemberRemark.showDropDown() }
|
||||||
|
holder.binding.btnMemberDropdown.setOnClickListener {
|
||||||
|
holder.binding.spMemberRemark.requestFocus()
|
||||||
|
holder.binding.spMemberRemark.showDropDown()
|
||||||
|
}
|
||||||
|
holder.itemView.setOnClickListener { holder.binding.spMemberRemark.showDropDown() }
|
||||||
|
|
||||||
|
holder.binding.layoutRemove.setOnClickListener {
|
||||||
|
val removePos = holder.bindingAdapterPosition
|
||||||
|
if (removePos != RecyclerView.NO_POSITION) {
|
||||||
|
adapterListener?.onRemove("", removePos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addRow() {
|
||||||
|
members.add("")
|
||||||
|
notifyItemInserted(members.lastIndex)
|
||||||
|
adapterListener?.onRefreshData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeRow(position: Int) {
|
||||||
|
if (position < 0 || position >= members.size) return
|
||||||
|
members.removeAt(position)
|
||||||
|
notifyItemRemoved(position)
|
||||||
|
notifyItemRangeChanged(position, members.size - position)
|
||||||
|
adapterListener?.onRefreshData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setRemark(position: Int, remark: String) {
|
||||||
|
if (position < 0 || position >= members.size) return
|
||||||
|
members[position] = remark
|
||||||
|
notifyItemChanged(position)
|
||||||
|
adapterListener?.onRefreshData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun replaceAll(newMembers: List<String>) {
|
||||||
|
members.clear()
|
||||||
|
members.addAll(newMembers)
|
||||||
|
notifyDataSetChanged()
|
||||||
|
adapterListener?.onRefreshData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMembers(): List<String> = members.toList()
|
||||||
|
|
||||||
|
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
|
||||||
|
if (fromPosition == toPosition) return true
|
||||||
|
Collections.swap(members, fromPosition, toPosition)
|
||||||
|
notifyItemMoved(fromPosition, toPosition)
|
||||||
|
notifyItemChanged(fromPosition)
|
||||||
|
notifyItemChanged(toPosition)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemMoveCompleted() {
|
||||||
|
adapterListener?.onRefreshData()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemDismiss(position: Int) {
|
||||||
|
// Swipe-to-dismiss disabled for this adapter.
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemberViewHolder(val binding: ItemRecyclerProxyChainMemberBinding) :
|
||||||
|
BaseViewHolder(binding.root), ItemTouchHelperViewHolder
|
||||||
|
|
||||||
|
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||||
|
fun onItemSelected() {
|
||||||
|
itemView.setBackgroundColor(Color.LTGRAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onItemClear() {
|
||||||
|
itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,19 +6,12 @@ import androidx.preference.CheckBoxPreference
|
|||||||
import androidx.preference.EditTextPreference
|
import androidx.preference.EditTextPreference
|
||||||
import androidx.preference.ListPreference
|
import androidx.preference.ListPreference
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
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
|
||||||
import com.v2ray.ang.AppConfig.VPN
|
import com.v2ray.ang.AppConfig.VPN
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.extension.toLongEx
|
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.helper.MmkvPreferenceDataStore
|
import com.v2ray.ang.helper.MmkvPreferenceDataStore
|
||||||
import com.v2ray.ang.handler.SubscriptionUpdater
|
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
class SettingsActivity : BaseActivity() {
|
class SettingsActivity : BaseActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
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 fragmentLength by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_LENGTH) }
|
||||||
private val fragmentInterval by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_INTERVAL) }
|
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 mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) }
|
||||||
|
|
||||||
private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) }
|
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 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 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?) {
|
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
|
||||||
// Use MMKV as the storage backend for all Preferences
|
// Use MMKV as the storage backend for all Preferences
|
||||||
// This prevents inconsistencies between SharedPreferences and MMKV
|
// This prevents inconsistencies between SharedPreferences and MMKV
|
||||||
@@ -88,15 +87,6 @@ class SettingsActivity : BaseActivity() {
|
|||||||
true
|
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 ->
|
mode?.setOnPreferenceChangeListener { pref, newValue ->
|
||||||
val valueStr = newValue.toString()
|
val valueStr = newValue.toString()
|
||||||
(pref as? ListPreference)?.let { lp ->
|
(pref as? ListPreference)?.let { lp ->
|
||||||
@@ -112,15 +102,33 @@ class SettingsActivity : BaseActivity() {
|
|||||||
updateHevTunSettings(newValue as Boolean)
|
updateHevTunSettings(newValue as Boolean)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enableLocalProxy?.setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
updateEnableLocalProxy(newValue as Boolean)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamicSocksPort?.setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
updateDynamicSocksPort(newValue as Boolean)
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initPreferenceSummaries() {
|
private fun initPreferenceSummaries() {
|
||||||
fun updateSummary(pref: androidx.preference.Preference) {
|
fun updateSummary(pref: androidx.preference.Preference) {
|
||||||
when (pref) {
|
when (pref) {
|
||||||
is EditTextPreference -> {
|
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 ->
|
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
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,14 +167,16 @@ class SettingsActivity : BaseActivity() {
|
|||||||
// Initialize mode-dependent UI states
|
// Initialize mode-dependent UI states
|
||||||
updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN))
|
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
|
// Initialize mux-dependent UI states
|
||||||
updateMux(MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false))
|
updateMux(MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false))
|
||||||
|
|
||||||
// Initialize fragment-dependent UI states
|
// Initialize fragment-dependent UI states
|
||||||
updateFragment(MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false))
|
updateFragment(MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false))
|
||||||
|
|
||||||
// Initialize auto-update interval state
|
updateDynamicSocksPort(MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false))
|
||||||
autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateMode(value: String?) {
|
private fun updateMode(value: String?) {
|
||||||
@@ -203,29 +213,6 @@ class SettingsActivity : BaseActivity() {
|
|||||||
vpnDns?.isEnabled = !enabled
|
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) {
|
private fun updateMux(enabled: Boolean) {
|
||||||
muxConcurrency?.isEnabled = enabled
|
muxConcurrency?.isEnabled = enabled
|
||||||
muxXudpConcurrency?.isEnabled = enabled
|
muxXudpConcurrency?.isEnabled = enabled
|
||||||
@@ -258,9 +245,45 @@ class SettingsActivity : BaseActivity() {
|
|||||||
fragmentInterval?.isEnabled = enabled
|
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) {
|
private fun updateHevTunSettings(enabled: Boolean) {
|
||||||
hevTunLogLevel?.isEnabled = enabled
|
hevTunLogLevel?.isEnabled = enabled
|
||||||
hevTunRwTimeout?.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import com.v2ray.ang.extension.toast
|
|||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsChangeManager
|
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 com.v2ray.ang.util.Utils
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -50,6 +52,7 @@ class SubEditActivity : BaseActivity() {
|
|||||||
binding.etFilter.text = Utils.getEditable(subItem.filter)
|
binding.etFilter.text = Utils.getEditable(subItem.filter)
|
||||||
binding.chkEnable.isChecked = subItem.enabled
|
binding.chkEnable.isChecked = subItem.enabled
|
||||||
binding.autoUpdateCheck.isChecked = subItem.autoUpdate
|
binding.autoUpdateCheck.isChecked = subItem.autoUpdate
|
||||||
|
binding.etUpdateInterval.text = Utils.getEditable(subItem.updateInterval.toString())
|
||||||
binding.allowInsecureUrl.isChecked = subItem.allowInsecureUrl
|
binding.allowInsecureUrl.isChecked = subItem.allowInsecureUrl
|
||||||
binding.etPreProfile.text = Utils.getEditable(subItem.prevProfile)
|
binding.etPreProfile.text = Utils.getEditable(subItem.prevProfile)
|
||||||
binding.etNextProfile.text = Utils.getEditable(subItem.nextProfile)
|
binding.etNextProfile.text = Utils.getEditable(subItem.nextProfile)
|
||||||
@@ -64,6 +67,7 @@ class SubEditActivity : BaseActivity() {
|
|||||||
binding.etUrl.text = null
|
binding.etUrl.text = null
|
||||||
binding.etFilter.text = null
|
binding.etFilter.text = null
|
||||||
binding.chkEnable.isChecked = true
|
binding.chkEnable.isChecked = true
|
||||||
|
binding.etUpdateInterval.text = null
|
||||||
binding.etPreProfile.text = null
|
binding.etPreProfile.text = null
|
||||||
binding.etNextProfile.text = null
|
binding.etNextProfile.text = null
|
||||||
return true
|
return true
|
||||||
@@ -81,6 +85,27 @@ class SubEditActivity : BaseActivity() {
|
|||||||
subItem.filter = binding.etFilter.text.toString()
|
subItem.filter = binding.etFilter.text.toString()
|
||||||
subItem.enabled = binding.chkEnable.isChecked
|
subItem.enabled = binding.chkEnable.isChecked
|
||||||
subItem.autoUpdate = binding.autoUpdateCheck.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.prevProfile = binding.etPreProfile.text.toString()
|
||||||
subItem.nextProfile = binding.etNextProfile.text.toString()
|
subItem.nextProfile = binding.etNextProfile.text.toString()
|
||||||
subItem.allowInsecureUrl = binding.allowInsecureUrl.isChecked
|
subItem.allowInsecureUrl = binding.allowInsecureUrl.isChecked
|
||||||
@@ -104,6 +129,7 @@ class SubEditActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MmkvManager.encodeSubscription(editSubId, subItem)
|
MmkvManager.encodeSubscription(editSubId, subItem)
|
||||||
|
SubscriptionUpdater.syncOne(subId = editSubId)
|
||||||
toastSuccess(R.string.toast_success)
|
toastSuccess(R.string.toast_success)
|
||||||
finish()
|
finish()
|
||||||
return true
|
return true
|
||||||
@@ -118,7 +144,7 @@ class SubEditActivity : BaseActivity() {
|
|||||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
MmkvManager.removeSubscription(editSubId)
|
SettingsManager.removeSubscriptionWithDefault(editSubId)
|
||||||
launch(Dispatchers.Main) {
|
launch(Dispatchers.Main) {
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
@@ -130,7 +156,7 @@ class SubEditActivity : BaseActivity() {
|
|||||||
.show()
|
.show()
|
||||||
} else {
|
} else {
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
MmkvManager.removeSubscription(editSubId)
|
SettingsManager.removeSubscriptionWithDefault(editSubId)
|
||||||
launch(Dispatchers.Main) {
|
launch(Dispatchers.Main) {
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
@@ -145,10 +171,6 @@ class SubEditActivity : BaseActivity() {
|
|||||||
del_config = menu.findItem(R.id.del_config)
|
del_config = menu.findItem(R.id.del_config)
|
||||||
save_config = menu.findItem(R.id.save_config)
|
save_config = menu.findItem(R.id.save_config)
|
||||||
|
|
||||||
if (editSubId.isEmpty()) {
|
|
||||||
del_config?.isVisible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.onCreateOptionsMenu(menu)
|
return super.onCreateOptionsMenu(menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
@@ -18,11 +17,10 @@ import com.v2ray.ang.contracts.BaseAdapterListener
|
|||||||
import com.v2ray.ang.databinding.ActivitySubSettingBinding
|
import com.v2ray.ang.databinding.ActivitySubSettingBinding
|
||||||
import com.v2ray.ang.databinding.ItemQrcodeBinding
|
import com.v2ray.ang.databinding.ItemQrcodeBinding
|
||||||
import com.v2ray.ang.extension.toast
|
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.AngConfigManager
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.QRCodeDecoder
|
import com.v2ray.ang.util.QRCodeDecoder
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
|
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
|
||||||
@@ -77,16 +75,23 @@ class SubSettingActivity : BaseActivity() {
|
|||||||
showLoading()
|
showLoading()
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val count = AngConfigManager.updateConfigViaSubAll()
|
val result = AngConfigManager.updateConfigViaSubAll()
|
||||||
delay(500L)
|
delay(500L)
|
||||||
launch(Dispatchers.Main) {
|
launch(Dispatchers.Main) {
|
||||||
if (count > 0) {
|
if (result.successCount + result.failureCount + result.skipCount == 0) {
|
||||||
toastSuccess(R.string.toast_success)
|
toast(R.string.title_update_subscription_no_subscription)
|
||||||
refreshData()
|
} else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
|
||||||
|
toast(getString(R.string.title_update_config_count, result.configCount))
|
||||||
} else {
|
} else {
|
||||||
toastError(R.string.toast_failure)
|
toast(
|
||||||
|
getString(
|
||||||
|
R.string.title_update_subscription_result,
|
||||||
|
result.configCount, result.successCount, result.failureCount, result.skipCount
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
hideLoading()
|
hideLoading()
|
||||||
|
refreshData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +156,7 @@ class SubSettingActivity : BaseActivity() {
|
|||||||
else -> ownerActivity.toast("else")
|
else -> ownerActivity.toast("else")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Share subscription failed", e)
|
LogUtil.e(AppConfig.TAG, "Share subscription failed", e)
|
||||||
}
|
}
|
||||||
}.show()
|
}.show()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -13,6 +12,7 @@ import com.v2ray.ang.AppConfig
|
|||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.databinding.ActivityTaskerBinding
|
import com.v2ray.ang.databinding.ActivityTaskerBinding
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
|
||||||
class TaskerActivity : BaseActivity() {
|
class TaskerActivity : BaseActivity() {
|
||||||
private val binding by lazy { ActivityTaskerBinding.inflate(layoutInflater) }
|
private val binding by lazy { ActivityTaskerBinding.inflate(layoutInflater) }
|
||||||
@@ -30,7 +30,7 @@ class TaskerActivity : BaseActivity() {
|
|||||||
lstData.add("Default")
|
lstData.add("Default")
|
||||||
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
|
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
|
||||||
|
|
||||||
MmkvManager.decodeServerList().forEach { key ->
|
MmkvManager.decodeAllServerList().forEach { key ->
|
||||||
MmkvManager.decodeServerConfig(key)?.let { config ->
|
MmkvManager.decodeServerConfig(key)?.let { config ->
|
||||||
lstData.add(config.remarks)
|
lstData.add(config.remarks)
|
||||||
lstGuid.add(key)
|
lstGuid.add(key)
|
||||||
@@ -62,7 +62,7 @@ class TaskerActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to initialize Tasker settings", e)
|
LogUtil.e(AppConfig.TAG, "Failed to initialize Tasker settings", e)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package com.v2ray.ang.ui
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
@@ -11,6 +10,7 @@ import com.v2ray.ang.databinding.ActivityLogcatBinding
|
|||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastError
|
import com.v2ray.ang.extension.toastError
|
||||||
import com.v2ray.ang.handler.AngConfigManager
|
import com.v2ray.ang.handler.AngConfigManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -55,7 +55,7 @@ class UrlSchemeActivity : BaseActivity() {
|
|||||||
startActivity(Intent(this, MainActivity::class.java))
|
startActivity(Intent(this, MainActivity::class.java))
|
||||||
finish()
|
finish()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Error processing URL scheme", e)
|
LogUtil.e(AppConfig.TAG, "Error processing URL scheme", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ class UrlSchemeActivity : BaseActivity() {
|
|||||||
if (uriString.isNullOrEmpty()) {
|
if (uriString.isNullOrEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Log.i(AppConfig.TAG, uriString)
|
LogUtil.i(AppConfig.TAG, uriString)
|
||||||
|
|
||||||
var decodedUrl = URLDecoder.decode(uriString, "UTF-8")
|
var decodedUrl = URLDecoder.decode(uriString, "UTF-8")
|
||||||
val uri = Uri.parse(decodedUrl)
|
val uri = Uri.parse(decodedUrl)
|
||||||
@@ -71,7 +71,7 @@ class UrlSchemeActivity : BaseActivity() {
|
|||||||
if (uri.fragment.isNullOrEmpty() && !fragment.isNullOrEmpty()) {
|
if (uri.fragment.isNullOrEmpty() && !fragment.isNullOrEmpty()) {
|
||||||
decodedUrl += "#${fragment}"
|
decodedUrl += "#${fragment}"
|
||||||
}
|
}
|
||||||
Log.i(AppConfig.TAG, decodedUrl)
|
LogUtil.i(AppConfig.TAG, decodedUrl)
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val (count, countSub) = AngConfigManager.importBatchConfig(decodedUrl, "", false)
|
val (count, countSub) = AngConfigManager.importBatchConfig(decodedUrl, "", false)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import android.util.Log
|
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
@@ -22,6 +21,7 @@ import com.v2ray.ang.extension.toastError
|
|||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import com.v2ray.ang.viewmodel.UserAssetViewModel
|
import com.v2ray.ang.viewmodel.UserAssetViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -84,7 +84,7 @@ class UserAssetActivity : HelperBaseActivity() {
|
|||||||
MmkvManager.encodeSettings(AppConfig.PREF_GEO_FILES_SOURCES, value)
|
MmkvManager.encodeSettings(AppConfig.PREF_GEO_FILES_SOURCES, value)
|
||||||
binding.tvGeoFilesSourcesSummary.text = value
|
binding.tvGeoFilesSourcesSummary.text = value
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to set geo files sources", e)
|
LogUtil.e(AppConfig.TAG, "Failed to set geo files sources", e)
|
||||||
}
|
}
|
||||||
}.show()
|
}.show()
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@ class UserAssetActivity : HelperBaseActivity() {
|
|||||||
}.also { cursor.close() }
|
}.also { cursor.close() }
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to get cursor name", e)
|
LogUtil.e(AppConfig.TAG, "Failed to get cursor name", e)
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ class UserAssetActivity : HelperBaseActivity() {
|
|||||||
.putExtra(UserAssetUrlActivity.ASSET_URL_QRCODE, url)
|
.putExtra(UserAssetUrlActivity.ASSET_URL_QRCODE, url)
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to import asset from URL", e)
|
LogUtil.e(AppConfig.TAG, "Failed to import asset from URL", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -173,9 +173,11 @@ class UserAssetActivity : HelperBaseActivity() {
|
|||||||
showLoading()
|
showLoading()
|
||||||
toast(R.string.msg_downloading_content)
|
toast(R.string.msg_downloading_content)
|
||||||
|
|
||||||
|
val proxyUsername = SettingsManager.getSocksUsername()
|
||||||
|
val proxyPassword = SettingsManager.getSocksPassword()
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
val httpPort = SettingsManager.getHttpPort()
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val result = viewModel.downloadGeoFiles(extDir, httpPort)
|
val result = viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (result.successCount > 0) {
|
if (result.successCount > 0) {
|
||||||
toast(getString(R.string.title_update_config_count, result.successCount))
|
toast(getString(R.string.title_update_config_count, result.successCount))
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.v2ray.ang.ui
|
|||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
@@ -13,6 +12,7 @@ import com.v2ray.ang.dto.AssetUrlItem
|
|||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ class UserAssetUrlActivity : BaseActivity() {
|
|||||||
try {
|
try {
|
||||||
file.delete()
|
file.delete()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to delete asset file: ${file.path}", e)
|
LogUtil.e(AppConfig.TAG, "Failed to delete asset file: ${file.path}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
package com.v2ray.ang.util
|
package com.v2ray.ang.util
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.LOOPBACK
|
import com.v2ray.ang.AppConfig.LOOPBACK
|
||||||
import com.v2ray.ang.BuildConfig
|
import com.v2ray.ang.BuildConfig
|
||||||
import com.v2ray.ang.util.Utils.encode
|
import java.io.File
|
||||||
import com.v2ray.ang.util.Utils.urlDecode
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.IDN
|
import java.net.IDN
|
||||||
import java.net.Inet6Address
|
import java.net.Inet6Address
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
@@ -16,6 +13,10 @@ import java.net.MalformedURLException
|
|||||||
import java.net.Proxy
|
import java.net.Proxy
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
|
||||||
object HttpUtil {
|
object HttpUtil {
|
||||||
|
|
||||||
@@ -89,11 +90,11 @@ object HttpUtil {
|
|||||||
|
|
||||||
val ipList = sortedAddresses.mapNotNull { it.hostAddress }
|
val ipList = sortedAddresses.mapNotNull { it.hostAddress }
|
||||||
|
|
||||||
Log.i(AppConfig.TAG, "Resolved IPs for $host: ${ipList.joinToString()}")
|
LogUtil.i(AppConfig.TAG, "Resolved IPs for $host: ${ipList.joinToString()}")
|
||||||
|
|
||||||
return ipList
|
return ipList
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to resolve host to IP", e)
|
LogUtil.e(AppConfig.TAG, "Failed to resolve host to IP", e)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,13 +108,31 @@ object HttpUtil {
|
|||||||
* @param httpPort The HTTP port to use.
|
* @param httpPort The HTTP port to use.
|
||||||
* @return The content of the URL as a string.
|
* @return The content of the URL as a string.
|
||||||
*/
|
*/
|
||||||
fun getUrlContent(url: String, timeout: Int, httpPort: Int = 0): String? {
|
fun getUrlContent(
|
||||||
val conn = createProxyConnection(url, httpPort, timeout, timeout) ?: return null
|
url: String,
|
||||||
|
timeout: Int,
|
||||||
|
httpPort: Int = 0,
|
||||||
|
proxyUsername: String? = null,
|
||||||
|
proxyPassword: String? = null
|
||||||
|
): String? {
|
||||||
|
val client = buildOkHttpClient(timeout, httpPort, proxyUsername, proxyPassword, followRedirects = true)
|
||||||
|
val requestBuilder = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.get()
|
||||||
|
.header("Connection", "close")
|
||||||
|
if (httpPort != 0 && !proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
|
||||||
|
requestBuilder.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return conn.inputStream.bufferedReader().readText()
|
client.newCall(requestBuilder.build()).execute().use { response ->
|
||||||
} catch (_: Exception) {
|
if (!response.isSuccessful) {
|
||||||
} finally {
|
LogUtil.w(AppConfig.TAG, "Failed to get URL content, code=${response.code}")
|
||||||
conn.disconnect()
|
return null
|
||||||
|
}
|
||||||
|
return response.body?.string()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to get URL content", e)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -128,127 +147,147 @@ object HttpUtil {
|
|||||||
* @throws IOException If an I/O error occurs.
|
* @throws IOException If an I/O error occurs.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun getUrlContentWithUserAgent(url: String?, userAgent: String?, timeout: Int = 15000, httpPort: Int = 0): String {
|
fun getUrlContentWithUserAgent(
|
||||||
|
url: String?,
|
||||||
|
userAgent: String?,
|
||||||
|
timeout: Int = 15000,
|
||||||
|
httpPort: Int = 0,
|
||||||
|
proxyUsername: String? = null,
|
||||||
|
proxyPassword: String? = null
|
||||||
|
): String {
|
||||||
var currentUrl = url
|
var currentUrl = url
|
||||||
var redirects = 0
|
var redirects = 0
|
||||||
val maxRedirects = 3
|
val maxRedirects = 3
|
||||||
|
|
||||||
while (redirects++ < maxRedirects) {
|
while (redirects++ < maxRedirects) {
|
||||||
if (currentUrl == null) continue
|
if (currentUrl == null) continue
|
||||||
val conn = createProxyConnection(currentUrl, httpPort, timeout, timeout) ?: continue
|
val client = buildOkHttpClient(timeout, httpPort, proxyUsername, proxyPassword, followRedirects = false)
|
||||||
val finalUserAgent = if (userAgent.isNullOrBlank()) {
|
val finalUserAgent = if (userAgent.isNullOrBlank()) {
|
||||||
"v2rayNG/${BuildConfig.VERSION_NAME}"
|
"v2rayNG/${BuildConfig.VERSION_NAME}"
|
||||||
} else {
|
} else {
|
||||||
userAgent
|
userAgent
|
||||||
}
|
}
|
||||||
conn.setRequestProperty("User-agent", finalUserAgent)
|
val requestBuilder = Request.Builder()
|
||||||
conn.connect()
|
.url(currentUrl)
|
||||||
|
.get()
|
||||||
|
.header("User-agent", finalUserAgent)
|
||||||
|
.header("Connection", "close")
|
||||||
|
if (httpPort != 0 && !proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
|
||||||
|
requestBuilder.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
|
||||||
|
}
|
||||||
|
|
||||||
val responseCode = conn.responseCode
|
client.newCall(requestBuilder.build()).execute().use { response ->
|
||||||
when (responseCode) {
|
when {
|
||||||
in 300..399 -> {
|
response.isRedirect -> {
|
||||||
val location = resolveLocation(conn)
|
val location = response.header("Location")
|
||||||
conn.disconnect()
|
if (location.isNullOrEmpty()) {
|
||||||
if (location.isNullOrEmpty()) {
|
throw IOException("Redirect location not found")
|
||||||
throw IOException("Redirect location not found")
|
}
|
||||||
|
currentUrl = resolveLocation(currentUrl, location)
|
||||||
|
if (currentUrl.isNullOrEmpty()) {
|
||||||
|
throw IOException("Failed to resolve redirect location")
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
currentUrl = location
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> try {
|
response.isSuccessful -> {
|
||||||
return conn.inputStream.use { it.bufferedReader().readText() }
|
return response.body?.string() ?: ""
|
||||||
} finally {
|
}
|
||||||
conn.disconnect()
|
|
||||||
|
else -> {
|
||||||
|
throw IOException("Request failed with status code ${response.code}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw IOException("Too many redirects")
|
throw IOException("Too many redirects")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun buildOkHttpClient(
|
||||||
* Creates an HttpURLConnection object connected through a proxy.
|
timeout: Int,
|
||||||
*
|
httpPort: Int,
|
||||||
* @param urlStr The target URL address.
|
proxyUsername: String?,
|
||||||
* @param port The port of the proxy server.
|
proxyPassword: String?,
|
||||||
* @param connectTimeout The connection timeout in milliseconds (default is 15000 ms).
|
followRedirects: Boolean
|
||||||
* @param readTimeout The read timeout in milliseconds (default is 15000 ms).
|
): OkHttpClient {
|
||||||
* @param needStream Whether the connection needs to support streaming.
|
val builder = OkHttpClient.Builder()
|
||||||
* @return Returns a configured HttpURLConnection object, or null if it fails.
|
.connectTimeout(timeout.toLong(), TimeUnit.MILLISECONDS)
|
||||||
*/
|
.readTimeout(timeout.toLong(), TimeUnit.MILLISECONDS)
|
||||||
fun createProxyConnection(
|
.followRedirects(followRedirects)
|
||||||
urlStr: String,
|
.followSslRedirects(followRedirects)
|
||||||
port: Int,
|
|
||||||
connectTimeout: Int = 15000,
|
|
||||||
readTimeout: Int = 15000,
|
|
||||||
needStream: Boolean = false
|
|
||||||
): HttpURLConnection? {
|
|
||||||
|
|
||||||
var conn: HttpURLConnection? = null
|
if (httpPort != 0) {
|
||||||
try {
|
builder.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(LOOPBACK, httpPort)))
|
||||||
val url = URL(urlStr)
|
if (!proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
|
||||||
// Create a connection
|
builder.proxyAuthenticator { _, response ->
|
||||||
conn = if (port == 0) {
|
if (response.request.header("Proxy-Authorization") != null) {
|
||||||
url.openConnection()
|
null
|
||||||
} else {
|
} else {
|
||||||
url.openConnection(
|
response.request.newBuilder()
|
||||||
Proxy(
|
.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
|
||||||
Proxy.Type.HTTP,
|
.build()
|
||||||
InetSocketAddress(LOOPBACK, port)
|
}
|
||||||
)
|
}
|
||||||
)
|
|
||||||
} as HttpURLConnection
|
|
||||||
|
|
||||||
// Set connection and read timeouts
|
|
||||||
conn.connectTimeout = connectTimeout
|
|
||||||
conn.readTimeout = readTimeout
|
|
||||||
if (!needStream) {
|
|
||||||
// Set request headers
|
|
||||||
conn.setRequestProperty("Connection", "close")
|
|
||||||
// Disable automatic redirects
|
|
||||||
conn.instanceFollowRedirects = false
|
|
||||||
// Disable caching
|
|
||||||
conn.useCaches = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//Add Basic Authorization
|
|
||||||
url.userInfo?.let {
|
|
||||||
conn.setRequestProperty(
|
|
||||||
"Authorization",
|
|
||||||
"Basic ${encode(urlDecode(it))}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(AppConfig.TAG, "Failed to create proxy connection", e)
|
|
||||||
// If an exception occurs, close the connection and return null
|
|
||||||
conn?.disconnect()
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
return conn
|
|
||||||
|
return builder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns absolute URL string location header sets
|
private fun resolveLocation(baseUrl: String, raw: String): String? {
|
||||||
fun resolveLocation(conn: HttpURLConnection): String? {
|
|
||||||
val raw = conn.getHeaderField("Location")?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
|
||||||
|
|
||||||
// Try check url is relative or absolute
|
|
||||||
return try {
|
return try {
|
||||||
val locUri = URI(raw)
|
val locUri = URI(raw)
|
||||||
val baseUri = conn.url.toURI()
|
val baseUri = URI(baseUrl)
|
||||||
val resolved = if (locUri.isAbsolute) locUri else baseUri.resolve(locUri)
|
val resolved = if (locUri.isAbsolute) locUri else baseUri.resolve(locUri)
|
||||||
resolved.toURL().toString()
|
resolved.toURL().toString()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Fallback: url resolver, also should handles //host/...
|
|
||||||
try {
|
try {
|
||||||
URL(raw).toString() // absolute with protocol
|
URL(raw).toString()
|
||||||
} catch (_: MalformedURLException) {
|
} catch (_: MalformedURLException) {
|
||||||
try {
|
try {
|
||||||
URL(conn.url, raw).toString()
|
URL(URL(baseUrl), raw).toString()
|
||||||
} catch (_: MalformedURLException) {
|
} catch (_: MalformedURLException) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun downloadToFile(
|
||||||
|
url: String,
|
||||||
|
targetFile: File,
|
||||||
|
timeout: Int = 15000,
|
||||||
|
httpPort: Int = 0,
|
||||||
|
proxyUsername: String? = null,
|
||||||
|
proxyPassword: String? = null
|
||||||
|
): Boolean {
|
||||||
|
val client = buildOkHttpClient(timeout, httpPort, proxyUsername, proxyPassword, followRedirects = true)
|
||||||
|
val requestBuilder = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.get()
|
||||||
|
.header("Connection", "close")
|
||||||
|
if (httpPort != 0 && !proxyUsername.isNullOrBlank() && !proxyPassword.isNullOrBlank()) {
|
||||||
|
requestBuilder.header("Proxy-Authorization", Credentials.basic(proxyUsername, proxyPassword))
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
client.newCall(requestBuilder.build()).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "Failed to download file, code=${response.code}, url=$url")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val body = response.body ?: return false
|
||||||
|
body.byteStream().use { input ->
|
||||||
|
targetFile.outputStream().use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to download file: $url", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.v2ray.ang.util
|
package com.v2ray.ang.util
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.GsonBuilder
|
import com.google.gson.GsonBuilder
|
||||||
import com.google.gson.JsonObject
|
import com.google.gson.JsonObject
|
||||||
@@ -72,7 +71,7 @@ object JsonUtil {
|
|||||||
try {
|
try {
|
||||||
return JsonParser.parseString(src).getAsJsonObject()
|
return JsonParser.parseString(src).getAsJsonObject()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to parse JSON string", e)
|
LogUtil.e(AppConfig.TAG, "Failed to parse JSON string", e)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.v2ray.ang.util
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
object LogUtil {
|
||||||
|
|
||||||
|
private const val DEFAULT_LEVEL = "warning"
|
||||||
|
private const val CACHE_UNSET = Int.MIN_VALUE
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cachedMinPriority: Int = CACHE_UNSET
|
||||||
|
|
||||||
|
private fun parsePriority(level: String?): Int {
|
||||||
|
return when ((level ?: DEFAULT_LEVEL).lowercase(Locale.US)) {
|
||||||
|
"verbose" -> Log.VERBOSE
|
||||||
|
"debug" -> Log.DEBUG
|
||||||
|
"info" -> Log.INFO
|
||||||
|
"warn", "warning" -> Log.WARN
|
||||||
|
"error" -> Log.ERROR
|
||||||
|
"none", "off" -> Int.MAX_VALUE
|
||||||
|
else -> Log.WARN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
fun refreshLogLevel() {
|
||||||
|
cachedMinPriority = parsePriority(MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL, DEFAULT_LEVEL))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun minPriority(): Int {
|
||||||
|
val cached = cachedMinPriority
|
||||||
|
if (cached != CACHE_UNSET) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
return synchronized(this) {
|
||||||
|
val current = cachedMinPriority
|
||||||
|
if (current != CACHE_UNSET) {
|
||||||
|
current
|
||||||
|
} else {
|
||||||
|
parsePriority(MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL, DEFAULT_LEVEL)).also {
|
||||||
|
cachedMinPriority = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isEnabled(priority: Int): Boolean {
|
||||||
|
return priority >= minPriority()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun log(priority: Int, tag: String, message: String, throwable: Throwable? = null) {
|
||||||
|
if (!isEnabled(priority)) return
|
||||||
|
|
||||||
|
when {
|
||||||
|
throwable == null -> Log.println(priority, tag, message)
|
||||||
|
priority >= Log.ERROR -> Log.e(tag, message, throwable)
|
||||||
|
priority == Log.WARN -> Log.w(tag, message, throwable)
|
||||||
|
priority == Log.INFO -> Log.i(tag, message, throwable)
|
||||||
|
priority == Log.DEBUG -> Log.d(tag, message, throwable)
|
||||||
|
else -> Log.v(tag, message, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun d(tag: String = AppConfig.TAG, message: String) = log(Log.DEBUG, tag, message)
|
||||||
|
fun i(tag: String = AppConfig.TAG, message: String) = log(Log.INFO, tag, message)
|
||||||
|
fun w(tag: String = AppConfig.TAG, message: String) = log(Log.WARN, tag, message)
|
||||||
|
fun e(tag: String = AppConfig.TAG, message: String) = log(Log.ERROR, tag, message)
|
||||||
|
|
||||||
|
fun d(tag: String = AppConfig.TAG, message: String, throwable: Throwable) = log(Log.DEBUG, tag, message, throwable)
|
||||||
|
fun i(tag: String = AppConfig.TAG, message: String, throwable: Throwable) = log(Log.INFO, tag, message, throwable)
|
||||||
|
fun w(tag: String = AppConfig.TAG, message: String, throwable: Throwable) = log(Log.WARN, tag, message, throwable)
|
||||||
|
fun e(tag: String = AppConfig.TAG, message: String, throwable: Throwable) = log(Log.ERROR, tag, message, throwable)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,9 +3,11 @@ package com.v2ray.ang.util
|
|||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.util.Log
|
import android.os.Build
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.service.V2RayTestService
|
import com.v2ray.ang.dto.TestServiceMessage
|
||||||
|
import com.v2ray.ang.service.CoreTestService
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
|
||||||
object MessageUtil {
|
object MessageUtil {
|
||||||
@@ -37,18 +39,33 @@ object MessageUtil {
|
|||||||
* Sends a message to the test service.
|
* Sends a message to the test service.
|
||||||
*
|
*
|
||||||
* @param ctx The context.
|
* @param ctx The context.
|
||||||
* @param what The message identifier.
|
* @param message The test service message containing key, subscriptionId, and serverGuids.
|
||||||
* @param content The message content.
|
|
||||||
*/
|
*/
|
||||||
fun sendMsg2TestService(ctx: Context, what: Int, content: Serializable) {
|
fun sendMsg2TestService(ctx: Context, message: TestServiceMessage) {
|
||||||
try {
|
try {
|
||||||
val intent = Intent()
|
val intent = Intent()
|
||||||
intent.component = ComponentName(ctx, V2RayTestService::class.java)
|
intent.component = ComponentName(ctx, CoreTestService::class.java)
|
||||||
intent.putExtra("key", what)
|
intent.putExtra("content", message)
|
||||||
intent.putExtra("content", content)
|
when (message.key) {
|
||||||
ctx.startService(intent)
|
AppConfig.MSG_MEASURE_CONFIG_START -> {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
ContextCompat.startForegroundService(ctx, intent)
|
||||||
|
} else {
|
||||||
|
ctx.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> {
|
||||||
|
// Do not wake up service just to cancel; stop only if it is already running.
|
||||||
|
ctx.stopService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
ctx.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to send message to test service", e)
|
LogUtil.e(AppConfig.TAG, "Failed to send message to test service", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +86,7 @@ object MessageUtil {
|
|||||||
intent.putExtra("content", content)
|
intent.putExtra("content", content)
|
||||||
ctx.sendBroadcast(intent)
|
ctx.sendBroadcast(intent)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to send message with action: $action", e)
|
LogUtil.e(AppConfig.TAG, "Failed to send message with action: $action", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package com.v2ray.ang.util
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.enums.NotificationChannelType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified notification helper for different notification channels.
|
||||||
|
* Supports both regular notifications and foreground service notifications.
|
||||||
|
*
|
||||||
|
* Performance: NotificationManager is cached. Builder is created once per update.
|
||||||
|
* Safe for high-frequency updates (100+ times/second).
|
||||||
|
*/
|
||||||
|
object NotificationHelper {
|
||||||
|
|
||||||
|
// Cached instances for performance
|
||||||
|
private var cachedNotificationManager: NotificationManager? = null
|
||||||
|
private val builderCache = mutableMapOf<Int, NotificationCompat.Builder>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify with a regular notification (non-foreground).
|
||||||
|
*
|
||||||
|
* @param channelType The notification channel type (defines channelId, notificationId, etc.)
|
||||||
|
* @param context The context for building the notification
|
||||||
|
* @param title The notification title
|
||||||
|
* @param content The notification content text
|
||||||
|
*/
|
||||||
|
fun notify(
|
||||||
|
channelType: NotificationChannelType,
|
||||||
|
context: Context,
|
||||||
|
title: String,
|
||||||
|
content: String
|
||||||
|
) {
|
||||||
|
ensureChannelCreated(channelType, context)
|
||||||
|
val notificationManager = getNotificationManager(context)
|
||||||
|
val builder = buildNotificationBuilder(channelType, context, title, content)
|
||||||
|
notificationManager.notify(channelType.notificationId, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing notification's content.
|
||||||
|
* Optimized for high-frequency updates (100+/sec).
|
||||||
|
* Reuses cached Builder to minimize allocation overhead.
|
||||||
|
*
|
||||||
|
* @param channelType The notification channel type
|
||||||
|
* @param context The context
|
||||||
|
* @param content The new content text
|
||||||
|
*/
|
||||||
|
fun updateNotification(
|
||||||
|
channelType: NotificationChannelType,
|
||||||
|
context: Context,
|
||||||
|
content: String
|
||||||
|
) {
|
||||||
|
val notificationManager = getNotificationManager(context)
|
||||||
|
|
||||||
|
// Get or create builder from cache
|
||||||
|
val builder = builderCache.getOrPut(channelType.notificationId) {
|
||||||
|
buildNotificationBuilder(channelType, context, "", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update only the content text (fast operation)
|
||||||
|
builder.setContentText(content)
|
||||||
|
notificationManager.notify(channelType.notificationId, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a foreground service with a notification.
|
||||||
|
*
|
||||||
|
* @param service The service to set as foreground
|
||||||
|
* @param channelType The notification channel type
|
||||||
|
* @param title The notification title
|
||||||
|
* @param content The notification content text
|
||||||
|
*/
|
||||||
|
fun startForeground(
|
||||||
|
service: Service,
|
||||||
|
channelType: NotificationChannelType,
|
||||||
|
title: String,
|
||||||
|
content: String
|
||||||
|
) {
|
||||||
|
ensureChannelCreated(channelType, service)
|
||||||
|
val builder = buildNotificationBuilder(channelType, service, title, content)
|
||||||
|
service.startForeground(channelType.notificationId, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the foreground notification for a service.
|
||||||
|
*
|
||||||
|
* @param service The service to stop foreground on
|
||||||
|
*/
|
||||||
|
fun stopForeground(service: Service) {
|
||||||
|
service.stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a notification and clean up cached builder.
|
||||||
|
*
|
||||||
|
* @param channelType The notification channel type
|
||||||
|
* @param context The context
|
||||||
|
*/
|
||||||
|
fun cancel(
|
||||||
|
channelType: NotificationChannelType,
|
||||||
|
context: Context
|
||||||
|
) {
|
||||||
|
getNotificationManager(context).cancel(channelType.notificationId)
|
||||||
|
builderCache.remove(channelType.notificationId) // Clean up cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Private helper methods ======
|
||||||
|
|
||||||
|
private fun getNotificationManager(context: Context): NotificationManager {
|
||||||
|
if (cachedNotificationManager == null) {
|
||||||
|
cachedNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
}
|
||||||
|
return cachedNotificationManager!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureChannelCreated(channelType: NotificationChannelType, context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
|
||||||
|
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (notificationManager.getNotificationChannel(channelType.channelId) != null) return
|
||||||
|
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
channelType.channelId,
|
||||||
|
channelType.channelName,
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||||
|
}
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotificationBuilder(
|
||||||
|
channelType: NotificationChannelType,
|
||||||
|
context: Context,
|
||||||
|
title: String,
|
||||||
|
content: String
|
||||||
|
): NotificationCompat.Builder {
|
||||||
|
val channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
channelType.channelId
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
val displayTitle = title.ifEmpty { context.getString(R.string.app_name) }
|
||||||
|
return NotificationCompat.Builder(context, channelId)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_name)
|
||||||
|
.setContentTitle(displayTitle)
|
||||||
|
.setContentText(content)
|
||||||
|
.setOngoing(false)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.v2ray.ang.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
object PackageUidResolver {
|
||||||
|
|
||||||
|
// In-process cache to avoid resolving the same package UID repeatedly.
|
||||||
|
private val packageUidCache = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
val packageUidMap: Map<String, String>
|
||||||
|
get() = packageUidCache
|
||||||
|
|
||||||
|
fun packageNamesToUids(context: Context, packageNames: List<String>): List<String> {
|
||||||
|
return packageNames.mapNotNull { pkg ->
|
||||||
|
packageUidCache[pkg] ?: resolveUid(context, pkg)?.also { uid ->
|
||||||
|
packageUidCache[pkg] = uid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uidsToPackageNames(uids: List<String>): List<String> {
|
||||||
|
if (uids.isEmpty()) return emptyList()
|
||||||
|
return packageUidCache.filterValues { it in uids }.keys.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uidToPackageName(uid: String): String? {
|
||||||
|
return packageUidCache.entries.firstOrNull { it.value == uid }?.key
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveUid(context: Context, packageName: String): String? {
|
||||||
|
// Special token for connections whose UID cannot be resolved (mapped to -1)
|
||||||
|
if (packageName == AppConfig.UNIDENTIFIED_PACKAGE) {
|
||||||
|
val uid = "-1"
|
||||||
|
LogUtil.d(AppConfig.TAG, "Special package: $packageName -> UID: $uid")
|
||||||
|
return uid
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val uid = context.packageManager.getPackageUid(packageName, 0).toString()
|
||||||
|
LogUtil.d(AppConfig.TAG, "Package: $packageName -> UID: $uid")
|
||||||
|
uid
|
||||||
|
} catch (_: PackageManager.NameNotFoundException) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "Package not found: $packageName")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import android.os.LocaleList
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
|
||||||
import android.util.Patterns
|
import android.util.Patterns
|
||||||
import android.webkit.URLUtil
|
import android.webkit.URLUtil
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -79,7 +78,7 @@ object Utils {
|
|||||||
val cmb = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val cmb = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
cmb.primaryClip?.getItemAt(0)?.text.toString()
|
cmb.primaryClip?.getItemAt(0)?.text.toString()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to get clipboard content", e)
|
LogUtil.e(AppConfig.TAG, "Failed to get clipboard content", e)
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +95,7 @@ object Utils {
|
|||||||
val clipData = ClipData.newPlainText(null, content)
|
val clipData = ClipData.newPlainText(null, content)
|
||||||
cmb.setPrimaryClip(clipData)
|
cmb.setPrimaryClip(clipData)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to set clipboard content", e)
|
LogUtil.e(AppConfig.TAG, "Failed to set clipboard content", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,12 +121,12 @@ object Utils {
|
|||||||
try {
|
try {
|
||||||
return Base64.decode(text, Base64.NO_WRAP).toString(Charsets.UTF_8)
|
return Base64.decode(text, Base64.NO_WRAP).toString(Charsets.UTF_8)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to decode standard base64", e)
|
LogUtil.e(AppConfig.TAG, "Failed to decode standard base64", e)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return Base64.decode(text, Base64.NO_WRAP.or(Base64.URL_SAFE)).toString(Charsets.UTF_8)
|
return Base64.decode(text, Base64.NO_WRAP.or(Base64.URL_SAFE)).toString(Charsets.UTF_8)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to decode URL-safe base64", e)
|
LogUtil.e(AppConfig.TAG, "Failed to decode URL-safe base64", e)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -139,7 +138,7 @@ object Utils {
|
|||||||
* @param removePadding
|
* @param removePadding
|
||||||
* @return The base64 encoded string, or an empty string if encoding fails.
|
* @return The base64 encoded string, or an empty string if encoding fails.
|
||||||
*/
|
*/
|
||||||
fun encode(text: String, removePadding : Boolean = false): String {
|
fun encode(text: String, removePadding: Boolean = false): String {
|
||||||
return try {
|
return try {
|
||||||
var encoded = Base64.encodeToString(text.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
var encoded = Base64.encodeToString(text.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
||||||
if (removePadding) {
|
if (removePadding) {
|
||||||
@@ -147,7 +146,7 @@ object Utils {
|
|||||||
}
|
}
|
||||||
encoded
|
encoded
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to encode text to base64", e)
|
LogUtil.e(AppConfig.TAG, "Failed to encode text to base64", e)
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,7 +189,7 @@ object Utils {
|
|||||||
|
|
||||||
return isIpv6Address(addr)
|
return isIpv6Address(addr)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to validate IP address", e)
|
LogUtil.e(AppConfig.TAG, "Failed to validate IP address", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,7 +270,7 @@ object Utils {
|
|||||||
Patterns.DOMAIN_NAME.matcher(value).matches() ||
|
Patterns.DOMAIN_NAME.matcher(value).matches() ||
|
||||||
URLUtil.isValidUrl(value)
|
URLUtil.isValidUrl(value)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to validate URL", e)
|
LogUtil.e(AppConfig.TAG, "Failed to validate URL", e)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,7 +286,7 @@ object Utils {
|
|||||||
val uri = uriString.toUri()
|
val uri = uriString.toUri()
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
|
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to open URI", e)
|
LogUtil.e(AppConfig.TAG, "Failed to open URI", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +299,7 @@ object Utils {
|
|||||||
return try {
|
return try {
|
||||||
UUID.randomUUID().toString().replace("-", "")
|
UUID.randomUUID().toString().replace("-", "")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to generate UUID", e)
|
LogUtil.e(AppConfig.TAG, "Failed to generate UUID", e)
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,7 +314,7 @@ object Utils {
|
|||||||
return try {
|
return try {
|
||||||
URLDecoder.decode(url, Charsets.UTF_8.toString())
|
URLDecoder.decode(url, Charsets.UTF_8.toString())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to decode URL", e)
|
LogUtil.e(AppConfig.TAG, "Failed to decode URL", e)
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,7 +329,7 @@ object Utils {
|
|||||||
return try {
|
return try {
|
||||||
URLEncoder.encode(url, Charsets.UTF_8.toString())
|
URLEncoder.encode(url, Charsets.UTF_8.toString())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to encode URL", e)
|
LogUtil.e(AppConfig.TAG, "Failed to encode URL", e)
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +348,7 @@ object Utils {
|
|||||||
// to avoid cross-language interoperability issues.
|
// to avoid cross-language interoperability issues.
|
||||||
URLDecoder.decode(url.replace("+", "%2B"), Charsets.UTF_8.toString())
|
URLDecoder.decode(url.replace("+", "%2B"), Charsets.UTF_8.toString())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to decode encodeURIComponent", e)
|
LogUtil.e(AppConfig.TAG, "Failed to decode encodeURIComponent", e)
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,7 +364,7 @@ object Utils {
|
|||||||
// Replace '+' with '%20' to conform to encodeURIComponent semantics.
|
// Replace '+' with '%20' to conform to encodeURIComponent semantics.
|
||||||
URLEncoder.encode(url, Charsets.UTF_8.toString()).replace("+", "%20")
|
URLEncoder.encode(url, Charsets.UTF_8.toString()).replace("+", "%20")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to encode encodeURIComponent", e)
|
LogUtil.e(AppConfig.TAG, "Failed to encode encodeURIComponent", e)
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,7 +386,7 @@ object Utils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to read asset file: $fileName", e)
|
LogUtil.e(AppConfig.TAG, "Failed to read asset file: $fileName", e)
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -405,7 +404,7 @@ object Utils {
|
|||||||
context.getExternalFilesDir(AppConfig.DIR_ASSETS)?.absolutePath
|
context.getExternalFilesDir(AppConfig.DIR_ASSETS)?.absolutePath
|
||||||
?: context.getDir(AppConfig.DIR_ASSETS, 0).absolutePath
|
?: context.getDir(AppConfig.DIR_ASSETS, 0).absolutePath
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to get user asset path", e)
|
LogUtil.e(AppConfig.TAG, "Failed to get user asset path", e)
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -420,7 +419,7 @@ object Utils {
|
|||||||
val androidId = Settings.Secure.ANDROID_ID.toByteArray(Charsets.UTF_8)
|
val androidId = Settings.Secure.ANDROID_ID.toByteArray(Charsets.UTF_8)
|
||||||
Base64.encodeToString(androidId.copyOf(32), Base64.NO_PADDING.or(Base64.URL_SAFE))
|
Base64.encodeToString(androidId.copyOf(32), Base64.NO_PADDING.or(Base64.URL_SAFE))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to generate device ID", e)
|
LogUtil.e(AppConfig.TAG, "Failed to generate device ID", e)
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -512,7 +511,7 @@ object Utils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to validate subscription URL", e)
|
LogUtil.e(AppConfig.TAG, "Failed to validate subscription URL", e)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -582,7 +581,7 @@ object Utils {
|
|||||||
// Check if they're in the same subnet
|
// Check if they're in the same subnet
|
||||||
return (ipLong and mask) == (cidrIpLong and mask)
|
return (ipLong and mask) == (cidrIpLong and mask)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to check if IP is in CIDR", e)
|
LogUtil.e(AppConfig.TAG, "Failed to check if IP is in CIDR", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -599,7 +598,7 @@ object Utils {
|
|||||||
val sdf = SimpleDateFormat(pattern, locale)
|
val sdf = SimpleDateFormat(pattern, locale)
|
||||||
sdf.format(Date(ts))
|
sdf.format(Date(ts))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to format timestamp", e)
|
LogUtil.e(AppConfig.TAG, "Failed to format timestamp", e)
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.v2ray.ang.util
|
package com.v2ray.ang.util
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import java.io.BufferedOutputStream
|
import java.io.BufferedOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -63,7 +62,7 @@ object ZipUtil {
|
|||||||
zos.closeEntry()
|
zos.closeEntry()
|
||||||
zos.close()
|
zos.close()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to zip folder", e)
|
LogUtil.e(AppConfig.TAG, "Failed to zip folder", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -99,7 +98,7 @@ object ZipUtil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.TAG, "Failed to unzip file", e)
|
LogUtil.e(AppConfig.TAG, "Failed to unzip file", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.v2ray.ang.viewmodel
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
class LogcatViewModel : ViewModel() {
|
class LogcatViewModel : ViewModel() {
|
||||||
@@ -28,7 +29,7 @@ class LogcatViewModel : ViewModel() {
|
|||||||
logsetsAll.addAll(allText)
|
logsetsAll.addAll(allText)
|
||||||
applyFilter()
|
applyFilter()
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
android.util.Log.e(AppConfig.TAG, "Failed to get logcat", e)
|
LogUtil.e(AppConfig.TAG, "Failed to get logcat", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ class LogcatViewModel : ViewModel() {
|
|||||||
logsetsAll.clear()
|
logsetsAll.clear()
|
||||||
filteredLogs = emptyList()
|
filteredLogs = emptyList()
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
android.util.Log.e(AppConfig.TAG, "Failed to clear logcat", e)
|
LogUtil.e(AppConfig.TAG, "Failed to clear logcat", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user