Compare commits
333 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66f100ebb3 | |||
| b0213ffdf6 | |||
| 538b2eb0f8 | |||
| f2093c4c52 | |||
| 02ac57a4c6 | |||
| 379266f205 | |||
| 23dbb35da7 | |||
| 22a605ecda | |||
| c7dd45f4f2 | |||
| 609e688d87 | |||
| a2a4ea79a9 | |||
| c08147c362 | |||
| c99fda9839 | |||
| 587d103518 | |||
| 192a4afe6d | |||
| ee69364f2b | |||
| 6be0bd7b21 | |||
| a3972b98f3 | |||
| a2b6e669d8 | |||
| 55c42fbf2a | |||
| ab4af6bd82 | |||
| c3a8ee59dc | |||
| 18f23d9d3b | |||
| 4a3686fb32 | |||
| 68ed08d36e | |||
| d0612619fd | |||
| 3ddd6ee699 | |||
| 485ea2e28d | |||
| 2fd57baa37 | |||
| 73d1dce4db | |||
| bd60224577 | |||
| 3f1b741d9d | |||
| b71614a9cd | |||
| 31d29d97c0 | |||
| 183391b6e3 | |||
| 3ad6464595 | |||
| e684bef35d | |||
| a87d5d375b | |||
| 545683c9ba | |||
| 1a8631257b | |||
| ebebd3277f | |||
| 5242827baf | |||
| c6799d5bf3 | |||
| 054a3bfa5e | |||
| 02a9d64188 | |||
| 29b67c29f5 | |||
| 79d5cad738 | |||
| de5b714a28 | |||
| 416ee28e80 | |||
| 71f0d98e68 | |||
| 37bd57ffc5 | |||
| b4ac79e4fa | |||
| 8aba43d448 | |||
| e564083352 | |||
| 08bf66350a | |||
| 66d4c21b77 | |||
| 359b0af7ae | |||
| 4b1fa29ade | |||
| e287ac88c8 | |||
| 4812f067db | |||
| 47be68c52f | |||
| 418477267b | |||
| 74ad37d329 | |||
| fd83f88ec2 | |||
| 214ab76459 | |||
| f67bee04ad | |||
| 2cfef7a614 | |||
| bd6b67cbfc | |||
| bb374f2c7e | |||
| f85c382dbd | |||
| 0b76ff0409 | |||
| e3bbd47c81 | |||
| 0672820718 | |||
| e0e58dff45 | |||
| cfae051109 | |||
| 60ddf5ba6b | |||
| 861b1746e3 | |||
| 4dec554b42 | |||
| d7ad91fe8b | |||
| fb51683981 | |||
| e5d14bada9 | |||
| 80fb265960 | |||
| f5493b08ae | |||
| 4f7d6db361 | |||
| 636b20290a | |||
| 7ead97079c | |||
| e42cc3a60a | |||
| d231490917 | |||
| 521d61acb3 | |||
| ed9c26b012 | |||
| e782da7c71 | |||
| 6fd0f7f0db | |||
| 7c14a0c200 | |||
| 2789db2c70 | |||
| 8aee96b5eb | |||
| 02d58dde9a | |||
| 623f5446f2 | |||
| b961c86066 | |||
| 3e4f0e9b5b | |||
| c0141225d3 | |||
| 026bf6a37b | |||
| c117a334ce | |||
| 051520de37 | |||
| b5146e4712 | |||
| 4457b6b2c9 | |||
| 12edb051c3 | |||
| 923e3f32d1 | |||
| 5490db90c0 | |||
| cb68d42291 | |||
| ec3a8e80d6 | |||
| eb0960125b | |||
| 12b70e2088 | |||
| 0c473dea19 | |||
| e71dd7342a | |||
| a765215dda | |||
| edea3027f7 | |||
| 305219c3bc | |||
| 9c4188fba7 | |||
| 0f3124bece | |||
| 2614f72a36 | |||
| 480c7fc8a1 | |||
| 302e7f148e | |||
| 98d6da983b | |||
| f6f60f77d3 | |||
| 7be0fb3ecd | |||
| d53a9f9ecd | |||
| 9eb03eece2 | |||
| 6632ba3b6e | |||
| bd582f1d90 | |||
| f5f1e12565 | |||
| 207d6f4f8c | |||
| 52e0a19826 | |||
| fc0e60a097 | |||
| 65d6b4aaa8 | |||
| 7b11755e7f | |||
| 2d0de4860c | |||
| 7406ef16ff | |||
| a084b21d50 | |||
| bf01fe2bdb | |||
| 519cc2a4b5 | |||
| c21653d40f | |||
| 1919c5e05f | |||
| 8a17d93882 | |||
| 21ed008c7b | |||
| d83cfa28c2 | |||
| d27e2091a7 | |||
| c545678e47 | |||
| 05cba9b0fe | |||
| b472aa0e6b | |||
| 1ad840851a | |||
| ffc74f479c | |||
| 81c0087ac1 | |||
| 97327a0101 | |||
| 6318dd554b | |||
| 785aa7eb8a | |||
| 872e926132 | |||
| b278180eb5 | |||
| ce64ffbf3b | |||
| 5ad8605a41 | |||
| 95f7e99752 | |||
| f703a41778 | |||
| 2251c59c0b | |||
| edd0ce96b1 | |||
| 562283b3cc | |||
| bb04953845 | |||
| 208a93a917 | |||
| 92f33ee3f8 | |||
| 084346b348 | |||
| 15de18b736 | |||
| b60423d1c0 | |||
| 86e38c6963 | |||
| 9ba4d7e691 | |||
| 16e72787c9 | |||
| 3ffac8b29f | |||
| 10df1b44ea | |||
| fa12878258 | |||
| 0f1ea1e119 | |||
| 1959608f24 | |||
| 0e041a6e9a | |||
| c78ef380cc | |||
| 57362a4bde | |||
| 7f24ad534f | |||
| 680832614b | |||
| 4357abbff4 | |||
| 905be66c3f | |||
| 318a7b54a5 | |||
| 5db2df77a0 | |||
| d039cb9edf | |||
| 9a1654bae9 | |||
| 3bf911da9c | |||
| 3f778a1ea2 | |||
| 8e03de8055 | |||
| 1f42d7fc07 | |||
| 0700e834f1 | |||
| 777190e861 | |||
| 33572477fc | |||
| 2fb6e62e13 | |||
| 94cc72d2b9 | |||
| f68c353715 | |||
| e077c18108 | |||
| 1a5e105212 | |||
| e0881caab4 | |||
| 7219425258 | |||
| 51eabe5440 | |||
| 6f0b3ce990 | |||
| 69e27ed3bb | |||
| fff6ab30e6 | |||
| fdb67a86f4 | |||
| ea088376ac | |||
| 52332d960e | |||
| 3ead542e2b | |||
| 9d1f98ff34 | |||
| f305e26a39 | |||
| aa47fba20d | |||
| 69c5bbfd3d | |||
| 90ed02804c | |||
| 822c1de79c | |||
| d910b93525 | |||
| 7e6b1c247b | |||
| f3f2b7fab5 | |||
| e6f260da76 | |||
| 55bc2bf934 | |||
| f22454da5d | |||
| 4a87549fa7 | |||
| d447adc97f | |||
| 3773962b64 | |||
| be0a2506ce | |||
| 7f9cb8dfdd | |||
| 71a5b6e480 | |||
| 02e53ced50 | |||
| 42c27a5e7e | |||
| af04bbcf87 | |||
| 9bedfe8a7b | |||
| 2fdf684ee7 | |||
| 5b79951da7 | |||
| 06aa680d45 | |||
| cdb9b1811c | |||
| 0fc1f2f5d3 | |||
| ef1bb3dd34 | |||
| 1bca321d3f | |||
| 247e2b3ba3 | |||
| 41fd2b0cfb | |||
| 72da42ee40 | |||
| c130d55e8f | |||
| 5ae84f7eac | |||
| df5ea251e1 | |||
| 8890d9f004 | |||
| 4fcb3f9d06 | |||
| 5bf7c98cd3 | |||
| 46bc1a49df | |||
| 21175f41ec | |||
| 864c63987e | |||
| 4ac0547e22 | |||
| 12a9ee262c | |||
| cfa9c19c94 | |||
| 56e33e6cdd | |||
| 02421072c1 | |||
| b862a0dc65 | |||
| 1f25d6a000 | |||
| e1def0616a | |||
| 83fd6efc17 | |||
| f0c0e2e83a | |||
| 6ca3eb769e | |||
| 963d24ab66 | |||
| cfd81441fa | |||
| 4084ae2938 | |||
| 3f9bc098ec | |||
| 9cb28ed969 | |||
| 773ddc5373 | |||
| 38193b5621 | |||
| 358713a2a3 | |||
| 5b9f24c1f0 | |||
| c47c2c3666 | |||
| 49f7c3e7d7 | |||
| 423e5de2c6 | |||
| 3e3387e63e | |||
| debddace8b | |||
| 160b412e0a | |||
| 0f3e0a0ea2 | |||
| c4cf90e807 | |||
| 5db46e81b7 | |||
| 1ef80a3a96 | |||
| a46d9d0d2a | |||
| 7b80536e1e | |||
| 5733ecf20e | |||
| eae33b61cf | |||
| 9e55b525f1 | |||
| 678b3cb505 | |||
| b4c833b039 | |||
| 597bd021b8 | |||
| ba03118a43 | |||
| 82148408b0 | |||
| 042900e065 | |||
| 874fccc351 | |||
| 14f36872e7 | |||
| 3b6ad3052a | |||
| 194fc6b6ed | |||
| 0275ad54ac | |||
| 7ca4044467 | |||
| 1672494ee9 | |||
| bbbbc72d22 | |||
| 1e7f49b756 | |||
| ac4c0f7ee1 | |||
| 6cc91b1a89 | |||
| 45facff41d | |||
| ee703e6c95 | |||
| 87213c34a6 | |||
| 73a7c76183 | |||
| ed5282f2b3 | |||
| 390c657047 | |||
| 7071072862 | |||
| d111328541 | |||
| 76cb2aaf46 | |||
| 7ff1397163 | |||
| bcf5d49a3d | |||
| 4fffb17283 | |||
| 83b8bdfdf4 | |||
| cc1538a24d | |||
| eb19199d18 | |||
| 441e5ef8d5 | |||
| d768774aad | |||
| c3d83907a5 | |||
| b52a98ae5e | |||
| a70e4089e3 | |||
| 397989769c | |||
| 796676abdc | |||
| 3c93ccb86c | |||
| fc8c74184a | |||
| 1939e6b5cf | |||
| 1c2ac9385d | |||
| 7513f1fe07 | |||
| 63e2c02daa | |||
| fec76385e1 |
+17
-47
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
fetch-depth: '0'
|
||||
@@ -31,33 +31,33 @@ jobs:
|
||||
- name: Install NDK
|
||||
run: |
|
||||
echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
||||
--channel=3 \
|
||||
--install "ndk;29.0.13113456"
|
||||
echo "NDK_HOME=$ANDROID_HOME/ndk/29.0.13113456" >> $GITHUB_ENV
|
||||
--channel=0 \
|
||||
--install "ndk;28.2.13676358"
|
||||
echo "NDK_HOME=$ANDROID_HOME/ndk/28.2.13676358" >> $GITHUB_ENV
|
||||
sed -i '10i\
|
||||
\
|
||||
ndkVersion = "29.0.13113456"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
|
||||
ndkVersion = "28.2.13676358"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
|
||||
|
||||
- name: Restore cached libtun2socks
|
||||
id: cache-libtun2socks-restore
|
||||
- name: Restore cached libhevtun
|
||||
id: cache-libhevtun-restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}/libs
|
||||
key: libtun2socks-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/badvpn/HEAD') }}-${{ hashFiles('.git/modules/libancillary/HEAD') }}
|
||||
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
|
||||
|
||||
- name: Build libtun2socks
|
||||
if: steps.cache-libtun2socks-restore.outputs.cache-hit != 'true'
|
||||
- name: Build libhevtun
|
||||
if: steps.cache-libhevtun-restore.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
bash compile-tun2socks.sh
|
||||
|
||||
- name: Save libtun2socks
|
||||
if: steps.cache-libtun2socks-restore.outputs.cache-hit != 'true'
|
||||
bash compile-hevtun.sh
|
||||
|
||||
- name: Save libhevtun
|
||||
if: steps.cache-libhevtun-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}/libs
|
||||
key: libtun2socks-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/badvpn/HEAD') }}-${{ hashFiles('.git/modules/libancillary/HEAD') }}
|
||||
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
|
||||
|
||||
- name: Copy libtun2socks
|
||||
- name: Copy libhevtun
|
||||
run: |
|
||||
cp -r ${{ github.workspace }}/libs ${{ github.workspace }}/V2rayNG/app
|
||||
|
||||
@@ -77,36 +77,6 @@ jobs:
|
||||
fileName: 'libv2ray.aar'
|
||||
out-file-path: V2rayNG/app/libs/
|
||||
|
||||
- name: Restore cached libhysteria2
|
||||
id: cache-libhysteria2-restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}/hysteria/libs
|
||||
key: libhysteria2-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hysteria/HEAD') }}-${{ hashFiles('libhysteria2.sh') }}
|
||||
|
||||
- name: Setup Golang
|
||||
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-go@v5.4.0
|
||||
with:
|
||||
go-version-file: 'AndroidLibXrayLite/go.mod'
|
||||
cache: false
|
||||
|
||||
- name: Build libhysteria2
|
||||
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
bash libhysteria2.sh
|
||||
|
||||
- name: Save libhysteria2
|
||||
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}/hysteria/libs
|
||||
key: libhysteria2-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hysteria/HEAD') }}-${{ hashFiles('libhysteria2.sh') }}
|
||||
|
||||
- name: Copy libhysteria2
|
||||
run: |
|
||||
cp -r ${{ github.workspace }}/hysteria/libs ${{ github.workspace }}/V2rayNG/app
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4.7.0
|
||||
with:
|
||||
@@ -153,7 +123,7 @@ jobs:
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
if: github.event.inputs.release_tag != ''
|
||||
with:
|
||||
file: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*playstore*/release/*.apk
|
||||
file: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*.apk
|
||||
tag: ${{ github.event.inputs.release_tag }}
|
||||
file_glob: true
|
||||
prerelease: true
|
||||
|
||||
@@ -13,4 +13,4 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Validate Fastlane Supply Metadata
|
||||
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.0.0
|
||||
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0
|
||||
|
||||
+60
@@ -1,6 +1,66 @@
|
||||
# Ignore data and key store files
|
||||
*.dat
|
||||
*.jks
|
||||
|
||||
# Ignore output JSON file
|
||||
V2rayNG/app/release/output.json
|
||||
|
||||
# Ignore IDE and build system directories
|
||||
.idea/
|
||||
.gradle/
|
||||
*.iml
|
||||
|
||||
# Ignore local properties and DS_Store files
|
||||
/local.properties
|
||||
.DS_Store
|
||||
|
||||
# Ignore build directories and captures
|
||||
/build
|
||||
/captures
|
||||
V2rayNG/app/build
|
||||
V2rayNG/build
|
||||
V2rayNG/local.properties
|
||||
|
||||
# Ignore APK and AAR files
|
||||
*.apk
|
||||
*.aar
|
||||
|
||||
# Ignore signing properties
|
||||
signing.properties
|
||||
|
||||
# Ignore shared object files
|
||||
*.so
|
||||
|
||||
# Ignore Google services JSON
|
||||
V2rayNG/app/google-services.json
|
||||
|
||||
# Additional common Android/Java ignores
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
*.swp
|
||||
*.orig
|
||||
*.class
|
||||
*.jar
|
||||
*.war
|
||||
*.ear
|
||||
|
||||
# Ignore executable files
|
||||
*.exe
|
||||
*.dll
|
||||
*.obj
|
||||
*.o
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# Ignore files from other IDEs
|
||||
.vscode/
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
|
||||
# Ignore OS-specific files
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
+3
-9
@@ -1,12 +1,6 @@
|
||||
[submodule "hysteria"]
|
||||
path = hysteria
|
||||
url = https://github.com/apernet/hysteria
|
||||
[submodule "AndroidLibXrayLite"]
|
||||
path = AndroidLibXrayLite
|
||||
url = https://github.com/2dust/AndroidLibXrayLite
|
||||
[submodule "badvpn"]
|
||||
path = badvpn
|
||||
url = https://github.com/XTLS/badvpn
|
||||
[submodule "libancillary"]
|
||||
path = libancillary
|
||||
url = https://github.com/shadowsocks/libancillary
|
||||
[submodule "hev-socks5-tunnel"]
|
||||
path = hev-socks5-tunnel
|
||||
url = https://github.com/heiher/hev-socks5-tunnel
|
||||
|
||||
+1
-1
Submodule AndroidLibXrayLite updated: c8a6ca7c5e...16222c3387
@@ -2,17 +2,13 @@
|
||||
|
||||
A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-core) and [v2fly core](https://github.com/v2fly/v2ray-core)
|
||||
|
||||
[](https://developer.android.com/about/versions/lollipop)
|
||||
[](https://kotlinlang.org)
|
||||
[](https://developer.android.com/about/versions/lollipop)
|
||||
[](https://kotlinlang.org)
|
||||
[](https://github.com/2dust/v2rayNG/commits/master)
|
||||
[](https://www.codefactor.io/repository/github/2dust/v2rayng)
|
||||
[](https://github.com/2dust/v2rayNG/releases)
|
||||
[](https://t.me/v2rayn)
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=com.v2ray.ang">
|
||||
<img alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png" width="165" height="64" />
|
||||
</a>
|
||||
|
||||
### Telegram Channel
|
||||
[github_2dust](https://t.me/github_2dust)
|
||||
|
||||
@@ -21,7 +17,7 @@ A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-cor
|
||||
#### Geoip and Geosite
|
||||
- geoip.dat and geosite.dat files are in `Android/data/com.v2ray.ang/files/assets` (path may differ on some Android device)
|
||||
- download feature will get enhanced version in this [repo](https://github.com/Loyalsoldier/v2ray-rules-dat) (Note it need a working proxy)
|
||||
- latest official [domain list](https://github.com/v2fly/domain-list-community) and [ip list](https://github.com/v2fly/geoip) can be imported manually
|
||||
- latest official [domain list](https://github.com/Loyalsoldier/v2ray-rules-dat) and [ip list](https://github.com/Loyalsoldier/geoip) can be imported manually
|
||||
- possible to use third party dat file in the same folder, like [h2y](https://guide.v2fly.org/routing/sitedata.html#%E5%A4%96%E7%BD%AE%E7%9A%84%E5%9F%9F%E5%90%8D%E6%96%87%E4%BB%B6)
|
||||
|
||||
### More in our [wiki](https://github.com/2dust/v2rayNG/wiki)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
*.apk
|
||||
signing.properties
|
||||
*.aar
|
||||
@@ -1,2 +0,0 @@
|
||||
/build
|
||||
/google-services.json
|
||||
@@ -6,14 +6,14 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.v2ray.ang"
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 641
|
||||
versionName = "1.9.41"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 702
|
||||
versionName = "2.0.2"
|
||||
multiDexEnabled = true
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
@@ -67,14 +67,16 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
@@ -82,8 +84,9 @@ android {
|
||||
val isFdroid = variant.productFlavors.any { it.name == "fdroid" }
|
||||
if (isFdroid) {
|
||||
val versionCodes =
|
||||
mapOf("armeabi-v7a" to 2, "arm64-v8a" to 1, "x86" to 4, "x86_64" to 3, "universal" to 0
|
||||
)
|
||||
mapOf(
|
||||
"armeabi-v7a" to 2, "arm64-v8a" to 1, "x86" to 4, "x86_64" to 3, "universal" to 0
|
||||
)
|
||||
|
||||
variant.outputs
|
||||
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
|
||||
@@ -145,16 +148,19 @@ dependencies {
|
||||
implementation(libs.preference.ktx)
|
||||
implementation(libs.recyclerview)
|
||||
implementation(libs.androidx.swiperefreshlayout)
|
||||
implementation(libs.androidx.viewpager2)
|
||||
implementation(libs.androidx.fragment)
|
||||
|
||||
// UI Libraries
|
||||
implementation(libs.material)
|
||||
implementation(libs.toastcompat)
|
||||
implementation(libs.toasty)
|
||||
implementation(libs.editorkit)
|
||||
implementation(libs.flexbox)
|
||||
|
||||
// Data and Storage Libraries
|
||||
implementation(libs.mmkv.static)
|
||||
implementation(libs.gson)
|
||||
implementation(libs.okhttp)
|
||||
|
||||
// Reactive and Utility Libraries
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">v2rayNG (F-Droid)</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_qu_switch_24dp"
|
||||
android:shortcutDisabledMessage="@string/app_widget_name"
|
||||
android:shortcutId="shortcuts_switch"
|
||||
android:shortcutLongLabel="@string/app_widget_name"
|
||||
android:shortcutShortLabel="@string/app_widget_name">
|
||||
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetClass="com.v2ray.ang.ui.ScSwitchActivity"
|
||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_qu_scan_24dp"
|
||||
android:shortcutDisabledMessage="@string/menu_item_import_config_qrcode"
|
||||
android:shortcutId="shortcuts_scan"
|
||||
android:shortcutLongLabel="@string/menu_item_import_config_qrcode"
|
||||
android:shortcutShortLabel="@string/menu_item_import_config_qrcode">
|
||||
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetClass="com.v2ray.ang.ui.ScScannerActivity"
|
||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
</shortcuts>
|
||||
@@ -10,10 +10,6 @@
|
||||
android:smallScreens="true"
|
||||
android:xlargeScreens="true" />
|
||||
|
||||
<uses-sdk
|
||||
android:minSdkVersion="21"
|
||||
tools:overrideLibrary="com.blacksquircle.ui.editorkit" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
@@ -30,12 +26,11 @@
|
||||
<!-- https://developer.android.com/about/versions/11/privacy/package-visibility -->
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<!-- <useapplications-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission
|
||||
@@ -52,17 +47,15 @@
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppThemeDayNight"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
tools:targetApi="m">
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar">
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -85,6 +78,10 @@
|
||||
android:name=".ui.ServerCustomConfigActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged" />
|
||||
<activity
|
||||
android:name=".ui.ServerGroupActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged" />
|
||||
<activity
|
||||
android:name=".ui.SettingsActivity"
|
||||
android:exported="false" />
|
||||
@@ -124,7 +121,7 @@
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:theme="@style/AppTheme.NoActionBar.Translucent" />
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.UrlSchemeActivity"
|
||||
@@ -145,6 +142,12 @@
|
||||
<data android:host="install-sub" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.CheckUpdateActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.BackupActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.AboutActivity"
|
||||
android:exported="false" />
|
||||
@@ -156,7 +159,8 @@
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
tools:ignore="VpnServicePolicy">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
@@ -193,8 +197,8 @@
|
||||
android:resource="@xml/app_widget_provider" />
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
<action android:name="com.v2ray.ang.action.widget.click" />
|
||||
<action android:name="com.v2ray.ang.action.activity" />
|
||||
<action android:name="${applicationId}.action.widget.click" />
|
||||
<action android:name="${applicationId}.action.activity" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver
|
||||
|
||||
@@ -6,14 +6,6 @@
|
||||
"bittorrent"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "Google cn",
|
||||
"outboundTag": "proxy",
|
||||
"domain": [
|
||||
"domain:googleapis.cn",
|
||||
"domain:gstatic.com"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "阻断udp443",
|
||||
"outboundTag": "block",
|
||||
@@ -21,10 +13,10 @@
|
||||
"network": "udp"
|
||||
},
|
||||
{
|
||||
"remarks": "阻断广告",
|
||||
"outboundTag": "block",
|
||||
"remarks": "代理Google",
|
||||
"outboundTag": "proxy",
|
||||
"domain": [
|
||||
"geosite:category-ads-all"
|
||||
"geosite:google"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -146,4 +138,4 @@
|
||||
"port": "0-65535",
|
||||
"outboundTag": "direct"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -5,13 +5,6 @@
|
||||
"port": "443",
|
||||
"network": "udp"
|
||||
},
|
||||
{
|
||||
"remarks": "阻断广告",
|
||||
"outboundTag": "block",
|
||||
"domain": [
|
||||
"geosite:category-ads-all"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "绕过局域网IP",
|
||||
"outboundTag": "direct",
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
[
|
||||
{
|
||||
"remarks": "Google cn",
|
||||
"outboundTag": "proxy",
|
||||
"domain": [
|
||||
"domain:googleapis.cn",
|
||||
"domain:gstatic.com"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "阻断udp443",
|
||||
"outboundTag": "block",
|
||||
@@ -14,10 +6,10 @@
|
||||
"network": "udp"
|
||||
},
|
||||
{
|
||||
"remarks": "阻断广告",
|
||||
"outboundTag": "block",
|
||||
"remarks": "代理Google",
|
||||
"outboundTag": "proxy",
|
||||
"domain": [
|
||||
"geosite:category-ads-all"
|
||||
"geosite:google"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -100,4 +92,4 @@
|
||||
"geosite:cn"
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -5,13 +5,6 @@
|
||||
"port": "443",
|
||||
"network": "udp"
|
||||
},
|
||||
{
|
||||
"remarks": "Block ads and trackers",
|
||||
"outboundTag": "block",
|
||||
"domain": [
|
||||
"geosite:category-ads-all"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "Direct LAN IP",
|
||||
"outboundTag": "direct",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
[
|
||||
{
|
||||
"remarks": "Bypass bittorrent",
|
||||
"outboundTag": "direct",
|
||||
"protocol": [
|
||||
"bittorrent"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "Block udp443",
|
||||
"outboundTag": "block",
|
||||
"port": "443",
|
||||
"network": "udp"
|
||||
},
|
||||
{
|
||||
"remarks": "Direct LAN IP",
|
||||
"outboundTag": "direct",
|
||||
"ip": [
|
||||
"geoip:private"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "Direct LAN domains",
|
||||
"outboundTag": "direct",
|
||||
"domain": [
|
||||
"geosite:private"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "Bypass Russia domains",
|
||||
"outboundTag": "direct",
|
||||
"domain": [
|
||||
"geosite:category-ru"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "Bypass Russia IP",
|
||||
"outboundTag": "direct",
|
||||
"ip": [
|
||||
"geoip:ru"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -33,14 +33,6 @@
|
||||
"tls"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "http",
|
||||
"port": 10809,
|
||||
"protocol": "http",
|
||||
"settings": {
|
||||
"userLevel": 8
|
||||
}
|
||||
}
|
||||
],
|
||||
"outbounds": [{
|
||||
@@ -97,7 +89,7 @@
|
||||
}
|
||||
],
|
||||
"routing": {
|
||||
"domainStrategy": "IPIfNonMatch",
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": []
|
||||
},
|
||||
"dns": {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"stats":{},
|
||||
"log": {
|
||||
"loglevel": "warning"
|
||||
},
|
||||
"policy":{
|
||||
"levels": {
|
||||
"8": {
|
||||
"handshake": 4,
|
||||
"connIdle": 300,
|
||||
"uplinkOnly": 1,
|
||||
"downlinkOnly": 1
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"statsOutboundUplink": true,
|
||||
"statsOutboundDownlink": true
|
||||
}
|
||||
},
|
||||
"inbounds": [{
|
||||
"tag": "socks",
|
||||
"port": 10808,
|
||||
"protocol": "socks",
|
||||
"settings": {
|
||||
"auth": "noauth",
|
||||
"udp": true,
|
||||
"userLevel": 8
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "tun",
|
||||
"port": 0,
|
||||
"protocol": "tun",
|
||||
"settings": {
|
||||
"name": "xray0",
|
||||
"MTU": 1500,
|
||||
"userLevel": 8
|
||||
}
|
||||
}
|
||||
],
|
||||
"outbounds": [{
|
||||
"tag": "proxy",
|
||||
"protocol": "vmess",
|
||||
"settings": {
|
||||
"vnext": [
|
||||
{
|
||||
"address": "v2ray.cool",
|
||||
"port": 10086,
|
||||
"users": [
|
||||
{
|
||||
"id": "a3482e88-686a-4a58-8126-99c9df64b7bf",
|
||||
"alterId": 0,
|
||||
"security": "auto",
|
||||
"level": 8
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"servers": [
|
||||
{
|
||||
"address": "v2ray.cool",
|
||||
"method": "chacha20",
|
||||
"ota": false,
|
||||
"password": "123456",
|
||||
"port": 10086,
|
||||
"level": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp"
|
||||
},
|
||||
"mux": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"protocol": "freedom",
|
||||
"settings": {
|
||||
"domainStrategy": "UseIP"
|
||||
},
|
||||
"tag": "direct"
|
||||
},
|
||||
{
|
||||
"protocol": "blackhole",
|
||||
"tag": "block",
|
||||
"settings": {
|
||||
"response": {
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"routing": {
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": []
|
||||
},
|
||||
"dns": {
|
||||
"hosts": {},
|
||||
"servers": []
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,16 @@ class AngApplication : MultiDexApplication() {
|
||||
|
||||
MMKV.initialize(this)
|
||||
|
||||
// Ensure critical preference defaults are present in MMKV early
|
||||
SettingsManager.ensureDefaultSettings()
|
||||
SettingsManager.setNightMode()
|
||||
// Initialize WorkManager with the custom configuration
|
||||
WorkManager.initialize(this, workManagerConfiguration)
|
||||
|
||||
SettingsManager.initRoutingRulesets(this)
|
||||
|
||||
es.dmoral.toasty.Toasty.Config.getInstance()
|
||||
.setGravity(android.view.Gravity.BOTTOM, 0, 200)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ object AppConfig {
|
||||
|
||||
/** The application's package name. */
|
||||
const val ANG_PACKAGE = BuildConfig.APPLICATION_ID
|
||||
const val TAG = BuildConfig.APPLICATION_ID
|
||||
|
||||
/** Directory names used in the app's file system. */
|
||||
const val DIR_ASSETS = "assets"
|
||||
const val DIR_BACKUPS = "backups"
|
||||
|
||||
const val WEBDAV_BACKUP_DIR = "backups"
|
||||
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
|
||||
|
||||
/** Legacy configuration keys. */
|
||||
const val ANG_CONFIG = "ang_config"
|
||||
@@ -25,6 +28,8 @@ object AppConfig {
|
||||
const val PREF_LOCAL_DNS_PORT = "pref_local_dns_port"
|
||||
const val PREF_VPN_DNS = "pref_vpn_dns"
|
||||
const val PREF_VPN_BYPASS_LAN = "pref_vpn_bypass_lan"
|
||||
const val PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX = "pref_vpn_interface_address_config_index"
|
||||
const val PREF_VPN_MTU = "pref_vpn_mtu"
|
||||
const val PREF_ROUTING_DOMAIN_STRATEGY = "pref_routing_domain_strategy"
|
||||
const val PREF_ROUTING_RULESET = "pref_routing_ruleset"
|
||||
const val PREF_MUX_ENABLED = "pref_mux_enabled"
|
||||
@@ -42,6 +47,7 @@ object AppConfig {
|
||||
const val PREF_SPEED_ENABLED = "pref_speed_enabled"
|
||||
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
|
||||
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
|
||||
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
|
||||
const val PREF_LANGUAGE = "pref_language"
|
||||
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
|
||||
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
|
||||
@@ -52,21 +58,30 @@ object AppConfig {
|
||||
const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
|
||||
const val PREF_DNS_HOSTS = "pref_dns_hosts"
|
||||
const val PREF_DELAY_TEST_URL = "pref_delay_test_url"
|
||||
const val PREF_IP_API_URL = "pref_ip_api_url"
|
||||
const val PREF_LOGLEVEL = "pref_core_loglevel"
|
||||
const val PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD = "pref_outbound_domain_resolve_method"
|
||||
const val PREF_MODE = "pref_mode"
|
||||
const val PREF_IS_BOOTED = "pref_is_booted"
|
||||
const val PREF_CHECK_UPDATE_PRE_RELEASE = "pref_check_update_pre_release"
|
||||
const val PREF_GEO_FILES_SOURCES = "pref_geo_files_sources"
|
||||
const val PREF_USE_HEV_TUNNEL = "pref_use_hev_tunnel_v2"
|
||||
const val PREF_HEV_TUNNEL_LOGLEVEL = "pref_hev_tunnel_loglevel"
|
||||
const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout_v2"
|
||||
const val PREF_AUTO_REMOVE_INVALID_AFTER_TEST = "pref_auto_remove_invalid_after_test"
|
||||
const val PREF_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
|
||||
|
||||
/** Cache keys. */
|
||||
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
|
||||
const val CACHE_KEYWORD_FILTER = "cache_keyword_filter"
|
||||
|
||||
/** Protocol identifiers. */
|
||||
const val PROTOCOL_FREEDOM: String = "freedom"
|
||||
const val PROTOCOL_FREEDOM = "freedom"
|
||||
|
||||
/** Broadcast actions. */
|
||||
const val BROADCAST_ACTION_SERVICE = "com.v2ray.ang.action.service"
|
||||
const val BROADCAST_ACTION_ACTIVITY = "com.v2ray.ang.action.activity"
|
||||
const val BROADCAST_ACTION_WIDGET_CLICK = "com.v2ray.ang.action.widget.click"
|
||||
const val BROADCAST_ACTION_SERVICE = "$ANG_PACKAGE.action.service"
|
||||
const val BROADCAST_ACTION_ACTIVITY = "$ANG_PACKAGE.action.activity"
|
||||
const val BROADCAST_ACTION_WIDGET_CLICK = "$ANG_PACKAGE.action.widget.click"
|
||||
|
||||
/** Tasker extras. */
|
||||
const val TASKER_EXTRA_BUNDLE = "com.twofortyfouram.locale.intent.extra.BUNDLE"
|
||||
@@ -80,25 +95,29 @@ object AppConfig {
|
||||
const val TAG_DIRECT = "direct"
|
||||
const val TAG_BLOCKED = "block"
|
||||
const val TAG_FRAGMENT = "fragment"
|
||||
const val TAG_DNS = "dns-module"
|
||||
const val TAG_DOMESTIC_DNS = "domestic-dns"
|
||||
|
||||
/** Network-related constants. */
|
||||
const val UPLINK = "uplink"
|
||||
const val DOWNLINK = "downlink"
|
||||
|
||||
/** URLs for various resources. */
|
||||
const val androidpackagenamelistUrl =
|
||||
"https://raw.githubusercontent.com/2dust/androidpackagenamelist/master/proxy.txt"
|
||||
const val v2rayCustomRoutingListUrl =
|
||||
"https://raw.githubusercontent.com/2dust/v2rayCustomRoutingList/master/"
|
||||
const val v2rayNGUrl = "https://github.com/2dust/v2rayNG"
|
||||
const val v2rayNGIssues = "$v2rayNGUrl/issues"
|
||||
const val v2rayNGWikiMode = "$v2rayNGUrl/wiki/Mode"
|
||||
const val v2rayNGPrivacyPolicy = "https://raw.githubusercontent.com/2dust/v2rayNG/master/CR.md"
|
||||
const val PromotionUrl = "aHR0cHM6Ly85LjIzNDQ1Ni54eXovYWJjLmh0bWw="
|
||||
const val GeoUrl = "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/"
|
||||
const val TgChannelUrl = "https://t.me/github_2dust"
|
||||
const val DelayTestUrl = "https://www.gstatic.com/generate_204"
|
||||
const val DelayTestUrl2 = "https://www.google.com/generate_204"
|
||||
const val GITHUB_URL = "https://github.com"
|
||||
const val GITHUB_RAW_URL = "https://raw.githubusercontent.com"
|
||||
const val GITHUB_DOWNLOAD_URL = "$GITHUB_URL/%s/releases/latest/download"
|
||||
const val ANDROID_PACKAGE_NAME_LIST_URL = "$GITHUB_RAW_URL/2dust/androidpackagenamelist/master/proxy.txt"
|
||||
const val APP_URL = "$GITHUB_URL/2dust/v2rayNG"
|
||||
const val APP_API_URL = "https://api.github.com/repos/2dust/v2rayNG/releases"
|
||||
const val APP_ISSUES_URL = "$APP_URL/issues"
|
||||
const val APP_WIKI_MODE = "$APP_URL/wiki/Mode"
|
||||
const val APP_PRIVACY_POLICY = "$GITHUB_RAW_URL/2dust/v2rayNG/master/CR.md"
|
||||
const val APP_PROMOTION_URL = "aHR0cHM6Ly85LjIzNDQ1Ni54eXovYWJjLmh0bWw="
|
||||
const val TG_CHANNEL_URL = "https://t.me/github_2dust"
|
||||
const val DELAY_TEST_URL = "https://www.gstatic.com/generate_204"
|
||||
const val DELAY_TEST_URL2 = "https://www.google.com/generate_204"
|
||||
// const val IP_API_URL = "https://speed.cloudflare.com/meta"
|
||||
const val IP_API_URL = "https://api.ip.sb/geoip"
|
||||
|
||||
/** DNS server addresses. */
|
||||
const val DNS_PROXY = "1.1.1.1"
|
||||
@@ -133,6 +152,8 @@ object AppConfig {
|
||||
const val MSG_MEASURE_CONFIG = 7
|
||||
const val MSG_MEASURE_CONFIG_SUCCESS = 71
|
||||
const val MSG_MEASURE_CONFIG_CANCEL = 72
|
||||
const val MSG_MEASURE_CONFIG_NOTIFY = 73
|
||||
const val MSG_MEASURE_CONFIG_FINISH = 74
|
||||
|
||||
/** Notification channel IDs and names. */
|
||||
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
|
||||
@@ -150,11 +171,16 @@ object AppConfig {
|
||||
const val TROJAN = "trojan://"
|
||||
const val WIREGUARD = "wireguard://"
|
||||
const val TUIC = "tuic://"
|
||||
const val HYSTERIA = "hysteria://"
|
||||
const val HYSTERIA2 = "hysteria2://"
|
||||
const val HY2 = "hy2://"
|
||||
|
||||
/** Give a good name to this, IDK*/
|
||||
const val VPN = "VPN"
|
||||
const val VPN_MTU = 1500
|
||||
|
||||
/** hev-sock5-tunnel read-write-timeout value */
|
||||
const val HEVTUN_RW_TIMEOUT = "300,60"
|
||||
|
||||
// Google API rule constants
|
||||
const val GOOGLEAPIS_CN_DOMAIN = "domain:googleapis.cn"
|
||||
@@ -163,19 +189,13 @@ object AppConfig {
|
||||
// Android Private DNS constants
|
||||
const val DNS_DNSPOD_DOMAIN = "dot.pub"
|
||||
const val DNS_ALIDNS_DOMAIN = "dns.alidns.com"
|
||||
const val DNS_CLOUDFLARE_DOMAIN = "one.one.one.one"
|
||||
const val DNS_CLOUDFLARE_ONE_DOMAIN = "one.one.one.one"
|
||||
const val DNS_CLOUDFLARE_DNS_COM_DOMAIN = "dns.cloudflare.com"
|
||||
const val DNS_CLOUDFLARE_DNS_DOMAIN = "cloudflare-dns.com"
|
||||
const val DNS_GOOGLE_DOMAIN = "dns.google"
|
||||
const val DNS_QUAD9_DOMAIN = "dns.quad9.net"
|
||||
const val DNS_YANDEX_DOMAIN = "common.dot.dns.yandex.net"
|
||||
|
||||
|
||||
val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
|
||||
val DNS_CLOUDFLARE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
|
||||
val DNS_DNSPOD_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
|
||||
val DNS_GOOGLE_ADDRESSES = arrayListOf("8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844")
|
||||
val DNS_QUAD9_ADDRESSES = arrayListOf("9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9")
|
||||
val DNS_YANDEX_ADDRESSES = arrayListOf("77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff")
|
||||
|
||||
const val DEFAULT_PORT = 443
|
||||
const val DEFAULT_SECURITY = "auto"
|
||||
const val DEFAULT_LEVEL = 8
|
||||
@@ -184,4 +204,64 @@ object AppConfig {
|
||||
const val REALITY = "reality"
|
||||
const val HEADER_TYPE_HTTP = "http"
|
||||
|
||||
val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
|
||||
val DNS_CLOUDFLARE_ONE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
|
||||
val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5")
|
||||
val DNS_CLOUDFLARE_DNS_ADDRESSES = arrayListOf("104.16.248.249", "104.16.249.249", "2606:4700::6810:f8f9", "2606:4700::6810:f9f9")
|
||||
val DNS_DNSPOD_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
|
||||
val DNS_GOOGLE_ADDRESSES = arrayListOf("8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844")
|
||||
val DNS_QUAD9_ADDRESSES = arrayListOf("9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9")
|
||||
val DNS_YANDEX_ADDRESSES = arrayListOf("77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff")
|
||||
|
||||
//minimum list https://serverfault.com/a/304791
|
||||
val ROUTED_IP_LIST = arrayListOf(
|
||||
"0.0.0.0/5",
|
||||
"8.0.0.0/7",
|
||||
"11.0.0.0/8",
|
||||
"12.0.0.0/6",
|
||||
"16.0.0.0/4",
|
||||
"32.0.0.0/3",
|
||||
"64.0.0.0/2",
|
||||
"128.0.0.0/3",
|
||||
"160.0.0.0/5",
|
||||
"168.0.0.0/6",
|
||||
"172.0.0.0/12",
|
||||
"172.32.0.0/11",
|
||||
"172.64.0.0/10",
|
||||
"172.128.0.0/9",
|
||||
"173.0.0.0/8",
|
||||
"174.0.0.0/7",
|
||||
"176.0.0.0/4",
|
||||
"192.0.0.0/9",
|
||||
"192.128.0.0/11",
|
||||
"192.160.0.0/13",
|
||||
"192.169.0.0/16",
|
||||
"192.170.0.0/15",
|
||||
"192.172.0.0/14",
|
||||
"192.176.0.0/12",
|
||||
"192.192.0.0/10",
|
||||
"193.0.0.0/8",
|
||||
"194.0.0.0/7",
|
||||
"196.0.0.0/6",
|
||||
"200.0.0.0/5",
|
||||
"208.0.0.0/4",
|
||||
"240.0.0.0/4"
|
||||
)
|
||||
|
||||
val PRIVATE_IP_LIST = arrayListOf(
|
||||
"0.0.0.0/8",
|
||||
"10.0.0.0/8",
|
||||
"127.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"169.254.0.0/16",
|
||||
"224.0.0.0/4"
|
||||
)
|
||||
|
||||
val GEO_FILES_SOURCES = arrayListOf(
|
||||
"Loyalsoldier/v2ray-rules-dat",
|
||||
"runetfreedom/russia-v2ray-rules-dat",
|
||||
"Chocolate4U/Iran-v2ray-rules"
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ data class AssetUrlItem(
|
||||
var remarks: String = "",
|
||||
var url: String = "",
|
||||
val addedTime: Long = System.currentTimeMillis(),
|
||||
var lastUpdated: Long = -1
|
||||
var lastUpdated: Long = -1,
|
||||
var locked: Boolean? = false,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class CheckUpdateResult(
|
||||
val hasUpdate: Boolean,
|
||||
val latestVersion: String? = null,
|
||||
val releaseNotes: String? = null,
|
||||
val downloadUrl: String? = null,
|
||||
val error: String? = null,
|
||||
val isPreRelease: Boolean = false
|
||||
)
|
||||
@@ -4,6 +4,5 @@ data class ConfigResult(
|
||||
var status: Boolean,
|
||||
var guid: String? = null,
|
||||
var content: String = "",
|
||||
var domainPort: String? = null,
|
||||
)
|
||||
|
||||
|
||||
@@ -11,9 +11,12 @@ enum class EConfigType(val value: Int, val protocolScheme: String) {
|
||||
VLESS(5, AppConfig.VLESS),
|
||||
TROJAN(6, AppConfig.TROJAN),
|
||||
WIREGUARD(7, AppConfig.WIREGUARD),
|
||||
// TUIC(8, AppConfig.TUIC),
|
||||
|
||||
// TUIC(8, AppConfig.TUIC),
|
||||
HYSTERIA2(9, AppConfig.HYSTERIA2),
|
||||
HTTP(10, AppConfig.HTTP);
|
||||
HYSTERIA(900, AppConfig.HYSTERIA),
|
||||
HTTP(10, AppConfig.HTTP),
|
||||
POLICYGROUP (101, AppConfig.CUSTOM);
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class GitHubRelease(
|
||||
@SerializedName("tag_name")
|
||||
val tagName: String,
|
||||
@SerializedName("body")
|
||||
val body: String,
|
||||
@SerializedName("assets")
|
||||
val assets: List<Asset>,
|
||||
@SerializedName("prerelease")
|
||||
val prerelease: Boolean = false,
|
||||
@SerializedName("published_at")
|
||||
val publishedAt: String = ""
|
||||
) {
|
||||
data class Asset(
|
||||
@SerializedName("name")
|
||||
val name: String,
|
||||
@SerializedName("browser_download_url")
|
||||
val browserDownloadUrl: String
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class GroupMapItem(
|
||||
var id: String,
|
||||
var remarks: String,
|
||||
)
|
||||
@@ -1,46 +0,0 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class Hysteria2Bean(
|
||||
val server: String?,
|
||||
val auth: String?,
|
||||
val lazy: Boolean? = true,
|
||||
val obfs: ObfsBean? = null,
|
||||
val socks5: Socks5Bean? = null,
|
||||
val http: Socks5Bean? = null,
|
||||
val tls: TlsBean? = null,
|
||||
val transport: TransportBean? = null,
|
||||
val bandwidth: BandwidthBean? = null,
|
||||
) {
|
||||
data class ObfsBean(
|
||||
val type: String?,
|
||||
val salamander: SalamanderBean?
|
||||
) {
|
||||
data class SalamanderBean(
|
||||
val password: String?,
|
||||
)
|
||||
}
|
||||
|
||||
data class Socks5Bean(
|
||||
val listen: String?,
|
||||
)
|
||||
|
||||
data class TlsBean(
|
||||
val sni: String?,
|
||||
val insecure: Boolean?,
|
||||
val pinSHA256: String?,
|
||||
)
|
||||
|
||||
data class TransportBean(
|
||||
val type: String?,
|
||||
val udp: TransportUdpBean?
|
||||
) {
|
||||
data class TransportUdpBean(
|
||||
val hopInterval: String?,
|
||||
)
|
||||
}
|
||||
|
||||
data class BandwidthBean(
|
||||
val down: String?,
|
||||
val up: String?,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class IPAPIInfo(
|
||||
var ip: String? = null,
|
||||
var clientIp: String? = null,
|
||||
var ip_addr: String? = null,
|
||||
var query: String? = null,
|
||||
var country: String? = null,
|
||||
var country_name: String? = null,
|
||||
var country_code: String? = null,
|
||||
var countryCode: String? = null,
|
||||
var location: LocationBean? = null
|
||||
) {
|
||||
data class LocationBean(
|
||||
var country_code: String? = null
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ enum class Language(val code: String) {
|
||||
VIETNAMESE("vi"),
|
||||
RUSSIAN("ru"),
|
||||
PERSIAN("fa"),
|
||||
ARABIC("ar"),
|
||||
BANGLA("bn"),
|
||||
BAKHTIARI("bqi-rIR");
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ enum class NetworkType(val type: String) {
|
||||
XHTTP("xhttp"),
|
||||
HTTP("http"),
|
||||
H2("h2"),
|
||||
|
||||
//QUIC("quic"),
|
||||
GRPC("grpc");
|
||||
GRPC("grpc"),
|
||||
HYSTERIA("hysteria");
|
||||
|
||||
companion object {
|
||||
fun fromString(type: String?) = entries.find { it.type == type } ?: TCP
|
||||
|
||||
@@ -40,10 +40,13 @@ data class ProfileItem(
|
||||
var alpn: String? = null,
|
||||
var fingerPrint: String? = null,
|
||||
var insecure: Boolean? = null,
|
||||
var echConfigList: String? = null,
|
||||
var echForceQuery: String? = null,
|
||||
|
||||
var publicKey: String? = null,
|
||||
var shortId: String? = null,
|
||||
var spiderX: String? = null,
|
||||
var mldsa65Verify: String? = null,
|
||||
|
||||
var secretKey: String? = null,
|
||||
var preSharedKey: String? = null,
|
||||
@@ -58,6 +61,10 @@ data class ProfileItem(
|
||||
var bandwidthDown: String? = null,
|
||||
var bandwidthUp: String? = null,
|
||||
|
||||
var policyGroupType: String? = null,
|
||||
var policyGroupSubscriptionId: String? = null,
|
||||
var policyGroupFilter: String? = null,
|
||||
|
||||
) {
|
||||
companion object {
|
||||
fun create(configType: EConfigType): ProfileItem {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class ProfileLiteItem(
|
||||
val configType: EConfigType,
|
||||
var subscriptionId: String = "",
|
||||
var remarks: String = "",
|
||||
var server: String?,
|
||||
var serverPort: Int?,
|
||||
)
|
||||
@@ -4,7 +4,8 @@ enum class RoutingType(val fileName: String) {
|
||||
WHITE("custom_routing_white"),
|
||||
BLACK("custom_routing_black"),
|
||||
GLOBAL("custom_routing_global"),
|
||||
WHITE_IRAN("custom_routing_white_iran");
|
||||
WHITE_IRAN("custom_routing_white_iran"),
|
||||
WHITE_RUSSIA("custom_routing_white_russia");
|
||||
|
||||
companion object {
|
||||
fun fromIndex(index: Int): RoutingType {
|
||||
@@ -13,6 +14,7 @@ enum class RoutingType(val fileName: String) {
|
||||
1 -> BLACK
|
||||
2 -> GLOBAL
|
||||
3 -> WHITE_IRAN
|
||||
4 -> WHITE_RUSSIA
|
||||
else -> WHITE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import com.v2ray.ang.AppConfig.TAG_BLOCKED
|
||||
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
||||
import com.v2ray.ang.AppConfig.TAG_PROXY
|
||||
|
||||
data class ServerConfig(
|
||||
val configVersion: Int = 3,
|
||||
val configType: EConfigType,
|
||||
var subscriptionId: String = "",
|
||||
val addedTime: Long = System.currentTimeMillis(),
|
||||
var remarks: String = "",
|
||||
val outboundBean: V2rayConfig.OutboundBean? = null,
|
||||
var fullConfig: V2rayConfig? = null
|
||||
) {
|
||||
companion object {
|
||||
fun create(configType: EConfigType): ServerConfig {
|
||||
when (configType) {
|
||||
EConfigType.VMESS,
|
||||
EConfigType.VLESS ->
|
||||
return ServerConfig(
|
||||
configType = configType,
|
||||
outboundBean = V2rayConfig.OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = V2rayConfig.OutboundBean.OutSettingsBean(
|
||||
vnext = listOf(
|
||||
V2rayConfig.OutboundBean.OutSettingsBean.VnextBean(
|
||||
users = listOf(V2rayConfig.OutboundBean.OutSettingsBean.VnextBean.UsersBean())
|
||||
)
|
||||
)
|
||||
),
|
||||
streamSettings = V2rayConfig.OutboundBean.StreamSettingsBean()
|
||||
)
|
||||
)
|
||||
|
||||
EConfigType.CUSTOM ->
|
||||
return ServerConfig(configType = configType)
|
||||
|
||||
EConfigType.SHADOWSOCKS,
|
||||
EConfigType.SOCKS,
|
||||
EConfigType.HTTP,
|
||||
EConfigType.TROJAN,
|
||||
EConfigType.HYSTERIA2 ->
|
||||
return ServerConfig(
|
||||
configType = configType,
|
||||
outboundBean = V2rayConfig.OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = V2rayConfig.OutboundBean.OutSettingsBean(
|
||||
servers = listOf(V2rayConfig.OutboundBean.OutSettingsBean.ServersBean())
|
||||
),
|
||||
streamSettings = V2rayConfig.OutboundBean.StreamSettingsBean()
|
||||
)
|
||||
)
|
||||
|
||||
EConfigType.WIREGUARD ->
|
||||
return ServerConfig(
|
||||
configType = configType,
|
||||
outboundBean = V2rayConfig.OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = V2rayConfig.OutboundBean.OutSettingsBean(
|
||||
secretKey = "",
|
||||
peers = listOf(V2rayConfig.OutboundBean.OutSettingsBean.WireGuardBean())
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getProxyOutbound(): V2rayConfig.OutboundBean? {
|
||||
if (configType != EConfigType.CUSTOM) {
|
||||
return outboundBean
|
||||
}
|
||||
return fullConfig?.getProxyOutbound()
|
||||
}
|
||||
|
||||
fun getAllOutboundTags(): MutableList<String> {
|
||||
if (configType != EConfigType.CUSTOM) {
|
||||
return mutableListOf(TAG_PROXY, TAG_DIRECT, TAG_BLOCKED)
|
||||
}
|
||||
fullConfig?.let { config ->
|
||||
return config.outbounds.map { it.tag }.toMutableList()
|
||||
}
|
||||
return mutableListOf()
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,7 @@ data class SubscriptionItem(
|
||||
var prevProfile: String? = null,
|
||||
var nextProfile: String? = null,
|
||||
var filter: String? = null,
|
||||
var allowInsecureUrl: Boolean = false,
|
||||
var userAgent: String? = null,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,28 +1,16 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import android.text.TextUtils
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonPrimitive
|
||||
import com.google.gson.JsonSerializationContext
|
||||
import com.google.gson.JsonSerializer
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean.ServersBean
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean.VnextBean
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean.VnextBean.UsersBean
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean.WireGuardBean
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.lang.reflect.Type
|
||||
|
||||
data class V2rayConfig(
|
||||
var remarks: String? = null,
|
||||
var stats: Any? = null,
|
||||
val log: LogBean,
|
||||
var policy: PolicyBean?,
|
||||
var policy: PolicyBean? = null,
|
||||
val inbounds: ArrayList<InboundBean>,
|
||||
var outbounds: ArrayList<OutboundBean>,
|
||||
var dns: DnsBean,
|
||||
var dns: DnsBean? = null,
|
||||
val routing: RoutingBean,
|
||||
val api: Any? = null,
|
||||
val transport: Any? = null,
|
||||
@@ -34,9 +22,9 @@ data class V2rayConfig(
|
||||
) {
|
||||
|
||||
data class LogBean(
|
||||
val access: String,
|
||||
val error: String,
|
||||
var loglevel: String?,
|
||||
val access: String? = null,
|
||||
val error: String? = null,
|
||||
var loglevel: String? = null,
|
||||
val dnsLog: Boolean? = null
|
||||
)
|
||||
|
||||
@@ -45,19 +33,19 @@ data class V2rayConfig(
|
||||
var port: Int,
|
||||
var protocol: String,
|
||||
var listen: String? = null,
|
||||
val settings: Any? = null,
|
||||
val sniffing: SniffingBean?,
|
||||
var settings: InSettingsBean? = null,
|
||||
val sniffing: SniffingBean? = null,
|
||||
val streamSettings: Any? = null,
|
||||
val allocate: Any? = null
|
||||
) {
|
||||
|
||||
data class InSettingsBean(
|
||||
val auth: String? = null,
|
||||
val udp: Boolean? = null,
|
||||
val userLevel: Int? = null,
|
||||
val address: String? = null,
|
||||
val port: Int? = null,
|
||||
val network: String? = null
|
||||
var auth: String? = null,
|
||||
var udp: Boolean? = null,
|
||||
var userLevel: Int? = null,
|
||||
var name: String? = null,
|
||||
@SerializedName("MTU")
|
||||
var mtu: Int? = null
|
||||
)
|
||||
|
||||
data class SniffingBean(
|
||||
@@ -77,50 +65,6 @@ data class V2rayConfig(
|
||||
val sendThrough: String? = null,
|
||||
var mux: MuxBean? = MuxBean(false)
|
||||
) {
|
||||
companion object {
|
||||
fun create(configType: EConfigType): OutboundBean? {
|
||||
return when (configType) {
|
||||
EConfigType.VMESS,
|
||||
EConfigType.VLESS ->
|
||||
return OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = OutSettingsBean(
|
||||
vnext = listOf(
|
||||
VnextBean(
|
||||
users = listOf(UsersBean())
|
||||
)
|
||||
)
|
||||
),
|
||||
streamSettings = StreamSettingsBean()
|
||||
)
|
||||
|
||||
EConfigType.SHADOWSOCKS,
|
||||
EConfigType.SOCKS,
|
||||
EConfigType.HTTP,
|
||||
EConfigType.TROJAN,
|
||||
EConfigType.HYSTERIA2 ->
|
||||
return OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = OutSettingsBean(
|
||||
servers = listOf(ServersBean())
|
||||
),
|
||||
streamSettings = StreamSettingsBean()
|
||||
)
|
||||
|
||||
EConfigType.WIREGUARD ->
|
||||
return OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = OutSettingsBean(
|
||||
secretKey = "",
|
||||
peers = listOf(WireGuardBean())
|
||||
)
|
||||
)
|
||||
|
||||
EConfigType.CUSTOM -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class OutSettingsBean(
|
||||
var vnext: List<VnextBean>? = null,
|
||||
var fragment: FragmentBean? = null,
|
||||
@@ -131,7 +75,7 @@ data class V2rayConfig(
|
||||
/*DNS*/
|
||||
val network: String? = null,
|
||||
var address: Any? = null,
|
||||
val port: Int? = null,
|
||||
var port: Int? = null,
|
||||
/*Freedom*/
|
||||
var domainStrategy: String? = null,
|
||||
val redirect: String? = null,
|
||||
@@ -197,7 +141,7 @@ data class V2rayConfig(
|
||||
|
||||
data class WireGuardBean(
|
||||
var publicKey: String = "",
|
||||
var preSharedKey: String = "",
|
||||
var preSharedKey: String? = null,
|
||||
var endpoint: String = ""
|
||||
)
|
||||
}
|
||||
@@ -215,7 +159,8 @@ data class V2rayConfig(
|
||||
var quicSettings: QuicSettingBean? = null,
|
||||
var realitySettings: TlsSettingsBean? = null,
|
||||
var grpcSettings: GrpcSettingsBean? = null,
|
||||
var hy2steriaSettings: Hy2steriaSettingsBean? = null,
|
||||
var hysteriaSettings: HysteriaSettingsBean? = null,
|
||||
var udpmasks: List<UdpMasksBean>? = null,
|
||||
val dsSettings: Any? = null,
|
||||
var sockopt: SockoptBean? = null
|
||||
) {
|
||||
@@ -261,7 +206,8 @@ data class V2rayConfig(
|
||||
) {
|
||||
data class HeaderBean(
|
||||
var type: String = "none",
|
||||
var domain: String? = null)
|
||||
var domain: String? = null
|
||||
)
|
||||
}
|
||||
|
||||
data class WsSettingsBean(
|
||||
@@ -298,7 +244,16 @@ data class V2rayConfig(
|
||||
var tcpFastOpen: Boolean? = null,
|
||||
var tproxy: String? = null,
|
||||
var mark: Int? = null,
|
||||
var dialerProxy: String? = null
|
||||
var dialerProxy: String? = null,
|
||||
var domainStrategy: String? = null,
|
||||
var happyEyeballs: HappyEyeballsBean? = null,
|
||||
)
|
||||
|
||||
data class HappyEyeballsBean(
|
||||
var prioritizeIPv6: Boolean? = null,
|
||||
var maxConcurrentTry: Int? = 4,
|
||||
var tryDelayMs: Int? = 250, // ms
|
||||
var interleave: Int? = null,
|
||||
)
|
||||
|
||||
data class TlsSettingsBean(
|
||||
@@ -313,11 +268,14 @@ data class V2rayConfig(
|
||||
val certificates: List<Any>? = null,
|
||||
val disableSystemRoot: Boolean? = null,
|
||||
val enableSessionResumption: Boolean? = null,
|
||||
var echConfigList: String? = null,
|
||||
var echForceQuery: String? = null,
|
||||
// REALITY settings
|
||||
val show: Boolean = false,
|
||||
var publicKey: String? = null,
|
||||
var shortId: String? = null,
|
||||
var spiderX: String? = null
|
||||
var spiderX: String? = null,
|
||||
var mldsa65Verify: String? = null
|
||||
)
|
||||
|
||||
data class QuicSettingBean(
|
||||
@@ -336,150 +294,26 @@ data class V2rayConfig(
|
||||
var health_check_timeout: Int? = null
|
||||
)
|
||||
|
||||
data class Hy2steriaSettingsBean(
|
||||
var password: String? = null,
|
||||
var use_udp_extension: Boolean? = true,
|
||||
var congestion: Hy2CongestionBean? = null
|
||||
data class HysteriaSettingsBean(
|
||||
var version: Int,
|
||||
var auth: String? = null,
|
||||
var up: String? = null,
|
||||
var down: String? = null,
|
||||
var udphop: HysteriaUdpHopBean? = null
|
||||
) {
|
||||
data class Hy2CongestionBean(
|
||||
var type: String? = "bbr",
|
||||
var up_mbps: Int? = null,
|
||||
var down_mbps: Int? = null,
|
||||
data class HysteriaUdpHopBean(
|
||||
var port: String? = null,
|
||||
var interval: Int? = null
|
||||
)
|
||||
}
|
||||
|
||||
fun populateTransportSettings(
|
||||
transport: String,
|
||||
headerType: String?,
|
||||
host: String?,
|
||||
path: String?,
|
||||
seed: String?,
|
||||
quicSecurity: String?,
|
||||
key: String?,
|
||||
mode: String?,
|
||||
serviceName: String?,
|
||||
authority: String?
|
||||
): String? {
|
||||
var sni: String? = null
|
||||
network = if (transport.isEmpty()) NetworkType.TCP.type else transport
|
||||
when (network) {
|
||||
NetworkType.TCP.type -> {
|
||||
val tcpSetting = TcpSettingsBean()
|
||||
if (headerType == AppConfig.HEADER_TYPE_HTTP) {
|
||||
tcpSetting.header.type = AppConfig.HEADER_TYPE_HTTP
|
||||
if (!TextUtils.isEmpty(host) || !TextUtils.isEmpty(path)) {
|
||||
val requestObj = 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
|
||||
}
|
||||
tcpSettings = tcpSetting
|
||||
}
|
||||
|
||||
NetworkType.KCP.type -> {
|
||||
val kcpsetting = KcpSettingsBean()
|
||||
kcpsetting.header.type = headerType ?: "none"
|
||||
if (seed.isNullOrEmpty()) {
|
||||
kcpsetting.seed = null
|
||||
} else {
|
||||
kcpsetting.seed = seed
|
||||
}
|
||||
if (host.isNullOrEmpty()) {
|
||||
kcpsetting.header.domain = null
|
||||
} else {
|
||||
kcpsetting.header.domain = host
|
||||
}
|
||||
kcpSettings = kcpsetting
|
||||
}
|
||||
|
||||
NetworkType.WS.type -> {
|
||||
val wssetting = WsSettingsBean()
|
||||
wssetting.headers.Host = host.orEmpty()
|
||||
sni = host
|
||||
wssetting.path = path ?: "/"
|
||||
wsSettings = wssetting
|
||||
}
|
||||
|
||||
NetworkType.HTTP_UPGRADE.type -> {
|
||||
val httpupgradeSetting = HttpupgradeSettingsBean()
|
||||
httpupgradeSetting.host = host.orEmpty()
|
||||
sni = host
|
||||
httpupgradeSetting.path = path ?: "/"
|
||||
httpupgradeSettings = httpupgradeSetting
|
||||
}
|
||||
|
||||
NetworkType.XHTTP.type -> {
|
||||
val xhttpSetting = XhttpSettingsBean()
|
||||
xhttpSetting.host = host.orEmpty()
|
||||
sni = host
|
||||
xhttpSetting.path = path ?: "/"
|
||||
xhttpSettings = xhttpSetting
|
||||
}
|
||||
|
||||
NetworkType.H2.type, NetworkType.HTTP.type -> {
|
||||
network = NetworkType.H2.type
|
||||
val h2Setting = HttpSettingsBean()
|
||||
h2Setting.host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
sni = h2Setting.host.getOrNull(0)
|
||||
h2Setting.path = path ?: "/"
|
||||
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 = GrpcSettingsBean()
|
||||
grpcSetting.multiMode = mode == "multi"
|
||||
grpcSetting.serviceName = serviceName.orEmpty()
|
||||
grpcSetting.authority = authority.orEmpty()
|
||||
grpcSetting.idle_timeout = 60
|
||||
grpcSetting.health_check_timeout = 20
|
||||
sni = authority
|
||||
grpcSettings = grpcSetting
|
||||
}
|
||||
}
|
||||
return sni
|
||||
}
|
||||
|
||||
fun populateTlsSettings(
|
||||
streamSecurity: String,
|
||||
allowInsecure: Boolean,
|
||||
sni: String?,
|
||||
fingerprint: String?,
|
||||
alpns: String?,
|
||||
publicKey: String?,
|
||||
shortId: String?,
|
||||
spiderX: String?
|
||||
data class UdpMasksBean(
|
||||
var type: String,
|
||||
var settings: UdpMasksSettingsBean? = null
|
||||
) {
|
||||
security = if (streamSecurity.isEmpty()) null else streamSecurity
|
||||
if (security == null) return
|
||||
val tlsSetting = TlsSettingsBean(
|
||||
allowInsecure = allowInsecure,
|
||||
serverName = if (sni.isNullOrEmpty()) null else sni,
|
||||
fingerprint = if (fingerprint.isNullOrEmpty()) null else fingerprint,
|
||||
alpn = if (alpns.isNullOrEmpty()) null else alpns.split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
||||
publicKey = if (publicKey.isNullOrEmpty()) null else publicKey,
|
||||
shortId = if (shortId.isNullOrEmpty()) null else shortId,
|
||||
spiderX = if (spiderX.isNullOrEmpty()) null else spiderX,
|
||||
data class UdpMasksSettingsBean(
|
||||
var password: String? = null
|
||||
)
|
||||
if (security == AppConfig.TLS) {
|
||||
tlsSettings = tlsSetting
|
||||
realitySettings = null
|
||||
} else if (security == AppConfig.REALITY) {
|
||||
tlsSettings = null
|
||||
realitySettings = tlsSetting
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,11 +333,14 @@ data class V2rayConfig(
|
||||
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||
) {
|
||||
return settings?.servers?.first()?.address
|
||||
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
||||
return settings?.peers?.first()?.endpoint?.substringBeforeLast(":")
|
||||
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA.name, true)
|
||||
) {
|
||||
return settings?.address as String?
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -517,134 +354,28 @@ data class V2rayConfig(
|
||||
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||
) {
|
||||
return settings?.servers?.first()?.port
|
||||
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
||||
return settings?.peers?.first()?.endpoint?.substringAfterLast(":")?.toInt()
|
||||
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA.name, true)
|
||||
) {
|
||||
return settings?.port
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getServerAddressAndPort(): String {
|
||||
val address = getServerAddress().orEmpty()
|
||||
val port = getServerPort()
|
||||
return Utils.getIpv6Address(address) + ":" + port
|
||||
}
|
||||
|
||||
fun getPassword(): String? {
|
||||
if (protocol.equals(EConfigType.VMESS.name, true)
|
||||
|| protocol.equals(EConfigType.VLESS.name, true)
|
||||
) {
|
||||
return settings?.vnext?.first()?.users?.first()?.id
|
||||
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||
) {
|
||||
return settings?.servers?.first()?.password
|
||||
} else if (protocol.equals(EConfigType.SOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||
) {
|
||||
return settings?.servers?.first()?.users?.first()?.pass
|
||||
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
||||
return settings?.secretKey
|
||||
fun ensureSockopt(): StreamSettingsBean.SockoptBean {
|
||||
val stream = streamSettings ?: StreamSettingsBean().also {
|
||||
streamSettings = it
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getSecurityEncryption(): String? {
|
||||
return when {
|
||||
protocol.equals(EConfigType.VMESS.name, true) -> settings?.vnext?.first()?.users?.first()?.security
|
||||
protocol.equals(EConfigType.VLESS.name, true) -> settings?.vnext?.first()?.users?.first()?.encryption
|
||||
protocol.equals(EConfigType.SHADOWSOCKS.name, true) -> settings?.servers?.first()?.method
|
||||
else -> null
|
||||
val sockopt = stream.sockopt ?: StreamSettingsBean.SockoptBean().also {
|
||||
stream.sockopt = it
|
||||
}
|
||||
}
|
||||
|
||||
fun getTransportSettingDetails(): List<String?>? {
|
||||
if (protocol.equals(EConfigType.VMESS.name, true)
|
||||
|| protocol.equals(EConfigType.VLESS.name, true)
|
||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||
|| protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||
) {
|
||||
val transport = streamSettings?.network ?: return null
|
||||
return when (transport) {
|
||||
NetworkType.TCP.type -> {
|
||||
val tcpSetting = streamSettings?.tcpSettings ?: return null
|
||||
listOf(
|
||||
tcpSetting.header.type,
|
||||
tcpSetting.header.request?.headers?.Host?.joinToString(",").orEmpty(),
|
||||
tcpSetting.header.request?.path?.joinToString(",").orEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
NetworkType.KCP.type -> {
|
||||
val kcpSetting = streamSettings?.kcpSettings ?: return null
|
||||
listOf(
|
||||
kcpSetting.header.type,
|
||||
"",
|
||||
kcpSetting.seed.orEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
NetworkType.WS.type -> {
|
||||
val wsSetting = streamSettings?.wsSettings ?: return null
|
||||
listOf(
|
||||
"",
|
||||
wsSetting.headers.Host,
|
||||
wsSetting.path
|
||||
)
|
||||
}
|
||||
|
||||
NetworkType.HTTP_UPGRADE.type -> {
|
||||
val httpupgradeSetting = streamSettings?.httpupgradeSettings ?: return null
|
||||
listOf(
|
||||
"",
|
||||
httpupgradeSetting.host,
|
||||
httpupgradeSetting.path
|
||||
)
|
||||
}
|
||||
|
||||
NetworkType.XHTTP.type -> {
|
||||
val xhttpSettings = streamSettings?.xhttpSettings ?: return null
|
||||
listOf(
|
||||
"",
|
||||
xhttpSettings.host,
|
||||
xhttpSettings.path
|
||||
)
|
||||
}
|
||||
|
||||
NetworkType.H2.type -> {
|
||||
val h2Setting = streamSettings?.httpSettings ?: return null
|
||||
listOf(
|
||||
"",
|
||||
h2Setting.host.joinToString(","),
|
||||
h2Setting.path
|
||||
)
|
||||
}
|
||||
|
||||
// "quic" -> {
|
||||
// val quicSetting = streamSettings?.quicSettings ?: return null
|
||||
// listOf(
|
||||
// quicSetting.header.type,
|
||||
// quicSetting.security,
|
||||
// quicSetting.key
|
||||
// )
|
||||
// }
|
||||
|
||||
NetworkType.GRPC.type -> {
|
||||
val grpcSetting = streamSettings?.grpcSettings ?: return null
|
||||
listOf(
|
||||
if (grpcSetting.multiMode == true) "multi" else "gun",
|
||||
grpcSetting.authority.orEmpty(),
|
||||
grpcSetting.serviceName
|
||||
)
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
return null
|
||||
return sockopt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,6 +394,7 @@ data class V2rayConfig(
|
||||
var expectIPs: List<String>? = null,
|
||||
val clientIp: String? = null,
|
||||
val skipFallback: Boolean? = null,
|
||||
val tag: String? = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -670,14 +402,14 @@ data class V2rayConfig(
|
||||
var domainStrategy: String,
|
||||
var domainMatcher: String? = null,
|
||||
var rules: ArrayList<RulesBean>,
|
||||
val balancers: List<Any>? = null
|
||||
var balancers: List<BalancerBean>? = null
|
||||
) {
|
||||
|
||||
data class RulesBean(
|
||||
var type: String = "field",
|
||||
var ip: ArrayList<String>? = null,
|
||||
var domain: ArrayList<String>? = null,
|
||||
var outboundTag: String = "",
|
||||
var outboundTag: String? = null,
|
||||
var balancerTag: String? = null,
|
||||
var port: String? = null,
|
||||
val sourcePort: String? = null,
|
||||
@@ -689,6 +421,32 @@ data class V2rayConfig(
|
||||
val attrs: String? = null,
|
||||
val domainMatcher: String? = null
|
||||
)
|
||||
|
||||
data class BalancerBean(
|
||||
val tag: String,
|
||||
val selector: List<String>,
|
||||
val fallbackTag: String? = null,
|
||||
val strategy: StrategyObject? = null
|
||||
)
|
||||
|
||||
data class StrategyObject(
|
||||
val type: String = "random", // "random" | "roundRobin" | "leastPing" | "leastLoad"
|
||||
val settings: StrategySettingsObject? = null
|
||||
)
|
||||
|
||||
data class StrategySettingsObject(
|
||||
val expected: Int? = null,
|
||||
val maxRTT: String? = null,
|
||||
val tolerance: Double? = null,
|
||||
val baselines: List<String>? = null,
|
||||
val costs: List<CostObject>? = null
|
||||
)
|
||||
|
||||
data class CostObject(
|
||||
val regexp: Boolean = false,
|
||||
val match: String,
|
||||
val value: Double
|
||||
)
|
||||
}
|
||||
|
||||
data class PolicyBean(
|
||||
@@ -706,6 +464,26 @@ data class V2rayConfig(
|
||||
)
|
||||
}
|
||||
|
||||
data class ObservatoryObject(
|
||||
val subjectSelector: List<String>,
|
||||
val probeUrl: String,
|
||||
val probeInterval: String,
|
||||
val enableConcurrency: Boolean = false
|
||||
)
|
||||
|
||||
data class BurstObservatoryObject(
|
||||
val subjectSelector: List<String>,
|
||||
val pingConfig: PingConfigObject
|
||||
) {
|
||||
data class PingConfigObject(
|
||||
val destination: String,
|
||||
val connectivity: String? = null,
|
||||
val interval: String,
|
||||
val sampling: Int,
|
||||
val timeout: String? = null
|
||||
)
|
||||
}
|
||||
|
||||
data class FakednsBean(
|
||||
var ipPool: String = "198.18.0.0/15",
|
||||
var poolSize: Int = 10000
|
||||
@@ -722,15 +500,9 @@ data class V2rayConfig(
|
||||
return null
|
||||
}
|
||||
|
||||
fun toPrettyPrinting(): String {
|
||||
return GsonBuilder()
|
||||
.setPrettyPrinting()
|
||||
.disableHtmlEscaping()
|
||||
.registerTypeAdapter( // custom serialiser is needed here since JSON by default parse number as Double, core will fail to start
|
||||
object : TypeToken<Double>() {}.type,
|
||||
JsonSerializer { src: Double?, _: Type?, _: JsonSerializationContext? -> JsonPrimitive(src?.toInt()) }
|
||||
)
|
||||
.create()
|
||||
.toJson(this)
|
||||
fun getAllProxyOutbound(): List<OutboundBean> {
|
||||
return outbounds.filter { outbound ->
|
||||
EConfigType.entries.any { it.name.equals(outbound.protocol, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@ data class VmessQRCode(
|
||||
var tls: String = "",
|
||||
var sni: String = "",
|
||||
var alpn: String = "",
|
||||
var fp: String = ""
|
||||
var fp: String = "",
|
||||
var insecure: String = ""
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
/**
|
||||
* VPN interface address configuration enum class
|
||||
* Defines predefined IPv4 and IPv6 address pairs for VPN TUN interface configuration.
|
||||
* Each option provides client and router addresses to establish point-to-point VPN tunnels.
|
||||
*/
|
||||
enum class VpnInterfaceAddressConfig(
|
||||
val displayName: String,
|
||||
val ipv4Client: String,
|
||||
val ipv4Router: String,
|
||||
val ipv6Client: String,
|
||||
val ipv6Router: String
|
||||
) {
|
||||
OPTION_1("10.10.14.x", "10.10.14.1", "10.10.14.2", "fc00::10:10:14:1", "fc00::10:10:14:2"),
|
||||
OPTION_2("10.1.0.x", "10.1.0.1", "10.1.0.2", "fc00::10:1:0:1", "fc00::10:1:0:2"),
|
||||
OPTION_3("10.0.0.x", "10.0.0.1", "10.0.0.2", "fc00::10:0:0:1", "fc00::10:0:0:2"),
|
||||
OPTION_4("172.31.0.x", "172.31.0.1", "172.31.0.2", "fc00::172:31:0:1", "fc00::172:31:0:2"),
|
||||
OPTION_5("172.20.0.x", "172.20.0.1", "172.20.0.2", "fc00::172:20:0:1", "fc00::172:20:0:2"),
|
||||
OPTION_6("172.16.0.x", "172.16.0.1", "172.16.0.2", "fc00::172:16:0:1", "fc00::172:16:0:2"),
|
||||
OPTION_7("192.168.100.x", "192.168.100.1", "192.168.100.2", "fc00::192:168:100:1", "fc00::192:168:100:2");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Retrieves the VPN interface address configuration based on the specified index.
|
||||
*
|
||||
* @param index The configuration index (0-based) corresponding to user selection
|
||||
* @return The VpnInterfaceAddressConfig instance at the specified index,
|
||||
* or OPTION_1 (default) if the index is out of bounds
|
||||
*/
|
||||
fun getConfigByIndex(index: Int): VpnInterfaceAddressConfig {
|
||||
return if (index in entries.toTypedArray().indices) {
|
||||
entries[index]
|
||||
} else {
|
||||
OPTION_1 // Default to the first configuration
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class WebDavConfig(
|
||||
val baseUrl: String,
|
||||
val username: String? = null,
|
||||
val password: String? = null,
|
||||
val remoteBasePath: String = "/",
|
||||
val timeoutSeconds: Long = 30
|
||||
)
|
||||
@@ -8,11 +8,9 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import com.v2ray.ang.AngApplication
|
||||
import me.drakeet.support.toast.ToastCompat
|
||||
import org.json.JSONObject
|
||||
import es.dmoral.toasty.Toasty
|
||||
import java.io.Serializable
|
||||
import java.net.URI
|
||||
import java.net.URLConnection
|
||||
|
||||
val Context.v2RayApplication: AngApplication?
|
||||
get() = applicationContext as? AngApplication
|
||||
@@ -23,7 +21,7 @@ val Context.v2RayApplication: AngApplication?
|
||||
* @param message The resource ID of the message to show.
|
||||
*/
|
||||
fun Context.toast(message: Int) {
|
||||
ToastCompat.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
Toasty.normal(this, message).show()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,25 +30,43 @@ fun Context.toast(message: Int) {
|
||||
* @param message The text of the message to show.
|
||||
*/
|
||||
fun Context.toast(message: CharSequence) {
|
||||
ToastCompat.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
Toasty.normal(this, message).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a key-value pair into the JSONObject.
|
||||
* Shows a toast message with the given resource ID.
|
||||
*
|
||||
* @param pair The key-value pair to put.
|
||||
* @param message The resource ID of the message to show.
|
||||
*/
|
||||
fun JSONObject.putOpt(pair: Pair<String, Any?>) {
|
||||
put(pair.first, pair.second)
|
||||
fun Context.toastSuccess(message: Int) {
|
||||
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts multiple key-value pairs into the JSONObject.
|
||||
* Shows a toast message with the given text.
|
||||
*
|
||||
* @param pairs The map of key-value pairs to put.
|
||||
* @param message The text of the message to show.
|
||||
*/
|
||||
fun JSONObject.putOpt(pairs: Map<String, Any?>) {
|
||||
pairs.forEach { put(it.key, it.value) }
|
||||
fun Context.toastSuccess(message: CharSequence) {
|
||||
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given resource ID.
|
||||
*
|
||||
* @param message The resource ID of the message to show.
|
||||
*/
|
||||
fun Context.toastError(message: Int) {
|
||||
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given text.
|
||||
*
|
||||
* @param message The text of the message to show.
|
||||
*/
|
||||
fun Context.toastError(message: CharSequence) {
|
||||
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
|
||||
}
|
||||
|
||||
const val THRESHOLD = 1000L
|
||||
@@ -79,13 +95,6 @@ fun Long.toTrafficString(): String {
|
||||
return String.format("%.1f %s", size, units[unitIndex])
|
||||
}
|
||||
|
||||
val URLConnection.responseLength: Long
|
||||
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
contentLengthLong
|
||||
} else {
|
||||
contentLength.toLong()
|
||||
}
|
||||
|
||||
val URI.idnHost: String
|
||||
get() = host?.replace("[", "")?.replace("]", "").orEmpty()
|
||||
|
||||
@@ -159,4 +168,17 @@ inline fun <reified T : Serializable> Intent.serializable(key: String): T? = whe
|
||||
*
|
||||
* @return True if the CharSequence is not null and not empty, false otherwise.
|
||||
*/
|
||||
fun CharSequence?.isNotNullEmpty(): Boolean = (this != null && this.isNotEmpty())
|
||||
fun CharSequence?.isNotNullEmpty(): Boolean = !this.isNullOrBlank()
|
||||
|
||||
fun String.concatUrl(vararg paths: String): String {
|
||||
val builder = StringBuilder(this.trimEnd('/'))
|
||||
|
||||
paths.forEach { path ->
|
||||
val trimmedPath = path.trim('/')
|
||||
if (trimmedPath.isNotEmpty()) {
|
||||
builder.append('/').append(trimmedPath)
|
||||
}
|
||||
}
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
@@ -16,7 +16,7 @@ object CustomFmt : FmtBase() {
|
||||
val config = ProfileItem.create(EConfigType.CUSTOM)
|
||||
|
||||
val fullConfig = JsonUtil.fromJson(str, V2rayConfig::class.java)
|
||||
val outbound = fullConfig.getProxyOutbound()
|
||||
val outbound = fullConfig?.getProxyOutbound()
|
||||
|
||||
config.remarks = fullConfig?.remarks ?: System.currentTimeMillis().toString()
|
||||
config.server = outbound?.getServerAddress()
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.NetworkType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -18,15 +20,15 @@ open class FmtBase {
|
||||
*/
|
||||
fun toUri(config: ProfileItem, userInfo: String?, dicQuery: HashMap<String, String>?): String {
|
||||
val query = if (dicQuery != null)
|
||||
("?" + dicQuery.toList().joinToString(
|
||||
"?" + dicQuery.toList().joinToString(
|
||||
separator = "&",
|
||||
transform = { it.first + "=" + Utils.urlEncode(it.second) }))
|
||||
transform = { it.first + "=" + Utils.urlEncode(it.second) })
|
||||
else ""
|
||||
|
||||
val url = String.format(
|
||||
"%s@%s:%s",
|
||||
Utils.urlEncode(userInfo ?: ""),
|
||||
Utils.getIpv6Address(config.server),
|
||||
Utils.getIpv6Address(HttpUtil.toIdnDomain(config.server.orEmpty())),
|
||||
config.serverPort
|
||||
)
|
||||
|
||||
@@ -70,17 +72,21 @@ open class FmtBase {
|
||||
if (config.security != AppConfig.TLS && config.security != AppConfig.REALITY) {
|
||||
config.security = null
|
||||
}
|
||||
config.insecure = if (queryParam["allowInsecure"].isNullOrEmpty()) {
|
||||
allowInsecure
|
||||
} else {
|
||||
queryParam["allowInsecure"].orEmpty() == "1"
|
||||
// Support multiple possible query keys for allowInsecure like the C# implementation
|
||||
val allowInsecureKeys = arrayOf("insecure", "allowInsecure", "allow_insecure")
|
||||
config.insecure = when {
|
||||
allowInsecureKeys.any { queryParam[it] == "1" } -> true
|
||||
allowInsecureKeys.any { queryParam[it] == "0" } -> false
|
||||
else -> allowInsecure
|
||||
}
|
||||
config.sni = queryParam["sni"]
|
||||
config.fingerPrint = queryParam["fp"]
|
||||
config.alpn = queryParam["alpn"]
|
||||
config.echConfigList = queryParam["ech"]
|
||||
config.publicKey = queryParam["pbk"]
|
||||
config.shortId = queryParam["sid"]
|
||||
config.spiderX = queryParam["spx"]
|
||||
config.mldsa65Verify = queryParam["pqv"]
|
||||
config.flow = queryParam["flow"]
|
||||
}
|
||||
|
||||
@@ -95,11 +101,19 @@ open class FmtBase {
|
||||
dicQuery["security"] = config.security?.ifEmpty { "none" }.orEmpty()
|
||||
config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() }
|
||||
config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() }
|
||||
config.echConfigList.let { if (it.isNotNullEmpty()) dicQuery["ech"] = it.orEmpty() }
|
||||
config.fingerPrint.let { if (it.isNotNullEmpty()) dicQuery["fp"] = it.orEmpty() }
|
||||
config.publicKey.let { if (it.isNotNullEmpty()) dicQuery["pbk"] = it.orEmpty() }
|
||||
config.shortId.let { if (it.isNotNullEmpty()) dicQuery["sid"] = it.orEmpty() }
|
||||
config.spiderX.let { if (it.isNotNullEmpty()) dicQuery["spx"] = it.orEmpty() }
|
||||
config.mldsa65Verify.let { if (it.isNotNullEmpty()) dicQuery["pqv"] = it.orEmpty() }
|
||||
config.flow.let { if (it.isNotNullEmpty()) dicQuery["flow"] = it.orEmpty() }
|
||||
// Add two keys for compatibility: "insecure" and "allowInsecure"
|
||||
if (config.security == AppConfig.TLS) {
|
||||
val insecureFlag = if (config.insecure == true) "1" else "0"
|
||||
dicQuery["insecure"] = insecureFlag
|
||||
dicQuery["allowInsecure"] = insecureFlag
|
||||
}
|
||||
|
||||
val networkType = NetworkType.fromString(config.network)
|
||||
dicQuery["type"] = networkType.type
|
||||
@@ -120,7 +134,7 @@ open class FmtBase {
|
||||
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
|
||||
}
|
||||
|
||||
NetworkType.XHTTP -> {
|
||||
NetworkType.XHTTP -> {
|
||||
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
|
||||
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
|
||||
config.xhttpMode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() }
|
||||
@@ -144,8 +158,27 @@ open class FmtBase {
|
||||
config.authority.let { if (it.isNotNullEmpty()) dicQuery["authority"] = it.orEmpty() }
|
||||
config.serviceName.let { if (it.isNotNullEmpty()) dicQuery["serviceName"] = it.orEmpty() }
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.v2ray.ang.dto.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() {
|
||||
/**
|
||||
@@ -13,10 +14,10 @@ object HttpFmt : FmtBase() {
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = OutboundBean.create(EConfigType.HTTP)
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HTTP)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = profileItem.server.orEmpty()
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
if (profileItem.username.isNotNullEmpty()) {
|
||||
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.LOOPBACK
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.Hysteria2Bean
|
||||
import com.v2ray.ang.dto.NetworkType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean.UdpMasksBean.UdpMasksSettingsBean
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -24,26 +26,24 @@ object Hysteria2Fmt : FmtBase() {
|
||||
val config = ProfileItem.create(EConfigType.HYSTERIA2)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
config.password = uri.userInfo
|
||||
config.security = AppConfig.TLS
|
||||
config.network = NetworkType.HYSTERIA.type
|
||||
|
||||
if (!uri.rawQuery.isNullOrEmpty()) {
|
||||
val queryParam = getQueryParam(uri)
|
||||
|
||||
config.security = queryParam["security"] ?: AppConfig.TLS
|
||||
config.insecure = if (queryParam["insecure"].isNullOrEmpty()) {
|
||||
allowInsecure
|
||||
} else {
|
||||
queryParam["insecure"].orEmpty() == "1"
|
||||
}
|
||||
config.sni = queryParam["sni"]
|
||||
config.alpn = queryParam["alpn"]
|
||||
getItemFormQuery(config, queryParam, allowInsecure)
|
||||
|
||||
config.security = queryParam["security"] ?: AppConfig.TLS
|
||||
config.obfsPassword = queryParam["obfs-password"]
|
||||
config.portHopping = queryParam["mport"]
|
||||
if (config.portHopping.isNotNullEmpty()) {
|
||||
config.portHoppingInterval = queryParam["mportHopInt"]
|
||||
}
|
||||
config.pinSHA256 = queryParam["pinSHA256"]
|
||||
|
||||
}
|
||||
@@ -72,6 +72,9 @@ object Hysteria2Fmt : FmtBase() {
|
||||
if (config.portHopping.isNotNullEmpty()) {
|
||||
dicQuery["mport"] = config.portHopping.orEmpty()
|
||||
}
|
||||
if (config.portHoppingInterval.isNotNullEmpty()) {
|
||||
dicQuery["mportHopInt"] = config.portHoppingInterval.orEmpty()
|
||||
}
|
||||
if (config.pinSHA256.isNotNullEmpty()) {
|
||||
dicQuery["pinSHA256"] = config.pinSHA256.orEmpty()
|
||||
}
|
||||
@@ -79,64 +82,6 @@ object Hysteria2Fmt : FmtBase() {
|
||||
return toUri(config, config.password, dicQuery)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to a Hysteria2Bean object.
|
||||
*
|
||||
* @param config the ProfileItem object to convert
|
||||
* @param socksPort the port number for the socks5 proxy
|
||||
* @return the converted Hysteria2Bean object, or null if conversion fails
|
||||
*/
|
||||
fun toNativeConfig(config: ProfileItem, socksPort: Int): Hysteria2Bean? {
|
||||
|
||||
val obfs = if (config.obfsPassword.isNullOrEmpty()) null else
|
||||
Hysteria2Bean.ObfsBean(
|
||||
type = "salamander",
|
||||
salamander = Hysteria2Bean.ObfsBean.SalamanderBean(
|
||||
password = config.obfsPassword
|
||||
)
|
||||
)
|
||||
|
||||
val transport = if (config.portHopping.isNullOrEmpty()) null else
|
||||
Hysteria2Bean.TransportBean(
|
||||
type = "udp",
|
||||
udp = Hysteria2Bean.TransportBean.TransportUdpBean(
|
||||
hopInterval = (config.portHoppingInterval ?: "30") + "s"
|
||||
)
|
||||
)
|
||||
|
||||
val bandwidth = if (config.bandwidthDown.isNullOrEmpty() || config.bandwidthUp.isNullOrEmpty()) null else
|
||||
Hysteria2Bean.BandwidthBean(
|
||||
down = config.bandwidthDown,
|
||||
up = config.bandwidthUp,
|
||||
)
|
||||
|
||||
val server =
|
||||
if (config.portHopping.isNullOrEmpty())
|
||||
config.getServerAddressAndPort()
|
||||
else
|
||||
Utils.getIpv6Address(config.server) + ":" + config.portHopping
|
||||
|
||||
val bean = Hysteria2Bean(
|
||||
server = server,
|
||||
auth = config.password,
|
||||
obfs = obfs,
|
||||
transport = transport,
|
||||
bandwidth = bandwidth,
|
||||
socks5 = Hysteria2Bean.Socks5Bean(
|
||||
listen = "$LOOPBACK:${socksPort}",
|
||||
),
|
||||
http = Hysteria2Bean.Socks5Bean(
|
||||
listen = "$LOOPBACK:${socksPort}",
|
||||
),
|
||||
tls = Hysteria2Bean.TlsBean(
|
||||
sni = config.sni ?: config.server,
|
||||
insecure = config.insecure,
|
||||
pinSHA256 = if (config.pinSHA256.isNullOrEmpty()) null else config.pinSHA256
|
||||
)
|
||||
)
|
||||
return bean
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
@@ -144,7 +89,33 @@ object Hysteria2Fmt : FmtBase() {
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = OutboundBean.create(EConfigType.HYSTERIA2)
|
||||
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()
|
||||
}
|
||||
|
||||
val sni = outboundBean.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
if (profileItem.obfsPassword.isNotNullEmpty()) {
|
||||
outboundBean.streamSettings?.udpmasks = mutableListOf(
|
||||
StreamSettingsBean.UdpMasksBean(
|
||||
type = "salamander",
|
||||
settings = UdpMasksSettingsBean(
|
||||
password = profileItem.obfsPassword
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
return outboundBean
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.NetworkType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -33,7 +36,7 @@ object ShadowsocksFmt : FmtBase() {
|
||||
if (uri.port <= 0) return null
|
||||
if (uri.userInfo.isNullOrEmpty()) return null
|
||||
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
|
||||
@@ -82,7 +85,7 @@ object ShadowsocksFmt : FmtBase() {
|
||||
config.remarks =
|
||||
Utils.urlDecode(result.substring(indexSplit + 1, result.length))
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
|
||||
}
|
||||
|
||||
result = result.substring(0, indexSplit)
|
||||
@@ -119,7 +122,7 @@ object ShadowsocksFmt : FmtBase() {
|
||||
fun toUri(config: ProfileItem): String {
|
||||
val pw = "${config.method}:${config.password}"
|
||||
|
||||
return toUri(config, Utils.encode(pw), null)
|
||||
return toUri(config, Utils.encode(pw, true), null)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,38 +132,22 @@ object ShadowsocksFmt : FmtBase() {
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = OutboundBean.create(EConfigType.SHADOWSOCKS)
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SHADOWSOCKS)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = profileItem.server.orEmpty()
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.password = profileItem.password
|
||||
server.method = profileItem.method
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.populateTransportSettings(
|
||||
profileItem.network.orEmpty(),
|
||||
profileItem.headerType,
|
||||
profileItem.host,
|
||||
profileItem.path,
|
||||
profileItem.seed,
|
||||
profileItem.quicSecurity,
|
||||
profileItem.quicKey,
|
||||
profileItem.mode,
|
||||
profileItem.serviceName,
|
||||
profileItem.authority,
|
||||
)
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.populateTlsSettings(
|
||||
profileItem.security.orEmpty(),
|
||||
profileItem.insecure == true,
|
||||
if (profileItem.sni.isNullOrEmpty()) sni else profileItem.sni,
|
||||
profileItem.fingerPrint,
|
||||
profileItem.alpn,
|
||||
profileItem.publicKey,
|
||||
profileItem.shortId,
|
||||
profileItem.spiderX,
|
||||
)
|
||||
outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -22,7 +23,7 @@ object SocksFmt : FmtBase() {
|
||||
if (uri.idnHost.isEmpty()) return null
|
||||
if (uri.port <= 0) return null
|
||||
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
|
||||
@@ -50,7 +51,7 @@ object SocksFmt : FmtBase() {
|
||||
else
|
||||
":"
|
||||
|
||||
return toUri(config, Utils.encode(pw), null)
|
||||
return toUri(config, Utils.encode(pw, true), null)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,10 +61,10 @@ object SocksFmt : FmtBase() {
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = OutboundBean.create(EConfigType.SOCKS)
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SOCKS)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = profileItem.server.orEmpty()
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
if (profileItem.username.isNotNullEmpty()) {
|
||||
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -22,7 +23,7 @@ object TrojanFmt : FmtBase() {
|
||||
val config = ProfileItem.create(EConfigType.TROJAN)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
config.password = uri.userInfo
|
||||
@@ -60,38 +61,22 @@ object TrojanFmt : FmtBase() {
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = OutboundBean.create(EConfigType.TROJAN)
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.TROJAN)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = profileItem.server.orEmpty()
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.password = profileItem.password
|
||||
server.flow = profileItem.flow
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.populateTransportSettings(
|
||||
profileItem.network.orEmpty(),
|
||||
profileItem.headerType,
|
||||
profileItem.host,
|
||||
profileItem.path,
|
||||
profileItem.seed,
|
||||
profileItem.quicSecurity,
|
||||
profileItem.quicKey,
|
||||
profileItem.mode,
|
||||
profileItem.serviceName,
|
||||
profileItem.authority,
|
||||
)
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.populateTlsSettings(
|
||||
profileItem.security.orEmpty(),
|
||||
profileItem.insecure == true,
|
||||
if (profileItem.sni.isNullOrEmpty()) sni else profileItem.sni,
|
||||
profileItem.fingerPrint,
|
||||
profileItem.alpn,
|
||||
profileItem.publicKey,
|
||||
profileItem.shortId,
|
||||
profileItem.spiderX,
|
||||
)
|
||||
outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -26,7 +26,7 @@ object VlessFmt : FmtBase() {
|
||||
if (uri.rawQuery.isNullOrEmpty()) return null
|
||||
val queryParam = getQueryParam(uri)
|
||||
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
config.password = uri.userInfo
|
||||
@@ -57,41 +57,23 @@ object VlessFmt : FmtBase() {
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = OutboundBean.create(EConfigType.VLESS)
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VLESS)
|
||||
|
||||
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||
vnext.address = profileItem.server.orEmpty()
|
||||
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?.populateTransportSettings(
|
||||
profileItem.network.orEmpty(),
|
||||
profileItem.headerType,
|
||||
profileItem.host,
|
||||
profileItem.path,
|
||||
profileItem.seed,
|
||||
profileItem.quicSecurity,
|
||||
profileItem.quicKey,
|
||||
profileItem.mode,
|
||||
profileItem.serviceName,
|
||||
profileItem.authority,
|
||||
)
|
||||
outboundBean?.streamSettings?.xhttpSettings?.mode = profileItem.xhttpMode
|
||||
outboundBean?.streamSettings?.xhttpSettings?.extra = JsonUtil.parseString(profileItem.xhttpExtra)
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.populateTlsSettings(
|
||||
profileItem.security.orEmpty(),
|
||||
profileItem.insecure == true,
|
||||
if (profileItem.sni.isNullOrEmpty()) sni else profileItem.sni,
|
||||
profileItem.fingerPrint,
|
||||
profileItem.alpn,
|
||||
profileItem.publicKey,
|
||||
profileItem.shortId,
|
||||
profileItem.spiderX,
|
||||
)
|
||||
outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.v2ray.ang.dto.VmessQRCode
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
@@ -27,23 +28,23 @@ object VmessFmt : FmtBase() {
|
||||
return parseVmessStd(str)
|
||||
}
|
||||
|
||||
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
||||
val allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
||||
val config = ProfileItem.create(EConfigType.VMESS)
|
||||
|
||||
var result = str.replace(EConfigType.VMESS.protocolScheme, "")
|
||||
result = Utils.decode(result)
|
||||
if (TextUtils.isEmpty(result)) {
|
||||
Log.d(AppConfig.ANG_PACKAGE, "R.string.toast_decoding_failed")
|
||||
Log.w(AppConfig.TAG, "Toast decoding failed")
|
||||
return null
|
||||
}
|
||||
val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java)
|
||||
val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java) ?: return null
|
||||
// Although VmessQRCode fields are non null, looks like Gson may still create null fields
|
||||
if (TextUtils.isEmpty(vmessQRCode.add)
|
||||
|| TextUtils.isEmpty(vmessQRCode.port)
|
||||
|| TextUtils.isEmpty(vmessQRCode.id)
|
||||
|| TextUtils.isEmpty(vmessQRCode.net)
|
||||
) {
|
||||
Log.d(AppConfig.ANG_PACKAGE, "R.string.toast_incorrect_protocol")
|
||||
Log.w(AppConfig.TAG, "Toast incorrect protocol")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -51,9 +52,13 @@ object VmessFmt : FmtBase() {
|
||||
config.server = vmessQRCode.add
|
||||
config.serverPort = vmessQRCode.port
|
||||
config.password = vmessQRCode.id
|
||||
config.method = if (TextUtils.isEmpty(vmessQRCode.scy)) AppConfig.DEFAULT_SECURITY else vmessQRCode.scy
|
||||
config.method =
|
||||
if (TextUtils.isEmpty(vmessQRCode.scy)) AppConfig.DEFAULT_SECURITY else vmessQRCode.scy
|
||||
|
||||
config.network = vmessQRCode.net ?: NetworkType.TCP.type
|
||||
config.network = vmessQRCode.net
|
||||
if (config.network.isNullOrEmpty()) {
|
||||
config.network = NetworkType.TCP.type
|
||||
}
|
||||
config.headerType = vmessQRCode.type
|
||||
config.host = vmessQRCode.host
|
||||
config.path = vmessQRCode.path
|
||||
@@ -73,15 +78,19 @@ object VmessFmt : FmtBase() {
|
||||
config.serviceName = vmessQRCode.path
|
||||
config.authority = vmessQRCode.host
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
config.security = vmessQRCode.tls
|
||||
config.insecure = allowInsecure
|
||||
config.sni = vmessQRCode.sni
|
||||
config.fingerPrint = vmessQRCode.fp
|
||||
config.alpn = vmessQRCode.alpn
|
||||
|
||||
config.insecure = when (vmessQRCode.insecure) {
|
||||
"1" -> true
|
||||
"0" -> false
|
||||
else -> allowInsecure
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -119,6 +128,7 @@ object VmessFmt : FmtBase() {
|
||||
vmessQRCode.path = config.serviceName.orEmpty()
|
||||
vmessQRCode.host = config.authority.orEmpty()
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
@@ -129,6 +139,11 @@ object VmessFmt : FmtBase() {
|
||||
vmessQRCode.sni = config.sni.orEmpty()
|
||||
vmessQRCode.fp = config.fingerPrint.orEmpty()
|
||||
vmessQRCode.alpn = config.alpn.orEmpty()
|
||||
vmessQRCode.insecure = when (config.insecure) {
|
||||
true -> "1"
|
||||
false -> "0"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
val json = JsonUtil.toJson(vmessQRCode)
|
||||
return Utils.encode(json)
|
||||
@@ -148,7 +163,7 @@ object VmessFmt : FmtBase() {
|
||||
if (uri.rawQuery.isNullOrEmpty()) return null
|
||||
val queryParam = getQueryParam(uri)
|
||||
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
config.password = uri.userInfo
|
||||
@@ -166,38 +181,22 @@ object VmessFmt : FmtBase() {
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = OutboundBean.create(EConfigType.VMESS)
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VMESS)
|
||||
|
||||
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||
vnext.address = profileItem.server.orEmpty()
|
||||
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?.populateTransportSettings(
|
||||
profileItem.network.orEmpty(),
|
||||
profileItem.headerType,
|
||||
profileItem.host,
|
||||
profileItem.path,
|
||||
profileItem.seed,
|
||||
profileItem.quicSecurity,
|
||||
profileItem.quicKey,
|
||||
profileItem.mode,
|
||||
profileItem.serviceName,
|
||||
profileItem.authority,
|
||||
)
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.populateTlsSettings(
|
||||
profileItem.security.orEmpty(),
|
||||
profileItem.insecure == true,
|
||||
if (profileItem.sni.isNullOrEmpty()) sni else profileItem.sni,
|
||||
profileItem.fingerPrint,
|
||||
profileItem.alpn,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.removeWhiteSpace
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -24,16 +25,16 @@ object WireguardFmt : FmtBase() {
|
||||
if (uri.rawQuery.isNullOrEmpty()) return null
|
||||
val queryParam = getQueryParam(uri)
|
||||
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
|
||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
|
||||
config.secretKey = uri.userInfo.orEmpty()
|
||||
config.localAddress = (queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4)
|
||||
config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
|
||||
config.publicKey = queryParam["publickey"].orEmpty()
|
||||
config.preSharedKey = queryParam["presharedkey"].orEmpty()
|
||||
config.preSharedKey = queryParam["presharedkey"]?.takeIf { it.isNotEmpty() }
|
||||
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
||||
config.reserved = (queryParam["reserved"] ?: "0,0,0")
|
||||
config.reserved = queryParam["reserved"] ?: "0,0,0"
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -83,7 +84,7 @@ object WireguardFmt : FmtBase() {
|
||||
config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
|
||||
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
||||
config.publicKey = peerParams["publickey"].orEmpty()
|
||||
config.preSharedKey = peerParams["presharedkey"].orEmpty()
|
||||
config.preSharedKey = peerParams["presharedkey"]?.takeIf { it.isNotEmpty() }
|
||||
val endpoint = peerParams["endpoint"].orEmpty()
|
||||
val endpointParts = endpoint.split(":", limit = 2)
|
||||
if (endpointParts.size == 2) {
|
||||
@@ -105,18 +106,18 @@ object WireguardFmt : FmtBase() {
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = OutboundBean.create(EConfigType.WIREGUARD)
|
||||
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.orEmpty()
|
||||
peer.preSharedKey = profileItem.preSharedKey?.takeIf { it.isNotEmpty() }
|
||||
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
|
||||
}
|
||||
wireguard.mtu = profileItem.mtu
|
||||
wireguard.reserved = profileItem.reserved?.split(",")?.map { it.toInt() }
|
||||
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
|
||||
@@ -7,7 +7,9 @@ import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.HY2
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.*
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.SubscriptionItem
|
||||
import com.v2ray.ang.fmt.CustomFmt
|
||||
import com.v2ray.ang.fmt.Hysteria2Fmt
|
||||
import com.v2ray.ang.fmt.ShadowsocksFmt
|
||||
@@ -42,7 +44,7 @@ object AngConfigManager {
|
||||
Utils.setClipboard(context, conf)
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to share config to clipboard", e)
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
@@ -69,9 +71,9 @@ object AngConfigManager {
|
||||
if (sb.count() > 0) {
|
||||
Utils.setClipboard(context, sb.toString())
|
||||
}
|
||||
return sb.lines().count()
|
||||
return sb.lines().count() - 1
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to share non-custom configs to clipboard", e)
|
||||
return -1
|
||||
}
|
||||
}
|
||||
@@ -91,7 +93,7 @@ object AngConfigManager {
|
||||
return QRCodeDecoder.createQRCode(conf)
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to share config as QR code", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -108,19 +110,12 @@ object AngConfigManager {
|
||||
if (guid == null) return -1
|
||||
val result = V2rayConfigManager.getV2rayConfig(context, guid)
|
||||
if (result.status) {
|
||||
val config = MmkvManager.decodeServerConfig(guid)
|
||||
if (config?.configType == EConfigType.HYSTERIA2) {
|
||||
val socksPort = Utils.findFreePort(listOf(100 + SettingsManager.getSocksPort(), 0))
|
||||
val hy2Config = Hysteria2Fmt.toNativeConfig(config, socksPort)
|
||||
Utils.setClipboard(context, JsonUtil.toJsonPretty(hy2Config) + "\n" + result.content)
|
||||
return 0
|
||||
}
|
||||
Utils.setClipboard(context, result.content)
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to share full content to clipboard", e)
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
@@ -146,9 +141,11 @@ object AngConfigManager {
|
||||
EConfigType.TROJAN -> TrojanFmt.toUri(config)
|
||||
EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
|
||||
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
|
||||
EConfigType.POLICYGROUP -> ""
|
||||
else -> {}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -203,7 +200,7 @@ object AngConfigManager {
|
||||
}
|
||||
return count
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to parse batch subscription", e)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -251,7 +248,7 @@ object AngConfigManager {
|
||||
}
|
||||
return count
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to parse batch config", e)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -273,7 +270,7 @@ object AngConfigManager {
|
||||
) {
|
||||
try {
|
||||
val serverList: Array<Any> =
|
||||
JsonUtil.fromJson(server, Array<Any>::class.java)
|
||||
JsonUtil.fromJson(server, Array<Any>::class.java)?: arrayOf()
|
||||
|
||||
if (serverList.isNotEmpty()) {
|
||||
var count = 0
|
||||
@@ -287,7 +284,7 @@ object AngConfigManager {
|
||||
return count
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -298,7 +295,7 @@ object AngConfigManager {
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
return 1
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
|
||||
}
|
||||
return 0
|
||||
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
||||
@@ -308,7 +305,7 @@ object AngConfigManager {
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
return 1
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
|
||||
}
|
||||
return 0
|
||||
} else {
|
||||
@@ -372,7 +369,7 @@ object AngConfigManager {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to parse config", e)
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
@@ -390,7 +387,7 @@ object AngConfigManager {
|
||||
count += updateConfigViaSub(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
|
||||
return 0
|
||||
}
|
||||
return count
|
||||
@@ -413,34 +410,45 @@ object AngConfigManager {
|
||||
if (!it.second.enabled) {
|
||||
return 0
|
||||
}
|
||||
val url = HttpUtil.idnToASCII(it.second.url)
|
||||
val url = HttpUtil.toIdnUrl(it.second.url)
|
||||
if (!Utils.isValidUrl(url)) {
|
||||
return 0
|
||||
}
|
||||
Log.d(AppConfig.ANG_PACKAGE, url)
|
||||
if (!it.second.allowInsecureUrl) {
|
||||
if (!Utils.isValidSubUrl(url)) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
Log.i(AppConfig.TAG, url)
|
||||
val userAgent = it.second.userAgent
|
||||
|
||||
var configText = try {
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
HttpUtil.getUrlContentWithUserAgent(url, 15000, httpPort)
|
||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error, try……")
|
||||
//e.printStackTrace()
|
||||
Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
|
||||
""
|
||||
}
|
||||
if (configText.isEmpty()) {
|
||||
configText = try {
|
||||
HttpUtil.getUrlContentWithUserAgent(url)
|
||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
|
||||
""
|
||||
}
|
||||
}
|
||||
if (configText.isEmpty()) {
|
||||
return 0
|
||||
}
|
||||
return parseConfigViaSub(configText, it.first, false)
|
||||
val count = parseConfigViaSub(configText, it.first, false)
|
||||
if (count > 0) {
|
||||
it.second.lastUpdated = System.currentTimeMillis()
|
||||
MmkvManager.encodeSubscription(it.first, it.second)
|
||||
Log.i(AppConfig.TAG, "Subscription updated: ${it.second.remarks}, $count configs")
|
||||
}
|
||||
return count
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to update config via subscription", e)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.util.Log
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.NetworkType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.ServerConfig
|
||||
import com.v2ray.ang.extension.removeWhiteSpace
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
|
||||
object MigrateManager {
|
||||
private const val ID_SERVER_CONFIG = "SERVER_CONFIG"
|
||||
private val serverStorage by lazy { MMKV.mmkvWithID(ID_SERVER_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
||||
|
||||
/**
|
||||
* Migrates server configurations to profile items.
|
||||
*
|
||||
* @return True if migration was successful, false otherwise.
|
||||
*/
|
||||
fun migrateServerConfig2Profile(): Boolean {
|
||||
if (serverStorage.count().toInt() == 0) {
|
||||
return false
|
||||
}
|
||||
val serverList = serverStorage.allKeys() ?: return false
|
||||
Log.d(ANG_PACKAGE, "migrateServerConfig2Profile-" + serverList.count())
|
||||
|
||||
for (guid in serverList) {
|
||||
var configOld = decodeServerConfigOld(guid) ?: continue
|
||||
var config = decodeServerConfig(guid)
|
||||
if (config != null) {
|
||||
serverStorage.remove(guid)
|
||||
continue
|
||||
}
|
||||
config = migrateServerConfig2ProfileSub(configOld) ?: continue
|
||||
config.subscriptionId = configOld.subscriptionId
|
||||
|
||||
MmkvManager.encodeServerConfig(guid, config)
|
||||
|
||||
//check and remove old
|
||||
decodeServerConfig(guid) ?: continue
|
||||
serverStorage.remove(guid)
|
||||
Log.d(ANG_PACKAGE, "migrateServerConfig2Profile-" + config.remarks)
|
||||
}
|
||||
Log.d(ANG_PACKAGE, "migrateServerConfig2Profile-end")
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates a server configuration to a profile item.
|
||||
*
|
||||
* @param configOld The old server configuration.
|
||||
* @return The profile item.
|
||||
*/
|
||||
private fun migrateServerConfig2ProfileSub(configOld: ServerConfig): ProfileItem? {
|
||||
return when (configOld.getProxyOutbound()?.protocol) {
|
||||
EConfigType.VMESS.name.lowercase() -> migrate2ProfileCommon(configOld)
|
||||
EConfigType.VLESS.name.lowercase() -> migrate2ProfileCommon(configOld)
|
||||
EConfigType.TROJAN.name.lowercase() -> migrate2ProfileCommon(configOld)
|
||||
EConfigType.SHADOWSOCKS.name.lowercase() -> migrate2ProfileCommon(configOld)
|
||||
|
||||
EConfigType.SOCKS.name.lowercase() -> migrate2ProfileSocks(configOld)
|
||||
EConfigType.HTTP.name.lowercase() -> migrate2ProfileHttp(configOld)
|
||||
EConfigType.WIREGUARD.name.lowercase() -> migrate2ProfileWireguard(configOld)
|
||||
EConfigType.HYSTERIA2.name.lowercase() -> migrate2ProfileHysteria2(configOld)
|
||||
|
||||
EConfigType.CUSTOM.name.lowercase() -> migrate2ProfileCustom(configOld)
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates a common server configuration to a profile item.
|
||||
*
|
||||
* @param configOld The old server configuration.
|
||||
* @return The profile item.
|
||||
*/
|
||||
private fun migrate2ProfileCommon(configOld: ServerConfig): ProfileItem? {
|
||||
val config = ProfileItem.create(configOld.configType)
|
||||
|
||||
val outbound = configOld.getProxyOutbound() ?: return null
|
||||
config.remarks = configOld.remarks
|
||||
config.server = outbound.getServerAddress()
|
||||
config.serverPort = outbound.getServerPort().toString()
|
||||
config.method = outbound.getSecurityEncryption()
|
||||
config.password = outbound.getPassword()
|
||||
config.flow = outbound?.settings?.vnext?.first()?.users?.first()?.flow ?: outbound?.settings?.servers?.first()?.flow
|
||||
|
||||
config.network = outbound?.streamSettings?.network ?: NetworkType.TCP.type
|
||||
outbound.getTransportSettingDetails()?.let { transportDetails ->
|
||||
config.headerType = transportDetails[0].orEmpty()
|
||||
config.host = transportDetails[1].orEmpty()
|
||||
config.path = transportDetails[2].orEmpty()
|
||||
}
|
||||
|
||||
config.seed = outbound?.streamSettings?.kcpSettings?.seed
|
||||
config.quicSecurity = outbound?.streamSettings?.quicSettings?.security
|
||||
config.quicKey = outbound?.streamSettings?.quicSettings?.key
|
||||
config.mode = if (outbound?.streamSettings?.grpcSettings?.multiMode == true) "multi" else "gun"
|
||||
config.serviceName = outbound?.streamSettings?.grpcSettings?.serviceName
|
||||
config.authority = outbound?.streamSettings?.grpcSettings?.authority
|
||||
|
||||
config.security = outbound.streamSettings?.security
|
||||
val tlsSettings = outbound?.streamSettings?.realitySettings ?: outbound?.streamSettings?.tlsSettings
|
||||
config.insecure = tlsSettings?.allowInsecure
|
||||
config.sni = tlsSettings?.serverName
|
||||
config.fingerPrint = tlsSettings?.fingerprint
|
||||
config.alpn = tlsSettings?.alpn?.joinToString(",").removeWhiteSpace().toString()
|
||||
|
||||
config.publicKey = tlsSettings?.publicKey
|
||||
config.shortId = tlsSettings?.shortId
|
||||
config.spiderX = tlsSettings?.spiderX
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates a SOCKS server configuration to a profile item.
|
||||
*
|
||||
* @param configOld The old server configuration.
|
||||
* @return The profile item.
|
||||
*/
|
||||
private fun migrate2ProfileSocks(configOld: ServerConfig): ProfileItem? {
|
||||
val config = ProfileItem.create(EConfigType.SOCKS)
|
||||
|
||||
val outbound = configOld.getProxyOutbound() ?: return null
|
||||
config.remarks = configOld.remarks
|
||||
config.server = outbound.getServerAddress()
|
||||
config.serverPort = outbound.getServerPort().toString()
|
||||
config.username = outbound.settings?.servers?.first()?.users?.first()?.user
|
||||
config.password = outbound.getPassword()
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates an HTTP server configuration to a profile item.
|
||||
*
|
||||
* @param configOld The old server configuration.
|
||||
* @return The profile item.
|
||||
*/
|
||||
private fun migrate2ProfileHttp(configOld: ServerConfig): ProfileItem? {
|
||||
val config = ProfileItem.create(EConfigType.HTTP)
|
||||
|
||||
val outbound = configOld.getProxyOutbound() ?: return null
|
||||
config.remarks = configOld.remarks
|
||||
config.server = outbound.getServerAddress()
|
||||
config.serverPort = outbound.getServerPort().toString()
|
||||
config.username = outbound.settings?.servers?.first()?.users?.first()?.user
|
||||
config.password = outbound.getPassword()
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates a WireGuard server configuration to a profile item.
|
||||
*
|
||||
* @param configOld The old server configuration.
|
||||
* @return The profile item.
|
||||
*/
|
||||
private fun migrate2ProfileWireguard(configOld: ServerConfig): ProfileItem? {
|
||||
val config = ProfileItem.create(EConfigType.WIREGUARD)
|
||||
|
||||
val outbound = configOld.getProxyOutbound() ?: return null
|
||||
config.remarks = configOld.remarks
|
||||
config.server = outbound.getServerAddress()
|
||||
config.serverPort = outbound.getServerPort().toString()
|
||||
|
||||
outbound.settings?.let { wireguard ->
|
||||
config.secretKey = wireguard.secretKey
|
||||
config.localAddress = (wireguard.address as List<*>).joinToString(",").removeWhiteSpace().toString()
|
||||
config.publicKey = wireguard.peers?.getOrNull(0)?.publicKey
|
||||
config.mtu = wireguard.mtu
|
||||
config.reserved = wireguard.reserved?.joinToString(",").removeWhiteSpace().toString()
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates a Hysteria2 server configuration to a profile item.
|
||||
*
|
||||
* @param configOld The old server configuration.
|
||||
* @return The profile item.
|
||||
*/
|
||||
private fun migrate2ProfileHysteria2(configOld: ServerConfig): ProfileItem? {
|
||||
val config = ProfileItem.create(EConfigType.HYSTERIA2)
|
||||
|
||||
val outbound = configOld.getProxyOutbound() ?: return null
|
||||
config.remarks = configOld.remarks
|
||||
config.server = outbound.getServerAddress()
|
||||
config.serverPort = outbound.getServerPort().toString()
|
||||
config.password = outbound.getPassword()
|
||||
|
||||
config.security = AppConfig.TLS
|
||||
outbound.streamSettings?.tlsSettings?.let { tlsSetting ->
|
||||
config.insecure = tlsSetting.allowInsecure
|
||||
config.sni = tlsSetting.serverName
|
||||
config.alpn = tlsSetting.alpn?.joinToString(",").removeWhiteSpace().orEmpty()
|
||||
|
||||
}
|
||||
config.obfsPassword = outbound.settings?.obfsPassword
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates a custom server configuration to a profile item.
|
||||
*
|
||||
* @param configOld The old server configuration.
|
||||
* @return The profile item.
|
||||
*/
|
||||
private fun migrate2ProfileCustom(configOld: ServerConfig): ProfileItem? {
|
||||
val config = ProfileItem.create(EConfigType.CUSTOM)
|
||||
|
||||
val outbound = configOld.getProxyOutbound() ?: return null
|
||||
config.remarks = configOld.remarks
|
||||
config.server = outbound.getServerAddress()
|
||||
config.serverPort = outbound.getServerPort().toString()
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the old server configuration.
|
||||
*
|
||||
* @param guid The server GUID.
|
||||
* @return The old server configuration.
|
||||
*/
|
||||
private fun decodeServerConfigOld(guid: String): ServerConfig? {
|
||||
if (guid.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val json = serverStorage.decodeString(guid)
|
||||
if (json.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
return JsonUtil.fromJson(json, ServerConfig::class.java)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.RulesetItem
|
||||
import com.v2ray.ang.dto.ServerAffiliationInfo
|
||||
import com.v2ray.ang.dto.SubscriptionItem
|
||||
import com.v2ray.ang.dto.WebDavConfig
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
@@ -26,6 +27,7 @@ object MmkvManager {
|
||||
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
|
||||
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
|
||||
private const val KEY_SUB_IDS = "SUB_IDS"
|
||||
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) }
|
||||
@@ -77,7 +79,7 @@ object MmkvManager {
|
||||
return if (json.isNullOrBlank()) {
|
||||
mutableListOf()
|
||||
} else {
|
||||
JsonUtil.fromJson(json, Array<String>::class.java).toMutableList()
|
||||
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +316,8 @@ object MmkvManager {
|
||||
decodeSubsList().forEach { key ->
|
||||
val json = subStorage.decodeString(key)
|
||||
if (!json.isNullOrBlank()) {
|
||||
subscriptions.add(Pair(key, JsonUtil.fromJson(json, SubscriptionItem::class.java)))
|
||||
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java)?: SubscriptionItem()
|
||||
subscriptions.add(Pair(key, item))
|
||||
}
|
||||
}
|
||||
return subscriptions
|
||||
@@ -381,7 +384,7 @@ object MmkvManager {
|
||||
return if (json.isNullOrBlank()) {
|
||||
mutableListOf()
|
||||
} else {
|
||||
JsonUtil.fromJson(json, Array<String>::class.java).toMutableList()
|
||||
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList()?: mutableListOf()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,7 +402,8 @@ object MmkvManager {
|
||||
assetStorage.allKeys()?.forEach { key ->
|
||||
val json = assetStorage.decodeString(key)
|
||||
if (!json.isNullOrBlank()) {
|
||||
assetUrlItems.add(Pair(key, JsonUtil.fromJson(json, AssetUrlItem::class.java)))
|
||||
val item = JsonUtil.fromJson(json, AssetUrlItem::class.java)?: AssetUrlItem()
|
||||
assetUrlItems.add(Pair(key, item))
|
||||
}
|
||||
}
|
||||
return assetUrlItems.sortedBy { (_, value) -> value.addedTime }
|
||||
@@ -448,7 +452,7 @@ object MmkvManager {
|
||||
fun decodeRoutingRulesets(): MutableList<RulesetItem>? {
|
||||
val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
|
||||
if (ruleset.isNullOrEmpty()) return null
|
||||
return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java).toMutableList()
|
||||
return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java)?.toMutableList()?: mutableListOf()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -465,6 +469,7 @@ object MmkvManager {
|
||||
|
||||
//endregion
|
||||
|
||||
//region settings
|
||||
/**
|
||||
* Encodes the settings.
|
||||
*
|
||||
@@ -487,6 +492,28 @@ object MmkvManager {
|
||||
return settingsStorage.encode(key, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the settings.
|
||||
*
|
||||
* @param key The settings key.
|
||||
* @param value The settings value.
|
||||
* @return Whether the encoding was successful.
|
||||
*/
|
||||
fun encodeSettings(key: String, value: Long): Boolean {
|
||||
return settingsStorage.encode(key, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the settings.
|
||||
*
|
||||
* @param key The settings key.
|
||||
* @param value The settings value.
|
||||
* @return Whether the encoding was successful.
|
||||
*/
|
||||
fun encodeSettings(key: String, value: Float): Boolean {
|
||||
return settingsStorage.encode(key, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the settings.
|
||||
*
|
||||
@@ -530,6 +557,39 @@ object MmkvManager {
|
||||
return settingsStorage.decodeString(key, defaultValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the settings integer.
|
||||
*
|
||||
* @param key The settings key.
|
||||
* @param defaultValue The default value.
|
||||
* @return The settings value.
|
||||
*/
|
||||
fun decodeSettingsInt(key: String, defaultValue: Int): Int {
|
||||
return settingsStorage.decodeInt(key, defaultValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the settings long.
|
||||
*
|
||||
* @param key The settings key.
|
||||
* @param defaultValue The default value.
|
||||
* @return The settings value.
|
||||
*/
|
||||
fun decodeSettingsLong(key: String, defaultValue: Long): Long {
|
||||
return settingsStorage.decodeLong(key, defaultValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the settings float.
|
||||
*
|
||||
* @param key The settings key.
|
||||
* @param defaultValue The default value.
|
||||
* @return The settings value.
|
||||
*/
|
||||
fun decodeSettingsFloat(key: String, defaultValue: Float): Float {
|
||||
return settingsStorage.decodeFloat(key, defaultValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the settings boolean.
|
||||
*
|
||||
@@ -561,17 +621,13 @@ object MmkvManager {
|
||||
return settingsStorage.decodeStringSet(key)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Others
|
||||
|
||||
/**
|
||||
* Encodes the start on boot setting.
|
||||
*
|
||||
* @param startOnBoot Whether to start on boot.
|
||||
*/
|
||||
fun encodeStartOnBoot(startOnBoot: Boolean) {
|
||||
MmkvManager.encodeSettings(PREF_IS_BOOTED, startOnBoot)
|
||||
encodeSettings(PREF_IS_BOOTED, startOnBoot)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -585,4 +641,22 @@ object MmkvManager {
|
||||
|
||||
//endregion
|
||||
|
||||
//region WebDAV
|
||||
|
||||
/**
|
||||
* Encodes the WebDAV config as JSON into storage.
|
||||
*/
|
||||
fun encodeWebDavConfig(config: WebDavConfig): Boolean {
|
||||
return mainStorage.encode(KEY_WEBDAV_CONFIG, JsonUtil.toJson(config))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the WebDAV config from storage.
|
||||
*/
|
||||
fun decodeWebDavConfig(): WebDavConfig? {
|
||||
val json = mainStorage.decodeString(KEY_WEBDAV_CONFIG) ?: return null
|
||||
return JsonUtil.fromJson(json, WebDavConfig::class.java)
|
||||
}
|
||||
|
||||
//endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import androidx.preference.PreferenceDataStore
|
||||
import com.v2ray.ang.AppConfig
|
||||
|
||||
/**
|
||||
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
|
||||
* This ensures that all Preference UI operations read/write directly from/to MMKV,
|
||||
* avoiding inconsistencies between SharedPreferences and MMKV.
|
||||
*/
|
||||
class MmkvPreferenceDataStore : PreferenceDataStore() {
|
||||
|
||||
override fun putString(key: String, value: String?) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getString(key: String, defaultValue: String?): String? {
|
||||
return MmkvManager.decodeSettingsString(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putInt(key: String, value: Int) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getInt(key: String, defaultValue: Int): Int {
|
||||
return MmkvManager.decodeSettingsInt(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putLong(key: String, value: Long) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getLong(key: String, defaultValue: Long): Long {
|
||||
return MmkvManager.decodeSettingsLong(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putFloat(key: String, value: Float) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getFloat(key: String, defaultValue: Float): Float {
|
||||
return MmkvManager.decodeSettingsFloat(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putBoolean(key: String, value: Boolean) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String, defaultValue: Boolean): Boolean {
|
||||
return MmkvManager.decodeSettingsBool(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putStringSet(key: String, values: MutableSet<String>?) {
|
||||
if (values == null) {
|
||||
MmkvManager.encodeSettings(key, null as String?)
|
||||
} else {
|
||||
MmkvManager.encodeSettings(key, values)
|
||||
}
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getStringSet(key: String, defaultValues: MutableSet<String>?): MutableSet<String>? {
|
||||
return MmkvManager.decodeSettingsStringSet(key) ?: defaultValues
|
||||
}
|
||||
|
||||
// Internal helper: notify other modules about setting changes
|
||||
private fun notifySettingChanged(key: String) {
|
||||
// Call SettingsManager.setNightMode if UI mode changed
|
||||
if (key == AppConfig.PREF_UI_MODE_NIGHT) {
|
||||
SettingsManager.setNightMode()
|
||||
}
|
||||
// Notify listeners that require service restart or reinit
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
}
|
||||
+12
-22
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.service
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
@@ -12,12 +12,9 @@ import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.extension.toSpeedString
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.ui.MainActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -27,7 +24,7 @@ import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.min
|
||||
|
||||
object NotificationService {
|
||||
object NotificationManager {
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val NOTIFICATION_PENDING_INTENT_CONTENT = 0
|
||||
private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1
|
||||
@@ -50,7 +47,7 @@ object NotificationService {
|
||||
lastQueryTime = System.currentTimeMillis()
|
||||
var lastZeroSpeed = false
|
||||
val outboundTags = currentConfig?.getAllOutboundTags()
|
||||
outboundTags?.remove(TAG_DIRECT)
|
||||
outboundTags?.remove(AppConfig.TAG_DIRECT)
|
||||
|
||||
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
while (isActive) {
|
||||
@@ -66,15 +63,15 @@ object NotificationService {
|
||||
proxyTotal += up + down
|
||||
}
|
||||
}
|
||||
val directUplink = V2RayServiceManager.queryStats(TAG_DIRECT, AppConfig.UPLINK)
|
||||
val directDownlink = V2RayServiceManager.queryStats(TAG_DIRECT, AppConfig.DOWNLINK)
|
||||
val directUplink = V2RayServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.UPLINK)
|
||||
val directDownlink = V2RayServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.DOWNLINK)
|
||||
val zeroSpeed = proxyTotal == 0L && directUplink == 0L && directDownlink == 0L
|
||||
if (!zeroSpeed || !lastZeroSpeed) {
|
||||
if (proxyTotal == 0L) {
|
||||
appendSpeedString(text, outboundTags?.firstOrNull(), 0.0, 0.0)
|
||||
}
|
||||
appendSpeedString(
|
||||
text, TAG_DIRECT, directUplink / sinceLastQueryInSeconds,
|
||||
text, AppConfig.TAG_DIRECT, directUplink / sinceLastQueryInSeconds,
|
||||
directDownlink / sinceLastQueryInSeconds
|
||||
)
|
||||
updateNotification(text.toString(), proxyTotal, directDownlink + directUplink)
|
||||
@@ -92,22 +89,18 @@ object NotificationService {
|
||||
*/
|
||||
fun showNotification(currentConfig: ProfileItem?) {
|
||||
val service = getService() ?: return
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
|
||||
val startMainIntent = Intent(service, MainActivity::class.java)
|
||||
val contentPendingIntent = PendingIntent.getActivity(service, NOTIFICATION_PENDING_INTENT_CONTENT, startMainIntent, flags)
|
||||
|
||||
val stopV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||
stopV2RayIntent.`package` = ANG_PACKAGE
|
||||
stopV2RayIntent.`package` = AppConfig.ANG_PACKAGE
|
||||
stopV2RayIntent.putExtra("key", AppConfig.MSG_STATE_STOP)
|
||||
val stopV2RayPendingIntent = PendingIntent.getBroadcast(service, NOTIFICATION_PENDING_INTENT_STOP_V2RAY, stopV2RayIntent, flags)
|
||||
|
||||
val restartV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||
restartV2RayIntent.`package` = ANG_PACKAGE
|
||||
restartV2RayIntent.`package` = AppConfig.ANG_PACKAGE
|
||||
restartV2RayIntent.putExtra("key", AppConfig.MSG_STATE_RESTART)
|
||||
val restartV2RayPendingIntent = PendingIntent.getBroadcast(service, NOTIFICATION_PENDING_INTENT_RESTART_V2RAY, restartV2RayIntent, flags)
|
||||
|
||||
@@ -149,15 +142,12 @@ object NotificationService {
|
||||
*/
|
||||
fun cancelNotification() {
|
||||
val service = getService() ?: return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
service.stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
service.stopForeground(true)
|
||||
}
|
||||
service.stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||
|
||||
mBuilder = null
|
||||
speedNotificationJob?.cancel()
|
||||
speedNotificationJob = null
|
||||
mNotificationManager = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,7 +223,7 @@ object NotificationService {
|
||||
*/
|
||||
private fun appendSpeedString(text: StringBuilder, name: String?, up: Double, down: Double) {
|
||||
var n = name ?: "no tag"
|
||||
n = n.substring(0, min(n.length, 6))
|
||||
n = n.take(min(n.length, 6))
|
||||
text.append(n)
|
||||
for (i in n.length..6 step 2) {
|
||||
text.append("\t")
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
object SettingsChangeManager {
|
||||
private val _restartService = MutableStateFlow(false)
|
||||
private val _setupGroupTab = MutableStateFlow(false)
|
||||
|
||||
// Mark restartService as requiring a restart
|
||||
fun makeRestartService() {
|
||||
_restartService.value = true
|
||||
}
|
||||
|
||||
// Read and clear the restartService flag
|
||||
fun consumeRestartService(): Boolean {
|
||||
val v = _restartService.value
|
||||
_restartService.value = false
|
||||
return v
|
||||
}
|
||||
|
||||
// Mark reinitGroupTab as requiring tab reinitialization
|
||||
fun makeSetupGroupTab() {
|
||||
_setupGroupTab.value = true
|
||||
}
|
||||
|
||||
// Read and clear the reinitGroupTab flag
|
||||
fun consumeSetupGroupTab(): Boolean {
|
||||
val v = _setupGroupTab.value
|
||||
_setupGroupTab.value = false
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.RoutingType
|
||||
import com.v2ray.ang.dto.RulesetItem
|
||||
import com.v2ray.ang.dto.V2rayConfig
|
||||
import com.v2ray.ang.dto.VpnInterfaceAddressConfig
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeServerList
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
@@ -52,7 +53,7 @@ object SettingsManager {
|
||||
return null
|
||||
}
|
||||
|
||||
return JsonUtil.fromJson(assets, Array<RulesetItem>::class.java).toMutableList()
|
||||
return JsonUtil.fromJson(assets, Array<RulesetItem>::class.java)?.toMutableList()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +77,7 @@ object SettingsManager {
|
||||
}
|
||||
|
||||
try {
|
||||
val rulesetList = JsonUtil.fromJson(content, Array<RulesetItem>::class.java).toMutableList()
|
||||
val rulesetList = JsonUtil.fromJson(content, Array<RulesetItem>::class.java)?.toMutableList()
|
||||
if (rulesetList.isNullOrEmpty()) {
|
||||
return false
|
||||
}
|
||||
@@ -84,7 +85,7 @@ object SettingsManager {
|
||||
resetRoutingRulesetsCommon(rulesetList)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(ANG_PACKAGE, "Failed to reset routing rulesets", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -159,7 +160,7 @@ object SettingsManager {
|
||||
* @return True if bypassing LAN, false otherwise.
|
||||
*/
|
||||
fun routingRulesetsBypassLan(): Boolean {
|
||||
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN) ?: "0"
|
||||
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN) ?: "1"
|
||||
if (vpnBypassLan == "1") {
|
||||
return true
|
||||
} else if (vpnBypassLan == "2") {
|
||||
@@ -167,11 +168,11 @@ object SettingsManager {
|
||||
}
|
||||
|
||||
val guid = MmkvManager.getSelectServer() ?: return false
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return false
|
||||
val config = decodeServerConfig(guid) ?: return false
|
||||
if (config.configType == EConfigType.CUSTOM) {
|
||||
val raw = MmkvManager.decodeServerRaw(guid) ?: return false
|
||||
val v2rayConfig = JsonUtil.fromJson(raw, V2rayConfig::class.java)
|
||||
val exist = v2rayConfig.routing.rules.filter { it.outboundTag == TAG_DIRECT }?.any {
|
||||
val exist = v2rayConfig?.routing?.rules?.filter { it.outboundTag == TAG_DIRECT }?.any {
|
||||
it.domain?.contains(GEOSITE_PRIVATE) == true || it.ip?.contains(GEOIP_PRIVATE) == true
|
||||
}
|
||||
return exist == true
|
||||
@@ -204,7 +205,7 @@ object SettingsManager {
|
||||
*/
|
||||
fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
|
||||
val subsList = MmkvManager.decodeSubsList()
|
||||
if (subsList.isNullOrEmpty()) return
|
||||
if (subsList.isEmpty()) return
|
||||
|
||||
Collections.swap(subsList, fromPosition, toPosition)
|
||||
MmkvManager.encodeSubsList(subsList)
|
||||
@@ -216,7 +217,7 @@ object SettingsManager {
|
||||
* @return The ProfileItem.
|
||||
*/
|
||||
fun getServerViaRemarks(remarks: String?): ProfileItem? {
|
||||
if (remarks == null) {
|
||||
if (remarks.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val serverList = decodeServerList()
|
||||
@@ -242,7 +243,7 @@ object SettingsManager {
|
||||
* @return The HTTP port.
|
||||
*/
|
||||
fun getHttpPort(): Int {
|
||||
return getSocksPort() + (if (Utils.isXray()) 0 else 1)
|
||||
return getSocksPort() + if (Utils.isXray()) 0 else 1
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,10 +266,7 @@ object SettingsManager {
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
Log.i(
|
||||
ANG_PACKAGE,
|
||||
"Copied from apk assets folder to ${target.absolutePath}"
|
||||
)
|
||||
Log.i(AppConfig.TAG, "Copied from apk assets folder to ${target.absolutePath}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(ANG_PACKAGE, "asset copy failed", e)
|
||||
@@ -319,10 +317,10 @@ object SettingsManager {
|
||||
*/
|
||||
fun getDelayTestUrl(second: Boolean = false): String {
|
||||
return if (second) {
|
||||
AppConfig.DelayTestUrl2
|
||||
AppConfig.DELAY_TEST_URL2
|
||||
} else {
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL)
|
||||
?: AppConfig.DelayTestUrl
|
||||
?: AppConfig.DELAY_TEST_URL
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,11 +338,12 @@ object SettingsManager {
|
||||
Language.ENGLISH -> Locale.ENGLISH
|
||||
Language.CHINA -> Locale.CHINA
|
||||
Language.TRADITIONAL_CHINESE -> Locale.TRADITIONAL_CHINESE
|
||||
Language.VIETNAMESE -> Locale("vi")
|
||||
Language.RUSSIAN -> Locale("ru")
|
||||
Language.PERSIAN -> Locale("fa")
|
||||
Language.BANGLA -> Locale("bn")
|
||||
Language.BAKHTIARI -> Locale("bqi", "IR")
|
||||
Language.VIETNAMESE -> Locale.forLanguageTag("vi")
|
||||
Language.RUSSIAN -> Locale.forLanguageTag("ru")
|
||||
Language.PERSIAN -> Locale.forLanguageTag("fa")
|
||||
Language.ARABIC -> Locale.forLanguageTag("ar")
|
||||
Language.BANGLA -> Locale.forLanguageTag("bn")
|
||||
Language.BAKHTIARI -> Locale.forLanguageTag("bqi-IR")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,4 +357,59 @@ object SettingsManager {
|
||||
"2" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the currently selected VPN interface address configuration.
|
||||
* This method reads the user's preference for VPN interface addressing and returns
|
||||
* the corresponding configuration containing IPv4 and IPv6 addresses.
|
||||
*
|
||||
* @return The selected VpnInterfaceAddressConfig instance, or the default configuration
|
||||
* if no valid selection is found or if the stored index is invalid.
|
||||
*/
|
||||
fun getCurrentVpnInterfaceAddressConfig(): VpnInterfaceAddressConfig {
|
||||
val selectedIndex = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX, "0")?.toInt()
|
||||
return VpnInterfaceAddressConfig.getConfigByIndex(selectedIndex ?: 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the VPN MTU from settings, defaulting to AppConfig.VPN_MTU.
|
||||
*/
|
||||
fun getVpnMtu(): Int {
|
||||
return Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_MTU), AppConfig.VPN_MTU)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if HEV TUN is being used.
|
||||
* @return True if HEV TUN is used, false otherwise.
|
||||
*/
|
||||
fun isUsingHevTun(): Boolean {
|
||||
return MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure default settings are present in MMKV.
|
||||
*/
|
||||
fun ensureDefaultSettings() {
|
||||
// Write defaults in the exact order requested by the user
|
||||
ensureDefaultValue(AppConfig.PREF_MODE, AppConfig.VPN)
|
||||
ensureDefaultValue(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
|
||||
ensureDefaultValue(AppConfig.PREF_VPN_MTU, AppConfig.VPN_MTU.toString())
|
||||
ensureDefaultValue(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL, AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL)
|
||||
ensureDefaultValue(AppConfig.PREF_SOCKS_PORT, AppConfig.PORT_SOCKS)
|
||||
ensureDefaultValue(AppConfig.PREF_REMOTE_DNS, AppConfig.DNS_PROXY)
|
||||
ensureDefaultValue(AppConfig.PREF_DOMESTIC_DNS, AppConfig.DNS_DIRECT)
|
||||
ensureDefaultValue(AppConfig.PREF_DELAY_TEST_URL, AppConfig.DELAY_TEST_URL)
|
||||
ensureDefaultValue(AppConfig.PREF_IP_API_URL, AppConfig.IP_API_URL)
|
||||
ensureDefaultValue(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, AppConfig.HEVTUN_RW_TIMEOUT)
|
||||
ensureDefaultValue(AppConfig.PREF_MUX_CONCURRENCY, "8")
|
||||
ensureDefaultValue(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
|
||||
}
|
||||
|
||||
private fun ensureDefaultValue(key: String, default: String) {
|
||||
if (MmkvManager.decodeSettingsString(key).isNullOrEmpty()) {
|
||||
MmkvManager.encodeSettings(key, default)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,18 @@ package com.v2ray.ang.handler
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.extension.responseLength
|
||||
import com.v2ray.ang.dto.IPAPIInfo
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.isActive
|
||||
import libv2ray.Libv2ray
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.net.UnknownHostException
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
object SpeedtestManager {
|
||||
|
||||
@@ -31,7 +30,7 @@ object SpeedtestManager {
|
||||
var time = -1L
|
||||
for (k in 0 until 2) {
|
||||
val one = socketConnectTime(url, port)
|
||||
if (!coroutineContext.isActive) {
|
||||
if (!currentCoroutineContext().isActive) {
|
||||
break
|
||||
}
|
||||
if (one != -1L && (time == -1L || one < time)) {
|
||||
@@ -41,46 +40,6 @@ object SpeedtestManager {
|
||||
return time
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the real ping time using the V2Ray library.
|
||||
*
|
||||
* @param config The configuration string for the V2Ray library.
|
||||
* @return The ping time in milliseconds, or -1 if the ping failed.
|
||||
*/
|
||||
fun realPing(config: String): Long {
|
||||
return try {
|
||||
Libv2ray.measureOutboundDelay(config, SettingsManager.getDelayTestUrl())
|
||||
} catch (e: Exception) {
|
||||
Log.d(AppConfig.ANG_PACKAGE, "realPing: $e")
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the ping time to a given URL using the system ping command.
|
||||
*
|
||||
* @param url The URL to ping.
|
||||
* @return The ping time in milliseconds as a string, or "-1ms" if the ping failed.
|
||||
*/
|
||||
fun ping(url: String): String {
|
||||
try {
|
||||
val command = "/system/bin/ping -c 3 $url"
|
||||
val process = Runtime.getRuntime().exec(command)
|
||||
val allText = process.inputStream.bufferedReader().use { it.readText() }
|
||||
if (!TextUtils.isEmpty(allText)) {
|
||||
val tempInfo = allText.substring(allText.indexOf("min/avg/max/mdev") + 19)
|
||||
val temps =
|
||||
tempInfo.split("/".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
if (temps.count() > 0 && temps[0].length < 10) {
|
||||
return temps[0].toFloat().toInt().toString() + "ms"
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return "-1ms"
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the time taken to establish a TCP connection to a given URL and port.
|
||||
*
|
||||
@@ -103,11 +62,11 @@ object SpeedtestManager {
|
||||
socket.close()
|
||||
return time
|
||||
} catch (e: UnknownHostException) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Unknown host: $url", e)
|
||||
} catch (e: IOException) {
|
||||
Log.d(AppConfig.ANG_PACKAGE, "socketConnectTime IOException: $e")
|
||||
Log.e(AppConfig.TAG, "socketConnectTime IOException: $e")
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -141,36 +100,48 @@ object SpeedtestManager {
|
||||
val code = conn.responseCode
|
||||
elapsed = SystemClock.elapsedRealtime() - start
|
||||
|
||||
if (code == 204 || code == 200 && conn.responseLength == 0L) {
|
||||
result = context.getString(R.string.connection_test_available, elapsed)
|
||||
} else {
|
||||
throw IOException(
|
||||
context.getString(
|
||||
R.string.connection_test_error_status_code,
|
||||
code
|
||||
)
|
||||
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.d(AppConfig.ANG_PACKAGE, "testConnection IOException: " + Log.getStackTraceString(e))
|
||||
Log.e(AppConfig.TAG, "Connection test IOException", e)
|
||||
result = context.getString(R.string.connection_test_error, e.message)
|
||||
} catch (e: Exception) {
|
||||
Log.d(AppConfig.ANG_PACKAGE, "testConnection Exception: " + Log.getStackTraceString(e))
|
||||
Log.e(AppConfig.TAG, "Connection test Exception", e)
|
||||
result = context.getString(R.string.connection_test_error, e.message)
|
||||
} finally {
|
||||
conn?.disconnect()
|
||||
conn.disconnect()
|
||||
}
|
||||
|
||||
return Pair(elapsed, result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the version of the V2Ray library.
|
||||
*
|
||||
* @return The version of the V2Ray library.
|
||||
*/
|
||||
fun getLibVersion(): String {
|
||||
return Libv2ray.checkVersionX()
|
||||
}
|
||||
fun getRemoteIPInfo(): String? {
|
||||
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
|
||||
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
|
||||
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
val content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: return null
|
||||
val ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
|
||||
|
||||
val ip = listOf(
|
||||
ipInfo.ip,
|
||||
ipInfo.clientIp,
|
||||
ipInfo.ip_addr,
|
||||
ipInfo.query
|
||||
).firstOrNull { !it.isNullOrBlank() }
|
||||
|
||||
val country = listOf(
|
||||
ipInfo.country_code,
|
||||
ipInfo.country,
|
||||
ipInfo.countryCode,
|
||||
ipInfo.location?.country_code
|
||||
).firstOrNull { !it.isNullOrBlank() }
|
||||
|
||||
return "(${country ?: "unknown"}) ${ip ?: "unknown"}"
|
||||
}
|
||||
}
|
||||
|
||||
+8
-15
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.service
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
@@ -11,11 +11,7 @@ import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.SUBSCRIPTION_UPDATE_CHANNEL
|
||||
import com.v2ray.ang.AppConfig.SUBSCRIPTION_UPDATE_CHANNEL_NAME
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.handler.AngConfigManager.updateConfigViaSub
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
|
||||
object SubscriptionUpdater {
|
||||
|
||||
@@ -24,7 +20,7 @@ object SubscriptionUpdater {
|
||||
|
||||
private val notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
private val notification =
|
||||
NotificationCompat.Builder(applicationContext, SUBSCRIPTION_UPDATE_CHANNEL)
|
||||
NotificationCompat.Builder(applicationContext, AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
|
||||
.setWhen(0)
|
||||
.setTicker("Update")
|
||||
.setContentTitle(context.getString(R.string.title_pref_auto_update_subscription))
|
||||
@@ -38,7 +34,7 @@ object SubscriptionUpdater {
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun doWork(): Result {
|
||||
Log.d(AppConfig.ANG_PACKAGE, "subscription automatic update starting")
|
||||
Log.i(AppConfig.TAG, "subscription automatic update starting")
|
||||
|
||||
val subs = MmkvManager.decodeSubscriptions().filter { it.second.autoUpdate }
|
||||
|
||||
@@ -46,21 +42,18 @@ object SubscriptionUpdater {
|
||||
val subItem = sub.second
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notification.setChannelId(SUBSCRIPTION_UPDATE_CHANNEL)
|
||||
notification.setChannelId(AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
SUBSCRIPTION_UPDATE_CHANNEL,
|
||||
SUBSCRIPTION_UPDATE_CHANNEL_NAME,
|
||||
AppConfig.SUBSCRIPTION_UPDATE_CHANNEL,
|
||||
AppConfig.SUBSCRIPTION_UPDATE_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_MIN
|
||||
)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
notificationManager.notify(3, notification.build())
|
||||
Log.d(
|
||||
AppConfig.ANG_PACKAGE,
|
||||
"subscription automatic update: ---${subItem.remarks}"
|
||||
)
|
||||
updateConfigViaSub(Pair(sub.first, subItem))
|
||||
Log.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
|
||||
AngConfigManager.updateConfigViaSub(Pair(sub.first, subItem))
|
||||
notification.setContentText("Updating ${subItem.remarks}")
|
||||
}
|
||||
notificationManager.cancel(3)
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.dto.CheckUpdateResult
|
||||
import com.v2ray.ang.dto.GitHubRelease
|
||||
import com.v2ray.ang.extension.concatUrl
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
object UpdateCheckerManager {
|
||||
suspend fun checkForUpdate(includePreRelease: Boolean = false): CheckUpdateResult = withContext(Dispatchers.IO) {
|
||||
val url = if (includePreRelease) {
|
||||
AppConfig.APP_API_URL
|
||||
} else {
|
||||
AppConfig.APP_API_URL.concatUrl("latest")
|
||||
}
|
||||
|
||||
var response = HttpUtil.getUrlContent(url, 5000)
|
||||
if (response.isNullOrEmpty()) {
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
response = HttpUtil.getUrlContent(url, 5000, httpPort)
|
||||
?: throw IllegalStateException("Failed to get response")
|
||||
}
|
||||
|
||||
val latestRelease = if (includePreRelease) {
|
||||
JsonUtil.fromJson(response, Array<GitHubRelease>::class.java)
|
||||
?.firstOrNull()
|
||||
?: throw IllegalStateException("No pre-release found")
|
||||
} else {
|
||||
JsonUtil.fromJson(response, GitHubRelease::class.java)
|
||||
}
|
||||
if (latestRelease == null) {
|
||||
return@withContext CheckUpdateResult(hasUpdate = false)
|
||||
}
|
||||
|
||||
val latestVersion = latestRelease.tagName.removePrefix("v")
|
||||
Log.i(
|
||||
AppConfig.TAG,
|
||||
"Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})"
|
||||
)
|
||||
|
||||
return@withContext if (compareVersions(latestVersion, BuildConfig.VERSION_NAME) > 0) {
|
||||
val downloadUrl = getDownloadUrl(latestRelease, Build.SUPPORTED_ABIS[0])
|
||||
CheckUpdateResult(
|
||||
hasUpdate = true,
|
||||
latestVersion = latestVersion,
|
||||
releaseNotes = latestRelease.body,
|
||||
downloadUrl = downloadUrl,
|
||||
isPreRelease = latestRelease.prerelease
|
||||
)
|
||||
} else {
|
||||
CheckUpdateResult(hasUpdate = false)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun downloadApk(context: Context, downloadUrl: String): File? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
val connection = HttpUtil.createProxyConnection(downloadUrl, httpPort, 10000, 10000, true)
|
||||
?: throw IllegalStateException("Failed to create connection")
|
||||
|
||||
try {
|
||||
val apkFile = File(context.cacheDir, "update.apk")
|
||||
Log.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath}")
|
||||
|
||||
FileOutputStream(apkFile).use { outputStream ->
|
||||
connection.inputStream.use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
Log.i(AppConfig.TAG, "APK download completed")
|
||||
return@withContext apkFile
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to download APK: ${e.message}")
|
||||
return@withContext null
|
||||
} finally {
|
||||
try {
|
||||
connection.disconnect()
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Error closing connection: ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to initiate download: ${e.message}")
|
||||
return@withContext null
|
||||
}
|
||||
}
|
||||
|
||||
private fun compareVersions(version1: String, version2: String): Int {
|
||||
val v1 = version1.split(".")
|
||||
val v2 = version2.split(".")
|
||||
|
||||
for (i in 0 until maxOf(v1.size, v2.size)) {
|
||||
val num1 = if (i < v1.size) v1[i].toInt() else 0
|
||||
val num2 = if (i < v2.size) v2[i].toInt() else 0
|
||||
if (num1 != num2) return num1 - num2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun getDownloadUrl(release: GitHubRelease, abi: String): String {
|
||||
val fDroid = "fdroid"
|
||||
|
||||
val assetsByAbi = release.assets.filter {
|
||||
(it.name.contains(abi, true))
|
||||
}
|
||||
|
||||
val asset = if (BuildConfig.APPLICATION_ID.contains(fDroid, ignoreCase = true)) {
|
||||
assetsByAbi.firstOrNull { it.name.contains(fDroid) }
|
||||
} else {
|
||||
assetsByAbi.firstOrNull { !it.name.contains(fDroid) }
|
||||
}
|
||||
|
||||
return asset?.browserDownloadUrl
|
||||
?: throw IllegalStateException("No compatible APK found")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.util.Utils
|
||||
import go.Seq
|
||||
import libv2ray.CoreCallbackHandler
|
||||
import libv2ray.CoreController
|
||||
import libv2ray.Libv2ray
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* V2Ray Native Library Manager
|
||||
*
|
||||
* Thread-safe singleton wrapper for Libv2ray native methods.
|
||||
* Provides initialization protection and unified API for V2Ray core operations.
|
||||
*/
|
||||
object V2RayNativeManager {
|
||||
private val initialized = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* Initialize V2Ray core environment.
|
||||
* This method is thread-safe and ensures initialization happens only once.
|
||||
* Subsequent calls will be ignored silently.
|
||||
*
|
||||
*/
|
||||
fun initCoreEnv(context: Context?) {
|
||||
if (initialized.compareAndSet(false, true)) {
|
||||
try {
|
||||
Seq.setContext(context?.applicationContext)
|
||||
val assetPath = Utils.userAssetPath(context)
|
||||
val deviceId = Utils.getDeviceIdForXUDPBaseKey()
|
||||
Libv2ray.initCoreEnv(assetPath, deviceId)
|
||||
Log.i(AppConfig.TAG, "V2Ray core environment initialized successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to initialize V2Ray core environment", e)
|
||||
initialized.set(false)
|
||||
throw e
|
||||
}
|
||||
} else {
|
||||
Log.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get V2Ray core version.
|
||||
*
|
||||
* @return Version string of the V2Ray core
|
||||
*/
|
||||
fun getLibVersion(): String {
|
||||
return try {
|
||||
Libv2ray.checkVersionX()
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to check V2Ray version", e)
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure outbound connection delay.
|
||||
*
|
||||
* @param config The configuration JSON string
|
||||
* @param testUrl The URL to test against
|
||||
* @return Delay in milliseconds, or -1 if test failed
|
||||
*/
|
||||
fun measureOutboundDelay(config: String, testUrl: String): Long {
|
||||
return try {
|
||||
Libv2ray.measureOutboundDelay(config, testUrl)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to measure outbound delay", e)
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new core controller instance.
|
||||
*
|
||||
* @param handler The callback handler for core events
|
||||
* @return A new CoreController instance
|
||||
*/
|
||||
fun newCoreController(handler: CoreCallbackHandler): CoreController {
|
||||
return try {
|
||||
Libv2ray.newCoreController(handler)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to create core controller", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
-116
@@ -1,45 +1,40 @@
|
||||
package com.v2ray.ang.service
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.service.ServiceControl
|
||||
import com.v2ray.ang.service.V2RayProxyOnlyService
|
||||
import com.v2ray.ang.service.V2RayVpnService
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
import com.v2ray.ang.util.PluginUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import go.Seq
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import libv2ray.Libv2ray
|
||||
import libv2ray.V2RayPoint
|
||||
import libv2ray.V2RayVPNServiceSupportsSet
|
||||
import libv2ray.CoreCallbackHandler
|
||||
import libv2ray.CoreController
|
||||
import java.lang.ref.SoftReference
|
||||
|
||||
object V2RayServiceManager {
|
||||
|
||||
private val v2rayPoint: V2RayPoint = Libv2ray.newV2RayPoint(V2RayCallback(), Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1)
|
||||
private val coreController: CoreController = V2RayNativeManager.newCoreController(CoreCallback())
|
||||
private val mMsgReceive = ReceiveMessageHandler()
|
||||
private var currentConfig: ProfileItem? = null
|
||||
|
||||
var serviceControl: SoftReference<ServiceControl>? = null
|
||||
set(value) {
|
||||
field = value
|
||||
Seq.setContext(value?.get()?.getService()?.applicationContext)
|
||||
Libv2ray.initV2Env(Utils.userAssetPath(value?.get()?.getService()), Utils.getDeviceIdForXUDPBaseKey())
|
||||
V2RayNativeManager.initCoreEnv(value?.get()?.getService())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +68,7 @@ object V2RayServiceManager {
|
||||
* @param context The context from which the service is stopped.
|
||||
*/
|
||||
fun stopVService(context: Context) {
|
||||
context.toast(R.string.toast_services_stop)
|
||||
//context.toast(R.string.toast_services_stop)
|
||||
MessageUtil.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
|
||||
}
|
||||
|
||||
@@ -81,7 +76,7 @@ object V2RayServiceManager {
|
||||
* Checks if the V2Ray service is running.
|
||||
* @return True if the service is running, false otherwise.
|
||||
*/
|
||||
fun isRunning() = v2rayPoint.isRunning
|
||||
fun isRunning() = coreController.isRunning
|
||||
|
||||
/**
|
||||
* Gets the name of the currently running server.
|
||||
@@ -91,20 +86,24 @@ object V2RayServiceManager {
|
||||
|
||||
/**
|
||||
* Starts the context service for V2Ray.
|
||||
* Chooses between VPN service or Proxy-only service based on user settings.
|
||||
* @param context The context from which the service is started.
|
||||
*/
|
||||
private fun startContextService(context: Context) {
|
||||
if (v2rayPoint.isRunning) return
|
||||
if (coreController.isRunning) {
|
||||
return
|
||||
}
|
||||
val guid = MmkvManager.getSelectServer() ?: return
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return
|
||||
if (config.configType != EConfigType.CUSTOM
|
||||
&& config.configType != EConfigType.POLICYGROUP
|
||||
&& !Utils.isValidUrl(config.server)
|
||||
&& !Utils.isIpAddress(config.server)
|
||||
&& !Utils.isPureIpAddress(config.server.orEmpty())
|
||||
) return
|
||||
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
|
||||
// if (!result.status) return
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING) == true) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) {
|
||||
context.toast(R.string.toast_warning_pref_proxysharing_short)
|
||||
} else {
|
||||
context.toast(R.string.toast_services_start)
|
||||
@@ -114,28 +113,25 @@ object V2RayServiceManager {
|
||||
} else {
|
||||
Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N_MR1) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refer to the official documentation for [registerReceiver](https://developer.android.com/reference/androidx/core/content/ContextCompat#registerReceiver(android.content.Context,android.content.BroadcastReceiver,android.content.IntentFilter,int):
|
||||
* `registerReceiver(Context, BroadcastReceiver, IntentFilter, int)`.
|
||||
* Starts the V2Ray point.
|
||||
* Starts the V2Ray core service.
|
||||
*/
|
||||
fun startV2rayPoint() {
|
||||
val service = getService() ?: return
|
||||
val guid = MmkvManager.getSelectServer() ?: return
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return
|
||||
if (v2rayPoint.isRunning) {
|
||||
return
|
||||
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
|
||||
if (coreController.isRunning) {
|
||||
return false
|
||||
}
|
||||
|
||||
val service = getService() ?: return false
|
||||
val guid = MmkvManager.getSelectServer() ?: return false
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return false
|
||||
val result = V2rayConfigManager.getV2rayConfig(service, guid)
|
||||
if (!result.status)
|
||||
return
|
||||
return false
|
||||
|
||||
try {
|
||||
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||
@@ -144,55 +140,69 @@ object V2RayServiceManager {
|
||||
mFilter.addAction(Intent.ACTION_USER_PRESENT)
|
||||
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
|
||||
} catch (e: Exception) {
|
||||
Log.d(ANG_PACKAGE, e.toString())
|
||||
Log.e(AppConfig.TAG, "Failed to register broadcast receiver", e)
|
||||
return false
|
||||
}
|
||||
|
||||
v2rayPoint.configureFileContent = result.content
|
||||
v2rayPoint.domainName = result.domainPort
|
||||
currentConfig = config
|
||||
var tunFd = vpnInterface?.fd ?: 0
|
||||
if (SettingsManager.isUsingHevTun()) {
|
||||
tunFd = 0
|
||||
}
|
||||
|
||||
try {
|
||||
v2rayPoint.runLoop(MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
|
||||
coreController.startLoop(result.content, tunFd)
|
||||
} catch (e: Exception) {
|
||||
Log.d(ANG_PACKAGE, e.toString())
|
||||
Log.e(AppConfig.TAG, "Failed to start Core loop", e)
|
||||
return false
|
||||
}
|
||||
|
||||
if (v2rayPoint.isRunning) {
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
|
||||
NotificationService.showNotification(currentConfig)
|
||||
|
||||
PluginUtil.runPlugin(service, config, result.domainPort)
|
||||
} else {
|
||||
if (coreController.isRunning == false) {
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
|
||||
NotificationService.cancelNotification()
|
||||
NotificationManager.cancelNotification()
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
|
||||
NotificationManager.showNotification(currentConfig)
|
||||
NotificationManager.startSpeedNotification(currentConfig)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to startup service", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the V2Ray point.
|
||||
* Stops the V2Ray core service.
|
||||
* Unregisters broadcast receivers, stops notifications, and shuts down plugins.
|
||||
* @return True if the core was stopped successfully, false otherwise.
|
||||
*/
|
||||
fun stopV2rayPoint() {
|
||||
val service = getService() ?: return
|
||||
fun stopCoreLoop(): Boolean {
|
||||
val service = getService() ?: return false
|
||||
|
||||
if (v2rayPoint.isRunning) {
|
||||
if (coreController.isRunning) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
v2rayPoint.stopLoop()
|
||||
coreController.stopLoop()
|
||||
} catch (e: Exception) {
|
||||
Log.d(ANG_PACKAGE, e.toString())
|
||||
Log.e(AppConfig.TAG, "Failed to stop V2Ray loop", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
|
||||
NotificationService.cancelNotification()
|
||||
NotificationManager.cancelNotification()
|
||||
|
||||
try {
|
||||
service.unregisterReceiver(mMsgReceive)
|
||||
} catch (e: Exception) {
|
||||
Log.d(ANG_PACKAGE, e.toString())
|
||||
Log.e(AppConfig.TAG, "Failed to unregister broadcast receiver", e)
|
||||
}
|
||||
PluginUtil.stopPlugin()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,40 +212,52 @@ object V2RayServiceManager {
|
||||
* @return The statistics value.
|
||||
*/
|
||||
fun queryStats(tag: String, link: String): Long {
|
||||
return v2rayPoint.queryStats(tag, link)
|
||||
return coreController.queryStats(tag, link)
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the delay for V2Ray.
|
||||
* Measures the connection delay for the current V2Ray configuration.
|
||||
* Tests with primary URL first, then falls back to alternative URL if needed.
|
||||
* Also fetches remote IP information if the delay test was successful.
|
||||
*/
|
||||
private fun measureV2rayDelay() {
|
||||
if (coreController.isRunning == false) {
|
||||
return
|
||||
}
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val service = getService() ?: return@launch
|
||||
var time = -1L
|
||||
var errstr = ""
|
||||
if (v2rayPoint.isRunning) {
|
||||
try {
|
||||
time = v2rayPoint.measureDelay(SettingsManager.getDelayTestUrl())
|
||||
} catch (e: Exception) {
|
||||
Log.d(ANG_PACKAGE, "measureV2rayDelay: $e")
|
||||
errstr = e.message?.substringAfter("\":") ?: "empty message"
|
||||
}
|
||||
if (time == -1L) {
|
||||
try {
|
||||
time = v2rayPoint.measureDelay(SettingsManager.getDelayTestUrl(true))
|
||||
} catch (e: Exception) {
|
||||
Log.d(ANG_PACKAGE, "measureV2rayDelay: $e")
|
||||
errstr = e.message?.substringAfter("\":") ?: "empty message"
|
||||
}
|
||||
}
|
||||
var errorStr = ""
|
||||
|
||||
try {
|
||||
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to measure delay with primary URL", e)
|
||||
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
||||
}
|
||||
val result = if (time == -1L) {
|
||||
service.getString(R.string.connection_test_error, errstr)
|
||||
} else {
|
||||
service.getString(R.string.connection_test_available, time)
|
||||
if (time == -1L) {
|
||||
try {
|
||||
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to measure delay with alternative URL", e)
|
||||
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
||||
}
|
||||
}
|
||||
|
||||
val result = if (time >= 0) {
|
||||
service.getString(R.string.connection_test_available, time)
|
||||
} else {
|
||||
service.getString(R.string.connection_test_error, errorStr)
|
||||
}
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, result)
|
||||
|
||||
// Only fetch IP info if the delay test was successful
|
||||
if (time >= 0) {
|
||||
SpeedtestManager.getRemoteIPInfo()?.let { ip ->
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result\n$ip")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,59 +269,53 @@ object V2RayServiceManager {
|
||||
return serviceControl?.get()?.getService()
|
||||
}
|
||||
|
||||
private class V2RayCallback : V2RayVPNServiceSupportsSet {
|
||||
/**
|
||||
* Core callback handler implementation for handling V2Ray core events.
|
||||
* Handles startup, shutdown, socket protection, and status emission.
|
||||
*/
|
||||
private class CoreCallback : CoreCallbackHandler {
|
||||
/**
|
||||
* Called when V2Ray core starts up.
|
||||
* @return 0 for success, any other value for failure.
|
||||
*/
|
||||
override fun startup(): Long {
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when V2Ray core shuts down.
|
||||
* @return 0 for success, any other value for failure.
|
||||
*/
|
||||
override fun shutdown(): Long {
|
||||
val serviceControl = serviceControl?.get() ?: return -1
|
||||
// called by go
|
||||
return try {
|
||||
serviceControl.stopService()
|
||||
0
|
||||
} catch (e: Exception) {
|
||||
Log.d(ANG_PACKAGE, e.toString())
|
||||
Log.e(AppConfig.TAG, "Failed to stop service in callback", e)
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
override fun prepare(): Long {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun protect(l: Long): Boolean {
|
||||
val serviceControl = serviceControl?.get() ?: return true
|
||||
return serviceControl.vpnProtect(l.toInt())
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by Go to emit status.
|
||||
* @param l The status code.
|
||||
* @param s The status message.
|
||||
* @return The status code.
|
||||
* Called when V2Ray core emits status information.
|
||||
* @param l Status code.
|
||||
* @param s Status message.
|
||||
* @return Always returns 0.
|
||||
*/
|
||||
override fun onEmitStatus(l: Long, s: String?): Long {
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by Go to set up the service.
|
||||
* @param s The setup string.
|
||||
* @return The status code.
|
||||
*/
|
||||
override fun setup(s: String): Long {
|
||||
val serviceControl = serviceControl?.get() ?: return -1
|
||||
return try {
|
||||
serviceControl.startService()
|
||||
NotificationService.startSpeedNotification(currentConfig)
|
||||
0
|
||||
} catch (e: Exception) {
|
||||
Log.d(ANG_PACKAGE, e.toString())
|
||||
-1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast receiver for handling messages sent to the service.
|
||||
* Handles registration, service control, and screen events.
|
||||
*/
|
||||
private class ReceiveMessageHandler : BroadcastReceiver() {
|
||||
/**
|
||||
* Handles received broadcast messages.
|
||||
* Processes service control messages and screen state changes.
|
||||
* @param ctx The context in which the receiver is running.
|
||||
* @param intent The intent being received.
|
||||
*/
|
||||
@@ -307,7 +323,7 @@ object V2RayServiceManager {
|
||||
val serviceControl = serviceControl?.get() ?: return
|
||||
when (intent?.getIntExtra("key", 0)) {
|
||||
AppConfig.MSG_REGISTER_CLIENT -> {
|
||||
if (v2rayPoint.isRunning) {
|
||||
if (coreController.isRunning) {
|
||||
MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_RUNNING, "")
|
||||
} else {
|
||||
MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
|
||||
@@ -323,12 +339,12 @@ object V2RayServiceManager {
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_STOP -> {
|
||||
Log.d(ANG_PACKAGE, "Stop Service")
|
||||
Log.i(AppConfig.TAG, "Stop Service")
|
||||
serviceControl.stopService()
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_RESTART -> {
|
||||
Log.d(ANG_PACKAGE, "Restart Service")
|
||||
Log.i(AppConfig.TAG, "Restart Service")
|
||||
serviceControl.stopService()
|
||||
Thread.sleep(500L)
|
||||
startVService(serviceControl.getService())
|
||||
@@ -341,15 +357,15 @@ object V2RayServiceManager {
|
||||
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SCREEN_OFF -> {
|
||||
Log.d(ANG_PACKAGE, "SCREEN_OFF, stop querying stats")
|
||||
NotificationService.stopSpeedNotification(currentConfig)
|
||||
Log.i(AppConfig.TAG, "SCREEN_OFF, stop querying stats")
|
||||
NotificationManager.stopSpeedNotification(currentConfig)
|
||||
}
|
||||
|
||||
Intent.ACTION_SCREEN_ON -> {
|
||||
Log.d(ANG_PACKAGE, "SCREEN_ON, start querying stats")
|
||||
NotificationService.startSpeedNotification(currentConfig)
|
||||
Log.i(AppConfig.TAG, "SCREEN_ON, start querying stats")
|
||||
NotificationManager.startSpeedNotification(currentConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.WebDavConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.URL
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object WebDavManager {
|
||||
private var cfg: WebDavConfig? = null
|
||||
private var client: OkHttpClient? = null
|
||||
|
||||
/**
|
||||
* Initialize the WebDAV manager with a configuration and build an OkHttp client.
|
||||
*
|
||||
* @param config WebDavConfig containing baseUrl, credentials, remoteBasePath and timeoutSeconds.
|
||||
*/
|
||||
fun init(config: WebDavConfig) {
|
||||
cfg = config
|
||||
client = OkHttpClient.Builder()
|
||||
.connectTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
|
||||
.readTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
|
||||
.writeTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
|
||||
.callTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a local file to a remote file name under the configured remoteBasePath.
|
||||
* The provided `remoteFileName` should be a file name (e.g. "backup_ng.zip").
|
||||
* The method will attempt to create parent directories via MKCOL before PUT.
|
||||
*
|
||||
* @param localFile File to upload.
|
||||
* @param remoteFileName Remote file name relative to configured remoteBasePath.
|
||||
* @return true if upload succeeded (HTTP 2xx), false otherwise.
|
||||
*/
|
||||
suspend fun uploadFile(localFile: File, remoteFileName: String): Boolean = withContext(Dispatchers.IO) {
|
||||
val remote = buildRemoteUrl(remoteFileName)
|
||||
try {
|
||||
val cl = client ?: return@withContext false
|
||||
|
||||
// Ensure parent directories exist
|
||||
val dirPath = remote.substringBeforeLast('/')
|
||||
if (dirPath != remote) {
|
||||
ensureRemoteDirs(dirPath)
|
||||
}
|
||||
|
||||
// Determine content type based on file extension
|
||||
val mediaType = when (localFile.extension.lowercase()) {
|
||||
"zip" -> "application/zip"
|
||||
"json" -> "application/json"
|
||||
"txt" -> "text/plain"
|
||||
else -> "application/octet-stream"
|
||||
}.toMediaTypeOrNull()
|
||||
|
||||
val body = localFile.asRequestBody(mediaType)
|
||||
val req = applyAuth(Request.Builder().url(remote).put(body)).build()
|
||||
cl.newCall(req).execute().use { resp ->
|
||||
val success = resp.isSuccessful
|
||||
if (success) {
|
||||
Log.i(AppConfig.TAG, "WebDAV upload success: $remote")
|
||||
} else {
|
||||
Log.e(AppConfig.TAG, "WebDAV upload failed: $remote (HTTP ${resp.code})")
|
||||
}
|
||||
return@withContext success
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a remote file (relative to configured remoteBasePath) into a local file.
|
||||
*
|
||||
* @param remoteFileName Remote file name relative to configured remoteBasePath.
|
||||
* @param destFile Local destination file to write to.
|
||||
* @return true if download and write succeeded, false otherwise.
|
||||
*/
|
||||
suspend fun downloadFile(remoteFileName: String, destFile: File): Boolean = withContext(Dispatchers.IO) {
|
||||
val remote = buildRemoteUrl(remoteFileName)
|
||||
try {
|
||||
val cl = client ?: return@withContext false
|
||||
val req = applyAuth(Request.Builder().url(remote).get()).build()
|
||||
cl.newCall(req).execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
Log.e(AppConfig.TAG, "WebDAV download failed: $remote (HTTP ${resp.code})")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
resp.body.byteStream().use { input ->
|
||||
destFile.parentFile?.mkdirs()
|
||||
FileOutputStream(destFile).use { fos ->
|
||||
input.copyTo(fos)
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(AppConfig.TAG, "WebDAV download success: $remote")
|
||||
return@withContext true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a full remote URL by combining the configured base URL, the configured
|
||||
* remote base path and a file name provided by the caller.
|
||||
*
|
||||
* Example: baseUrl="https://example.com/remote.php/dav", remoteBasePath="backups",
|
||||
* remoteFileName="backup_ng.zip" => "https://example.com/remote.php/dav/backups/backup_ng.zip"
|
||||
*
|
||||
* @param remoteFileName A file name relative to the configured remoteBasePath (no leading slash required).
|
||||
* @return Full URL string used for HTTP operations.
|
||||
*/
|
||||
private fun buildRemoteUrl(remoteFileName: String): String {
|
||||
val base = cfg?.baseUrl?.trimEnd('/') ?: ""
|
||||
// Use configured remoteBasePath when not empty; otherwise fallback to AppConfig.WEBDAV_BACKUP_DIR
|
||||
val basePathConfigured = cfg?.remoteBasePath?.trim('/')?.takeIf { it.isNotEmpty() }
|
||||
val basePath = basePathConfigured ?: AppConfig.WEBDAV_BACKUP_DIR
|
||||
val rel = remoteFileName.trimStart('/')
|
||||
return if (basePath.isEmpty()) "$base/$rel" else "$base/$basePath/$rel"
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply HTTP Basic authentication headers to the given request builder when
|
||||
* username is configured in `cfg`.
|
||||
*
|
||||
* @param builder OkHttp Request.Builder to modify.
|
||||
* @return The same builder instance with Authorization header applied if credentials exist.
|
||||
*/
|
||||
private fun applyAuth(builder: Request.Builder): Request.Builder {
|
||||
val username = cfg?.username
|
||||
val password = cfg?.password
|
||||
if (!username.isNullOrEmpty()) {
|
||||
builder.header("Authorization", Credentials.basic(username, password ?: ""))
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that each directory segment in the given directory URL exists on the
|
||||
* WebDAV server. This issues MKCOL requests for each segment in a best-effort
|
||||
* manner and ignores errors for segments that already exist.
|
||||
*
|
||||
* @param dirUrl Absolute URL to the directory that should exist (e.g. https://.../backups)
|
||||
*/
|
||||
private fun ensureRemoteDirs(dirUrl: String) {
|
||||
try {
|
||||
val cl = client ?: return
|
||||
val url = URL(dirUrl)
|
||||
val segments = url.path.split("/").filter { it.isNotEmpty() }
|
||||
var accum = ""
|
||||
for (seg in segments) {
|
||||
accum += "/$seg"
|
||||
val mkUrl = URL(url.protocol, url.host, if (url.port == -1) -1 else url.port, accum).toString()
|
||||
try {
|
||||
val req = applyAuth(Request.Builder().url(mkUrl).method("MKCOL", null)).build()
|
||||
cl.newCall(req).execute().use { resp ->
|
||||
// 201 Created or 405 Method Not Allowed (already exists) are acceptable
|
||||
if (resp.code != 201 && resp.code != 405 && resp.code != 409) {
|
||||
Log.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}")
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// best-effort, continue
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "WebDAV ensureRemoteDirs error", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,12 +34,12 @@ import kotlin.math.sign
|
||||
*
|
||||
* @author Paul Burke (ipaulpro)
|
||||
*/
|
||||
class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter) : ItemTouchHelper.Callback() {
|
||||
class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter, private val allowSwipe: Boolean = false) : ItemTouchHelper.Callback() {
|
||||
private var mReturnAnimator: ValueAnimator? = null
|
||||
|
||||
override fun isLongPressDragEnabled(): Boolean = true
|
||||
|
||||
override fun isItemViewSwipeEnabled(): Boolean = true
|
||||
override fun isItemViewSwipeEnabled(): Boolean = allowSwipe
|
||||
|
||||
override fun getMovementFlags(
|
||||
recyclerView: RecyclerView,
|
||||
@@ -49,10 +49,10 @@ class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter
|
||||
val swipeFlags: Int
|
||||
if (recyclerView.layoutManager is GridLayoutManager) {
|
||||
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
|
||||
swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END
|
||||
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
|
||||
} else {
|
||||
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
|
||||
swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END
|
||||
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
|
||||
}
|
||||
return makeMovementFlags(dragFlags, swipeFlags)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter
|
||||
addUpdateListener { animation ->
|
||||
val value = animation.animatedValue as Float
|
||||
viewHolder.itemView.translationX = value
|
||||
viewHolder.itemView.alpha = (1f - abs(value) / (viewHolder.itemView.width * SWIPE_THRESHOLD))
|
||||
viewHolder.itemView.alpha = 1f - abs(value) / (viewHolder.itemView.width * SWIPE_THRESHOLD)
|
||||
}
|
||||
interpolator = DecelerateInterpolator()
|
||||
duration = ANIMATION_DURATION
|
||||
@@ -144,4 +144,4 @@ class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter
|
||||
private const val SWIPE_THRESHOLD = 0.25f
|
||||
private const val ANIMATION_DURATION: Long = 200
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/******************************************************************************
|
||||
* *
|
||||
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
|
||||
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
|
||||
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* This program is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
******************************************************************************/
|
||||
|
||||
package com.v2ray.ang.plugin
|
||||
|
||||
import android.content.pm.ResolveInfo
|
||||
|
||||
class NativePlugin(resolveInfo: ResolveInfo) : ResolvedPlugin(resolveInfo) {
|
||||
init {
|
||||
check(resolveInfo.providerInfo != null)
|
||||
}
|
||||
|
||||
override val componentInfo get() = resolveInfo.providerInfo!!
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/******************************************************************************
|
||||
* *
|
||||
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
|
||||
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
|
||||
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* This program is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
******************************************************************************/
|
||||
|
||||
package com.v2ray.ang.plugin
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
|
||||
abstract class Plugin {
|
||||
abstract val id: String
|
||||
abstract val label: CharSequence
|
||||
abstract val version: Int
|
||||
abstract val versionName: String
|
||||
open val icon: Drawable? get() = null
|
||||
open val defaultConfig: String? get() = null
|
||||
open val packageName: String get() = ""
|
||||
open val directBootAware: Boolean get() = true
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
return id == (other as Plugin).id
|
||||
}
|
||||
|
||||
override fun hashCode() = id.hashCode()
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/******************************************************************************
|
||||
* *
|
||||
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* This program is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
******************************************************************************/
|
||||
|
||||
package com.v2ray.ang.plugin
|
||||
|
||||
object PluginContract {
|
||||
|
||||
const val ACTION_NATIVE_PLUGIN = "io.nekohasekai.sagernet.plugin.ACTION_NATIVE_PLUGIN"
|
||||
const val EXTRA_ENTRY = "io.nekohasekai.sagernet.plugin.EXTRA_ENTRY"
|
||||
const val METADATA_KEY_ID = "io.nekohasekai.sagernet.plugin.id"
|
||||
const val METADATA_KEY_EXECUTABLE_PATH = "io.nekohasekai.sagernet.plugin.executable_path"
|
||||
const val METHOD_GET_EXECUTABLE = "sagernet:getExecutable"
|
||||
|
||||
const val COLUMN_PATH = "path"
|
||||
const val COLUMN_MODE = "mode"
|
||||
const val SCHEME = "plugin"
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/******************************************************************************
|
||||
* *
|
||||
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
|
||||
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
|
||||
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* This program is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
******************************************************************************/
|
||||
|
||||
package com.v2ray.ang.plugin
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import com.v2ray.ang.AngApplication
|
||||
|
||||
class PluginList : ArrayList<Plugin>() {
|
||||
init {
|
||||
addAll(
|
||||
AngApplication.application.packageManager.queryIntentContentProviders(
|
||||
Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA
|
||||
)
|
||||
.filter { it.providerInfo.exported }.map { NativePlugin(it) })
|
||||
}
|
||||
|
||||
val lookup = mutableMapOf<String, Plugin>().apply {
|
||||
for (plugin in this@PluginList.toList()) {
|
||||
fun check(old: Plugin?) {
|
||||
if (old != null && old != plugin) {
|
||||
this@PluginList.remove(old)
|
||||
}
|
||||
/* if (old != null && old !== plugin) {
|
||||
val packages = this@PluginList.filter { it.id == plugin.id }
|
||||
.joinToString { it.packageName }
|
||||
val message = "Conflicting plugins found from: $packages"
|
||||
Toast.makeText(SagerNet.application, message, Toast.LENGTH_LONG).show()
|
||||
throw IllegalStateException(message)
|
||||
}*/
|
||||
}
|
||||
check(put(plugin.id, plugin))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
/******************************************************************************
|
||||
* *
|
||||
* Copyright (C) 2021 by nekohasekai <contact-AngApplication@sekai.icu> *
|
||||
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
|
||||
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* This program is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
******************************************************************************/
|
||||
|
||||
package com.v2ray.ang.plugin
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ContentResolver
|
||||
import android.content.Intent
|
||||
import android.content.pm.ComponentInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ProviderInfo
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.system.Os
|
||||
import android.widget.Toast
|
||||
import androidx.core.os.bundleOf
|
||||
import com.v2ray.ang.AngApplication
|
||||
import com.v2ray.ang.extension.listenForPackageChanges
|
||||
import com.v2ray.ang.plugin.PluginContract.METADATA_KEY_ID
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
object PluginManager {
|
||||
|
||||
class PluginNotFoundException(val plugin: String) : FileNotFoundException(plugin)
|
||||
|
||||
private var receiver: BroadcastReceiver? = null
|
||||
private var cachedPlugins: PluginList? = null
|
||||
fun fetchPlugins() = synchronized(this) {
|
||||
if (receiver == null) receiver = AngApplication.application.listenForPackageChanges {
|
||||
synchronized(this) {
|
||||
receiver = null
|
||||
cachedPlugins = null
|
||||
}
|
||||
}
|
||||
if (cachedPlugins == null) cachedPlugins = PluginList()
|
||||
cachedPlugins!!
|
||||
}
|
||||
|
||||
private fun buildUri(id: String, authority: String) = Uri.Builder()
|
||||
.scheme(PluginContract.SCHEME)
|
||||
.authority(authority)
|
||||
.path("/$id")
|
||||
.build()
|
||||
|
||||
data class InitResult(
|
||||
val path: String,
|
||||
)
|
||||
|
||||
@Throws(Throwable::class)
|
||||
fun init(pluginId: String): InitResult? {
|
||||
if (pluginId.isEmpty()) return null
|
||||
var throwable: Throwable? = null
|
||||
|
||||
try {
|
||||
val result = initNative(pluginId)
|
||||
if (result != null) return result
|
||||
} catch (t: Throwable) {
|
||||
if (throwable == null) throwable = t //Logs.w(t)
|
||||
}
|
||||
|
||||
throw throwable ?: PluginNotFoundException(pluginId)
|
||||
}
|
||||
|
||||
private fun initNative(pluginId: String): InitResult? {
|
||||
var flags = PackageManager.GET_META_DATA
|
||||
if (Build.VERSION.SDK_INT >= 24) {
|
||||
flags =
|
||||
flags or PackageManager.MATCH_DIRECT_BOOT_UNAWARE or PackageManager.MATCH_DIRECT_BOOT_AWARE
|
||||
}
|
||||
var providers = AngApplication.application.packageManager.queryIntentContentProviders(
|
||||
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "com.github.dyhkwong.AngApplication")), flags
|
||||
)
|
||||
.filter { it.providerInfo.exported }
|
||||
if (providers.isEmpty()) {
|
||||
providers = AngApplication.application.packageManager.queryIntentContentProviders(
|
||||
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "io.nekohasekai.AngApplication")), flags
|
||||
)
|
||||
.filter { it.providerInfo.exported }
|
||||
}
|
||||
if (providers.isEmpty()) {
|
||||
providers = AngApplication.application.packageManager.queryIntentContentProviders(
|
||||
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "moe.matsuri.lite")), flags
|
||||
)
|
||||
.filter { it.providerInfo.exported }
|
||||
}
|
||||
if (providers.isEmpty()) {
|
||||
providers = AngApplication.application.packageManager.queryIntentContentProviders(
|
||||
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "fr.husi")), flags
|
||||
)
|
||||
.filter { it.providerInfo.exported }
|
||||
}
|
||||
if (providers.isEmpty()) {
|
||||
providers = AngApplication.application.packageManager.queryIntentContentProviders(
|
||||
Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA
|
||||
).filter {
|
||||
it.providerInfo.exported &&
|
||||
it.providerInfo.metaData.containsKey(METADATA_KEY_ID) &&
|
||||
it.providerInfo.metaData.getString(METADATA_KEY_ID) == pluginId
|
||||
}
|
||||
if (providers.size > 1) {
|
||||
providers = listOf(providers[0]) // What if there is more than one?
|
||||
}
|
||||
}
|
||||
if (providers.isEmpty()) return null
|
||||
if (providers.size > 1) {
|
||||
val message =
|
||||
"Conflicting plugins found from: ${providers.joinToString { it.providerInfo.packageName }}"
|
||||
Toast.makeText(AngApplication.application, message, Toast.LENGTH_LONG).show()
|
||||
throw IllegalStateException(message)
|
||||
}
|
||||
val provider = providers.single().providerInfo
|
||||
var failure: Throwable? = null
|
||||
try {
|
||||
initNativeFaster(provider)?.also { return InitResult(it) }
|
||||
} catch (t: Throwable) {
|
||||
// Logs.w("Initializing native plugin faster mode failed")
|
||||
failure = t
|
||||
}
|
||||
|
||||
val uri = Uri.Builder().apply {
|
||||
scheme(ContentResolver.SCHEME_CONTENT)
|
||||
authority(provider.authority)
|
||||
}.build()
|
||||
try {
|
||||
return initNativeFast(
|
||||
AngApplication.application.contentResolver,
|
||||
pluginId,
|
||||
uri
|
||||
)?.let { InitResult(it) }
|
||||
} catch (t: Throwable) {
|
||||
// Logs.w("Initializing native plugin fast mode failed")
|
||||
failure?.also { t.addSuppressed(it) }
|
||||
failure = t
|
||||
}
|
||||
|
||||
try {
|
||||
return initNativeSlow(
|
||||
AngApplication.application.contentResolver,
|
||||
pluginId,
|
||||
uri
|
||||
)?.let { InitResult(it) }
|
||||
} catch (t: Throwable) {
|
||||
failure?.also { t.addSuppressed(it) }
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
private fun initNativeFaster(provider: ProviderInfo): String? {
|
||||
return provider.loadString(PluginContract.METADATA_KEY_EXECUTABLE_PATH)
|
||||
?.let { relativePath ->
|
||||
File(provider.applicationInfo.nativeLibraryDir).resolve(relativePath).apply {
|
||||
check(canExecute())
|
||||
}.absolutePath
|
||||
}
|
||||
}
|
||||
|
||||
private fun initNativeFast(cr: ContentResolver, pluginId: String, uri: Uri): String? {
|
||||
return cr.call(uri, PluginContract.METHOD_GET_EXECUTABLE, null, bundleOf())
|
||||
?.getString(PluginContract.EXTRA_ENTRY)?.also {
|
||||
check(File(it).canExecute())
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
private fun initNativeSlow(cr: ContentResolver, pluginId: String, uri: Uri): String? {
|
||||
var initialized = false
|
||||
fun entryNotFound(): Nothing =
|
||||
throw IndexOutOfBoundsException("Plugin entry binary not found")
|
||||
|
||||
val pluginDir = File(AngApplication.application.noBackupFilesDir, "plugin")
|
||||
(cr.query(
|
||||
uri,
|
||||
arrayOf(PluginContract.COLUMN_PATH, PluginContract.COLUMN_MODE),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
?: return null).use { cursor ->
|
||||
if (!cursor.moveToFirst()) entryNotFound()
|
||||
pluginDir.deleteRecursively()
|
||||
if (!pluginDir.mkdirs()) throw FileNotFoundException("Unable to create plugin directory")
|
||||
val pluginDirPath = pluginDir.absolutePath + '/'
|
||||
do {
|
||||
val path = cursor.getString(0)
|
||||
val file = File(pluginDir, path)
|
||||
check(file.absolutePath.startsWith(pluginDirPath))
|
||||
cr.openInputStream(uri.buildUpon().path(path).build())!!.use { inStream ->
|
||||
file.outputStream().use { outStream -> inStream.copyTo(outStream) }
|
||||
}
|
||||
Os.chmod(
|
||||
file.absolutePath, when (cursor.getType(1)) {
|
||||
Cursor.FIELD_TYPE_INTEGER -> cursor.getInt(1)
|
||||
Cursor.FIELD_TYPE_STRING -> cursor.getString(1).toInt(8)
|
||||
else -> throw IllegalArgumentException("File mode should be of type int")
|
||||
}
|
||||
)
|
||||
if (path == pluginId) initialized = true
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
if (!initialized) entryNotFound()
|
||||
return File(pluginDir, pluginId).absolutePath
|
||||
}
|
||||
|
||||
fun ComponentInfo.loadString(key: String) = when (val value = metaData.getString(key)) {
|
||||
is String -> value
|
||||
is Int -> AngApplication.application.packageManager.getResourcesForApplication(applicationInfo)
|
||||
.getString(value)
|
||||
|
||||
null -> null
|
||||
else -> error("meta-data $key has invalid type ${value.javaClass}")
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/******************************************************************************
|
||||
* *
|
||||
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
|
||||
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
|
||||
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* This program is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
******************************************************************************/
|
||||
|
||||
package com.v2ray.ang.plugin
|
||||
|
||||
import android.content.pm.ComponentInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ResolveInfo
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import com.v2ray.ang.AngApplication
|
||||
import com.v2ray.ang.plugin.PluginManager.loadString
|
||||
|
||||
abstract class ResolvedPlugin(protected val resolveInfo: ResolveInfo) : Plugin() {
|
||||
protected abstract val componentInfo: ComponentInfo
|
||||
|
||||
override val id by lazy { componentInfo.loadString(PluginContract.METADATA_KEY_ID)!! }
|
||||
override val version by lazy {
|
||||
getPackageInfo(componentInfo.packageName).versionCode
|
||||
}
|
||||
override val versionName: String by lazy {
|
||||
getPackageInfo(componentInfo.packageName).versionName!!
|
||||
}
|
||||
override val label: CharSequence get() = resolveInfo.loadLabel(AngApplication.application.packageManager)
|
||||
override val icon: Drawable get() = resolveInfo.loadIcon(AngApplication.application.packageManager)
|
||||
override val packageName: String get() = componentInfo.packageName
|
||||
override val directBootAware get() = Build.VERSION.SDK_INT < 24 || componentInfo.directBootAware
|
||||
|
||||
fun getPackageInfo(packageName: String) = AngApplication.application.packageManager.getPackageInfo(
|
||||
packageName, if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES
|
||||
else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
|
||||
)!!
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.service.V2RayServiceManager
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.text.TextUtils
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.service.V2RayServiceManager
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
|
||||
class TaskerReceiver : BroadcastReceiver() {
|
||||
|
||||
@@ -35,7 +35,7 @@ class TaskerReceiver : BroadcastReceiver() {
|
||||
V2RayServiceManager.stopVService(context)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
android.util.Log.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@ import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.widget.RemoteViews
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.service.V2RayServiceManager
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
|
||||
class WidgetProvider : AppWidgetProvider() {
|
||||
/**
|
||||
@@ -42,11 +41,7 @@ class WidgetProvider : AppWidgetProvider() {
|
||||
context,
|
||||
R.id.layout_switch,
|
||||
intent,
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
remoteViews.setOnClickPendingIntent(R.id.layout_switch, pendingIntent)
|
||||
if (isRunning) {
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.v2ray.ang.service
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.AppConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -16,7 +16,7 @@ class ProcessService {
|
||||
* @param cmd The command to run.
|
||||
*/
|
||||
fun runProcess(context: Context, cmd: MutableList<String>) {
|
||||
Log.d(ANG_PACKAGE, cmd.toString())
|
||||
Log.i(AppConfig.TAG, cmd.toString())
|
||||
|
||||
try {
|
||||
val proBuilder = ProcessBuilder(cmd)
|
||||
@@ -27,14 +27,14 @@ class ProcessService {
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
Thread.sleep(50L)
|
||||
Log.d(ANG_PACKAGE, "runProcess check")
|
||||
Log.i(AppConfig.TAG, "runProcess check")
|
||||
process?.waitFor()
|
||||
Log.d(ANG_PACKAGE, "runProcess exited")
|
||||
Log.i(AppConfig.TAG, "runProcess exited")
|
||||
}
|
||||
Log.d(ANG_PACKAGE, process.toString())
|
||||
Log.i(AppConfig.TAG, process.toString())
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.d(ANG_PACKAGE, e.toString())
|
||||
Log.e(AppConfig.TAG, e.toString(), e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,10 @@ class ProcessService {
|
||||
*/
|
||||
fun stopProcess() {
|
||||
try {
|
||||
Log.d(ANG_PACKAGE, "runProcess destroy")
|
||||
Log.i(AppConfig.TAG, "runProcess destroy")
|
||||
process?.destroy()
|
||||
} catch (e: Exception) {
|
||||
Log.d(ANG_PACKAGE, e.toString())
|
||||
Log.e(AppConfig.TAG, "Failed to destroy process", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.lang.ref.SoftReference
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.N)
|
||||
class QSTileService : TileService() {
|
||||
|
||||
/**
|
||||
@@ -24,14 +23,13 @@ class QSTileService : TileService() {
|
||||
* @param state The state to set.
|
||||
*/
|
||||
fun setState(state: Int) {
|
||||
qsTile?.icon = Icon.createWithResource(applicationContext, R.drawable.ic_stat_name)
|
||||
if (state == Tile.STATE_INACTIVE) {
|
||||
qsTile?.state = Tile.STATE_INACTIVE
|
||||
qsTile?.label = getString(R.string.app_name)
|
||||
qsTile?.icon = Icon.createWithResource(applicationContext, R.drawable.ic_stat_name)
|
||||
} else if (state == Tile.STATE_ACTIVE) {
|
||||
qsTile?.state = Tile.STATE_ACTIVE
|
||||
qsTile?.label = V2RayServiceManager.getRunningServerName()
|
||||
qsTile?.icon = Icon.createWithResource(applicationContext, R.drawable.ic_stat_name)
|
||||
}
|
||||
|
||||
qsTile?.updateTile()
|
||||
@@ -44,7 +42,11 @@ class QSTileService : TileService() {
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
|
||||
setState(Tile.STATE_INACTIVE)
|
||||
if (V2RayServiceManager.isRunning()) {
|
||||
setState(Tile.STATE_ACTIVE)
|
||||
} else {
|
||||
setState(Tile.STATE_INACTIVE)
|
||||
}
|
||||
mMsgReceive = ReceiveMessageHandler(this)
|
||||
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_ACTIVITY)
|
||||
ContextCompat.registerReceiver(applicationContext, mMsgReceive, mFilter, Utils.receiverFlags())
|
||||
@@ -61,7 +63,7 @@ class QSTileService : TileService() {
|
||||
applicationContext.unregisterReceiver(mMsgReceive)
|
||||
mMsgReceive = null
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to unregister receiver", e)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.content.Context
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.V2RayNativeManager
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineName
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Worker that runs a batch of real-ping tests independently.
|
||||
* Each batch owns its own CoroutineScope/dispatcher and can be cancelled separately.
|
||||
*/
|
||||
class RealPingWorkerService(
|
||||
private val context: Context,
|
||||
private val guids: List<String>,
|
||||
private val onFinish: (status: String) -> Unit = {}
|
||||
) {
|
||||
private val job = SupervisorJob()
|
||||
private val cpu = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
|
||||
private val dispatcher = Executors.newFixedThreadPool(cpu * 4).asCoroutineDispatcher()
|
||||
private val scope = CoroutineScope(job + dispatcher + CoroutineName("RealPingBatchWorker"))
|
||||
|
||||
private val runningCount = AtomicInteger(0)
|
||||
private val totalCount = AtomicInteger(0)
|
||||
|
||||
fun start() {
|
||||
val jobs = guids.map { guid ->
|
||||
totalCount.incrementAndGet()
|
||||
scope.launch {
|
||||
runningCount.incrementAndGet()
|
||||
try {
|
||||
val result = startRealPing(guid)
|
||||
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
|
||||
} finally {
|
||||
val count = totalCount.decrementAndGet()
|
||||
val left = runningCount.decrementAndGet()
|
||||
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $count")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
joinAll(*jobs.toTypedArray())
|
||||
onFinish("0")
|
||||
} catch (_: CancellationException) {
|
||||
onFinish("-1")
|
||||
} finally {
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
private fun close() {
|
||||
try {
|
||||
dispatcher.close()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRealPing(guid: String): Long {
|
||||
val retFailure = -1L
|
||||
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
|
||||
if (!configResult.status) {
|
||||
return retFailure
|
||||
}
|
||||
return V2RayNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.content.Context
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Manages the tun2socks process that handles VPN traffic
|
||||
*/
|
||||
class TProxyService(
|
||||
private val context: Context,
|
||||
private val vpnInterface: ParcelFileDescriptor,
|
||||
private val isRunningProvider: () -> Boolean,
|
||||
private val restartCallback: () -> Unit
|
||||
) : Tun2SocksControl {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Suppress("FunctionName")
|
||||
private external fun TProxyStartService(configPath: String, fd: Int)
|
||||
@JvmStatic
|
||||
@Suppress("FunctionName")
|
||||
private external fun TProxyStopService()
|
||||
@JvmStatic
|
||||
@Suppress("FunctionName")
|
||||
private external fun TProxyGetStats(): LongArray?
|
||||
|
||||
init {
|
||||
System.loadLibrary("hev-socks5-tunnel")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the tun2socks process with the appropriate parameters.
|
||||
*/
|
||||
override fun startTun2Socks() {
|
||||
// Log.i(AppConfig.TAG, "Starting HevSocks5Tunnel via JNI")
|
||||
|
||||
val configContent = buildConfig()
|
||||
val configFile = File(context.filesDir, "hev-socks5-tunnel.yaml").apply {
|
||||
writeText(configContent)
|
||||
}
|
||||
// Log.i(AppConfig.TAG, "Config file created: ${configFile.absolutePath}")
|
||||
Log.d(AppConfig.TAG, "HevSocks5Tunnel Config content:\n$configContent")
|
||||
|
||||
try {
|
||||
// Log.i(AppConfig.TAG, "TProxyStartService...")
|
||||
TProxyStartService(configFile.absolutePath, vpnInterface.fd)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "HevSocks5Tunnel exception: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildConfig(): String {
|
||||
val socksPort = SettingsManager.getSocksPort()
|
||||
val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
|
||||
return buildString {
|
||||
appendLine("tunnel:")
|
||||
appendLine(" mtu: ${SettingsManager.getVpnMtu()}")
|
||||
appendLine(" ipv4: ${vpnConfig.ipv4Client}")
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6)) {
|
||||
appendLine(" ipv6: '${vpnConfig.ipv6Client}'")
|
||||
}
|
||||
|
||||
appendLine("socks5:")
|
||||
appendLine(" port: ${socksPort}")
|
||||
appendLine(" address: ${AppConfig.LOOPBACK}")
|
||||
appendLine(" udp: 'udp'")
|
||||
|
||||
// Read-write timeout settings
|
||||
val timeoutSetting = MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) ?: AppConfig.HEVTUN_RW_TIMEOUT
|
||||
val parts = timeoutSetting.split(",")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
val tcpTimeout = parts.getOrNull(0)?.toIntOrNull() ?: 300
|
||||
val udpTimeout = parts.getOrNull(1)?.toIntOrNull() ?: 60
|
||||
|
||||
appendLine("misc:")
|
||||
appendLine(" tcp-read-write-timeout: ${tcpTimeout * 1000}")
|
||||
appendLine(" udp-read-write-timeout: ${udpTimeout * 1000}")
|
||||
appendLine(" log-level: ${MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) ?: "warn"}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the tun2socks process
|
||||
*/
|
||||
override fun stopTun2Socks() {
|
||||
try {
|
||||
Log.i(AppConfig.TAG, "TProxyStopService...")
|
||||
TProxyStopService()
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to stop hev-socks5-tunnel", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
/**
|
||||
* Interface that defines the control operations for tun2socks implementations.
|
||||
*
|
||||
* This interface is implemented by different tunnel solutions like:
|
||||
*/
|
||||
interface Tun2SocksControl {
|
||||
/**
|
||||
* Starts the tun2socks process with the appropriate parameters.
|
||||
* This initializes the VPN tunnel and connects it to the SOCKS proxy.
|
||||
*/
|
||||
fun startTun2Socks()
|
||||
|
||||
/**
|
||||
* Stops the tun2socks process and cleans up resources.
|
||||
*/
|
||||
fun stopTun2Socks()
|
||||
}
|
||||
@@ -3,10 +3,9 @@ package com.v2ray.ang.service
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
import com.v2ray.ang.util.MyContextWrapper
|
||||
import java.lang.ref.SoftReference
|
||||
|
||||
@@ -27,7 +26,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
|
||||
* @return The start mode.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
V2RayServiceManager.startV2rayPoint()
|
||||
V2RayServiceManager.startCoreLoop(null)
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
@@ -36,7 +35,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
V2RayServiceManager.stopV2rayPoint()
|
||||
V2RayServiceManager.stopCoreLoop()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +82,6 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
|
||||
* Attaches the base context to the service.
|
||||
* @param newBase The new base context.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
val context = newBase?.let {
|
||||
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
|
||||
|
||||
@@ -3,60 +3,25 @@ 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.AppConfig.MSG_MEASURE_CONFIG_SUCCESS
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.extension.serializable
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SpeedtestManager
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.handler.V2RayNativeManager
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
import com.v2ray.ang.util.PluginUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import go.Seq
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.cancelChildren
|
||||
import kotlinx.coroutines.launch
|
||||
import libv2ray.Libv2ray
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.Collections
|
||||
|
||||
class V2RayTestService : Service() {
|
||||
private val realTestScope by lazy { CoroutineScope(Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()).asCoroutineDispatcher()) }
|
||||
|
||||
// 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()
|
||||
Seq.setContext(this)
|
||||
Libv2ray.initV2Env(Utils.userAssetPath(this), Utils.getDeviceIdForXUDPBaseKey())
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 guid = intent.serializable<String>("content") ?: ""
|
||||
realTestScope.launch {
|
||||
val result = startRealPing(guid)
|
||||
MessageUtil.sendMsg2UI(this@V2RayTestService, MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
|
||||
}
|
||||
}
|
||||
|
||||
MSG_MEASURE_CONFIG_CANCEL -> {
|
||||
realTestScope.coroutineContext[Job]?.cancelChildren()
|
||||
}
|
||||
}
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
V2RayNativeManager.initCoreEnv(this)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,23 +34,46 @@ class V2RayTestService : Service() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the real ping test.
|
||||
* @param guid The GUID of the configuration.
|
||||
* @return The ping result.
|
||||
* Cleans up resources when the service is destroyed.
|
||||
*/
|
||||
private fun startRealPing(guid: String): Long {
|
||||
val retFailure = -1L
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return retFailure
|
||||
if (config.configType == EConfigType.HYSTERIA2) {
|
||||
val delay = PluginUtil.realPingHy2(this, config)
|
||||
return delay
|
||||
} else {
|
||||
val config = V2rayConfigManager.getV2rayConfig(this, guid)
|
||||
if (!config.status) {
|
||||
return retFailure
|
||||
}
|
||||
return SpeedtestManager.realPing(config.content)
|
||||
}
|
||||
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,8 +5,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LocalSocket
|
||||
import android.net.LocalSocketAddress
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
@@ -18,33 +16,20 @@ import android.os.StrictMode
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.AppConfig.LOOPBACK
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.NotificationManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
import com.v2ray.ang.util.MyContextWrapper
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.lang.ref.SoftReference
|
||||
|
||||
class V2RayVpnService : VpnService(), ServiceControl {
|
||||
companion object {
|
||||
private const val VPN_MTU = 1500
|
||||
private const val PRIVATE_VLAN4_CLIENT = "10.10.14.1"
|
||||
private const val PRIVATE_VLAN4_ROUTER = "10.10.14.2"
|
||||
private const val PRIVATE_VLAN6_CLIENT = "fc00::10:10:14:1"
|
||||
private const val PRIVATE_VLAN6_ROUTER = "fc00::10:10:14:2"
|
||||
private const val TUN2SOCKS = "libtun2socks.so"
|
||||
}
|
||||
|
||||
private lateinit var mInterface: ParcelFileDescriptor
|
||||
private var isRunning = false
|
||||
private lateinit var process: Process
|
||||
private var tun2SocksService: Tun2SocksControl? = null
|
||||
|
||||
/**destroy
|
||||
* Unfortunately registerDefaultNetworkCallback is going to return our VPN interface: https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e
|
||||
@@ -101,11 +86,12 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
NotificationService.cancelNotification()
|
||||
NotificationManager.cancelNotification()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
V2RayServiceManager.startV2rayPoint()
|
||||
setupVpnService()
|
||||
startService()
|
||||
return START_STICKY
|
||||
//return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
@@ -115,7 +101,11 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
}
|
||||
|
||||
override fun startService() {
|
||||
setup()
|
||||
if (mInterface == null) {
|
||||
Log.e(AppConfig.TAG, "Failed to create VPN interface")
|
||||
return
|
||||
}
|
||||
V2RayServiceManager.startCoreLoop(mInterface)
|
||||
}
|
||||
|
||||
override fun stopService() {
|
||||
@@ -126,7 +116,6 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
return protect(socket)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
val context = newBase?.let {
|
||||
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
|
||||
@@ -138,13 +127,13 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
* Sets up the VPN service.
|
||||
* Prepares the VPN and configures it if preparation is successful.
|
||||
*/
|
||||
private fun setup() {
|
||||
private fun setupVpnService() {
|
||||
val prepare = prepare(this)
|
||||
if (prepare != null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (setupVpnService() != true) {
|
||||
if (configureVpnService() != true) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -155,18 +144,54 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
* Configures the VPN service.
|
||||
* @return True if the VPN service was configured successfully, false otherwise.
|
||||
*/
|
||||
private fun setupVpnService(): Boolean {
|
||||
// If the old interface has exactly the same parameters, use it!
|
||||
// Configure a builder while parsing the parameters.
|
||||
private fun configureVpnService(): Boolean {
|
||||
val builder = Builder()
|
||||
//val enableLocalDns = defaultDPreference.getPrefBoolean(AppConfig.PREF_LOCAL_DNS_ENABLED, false)
|
||||
|
||||
builder.setMtu(VPN_MTU)
|
||||
builder.addAddress(PRIVATE_VLAN4_CLIENT, 30)
|
||||
//builder.addDnsServer(PRIVATE_VLAN4_ROUTER)
|
||||
// Configure network settings (addresses, routing and DNS)
|
||||
configureNetworkSettings(builder)
|
||||
|
||||
// Configure app-specific settings (session name and per-app proxy)
|
||||
configurePerAppProxy(builder)
|
||||
|
||||
// Close the old interface since the parameters have been changed
|
||||
try {
|
||||
mInterface.close()
|
||||
} catch (ignored: Exception) {
|
||||
// ignored
|
||||
}
|
||||
|
||||
// Configure platform-specific features
|
||||
configurePlatformFeatures(builder)
|
||||
|
||||
// Create a new interface using the builder and save the parameters
|
||||
try {
|
||||
mInterface = builder.establish()!!
|
||||
isRunning = true
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to establish VPN interface", e)
|
||||
stopV2Ray()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the basic network settings for the VPN.
|
||||
* This includes IP addresses, routing rules, and DNS servers.
|
||||
*
|
||||
* @param builder The VPN Builder to configure
|
||||
*/
|
||||
private fun configureNetworkSettings(builder: Builder) {
|
||||
val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
|
||||
val bypassLan = SettingsManager.routingRulesetsBypassLan()
|
||||
|
||||
// Configure IPv4 settings
|
||||
builder.setMtu(SettingsManager.getVpnMtu())
|
||||
builder.addAddress(vpnConfig.ipv4Client, 30)
|
||||
|
||||
// Configure routing rules
|
||||
if (bypassLan) {
|
||||
resources.getStringArray(R.array.bypass_private_ip_address).forEach {
|
||||
AppConfig.ROUTED_IP_LIST.forEach {
|
||||
val addr = it.split('/')
|
||||
builder.addRoute(addr[0], addr[1].toInt())
|
||||
}
|
||||
@@ -174,81 +199,97 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
builder.addRoute("0.0.0.0", 0)
|
||||
}
|
||||
|
||||
// Configure IPv6 if enabled
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true) {
|
||||
builder.addAddress(PRIVATE_VLAN6_CLIENT, 126)
|
||||
builder.addAddress(vpnConfig.ipv6Client, 126)
|
||||
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
|
||||
builder.addRoute("fc00::", 18) // Xray-core default FakeIPv6 Pool
|
||||
} else {
|
||||
builder.addRoute("::", 0)
|
||||
}
|
||||
}
|
||||
|
||||
// if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
|
||||
// builder.addDnsServer(PRIVATE_VLAN4_ROUTER)
|
||||
// } else {
|
||||
SettingsManager.getVpnDnsServers()
|
||||
.forEach {
|
||||
if (Utils.isPureIpAddress(it)) {
|
||||
builder.addDnsServer(it)
|
||||
}
|
||||
// Configure DNS servers
|
||||
//if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
|
||||
// builder.addDnsServer(PRIVATE_VLAN4_ROUTER)
|
||||
//} else {
|
||||
SettingsManager.getVpnDnsServers().forEach {
|
||||
if (Utils.isPureIpAddress(it)) {
|
||||
builder.addDnsServer(it)
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
builder.setSession(V2RayServiceManager.getRunningServerName())
|
||||
}
|
||||
|
||||
val selfPackageName = BuildConfig.APPLICATION_ID
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY)) {
|
||||
val apps = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)
|
||||
val bypassApps = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS)
|
||||
//process self package
|
||||
if (bypassApps) apps?.add(selfPackageName) else apps?.remove(selfPackageName)
|
||||
apps?.forEach {
|
||||
try {
|
||||
if (bypassApps)
|
||||
builder.addDisallowedApplication(it)
|
||||
else
|
||||
builder.addAllowedApplication(it)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
Log.d(ANG_PACKAGE, "setup error : --${e.localizedMessage}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
builder.addDisallowedApplication(selfPackageName)
|
||||
}
|
||||
|
||||
// Close the old interface since the parameters have been changed.
|
||||
try {
|
||||
mInterface.close()
|
||||
} catch (ignored: Exception) {
|
||||
// ignored
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures platform-specific VPN features for different Android versions.
|
||||
*
|
||||
* @param builder The VPN Builder to configure
|
||||
*/
|
||||
private fun configurePlatformFeatures(builder: Builder) {
|
||||
// Android P (API 28) and above: Configure network callbacks
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
try {
|
||||
connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to request default network", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Android Q (API 29) and above: Configure metering and HTTP proxy
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
builder.setMetered(false)
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_APPEND_HTTP_PROXY)) {
|
||||
builder.setHttpProxy(ProxyInfo.buildDirectProxy(LOOPBACK, SettingsManager.getHttpPort()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new interface using the builder and save the parameters.
|
||||
try {
|
||||
mInterface = builder.establish()!!
|
||||
isRunning = true
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
// non-nullable lateinit var
|
||||
e.printStackTrace()
|
||||
stopV2Ray()
|
||||
/**
|
||||
* Configures per-app proxy rules for the VPN builder.
|
||||
*
|
||||
* - If per-app proxy is not enabled, disallow the VPN service's own package.
|
||||
* - If no apps are selected, disallow the VPN service's own package.
|
||||
* - If bypass mode is enabled, disallow all selected apps (including self).
|
||||
* - If proxy mode is enabled, only allow the selected apps (excluding self).
|
||||
*
|
||||
* @param builder The VPN Builder to configure.
|
||||
*/
|
||||
private fun configurePerAppProxy(builder: Builder) {
|
||||
val selfPackageName = BuildConfig.APPLICATION_ID
|
||||
|
||||
// If per-app proxy is not enabled, disallow the VPN service's own package and return
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY) == false) {
|
||||
builder.addDisallowedApplication(selfPackageName)
|
||||
return
|
||||
}
|
||||
|
||||
// If no apps are selected, disallow the VPN service's own package and return
|
||||
val apps = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)
|
||||
if (apps.isNullOrEmpty()) {
|
||||
builder.addDisallowedApplication(selfPackageName)
|
||||
return
|
||||
}
|
||||
|
||||
val bypassApps = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS)
|
||||
// Handle the VPN service's own package according to the mode
|
||||
if (bypassApps) apps.add(selfPackageName) else apps.remove(selfPackageName)
|
||||
|
||||
apps.forEach {
|
||||
try {
|
||||
if (bypassApps) {
|
||||
// In bypass mode, disallow the selected apps
|
||||
builder.addDisallowedApplication(it)
|
||||
} else {
|
||||
// In proxy mode, only allow the selected apps
|
||||
builder.addAllowedApplication(it)
|
||||
}
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
Log.e(AppConfig.TAG, "Failed to configure app in VPN: ${e.localizedMessage}", e)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,78 +297,18 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
* Starts the tun2socks process with the appropriate parameters.
|
||||
*/
|
||||
private fun runTun2socks() {
|
||||
val socksPort = SettingsManager.getSocksPort()
|
||||
val cmd = arrayListOf(
|
||||
File(applicationContext.applicationInfo.nativeLibraryDir, TUN2SOCKS).absolutePath,
|
||||
"--netif-ipaddr", PRIVATE_VLAN4_ROUTER,
|
||||
"--netif-netmask", "255.255.255.252",
|
||||
"--socks-server-addr", "$LOOPBACK:${socksPort}",
|
||||
"--tunmtu", VPN_MTU.toString(),
|
||||
"--sock-path", "sock_path",//File(applicationContext.filesDir, "sock_path").absolutePath,
|
||||
"--enable-udprelay",
|
||||
"--loglevel", "notice"
|
||||
)
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6)) {
|
||||
cmd.add("--netif-ip6addr")
|
||||
cmd.add(PRIVATE_VLAN6_ROUTER)
|
||||
if (SettingsManager.isUsingHevTun()) {
|
||||
tun2SocksService = TProxyService(
|
||||
context = applicationContext,
|
||||
vpnInterface = mInterface,
|
||||
isRunningProvider = { isRunning },
|
||||
restartCallback = { runTun2socks() }
|
||||
)
|
||||
} else {
|
||||
tun2SocksService = null
|
||||
}
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED)) {
|
||||
val localDnsPort = Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_LOCAL_DNS_PORT), AppConfig.PORT_LOCAL_DNS.toInt())
|
||||
cmd.add("--dnsgw")
|
||||
cmd.add("$LOOPBACK:${localDnsPort}")
|
||||
}
|
||||
Log.d(packageName, cmd.toString())
|
||||
|
||||
try {
|
||||
val proBuilder = ProcessBuilder(cmd)
|
||||
proBuilder.redirectErrorStream(true)
|
||||
process = proBuilder
|
||||
.directory(applicationContext.filesDir)
|
||||
.start()
|
||||
Thread {
|
||||
Log.d(packageName, "$TUN2SOCKS check")
|
||||
process.waitFor()
|
||||
Log.d(packageName, "$TUN2SOCKS exited")
|
||||
if (isRunning) {
|
||||
Log.d(packageName, "$TUN2SOCKS restart")
|
||||
runTun2socks()
|
||||
}
|
||||
}.start()
|
||||
Log.d(packageName, process.toString())
|
||||
|
||||
sendFd()
|
||||
} catch (e: Exception) {
|
||||
Log.d(packageName, e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the file descriptor to the tun2socks process.
|
||||
* Attempts to send the file descriptor multiple times if necessary.
|
||||
*/
|
||||
private fun sendFd() {
|
||||
val fd = mInterface.fileDescriptor
|
||||
val path = File(applicationContext.filesDir, "sock_path").absolutePath
|
||||
Log.d(packageName, path)
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
var tries = 0
|
||||
while (true) try {
|
||||
Thread.sleep(50L shl tries)
|
||||
Log.d(packageName, "sendFd tries: $tries")
|
||||
LocalSocket().use { localSocket ->
|
||||
localSocket.connect(LocalSocketAddress(path, LocalSocketAddress.Namespace.FILESYSTEM))
|
||||
localSocket.setFileDescriptorsForSend(arrayOf(fd))
|
||||
localSocket.outputStream.write(42)
|
||||
}
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
Log.d(packageName, e.toString())
|
||||
if (tries > 5) break
|
||||
tries += 1
|
||||
}
|
||||
}
|
||||
tun2SocksService?.startTun2Socks()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,14 +329,10 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Log.d(packageName, "tun2socks destroy")
|
||||
process.destroy()
|
||||
} catch (e: Exception) {
|
||||
Log.d(packageName, e.toString())
|
||||
}
|
||||
tun2SocksService?.stopTun2Socks()
|
||||
tun2SocksService = null
|
||||
|
||||
V2RayServiceManager.stopV2rayPoint()
|
||||
V2RayServiceManager.stopCoreLoop()
|
||||
|
||||
if (isForced) {
|
||||
//stopSelf has to be called ahead of mInterface.close(). otherwise v2ray core cannot be stooped
|
||||
@@ -367,9 +344,10 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
|
||||
try {
|
||||
mInterface.close()
|
||||
} catch (ignored: Exception) {
|
||||
// ignored
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to close VPN interface", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,110 +1,31 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityAboutBinding
|
||||
import com.v2ray.ang.handler.SpeedtestManager
|
||||
import com.v2ray.ang.handler.V2RayNativeManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.util.ZipUtil
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class AboutActivity : BaseActivity() {
|
||||
|
||||
private val binding by lazy { ActivityAboutBinding.inflate(layoutInflater) }
|
||||
private val extDir by lazy { File(Utils.backupPath(this)) }
|
||||
|
||||
private val requestPermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
|
||||
if (isGranted) {
|
||||
try {
|
||||
showFileChooser()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
} else {
|
||||
toast(R.string.toast_permission_denied)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
|
||||
title = getString(R.string.title_about)
|
||||
|
||||
binding.tvBackupSummary.text = this.getString(R.string.summary_configuration_backup, extDir)
|
||||
|
||||
binding.layoutBackup.setOnClickListener {
|
||||
val ret = backupConfiguration(extDir.absolutePath)
|
||||
if (ret.first) {
|
||||
toast(R.string.toast_success)
|
||||
} else {
|
||||
toast(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
|
||||
binding.layoutShare.setOnClickListener {
|
||||
val ret = backupConfiguration(cacheDir.absolutePath)
|
||||
if (ret.first) {
|
||||
startActivity(
|
||||
Intent.createChooser(
|
||||
Intent(Intent.ACTION_SEND).setType("application/zip")
|
||||
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.putExtra(
|
||||
Intent.EXTRA_STREAM,
|
||||
FileProvider.getUriForFile(
|
||||
this, BuildConfig.APPLICATION_ID + ".cache", File(ret.second)
|
||||
)
|
||||
), getString(R.string.title_configuration_share)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
toast(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
|
||||
binding.layoutRestore.setOnClickListener {
|
||||
val permission =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, permission) == android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
try {
|
||||
showFileChooser()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
} else {
|
||||
requestPermissionLauncher.launch(permission)
|
||||
}
|
||||
}
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_about))
|
||||
|
||||
binding.layoutSoureCcode.setOnClickListener {
|
||||
Utils.openUri(this, AppConfig.v2rayNGUrl)
|
||||
Utils.openUri(this, AppConfig.APP_URL)
|
||||
}
|
||||
|
||||
binding.layoutFeedback.setOnClickListener {
|
||||
Utils.openUri(this, AppConfig.v2rayNGIssues)
|
||||
Utils.openUri(this, AppConfig.APP_ISSUES_URL)
|
||||
}
|
||||
|
||||
binding.layoutOssLicenses.setOnClickListener {
|
||||
val webView = android.webkit.WebView(this);
|
||||
val webView = android.webkit.WebView(this)
|
||||
webView.loadUrl("file:///android_asset/open_source_licenses.html")
|
||||
android.app.AlertDialog.Builder(this)
|
||||
.setTitle("Open source licenses")
|
||||
@@ -114,89 +35,18 @@ class AboutActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
binding.layoutTgChannel.setOnClickListener {
|
||||
Utils.openUri(this, AppConfig.TgChannelUrl)
|
||||
Utils.openUri(this, AppConfig.TG_CHANNEL_URL)
|
||||
}
|
||||
|
||||
binding.layoutPrivacyPolicy.setOnClickListener {
|
||||
Utils.openUri(this, AppConfig.v2rayNGPrivacyPolicy)
|
||||
Utils.openUri(this, AppConfig.APP_PRIVACY_POLICY)
|
||||
}
|
||||
|
||||
"v${BuildConfig.VERSION_NAME} (${SpeedtestManager.getLibVersion()})".also {
|
||||
"v${BuildConfig.VERSION_NAME} (${V2RayNativeManager.getLibVersion()})".also {
|
||||
binding.tvVersion.text = it
|
||||
}
|
||||
}
|
||||
|
||||
fun backupConfiguration(outputZipFilePos: String): Pair<Boolean, String> {
|
||||
val dateFormated = SimpleDateFormat(
|
||||
"yyyy-MM-dd-HH-mm-ss",
|
||||
Locale.getDefault()
|
||||
).format(System.currentTimeMillis())
|
||||
val folderName = "${getString(R.string.app_name)}_${dateFormated}"
|
||||
val backupDir = this.cacheDir.absolutePath + "/$folderName"
|
||||
val outputZipFilePath = "$outputZipFilePos/$folderName.zip"
|
||||
|
||||
val count = MMKV.backupAllToDirectory(backupDir)
|
||||
if (count <= 0) {
|
||||
return Pair(false, "")
|
||||
BuildConfig.APPLICATION_ID.also {
|
||||
binding.tvAppId.text = it
|
||||
}
|
||||
|
||||
if (ZipUtil.zipFromFolder(backupDir, outputZipFilePath)) {
|
||||
return Pair(true, outputZipFilePath)
|
||||
} else {
|
||||
return Pair(false, "")
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreConfiguration(zipFile: File): Boolean {
|
||||
val backupDir = this.cacheDir.absolutePath + "/${System.currentTimeMillis()}"
|
||||
|
||||
if (!ZipUtil.unzipToFolder(zipFile, backupDir)) {
|
||||
return false
|
||||
}
|
||||
|
||||
val count = MMKV.restoreAllFromDirectory(backupDir)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
private fun showFileChooser() {
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||
type = "*/*"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
|
||||
try {
|
||||
chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
|
||||
} catch (ex: android.content.ActivityNotFoundException) {
|
||||
Log.e(AppConfig.ANG_PACKAGE, "File chooser activity not found: ${ex.message}", ex)
|
||||
toast(R.string.toast_require_file_manager)
|
||||
}
|
||||
}
|
||||
|
||||
private val chooseFile =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val uri = result.data?.data
|
||||
if (result.resultCode == RESULT_OK && uri != null) {
|
||||
try {
|
||||
val targetFile =
|
||||
File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
|
||||
contentResolver.openInputStream(uri).use { input ->
|
||||
targetFile.outputStream().use { fileOut ->
|
||||
input?.copyTo(fileOut)
|
||||
}
|
||||
}
|
||||
if (restoreConfiguration(targetFile)) {
|
||||
toast(R.string.toast_success)
|
||||
} else {
|
||||
toast(R.string.toast_failure)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.ANG_PACKAGE, "Error during file restore: ${e.message}", e)
|
||||
toast(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toast(messageResId: Int) {
|
||||
Toast.makeText(this, getString(messageResId), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.AlertDialog
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.WEBDAV_BACKUP_FILE_NAME
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityBackupBinding
|
||||
import com.v2ray.ang.databinding.DialogWebdavBinding
|
||||
import com.v2ray.ang.dto.WebDavConfig
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.WebDavManager
|
||||
import com.v2ray.ang.util.ZipUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class BackupActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivityBackupBinding.inflate(layoutInflater) }
|
||||
|
||||
private val config_backup_options: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.config_backup_options)
|
||||
}
|
||||
|
||||
private val requestPermissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
|
||||
if (isGranted) {
|
||||
try {
|
||||
showFileChooser()
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to show file chooser", e)
|
||||
}
|
||||
} else {
|
||||
toast(R.string.toast_permission_denied)
|
||||
}
|
||||
}
|
||||
|
||||
private val createBackupFile =
|
||||
registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
|
||||
if (uri != null) {
|
||||
try {
|
||||
val ret = backupConfigurationToCache()
|
||||
if (ret.first) {
|
||||
// Copy the cached zip file to user-selected location
|
||||
contentResolver.openOutputStream(uri)?.use { output ->
|
||||
File(ret.second).inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
// Clean up cache file
|
||||
File(ret.second).delete()
|
||||
toastSuccess(R.string.toast_success)
|
||||
} else {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to backup configuration", e)
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_configuration_backup_restore))
|
||||
|
||||
binding.layoutBackup.setOnClickListener {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.title_configuration_backup)
|
||||
.setItems(config_backup_options) { dialog, which ->
|
||||
when (which) {
|
||||
0 -> backupViaLocal()
|
||||
1 -> backupViaWebDav()
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
binding.layoutShare.setOnClickListener {
|
||||
val ret = backupConfigurationToCache()
|
||||
if (ret.first) {
|
||||
startActivity(
|
||||
Intent.createChooser(
|
||||
Intent(Intent.ACTION_SEND).setType("application/zip")
|
||||
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.putExtra(
|
||||
Intent.EXTRA_STREAM,
|
||||
FileProvider.getUriForFile(
|
||||
this, BuildConfig.APPLICATION_ID + ".cache", File(ret.second)
|
||||
)
|
||||
), getString(R.string.title_configuration_share)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
|
||||
binding.layoutRestore.setOnClickListener {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.title_configuration_restore)
|
||||
.setItems(config_backup_options) { dialog, which ->
|
||||
when (which) {
|
||||
0 -> restoreViaLocal()
|
||||
1 -> restoreViaWebDav()
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
binding.layoutWebdavConfigSetting.setOnClickListener {
|
||||
showWebDavSettingsDialog()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup configuration to cache directory
|
||||
* Returns Pair<success, zipFilePath>
|
||||
*/
|
||||
private fun backupConfigurationToCache(): Pair<Boolean, String> {
|
||||
val dateFormatted = SimpleDateFormat(
|
||||
"yyyy-MM-dd-HH-mm-ss",
|
||||
Locale.getDefault()
|
||||
).format(System.currentTimeMillis())
|
||||
val folderName = "${getString(R.string.app_name)}_${dateFormatted}"
|
||||
val backupDir = this.cacheDir.absolutePath + "/$folderName"
|
||||
val outputZipFilePath = "${this.cacheDir.absolutePath}/$folderName.zip"
|
||||
|
||||
val count = MMKV.backupAllToDirectory(backupDir)
|
||||
if (count <= 0) {
|
||||
return Pair(false, "")
|
||||
}
|
||||
|
||||
if (ZipUtil.zipFromFolder(backupDir, outputZipFilePath)) {
|
||||
return Pair(true, outputZipFilePath)
|
||||
} else {
|
||||
return Pair(false, "")
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreConfiguration(zipFile: File): Boolean {
|
||||
val backupDir = this.cacheDir.absolutePath + "/${System.currentTimeMillis()}"
|
||||
|
||||
if (!ZipUtil.unzipToFolder(zipFile, backupDir)) {
|
||||
return false
|
||||
}
|
||||
|
||||
val count = MMKV.restoreAllFromDirectory(backupDir)
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
SettingsChangeManager.makeRestartService()
|
||||
return count > 0
|
||||
}
|
||||
|
||||
private fun showFileChooser() {
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||
type = "*/*"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
|
||||
try {
|
||||
chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
|
||||
} catch (ex: ActivityNotFoundException) {
|
||||
Log.e(AppConfig.TAG, "File chooser activity not found", ex)
|
||||
toast(R.string.toast_require_file_manager)
|
||||
}
|
||||
}
|
||||
|
||||
private val chooseFile =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val uri = result.data?.data
|
||||
if (result.resultCode == RESULT_OK && uri != null) {
|
||||
try {
|
||||
val targetFile =
|
||||
File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
|
||||
contentResolver.openInputStream(uri).use { input ->
|
||||
targetFile.outputStream().use { fileOut ->
|
||||
input?.copyTo(fileOut)
|
||||
}
|
||||
}
|
||||
if (restoreConfiguration(targetFile)) {
|
||||
toastSuccess(R.string.toast_success)
|
||||
} else {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Error during file restore", e)
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun backupViaLocal() {
|
||||
val dateFormatted = SimpleDateFormat(
|
||||
"yyyy-MM-dd-HH-mm-ss",
|
||||
Locale.getDefault()
|
||||
).format(System.currentTimeMillis())
|
||||
val defaultFileName = "${getString(R.string.app_name)}_${dateFormatted}.zip"
|
||||
createBackupFile.launch(defaultFileName)
|
||||
}
|
||||
|
||||
private fun restoreViaLocal() {
|
||||
val permission =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
|
||||
try {
|
||||
showFileChooser()
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to show file chooser", e)
|
||||
}
|
||||
} else {
|
||||
requestPermissionLauncher.launch(permission)
|
||||
}
|
||||
}
|
||||
|
||||
private fun backupViaWebDav() {
|
||||
val saved = MmkvManager.decodeWebDavConfig()
|
||||
if (saved == null || saved.baseUrl.isEmpty()) {
|
||||
toastError(R.string.title_webdav_config_setting_unknown)
|
||||
return
|
||||
}
|
||||
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
var tempFile: File? = null
|
||||
try {
|
||||
val ret = backupConfigurationToCache()
|
||||
if (!ret.first) {
|
||||
withContext(Dispatchers.Main) {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
tempFile = File(ret.second)
|
||||
WebDavManager.init(saved)
|
||||
|
||||
val ok = try {
|
||||
WebDavManager.uploadFile(tempFile, WEBDAV_BACKUP_FILE_NAME)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "WebDAV upload error", e)
|
||||
false
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (ok) toastSuccess(R.string.toast_success) else toastError(R.string.toast_failure)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "WebDAV backup error", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
tempFile?.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreViaWebDav() {
|
||||
val saved = MmkvManager.decodeWebDavConfig()
|
||||
if (saved == null || saved.baseUrl.isEmpty()) {
|
||||
toastError(R.string.title_webdav_config_setting_unknown)
|
||||
return
|
||||
}
|
||||
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
var target: File? = null
|
||||
try {
|
||||
target = File(cacheDir, "download_${System.currentTimeMillis()}.zip")
|
||||
WebDavManager.init(saved)
|
||||
val ok = WebDavManager.downloadFile(WEBDAV_BACKUP_FILE_NAME, target)
|
||||
if (!ok) {
|
||||
withContext(Dispatchers.Main) {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
val restored = restoreConfiguration(target)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (restored) {
|
||||
toastSuccess(R.string.toast_success)
|
||||
} else {
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "WebDAV download error", e)
|
||||
withContext(Dispatchers.Main) { toastError(R.string.toast_failure) }
|
||||
} finally {
|
||||
try {
|
||||
target?.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showWebDavSettingsDialog() {
|
||||
val dialogBinding = DialogWebdavBinding.inflate(layoutInflater)
|
||||
|
||||
MmkvManager.decodeWebDavConfig()?.let { cfg ->
|
||||
dialogBinding.etWebdavUrl.setText(cfg.baseUrl)
|
||||
dialogBinding.etWebdavUser.setText(cfg.username ?: "")
|
||||
dialogBinding.etWebdavPass.setText(cfg.password ?: "")
|
||||
dialogBinding.etWebdavRemotePath.setText(cfg.remoteBasePath ?: "/")
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.title_webdav_config_setting)
|
||||
.setView(dialogBinding.root)
|
||||
.setPositiveButton(R.string.menu_item_save_config) { _, _ ->
|
||||
val url = dialogBinding.etWebdavUrl.text.toString().trim()
|
||||
val user = dialogBinding.etWebdavUser.text.toString().trim().ifEmpty { null }
|
||||
val pass = dialogBinding.etWebdavPass.text.toString()
|
||||
val remotePath = dialogBinding.etWebdavRemotePath.text.toString().trim().ifEmpty { AppConfig.WEBDAV_BACKUP_DIR }
|
||||
val cfg = WebDavConfig(baseUrl = url, username = user, password = pass, remoteBasePath = remotePath)
|
||||
MmkvManager.encodeWebDavConfig(cfg)
|
||||
toastSuccess(R.string.toast_success)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,42 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.annotation.RequiresApi
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.helper.CustomDividerItemDecoration
|
||||
import com.v2ray.ang.util.MyContextWrapper
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
|
||||
/**
|
||||
* BaseActivity provides common helpers and UI wiring used across the app's activities.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Inflate a shared base layout that contains a toolbar and a content container.
|
||||
* - Provide convenient overloads of `setContentViewWithToolbar` to attach child layouts or
|
||||
* view-binding roots into the base container and initialize the toolbar.
|
||||
* - Expose a global in-layout `ProgressBar` (cached) with `showLoading()` / `hideLoading()` helpers.
|
||||
* - Provide a helper to add a custom divider to RecyclerViews.
|
||||
* - Wrap base context according to user locale settings.
|
||||
*/
|
||||
abstract class BaseActivity : AppCompatActivity() {
|
||||
// Progress indicator that sits at the bottom of the toolbar
|
||||
private var progressBar: LinearProgressIndicator? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
@@ -27,6 +47,15 @@ abstract class BaseActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle action bar item selections.
|
||||
*
|
||||
* Currently this handles the home/up button by delegating to the activity's
|
||||
* onBackPressedDispatcher to provide consistent back navigation behavior.
|
||||
*
|
||||
* @param item the selected menu item
|
||||
* @return true if the event was handled, otherwise delegates to the superclass
|
||||
*/
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
// Handles the home button press by delegating to the onBackPressedDispatcher.
|
||||
@@ -38,20 +67,31 @@ abstract class BaseActivity : AppCompatActivity() {
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
/**
|
||||
* Wrap the base context with the user's locale settings.
|
||||
*
|
||||
* This ensures resources are loaded using the configured locale.
|
||||
*
|
||||
* @param newBase the original base context to wrap
|
||||
*/
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(MyContextWrapper.wrap(newBase ?: return, SettingsManager.getLocale()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a custom divider to a RecyclerView.
|
||||
* Adds a custom divider drawable to the provided RecyclerView.
|
||||
*
|
||||
* @param recyclerView The target RecyclerView to which the divider will be added.
|
||||
* @param context The context used to access resources.
|
||||
* @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).
|
||||
* This is a convenience helper that constructs a [CustomDividerItemDecoration]
|
||||
* using the given drawable resource id and adds it to the RecyclerView.
|
||||
*
|
||||
* @param recyclerView the target RecyclerView
|
||||
* @param context the context used to resolve resources (may be activity or application context)
|
||||
* @param drawableResId the drawable resource id to use as the divider
|
||||
* @param orientation one of [DividerItemDecoration.VERTICAL] or [DividerItemDecoration.HORIZONTAL]
|
||||
*
|
||||
* @throws IllegalArgumentException if the drawable resource cannot be found
|
||||
*/
|
||||
fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, context: Context?, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
|
||||
protected fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, context: Context?, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
|
||||
// Get the drawable from resources
|
||||
val drawable = ContextCompat.getDrawable(context!!, drawableResId)
|
||||
requireNotNull(drawable) { "Drawable resource not found" }
|
||||
@@ -62,4 +102,115 @@ abstract class BaseActivity : AppCompatActivity() {
|
||||
// Add the divider to the RecyclerView
|
||||
recyclerView.addItemDecoration(dividerItemDecoration)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the toolbar instance using the default toolbar id if null is passed.
|
||||
*
|
||||
* This helper will set the toolbar as the action bar and configure the up button
|
||||
* visibility plus optional title.
|
||||
*
|
||||
* @param toolbar the toolbar instance to configure (may be null, in which case the view
|
||||
* with id R.id.toolbar in the activity content will be used)
|
||||
* @param showHomeAsUp whether the home/up affordance should be shown (default true)
|
||||
* @param title optional title to set on the activity
|
||||
*/
|
||||
protected fun setupToolbar(toolbar: Toolbar?, showHomeAsUp: Boolean = true, title: CharSequence? = null) {
|
||||
val tb = toolbar ?: findViewById<Toolbar?>(R.id.toolbar)
|
||||
tb?.let {
|
||||
setSupportActionBar(it)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
|
||||
title?.let { t -> this.title = t }
|
||||
}
|
||||
progressBar = findViewById(R.id.progress_bar)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inflate the shared base layout, attach the child layout resource into the base
|
||||
* content container, cache the in-layout ProgressBar and configure the toolbar.
|
||||
*
|
||||
* Typical usage in subclasses:
|
||||
* setContentViewWithToolbar(R.layout.activity_settings, showHomeAsUp = true, title = "Settings")
|
||||
*
|
||||
* @param layoutResId child layout resource to inflate into the base content container
|
||||
* @param showHomeAsUp whether to show the up/home affordance on the toolbar (default true)
|
||||
* @param title optional activity title to set on the toolbar
|
||||
*/
|
||||
protected fun setContentViewWithToolbar(layoutResId: Int, showHomeAsUp: Boolean = true, title: CharSequence? = null) {
|
||||
val base = LayoutInflater.from(this).inflate(R.layout.activity_base, null)
|
||||
val container = base.findViewById<FrameLayout>(R.id.content_container)
|
||||
LayoutInflater.from(this).inflate(layoutResId, container, true)
|
||||
progressBar = base.findViewById(R.id.progress_bar)
|
||||
super.setContentView(base)
|
||||
setupToolbar(base, showHomeAsUp, title)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inflate the shared base layout, attach the provided child view (commonly a view-binding root)
|
||||
* into the base content container, cache the in-layout ProgressBar and configure the toolbar.
|
||||
*
|
||||
* Typical usage with view binding:
|
||||
* setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = "...")
|
||||
*
|
||||
* @param childView the already-inflated child view to add to the base content container
|
||||
* @param showHomeAsUp whether to show the up/home affordance on the toolbar (default true)
|
||||
* @param title optional activity title to set on the toolbar
|
||||
*/
|
||||
protected fun setContentViewWithToolbar(childView: View, showHomeAsUp: Boolean = true, title: CharSequence? = null) {
|
||||
val base = LayoutInflater.from(this).inflate(R.layout.activity_base, null)
|
||||
val container = base.findViewById<FrameLayout>(R.id.content_container)
|
||||
container.addView(childView, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
progressBar = base.findViewById(R.id.progress_bar)
|
||||
super.setContentView(base)
|
||||
setupToolbar(base, showHomeAsUp, title)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper that configures the MaterialToolbar found in the inflated base root.
|
||||
*
|
||||
* @param baseRoot the root view of the inflated base layout
|
||||
* @param showHomeAsUp whether to show the up/home affordance
|
||||
* @param title optional title to set on the support action bar
|
||||
*/
|
||||
private fun setupToolbar(baseRoot: View, showHomeAsUp: Boolean, title: CharSequence?) {
|
||||
val toolbar = baseRoot.findViewById<MaterialToolbar>(R.id.toolbar)
|
||||
toolbar?.let {
|
||||
setSupportActionBar(it)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
|
||||
title?.let { t -> supportActionBar?.title = t }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the base layout's ProgressBar.
|
||||
*
|
||||
* This method is safe to call from background threads; the visibility change will
|
||||
* be posted to the UI thread via [runOnUiThread]. If the base layout was not set yet
|
||||
* (progressBar == null) the call is a no-op.
|
||||
*/
|
||||
protected fun showLoading() {
|
||||
runOnUiThread {
|
||||
progressBar?.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the base layout's ProgressBar.
|
||||
*
|
||||
* Safe to call from background threads. No-op if the progress bar hasn't been cached.
|
||||
*/
|
||||
protected fun hideLoading() {
|
||||
runOnUiThread {
|
||||
progressBar?.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the base ProgressBar is currently visible.
|
||||
*
|
||||
* @return true if the progress bar exists and its visibility is VISIBLE
|
||||
*/
|
||||
protected fun isLoadingVisible(): Boolean {
|
||||
return progressBar?.visibility == View.VISIBLE
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.v2ray.ang.helper.CustomDividerItemDecoration
|
||||
|
||||
abstract class BaseFragment<VB : ViewBinding> : Fragment() {
|
||||
private var _binding: VB? = null
|
||||
protected val binding: VB
|
||||
get() = _binding!!
|
||||
|
||||
protected abstract fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?): VB
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = inflateBinding(inflater, container)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a custom divider to a RecyclerView.
|
||||
*
|
||||
* @param recyclerView The target RecyclerView to which the divider will be added.
|
||||
* @param drawableResId The resource ID of the drawable to be used as the divider.
|
||||
* @param orientation The orientation of the divider (DividerItemDecoration.VERTICAL or DividerItemDecoration.HORIZONTAL).
|
||||
*/
|
||||
fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
|
||||
// Get the drawable from resources
|
||||
val drawable = ContextCompat.getDrawable(requireContext(), drawableResId)
|
||||
requireNotNull(drawable) { "Drawable resource not found" }
|
||||
|
||||
// Create a DividerItemDecoration with the specified orientation
|
||||
val dividerItemDecoration = CustomDividerItemDecoration(drawable, orientation)
|
||||
|
||||
// Add the divider to the RecyclerView
|
||||
recyclerView.addItemDecoration(dividerItemDecoration)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityCheckUpdateBinding
|
||||
import com.v2ray.ang.dto.CheckUpdateResult
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.UpdateCheckerManager
|
||||
import com.v2ray.ang.handler.V2RayNativeManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CheckUpdateActivity : BaseActivity() {
|
||||
|
||||
private val binding by lazy { ActivityCheckUpdateBinding.inflate(layoutInflater) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.update_check_for_update))
|
||||
|
||||
binding.layoutCheckUpdate.setOnClickListener {
|
||||
checkForUpdates(binding.checkPreRelease.isChecked)
|
||||
}
|
||||
|
||||
binding.checkPreRelease.setOnCheckedChangeListener { _, isChecked ->
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, isChecked)
|
||||
}
|
||||
binding.checkPreRelease.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, false)
|
||||
|
||||
"v${BuildConfig.VERSION_NAME} (${V2RayNativeManager.getLibVersion()})".also {
|
||||
binding.tvVersion.text = it
|
||||
}
|
||||
|
||||
checkForUpdates(binding.checkPreRelease.isChecked)
|
||||
}
|
||||
|
||||
private fun checkForUpdates(includePreRelease: Boolean) {
|
||||
toast(R.string.update_checking_for_update)
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val result = UpdateCheckerManager.checkForUpdate(includePreRelease)
|
||||
if (result.hasUpdate) {
|
||||
showUpdateDialog(result)
|
||||
} else {
|
||||
toastSuccess(R.string.update_already_latest_version)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to check for updates: ${e.message}")
|
||||
toastError(e.message ?: getString(R.string.toast_failure))
|
||||
}
|
||||
finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showUpdateDialog(result: CheckUpdateResult) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(getString(R.string.update_new_version_found, result.latestVersion))
|
||||
.setMessage(result.releaseNotes)
|
||||
.setPositiveButton(R.string.update_now) { _, _ ->
|
||||
result.downloadUrl?.let {
|
||||
Utils.openUri(this, it)
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
|
||||
class FragmentAdapter(fragmentActivity: FragmentActivity, private val mFragments: List<Fragment>) :
|
||||
FragmentStateAdapter(fragmentActivity) {
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return mFragments[position]
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return mFragments.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.v2ray.ang.dto.GroupMapItem
|
||||
|
||||
/**
|
||||
* Pager adapter for subscription groups.
|
||||
*/
|
||||
class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) {
|
||||
override fun getItemCount(): Int = groups.size
|
||||
override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id)
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun update(groups: List<GroupMapItem>) {
|
||||
this.groups = groups
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.TAG
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.FragmentGroupServerBinding
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||
import com.v2ray.ang.viewmodel.MainViewModel
|
||||
|
||||
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
||||
private val mainViewModel: MainViewModel by activityViewModels()
|
||||
private lateinit var adapter: MainRecyclerAdapter
|
||||
private var itemTouchHelper: ItemTouchHelper? = null
|
||||
private val subId: String by lazy { arguments?.getString(ARG_SUB_ID).orEmpty() }
|
||||
|
||||
companion object {
|
||||
private const val ARG_SUB_ID = "subscriptionId"
|
||||
fun newInstance(subId: String) = GroupServerFragment().apply {
|
||||
arguments = Bundle().apply { putString(ARG_SUB_ID, subId) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?) =
|
||||
FragmentGroupServerBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
adapter = MainRecyclerAdapter(requireActivity() as MainActivity)
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
|
||||
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
|
||||
} else {
|
||||
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 1)
|
||||
}
|
||||
addCustomDividerToRecyclerView(binding.recyclerView, R.drawable.custom_divider)
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
|
||||
itemTouchHelper?.attachToRecyclerView(binding.recyclerView)
|
||||
|
||||
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
|
||||
if (mainViewModel.subscriptionId != subId) {
|
||||
return@observe
|
||||
}
|
||||
Log.d(TAG, "GroupServerFragment updateListAction subId=$subId")
|
||||
adapter.setData(mainViewModel.serversCache, index)
|
||||
}
|
||||
mainViewModel.isRunning.observe(viewLifecycleOwner) { isRunning ->
|
||||
adapter.isRunning = isRunning
|
||||
}
|
||||
|
||||
Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
mainViewModel.subscriptionIdChanged(subId)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityLogcatBinding
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -21,15 +24,14 @@ import java.io.IOException
|
||||
class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
||||
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
|
||||
|
||||
var logsetsAll: MutableList<String> = mutableListOf()
|
||||
private var logsetsAll: MutableList<String> = mutableListOf()
|
||||
var logsets: MutableList<String> = mutableListOf()
|
||||
private val adapter by lazy { LogcatRecyclerAdapter(this) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
|
||||
title = getString(R.string.title_logcat)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_logcat))
|
||||
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
@@ -53,7 +55,7 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
||||
lst.add("-v")
|
||||
lst.add("time")
|
||||
lst.add("-s")
|
||||
lst.add("GoLog,tun2socks,${ANG_PACKAGE},AndroidRuntime,System.err")
|
||||
lst.add("GoLog,${ANG_PACKAGE},AndroidRuntime,System.err")
|
||||
val process = withContext(Dispatchers.IO) {
|
||||
Runtime.getRuntime().exec(lst.toTypedArray())
|
||||
}
|
||||
@@ -62,12 +64,12 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
||||
launch(Dispatchers.Main) {
|
||||
logsetsAll = allText.toMutableList()
|
||||
logsets = allText.toMutableList()
|
||||
adapter.notifyDataSetChanged()
|
||||
refreshData()
|
||||
binding.refreshLayout.isRefreshing = false
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to get logcat", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,11 +86,11 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
||||
launch(Dispatchers.Main) {
|
||||
logsetsAll.clear()
|
||||
logsets.clear()
|
||||
adapter.notifyDataSetChanged()
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to clear logcat", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +120,7 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.copy_all -> {
|
||||
Utils.setClipboard(this, logsets.joinToString("\n"))
|
||||
toast(R.string.toast_success)
|
||||
toastSuccess(R.string.toast_success)
|
||||
true
|
||||
}
|
||||
|
||||
@@ -138,11 +140,16 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
||||
logsetsAll.filter { it.contains(key) }.toMutableList()
|
||||
}
|
||||
|
||||
adapter?.notifyDataSetChanged()
|
||||
refreshData()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
getLogcat()
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun refreshData() {
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
class LogcatRecyclerAdapter(val activity: LogcatActivity) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() {
|
||||
private var mActivity: LogcatActivity = activity
|
||||
|
||||
|
||||
override fun getItemCount() = mActivity.logsets.size
|
||||
|
||||
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
|
||||
@@ -21,8 +25,13 @@ class LogcatRecyclerAdapter(val activity: LogcatActivity) : RecyclerView.Adapter
|
||||
holder.itemSubSettingBinding.logTag.text = content.first().split("(", limit = 2).first().trim()
|
||||
holder.itemSubSettingBinding.logContent.text = if (content.count() > 1) content.last().trim() else ""
|
||||
}
|
||||
|
||||
holder.itemView.setOnLongClickListener {
|
||||
Utils.setClipboard(mActivity, log)
|
||||
true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Error binding log view data", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.net.Uri
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
@@ -21,21 +22,19 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.navigation.NavigationView
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.VPN
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityMainBinding
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MigrateManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||
import com.v2ray.ang.service.V2RayServiceManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.MainViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -48,31 +47,23 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
ActivityMainBinding.inflate(layoutInflater)
|
||||
}
|
||||
|
||||
private val adapter by lazy { MainRecyclerAdapter(this) }
|
||||
val mainViewModel: MainViewModel by viewModels()
|
||||
private lateinit var groupPagerAdapter: GroupPagerAdapter
|
||||
private var tabMediator: TabLayoutMediator? = null
|
||||
|
||||
private val requestVpnPermission = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == RESULT_OK) {
|
||||
startV2Ray()
|
||||
}
|
||||
}
|
||||
private val requestSubSettingActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
initGroupTab()
|
||||
}
|
||||
private val tabGroupListener = object : TabLayout.OnTabSelectedListener {
|
||||
override fun onTabSelected(tab: TabLayout.Tab?) {
|
||||
val selectId = tab?.tag.toString()
|
||||
if (selectId != mainViewModel.subscriptionId) {
|
||||
mainViewModel.subscriptionIdChanged(selectId)
|
||||
}
|
||||
private val requestActivityLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (SettingsChangeManager.consumeRestartService() && mainViewModel.isRunning.value == true) {
|
||||
restartV2Ray()
|
||||
}
|
||||
|
||||
override fun onTabUnselected(tab: TabLayout.Tab?) {
|
||||
}
|
||||
|
||||
override fun onTabReselected(tab: TabLayout.Tab?) {
|
||||
if (SettingsChangeManager.consumeSetupGroupTab()) {
|
||||
setupGroupTab()
|
||||
}
|
||||
}
|
||||
private var mItemTouchHelper: ItemTouchHelper? = null
|
||||
val mainViewModel: MainViewModel by viewModels()
|
||||
|
||||
// register activity result for requesting permission
|
||||
private val requestPermissionLauncher =
|
||||
@@ -84,9 +75,6 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
Action.IMPORT_QR_CODE_CONFIG ->
|
||||
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java))
|
||||
|
||||
// Action.IMPORT_QR_CODE_URL ->
|
||||
// scanQRCodeForUrlToCustomConfig.launch(Intent(this, ScannerActivity::class.java))
|
||||
|
||||
Action.READ_CONTENT_FROM_URI ->
|
||||
chooseFileForCustomConfig.launch(Intent.createChooser(Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||
type = "*/*"
|
||||
@@ -107,8 +95,6 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
enum class Action {
|
||||
NONE,
|
||||
IMPORT_QR_CODE_CONFIG,
|
||||
|
||||
//IMPORT_QR_CODE_URL,
|
||||
READ_CONTENT_FROM_URI,
|
||||
POST_NOTIFICATIONS
|
||||
}
|
||||
@@ -126,67 +112,23 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
}
|
||||
}
|
||||
|
||||
// private val scanQRCodeForUrlToCustomConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
// if (it.resultCode == RESULT_OK) {
|
||||
// importConfigCustomUrl(it.data?.getStringExtra("SCAN_RESULT"))
|
||||
// }
|
||||
// }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
title = getString(R.string.title_server)
|
||||
setSupportActionBar(binding.toolbar)
|
||||
setupToolbar(binding.toolbar, false, getString(R.string.title_server))
|
||||
|
||||
binding.fab.setOnClickListener {
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
V2RayServiceManager.stopVService(this)
|
||||
} else if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: VPN) == VPN) {
|
||||
val intent = VpnService.prepare(this)
|
||||
if (intent == null) {
|
||||
startV2Ray()
|
||||
} else {
|
||||
requestVpnPermission.launch(intent)
|
||||
}
|
||||
} else {
|
||||
startV2Ray()
|
||||
}
|
||||
}
|
||||
binding.layoutTest.setOnClickListener {
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
setTestState(getString(R.string.connection_test_testing))
|
||||
mainViewModel.testCurrentServerRealPing()
|
||||
} else {
|
||||
// tv_test_state.text = getString(R.string.connection_test_fail)
|
||||
}
|
||||
}
|
||||
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
|
||||
mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
|
||||
// setup viewpager and tablayout
|
||||
groupPagerAdapter = GroupPagerAdapter(this, emptyList())
|
||||
binding.viewPager.adapter = groupPagerAdapter
|
||||
binding.viewPager.isUserInputEnabled = true
|
||||
|
||||
// setup navigation drawer
|
||||
val toggle = ActionBarDrawerToggle(
|
||||
this, binding.drawerLayout, binding.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close
|
||||
)
|
||||
binding.drawerLayout.addDrawerListener(toggle)
|
||||
toggle.syncState()
|
||||
binding.navView.setNavigationItemSelectedListener(this)
|
||||
|
||||
initGroupTab()
|
||||
setupViewModel()
|
||||
migrateLegacy()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
pendingAction = Action.POST_NOTIFICATIONS
|
||||
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
|
||||
@@ -198,74 +140,76 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
binding.fab.setOnClickListener { handleFabAction() }
|
||||
binding.layoutTest.setOnClickListener { handleLayoutTestClick() }
|
||||
|
||||
setupGroupTab()
|
||||
setupViewModel()
|
||||
mainViewModel.reloadServerList()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
pendingAction = Action.POST_NOTIFICATIONS
|
||||
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupViewModel() {
|
||||
mainViewModel.updateListAction.observe(this) { index ->
|
||||
if (index >= 0) {
|
||||
adapter.notifyItemChanged(index)
|
||||
} else {
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
mainViewModel.updateTestResultAction.observe(this) { setTestState(it) }
|
||||
mainViewModel.isRunning.observe(this) { isRunning ->
|
||||
adapter.isRunning = isRunning
|
||||
if (isRunning) {
|
||||
binding.fab.setImageResource(R.drawable.ic_stop_24dp)
|
||||
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
|
||||
setTestState(getString(R.string.connection_connected))
|
||||
binding.layoutTest.isFocusable = true
|
||||
} else {
|
||||
binding.fab.setImageResource(R.drawable.ic_play_24dp)
|
||||
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
|
||||
setTestState(getString(R.string.connection_not_connected))
|
||||
binding.layoutTest.isFocusable = false
|
||||
}
|
||||
applyRunningState(false, isRunning)
|
||||
}
|
||||
mainViewModel.startListenBroadcast()
|
||||
mainViewModel.initAssets(assets)
|
||||
}
|
||||
|
||||
private fun migrateLegacy() {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = MigrateManager.migrateServerConfig2Profile()
|
||||
launch(Dispatchers.Main) {
|
||||
if (result) {
|
||||
toast(getString(R.string.migration_success))
|
||||
mainViewModel.reloadServerList()
|
||||
} else {
|
||||
//toast(getString(R.string.migration_fail))
|
||||
}
|
||||
private fun setupGroupTab() {
|
||||
val groups = mainViewModel.getSubscriptions(this)
|
||||
groupPagerAdapter.update(groups)
|
||||
|
||||
tabMediator?.detach()
|
||||
tabMediator = TabLayoutMediator(binding.tabGroup, binding.viewPager) { tab, position ->
|
||||
groupPagerAdapter.groups.getOrNull(position)?.let {
|
||||
tab.text = it.remarks
|
||||
tab.tag = it.id
|
||||
}
|
||||
}.also { it.attach() }
|
||||
|
||||
val targetIndex = groups.indexOfFirst { it.id == mainViewModel.subscriptionId }.takeIf { it >= 0 } ?: (groups.size - 1)
|
||||
binding.viewPager.setCurrentItem(targetIndex, false)
|
||||
|
||||
binding.tabGroup.isVisible = groups.size > 1
|
||||
}
|
||||
|
||||
private fun handleFabAction() {
|
||||
applyRunningState(isLoading = true, isRunning = false)
|
||||
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
V2RayServiceManager.stopVService(this)
|
||||
} else if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: VPN) == VPN) {
|
||||
val intent = VpnService.prepare(this)
|
||||
if (intent == null) {
|
||||
startV2Ray()
|
||||
} else {
|
||||
requestVpnPermission.launch(intent)
|
||||
}
|
||||
} else {
|
||||
startV2Ray()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initGroupTab() {
|
||||
binding.tabGroup.removeOnTabSelectedListener(tabGroupListener)
|
||||
binding.tabGroup.removeAllTabs()
|
||||
binding.tabGroup.isVisible = false
|
||||
|
||||
val (listId, listRemarks) = mainViewModel.getSubscriptions(this)
|
||||
if (listId == null || listRemarks == null) {
|
||||
return
|
||||
private fun handleLayoutTestClick() {
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
setTestState(getString(R.string.connection_test_testing))
|
||||
mainViewModel.testCurrentServerRealPing()
|
||||
} else {
|
||||
// service not running: keep existing no-op (could show a message if desired)
|
||||
}
|
||||
|
||||
for (it in listRemarks.indices) {
|
||||
val tab = binding.tabGroup.newTab()
|
||||
tab.text = listRemarks[it]
|
||||
tab.tag = listId[it]
|
||||
binding.tabGroup.addTab(tab)
|
||||
}
|
||||
val selectIndex =
|
||||
listId.indexOf(mainViewModel.subscriptionId).takeIf { it >= 0 } ?: (listId.count() - 1)
|
||||
binding.tabGroup.selectTab(binding.tabGroup.getTabAt(selectIndex))
|
||||
binding.tabGroup.addOnTabSelectedListener(tabGroupListener)
|
||||
binding.tabGroup.isVisible = true
|
||||
}
|
||||
|
||||
fun startV2Ray() {
|
||||
private fun startV2Ray() {
|
||||
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
|
||||
toast(R.string.title_file_chooser)
|
||||
return
|
||||
@@ -283,12 +227,36 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
}
|
||||
}
|
||||
|
||||
public override fun onResume() {
|
||||
super.onResume()
|
||||
mainViewModel.reloadServerList()
|
||||
private fun setTestState(content: String?) {
|
||||
binding.tvTestState.text = content
|
||||
}
|
||||
|
||||
public override fun onPause() {
|
||||
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
|
||||
if (isLoading) {
|
||||
binding.fab.setImageResource(R.drawable.ic_fab_check)
|
||||
return
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
binding.fab.setImageResource(R.drawable.ic_stop_24dp)
|
||||
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
|
||||
binding.fab.contentDescription = getString(R.string.action_stop_service)
|
||||
setTestState(getString(R.string.connection_connected))
|
||||
binding.layoutTest.isFocusable = true
|
||||
} else {
|
||||
binding.fab.setImageResource(R.drawable.ic_play_24dp)
|
||||
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
|
||||
binding.fab.contentDescription = getString(R.string.tasker_start_service)
|
||||
setTestState(getString(R.string.connection_not_connected))
|
||||
binding.layoutTest.isFocusable = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
@@ -317,7 +285,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.import_qrcode -> {
|
||||
importQRcode(true)
|
||||
importQRcode()
|
||||
true
|
||||
}
|
||||
|
||||
@@ -331,6 +299,11 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
true
|
||||
}
|
||||
|
||||
R.id.import_manually_policy_group -> {
|
||||
importManually(EConfigType.POLICYGROUP.value)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.import_manually_vmess -> {
|
||||
importManually(EConfigType.VMESS.value)
|
||||
true
|
||||
@@ -371,26 +344,6 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
true
|
||||
}
|
||||
|
||||
// R.id.import_config_custom_clipboard -> {
|
||||
// importConfigCustomClipboard()
|
||||
// true
|
||||
// }
|
||||
//
|
||||
// R.id.import_config_custom_local -> {
|
||||
// importConfigCustomLocal()
|
||||
// true
|
||||
// }
|
||||
//
|
||||
// R.id.import_config_custom_url -> {
|
||||
// importConfigCustomUrlClipboard()
|
||||
// true
|
||||
// }
|
||||
//
|
||||
// R.id.import_config_custom_url_scan -> {
|
||||
// importQRcode(false)
|
||||
// true
|
||||
// }
|
||||
|
||||
R.id.export_all -> {
|
||||
exportAll()
|
||||
true
|
||||
@@ -443,27 +396,31 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
}
|
||||
|
||||
private fun importManually(createConfigType: Int) {
|
||||
startActivity(
|
||||
Intent()
|
||||
.putExtra("createConfigType", createConfigType)
|
||||
.putExtra("subscriptionId", mainViewModel.subscriptionId)
|
||||
.setClass(this, ServerActivity::class.java)
|
||||
)
|
||||
if (createConfigType == EConfigType.POLICYGROUP.value) {
|
||||
startActivity(
|
||||
Intent()
|
||||
.putExtra("subscriptionId", mainViewModel.subscriptionId)
|
||||
.setClass(this, ServerGroupActivity::class.java)
|
||||
)
|
||||
} else {
|
||||
startActivity(
|
||||
Intent()
|
||||
.putExtra("createConfigType", createConfigType)
|
||||
.putExtra("subscriptionId", mainViewModel.subscriptionId)
|
||||
.setClass(this, ServerActivity::class.java)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* import config from qrcode
|
||||
*/
|
||||
private fun importQRcode(forConfig: Boolean): Boolean {
|
||||
private fun importQRcode(): Boolean {
|
||||
val permission = Manifest.permission.CAMERA
|
||||
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
|
||||
if (forConfig) {
|
||||
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java))
|
||||
} else {
|
||||
//scanQRCodeForUrlToCustomConfig.launch(Intent(this, ScannerActivity::class.java))
|
||||
}
|
||||
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java))
|
||||
} else {
|
||||
pendingAction = Action.IMPORT_QR_CODE_CONFIG//if (forConfig) Action.IMPORT_QR_CODE_CONFIG else Action.IMPORT_QR_CODE_URL
|
||||
pendingAction = Action.IMPORT_QR_CODE_CONFIG
|
||||
requestPermissionLauncher.launch(permission)
|
||||
}
|
||||
return true
|
||||
@@ -478,14 +435,14 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
val clipboard = Utils.getClipboard(this)
|
||||
importBatchConfig(clipboard)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to import config from clipboard", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun importBatchConfig(server: String?) {
|
||||
binding.pbWaiting.show()
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -498,108 +455,40 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
mainViewModel.reloadServerList()
|
||||
}
|
||||
|
||||
countSub > 0 -> initGroupTab()
|
||||
else -> toast(R.string.toast_failure)
|
||||
countSub > 0 -> setupGroupTab()
|
||||
else -> toastError(R.string.toast_failure)
|
||||
}
|
||||
binding.pbWaiting.hide()
|
||||
hideLoading()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
toast(R.string.toast_failure)
|
||||
binding.pbWaiting.hide()
|
||||
toastError(R.string.toast_failure)
|
||||
hideLoading()
|
||||
}
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to import batch config", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* import config from local config file
|
||||
*/
|
||||
private fun importConfigLocal(): Boolean {
|
||||
try {
|
||||
showFileChooser()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to import config from local file", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// private fun importConfigCustomClipboard()
|
||||
// : Boolean {
|
||||
// try {
|
||||
// val configText = Utils.getClipboard(this)
|
||||
// if (TextUtils.isEmpty(configText)) {
|
||||
// toast(R.string.toast_none_data_clipboard)
|
||||
// return false
|
||||
// }
|
||||
// importCustomizeConfig(configText)
|
||||
// return true
|
||||
// } catch (e: Exception) {
|
||||
// e.printStackTrace()
|
||||
// return false
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* import config from local config file
|
||||
*/
|
||||
// private fun importConfigCustomLocal(): Boolean {
|
||||
// try {
|
||||
// showFileChooser()
|
||||
// } catch (e: Exception) {
|
||||
// e.printStackTrace()
|
||||
// return false
|
||||
// }
|
||||
// return true
|
||||
// }
|
||||
//
|
||||
// private fun importConfigCustomUrlClipboard()
|
||||
// : Boolean {
|
||||
// try {
|
||||
// val url = Utils.getClipboard(this)
|
||||
// if (TextUtils.isEmpty(url)) {
|
||||
// toast(R.string.toast_none_data_clipboard)
|
||||
// return false
|
||||
// }
|
||||
// return importConfigCustomUrl(url)
|
||||
// } catch (e: Exception) {
|
||||
// e.printStackTrace()
|
||||
// return false
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* import config from url
|
||||
*/
|
||||
// private fun importConfigCustomUrl(url: String?): Boolean {
|
||||
// try {
|
||||
// if (!Utils.isValidUrl(url)) {
|
||||
// toast(R.string.toast_invalid_url)
|
||||
// return false
|
||||
// }
|
||||
// lifecycleScope.launch(Dispatchers.IO) {
|
||||
// val configText = try {
|
||||
// HttpUtil.getUrlContentWithUserAgent(url)
|
||||
// } catch (e: Exception) {
|
||||
// e.printStackTrace()
|
||||
// ""
|
||||
// }
|
||||
// launch(Dispatchers.Main) {
|
||||
// importCustomizeConfig(configText)
|
||||
// }
|
||||
// }
|
||||
// } catch (e: Exception) {
|
||||
// e.printStackTrace()
|
||||
// return false
|
||||
// }
|
||||
// return true
|
||||
// }
|
||||
|
||||
/**
|
||||
* import config from sub
|
||||
*/
|
||||
private fun importConfigViaSub(): Boolean {
|
||||
binding.pbWaiting.show()
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val count = mainViewModel.updateConfigViaSubAll()
|
||||
@@ -609,24 +498,24 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
toast(getString(R.string.title_update_config_count, count))
|
||||
mainViewModel.reloadServerList()
|
||||
} else {
|
||||
toast(R.string.toast_failure)
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
binding.pbWaiting.hide()
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun exportAll() {
|
||||
binding.pbWaiting.show()
|
||||
showLoading()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val ret = mainViewModel.exportAllServer()
|
||||
launch(Dispatchers.Main) {
|
||||
if (ret > 0)
|
||||
toast(getString(R.string.title_export_config_count, ret))
|
||||
else
|
||||
toast(R.string.toast_failure)
|
||||
binding.pbWaiting.hide()
|
||||
toastError(R.string.toast_failure)
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -634,13 +523,13 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
private fun delAllConfig() {
|
||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
binding.pbWaiting.show()
|
||||
showLoading()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val ret = mainViewModel.removeAllServer()
|
||||
launch(Dispatchers.Main) {
|
||||
mainViewModel.reloadServerList()
|
||||
toast(getString(R.string.title_del_config_count, ret))
|
||||
binding.pbWaiting.hide()
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -653,13 +542,13 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
private fun delDuplicateConfig() {
|
||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
binding.pbWaiting.show()
|
||||
showLoading()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val ret = mainViewModel.removeDuplicateServer()
|
||||
launch(Dispatchers.Main) {
|
||||
mainViewModel.reloadServerList()
|
||||
toast(getString(R.string.title_del_duplicate_config_count, ret))
|
||||
binding.pbWaiting.hide()
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -672,13 +561,13 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
private fun delInvalidConfig() {
|
||||
AlertDialog.Builder(this).setMessage(R.string.del_invalid_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
binding.pbWaiting.show()
|
||||
showLoading()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val ret = mainViewModel.removeInvalidServer()
|
||||
launch(Dispatchers.Main) {
|
||||
mainViewModel.reloadServerList()
|
||||
toast(getString(R.string.title_del_config_count, ret))
|
||||
binding.pbWaiting.hide()
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -689,12 +578,12 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
}
|
||||
|
||||
private fun sortByTestResults() {
|
||||
binding.pbWaiting.show()
|
||||
showLoading()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
mainViewModel.sortByTestResults()
|
||||
launch(Dispatchers.Main) {
|
||||
mainViewModel.reloadServerList()
|
||||
binding.pbWaiting.hide()
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -737,49 +626,13 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
importBatchConfig(input?.bufferedReader()?.readText())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to read content from URI", e)
|
||||
}
|
||||
} else {
|
||||
requestPermissionLauncher.launch(permission)
|
||||
}
|
||||
}
|
||||
|
||||
// /**
|
||||
// * import customize config
|
||||
// */
|
||||
// private fun importCustomizeConfig(server: String?) {
|
||||
// try {
|
||||
// if (server == null || TextUtils.isEmpty(server)) {
|
||||
// toast(R.string.toast_none_data)
|
||||
// return
|
||||
// }
|
||||
// if (mainViewModel.appendCustomConfigServer(server)) {
|
||||
// mainViewModel.reloadServerList()
|
||||
// toast(R.string.toast_success)
|
||||
// } else {
|
||||
// toast(R.string.toast_failure)
|
||||
// }
|
||||
// //adapter.notifyItemInserted(mainViewModel.serverList.lastIndex)
|
||||
// } catch (e: Exception) {
|
||||
// ToastCompat.makeText(this, "${getString(R.string.toast_malformed_josn)} ${e.cause?.message}", Toast.LENGTH_LONG).show()
|
||||
// e.printStackTrace()
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
|
||||
private fun setTestState(content: String?) {
|
||||
binding.tvTestState.text = content
|
||||
}
|
||||
|
||||
// val mConnection = object : ServiceConnection {
|
||||
// override fun onServiceDisconnected(name: ComponentName?) {
|
||||
// }
|
||||
//
|
||||
// override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
// sendMsg(AppConfig.MSG_REGISTER_CLIENT, "")
|
||||
// }
|
||||
// }
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
moveTaskToBack(false)
|
||||
@@ -792,20 +645,24 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
|
||||
override fun onNavigationItemSelected(item: MenuItem): Boolean {
|
||||
// Handle navigation view item clicks here.
|
||||
when (item.itemId) {
|
||||
R.id.sub_setting -> requestSubSettingActivity.launch(Intent(this, SubSettingActivity::class.java))
|
||||
R.id.settings -> startActivity(
|
||||
Intent(this, SettingsActivity::class.java)
|
||||
.putExtra("isRunning", mainViewModel.isRunning.value == true)
|
||||
)
|
||||
|
||||
R.id.per_app_proxy_settings -> startActivity(Intent(this, PerAppProxyActivity::class.java))
|
||||
R.id.routing_setting -> requestSubSettingActivity.launch(Intent(this, RoutingSettingActivity::class.java))
|
||||
R.id.promotion -> Utils.openUri(this, "${Utils.decode(AppConfig.PromotionUrl)}?t=${System.currentTimeMillis()}")
|
||||
R.id.sub_setting -> requestActivityLauncher.launch(Intent(this, SubSettingActivity::class.java))
|
||||
R.id.per_app_proxy_settings -> requestActivityLauncher.launch(Intent(this, PerAppProxyActivity::class.java))
|
||||
R.id.routing_setting -> requestActivityLauncher.launch(Intent(this, RoutingSettingActivity::class.java))
|
||||
R.id.user_asset_setting -> requestActivityLauncher.launch(Intent(this, UserAssetActivity::class.java))
|
||||
R.id.settings -> requestActivityLauncher.launch(Intent(this, SettingsActivity::class.java))
|
||||
R.id.promotion -> Utils.openUri(this, "${Utils.decode(AppConfig.APP_PROMOTION_URL)}?t=${System.currentTimeMillis()}")
|
||||
R.id.logcat -> startActivity(Intent(this, LogcatActivity::class.java))
|
||||
R.id.check_for_update -> startActivity(Intent(this, CheckUpdateActivity::class.java))
|
||||
R.id.backup_restore -> requestActivityLauncher.launch(Intent(this, BackupActivity::class.java))
|
||||
R.id.about -> startActivity(Intent(this, AboutActivity::class.java))
|
||||
}
|
||||
|
||||
binding.drawerLayout.closeDrawer(GravityCompat.START)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
tabMediator?.detach()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -17,15 +19,18 @@ import com.v2ray.ang.databinding.ItemQrcodeBinding
|
||||
import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
|
||||
import com.v2ray.ang.databinding.ItemRecyclerMainBinding
|
||||
import com.v2ray.ang.dto.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.ServersCache
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
||||
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
||||
import com.v2ray.ang.service.V2RayServiceManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Collections
|
||||
|
||||
class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter {
|
||||
companion object {
|
||||
@@ -37,173 +42,296 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
||||
private val share_method: Array<out String> by lazy {
|
||||
mActivity.resources.getStringArray(R.array.share_method)
|
||||
}
|
||||
private val share_method_more: Array<out String> by lazy {
|
||||
mActivity.resources.getStringArray(R.array.share_method_more)
|
||||
}
|
||||
var isRunning = false
|
||||
private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
|
||||
private var data: MutableList<ServersCache> = mutableListOf()
|
||||
|
||||
override fun getItemCount() = mActivity.mainViewModel.serversCache.size + 1
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun setData(newData: MutableList<ServersCache>?, position: Int = -1) {
|
||||
if (android.os.Looper.myLooper() != android.os.Looper.getMainLooper()) {
|
||||
mActivity.runOnUiThread { setData(newData, position) }
|
||||
return
|
||||
}
|
||||
data = newData?.toMutableList() ?: mutableListOf()
|
||||
|
||||
if (position >= 0 && position in data.indices) {
|
||||
notifyItemChanged(position)
|
||||
} else {
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = data.size + 1
|
||||
|
||||
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
|
||||
if (holder is MainViewHolder) {
|
||||
val guid = mActivity.mainViewModel.serversCache[position].guid
|
||||
val profile = mActivity.mainViewModel.serversCache[position].profile
|
||||
// //filter
|
||||
// if (mActivity.mainViewModel.subscriptionId.isNotEmpty()
|
||||
// && mActivity.mainViewModel.subscriptionId != config.subscriptionId
|
||||
// ) {
|
||||
// holder.itemMainBinding.cardView.visibility = View.GONE
|
||||
// } else {
|
||||
// holder.itemMainBinding.cardView.visibility = View.VISIBLE
|
||||
// }
|
||||
val guid = data[position].guid
|
||||
val profile = data[position].profile
|
||||
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
|
||||
|
||||
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
|
||||
|
||||
holder.itemMainBinding.tvName.text = profile.remarks
|
||||
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
|
||||
//Name address
|
||||
holder.itemMainBinding.tvName.text = profile.remarks
|
||||
holder.itemMainBinding.tvStatistics.text = getAddress(profile)
|
||||
holder.itemMainBinding.tvType.text = profile.configType.name
|
||||
|
||||
//TestResult
|
||||
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
|
||||
holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty()
|
||||
if ((aff?.testDelayMillis ?: 0L) < 0L) {
|
||||
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPingRed))
|
||||
} else {
|
||||
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPing))
|
||||
}
|
||||
|
||||
//layoutIndicator
|
||||
if (guid == MmkvManager.getSelectServer()) {
|
||||
holder.itemMainBinding.layoutIndicator.setBackgroundResource(R.color.colorAccent)
|
||||
holder.itemMainBinding.layoutIndicator.setBackgroundResource(R.color.colorIndicator)
|
||||
} else {
|
||||
holder.itemMainBinding.layoutIndicator.setBackgroundResource(0)
|
||||
}
|
||||
holder.itemMainBinding.tvSubscription.text =
|
||||
if (mActivity.mainViewModel.subscriptionId.isEmpty())
|
||||
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks.orEmpty()
|
||||
else
|
||||
""
|
||||
|
||||
var shareOptions = share_method.asList()
|
||||
when (profile.configType) {
|
||||
EConfigType.CUSTOM -> {
|
||||
holder.itemMainBinding.tvType.text = mActivity.getString(R.string.server_customize_config)
|
||||
shareOptions = shareOptions.takeLast(1)
|
||||
//subscription remarks
|
||||
val subRemarks = getSubscriptionRemarks(profile)
|
||||
holder.itemMainBinding.tvSubscription.text = subRemarks
|
||||
holder.itemMainBinding.layoutSubscription.visibility = if (subRemarks.isEmpty()) View.GONE else View.VISIBLE
|
||||
|
||||
//layout
|
||||
if (doubleColumnDisplay) {
|
||||
holder.itemMainBinding.layoutShare.visibility = View.GONE
|
||||
holder.itemMainBinding.layoutEdit.visibility = View.GONE
|
||||
holder.itemMainBinding.layoutRemove.visibility = View.GONE
|
||||
holder.itemMainBinding.layoutMore.visibility = View.VISIBLE
|
||||
|
||||
//share method
|
||||
val shareOptions = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
|
||||
|
||||
holder.itemMainBinding.layoutMore.setOnClickListener {
|
||||
shareServer(guid, profile, position, shareOptions, if (isCustom) 2 else 0)
|
||||
}
|
||||
} else {
|
||||
holder.itemMainBinding.layoutShare.visibility = View.VISIBLE
|
||||
holder.itemMainBinding.layoutEdit.visibility = View.VISIBLE
|
||||
holder.itemMainBinding.layoutRemove.visibility = View.VISIBLE
|
||||
holder.itemMainBinding.layoutMore.visibility = View.GONE
|
||||
|
||||
//share method
|
||||
val shareOptions = if (isCustom) share_method.asList().takeLast(1) else share_method.asList()
|
||||
|
||||
holder.itemMainBinding.layoutShare.setOnClickListener {
|
||||
shareServer(guid, profile, position, shareOptions, if (isCustom) 2 else 0)
|
||||
}
|
||||
|
||||
else -> {
|
||||
holder.itemMainBinding.tvType.text = profile.configType.name
|
||||
holder.itemMainBinding.layoutEdit.setOnClickListener {
|
||||
editServer(guid, profile)
|
||||
}
|
||||
}
|
||||
|
||||
// Hide xxx:xxx:***/xxx.xxx.xxx.***
|
||||
val strState = "${
|
||||
profile.server?.let {
|
||||
if (it.contains(":"))
|
||||
it.split(":").take(2).joinToString(":", postfix = ":***")
|
||||
else
|
||||
it.split('.').dropLast(1).joinToString(".", postfix = ".***")
|
||||
}
|
||||
} : ${profile.serverPort}"
|
||||
|
||||
holder.itemMainBinding.tvStatistics.text = strState
|
||||
|
||||
holder.itemMainBinding.layoutShare.setOnClickListener {
|
||||
AlertDialog.Builder(mActivity).setItems(shareOptions.toTypedArray()) { _, i ->
|
||||
try {
|
||||
when (i) {
|
||||
0 -> {
|
||||
if (profile.configType == EConfigType.CUSTOM) {
|
||||
shareFullContent(guid)
|
||||
} else {
|
||||
val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(mActivity))
|
||||
ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
|
||||
AlertDialog.Builder(mActivity).setView(ivBinding.root).show()
|
||||
}
|
||||
}
|
||||
|
||||
1 -> {
|
||||
if (AngConfigManager.share2Clipboard(mActivity, guid) == 0) {
|
||||
mActivity.toast(R.string.toast_success)
|
||||
} else {
|
||||
mActivity.toast(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
|
||||
2 -> shareFullContent(guid)
|
||||
else -> mActivity.toast("else")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
holder.itemMainBinding.layoutEdit.setOnClickListener {
|
||||
val intent = Intent().putExtra("guid", guid)
|
||||
.putExtra("isRunning", isRunning)
|
||||
.putExtra("createConfigType", profile.configType.value)
|
||||
if (profile.configType == EConfigType.CUSTOM) {
|
||||
mActivity.startActivity(intent.setClass(mActivity, ServerCustomConfigActivity::class.java))
|
||||
} else {
|
||||
mActivity.startActivity(intent.setClass(mActivity, ServerActivity::class.java))
|
||||
}
|
||||
}
|
||||
holder.itemMainBinding.layoutRemove.setOnClickListener {
|
||||
if (guid != MmkvManager.getSelectServer()) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE) == true) {
|
||||
AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
removeServer(guid, position)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
//do noting
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
removeServer(guid, position)
|
||||
}
|
||||
} else {
|
||||
application.toast(R.string.toast_action_not_allowed)
|
||||
holder.itemMainBinding.layoutRemove.setOnClickListener {
|
||||
removeServer(guid, position)
|
||||
}
|
||||
}
|
||||
|
||||
holder.itemMainBinding.infoContainer.setOnClickListener {
|
||||
val selected = MmkvManager.getSelectServer()
|
||||
if (guid != selected) {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
if (!TextUtils.isEmpty(selected)) {
|
||||
notifyItemChanged(mActivity.mainViewModel.getPosition(selected.orEmpty()))
|
||||
}
|
||||
notifyItemChanged(mActivity.mainViewModel.getPosition(guid))
|
||||
if (isRunning) {
|
||||
V2RayServiceManager.stopVService(mActivity)
|
||||
mActivity.lifecycleScope.launch {
|
||||
try {
|
||||
delay(500)
|
||||
V2RayServiceManager.startVService(mActivity)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (holder is FooterViewHolder) {
|
||||
if (true) {
|
||||
holder.itemFooterBinding.layoutEdit.visibility = View.INVISIBLE
|
||||
} else {
|
||||
holder.itemFooterBinding.layoutEdit.setOnClickListener {
|
||||
Utils.openUri(mActivity, "${Utils.decode(AppConfig.PromotionUrl)}?t=${System.currentTimeMillis()}")
|
||||
}
|
||||
setSelectServer(guid)
|
||||
}
|
||||
}
|
||||
// if (holder is FooterViewHolder) {
|
||||
// if (true) {
|
||||
// holder.itemFooterBinding.layoutEdit.visibility = View.INVISIBLE
|
||||
// } else {
|
||||
// holder.itemFooterBinding.layoutEdit.setOnClickListener {
|
||||
// Utils.openUri(mActivity, "${Utils.decode(AppConfig.PromotionUrl)}?t=${System.currentTimeMillis()}")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
private fun shareFullContent(guid: String) {
|
||||
if (AngConfigManager.shareFullContent2Clipboard(mActivity, guid) == 0) {
|
||||
mActivity.toast(R.string.toast_success)
|
||||
/**
|
||||
* Gets the server address information
|
||||
* Hides part of IP or domain information for privacy protection
|
||||
* @param profile The server configuration
|
||||
* @return Formatted address string
|
||||
*/
|
||||
private fun getAddress(profile: ProfileItem): String {
|
||||
// Hide xxx:xxx:***/xxx.xxx.xxx.***
|
||||
val server = profile.server
|
||||
val port = profile.serverPort
|
||||
if (server.isNullOrBlank() && port.isNullOrBlank()) return ""
|
||||
|
||||
val addrPart = server?.let {
|
||||
if (it.contains(":"))
|
||||
it.split(":").take(2).joinToString(":", postfix = ":***")
|
||||
else
|
||||
it.split('.').dropLast(1).joinToString(".", postfix = ".***")
|
||||
} ?: ""
|
||||
|
||||
return "$addrPart : ${port ?: ""}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the subscription remarks information
|
||||
* @param profile The server configuration
|
||||
* @return Subscription remarks string, or empty string if none
|
||||
*/
|
||||
private fun getSubscriptionRemarks(profile: ProfileItem): String {
|
||||
val subRemarks =
|
||||
if (mActivity.mainViewModel.subscriptionId.isEmpty())
|
||||
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
|
||||
else
|
||||
null
|
||||
return subRemarks?.toString() ?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares server configuration
|
||||
* Displays a dialog with sharing options and executes the selected action
|
||||
* @param guid The server unique identifier
|
||||
* @param profile The server configuration
|
||||
* @param position The position in the list
|
||||
* @param shareOptions The list of share options
|
||||
* @param skip The number of options to skip
|
||||
*/
|
||||
private fun shareServer(guid: String, profile: ProfileItem, position: Int, shareOptions: List<String>, skip: Int) {
|
||||
AlertDialog.Builder(mActivity).setItems(shareOptions.toTypedArray()) { _, i ->
|
||||
try {
|
||||
when (i + skip) {
|
||||
0 -> showQRCode(guid)
|
||||
1 -> share2Clipboard(guid)
|
||||
2 -> shareFullContent(guid)
|
||||
3 -> editServer(guid, profile)
|
||||
4 -> removeServer(guid, position)
|
||||
else -> mActivity.toast("else")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Error when sharing server", e)
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays QR code for the server configuration
|
||||
* @param guid The server unique identifier
|
||||
*/
|
||||
private fun showQRCode(guid: String) {
|
||||
val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(mActivity))
|
||||
ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
|
||||
if (share_method.isNotEmpty()) {
|
||||
ivBinding.ivQcode.contentDescription = share_method[0]
|
||||
} else {
|
||||
mActivity.toast(R.string.toast_failure)
|
||||
ivBinding.ivQcode.contentDescription = "QR Code"
|
||||
}
|
||||
AlertDialog.Builder(mActivity).setView(ivBinding.root).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares server configuration to clipboard
|
||||
* @param guid The server unique identifier
|
||||
*/
|
||||
private fun share2Clipboard(guid: String) {
|
||||
if (AngConfigManager.share2Clipboard(mActivity, guid) == 0) {
|
||||
mActivity.toastSuccess(R.string.toast_success)
|
||||
} else {
|
||||
mActivity.toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares full server configuration content to clipboard
|
||||
* @param guid The server unique identifier
|
||||
*/
|
||||
private fun shareFullContent(guid: String) {
|
||||
mActivity.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = AngConfigManager.shareFullContent2Clipboard(mActivity, guid)
|
||||
launch(Dispatchers.Main) {
|
||||
if (result == 0) {
|
||||
mActivity.toastSuccess(R.string.toast_success)
|
||||
} else {
|
||||
mActivity.toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits server configuration
|
||||
* Opens appropriate editing interface based on configuration type
|
||||
* @param guid The server unique identifier
|
||||
* @param profile The server configuration
|
||||
*/
|
||||
private fun editServer(guid: String, profile: ProfileItem) {
|
||||
val intent = Intent().putExtra("guid", guid)
|
||||
.putExtra("isRunning", isRunning)
|
||||
.putExtra("createConfigType", profile.configType.value)
|
||||
if (profile.configType == EConfigType.CUSTOM) {
|
||||
mActivity.startActivity(intent.setClass(mActivity, ServerCustomConfigActivity::class.java))
|
||||
} else if (profile.configType == EConfigType.POLICYGROUP) {
|
||||
mActivity.startActivity(intent.setClass(mActivity, ServerGroupActivity::class.java))
|
||||
} else {
|
||||
mActivity.startActivity(intent.setClass(mActivity, ServerActivity::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes server configuration
|
||||
* Handles confirmation dialog and related checks
|
||||
* @param guid The server unique identifier
|
||||
* @param position The position in the list
|
||||
*/
|
||||
private fun removeServer(guid: String, position: Int) {
|
||||
if (guid == MmkvManager.getSelectServer()) {
|
||||
application.toast(R.string.toast_action_not_allowed)
|
||||
return
|
||||
}
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
|
||||
AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
removeServerSub(guid, position)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
//do noting
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
removeServerSub(guid, position)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the actual server removal process
|
||||
* @param guid The server unique identifier
|
||||
* @param position The position in the list
|
||||
*/
|
||||
private fun removeServerSub(guid: String, position: Int) {
|
||||
mActivity.mainViewModel.removeServer(guid)
|
||||
notifyItemRemoved(position)
|
||||
notifyItemRangeChanged(position, mActivity.mainViewModel.serversCache.size)
|
||||
val idx = data.indexOfFirst { it.guid == guid }
|
||||
if (idx >= 0) {
|
||||
data.removeAt(idx)
|
||||
notifyItemRemoved(idx)
|
||||
notifyItemRangeChanged(idx, data.size - idx)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the selected server
|
||||
* Updates UI and restarts service if needed
|
||||
* @param guid The server unique identifier to select
|
||||
*/
|
||||
private fun setSelectServer(guid: String) {
|
||||
val selected = MmkvManager.getSelectServer()
|
||||
if (guid != selected) {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
if (!TextUtils.isEmpty(selected)) {
|
||||
notifyItemChanged(mActivity.mainViewModel.getPosition(selected.orEmpty()))
|
||||
}
|
||||
notifyItemChanged(mActivity.mainViewModel.getPosition(guid))
|
||||
if (isRunning) {
|
||||
mActivity.restartV2Ray()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
|
||||
@@ -217,7 +345,7 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return if (position == mActivity.mainViewModel.serversCache.size) {
|
||||
return if (position == data.size) {
|
||||
VIEW_TYPE_FOOTER
|
||||
} else {
|
||||
VIEW_TYPE_ITEM
|
||||
@@ -242,6 +370,9 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
|
||||
|
||||
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
|
||||
mActivity.mainViewModel.swapServer(fromPosition, toPosition)
|
||||
if (fromPosition < data.size && toPosition < data.size) {
|
||||
Collections.swap(data, fromPosition, toPosition)
|
||||
}
|
||||
notifyItemMoved(fromPosition, toPosition)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
@@ -13,66 +16,36 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityBypassListBinding
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.extension.v2RayApplication
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.AppManagerUtil
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
|
||||
import es.dmoral.toasty.Toasty
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.Collator
|
||||
|
||||
class PerAppProxyActivity : BaseActivity() {
|
||||
private val binding by lazy {
|
||||
ActivityBypassListBinding.inflate(layoutInflater)
|
||||
}
|
||||
private val binding by lazy { ActivityBypassListBinding.inflate(layoutInflater) }
|
||||
|
||||
private var adapter: PerAppProxyAdapter? = null
|
||||
private var appsAll: List<AppInfo>? = null
|
||||
private val viewModel: PerAppProxyViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
|
||||
title = getString(R.string.per_app_proxy_settings)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.per_app_proxy_settings))
|
||||
|
||||
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
binding.pbWaiting.show()
|
||||
val blacklist = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)
|
||||
val apps = withContext(Dispatchers.IO) {
|
||||
val appsList = AppManagerUtil.loadNetworkAppList(this@PerAppProxyActivity)
|
||||
|
||||
if (blacklist != null) {
|
||||
appsList.forEach { app ->
|
||||
app.isSelected = if (blacklist.contains(app.packageName)) 1 else 0
|
||||
}
|
||||
appsList.sortedWith(Comparator { p1, p2 ->
|
||||
when {
|
||||
p1.isSelected > p2.isSelected -> -1
|
||||
p1.isSelected == p2.isSelected -> 0
|
||||
else -> 1
|
||||
}
|
||||
})
|
||||
} else {
|
||||
val collator = Collator.getInstance()
|
||||
appsList.sortedWith(compareBy(collator) { it.appName })
|
||||
}
|
||||
}
|
||||
|
||||
appsAll = apps
|
||||
adapter = PerAppProxyAdapter(this@PerAppProxyActivity, apps, blacklist)
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.pbWaiting.hide()
|
||||
} catch (e: Exception) {
|
||||
binding.pbWaiting.hide()
|
||||
Log.e(ANG_PACKAGE, "Error loading apps", e)
|
||||
}
|
||||
}
|
||||
initList()
|
||||
|
||||
binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked ->
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY, isChecked)
|
||||
@@ -83,12 +56,53 @@ class PerAppProxyActivity : BaseActivity() {
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_BYPASS_APPS, isChecked)
|
||||
}
|
||||
binding.switchBypassApps.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS, false)
|
||||
|
||||
binding.layoutSwitchBypassAppsTips.setOnClickListener {
|
||||
Toasty.info(this, R.string.summary_pref_per_app_proxy, Toast.LENGTH_LONG, true).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
adapter?.let {
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY_SET, it.blacklist)
|
||||
private fun initList() {
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val apps = withContext(Dispatchers.IO) {
|
||||
val appsList = AppManagerUtil.loadNetworkAppList(this@PerAppProxyActivity)
|
||||
|
||||
val blacklistSet = viewModel.getAll()
|
||||
if (blacklistSet.isNotEmpty()) {
|
||||
appsList.forEach { app ->
|
||||
app.isSelected = if (blacklistSet.contains(app.packageName)) 1 else 0
|
||||
}
|
||||
appsList.sortedWith { p1, p2 ->
|
||||
when {
|
||||
p1.isSelected > p2.isSelected -> -1
|
||||
p1.isSelected < p2.isSelected -> 1
|
||||
p1.isSystemApp > p2.isSystemApp -> 1
|
||||
p1.isSystemApp < p2.isSystemApp -> -1
|
||||
p1.appName.lowercase() > p2.appName.lowercase() -> 1
|
||||
p1.appName.lowercase() < p2.appName.lowercase() -> -1
|
||||
p1.packageName > p2.packageName -> 1
|
||||
p1.packageName < p2.packageName -> -1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val collator = Collator.getInstance()
|
||||
appsList.sortedWith(compareBy(collator) { it.appName })
|
||||
}
|
||||
}
|
||||
|
||||
appsAll = apps
|
||||
adapter = PerAppProxyAdapter(this@PerAppProxyActivity, apps, viewModel)
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(ANG_PACKAGE, "Error loading apps", e)
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,35 +122,32 @@ class PerAppProxyActivity : BaseActivity() {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.select_all -> adapter?.let {
|
||||
val pkgNames = it.apps.map { it.packageName }
|
||||
if (it.blacklist.containsAll(pkgNames)) {
|
||||
it.apps.forEach {
|
||||
val packageName = it.packageName
|
||||
adapter?.blacklist?.remove(packageName)
|
||||
}
|
||||
} else {
|
||||
it.apps.forEach {
|
||||
val packageName = it.packageName
|
||||
adapter?.blacklist?.add(packageName)
|
||||
}
|
||||
}
|
||||
it.notifyDataSetChanged()
|
||||
R.id.select_all -> {
|
||||
selectAllApp()
|
||||
allowPerAppProxy()
|
||||
true
|
||||
} == true
|
||||
}
|
||||
R.id.invert_selection -> {
|
||||
invertSelection()
|
||||
allowPerAppProxy()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.select_proxy_app -> {
|
||||
selectProxyApp()
|
||||
selectProxyAppAuto()
|
||||
allowPerAppProxy()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.import_proxy_app -> {
|
||||
importProxyApp()
|
||||
allowPerAppProxy()
|
||||
true
|
||||
}
|
||||
|
||||
@@ -148,11 +159,34 @@ class PerAppProxyActivity : BaseActivity() {
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun selectProxyApp() {
|
||||
toast(R.string.msg_downloading_content)
|
||||
binding.pbWaiting.show()
|
||||
private fun selectAllApp() {
|
||||
adapter?.let { adapter ->
|
||||
val pkgNames = adapter.apps.map { it.packageName }
|
||||
val allSelected = pkgNames.all { viewModel.contains(it) }
|
||||
|
||||
val url = AppConfig.androidpackagenamelistUrl
|
||||
if (allSelected) {
|
||||
viewModel.removeAll(pkgNames)
|
||||
} else {
|
||||
viewModel.addAll(pkgNames)
|
||||
}
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun invertSelection() {
|
||||
adapter?.let { adapter ->
|
||||
adapter.apps.forEach { app ->
|
||||
viewModel.toggle(app.packageName)
|
||||
}
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectProxyAppAuto() {
|
||||
toast(R.string.msg_downloading_content)
|
||||
showLoading()
|
||||
|
||||
val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
var content = HttpUtil.getUrlContent(url, 5000)
|
||||
if (content.isNullOrEmpty()) {
|
||||
@@ -160,10 +194,10 @@ class PerAppProxyActivity : BaseActivity() {
|
||||
content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: ""
|
||||
}
|
||||
launch(Dispatchers.Main) {
|
||||
Log.d(ANG_PACKAGE, content)
|
||||
//Log.i(AppConfig.TAG, content)
|
||||
selectProxyApp(content, true)
|
||||
toast(R.string.toast_success)
|
||||
binding.pbWaiting.hide()
|
||||
toastSuccess(R.string.toast_success)
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,65 +206,66 @@ class PerAppProxyActivity : BaseActivity() {
|
||||
val content = Utils.getClipboard(applicationContext)
|
||||
if (TextUtils.isEmpty(content)) return
|
||||
selectProxyApp(content, false)
|
||||
toast(R.string.toast_success)
|
||||
toastSuccess(R.string.toast_success)
|
||||
}
|
||||
|
||||
private fun exportProxyApp() {
|
||||
var lst = binding.switchBypassApps.isChecked.toString()
|
||||
|
||||
adapter?.blacklist?.forEach block@{
|
||||
lst = lst + System.getProperty("line.separator") + it
|
||||
viewModel.getAll().forEach { pkg ->
|
||||
lst = lst + System.lineSeparator() + pkg
|
||||
}
|
||||
Utils.setClipboard(applicationContext, lst)
|
||||
toast(R.string.toast_success)
|
||||
toastSuccess(R.string.toast_success)
|
||||
}
|
||||
|
||||
private fun allowPerAppProxy() {
|
||||
binding.switchPerAppProxy.isChecked = true
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
private fun selectProxyApp(content: String, force: Boolean): Boolean {
|
||||
try {
|
||||
val proxyApps = if (TextUtils.isEmpty(content)) {
|
||||
Utils.readTextFromAssets(v2RayApplication, "proxy_packagename.txt")
|
||||
Utils.readTextFromAssets(v2RayApplication, "proxy_package_name")
|
||||
} else {
|
||||
content
|
||||
}
|
||||
if (TextUtils.isEmpty(proxyApps)) return false
|
||||
|
||||
adapter?.blacklist?.clear()
|
||||
viewModel.clear()
|
||||
|
||||
if (binding.switchBypassApps.isChecked) {
|
||||
adapter?.let {
|
||||
it.apps.forEach block@{
|
||||
val packageName = it.packageName
|
||||
Log.d(ANG_PACKAGE, packageName)
|
||||
adapter?.let { adapter ->
|
||||
adapter.apps.forEach { app ->
|
||||
val packageName = app.packageName
|
||||
if (!inProxyApps(proxyApps, packageName, force)) {
|
||||
adapter?.blacklist?.add(packageName)
|
||||
println(packageName)
|
||||
return@block
|
||||
viewModel.add(packageName)
|
||||
}
|
||||
}
|
||||
it.notifyDataSetChanged()
|
||||
refreshData()
|
||||
}
|
||||
} else {
|
||||
adapter?.let {
|
||||
it.apps.forEach block@{
|
||||
val packageName = it.packageName
|
||||
Log.d(ANG_PACKAGE, packageName)
|
||||
adapter?.let { adapter ->
|
||||
adapter.apps.forEach { app ->
|
||||
val packageName = app.packageName
|
||||
if (inProxyApps(proxyApps, packageName, force)) {
|
||||
adapter?.blacklist?.add(packageName)
|
||||
println(packageName)
|
||||
return@block
|
||||
viewModel.add(packageName)
|
||||
}
|
||||
}
|
||||
it.notifyDataSetChanged()
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Error selecting proxy app", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun inProxyApps(proxyApps: String, packageName: String, force: Boolean): Boolean {
|
||||
println(packageName)
|
||||
if (force) {
|
||||
if (packageName == "com.google.android.webview") return false
|
||||
if (packageName.startsWith("com.google")) return true
|
||||
@@ -257,9 +292,14 @@ class PerAppProxyActivity : BaseActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
adapter = PerAppProxyAdapter(this, apps, adapter?.blacklist)
|
||||
adapter = PerAppProxyAdapter(this, apps, adapter?.viewModel ?: viewModel)
|
||||
binding.recyclerView.adapter = adapter
|
||||
adapter?.notifyDataSetChanged()
|
||||
refreshData()
|
||||
return true
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun refreshData() {
|
||||
adapter?.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
|
||||
|
||||
class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, blacklist: MutableSet<String>?) :
|
||||
class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, val viewModel: PerAppProxyViewModel) :
|
||||
RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
|
||||
|
||||
companion object {
|
||||
@@ -15,8 +16,6 @@ class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, bl
|
||||
private const val VIEW_TYPE_ITEM = 1
|
||||
}
|
||||
|
||||
val blacklist = if (blacklist == null) HashSet() else HashSet(blacklist)
|
||||
|
||||
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
|
||||
if (holder is AppViewHolder) {
|
||||
val appInfo = apps[position - 1]
|
||||
@@ -38,11 +37,7 @@ class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, bl
|
||||
)
|
||||
BaseViewHolder(view)
|
||||
}
|
||||
// VIEW_TYPE_ITEM -> AppViewHolder(ctx.layoutInflater
|
||||
// .inflate(R.layout.item_recycler_bypass_list, parent, false))
|
||||
|
||||
else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,13 +47,11 @@ class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, bl
|
||||
|
||||
inner class AppViewHolder(private val itemBypassBinding: ItemRecyclerBypassListBinding) : BaseViewHolder(itemBypassBinding.root),
|
||||
View.OnClickListener {
|
||||
private val inBlacklist: Boolean get() = blacklist.contains(appInfo.packageName)
|
||||
private lateinit var appInfo: AppInfo
|
||||
|
||||
fun bind(appInfo: AppInfo) {
|
||||
this.appInfo = appInfo
|
||||
|
||||
// Set app icon and name
|
||||
itemBypassBinding.icon.setImageDrawable(appInfo.appIcon)
|
||||
itemBypassBinding.name.text = if (appInfo.isSystemApp) {
|
||||
String.format("** %s", appInfo.appName)
|
||||
@@ -66,23 +59,16 @@ class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, bl
|
||||
appInfo.appName
|
||||
}
|
||||
|
||||
// Set package name and checkbox state
|
||||
itemBypassBinding.packageName.text = appInfo.packageName
|
||||
itemBypassBinding.checkBox.isChecked = inBlacklist
|
||||
itemBypassBinding.checkBox.isChecked = viewModel.contains(appInfo.packageName)
|
||||
|
||||
// Handle item click to toggle blacklist status
|
||||
itemView.setOnClickListener(this)
|
||||
}
|
||||
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
if (inBlacklist) {
|
||||
blacklist.remove(appInfo.packageName)
|
||||
itemBypassBinding.checkBox.isChecked = false
|
||||
} else {
|
||||
blacklist.add(appInfo.packageName)
|
||||
itemBypassBinding.checkBox.isChecked = true
|
||||
}
|
||||
val packageName = appInfo.packageName
|
||||
viewModel.toggle(packageName)
|
||||
itemBypassBinding.checkBox.isChecked = viewModel.contains(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityRoutingEditBinding
|
||||
import com.v2ray.ang.dto.RulesetItem
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -24,8 +25,8 @@ class RoutingEditActivity : BaseActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
title = getString(R.string.routing_settings_rule_title)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title))
|
||||
|
||||
val rulesetItem = SettingsManager.getRoutingRuleset(position)
|
||||
if (rulesetItem != null) {
|
||||
@@ -78,7 +79,7 @@ class RoutingEditActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
SettingsManager.saveRoutingRuleset(position, rulesetItem)
|
||||
toast(R.string.toast_success)
|
||||
toastSuccess(R.string.toast_success)
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
@@ -105,10 +106,10 @@ class RoutingEditActivity : BaseActivity() {
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.action_server, menu)
|
||||
val del_config = menu.findItem(R.id.del_config)
|
||||
val delConfig = menu.findItem(R.id.del_config)
|
||||
|
||||
if (position < 0) {
|
||||
del_config?.isVisible = false
|
||||
delConfig?.isVisible = false
|
||||
}
|
||||
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.AdapterView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
@@ -15,13 +16,15 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityRoutingSettingBinding
|
||||
import com.v2ray.ang.dto.RulesetItem
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -29,8 +32,8 @@ import kotlinx.coroutines.withContext
|
||||
class RoutingSettingActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) }
|
||||
|
||||
var rulesets: MutableList<RulesetItem> = mutableListOf()
|
||||
private val adapter by lazy { RoutingSettingRecyclerAdapter(this) }
|
||||
private val viewModel: RoutingSettingsViewModel by viewModels()
|
||||
private lateinit var adapter: RoutingSettingRecyclerAdapter
|
||||
private var mItemTouchHelper: ItemTouchHelper? = null
|
||||
private val routing_domain_strategy: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.routing_domain_strategy)
|
||||
@@ -51,9 +54,10 @@ class RoutingSettingActivity : BaseActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_title))
|
||||
|
||||
title = getString(R.string.routing_settings_title)
|
||||
adapter = RoutingSettingRecyclerAdapter(this, viewModel)
|
||||
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
@@ -63,15 +67,9 @@ class RoutingSettingActivity : BaseActivity() {
|
||||
mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
|
||||
mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
|
||||
|
||||
val found = Utils.arrayFind(routing_domain_strategy, MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY) ?: "")
|
||||
found.let { binding.spDomainStrategy.setSelection(if (it >= 0) it else 0) }
|
||||
binding.spDomainStrategy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {
|
||||
}
|
||||
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, routing_domain_strategy[position])
|
||||
}
|
||||
binding.tvDomainStrategySummary.text = getDomainStrategy()
|
||||
binding.layoutDomainStrategy.setOnClickListener {
|
||||
setDomainStrategy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +85,6 @@ class RoutingSettingActivity : BaseActivity() {
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
|
||||
R.id.add_rule -> startActivity(Intent(this, RoutingEditActivity::class.java)).let { true }
|
||||
R.id.user_asset_setting -> startActivity(Intent(this, UserAssetActivity::class.java)).let { true }
|
||||
R.id.import_predefined_rulesets -> importPredefined().let { true }
|
||||
R.id.import_rulesets_from_clipboard -> importFromClipboard().let { true }
|
||||
R.id.import_rulesets_from_qrcode -> requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA).let { true }
|
||||
@@ -95,6 +92,22 @@ class RoutingSettingActivity : BaseActivity() {
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun getDomainStrategy(): String {
|
||||
return MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY) ?: routing_domain_strategy.first()
|
||||
}
|
||||
|
||||
private fun setDomainStrategy() {
|
||||
android.app.AlertDialog.Builder(this).setItems(routing_domain_strategy.asList().toTypedArray()) { _, i ->
|
||||
try {
|
||||
val value = routing_domain_strategy[i]
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
|
||||
binding.tvDomainStrategySummary.text = value
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to set domain strategy", e)
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun importPredefined() {
|
||||
AlertDialog.Builder(this).setItems(preset_rulesets.asList().toTypedArray()) { _, i ->
|
||||
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
|
||||
@@ -104,11 +117,11 @@ class RoutingSettingActivity : BaseActivity() {
|
||||
SettingsManager.resetRoutingRulesetsFromPresets(this@RoutingSettingActivity, i)
|
||||
launch(Dispatchers.Main) {
|
||||
refreshData()
|
||||
toast(R.string.toast_success)
|
||||
toastSuccess(R.string.toast_success)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e(AppConfig.TAG, "Failed to import predefined ruleset", e)
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
@@ -124,8 +137,8 @@ class RoutingSettingActivity : BaseActivity() {
|
||||
val clipboard = try {
|
||||
Utils.getClipboard(this)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
toast(R.string.toast_failure)
|
||||
Log.e(AppConfig.TAG, "Failed to get clipboard content", e)
|
||||
toastError(R.string.toast_failure)
|
||||
return@setPositiveButton
|
||||
}
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
@@ -133,9 +146,9 @@ class RoutingSettingActivity : BaseActivity() {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (result) {
|
||||
refreshData()
|
||||
toast(R.string.toast_success)
|
||||
toastSuccess(R.string.toast_success)
|
||||
} else {
|
||||
toast(R.string.toast_failure)
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,10 +162,10 @@ class RoutingSettingActivity : BaseActivity() {
|
||||
private fun export2Clipboard() {
|
||||
val rulesetList = MmkvManager.decodeRoutingRulesets()
|
||||
if (rulesetList.isNullOrEmpty()) {
|
||||
toast(R.string.toast_failure)
|
||||
toastError(R.string.toast_failure)
|
||||
} else {
|
||||
Utils.setClipboard(this, JsonUtil.toJson(rulesetList))
|
||||
toast(R.string.toast_success)
|
||||
toastSuccess(R.string.toast_success)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,9 +183,9 @@ class RoutingSettingActivity : BaseActivity() {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (result) {
|
||||
refreshData()
|
||||
toast(R.string.toast_success)
|
||||
toastSuccess(R.string.toast_success)
|
||||
} else {
|
||||
toast(R.string.toast_failure)
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,9 +197,9 @@ class RoutingSettingActivity : BaseActivity() {
|
||||
return true
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun refreshData() {
|
||||
rulesets.clear()
|
||||
rulesets.addAll(MmkvManager.decodeRoutingRulesets() ?: mutableListOf())
|
||||
viewModel.reload()
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user