mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-10-29 14:39:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4dd3d08de | ||
|
|
ab09f816a0 | ||
|
|
504f22d75b | ||
|
|
996b039b48 | ||
|
|
e151890c55 | ||
|
|
04d407f5a1 | ||
|
|
5029d75475 | ||
|
|
85b1f694c9 | ||
|
|
c63e18b751 | ||
|
|
89f53233cb | ||
|
|
be1f2cf00b | ||
|
|
bd21bbc3f9 | ||
|
|
6e5d742e40 | ||
|
|
4472162614 | ||
|
|
1f7cef32dc | ||
|
|
7e68e2eaad | ||
|
|
4c7c964e65 | ||
|
|
1bd1bceadb | ||
|
|
0e80f6b431 | ||
|
|
1ad5f30ede | ||
|
|
e4c0b09e1b | ||
|
|
073376e46d | ||
|
|
08c2a4deb1 | ||
|
|
60ab1af384 | ||
|
|
c48fbd3fc8 | ||
|
|
b98895219a | ||
|
|
0f65b1d348 | ||
|
|
a1cfa2aa7a | ||
|
|
53fce9a8f5 | ||
|
|
cb39a10ada | ||
|
|
c1c93b62d1 | ||
|
|
a9b1242686 | ||
|
|
173f60a1da | ||
|
|
b9c2decb34 | ||
|
|
200c59f773 | ||
|
|
49835a5cdb | ||
|
|
53c6506308 | ||
|
|
01b47581c0 | ||
|
|
362e541cb6 | ||
|
|
157acc8daa | ||
|
|
5d76fcd0fa | ||
|
|
4004cee279 | ||
|
|
06bcd4c16b | ||
|
|
8d61aedeca | ||
|
|
2020807c25 | ||
|
|
ca4185abad | ||
|
|
8d9152bb4e | ||
|
|
5bfcdf90ec | ||
|
|
f4f992eb9b | ||
|
|
1af8f4677c | ||
|
|
4d8ee1a916 | ||
|
|
95bcf32c0b | ||
|
|
c548e5f615 | ||
|
|
258de20972 | ||
|
|
3dc0173b9f | ||
|
|
13138dddca | ||
|
|
d5fc104e1d | ||
|
|
384553e74e | ||
|
|
11b5784321 | ||
|
|
e27941558e | ||
|
|
c73498b064 | ||
|
|
e2dc37ba26 | ||
|
|
26099c198e | ||
|
|
3c9336f671 | ||
|
|
767f88667c | ||
|
|
e3fa861571 | ||
|
|
fdacd58ed0 | ||
|
|
38da97e031 | ||
|
|
a1b45bbfb2 | ||
|
|
dac65a972c | ||
|
|
63f557242b | ||
|
|
9fcb1a30f8 | ||
|
|
d4fc91502c | ||
|
|
75c9194ec0 | ||
|
|
590be2e9ae | ||
|
|
1ee1213563 | ||
|
|
37b42f04a7 | ||
|
|
3c91b51489 | ||
|
|
06a27ec4d4 | ||
|
|
7a07565f72 | ||
|
|
0456f835bc | ||
|
|
e4e3a36e93 | ||
|
|
4b3c8177db | ||
|
|
780d194213 | ||
|
|
c1374a73fb | ||
|
|
49fe45c49e | ||
|
|
36ab5615d9 | ||
|
|
91c2c93a62 | ||
|
|
10932f5764 | ||
|
|
8dcff4238f | ||
|
|
809bdcdb9d | ||
|
|
b348ca792b | ||
|
|
1fa0ccb4f2 | ||
|
|
3033e471cd | ||
|
|
36796fee37 | ||
|
|
2607b5cbff | ||
|
|
9cdbc5cebb | ||
|
|
f976d397e0 | ||
|
|
a16a7390f4 | ||
|
|
606246f36e | ||
|
|
e8a82d9810 | ||
|
|
739e303fc0 | ||
|
|
f63f649477 | ||
|
|
a2be68f7c5 | ||
|
|
68f9e66a76 | ||
|
|
9be547c3b9 | ||
|
|
fd48dcf9e0 | ||
|
|
775546ce40 | ||
|
|
d8b857665c | ||
|
|
17d03bcc46 | ||
|
|
efb16e7979 | ||
|
|
5ac660e395 | ||
|
|
2dcc7781a5 | ||
|
|
2a51e7e767 | ||
|
|
007e167a45 | ||
|
|
9c80ab3d44 | ||
|
|
320fb6a10b | ||
|
|
559a00e66e | ||
|
|
31dfaeed6c | ||
|
|
19b0c9a2c3 | ||
|
|
db0755cf0c | ||
|
|
dc6a7eae93 | ||
|
|
3ae8db972e | ||
|
|
a899d7f444 | ||
|
|
c8f78621bf | ||
|
|
0cf95ac8a3 | ||
|
|
83abd61a7b | ||
|
|
4dc3a20a5c | ||
|
|
bcfb9c2bc9 | ||
|
|
55103078c2 | ||
|
|
8b81fdf383 | ||
|
|
17b6254d4d | ||
|
|
435182e0c9 | ||
|
|
257c4bf21b | ||
|
|
389e55d72a | ||
|
|
490a07d75b | ||
|
|
d4ebab69c7 | ||
|
|
97195f2d3a | ||
|
|
be2dabe029 | ||
|
|
3fc03bad2b | ||
|
|
888d7cd398 | ||
|
|
919dd71ea7 | ||
|
|
2c1efde450 | ||
|
|
e549000e5a | ||
|
|
aacc8e091f | ||
|
|
070abd78ea | ||
|
|
3380a45475 | ||
|
|
4fee3a67f7 | ||
|
|
794940a1a0 | ||
|
|
8421d2300b | ||
|
|
596084b939 | ||
|
|
35c39e4f36 | ||
|
|
4295d5d6d1 | ||
|
|
827682b768 | ||
|
|
9896dd2974 | ||
|
|
842567fb89 | ||
|
|
cce2c9cdbd | ||
|
|
7f6bf57ce1 | ||
|
|
5ee70b723b | ||
|
|
2be47f37fe | ||
|
|
ce75aad55e | ||
|
|
c9e1501d39 | ||
|
|
3be230c024 | ||
|
|
3ae13b0ba4 | ||
|
|
a231151e70 | ||
|
|
f7f170e8d3 | ||
|
|
1b13fadce7 | ||
|
|
fc39e045fc | ||
|
|
60562acb80 | ||
|
|
4949528eeb | ||
|
|
f6ea2df5a0 | ||
|
|
4b903cf5e0 | ||
|
|
ccadd5453a | ||
|
|
7213f9fa9f | ||
|
|
6a00dff160 | ||
|
|
4c27f8682a | ||
|
|
607311efd2 | ||
|
|
f1eac6fb8c | ||
|
|
1b9401fc42 | ||
|
|
1dee6230f9 | ||
|
|
ca79a574fa | ||
|
|
eeec6de96c | ||
|
|
b02647d664 | ||
|
|
90ae44e532 | ||
|
|
aa55cdca76 | ||
|
|
04a5c56844 | ||
|
|
aaf071053b | ||
|
|
70352cc341 | ||
|
|
1e3f16b083 | ||
|
|
7cbb0febf0 | ||
|
|
ac03b0e87b | ||
|
|
ca9b60fceb | ||
|
|
09caeec326 | ||
|
|
346c6cd60c | ||
|
|
c74a6cade3 | ||
|
|
f6f981f9fd | ||
|
|
16cd06f07a | ||
|
|
1eaea8e161 | ||
|
|
69cf1e70c1 | ||
|
|
c49d7e98b1 | ||
|
|
7ccac9eb2d | ||
|
|
1338ec8e64 | ||
|
|
b95db14005 | ||
|
|
8d726fa829 | ||
|
|
06e512f5c5 | ||
|
|
9397a9e4a1 | ||
|
|
223eb1494b | ||
|
|
15b4fff8e4 | ||
|
|
1007f54743 | ||
|
|
5369303e18 | ||
|
|
318afdbafc | ||
|
|
ec04df4693 | ||
|
|
53cccd9089 | ||
|
|
f226ace30d | ||
|
|
10a4221b25 | ||
|
|
ffac1476c0 | ||
|
|
7a0472b63d | ||
|
|
62cecc4d0d | ||
|
|
eb56b2e89e | ||
|
|
e83dba94a0 | ||
|
|
42c12fdef4 | ||
|
|
226b36903e | ||
|
|
de351d4438 | ||
|
|
5436d67e47 | ||
|
|
1c3d247071 | ||
|
|
d3e1e7c6fe | ||
|
|
a65c57ea0d | ||
|
|
8586c2550f | ||
|
|
fa6c4807f3 | ||
|
|
6e65f46e3f | ||
|
|
98e7b8e1a1 | ||
|
|
7dd42528db | ||
|
|
e580b76e9e | ||
|
|
c4d02c00ce | ||
|
|
007a4e674f | ||
|
|
46af9d803c | ||
|
|
32a9263e0a | ||
|
|
253e171fec | ||
|
|
31b66a705b | ||
|
|
23c7cd5b95 | ||
|
|
32a37dd626 | ||
|
|
53e84d5fd8 | ||
|
|
0519d02001 | ||
|
|
6dfed5bbc2 | ||
|
|
dcd060e350 | ||
|
|
71f94db929 | ||
|
|
d06b9cb999 | ||
|
|
f53a91241c | ||
|
|
3fb067dc99 | ||
|
|
02103287e5 | ||
|
|
b103e3aec9 | ||
|
|
19fc65d128 | ||
|
|
27cafd1052 | ||
|
|
a155b24c1e | ||
|
|
e805fcf088 | ||
|
|
95e37db42e | ||
|
|
fe812c4456 | ||
|
|
d5af0fe735 | ||
|
|
9126aa9c9e | ||
|
|
4a763d2ec5 | ||
|
|
08d6de4504 | ||
|
|
629d410189 | ||
|
|
39d2513df1 | ||
|
|
3cb4df474b | ||
|
|
b64bb92c8c | ||
|
|
7f0e2f801d | ||
|
|
0f7cee0613 | ||
|
|
b2e10fc60b | ||
|
|
aa3f17fb51 | ||
|
|
d64fc627bb | ||
|
|
6934ab3e5d | ||
|
|
028d13aef8 | ||
|
|
4a78c165bc | ||
|
|
c1033a006f | ||
|
|
e8a42013c0 | ||
|
|
94e5263250 | ||
|
|
7c68aa9d83 | ||
|
|
ef13e336da | ||
|
|
d16cb21059 | ||
|
|
237837e7be | ||
|
|
2a6edd98be | ||
|
|
9238b80675 | ||
|
|
bb5e6f1959 | ||
|
|
1cc0865411 | ||
|
|
cb8d979f23 | ||
|
|
5932b0db29 | ||
|
|
dbe4fe78b5 | ||
|
|
b5bcd81ae4 | ||
|
|
d45ccd7953 | ||
|
|
6815563902 | ||
|
|
7c38ad7c57 | ||
|
|
885aeb384e | ||
|
|
57f01412e7 | ||
|
|
fd84ea4114 | ||
|
|
a4e2e6d6a2 | ||
|
|
b8cfe9bc82 | ||
|
|
b945f4a3c7 | ||
|
|
cfb6776e8f | ||
|
|
ebaa5088b6 | ||
|
|
209679f098 | ||
|
|
19ad248cfe | ||
|
|
35f1f58476 | ||
|
|
4c86e7c26e | ||
|
|
5d0897cd94 | ||
|
|
fc4ab733c8 | ||
|
|
d606453b83 | ||
|
|
c6c8eccd51 | ||
|
|
03074fba61 | ||
|
|
ac02d23083 | ||
|
|
5c6aaf285b | ||
|
|
d3f9809486 | ||
|
|
a12909e33d | ||
|
|
3e823a7440 | ||
|
|
dc1d3a27dd | ||
|
|
d0f1e21f49 | ||
|
|
3b8615fad0 | ||
|
|
d9d00f4137 | ||
|
|
2aefbd43eb | ||
|
|
cf33391785 | ||
|
|
a35ba5dfbd | ||
|
|
3b4d895c1d | ||
|
|
e8585a82c6 | ||
|
|
087c08f95a | ||
|
|
6612ca8256 | ||
|
|
487692deb7 | ||
|
|
e9332168bf | ||
|
|
15fcfec4c8 | ||
|
|
a4baeeb0fa | ||
|
|
093e51186d | ||
|
|
14a29884e7 | ||
|
|
30b0bd8d0d | ||
|
|
ae84df7367 | ||
|
|
b673167731 | ||
|
|
659f2ded09 | ||
|
|
585d64b0be | ||
|
|
2ae2e747e2 | ||
|
|
4199cee4e5 | ||
|
|
8be8a912c4 | ||
|
|
851f22680a | ||
|
|
12abc99bfc | ||
|
|
07e50637a2 | ||
|
|
c1824c7598 | ||
|
|
18eebf999d | ||
|
|
e37d6b7523 | ||
|
|
8c5375ec62 | ||
|
|
30792299e8 | ||
|
|
e6db0f6f9e | ||
|
|
04478ab490 | ||
|
|
f371da490a | ||
|
|
0a0ef6e6b5 | ||
|
|
fd57019e73 | ||
|
|
04ac6cb2d7 | ||
|
|
29a0be9629 | ||
|
|
7aa27fd3de | ||
|
|
fcc478ff63 | ||
|
|
54d5ad2342 | ||
|
|
ba25e05536 | ||
|
|
5a5a81fa0b | ||
|
|
322c4b305e | ||
|
|
590f11e671 | ||
|
|
872ec80292 | ||
|
|
f19f153f9c | ||
|
|
d7abb8f49e | ||
|
|
e54e713db7 | ||
|
|
de91fb27db | ||
|
|
db8f46eae5 | ||
|
|
42e7f95e4f | ||
|
|
0a52a79fa0 | ||
|
|
9dc1b1f2cf | ||
|
|
b61cad5a5f | ||
|
|
66cd746ad2 | ||
|
|
ae4a92b66b | ||
|
|
55c0ccf1b0 | ||
|
|
deedd26fac | ||
|
|
e6c8307b96 | ||
|
|
d6d5753283 | ||
|
|
014d510774 | ||
|
|
42f89e69a7 | ||
|
|
d5a0997775 | ||
|
|
af7db6f86b | ||
|
|
7b7f43e12e | ||
|
|
8dd1df1451 | ||
|
|
d4a73493b5 | ||
|
|
9fce03d0ed | ||
|
|
a12ca3ea04 | ||
|
|
4e9df84d3e | ||
|
|
d3118b53f2 | ||
|
|
cd45b51487 | ||
|
|
6aa26d2621 | ||
|
|
0b452707c3 | ||
|
|
303b642ade | ||
|
|
80eec036dd | ||
|
|
f9a85366ea | ||
|
|
378c891399 | ||
|
|
d04b5eee37 | ||
|
|
405cd7f55e | ||
|
|
850096789b | ||
|
|
3d42ac9dba | ||
|
|
d0265265f3 | ||
|
|
2d6dd33a7b | ||
|
|
3a32373abe | ||
|
|
befb2937d1 | ||
|
|
0a375bec67 | ||
|
|
029f8b67e7 | ||
|
|
7475d1757b | ||
|
|
5a16614a7b | ||
|
|
edb06b466a | ||
|
|
30b5391895 | ||
|
|
6cd04b831f | ||
|
|
1e616198f0 | ||
|
|
a710146e4f | ||
|
|
9cd530d223 | ||
|
|
fc89f25591 | ||
|
|
84ab54fea2 | ||
|
|
d642d09844 | ||
|
|
6ae46c50e2 | ||
|
|
fe4b451988 | ||
|
|
5c9b634039 | ||
|
|
09461663cb | ||
|
|
50c1145815 | ||
|
|
edc4856cb8 | ||
|
|
4dd7eee8fe | ||
|
|
516637954c | ||
|
|
9e714bfcb3 | ||
|
|
858dc5237a | ||
|
|
2b4a4f7ecc | ||
|
|
f9f0c905a4 | ||
|
|
f997335113 | ||
|
|
ed344cd281 | ||
|
|
94775f120a |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# Agent-guide adapter
|
||||
|
||||
Before starting any repository task, open and follow the repository-root `AGENTS.md`.
|
||||
That guide defines the scoped `AGENTS.md` files that must also be opened before editing
|
||||
their paths. Treat the root and applicable scoped guides as mandatory. Do not duplicate
|
||||
or replace their rules in this file.
|
||||
+55
-22
@@ -10,37 +10,40 @@ on:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
fetch-depth: '0'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3.2.0
|
||||
uses: android-actions/setup-android@v4
|
||||
with:
|
||||
log-accepted-android-sdk-licenses: false
|
||||
cmdline-tools-version: '13114758'
|
||||
packages: 'platforms;android-36.1 build-tools;36.1.0 platform-tools'
|
||||
cmdline-tools-version: '14742923'
|
||||
packages: 'platforms;android-37.0 build-tools;37.0.0 platform-tools'
|
||||
|
||||
- name: Install NDK
|
||||
run: |
|
||||
echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
||||
--channel=0 \
|
||||
--install "ndk;28.2.13676358"
|
||||
echo "NDK_HOME=$ANDROID_HOME/ndk/28.2.13676358" >> $GITHUB_ENV
|
||||
--install "ndk;30.0.16248370"
|
||||
echo "NDK_HOME=$ANDROID_HOME/ndk/30.0.16248370" >> $GITHUB_ENV
|
||||
sed -i '10i\
|
||||
\
|
||||
ndkVersion = "28.2.13676358"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
|
||||
ndkVersion = "30.0.16248370"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
|
||||
|
||||
- name: Restore cached libhevtun
|
||||
id: cache-libhevtun-restore
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/libs
|
||||
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
|
||||
@@ -52,7 +55,7 @@ jobs:
|
||||
|
||||
- name: Save libhevtun
|
||||
if: steps.cache-libhevtun-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/libs
|
||||
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
|
||||
@@ -70,7 +73,7 @@ jobs:
|
||||
popd
|
||||
|
||||
- name: Download libv2ray
|
||||
uses: robinraju/release-downloader@v1.12
|
||||
uses: robinraju/release-downloader@v1.13
|
||||
with:
|
||||
repository: '2dust/AndroidLibXrayLite'
|
||||
tag: ${{ env.CURRENT_TAG }}
|
||||
@@ -78,13 +81,13 @@ jobs:
|
||||
out-file-path: V2rayNG/app/libs/
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v6.0.1
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
- name: Decode Keystore
|
||||
uses: timheuer/base64-to-file@v1.2.4
|
||||
uses: timheuer/base64-to-file@v2.0.0
|
||||
id: android_keystore
|
||||
with:
|
||||
fileName: "android_keystore.jks"
|
||||
@@ -99,31 +102,61 @@ jobs:
|
||||
./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }}
|
||||
|
||||
- name: Upload arm64-v8a APK
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ success() }}
|
||||
with:
|
||||
name: arm64-v8a
|
||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk
|
||||
|
||||
- name: Upload armeabi-v7a APK
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ success() }}
|
||||
with:
|
||||
name: armeabi-v7a
|
||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk
|
||||
|
||||
- name: Upload x86 APK
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ success() }}
|
||||
with:
|
||||
name: x86-apk
|
||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*x86*.apk
|
||||
|
||||
- name: Upload to release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
if: github.event.inputs.release_tag != ''
|
||||
- name: Upload universal APK
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ success() }}
|
||||
with:
|
||||
file: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*.apk
|
||||
tag: ${{ github.event.inputs.release_tag }}
|
||||
file_glob: true
|
||||
prerelease: true
|
||||
name: universal-apk
|
||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*universal*.apk
|
||||
|
||||
sign-and-release:
|
||||
needs: build
|
||||
runs-on: ubuntu-26.04
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
|
||||
|
||||
steps:
|
||||
- name: Download APK artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: release
|
||||
|
||||
- name: Sign APK files
|
||||
run: |
|
||||
printf '%s' "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
|
||||
KEY=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/ {print $10; exit}')
|
||||
mkdir -p release-files
|
||||
|
||||
find release -type f -name '*.apk' | while read apk; do
|
||||
cp "$apk" release-files/
|
||||
gpg --batch --yes --local-user "$KEY" --detach-sign --output "release-files/$(basename "$apk").sig" "$apk"
|
||||
done
|
||||
|
||||
gpg --armor --export "$KEY" > release-files/v2rayN-public-key.asc
|
||||
|
||||
- name: Upload APKs, signatures and public key to release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
file: release-files/*
|
||||
tag: ${{ inputs.release_tag }}
|
||||
file_glob: true
|
||||
prerelease: true
|
||||
@@ -11,6 +11,6 @@ jobs:
|
||||
go:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Validate Fastlane Supply Metadata
|
||||
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0
|
||||
@@ -21,6 +21,9 @@ V2rayNG/app/build
|
||||
V2rayNG/build
|
||||
V2rayNG/local.properties
|
||||
|
||||
# Ignore prebuilt native libraries (built from submodules by compile-hevtun.sh / AndroidLibXrayLite)
|
||||
V2rayNG/app/libs
|
||||
|
||||
# Ignore APK and AAR files
|
||||
*.apk
|
||||
*.aar
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# v2rayNG agent guide
|
||||
|
||||
## Scope and precedence
|
||||
|
||||
This file applies to the entire repository. Read the applicable scoped guide before editing any
|
||||
listed path, including when the agent starts at the repository root:
|
||||
|
||||
- Read `V2rayNG/app/src/main/java/com/v2ray/ang/service/AGENTS.md` for
|
||||
`V2rayNG/app/src/main/java/com/v2ray/ang/service/`,
|
||||
`V2rayNG/app/src/test/java/com/v2ray/ang/service/`, and these paths under
|
||||
`V2rayNG/app/src/main/java/com/v2ray/ang/`: `core/CoreServiceManager.kt`,
|
||||
`core/LauncherManager.kt`, `root/`, `contracts/ServiceControl.kt`,
|
||||
`contracts/Tun2SocksControl.kt`, `handler/NotificationManager.kt`,
|
||||
`helper/MessageHelper.kt`, and `helper/NotificationHelper.kt`.
|
||||
- Read `V2rayNG/app/src/main/java/com/v2ray/ang/ui/AGENTS.md` for
|
||||
`V2rayNG/app/src/main/java/com/v2ray/ang/ui/` and
|
||||
`V2rayNG/app/src/test/java/com/v2ray/ang/ui/`.
|
||||
|
||||
Follow both guides in scoped paths. A scoped rule overrides a root rule only when they require
|
||||
incompatible actions; every other root rule remains active.
|
||||
|
||||
## Requirement language
|
||||
|
||||
- `must`, `must not`, `only`, and `never` are mandatory. Only the current user request or linked
|
||||
acceptance criteria can state an exception; agent inference cannot.
|
||||
- `Task scope` is the named files and behavior plus dependency, test, or build edits that a
|
||||
compiler or required validation proves necessary. Every other change is outside scope.
|
||||
- `Changed behavior` includes direct and downstream build-time or runtime effects through callers,
|
||||
consumers, storage, IPC, and resources.
|
||||
- `Supported` versions, ABIs, distributions, and locales are exactly those declared by
|
||||
`V2rayNG/app/build.gradle.kts` and `.github/workflows/build.yml` inputs.
|
||||
- A behavior is `verified` only when its named check passed on branch HEAD. Otherwise list the
|
||||
exact unrun check and blocker, and do not call the behavior verified.
|
||||
|
||||
## Existing code is not a compliance baseline
|
||||
|
||||
- These guides define the target state; they do not claim that this checkout, an older commit, or
|
||||
another worktree complies. Existing code shows current behavior and is not permission to repeat
|
||||
a pattern.
|
||||
- A `touched declaration` is any function, property, class, composable, resource entry, manifest
|
||||
element, or Gradle block containing an added or removed line. Inspect it and every caller or
|
||||
consumer whose contract changes against all applicable rules before editing.
|
||||
- Every new declaration and changed behavior must comply. Inspect a helper or pattern before
|
||||
reusing it. Tests prove only their assertions; names, green builds, and prior approval prove
|
||||
nothing else.
|
||||
- Do not repair unrelated violations. Fix a violation left on a task-changed execution path in the
|
||||
same branch; if that would change behavior outside scope, request approval first. Report any
|
||||
remaining violation that blocks required validation.
|
||||
|
||||
## Agent-guide synchronization and PR boundary
|
||||
|
||||
- `AGENTS.md` files are the only rule source. `CLAUDE.md`, `GEMINI.md`, and
|
||||
`.github/copilot-instructions.md` are routing shims and must only load or point to applicable
|
||||
`AGENTS.md` files.
|
||||
- Feature, fix, and release branches use instruction files from fetched `upstream/master` as their
|
||||
baseline. If `upstream` is absent, run
|
||||
`git remote add upstream https://github.com/2dust/v2rayNG.git`; otherwise require
|
||||
`git remote get-url upstream` to print exactly that URL. Successfully run
|
||||
`git fetch upstream master` before comparison. An agent-instruction branch uses its HEAD and
|
||||
working diff as the proposal.
|
||||
- At task start and after each upstream merge or rebase, synchronize canonical instructions into
|
||||
the worktree without deleting local-only text. Compare them with:
|
||||
|
||||
```sh
|
||||
git diff upstream/master -- ':(glob)**/AGENTS.md' ':(glob)**/CLAUDE.md' ':(glob)**/GEMINI.md' .github/copilot-instructions.md
|
||||
```
|
||||
|
||||
After synchronization, every displayed difference must be inside a local-only block. If a branch
|
||||
predates the files, keep copied versions untracked or unstaged; acquire tracked copies only by
|
||||
merging or rebasing their upstream commit.
|
||||
- User-, machine-, worktree-, and temporary text is local-only. Put it in `AGENTS.md` between
|
||||
visible lines `LOCAL INSTRUCTIONS - START` and `LOCAL INSTRUCTIONS - END`.
|
||||
Unless the task targets agent instructions, treat every pre-existing instruction-file difference
|
||||
as local-only.
|
||||
- Keep local-only text unstaged and uncommitted. Never use `git add .`, `git add -A`, or
|
||||
`git commit -a` in that worktree. Stage task paths explicitly; never delete, restore, or overwrite
|
||||
local instructions to clean the worktree.
|
||||
- Before each commit, push, or PR update outside agent-instruction scope, run both commands. Both
|
||||
must print no paths; otherwise stop and remove the instruction change from the staged or branch
|
||||
diff without altering its working copy.
|
||||
|
||||
```sh
|
||||
git diff --cached --name-only -- ':(glob)**/AGENTS.md' ':(glob)**/CLAUDE.md' ':(glob)**/GEMINI.md' .github/copilot-instructions.md
|
||||
git diff --name-only upstream/master...HEAD -- ':(glob)**/AGENTS.md' ':(glob)**/CLAUDE.md' ':(glob)**/GEMINI.md' .github/copilot-instructions.md
|
||||
```
|
||||
|
||||
- Commit instruction files only for a task explicitly limited to agent instructions. The branch
|
||||
and PR may contain only `AGENTS.md` and necessary routing-shim changes; name agent instructions
|
||||
in the PR title and state that exclusive scope in the body.
|
||||
|
||||
## Project boundaries
|
||||
|
||||
- The Android project is under `V2rayNG/` and uses Kotlin, Gradle Kotlin DSL, Compose, Material 3,
|
||||
coroutines, and AndroidX lifecycle. Read dependency versions from
|
||||
`V2rayNG/gradle/libs.versions.toml`, SDK levels from `V2rayNG/app/build.gradle.kts`, and CI tools
|
||||
from `.github/workflows/build.yml`; never copy those numbers into a guide.
|
||||
- `AngApplication` initializes MMKV, app locales, WorkManager, defaults, and theme state. Persist
|
||||
application data through `MmkvManager` or `SettingsManager`; do not create `SharedPreferences`
|
||||
or another path for data they own.
|
||||
- `core/` owns native configuration and lifecycle, `handler/` data and application operations,
|
||||
`service/` Android services, and `ui/` Compose activities, components, and ViewModels. Put new
|
||||
code in its owner unless a scoped guide names a narrower owner.
|
||||
- Shared lifecycle and configuration code must retain VPN, proxy-only, and root-mode branches.
|
||||
Change one mode only when task scope names it; gate the change and preserve the other modes.
|
||||
- `AndroidLibXrayLite` is native-core source. `V2rayNG/app/libs/libv2ray.aar` and the HEV libraries
|
||||
are generated inputs; do not modify or commit them unless task scope upgrades a native dependency.
|
||||
Such a PR must identify the source revision and verify every app-declared ABI.
|
||||
|
||||
## Build and validation
|
||||
|
||||
Before building a clean checkout, initialize submodules recursively and reproduce the HEV and AAR
|
||||
steps in `.github/workflows/build.yml`. The AAR revision must match the `AndroidLibXrayLite` gitlink
|
||||
from `git ls-tree HEAD AndroidLibXrayLite`. Take build-tool versions from that workflow and
|
||||
`V2rayNG/gradle/libs.versions.toml`.
|
||||
|
||||
Run Gradle from `V2rayNG/`, using `gradlew.bat` in Windows PowerShell or `./gradlew` on POSIX. Use
|
||||
the Play Store debug variant unless task scope names another distribution.
|
||||
|
||||
Apply every matching validation rule:
|
||||
|
||||
- Documentation only: run `git diff --check`; no Gradle task is required.
|
||||
- Kotlin/Java production code: run the test class that asserts each changed behavior, then
|
||||
`:app:testPlaystoreDebugUnitTest` and `:app:compilePlaystoreDebugKotlin`.
|
||||
- Resources, manifest, Gradle, dependencies, or native packaging: run
|
||||
`:app:assemblePlaystoreDebug`.
|
||||
- F-Droid only: replace `Playstore` with `Fdroid`. Run both variants when shared code branches on a
|
||||
flavor, `BuildConfig.DISTRIBUTION`, or a flavor-specific resource or dependency.
|
||||
- Android service lifecycle, framework callback, native interaction, permission, accessibility,
|
||||
focus, or state restoration: also run an emulator or physical-device check.
|
||||
|
||||
Add or update JUnit tests under `V2rayNG/app/src/test/java/` for changed deterministic logic. Report
|
||||
an unavailable required check under the exact label `Not run`, with its command or scenario and
|
||||
reason. A compile or assembled APK does not verify runtime behavior.
|
||||
|
||||
There is no enforced Kotlin formatter. Preserve local indentation and import order. Do not change
|
||||
whitespace outside touched declarations or reorder imports except as the diff requires.
|
||||
|
||||
## Repository-wide coding rules
|
||||
|
||||
- Keep the diff inside task scope; exclude unrelated refactors, upgrades, generated files,
|
||||
translation cleanup, renames, and formatting-only changes.
|
||||
- Use server GUIDs and group IDs for persistence, asynchronous work, and UI state. Never use a
|
||||
list index, adapter position, or paging position as server or group identity.
|
||||
- Run disk, network, package-manager, native, bitmap, and CPU-intensive work off the main thread.
|
||||
Own each asynchronous operation with a named lifecycle or ViewModel scope and cancel it when the
|
||||
owner ends or newer work supersedes it.
|
||||
- Put all visible and accessibility text in Android resources. Update every locale in
|
||||
`androidResources.localeFilters` for each changed key, preserving placeholders, plurals, and
|
||||
formatting tags.
|
||||
- Never rename or delete a persisted, serialized, routing, import, or export field without a
|
||||
backward-compatible migration and a regression test reading the preceding released format.
|
||||
Multi-record writes must complete together or restore/remove partial writes on every failure.
|
||||
- Log recoverable failures through `LogUtil`, including operation, mode/component, non-secret
|
||||
stable ID when available, and exception. Never log credentials, full proxy URLs, private keys,
|
||||
or exported configurations.
|
||||
- Before adding a constant, helper, manager, repository, or state holder, search its owner. Extend
|
||||
an owner that controls the same data or lifecycle. Create a new abstraction only for different
|
||||
ownership or lifetime, and state that distinction in code or the PR.
|
||||
- A platform workaround needs a comment naming its Android/API or vendor boundary, prevented
|
||||
failure, and exact removal condition.
|
||||
|
||||
## Deprecated, experimental, and version-gated APIs
|
||||
|
||||
- Introduce a deprecated API only for a supported-version fallback lacking the replacement, a
|
||||
required deprecated callback/interface member, or a documented platform/vendor defect that
|
||||
blocks acceptance criteria. The defect requires a test or linked upstream issue.
|
||||
- Isolate each permitted deprecated call in one compatibility function or adapter. Comment the
|
||||
reason and an exact removal trigger. Use the stable API wherever available.
|
||||
- Scope `@Suppress("DEPRECATION")` or `@SuppressLint("<IssueId>")` to the containing expression or function.
|
||||
Class scope is allowed only when every member implements the same required deprecated interface;
|
||||
file, package, and module scope is forbidden. Never suppress permission, background-execution,
|
||||
lifecycle, or security requirements.
|
||||
- Guard every API above `minSdk` by SDK or extension version in the same function, or use
|
||||
`@RequiresApi` only when every entry point is guarded. Mark reusable guards
|
||||
`@ChecksSdkIntAtLeast`. Lower versions must use an
|
||||
existing API, return explicit unsupported status, or hide/disable the feature; never crash or
|
||||
report false success.
|
||||
- Add or expand an experimental API only when no stable API meets named acceptance criteria, its
|
||||
dependency is pinned in `libs.versions.toml`, a project-owned stable interface contains it and
|
||||
excludes it from persisted data, IPC payloads, and public shared contracts, a regression test
|
||||
covers it, and a comment names the opt-in marker and exact reevaluation condition.
|
||||
- Put `@OptIn` on the direct function/property, or on a class only when multiple members need it.
|
||||
File and module opt-ins are forbidden. Existing experimental use outside scope does not permit a
|
||||
new call site.
|
||||
- A preview/canary SDK or alpha dependency is allowed in production only when the request or linked
|
||||
issue requires it. Keep a stable path for supported non-preview devices, gate by runtime API or
|
||||
feature availability, and test both paths. After an API guard, opt-in, or lint suppression, run
|
||||
`:app:lintPlaystoreDebug` and also `:app:lintFdroidDebug` when behavior differs by distribution.
|
||||
+1
-1
Submodule AndroidLibXrayLite updated: d783dc8ea7...ea96a7f9c3.
@@ -3,30 +3,88 @@
|
||||
A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-core) and [v2fly core](https://github.com/v2fly/v2ray-core)
|
||||
|
||||
[](https://developer.android.com/about/versions/lollipop)
|
||||
[](https://kotlinlang.org)
|
||||
[](https://kotlinlang.org)
|
||||
[](https://github.com/2dust/v2rayNG/commits/master)
|
||||
[](https://www.codefactor.io/repository/github/2dust/v2rayng)
|
||||
[](https://github.com/2dust/v2rayNG/releases)
|
||||
[](https://t.me/v2rayn)
|
||||
|
||||
### Telegram Channel
|
||||
[github_2dust](https://t.me/github_2dust)
|
||||
---
|
||||
|
||||
### Usage
|
||||
## Download / 下载
|
||||
|
||||
Download the latest release here:
|
||||
|
||||
在这里下载最新版本:
|
||||
|
||||
[https://github.com/2dust/v2rayNG/releases](https://github.com/2dust/v2rayNG/releases)
|
||||
|
||||
> [!TIP]
|
||||
> v2rayNG is the mobile version. For the desktop version, please visit the v2rayN \
|
||||
> v2rayNG 是手机版,电脑版请访问 v2rayN
|
||||
>
|
||||
> https://github.com/2dust/v2rayN
|
||||
|
||||
---
|
||||
|
||||
### Geoip and Geosite
|
||||
|
||||
#### 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)
|
||||
- download feature will get enhanced version in this [repo](https://github.com/Loyalsoldier/v2ray-rules-dat) (note: it needs a working proxy)
|
||||
- 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)
|
||||
- possible to use a 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)
|
||||
More in our [wiki](https://github.com/2dust/v2rayNG/wiki)
|
||||
|
||||
### Development guide
|
||||
### Geoip 与 Geosite
|
||||
|
||||
Android project under V2rayNG folder can be compiled directly in Android Studio, or using Gradle wrapper. But the v2ray core inside the aar is (probably) outdated.
|
||||
The aar can be compiled from the Golang project [AndroidLibV2rayLite](https://github.com/2dust/AndroidLibV2rayLite) or [AndroidLibXrayLite](https://github.com/2dust/AndroidLibXrayLite).
|
||||
For a quick start, read guide for [Go Mobile](https://github.com/golang/go/wiki/Mobile) and [Makefiles for Go Developers](https://tutorialedge.net/golang/makefiles-for-go-developers/)
|
||||
- geoip.dat 和 geosite.dat 文件位于 `Android/data/com.v2ray.ang/files/assets`(部分设备路径可能不同)
|
||||
- 下载功能将获取该 [仓库](https://github.com/Loyalsoldier/v2ray-rules-dat) 中的增强版本(注意:此功能需要一个可用的代理)
|
||||
- 最新官方 [域名列表](https://github.com/Loyalsoldier/v2ray-rules-dat) 和 [IP 列表](https://github.com/Loyalsoldier/geoip) 可手动导入
|
||||
- 也可在同一文件夹中使用第三方 dat 文件,例如 [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)
|
||||
|
||||
v2rayNG can run on Android Emulators. For WSA, VPN permission need to be granted via
|
||||
`appops set [package name] ACTIVATE_VPN allow`
|
||||
更多内容请见我们的 [wiki](https://github.com/2dust/v2rayNG/wiki)
|
||||
|
||||
---
|
||||
|
||||
## Development guide / 开发指南
|
||||
|
||||
### Note
|
||||
|
||||
- Android project under the V2rayNG folder can be compiled directly in Android Studio, or using the Gradle wrapper. But the v2ray core inside the aar is (probably) outdated.
|
||||
- The aar can be compiled from the Golang project [AndroidLibV2rayLite](https://github.com/2dust/AndroidLibV2rayLite) or [AndroidLibXrayLite](https://github.com/2dust/AndroidLibXrayLite). For a quick start, read the guides for [Go Mobile](https://github.com/golang/go/wiki/Mobile) and [Makefiles for Go Developers](https://tutorialedge.net/golang/makefiles-for-go-developers/).
|
||||
- v2rayNG can run on Android Emulators. For WSA, VPN permission needs to be granted via `appops set [package name] ACTIVATE_VPN allow`.
|
||||
|
||||
### 提示
|
||||
|
||||
- V2rayNG 文件夹下的 Android 项目可直接在 Android Studio 中编译,或使用 Gradle wrapper 编译。但 aar 内置的 v2ray core(可能)已过时。
|
||||
- aar 可由 Golang 项目 [AndroidLibV2rayLite](https://github.com/2dust/AndroidLibV2rayLite) 或 [AndroidLibXrayLite](https://github.com/2dust/AndroidLibXrayLite) 编译而成。快速入门可参考 [Go Mobile](https://github.com/golang/go/wiki/Mobile) 指南和 [Makefiles for Go Developers](https://tutorialedge.net/golang/makefiles-for-go-developers/)。
|
||||
- v2rayNG 可在 Android 模拟器上运行。对于 WSA,需要通过 `appops set [package name] ACTIVATE_VPN allow` 授予 VPN 权限。
|
||||
|
||||
---
|
||||
|
||||
|
||||
## GPG Verification / GPG 签名校验
|
||||
|
||||
Release files are signed with GPG to verify authenticity and integrity, helping prevent mirror, ISP, or CDN hijacking.
|
||||
|
||||
发布文件已使用 GPG 签名,可用于校验文件真实性与完整性,预防镜像站、运营商或 CDN 劫持。
|
||||
|
||||
### Fingerprint / 公钥指纹
|
||||
|
||||
```text
|
||||
7694 5E9F 3E9A 168F 8070 F195 805D 661C
|
||||
134D FAF6 8903 C199 463C 31E5 AE90 3AE0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Community / 社区
|
||||
|
||||
Telegram Group / Telegram 群组:
|
||||
|
||||
[https://t.me/v2rayN](https://t.me/v2rayN)
|
||||
|
||||
Telegram Channel / Telegram 频道:
|
||||
|
||||
[https://t.me/github_2dust](https://t.me/github_2dust)
|
||||
@@ -1,27 +1,27 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
id("com.jaredsburrows.license")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.v2ray.ang"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 708
|
||||
versionName = "2.0.8"
|
||||
multiDexEnabled = true
|
||||
targetSdk = 37
|
||||
versionCode = 750
|
||||
versionName = "2.3.10"
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
reset()
|
||||
if (abiFilterList != null && abiFilterList.isNotEmpty()) {
|
||||
if (!abiFilterList.isNullOrEmpty()) {
|
||||
include(*abiFilterList.toTypedArray())
|
||||
} else {
|
||||
include(
|
||||
@@ -124,8 +124,23 @@ android {
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
compose = true
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
localeFilters += listOf(
|
||||
"en",
|
||||
"zh-rCN",
|
||||
"zh-rTW",
|
||||
"vi",
|
||||
"ru",
|
||||
"fa",
|
||||
"ar",
|
||||
"bn",
|
||||
"bqi-rIR"
|
||||
)
|
||||
}
|
||||
|
||||
packaging {
|
||||
@@ -134,6 +149,17 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.all {
|
||||
it.useJUnitPlatform()
|
||||
// Treat Jupiter discovery warnings as failures: JUnit 4 failed on an invalid @Test
|
||||
// method, such as one that returns a value, while Jupiter only warns and skips it.
|
||||
// The key is @API(status = EXPERIMENTAL) in JUnit 6; after a JUnit upgrade, check
|
||||
// that a temporary `@Test fun probe() = 1` still fails the unit test task.
|
||||
it.systemProperty("junit.platform.discovery.issue.severity.critical", "WARNING")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -143,19 +169,20 @@ dependencies {
|
||||
// AndroidX Core Libraries
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
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.toasty)
|
||||
implementation(libs.editorkit)
|
||||
implementation(libs.flexbox)
|
||||
// Compose Libraries
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
// Data and Storage Libraries
|
||||
implementation(libs.mmkv.static)
|
||||
@@ -166,31 +193,31 @@ dependencies {
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
// Language and Processing Libraries
|
||||
implementation(libs.language.base)
|
||||
implementation(libs.language.json)
|
||||
|
||||
// Intent and Utility Libraries
|
||||
implementation(libs.quickie.foss)
|
||||
implementation(libs.core)
|
||||
// QR Code: CameraX + ZXing
|
||||
implementation(libs.camerax.core)
|
||||
implementation(libs.camerax.camera2)
|
||||
implementation(libs.camerax.lifecycle)
|
||||
implementation(libs.camerax.compose)
|
||||
implementation(libs.core) // zxing core
|
||||
|
||||
// AndroidX Lifecycle and Architecture Components
|
||||
implementation(libs.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.lifecycle.livedata.ktx)
|
||||
implementation(libs.lifecycle.runtime.ktx)
|
||||
|
||||
// Background Task Libraries
|
||||
implementation(libs.work.runtime.ktx)
|
||||
implementation(libs.work.multiprocess)
|
||||
|
||||
// Multidex Support
|
||||
implementation(libs.multidex)
|
||||
// Reorderable list
|
||||
implementation(libs.reorderable)
|
||||
|
||||
// Testing Libraries
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(platform(libs.junit.bom))
|
||||
testImplementation(libs.junit.jupiter)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
testImplementation(libs.org.mockito.mockito.inline)
|
||||
testImplementation(libs.mockito.core)
|
||||
testImplementation(libs.mockito.kotlin)
|
||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetClass="com.v2ray.ang.ui.ScSwitchActivity"
|
||||
android:targetClass="com.v2ray.ang.ui.shortcut.ScSwitchActivity"
|
||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
@@ -26,8 +26,38 @@
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetClass="com.v2ray.ang.ui.ScScannerActivity"
|
||||
android:targetClass="com.v2ray.ang.ui.shortcut.ScScannerActivity"
|
||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
</shortcuts>
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_qu_start_24dp"
|
||||
android:shortcutDisabledMessage="@string/acc_start"
|
||||
android:shortcutId="shortcuts_start"
|
||||
android:shortcutLongLabel="@string/acc_start"
|
||||
android:shortcutShortLabel="@string/acc_start">
|
||||
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetClass="com.v2ray.ang.ui.shortcut.ScStartActivity"
|
||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_qu_stop_24dp"
|
||||
android:shortcutDisabledMessage="@string/acc_stop"
|
||||
android:shortcutId="shortcuts_stop"
|
||||
android:shortcutLongLabel="@string/acc_stop"
|
||||
android:shortcutShortLabel="@string/acc_stop">
|
||||
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetClass="com.v2ray.ang.ui.shortcut.ScStopActivity"
|
||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
</shortcuts>
|
||||
@@ -29,16 +29,16 @@
|
||||
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.ACCESS_LOCAL_NETWORK" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"
|
||||
tools:ignore="ForegroundServicesPolicy" />
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
|
||||
android:minSdkVersion="34" />
|
||||
<!-- <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
@@ -52,8 +52,17 @@
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:name=".ui.main.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
@@ -71,56 +80,106 @@
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.ServerActivity"
|
||||
android:name=".ui.server.ServerVmessActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged" />
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.ServerCustomConfigActivity"
|
||||
android:name=".ui.server.ServerVlessActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged" />
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.ServerGroupActivity"
|
||||
android:name=".ui.server.ServerShadowsocksActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged" />
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.SettingsActivity"
|
||||
android:name=".ui.server.ServerSocksActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.server.ServerHttpActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.server.ServerTrojanActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.server.ServerWireguardActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.server.ServerHysteria2Activity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.server.ServerCustomConfigActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.server.ServerGroupActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.server.ServerProxyChainActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize" />
|
||||
<activity
|
||||
android:name=".ui.settings.SettingsActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.PerAppProxyActivity"
|
||||
android:name=".ui.perappproxy.PerAppProxyActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.apppicker.AppPickerActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.ScannerActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.LogcatActivity"
|
||||
android:name=".ui.logcat.LogcatActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.RoutingSettingActivity"
|
||||
android:name=".ui.routing.RoutingSettingActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.RoutingEditActivity"
|
||||
android:name=".ui.routing.RoutingEditActivity"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.SubSettingActivity"
|
||||
android:name=".ui.subscription.SubSettingActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.UserAssetActivity"
|
||||
android:name=".ui.userasset.UserAssetActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.UserAssetUrlActivity"
|
||||
android:name=".ui.userasset.UserAssetUrlActivity"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.SubEditActivity"
|
||||
android:name=".ui.subscription.SubEditActivity"
|
||||
android:windowSoftInputMode="stateUnchanged|adjustResize"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.ScScannerActivity"
|
||||
android:name=".ui.shortcut.ScScannerActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.ScSwitchActivity"
|
||||
android:name=".ui.shortcut.ScSwitchActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:process=":daemon"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
<activity
|
||||
android:name=".ui.shortcut.ScStartActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":daemon"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
<activity
|
||||
android:name=".ui.shortcut.ScStopActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":daemon"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
|
||||
<activity
|
||||
@@ -143,23 +202,26 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.CheckUpdateActivity"
|
||||
android:name=".ui.checkupdate.CheckUpdateActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.BackupActivity"
|
||||
android:name=".ui.backup.BackupActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.AboutActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.TranslatorsActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".service.V2RayVpnService"
|
||||
android:name=".service.CoreVpnService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:process=":daemon"
|
||||
tools:ignore="VpnServicePolicy">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
@@ -173,25 +235,51 @@
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".service.V2RayProxyOnlyService"
|
||||
android:name=".service.CoreProxyOnlyService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:label="@string/app_name"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":daemon">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="proxy" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".service.V2RayTestService"
|
||||
android:name=".service.CoreRootService"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon" />
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:label="@string/app_name"
|
||||
android:process=":daemon">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="proxy" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".service.CoreTestService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:process=":tasks">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="test" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".service.SubscriptionUpdateService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:process=":tasks">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="test" />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".receiver.WidgetProvider"
|
||||
android:exported="true"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":daemon">
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/app_widget_provider" />
|
||||
@@ -207,6 +295,8 @@
|
||||
android:label="BootReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
@@ -217,7 +307,7 @@
|
||||
android:icon="@drawable/ic_stat_name"
|
||||
android:label="@string/app_tile_name"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":daemon">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
@@ -227,7 +317,7 @@
|
||||
</service>
|
||||
<!-- =====================Tasker===================== -->
|
||||
<activity
|
||||
android:name=".ui.TaskerActivity"
|
||||
android:name=".ui.shortcut.TaskerActivity"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<intent-filter>
|
||||
@@ -238,7 +328,7 @@
|
||||
<receiver
|
||||
android:name=".receiver.TaskerReceiver"
|
||||
android:exported="true"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:process=":daemon"
|
||||
tools:ignore="ExportedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="com.twofortyfouram.locale.intent.action.FIRE_SETTING" />
|
||||
@@ -258,6 +348,11 @@
|
||||
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name="androidx.work.multiprocess.RemoteWorkManagerService"
|
||||
android:exported="false"
|
||||
android:process=":bg" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.cache"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
[
|
||||
{
|
||||
"language": "العربية (Arabic)",
|
||||
"contributors": [
|
||||
{ "name": "@mribrahem", "displayName": "ibrahem Qasim", "url": "https://github.com/mribrahem" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "বাংলা (Bengali)",
|
||||
"contributors": [
|
||||
{ "name": "@codewithtamim", "displayName": "Tamim Hossain", "url": "https://github.com/codewithtamim" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "لۊری بختیاری (Luri Bakhtiari)",
|
||||
"contributors": [
|
||||
{ "name": "@hosseinabaspanah", "displayName": "Hossein Abaspanah", "url": "https://github.com/hosseinabaspanah" },
|
||||
{ "name": "@codewithtamim", "displayName": "Tamim Hossain", "url": "https://github.com/codewithtamim" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "فارسی (Persian)",
|
||||
"contributors": [
|
||||
{ "name": "@themrvx", "displayName": "MRVX", "url": "https://github.com/themrvx" },
|
||||
{ "name": "@skh-web6982", "url": "https://github.com/skh-web6982" },
|
||||
{ "name": "@ptechgithub", "displayName": "Peyman", "url": "https://github.com/ptechgithub" },
|
||||
{ "name": "@pk-web6936" },
|
||||
{ "name": "@alphax-hue3682" },
|
||||
{ "name": "@phoenix6936" },
|
||||
{ "name": "@decorativefamily" },
|
||||
{ "name": "@decorativeman" },
|
||||
{ "name": "@mh292929", "displayName": "MH", "url": "https://github.com/mh292929" },
|
||||
{ "name": "@amir-yazdanmanesh", "displayName": "AmirMohammad Yazdanmanesh", "url": "https://github.com/amir-yazdanmanesh" },
|
||||
{ "name": "@cumoon", "displayName": "C_U_Moon", "url": "https://github.com/cumoon" },
|
||||
{ "name": "@hadi-norouzi", "displayName": "Hadi Norouzi", "url": "https://github.com/hadi-norouzi" },
|
||||
{ "name": "Vahid Farid" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Русский (Russian)",
|
||||
"contributors": [
|
||||
{ "name": "@solokot", "url": "https://github.com/solokot" },
|
||||
{ "name": "@liniya", "url": "https://github.com/liniya" },
|
||||
{ "name": "@eliotcougar", "url": "https://github.com/eliotcougar" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Tiếng Việt (Vietnamese)",
|
||||
"contributors": [
|
||||
{ "name": "@admarty", "url": "https://github.com/admarty" },
|
||||
{ "name": "@user09283", "url": "https://github.com/user09283" },
|
||||
{ "name": "@yuhan6665", "url": "https://github.com/yuhan6665" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "简体中文 (Simplified Chinese)",
|
||||
"contributors": [
|
||||
{ "name": "@yau08", "displayName": "David", "url": "https://github.com/yau08" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "繁體中文 (Traditional Chinese)",
|
||||
"contributors": [
|
||||
{ "name": "@yau08", "displayName": "David", "url": "https://github.com/yau08" },
|
||||
{ "name": "@fubuki0x10de", "url": "https://github.com/fubuki0x10de" }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,99 +1,86 @@
|
||||
{
|
||||
"stats":{},
|
||||
"stats": {},
|
||||
"log": {
|
||||
"loglevel": "warning"
|
||||
},
|
||||
"policy":{
|
||||
"levels": {
|
||||
"8": {
|
||||
"handshake": 4,
|
||||
"connIdle": 300,
|
||||
"uplinkOnly": 1,
|
||||
"downlinkOnly": 1
|
||||
"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",
|
||||
"quic"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"tag": "proxy",
|
||||
"protocol": "vmess",
|
||||
"settings": {
|
||||
"address": "v2ray.cool",
|
||||
"port": 10086
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp"
|
||||
},
|
||||
"mux": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"protocol": "freedom",
|
||||
"streamSettings": {
|
||||
"sockopt": {
|
||||
"domainStrategy": "UseIP",
|
||||
"happyEyeballs": {
|
||||
"tryDelayMs": 250,
|
||||
"interleave": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"statsOutboundUplink": true,
|
||||
"statsOutboundDownlink": true
|
||||
}
|
||||
},
|
||||
"inbounds": [{
|
||||
"tag": "socks",
|
||||
"port": 10808,
|
||||
"protocol": "socks",
|
||||
"settings": {
|
||||
"auth": "noauth",
|
||||
"udp": true,
|
||||
"userLevel": 8
|
||||
"tag": "direct"
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"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
|
||||
}
|
||||
]
|
||||
{
|
||||
"protocol": "blackhole",
|
||||
"tag": "block",
|
||||
"settings": {
|
||||
"response": {
|
||||
"type": "http"
|
||||
}
|
||||
],
|
||||
"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": []
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": []
|
||||
},
|
||||
"dns": {
|
||||
"hosts": {},
|
||||
"servers": []
|
||||
"hosts": {},
|
||||
"servers": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,116 +1,103 @@
|
||||
{
|
||||
"stats":{},
|
||||
"stats": {},
|
||||
"log": {
|
||||
"loglevel": "warning"
|
||||
},
|
||||
"policy":{
|
||||
"levels": {
|
||||
"8": {
|
||||
"handshake": 4,
|
||||
"connIdle": 300,
|
||||
"uplinkOnly": 1,
|
||||
"downlinkOnly": 1
|
||||
"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",
|
||||
"quic"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "tun",
|
||||
"protocol": "tun",
|
||||
"settings": {
|
||||
"name": "xray0",
|
||||
"MTU": 1500,
|
||||
"userLevel": 8
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"tag": "proxy",
|
||||
"protocol": "vmess",
|
||||
"settings": {
|
||||
"address": "v2ray.cool",
|
||||
"port": 10086
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "tcp"
|
||||
},
|
||||
"mux": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"protocol": "freedom",
|
||||
"streamSettings": {
|
||||
"sockopt": {
|
||||
"domainStrategy": "UseIP",
|
||||
"happyEyeballs": {
|
||||
"tryDelayMs": 250,
|
||||
"interleave": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"statsOutboundUplink": true,
|
||||
"statsOutboundDownlink": true
|
||||
}
|
||||
},
|
||||
"inbounds": [{
|
||||
"tag": "socks",
|
||||
"port": 10808,
|
||||
"protocol": "socks",
|
||||
"settings": {
|
||||
"auth": "noauth",
|
||||
"udp": true,
|
||||
"userLevel": 8
|
||||
"tag": "direct"
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "tun",
|
||||
"port": 0,
|
||||
"protocol": "tun",
|
||||
"settings": {
|
||||
"name": "xray0",
|
||||
"MTU": 1500,
|
||||
"userLevel": 8
|
||||
},
|
||||
"sniffing": {
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"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
|
||||
}
|
||||
]
|
||||
{
|
||||
"protocol": "blackhole",
|
||||
"tag": "block",
|
||||
"settings": {
|
||||
"response": {
|
||||
"type": "http"
|
||||
}
|
||||
],
|
||||
"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": []
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": []
|
||||
},
|
||||
"dns": {
|
||||
"hosts": {},
|
||||
"servers": []
|
||||
"hosts": {},
|
||||
"servers": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
package com.v2ray.ang
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.multidex.MultiDexApplication
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.handler.AppLocaleManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.ui.compose.ThemeManager
|
||||
|
||||
class AngApplication : MultiDexApplication() {
|
||||
class AngApplication : Application() {
|
||||
companion object {
|
||||
lateinit var application: AngApplication
|
||||
}
|
||||
@@ -18,7 +21,7 @@ class AngApplication : MultiDexApplication() {
|
||||
* @param base The base context.
|
||||
*/
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
super.attachBaseContext(base?.let(ContextCompat::getContextForLanguage))
|
||||
application = this
|
||||
}
|
||||
|
||||
@@ -32,18 +35,17 @@ class AngApplication : MultiDexApplication() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
MMKV.initialize(this)
|
||||
MmkvManager.initialize(this)
|
||||
|
||||
AppLocaleManager.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)
|
||||
// Ensure critical preference defaults are present in MMKV early
|
||||
SettingsManager.initApp(this)
|
||||
|
||||
es.dmoral.toasty.Toasty.Config.getInstance()
|
||||
.setGravity(android.view.Gravity.BOTTOM, 0, 200)
|
||||
.apply()
|
||||
// Initialize theme state from MMKV
|
||||
ThemeManager.refresh()
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,15 @@ object AppConfig {
|
||||
/** Directory names used in the app's file system. */
|
||||
const val DIR_ASSETS = "assets"
|
||||
|
||||
const val WEBDAV_BACKUP_DIR = "backups"
|
||||
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
|
||||
const val WEBDAV_BACKUP_DIR = "backups"
|
||||
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
|
||||
|
||||
/** Legacy configuration keys. */
|
||||
const val ANG_CONFIG = "ang_config"
|
||||
|
||||
// Default subscription ID for ungrouped servers
|
||||
const val DEFAULT_SUBSCRIPTION_ID = "__default_subscription__"
|
||||
|
||||
/** Preferences mapped to MMKV storage. */
|
||||
const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
|
||||
const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled"
|
||||
@@ -40,20 +43,31 @@ object AppConfig {
|
||||
const val PREF_FRAGMENT_PACKETS = "pref_fragment_packets"
|
||||
const val PREF_FRAGMENT_LENGTH = "pref_fragment_length"
|
||||
const val PREF_FRAGMENT_INTERVAL = "pref_fragment_interval"
|
||||
const val SUBSCRIPTION_AUTO_UPDATE = "pref_auto_update_subscription"
|
||||
const val SUBSCRIPTION_AUTO_UPDATE_INTERVAL = "pref_auto_update_interval"
|
||||
const val SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL = "1440" // Default is 24 hours
|
||||
const val PREF_FRAGMENT_MAXSPLIT = "pref_fragment_maxsplit"
|
||||
const val PREF_OBSERVATORY_LEAST_PING_INTERVAL = "pref_observatory_least_ping_interval"
|
||||
const val PREF_OBSERVATORY_LEAST_LOAD_INTERVAL = "pref_observatory_least_load_interval"
|
||||
const val PREF_OBSERVATORY_LEAST_LOAD_METHOD = "pref_observatory_least_load_method"
|
||||
const val PREF_OBSERVATORY_LEAST_LOAD_SAMPLING = "pref_observatory_least_load_sampling"
|
||||
const val PREF_OBSERVATORY_LEAST_LOAD_TIMEOUT = "pref_observatory_least_load_timeout"
|
||||
const val SUBSCRIPTION_UPDATE_TASK_NAME = "subscription_updater"
|
||||
const val SUBSCRIPTION_MIN_INTERVAL_MINUTES = 15L
|
||||
const val PREF_SPEED_ENABLED = "pref_speed_enabled"
|
||||
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
|
||||
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
|
||||
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
|
||||
const val PREF_GROUP_ALL_DISPLAY = "pref_group_all_display"
|
||||
const val PREF_LANGUAGE = "pref_language"
|
||||
const val PREF_APP_LOCALE_MIGRATED = "pref_app_locale_migrated"
|
||||
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
|
||||
const val PREF_DYNAMIC_COLOR = "pref_dynamic_color"
|
||||
const val PREF_IPV6_ENABLED = "pref_ipv6_enabled"
|
||||
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
|
||||
const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
|
||||
const val PREF_ALLOW_INSECURE = "pref_allow_insecure"
|
||||
const val PREF_ENABLE_LOCAL_PROXY = "pref_enable_local_proxy"
|
||||
const val PREF_SOCKS_PORT = "pref_socks_port"
|
||||
const val PREF_DYNAMIC_SOCKS_PORT = "pref_dynamic_socks_port"
|
||||
const val PREF_SOCKS_USERNAME = "pref_socks_username"
|
||||
const val PREF_SOCKS_PASSWORD = "pref_socks_password"
|
||||
const val PREF_SOCKS_ENABLE_UDP = "pref_socks_enable_udp"
|
||||
const val PREF_REMOTE_DNS = "pref_remote_dns"
|
||||
const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
|
||||
const val PREF_DNS_HOSTS = "pref_dns_hosts"
|
||||
@@ -62,18 +76,22 @@ object AppConfig {
|
||||
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_ROOT_MODE_ENABLE = "pref_root_mode_enabled"
|
||||
const val PREF_ROOT_LAN_SHARING = "pref_root_lan_sharing"
|
||||
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_UPDATE_SUBSCRIPTION = "pref_update_subscription"
|
||||
const val PREF_AUTO_TEST_AFTER_UPDATE_SUBSCRIPTION = "pref_auto_test_after_update_subscription"
|
||||
const val PREF_AUTO_REMOVE_INVALID_AFTER_TEST = "pref_auto_remove_invalid_after_test"
|
||||
const val PREF_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
|
||||
const val PREF_REAL_PING_CONCURRENCY = "pref_real_ping_concurrency"
|
||||
|
||||
/** Cache keys. */
|
||||
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
|
||||
const val CACHE_KEYWORD_FILTER = "cache_keyword_filter"
|
||||
|
||||
/** Protocol identifiers. */
|
||||
const val PROTOCOL_FREEDOM = "freedom"
|
||||
@@ -97,7 +115,8 @@ object AppConfig {
|
||||
const val TAG_FRAGMENT = "fragment"
|
||||
const val TAG_DNS = "dns-module"
|
||||
const val TAG_DOMESTIC_DNS = "domestic-dns"
|
||||
const val TAG_BALANCER = "proxy-round"
|
||||
const val TAG_BALANCER = "balancer-main"
|
||||
const val TAG_BALANCER_PRE = "balancer"
|
||||
|
||||
/** Network-related constants. */
|
||||
const val UPLINK = "uplink"
|
||||
@@ -117,11 +136,17 @@ object AppConfig {
|
||||
const val TG_CHANNEL_URL = "https://t.me/github_2dust"
|
||||
const val DELAY_TEST_URL = "https://www.gstatic.com/generate_204"
|
||||
const val DELAY_TEST_URL2 = "https://www.google.com/generate_204"
|
||||
// const val IP_API_URL = "https://speed.cloudflare.com/meta"
|
||||
const val OBSERVATORY_LEAST_PING_INTERVAL = "3m"
|
||||
const val OBSERVATORY_LEAST_LOAD_INTERVAL = "5m"
|
||||
const val OBSERVATORY_LEAST_LOAD_METHOD = "HEAD"
|
||||
const val OBSERVATORY_LEAST_LOAD_SAMPLING = "2"
|
||||
const val OBSERVATORY_LEAST_LOAD_TIMEOUT = "30s"
|
||||
|
||||
// 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"
|
||||
const val DNS_PROXY = "https://cloudflare-dns.com/dns-query"
|
||||
const val DNS_DIRECT = "223.5.5.5"
|
||||
const val DNS_VPN = "1.1.1.1"
|
||||
const val GEOSITE_PRIVATE = "geosite:private"
|
||||
@@ -129,14 +154,28 @@ object AppConfig {
|
||||
const val GEOIP_PRIVATE = "geoip:private"
|
||||
const val GEOIP_CN = "geoip:cn"
|
||||
|
||||
/** Geo data file names. */
|
||||
const val GEOSITE_DAT = "geosite.dat"
|
||||
const val GEOIP_DAT = "geoip.dat"
|
||||
const val GEOIP_ONLY_CN_PRIVATE_DAT = "geoip-only-cn-private.dat"
|
||||
const val GEOIP_ONLY_CN_PRIVATE_URL = "$GITHUB_RAW_URL/Loyalsoldier/geoip/release/$GEOIP_ONLY_CN_PRIVATE_DAT"
|
||||
|
||||
/** Ports and addresses for various services. */
|
||||
const val PORT_LOCAL_DNS = "10853"
|
||||
const val PORT_SOCKS = "10808"
|
||||
const val WIREGUARD_LOCAL_ADDRESS_V4 = "172.16.0.2/32"
|
||||
const val WIREGUARD_LOCAL_ADDRESS_V6 = "2606:4700:110:8f81:d551:a0:532e:a2b3/128"
|
||||
const val WIREGUARD_LOCAL_MTU = "1420"
|
||||
const val WIREGUARD_LOCAL_REMOTE_DNS = "1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001"
|
||||
const val LOOPBACK = "127.0.0.1"
|
||||
|
||||
/** Shared defaults for settings shown in the UI and consumed by config generation. */
|
||||
const val DEFAULT_SOCKS_ENABLE_UDP = true
|
||||
const val DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD = "1"
|
||||
const val DEFAULT_VPN_BYPASS_LAN = "1"
|
||||
const val DEFAULT_HEV_TUNNEL_LOGLEVEL = "warn"
|
||||
const val DEFAULT_MUX_XUDP_CONCURRENCY = "8"
|
||||
|
||||
/** Message constants for communication. */
|
||||
const val MSG_REGISTER_CLIENT = 1
|
||||
const val MSG_STATE_RUNNING = 11
|
||||
@@ -149,24 +188,28 @@ object AppConfig {
|
||||
const val MSG_STATE_STOP_SUCCESS = 41
|
||||
const val MSG_STATE_RESTART = 5
|
||||
const val MSG_MEASURE_DELAY = 6
|
||||
const val MSG_MEASURE_DELAY_SUCCESS = 61
|
||||
const val MSG_MEASURE_CONFIG = 7
|
||||
const val MSG_MEASURE_CONFIG_SUCCESS = 71
|
||||
const val MSG_MEASURE_CONFIG_CANCEL = 72
|
||||
const val MSG_MEASURE_DELAY_RESULT = 61
|
||||
const val MSG_MEASURE_DELAY_CANCEL = 62
|
||||
const val MSG_MEASURE_CONFIG_START = 7
|
||||
const val MSG_MEASURE_CONFIG_CANCEL = 71
|
||||
const val MSG_MEASURE_CONFIG_SUCCESS = 72
|
||||
const val MSG_MEASURE_CONFIG_NOTIFY = 73
|
||||
const val MSG_MEASURE_CONFIG_FINISH = 74
|
||||
|
||||
const val MSG_SUB_UPDATE_START = 8
|
||||
const val MSG_SUB_UPDATE_CANCEL = 81
|
||||
|
||||
/** Notification channel IDs and names. */
|
||||
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
|
||||
const val RAY_NG_CHANNEL_NAME = "v2rayNG Background Service"
|
||||
const val SUBSCRIPTION_UPDATE_CHANNEL = "subscription_update_channel"
|
||||
const val SUBSCRIPTION_UPDATE_CHANNEL_NAME = "Subscription Update Service"
|
||||
// Use a new ID because Android does not let an app raise an existing channel's importance.
|
||||
const val RAY_NG_CHANNEL_ID = "CORE_M_CH_ID_V2"
|
||||
|
||||
/** Protocols Scheme **/
|
||||
const val VMESS = "vmess://"
|
||||
const val CUSTOM = ""
|
||||
const val SHADOWSOCKS = "ss://"
|
||||
const val SOCKS = "socks://"
|
||||
const val SOCKS4 = "socks4://"
|
||||
const val SOCKS5 = "socks5://"
|
||||
const val HTTP = "http://"
|
||||
const val VLESS = "vless://"
|
||||
const val TROJAN = "trojan://"
|
||||
@@ -175,11 +218,34 @@ object AppConfig {
|
||||
const val HYSTERIA = "hysteria://"
|
||||
const val HYSTERIA2 = "hysteria2://"
|
||||
const val HY2 = "hy2://"
|
||||
const val V2RAYNFMTS = "v2rayn://"
|
||||
|
||||
/** Give a good name to this, IDK*/
|
||||
const val VPN = "VPN"
|
||||
const val VPN_MTU = 1500
|
||||
|
||||
/** Root (system-wide) mode runtime constants. */
|
||||
const val ROOT_RUNTIME_DIR = "sys_cache"
|
||||
const val ROOT_IPTABLES_CHAIN = "CORE_FILTER"
|
||||
const val ROOT_FWMARK = 255 // defensive RETURN tag; hev's only upstream socket is loopback (already bypassed)
|
||||
const val ROOT_MARK_ROUTE = 1 // packets we want pushed into the tun device
|
||||
const val ROOT_ROUTE_TABLE = 2024
|
||||
const val ROOT_RULE_PRIORITY = 1000
|
||||
const val ROOT_TUN_NAME = "utun7788"
|
||||
const val ROOT_TUN_ADDR_V4 = "198.18.0.1/15"
|
||||
const val ROOT_TUN_ADDR_V6 = "fdfe:dcba:9876::1/64"
|
||||
|
||||
// hev-socks5-tunnel run as a standalone root binary (reuses the same project already
|
||||
// bundled for the VPN hev path; distinct filename from the JNI lib to avoid collision).
|
||||
const val ROOT_TUN2SOCKS_BIN = "libhevsockstun.so"
|
||||
const val ROOT_FWD_CHAIN = "CORE_FWD" // FORWARD chain for LAN/tethering sharing
|
||||
const val ROOT_DNS_CHAIN = "CORE_DNS" // nat chain for tethered-client DNS DNAT
|
||||
const val ROOT_V6_CHAIN = "CORE6_FILTER" // ip6tables filter/OUTPUT chain: blackhole native IPv6 when it isn't tunneled
|
||||
const val ROOT_V6_FWD_CHAIN = "CORE6_FWD" // ip6tables FORWARD chain: route or reject tethered clients' native IPv6
|
||||
const val ROOT_V6_PRE_CHAIN = "CORE6_PRE" // ip6tables mangle/PREROUTING chain: mark forwarded clients' IPv6 into the tun
|
||||
const val ROOT_LAN_DNS = "1.1.1.1" // fallback resolver for tethered clients when no plain-IPv4 DNS is configured
|
||||
const val ROOT_OOM_SCORE = "-1000" // oom_score_adj that makes the LMK never kill us
|
||||
|
||||
/** hev-sock5-tunnel read-write-timeout value */
|
||||
const val HEVTUN_RW_TIMEOUT = "300,60"
|
||||
|
||||
@@ -188,13 +254,19 @@ object AppConfig {
|
||||
const val GOOGLEAPIS_COM_DOMAIN = "googleapis.com"
|
||||
|
||||
// Android Private DNS constants
|
||||
const val DNS_DNSPOD_DOMAIN = "dot.pub"
|
||||
const val DNS_ALIDNS_DOMAIN = "dns.alidns.com"
|
||||
const val DNS_CISCO_SSE_DOMAIN = "dns.sse.cisco.com"
|
||||
const val DNS_CISCO_UMBRELLA_DOMAIN = "dns.umbrella.com"
|
||||
const val DNS_CLOUDFLARE_ONE_DOMAIN = "one.one.one.one"
|
||||
const val DNS_CLOUDFLARE_ONEDOT_DNS_DOMAIN = "1dot1dot1dot1.cloudflare-dns.com"
|
||||
const val DNS_CLOUDFLARE_DNS_COM_DOMAIN = "dns.cloudflare.com"
|
||||
const val DNS_CLOUDFLARE_DNS_DOMAIN = "cloudflare-dns.com"
|
||||
const val DNS_CLOUDFLARE_WARP_DOMAIN = "engage.cloudflareclient.com"
|
||||
const val DNS_DNSPOD_DOH_DOMAIN = "doh.pub"
|
||||
const val DNS_DNSPOD_DOT_DOMAIN = "dot.pub"
|
||||
const val DNS_GOOGLE_DOMAIN = "dns.google"
|
||||
const val DNS_QUAD9_DOMAIN = "dns.quad9.net"
|
||||
const val DNS_SB_DOMAIN = "dns.sb"
|
||||
const val DNS_YANDEX_DOMAIN = "common.dot.dns.yandex.net"
|
||||
|
||||
const val DEFAULT_PORT = 443
|
||||
@@ -205,13 +277,21 @@ object AppConfig {
|
||||
const val REALITY = "reality"
|
||||
const val HEADER_TYPE_HTTP = "http"
|
||||
|
||||
const val UNIDENTIFIED_PACKAGE = "__unknown_app__"
|
||||
|
||||
val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
|
||||
val DNS_CISCO_SSE_ADDRESSES = arrayListOf("208.67.220.220", "208.67.222.222", "2620:119:35::35", "2620:119:53::53")
|
||||
val DNS_CISCO_UMBRELLA_ADDRESSES = arrayListOf("208.67.220.220", "208.67.222.222", "2620:119:35::35", "2620:119:53::53")
|
||||
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_ONEDOT_DNS_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("162.159.61.8", "172.64.41.8", "2a06:98c1:52::8", "2803:f800:53::8")
|
||||
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_CLOUDFLARE_WARP_ADDRESSES = arrayListOf("162.159.192.1", "2606:4700:d0::a29f:c001")
|
||||
val DNS_DNSPOD_DOH_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
|
||||
val DNS_DNSPOD_DOT_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_SB_ADDRESSES = arrayListOf("45.11.45.11", "185.222.222.222", "2a09::", "2a11::")
|
||||
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
|
||||
@@ -265,4 +345,11 @@ object AppConfig {
|
||||
"Chocolate4U/Iran-v2ray-rules"
|
||||
)
|
||||
|
||||
val BUILTIN_OUTBOUND_TAGS = setOf(
|
||||
TAG_PROXY,
|
||||
TAG_DIRECT,
|
||||
TAG_BLOCKED,
|
||||
)
|
||||
|
||||
val OBSERVATORY_DURATION_PATTERN = Regex("""[1-9]\d*(ms|s|m|h)""")
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
/**
|
||||
* A common Adapter -> host callback interface that includes common actions: edit, remove and refresh.
|
||||
* Extend this interface or define more specific interfaces for different adapters as needed.
|
||||
*/
|
||||
interface BaseAdapterListener {
|
||||
/**
|
||||
* Request the host to edit the specified item.
|
||||
* @param guid Unique identifier (GUID) of the item
|
||||
* @param position Current position in the adapter (optional; host should validate it)
|
||||
*/
|
||||
fun onEdit(guid: String, position: Int)
|
||||
|
||||
/**
|
||||
* Request the host to remove the specified item. Position is provided for optional animation or validation.
|
||||
* @param guid Unique identifier (GUID) of the item
|
||||
* @param position Current position in the adapter (optional; host should validate it)
|
||||
*/
|
||||
fun onRemove(guid: String, position: Int)
|
||||
|
||||
/**
|
||||
* Request the host to share the specified URL.
|
||||
* @param url The URL to be shared
|
||||
*/
|
||||
fun onShare(url: String)
|
||||
|
||||
/**
|
||||
* Request the host to refresh data (for example, reload from the ViewModel or call notifyDataSetChanged).
|
||||
*/
|
||||
fun onRefreshData()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
import android.content.Context
|
||||
|
||||
interface IDialerService {
|
||||
fun start(context: Context, dialerAddr: String)
|
||||
fun stop()
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
|
||||
interface MainAdapterListener :BaseAdapterListener {
|
||||
|
||||
fun onEdit(guid: String, position: Int, profile: ProfileItem)
|
||||
|
||||
fun onSelectServer(guid: String)
|
||||
|
||||
fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean)
|
||||
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
import android.app.Service
|
||||
import android.net.Network
|
||||
|
||||
interface ServiceControl {
|
||||
/**
|
||||
@@ -25,4 +26,13 @@ interface ServiceControl {
|
||||
* @return True if the socket is protected, false otherwise.
|
||||
*/
|
||||
fun vpnProtect(socket: Int): Boolean
|
||||
|
||||
/**
|
||||
* Declares the networks the tunnel runs on top of.
|
||||
* Only meaningful for the VPN service, the other run modes have no interface to report.
|
||||
*
|
||||
* @param networks The upstream networks, null to let the system pick.
|
||||
* @return True if the networks were accepted.
|
||||
*/
|
||||
fun setUnderlyingNetworks(networks: Array<Network>?): Boolean = false
|
||||
}
|
||||
@@ -11,7 +11,7 @@ interface Tun2SocksControl {
|
||||
* This initializes the VPN tunnel and connects it to the SOCKS proxy.
|
||||
*/
|
||||
fun startTun2Socks()
|
||||
|
||||
|
||||
/**
|
||||
* Stops the tun2socks process and cleans up resources.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.content.Context
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.CoreConfigContext
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.BalancerStrategyType
|
||||
import com.v2ray.ang.enums.CoreResolvedType
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
/**
|
||||
* Build runtime context from the selected profile.
|
||||
*
|
||||
* All outbound type analysis is completed here for both the selected profile
|
||||
* and routing targets. Custom profiles are returned immediately without
|
||||
* entering the normal analysis flow.
|
||||
*/
|
||||
object CoreConfigContextBuilder {
|
||||
|
||||
/**
|
||||
* Load one profile and produce a fully analyzed context.
|
||||
*
|
||||
* Null is returned only when the selected profile cannot be loaded.
|
||||
*/
|
||||
fun build(context: Context, guid: String): CoreConfigContext? {
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return null
|
||||
|
||||
// CUSTOM: return immediately — CoreConfigManager handles this path on its own.
|
||||
if (config.configType == EConfigType.CUSTOM) {
|
||||
return CoreConfigContext(context = context, guid = guid, isCustom = true)
|
||||
}
|
||||
|
||||
// Step 1: Resolve the main outbound (always tag = TAG_PROXY).
|
||||
val primaryResolvedOutbound = resolveOutbound(AppConfig.TAG_PROXY, config) ?: run {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve main outbound for '${config.remarks}'")
|
||||
return null
|
||||
}
|
||||
|
||||
// Step 2: Resolve all non-builtin routing outbound tags.
|
||||
val routingResolvedOutbounds = resolveRoutingOutbounds()
|
||||
val resolvedOutbounds = listOf(primaryResolvedOutbound) + routingResolvedOutbounds
|
||||
val fallbackResolvedOutbounds = resolveFallbackOutbounds(resolvedOutbounds)
|
||||
val routingDomainRules = collectRoutingDomainRulesForDns()
|
||||
|
||||
return CoreConfigContext(
|
||||
context = context,
|
||||
guid = guid,
|
||||
resolvedOutbounds = resolvedOutbounds + fallbackResolvedOutbounds,
|
||||
routingDomainRules = routingDomainRules,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one outbound target into a normalized outbound entry.
|
||||
*
|
||||
* Custom profiles are ignored at this stage and produce no entry.
|
||||
*/
|
||||
private fun resolveOutbound(tag: String, profile: ProfileItem): CoreConfigContext.ResolvedOutbound? {
|
||||
if (profile.configType == EConfigType.CUSTOM) {
|
||||
return null
|
||||
}
|
||||
|
||||
val (resolvedProfiles, resolvedType) = when (profile.configType) {
|
||||
EConfigType.POLICYGROUP -> Pair(
|
||||
resolvePolicyGroupProfiles(profile),
|
||||
CoreResolvedType.POLICYGROUP,
|
||||
)
|
||||
|
||||
EConfigType.PROXYCHAIN -> {
|
||||
val chainProfiles = resolveProxyChainProfiles(profile)
|
||||
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
|
||||
Pair(chainProfiles, type)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val chainProfiles = resolveProxyChainProfilesFromGroup(profile)
|
||||
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
|
||||
Pair(chainProfiles, type)
|
||||
}
|
||||
}
|
||||
|
||||
return CoreConfigContext.ResolvedOutbound(
|
||||
tag = tag,
|
||||
profile = profile,
|
||||
resolvedProfiles = resolvedProfiles,
|
||||
resolvedType = resolvedType,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect and resolve non-builtin routing targets from enabled rules.
|
||||
*
|
||||
* Invalid or empty targets are skipped and handled by fallback logic later.
|
||||
*/
|
||||
private fun resolveRoutingOutbounds(): List<CoreConfigContext.ResolvedOutbound> {
|
||||
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return emptyList()
|
||||
val resolvedOutbounds = mutableListOf<CoreConfigContext.ResolvedOutbound>()
|
||||
val processedTags = mutableSetOf<String>()
|
||||
|
||||
try {
|
||||
rulesetItems
|
||||
.filter { it.enabled }
|
||||
.mapNotNull { it.outboundTag.takeIf { tag -> tag.isNotBlank() } }
|
||||
.filter { tag -> tag !in AppConfig.BUILTIN_OUTBOUND_TAGS }
|
||||
.distinct()
|
||||
.forEach { tag ->
|
||||
if (tag in processedTags) {
|
||||
return@forEach
|
||||
}
|
||||
processedTags.add(tag)
|
||||
|
||||
try {
|
||||
val profile = SettingsManager.getServerViaRemarks(tag) ?: run {
|
||||
LogUtil.w(AppConfig.TAG, "Routing tag '$tag' has no matching profile — will fall back to proxy at routing time")
|
||||
return@forEach
|
||||
}
|
||||
val resolvedOutbound = resolveOutbound(tag, profile) ?: run {
|
||||
LogUtil.w(AppConfig.TAG, "Cannot use CUSTOM profile as routing outbound for tag '$tag', skipping")
|
||||
return@forEach
|
||||
}
|
||||
if (resolvedOutbound.resolvedProfiles.isEmpty()) {
|
||||
LogUtil.w(AppConfig.TAG, "Routing outbound '$tag' resolved to empty list, skipping")
|
||||
return@forEach
|
||||
}
|
||||
resolvedOutbounds.add(resolvedOutbound)
|
||||
LogUtil.d(AppConfig.TAG, "Resolved routing outbound: tag='$tag', type='${resolvedOutbound.resolvedType}', profiles=${resolvedOutbound.resolvedProfiles.size}")
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve routing outbound for tag '$tag', skipping", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve routing outbounds from rulesets", e)
|
||||
}
|
||||
|
||||
return resolvedOutbounds
|
||||
}
|
||||
|
||||
private fun resolvePolicyGroupProfiles(config: ProfileItem): List<ProfileItem> {
|
||||
try {
|
||||
val serverList = MmkvManager.decodeAllServerList()
|
||||
return serverList
|
||||
.asSequence()
|
||||
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
|
||||
.filter { profile ->
|
||||
val subscriptionId = config.policyGroupSubscriptionId
|
||||
if (subscriptionId.isNullOrBlank()) {
|
||||
true
|
||||
} else {
|
||||
profile.subscriptionId == subscriptionId
|
||||
}
|
||||
}
|
||||
.filter { profile ->
|
||||
val filter = config.policyGroupFilter
|
||||
if (filter.isNullOrBlank()) {
|
||||
true
|
||||
} else {
|
||||
try {
|
||||
Regex(filter).containsMatchIn(profile.remarks)
|
||||
} catch (_: Exception) {
|
||||
profile.remarks.contains(filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
.filter { it.server.isNotNullEmpty() }
|
||||
.filter { Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
|
||||
.filter { !it.configType.isComplexType() }
|
||||
.toList()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve policy group profiles for '${config.remarks}'", e)
|
||||
return listOf(config)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveProxyChainProfiles(config: ProfileItem): List<ProfileItem> {
|
||||
if (config.proxyChainProfiles.isNullOrBlank()) {
|
||||
return listOf(config)
|
||||
}
|
||||
|
||||
try {
|
||||
return config.proxyChainProfiles.orEmpty().split(",")
|
||||
.asSequence()
|
||||
.mapNotNull { remark -> SettingsManager.getServerViaRemarks(remark) }
|
||||
.filter { it.server.isNotNullEmpty() }
|
||||
.filter { Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
|
||||
.filter { !it.configType.isComplexType() }
|
||||
.toList()
|
||||
.reversed()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles for '${config.remarks}'", e)
|
||||
return listOf(config)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve chain nodes from subscription neighbors in order: next, current, prev.
|
||||
*
|
||||
* When no chain is available, return a single-node result.
|
||||
*/
|
||||
private fun resolveProxyChainProfilesFromGroup(config: ProfileItem): List<ProfileItem> {
|
||||
if (config.subscriptionId.isEmpty()) {
|
||||
return listOf(config)
|
||||
}
|
||||
|
||||
try {
|
||||
val subItem = MmkvManager.decodeSubscription(config.subscriptionId) ?: return listOf(config)
|
||||
val resolved = mutableListOf<ProfileItem>()
|
||||
SettingsManager.getServerViaRemarks(subItem.nextProfile)?.let { resolved.add(it) }
|
||||
resolved.add(config)
|
||||
SettingsManager.getServerViaRemarks(subItem.prevProfile)?.let { resolved.add(it) }
|
||||
return resolved
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain from group for '${config.remarks}'", e)
|
||||
return listOf(config)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect enabled routing domain rules in original order for DNS segmentation.
|
||||
*
|
||||
* outbounds are normalized into three tags only: proxy / direct / block.
|
||||
*/
|
||||
private fun collectRoutingDomainRulesForDns(): List<CoreConfigContext.RoutingDomainRule> {
|
||||
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return emptyList()
|
||||
val result = mutableListOf<CoreConfigContext.RoutingDomainRule>()
|
||||
|
||||
rulesetItems
|
||||
.asSequence()
|
||||
.filter { it.enabled }
|
||||
.filter { !it.domain.isNullOrEmpty() }
|
||||
.forEach { rule ->
|
||||
val normalizedOutboundTag = when (rule.outboundTag) {
|
||||
AppConfig.TAG_DIRECT -> AppConfig.TAG_DIRECT
|
||||
AppConfig.TAG_BLOCKED -> AppConfig.TAG_BLOCKED
|
||||
else -> AppConfig.TAG_PROXY
|
||||
}
|
||||
result.add(
|
||||
CoreConfigContext.RoutingDomainRule(
|
||||
domain = rule.domain.orEmpty(),
|
||||
outboundTag = normalizedOutboundTag
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and collect fallback outbounds from all POLICYGROUP nodes.
|
||||
*
|
||||
* Fallback targets must not overlap with already resolved tags or builtin tags.
|
||||
*/
|
||||
private fun resolveFallbackOutbounds(resolvedOutbounds: List<CoreConfigContext.ResolvedOutbound>): List<CoreConfigContext.ResolvedOutbound> {
|
||||
return resolvedOutbounds
|
||||
.asSequence()
|
||||
.filter { it.resolvedType == CoreResolvedType.POLICYGROUP }
|
||||
.filter { BalancerStrategyType.from(it.profile.policyGroupType).supportsObservatory && it.profile.policyGroupTestOutbounds != false }
|
||||
.mapNotNull { it.profile.policyGroupFallbackTag }
|
||||
.filter { it !in AppConfig.BUILTIN_OUTBOUND_TAGS && resolvedOutbounds.none { outbound -> outbound.tag == it } }
|
||||
.distinct()
|
||||
.mapNotNull { tag ->
|
||||
SettingsManager.getServerViaRemarks(tag)
|
||||
?.takeUnless { it.configType == EConfigType.CUSTOM || it.configType == EConfigType.POLICYGROUP }
|
||||
?.let { resolveOutbound(tag, it) }
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+19
-10
@@ -1,8 +1,8 @@
|
||||
package com.v2ray.ang.handler
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import go.Seq
|
||||
import libv2ray.CoreCallbackHandler
|
||||
@@ -16,7 +16,7 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
* Thread-safe singleton wrapper for Libv2ray native methods.
|
||||
* Provides initialization protection and unified API for V2Ray core operations.
|
||||
*/
|
||||
object V2RayNativeManager {
|
||||
object CoreNativeManager {
|
||||
private val initialized = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
@@ -32,14 +32,23 @@ object V2RayNativeManager {
|
||||
val assetPath = Utils.userAssetPath(context)
|
||||
val deviceId = Utils.getDeviceIdForXUDPBaseKey()
|
||||
Libv2ray.initCoreEnv(assetPath, deviceId)
|
||||
Log.i(AppConfig.TAG, "V2Ray core environment initialized successfully")
|
||||
LogUtil.i(AppConfig.TAG, "V2Ray core environment initialized successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to initialize V2Ray core environment", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to initialize V2Ray core environment", e)
|
||||
initialized.set(false)
|
||||
throw e
|
||||
}
|
||||
} else {
|
||||
Log.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping")
|
||||
LogUtil.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping")
|
||||
}
|
||||
}
|
||||
|
||||
fun reconcileBrowserDialer(dialerAddr: String) {
|
||||
try {
|
||||
Libv2ray.reconcileBrowserDialer(dialerAddr)
|
||||
LogUtil.i(AppConfig.TAG, "Browser dialer reconciled successfully with address: $dialerAddr")
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to reconcile browser dialer with address: $dialerAddr", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +62,7 @@ object V2RayNativeManager {
|
||||
return try {
|
||||
Libv2ray.checkVersionX()
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to check V2Ray version", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to check V2Ray version", e)
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
@@ -69,7 +78,7 @@ object V2RayNativeManager {
|
||||
return try {
|
||||
Libv2ray.measureOutboundDelay(config, testUrl)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to measure outbound delay", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to measure outbound delay", e)
|
||||
-1L
|
||||
}
|
||||
}
|
||||
@@ -84,8 +93,8 @@ object V2RayNativeManager {
|
||||
return try {
|
||||
Libv2ray.newCoreController(handler)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to create core controller", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to create core controller", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
/**
|
||||
* Centralizes ProfileItem -> OutboundBean conversion.
|
||||
* Most protocol builders mirror the previous *Fmt.toOutbound behavior.
|
||||
*/
|
||||
object CoreOutboundBuilder {
|
||||
|
||||
/** Dispatches a profile to protocol-specific outbound builder. */
|
||||
fun convert(profileItem: ProfileItem): OutboundBean? {
|
||||
val outbound = when (profileItem.configType) {
|
||||
EConfigType.VMESS -> toOutboundVmess(profileItem)
|
||||
EConfigType.SHADOWSOCKS -> toOutboundShadowsocks(profileItem)
|
||||
EConfigType.SOCKS -> toOutboundSocks(profileItem)
|
||||
EConfigType.VLESS -> toOutboundVless(profileItem)
|
||||
EConfigType.TROJAN -> toOutboundTrojan(profileItem)
|
||||
EConfigType.WIREGUARD -> toOutboundWireguard(profileItem)
|
||||
EConfigType.HYSTERIA2 -> toOutboundHysteria2(profileItem)
|
||||
EConfigType.HTTP -> toOutboundHttp(profileItem)
|
||||
else -> null
|
||||
}
|
||||
|
||||
outbound ?: return null
|
||||
val ret = updateOutboundWithGlobalSettings(outbound)
|
||||
if (!ret) return null
|
||||
return outbound
|
||||
}
|
||||
|
||||
/** Applies global outbound options (mux, protocol-specific tweaks, etc.). */
|
||||
private fun updateOutboundWithGlobalSettings(outbound: OutboundBean): Boolean {
|
||||
try {
|
||||
var muxEnabled = MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false)
|
||||
val protocol = outbound.protocol
|
||||
if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||
|| protocol.equals(EConfigType.WIREGUARD.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA.name, true)
|
||||
) {
|
||||
muxEnabled = false
|
||||
} else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) {
|
||||
muxEnabled = false
|
||||
}
|
||||
|
||||
if (muxEnabled) {
|
||||
outbound.mux?.enabled = true
|
||||
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
|
||||
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY).orEmpty().toInt()
|
||||
outbound.mux?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
|
||||
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.flow?.isNotEmpty() == true) {
|
||||
outbound.mux?.concurrency = -1
|
||||
}
|
||||
} else {
|
||||
outbound.mux?.enabled = false
|
||||
outbound.mux?.concurrency = -1
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to update outbound with global settings", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Creates an initial outbound template for a protocol type. */
|
||||
fun createInitOutbound(configType: EConfigType): OutboundBean? {
|
||||
return when (configType) {
|
||||
EConfigType.VMESS,
|
||||
EConfigType.VLESS,
|
||||
EConfigType.SHADOWSOCKS,
|
||||
EConfigType.SOCKS,
|
||||
EConfigType.HTTP,
|
||||
EConfigType.TROJAN -> OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(),
|
||||
streamSettings = OutboundBean.StreamSettingsBean()
|
||||
)
|
||||
|
||||
EConfigType.WIREGUARD -> OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(
|
||||
secretKey = "",
|
||||
peers = listOf(OutboundBean.OutSettingsBean.WireGuardBean())
|
||||
)
|
||||
)
|
||||
|
||||
EConfigType.HYSTERIA,
|
||||
EConfigType.HYSTERIA2 -> OutboundBean(
|
||||
protocol = EConfigType.HYSTERIA.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(),
|
||||
streamSettings = OutboundBean.StreamSettingsBean()
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-protocol builders — implementations are identical to each *Fmt.toOutbound ──
|
||||
|
||||
private fun toOutboundVmess(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.VMESS)
|
||||
|
||||
outboundBean?.settings?.let { settings ->
|
||||
settings.address = getServerAddress(profileItem)
|
||||
settings.port = profileItem.serverPort.orEmpty().toInt()
|
||||
settings.id = profileItem.password.orEmpty()
|
||||
settings.security = profileItem.method
|
||||
settings.level = AppConfig.DEFAULT_LEVEL
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundVless(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.VLESS)
|
||||
|
||||
outboundBean?.settings?.let { settings ->
|
||||
settings.address = getServerAddress(profileItem)
|
||||
settings.port = profileItem.serverPort.orEmpty().toInt()
|
||||
settings.id = profileItem.password.orEmpty()
|
||||
settings.encryption = profileItem.method
|
||||
settings.flow = profileItem.flow
|
||||
settings.level = AppConfig.DEFAULT_LEVEL
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundShadowsocks(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.SHADOWSOCKS)
|
||||
|
||||
outboundBean?.settings?.let { settings ->
|
||||
settings.address = getServerAddress(profileItem)
|
||||
settings.port = profileItem.serverPort.orEmpty().toInt()
|
||||
settings.password = profileItem.password
|
||||
settings.method = profileItem.method
|
||||
settings.level = AppConfig.DEFAULT_LEVEL
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundTrojan(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.TROJAN)
|
||||
|
||||
outboundBean?.settings?.let { settings ->
|
||||
settings.address = getServerAddress(profileItem)
|
||||
settings.port = profileItem.serverPort.orEmpty().toInt()
|
||||
settings.password = profileItem.password
|
||||
settings.flow = profileItem.flow
|
||||
settings.level = AppConfig.DEFAULT_LEVEL
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundSocks(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.SOCKS)
|
||||
|
||||
outboundBean?.settings?.let { settings ->
|
||||
settings.address = getServerAddress(profileItem)
|
||||
settings.port = profileItem.serverPort.orEmpty().toInt()
|
||||
settings.level = AppConfig.DEFAULT_LEVEL
|
||||
if (profileItem.username.isNotNullEmpty()) {
|
||||
settings.user = profileItem.username.orEmpty()
|
||||
settings.pass = profileItem.password.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundHttp(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.HTTP)
|
||||
|
||||
outboundBean?.settings?.let { settings ->
|
||||
settings.address = getServerAddress(profileItem)
|
||||
settings.port = profileItem.serverPort.orEmpty().toInt()
|
||||
settings.level = AppConfig.DEFAULT_LEVEL
|
||||
if (profileItem.username.isNotNullEmpty()) {
|
||||
settings.user = profileItem.username.orEmpty()
|
||||
settings.pass = profileItem.password.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundWireguard(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.WIREGUARD)
|
||||
|
||||
val rawAddresses = profileItem.localAddress
|
||||
?.split(",")
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.ifEmpty { null }
|
||||
?: listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4)
|
||||
|
||||
val addresses = if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
|
||||
rawAddresses
|
||||
} else {
|
||||
val ipv4Addresses = rawAddresses.filter { !it.contains(":") }
|
||||
ipv4Addresses.ifEmpty { listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4) }
|
||||
}
|
||||
|
||||
val rawDNS = profileItem.remoteDNS
|
||||
?.split(",")
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.ifEmpty { null }
|
||||
?: listOf(AppConfig.WIREGUARD_LOCAL_REMOTE_DNS)
|
||||
|
||||
val remotes = if (rawDNS.size == 1 && rawDNS[0] == "local") {
|
||||
rawDNS
|
||||
} else if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
|
||||
rawDNS
|
||||
} else {
|
||||
val ipv4Dns = rawDNS.filter { !it.contains(":") }
|
||||
ipv4Dns.ifEmpty { listOf(AppConfig.WIREGUARD_LOCAL_REMOTE_DNS) }
|
||||
}
|
||||
|
||||
outboundBean?.settings?.let { wireguard ->
|
||||
wireguard.secretKey = profileItem.secretKey
|
||||
wireguard.address = addresses
|
||||
wireguard.port = null
|
||||
wireguard.peers?.firstOrNull()?.let { peer ->
|
||||
peer.publicKey = profileItem.publicKey.orEmpty()
|
||||
peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
|
||||
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
|
||||
}
|
||||
wireguard.mtu = profileItem.mtu
|
||||
wireguard.remoteDNS = remotes
|
||||
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
|
||||
}
|
||||
|
||||
if (!profileItem.finalMask.isNullOrBlank()) {
|
||||
outboundBean?.streamSettings = OutboundBean.StreamSettingsBean()
|
||||
outboundBean?.streamSettings?.let {
|
||||
updateOutboundFinalMask(it, profileItem)
|
||||
it.network = null
|
||||
}
|
||||
}
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundHysteria2(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.HYSTERIA2) ?: return null
|
||||
profileItem.network = NetworkType.HYSTERIA.type
|
||||
profileItem.alpn = "h3"
|
||||
|
||||
outboundBean.settings?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.version = 2
|
||||
}
|
||||
|
||||
val sni = outboundBean.streamSettings?.let {
|
||||
populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean.streamSettings?.let {
|
||||
populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun createTcpHttpRequest(
|
||||
host: String?,
|
||||
path: String?
|
||||
): OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean {
|
||||
val requestString =
|
||||
"""{"version":"1.1","method":"GET","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.122 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate"],"Connection":["keep-alive"],"Pragma":"no-cache"}}"""
|
||||
val request = JsonUtil.fromJson(
|
||||
requestString,
|
||||
OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean::class.java
|
||||
) ?: OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean()
|
||||
|
||||
val parsedHost = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
request.headers.Host = parsedHost.ifEmpty { null }
|
||||
request.path = path.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }.ifEmpty { listOf("/") }
|
||||
return request
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures transport settings for an outbound connection.
|
||||
*
|
||||
* Sets up protocol-specific transport options based on the profile settings.
|
||||
*
|
||||
* @param streamSettings The stream settings to configure
|
||||
* @param profileItem The profile containing transport configuration
|
||||
* @return The Server Name Indication (SNI) value to use, or null if not applicable
|
||||
*/
|
||||
fun populateTransportSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem): String? {
|
||||
val transport = profileItem.network.orEmpty()
|
||||
val headerType = profileItem.headerType
|
||||
val host = profileItem.host
|
||||
val path = profileItem.path
|
||||
val seed = profileItem.seed
|
||||
// val quicSecurity = profileItem.quicSecurity
|
||||
// val key = profileItem.quicKey
|
||||
val mode = profileItem.mode
|
||||
val serviceName = profileItem.serviceName
|
||||
val authority = profileItem.authority
|
||||
val xhttpMode = profileItem.xhttpMode
|
||||
val xhttpExtra = profileItem.xhttpExtra
|
||||
val finalMask = profileItem.finalMask
|
||||
var sni: String? = null
|
||||
streamSettings.network = transport.ifEmpty { NetworkType.TCP.type }
|
||||
when (streamSettings.network) {
|
||||
NetworkType.TCP.type -> {
|
||||
val tcpSetting = OutboundBean.StreamSettingsBean.TcpSettingsBean()
|
||||
if (headerType == AppConfig.HEADER_TYPE_HTTP) {
|
||||
tcpSetting.header.type = AppConfig.HEADER_TYPE_HTTP
|
||||
val requestObj = createTcpHttpRequest(host, path)
|
||||
tcpSetting.header.request = requestObj
|
||||
sni = requestObj.headers.Host?.getOrNull(0)
|
||||
} else {
|
||||
tcpSetting.header.type = "none"
|
||||
sni = host
|
||||
}
|
||||
streamSettings.tcpSettings = tcpSetting
|
||||
}
|
||||
|
||||
NetworkType.KCP.type -> {
|
||||
val kcpSetting = OutboundBean.StreamSettingsBean.KcpSettingsBean()
|
||||
profileItem.kcpMtu?.let { kcpSetting.mtu = it }
|
||||
profileItem.kcpTti?.let { kcpSetting.tti = it }
|
||||
streamSettings.kcpSettings = kcpSetting
|
||||
val udpMaskList =
|
||||
mutableListOf<OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean>()
|
||||
if (!headerType.isNullOrEmpty() && headerType != "none") {
|
||||
val kcpHeaderType = when {
|
||||
headerType == "wechat-video" -> "wechat"
|
||||
else -> headerType
|
||||
}
|
||||
udpMaskList.add(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "mkcp-legacy",
|
||||
settings =
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
header = kcpHeaderType,
|
||||
value = if (headerType == "dns" && !host.isNullOrEmpty()) {
|
||||
host
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (seed.isNullOrEmpty()) {
|
||||
udpMaskList.add(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "mkcp-legacy"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
udpMaskList.add(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "mkcp-legacy",
|
||||
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
value = seed
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
udpMaskList.reverse()
|
||||
streamSettings.finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
|
||||
udp = udpMaskList.toList()
|
||||
)
|
||||
}
|
||||
|
||||
NetworkType.WS.type -> {
|
||||
val wssetting = OutboundBean.StreamSettingsBean.WsSettingsBean()
|
||||
wssetting.host = host.orEmpty()
|
||||
sni = host
|
||||
wssetting.path = path ?: "/"
|
||||
streamSettings.wsSettings = wssetting
|
||||
}
|
||||
|
||||
NetworkType.HTTP_UPGRADE.type -> {
|
||||
val httpupgradeSetting = OutboundBean.StreamSettingsBean.HttpupgradeSettingsBean()
|
||||
httpupgradeSetting.host = host.orEmpty()
|
||||
sni = host
|
||||
httpupgradeSetting.path = path ?: "/"
|
||||
streamSettings.httpupgradeSettings = httpupgradeSetting
|
||||
}
|
||||
|
||||
NetworkType.XHTTP.type -> {
|
||||
val xhttpSetting = OutboundBean.StreamSettingsBean.XhttpSettingsBean()
|
||||
xhttpSetting.host = host.orEmpty()
|
||||
sni = host
|
||||
xhttpSetting.path = path ?: "/"
|
||||
xhttpSetting.mode = xhttpMode
|
||||
xhttpSetting.extra = JsonUtil.parseString(xhttpExtra)
|
||||
streamSettings.xhttpSettings = xhttpSetting
|
||||
}
|
||||
|
||||
NetworkType.H2.type, NetworkType.HTTP.type -> {
|
||||
streamSettings.network = NetworkType.H2.type
|
||||
val h2Setting = OutboundBean.StreamSettingsBean.HttpSettingsBean()
|
||||
h2Setting.host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
sni = h2Setting.host.getOrNull(0)
|
||||
h2Setting.path = path ?: "/"
|
||||
streamSettings.httpSettings = h2Setting
|
||||
}
|
||||
|
||||
// "quic" -> {
|
||||
// val quicsetting = QuicSettingBean()
|
||||
// quicsetting.security = quicSecurity ?: "none"
|
||||
// quicsetting.key = key.orEmpty()
|
||||
// quicsetting.header.type = headerType ?: "none"
|
||||
// quicSettings = quicsetting
|
||||
// }
|
||||
|
||||
NetworkType.GRPC.type -> {
|
||||
val grpcSetting = OutboundBean.StreamSettingsBean.GrpcSettingsBean()
|
||||
grpcSetting.multiMode = mode == "multi"
|
||||
grpcSetting.serviceName = serviceName.orEmpty()
|
||||
grpcSetting.authority = authority.orEmpty()
|
||||
grpcSetting.idle_timeout = 60
|
||||
grpcSetting.health_check_timeout = 20
|
||||
sni = authority
|
||||
streamSettings.grpcSettings = grpcSetting
|
||||
}
|
||||
|
||||
NetworkType.HYSTERIA.type -> {
|
||||
val hysteriaSetting = OutboundBean.StreamSettingsBean.HysteriaSettingsBean(
|
||||
version = 2,
|
||||
auth = profileItem.password.orEmpty(),
|
||||
)
|
||||
val quicParams = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean(
|
||||
brutalUp = profileItem.bandwidthUp?.nullIfBlank(),
|
||||
brutalDown = profileItem.bandwidthDown?.nullIfBlank(),
|
||||
)
|
||||
quicParams.congestion = if (quicParams.brutalUp != null || quicParams.brutalDown != null) "brutal" else null
|
||||
if (profileItem.portHopping.isNotNullEmpty()) {
|
||||
val rawInterval = profileItem.portHoppingInterval?.trim().nullIfBlank()
|
||||
val interval = if (rawInterval == null) {
|
||||
"30"
|
||||
} else {
|
||||
val singleValue = rawInterval.toIntOrNull()
|
||||
if (singleValue != null) {
|
||||
if (singleValue < 5) {
|
||||
"30"
|
||||
} else {
|
||||
rawInterval
|
||||
}
|
||||
} else {
|
||||
val parts = rawInterval.split('-')
|
||||
if (parts.size == 2) {
|
||||
val start = parts[0].trim().toIntOrNull()
|
||||
val end = parts[1].trim().toIntOrNull()
|
||||
if (start != null && end != null) {
|
||||
val minStart = maxOf(5, start)
|
||||
val minEnd = maxOf(minStart, end)
|
||||
"$minStart-$minEnd"
|
||||
} else {
|
||||
"30"
|
||||
}
|
||||
} else {
|
||||
"30"
|
||||
}
|
||||
}
|
||||
}
|
||||
quicParams.udpHop = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean.UdpHopBean(
|
||||
ports = profileItem.portHopping,
|
||||
interval = interval
|
||||
)
|
||||
}
|
||||
val finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
|
||||
quicParams = quicParams
|
||||
)
|
||||
if (profileItem.obfsPassword.isNotNullEmpty()) {
|
||||
finalmask.udp = listOf(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "salamander",
|
||||
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
password = profileItem.obfsPassword.orEmpty()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
streamSettings.hysteriaSettings = hysteriaSetting
|
||||
streamSettings.finalmask = finalmask
|
||||
}
|
||||
}
|
||||
finalMask?.let {
|
||||
val parsedFinalMask = JsonUtil.parseString(finalMask)
|
||||
if (parsedFinalMask != null) {
|
||||
streamSettings.finalmask = parsedFinalMask
|
||||
} else {
|
||||
LogUtil.w("V2rayConfigManager", "Invalid finalMask JSON, keeping previously generated finalmask")
|
||||
}
|
||||
}
|
||||
return sni
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures TLS or REALITY security settings for an outbound connection.
|
||||
*
|
||||
* Sets up security-related parameters like certificates, fingerprints, and SNI.
|
||||
*
|
||||
* @param streamSettings The stream settings to configure
|
||||
* @param profileItem The profile containing security configuration
|
||||
* @param sniExt An external SNI value to use if the profile doesn't specify one
|
||||
*/
|
||||
fun populateTlsSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem, sniExt: String?) {
|
||||
val streamSecurity = profileItem.security.orEmpty()
|
||||
val allowInsecure = profileItem.insecure == true && profileItem.pinnedCA256.isNullOrEmpty()
|
||||
val sni = if (profileItem.sni.isNullOrEmpty()) {
|
||||
when {
|
||||
sniExt.isNotNullEmpty() && Utils.isDomainName(sniExt) -> sniExt
|
||||
profileItem.server.isNotNullEmpty() && Utils.isDomainName(profileItem.server) -> profileItem.server
|
||||
else -> sniExt
|
||||
}
|
||||
} else {
|
||||
profileItem.sni
|
||||
}
|
||||
|
||||
streamSettings.security = streamSecurity.nullIfBlank()
|
||||
if (streamSettings.security == null) return
|
||||
val tlsSetting = OutboundBean.StreamSettingsBean.TlsSettingsBean(
|
||||
allowInsecure = allowInsecure,
|
||||
serverName = sni.nullIfBlank(),
|
||||
fingerprint = profileItem.fingerPrint.nullIfBlank(),
|
||||
alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() },
|
||||
echConfigList = profileItem.echConfigList.nullIfBlank(),
|
||||
verifyPeerCertByName = profileItem.verifyPeerCertByName.nullIfBlank(),
|
||||
pinnedPeerCertSha256 = profileItem.pinnedCA256.nullIfBlank(),
|
||||
publicKey = profileItem.publicKey.nullIfBlank(),
|
||||
shortId = profileItem.shortId.nullIfBlank(),
|
||||
spiderX = profileItem.spiderX.nullIfBlank(),
|
||||
mldsa65Verify = profileItem.mldsa65Verify.nullIfBlank(),
|
||||
)
|
||||
if (streamSettings.security == AppConfig.TLS) {
|
||||
streamSettings.tlsSettings = tlsSetting
|
||||
streamSettings.realitySettings = null
|
||||
} else if (streamSettings.security == AppConfig.REALITY) {
|
||||
streamSettings.tlsSettings = null
|
||||
streamSettings.realitySettings = tlsSetting
|
||||
}
|
||||
|
||||
if (profileItem.finalMask.isNullOrEmpty()) {
|
||||
updateOutboundFragment(streamSettings)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the outbound with fragment settings for traffic optimization.
|
||||
*
|
||||
* Configures packet fragmentation for TLS and REALITY protocols if enabled.
|
||||
*
|
||||
* @param streamSettings The streamSettings object to be modified
|
||||
* @return true if fragment configuration was successful, false otherwise
|
||||
*/
|
||||
private fun updateOutboundFragment(streamSettings: OutboundBean.StreamSettingsBean): Boolean {
|
||||
try {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == false) {
|
||||
return true
|
||||
}
|
||||
if (streamSettings.security != AppConfig.TLS
|
||||
&& streamSettings.security != AppConfig.REALITY
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (streamSettings.sockopt?.dialerProxy.isNotNullEmpty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
var packets =
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS) ?: "tlshello"
|
||||
if (streamSettings.security == AppConfig.REALITY
|
||||
&& packets == "tlshello"
|
||||
) {
|
||||
packets = "1-3"
|
||||
}
|
||||
|
||||
val fragmentMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "fragment",
|
||||
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
packets = packets,
|
||||
length = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH)
|
||||
?: "50-100",
|
||||
delay = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL)
|
||||
?: "10-20",
|
||||
maxSplit = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_MAXSPLIT)
|
||||
?: "10"
|
||||
)
|
||||
)
|
||||
val noiseMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "noise",
|
||||
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
noise = listOf(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean.NoiseMaskBean(
|
||||
rand = "10-20",
|
||||
delay = "10-16",
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val finalMaskObj = streamSettings.finalmask?.let { existingFinalMask ->
|
||||
JsonUtil.parseString(JsonUtil.toJson(existingFinalMask))
|
||||
} ?: JsonObject()
|
||||
|
||||
fun appendMask(scope: String, mask: OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean) {
|
||||
val current = finalMaskObj.get(scope)
|
||||
if (current != null && current.isJsonArray && current.asJsonArray.size() > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
val newArray = JsonArray()
|
||||
newArray.add(JsonUtil.parseString(JsonUtil.toJson(mask)))
|
||||
|
||||
if (current != null && current.isJsonArray) {
|
||||
current.asJsonArray.forEach { newArray.add(it) }
|
||||
}
|
||||
finalMaskObj.add(scope, newArray)
|
||||
}
|
||||
|
||||
appendMask("tcp", fragmentMask)
|
||||
appendMask("udp", noiseMask)
|
||||
streamSettings.finalmask = finalMaskObj
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to update outbound fragment", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun getServerAddress(profileItem: ProfileItem): String {
|
||||
if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
|
||||
return profileItem.server.orEmpty()
|
||||
}
|
||||
|
||||
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
|
||||
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, AppConfig.DEFAULT_OUTBOUND_DOMAIN_RESOLVE_METHOD) != "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()
|
||||
}
|
||||
|
||||
fun updateOutboundFinalMask(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem) {
|
||||
val finalMask = profileItem.finalMask
|
||||
finalMask?.let {
|
||||
val parsedFinalMask = JsonUtil.parseString(profileItem.finalMask)
|
||||
if (parsedFinalMask != null) {
|
||||
streamSettings.finalmask = parsedFinalMask
|
||||
} else {
|
||||
LogUtil.w("V2rayConfigManager", "Invalid finalMask JSON, keeping previously generated finalmask")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.system.OsConstants
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.contracts.IDialerService
|
||||
import com.v2ray.ang.contracts.ServiceControl
|
||||
import com.v2ray.ang.dto.ConnectionTestResult
|
||||
import com.v2ray.ang.dto.OutboundTrafficStat
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.BrowserDialerMode
|
||||
import com.v2ray.ang.extension.delay
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.NotificationManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.SpeedtestManager
|
||||
import com.v2ray.ang.helper.MessageHelper
|
||||
import com.v2ray.ang.service.DialerNativeService
|
||||
import com.v2ray.ang.service.DialerWebviewService
|
||||
import com.v2ray.ang.service.NetworkMonitor
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelChildren
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import libv2ray.CoreCallbackHandler
|
||||
import libv2ray.CoreController
|
||||
import libv2ray.ProcessFinder
|
||||
import java.lang.ref.SoftReference
|
||||
import java.net.InetSocketAddress
|
||||
|
||||
object CoreServiceManager {
|
||||
|
||||
private val coreController: CoreController = CoreNativeManager.newCoreController(CoreCallback())
|
||||
private val mMsgReceive = ReceiveMessageHandler()
|
||||
private var currentConfig: ProfileItem? = null
|
||||
private var processFinder: XrayProcessFinder? = null
|
||||
private var browserDialer: IDialerService? = null
|
||||
private var networkMonitor: NetworkMonitor? = null
|
||||
private val connectionTestScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Volatile
|
||||
private var isReloading = false
|
||||
|
||||
/** Tun descriptor the core was started with, null in the proxy only and root run modes. */
|
||||
private var currentVpnInterface: ParcelFileDescriptor? = null
|
||||
|
||||
var serviceControl: SoftReference<ServiceControl>? = null
|
||||
set(value) {
|
||||
field = value
|
||||
val service = value?.get()?.getService()
|
||||
CoreNativeManager.initCoreEnv(service)
|
||||
if (service != null && processFinder == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
processFinder = XrayProcessFinder(service)
|
||||
coreController.registerProcessFinder(processFinder)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the V2Ray service is running.
|
||||
* @return True if the service is running, false otherwise.
|
||||
*/
|
||||
fun isRunning() = coreController.isRunning
|
||||
|
||||
/**
|
||||
* Gets the name of the currently running server.
|
||||
* @return The name of the running server.
|
||||
*/
|
||||
fun getRunningServerName() = currentConfig?.remarks.orEmpty()
|
||||
|
||||
/**
|
||||
* 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 core service.
|
||||
*/
|
||||
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
|
||||
if (isRunning()) {
|
||||
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||
return false
|
||||
}
|
||||
|
||||
val service = getService()
|
||||
if (service == null) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Service is null")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
doStartCoreLoop(service, vpnInterface)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
val message = e.message?.takeUnless { it.isBlank() } ?: e.javaClass.simpleName
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: $message", e)
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, message)
|
||||
NotificationManager.cancelNotification()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun doStartCoreLoop(service: Service, vpnInterface: ParcelFileDescriptor?) {
|
||||
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||
mFilter.addAction(Intent.ACTION_SCREEN_ON)
|
||||
mFilter.addAction(Intent.ACTION_SCREEN_OFF)
|
||||
mFilter.addAction(Intent.ACTION_USER_PRESENT)
|
||||
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
|
||||
|
||||
currentVpnInterface = vpnInterface
|
||||
launchCore(service, vpnInterface)
|
||||
startNetworkMonitor(service)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun launchCore(service: Service, vpnInterface: ParcelFileDescriptor?, isReload: Boolean = false) {
|
||||
val guid = MmkvManager.getSelectServer() ?: error("No server selected")
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: error("Failed to decode server config")
|
||||
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting core loop for ${config.remarks}")
|
||||
val result = CoreConfigManager.getV2rayConfig(service, guid)
|
||||
LogUtil.d(AppConfig.TAG, result.content)
|
||||
if (!result.status) {
|
||||
error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
|
||||
}
|
||||
|
||||
currentConfig = config
|
||||
var tunFd = vpnInterface?.fd ?: 0
|
||||
val dialerMode = BrowserDialerMode.from(config.browserDialerMode)
|
||||
val dialerAddr = if (dialerMode != null) {
|
||||
"127.0.0.1:${Utils.findRandomFreePort()}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
if (SettingsManager.isUsingHevTun()) {
|
||||
tunFd = 0
|
||||
}
|
||||
|
||||
NotificationManager.showNotification(currentConfig)
|
||||
if (dialerAddr.isNotNullEmpty()) {
|
||||
CoreNativeManager.reconcileBrowserDialer(dialerAddr)
|
||||
}
|
||||
coreController.startLoop(result.content, tunFd)
|
||||
|
||||
if (!isRunning()) {
|
||||
error("Core failed to start")
|
||||
}
|
||||
|
||||
if (browserDialer != null) {
|
||||
browserDialer!!.stop()
|
||||
browserDialer = null
|
||||
}
|
||||
when (dialerMode) {
|
||||
BrowserDialerMode.OKHTTP -> {
|
||||
browserDialer = DialerNativeService()
|
||||
browserDialer!!.start(service, dialerAddr)
|
||||
}
|
||||
|
||||
BrowserDialerMode.WEBVIEW -> {
|
||||
browserDialer = DialerWebviewService()
|
||||
browserDialer!!.start(service, dialerAddr)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
if (!isReload) {
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
|
||||
}
|
||||
NotificationManager.startSpeedNotification()
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 stopCoreLoop(): Boolean {
|
||||
connectionTestScope.coroutineContext.cancelChildren()
|
||||
val service = getService() ?: return false
|
||||
|
||||
networkMonitor?.unregister()
|
||||
networkMonitor = null
|
||||
currentVpnInterface = null
|
||||
|
||||
if (isRunning()) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
coreController.stopLoop()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop V2Ray loop", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close existing browser dialer
|
||||
CoreNativeManager.reconcileBrowserDialer("")
|
||||
if (browserDialer != null) {
|
||||
browserDialer!!.stop()
|
||||
browserDialer = null
|
||||
}
|
||||
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
|
||||
NotificationManager.cancelNotification()
|
||||
|
||||
try {
|
||||
service.unregisterReceiver(mMsgReceive)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to unregister receiver", e)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to upstream network changes for whichever run mode is active.
|
||||
* All three services share this manager, so the tunnel recovers from a handover in proxy only
|
||||
* and root mode as well, not just behind the VPN interface.
|
||||
*/
|
||||
private fun startNetworkMonitor(service: Service) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
|
||||
if (networkMonitor != null) return
|
||||
|
||||
val connectivity = service.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
|
||||
networkMonitor = NetworkMonitor(
|
||||
connectivity = connectivity,
|
||||
onUnderlyingNetworksChanged = { networks -> serviceControl?.get()?.setUnderlyingNetworks(networks) },
|
||||
onHandover = { reloadCore() },
|
||||
).also { it.register() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Restarts the core in place after the upstream network changed: the service, the notification
|
||||
* and the VPN interface all stay up, so nothing of this is visible.
|
||||
*
|
||||
* The config is rebuilt on purpose, outbound server domains are resolved while building it and
|
||||
* an address resolved on a network that is gone can be unusable on the new one.
|
||||
*
|
||||
* @return True if the core is running again.
|
||||
*/
|
||||
private fun reloadCore(): Boolean {
|
||||
if (isReloading) return false
|
||||
val service = getService() ?: return false
|
||||
if (!isRunning()) return false
|
||||
|
||||
return try {
|
||||
val tunFd = currentVpnInterface
|
||||
|
||||
isReloading = true
|
||||
connectionTestScope.coroutineContext.cancelChildren()
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core reload start...")
|
||||
|
||||
coreController.stopLoop()
|
||||
launchCore(service, tunFd, isReload = true)
|
||||
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core reload finished")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
val message = e.message?.takeUnless { it.isBlank() } ?: e.javaClass.simpleName
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to reload core: $message", e)
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, message)
|
||||
false
|
||||
} finally {
|
||||
isReloading = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries and resets all outbound traffic counters in one core call.
|
||||
* Go side format: tag,direction,value;tag,direction,value;
|
||||
*/
|
||||
fun queryAllOutboundTrafficStats(): List<OutboundTrafficStat> {
|
||||
// The stats manager is gone once the core stops, querying it then reaches into freed state.
|
||||
if (!isRunning()) return emptyList()
|
||||
|
||||
val payload = coreController.queryAllOutboundTrafficStats()
|
||||
|
||||
val result = ArrayList<OutboundTrafficStat>()
|
||||
|
||||
payload.split(';').forEach { entry ->
|
||||
if (entry.isBlank()) return@forEach
|
||||
|
||||
val parts = entry.split(',', limit = 3)
|
||||
if (parts.size != 3) return@forEach
|
||||
|
||||
val value = parts[2].toLongOrNull() ?: return@forEach
|
||||
|
||||
result.add(
|
||||
OutboundTrafficStat(
|
||||
tag = parts[0],
|
||||
direction = parts[1],
|
||||
value = value,
|
||||
)
|
||||
)
|
||||
}
|
||||
// LogUtil.d(AppConfig.TAG, "Queried outbound traffic stats: $result")
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(requestId: String) {
|
||||
val service = getService() ?: return
|
||||
if (!isRunning() || isReloading) {
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_CANCEL, "", requestId)
|
||||
return
|
||||
}
|
||||
|
||||
connectionTestScope.coroutineContext.cancelChildren()
|
||||
connectionTestScope.launch {
|
||||
var time = -1L
|
||||
var errorStr = ""
|
||||
|
||||
try {
|
||||
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||
errorStr = e.message?.substringAfter("\":").orEmpty()
|
||||
}
|
||||
if (time == -1L) {
|
||||
ensureActive()
|
||||
try {
|
||||
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||
errorStr = e.message?.substringAfter("\":").orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
ensureActive()
|
||||
val endpoint = if (time >= 0) SpeedtestManager.getRemoteIPInfo() else null
|
||||
val result = ConnectionTestResult(
|
||||
delayMillis = time,
|
||||
errorMessage = errorStr,
|
||||
country = endpoint?.country,
|
||||
ipAddress = endpoint?.ipAddress,
|
||||
)
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
if (isRunning()) {
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_RESULT, result, requestId)
|
||||
} else {
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_CANCEL, "", requestId)
|
||||
}
|
||||
}
|
||||
}.invokeOnCompletion { cause ->
|
||||
if (cause is CancellationException) {
|
||||
MessageHelper.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_CANCEL, "", requestId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current service instance.
|
||||
* @return The current service instance, or null if not available.
|
||||
*/
|
||||
private fun getService(): Service? {
|
||||
return serviceControl?.get()?.getService()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback startup")
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when V2Ray core shuts down.
|
||||
* @return 0 for success, any other value for failure.
|
||||
*/
|
||||
override fun shutdown(): Long {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback shutdown")
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback onEmitStatus $s")
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process finder implementation for Xray core.
|
||||
* Uses ConnectivityManager to find the owning UID of a connection based on network parameters.
|
||||
*/
|
||||
private class XrayProcessFinder(context: Context) : ProcessFinder {
|
||||
private val cm: ConnectivityManager? = context.getSystemService(ConnectivityManager::class.java)
|
||||
|
||||
override fun findProcessByConnection(network: String, srcIP: String, srcPort: Long, destIP: String, destPort: Long): Long {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return -1L
|
||||
if (cm == null) return -1L
|
||||
val proto = when (network) {
|
||||
"tcp" -> OsConstants.IPPROTO_TCP
|
||||
"udp" -> OsConstants.IPPROTO_UDP
|
||||
else -> return -1L
|
||||
}
|
||||
|
||||
if (destIP.isBlank() || destPort == 0L) {
|
||||
LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to :$destPort, (no dest)")
|
||||
return -1L
|
||||
}
|
||||
|
||||
return try {
|
||||
val uid = cm.getConnectionOwnerUid(
|
||||
proto,
|
||||
InetSocketAddress(srcIP, srcPort.toInt()),
|
||||
InetSocketAddress(destIP, destPort.toInt())
|
||||
).toLong()
|
||||
LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to $destIP:$destPort, uid=$uid")
|
||||
//LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to $destIP:$destPort, uid=$uid,${PackageUidResolver.uidToPackageName(uid.toString())}")
|
||||
|
||||
uid
|
||||
} catch (_: Exception) {
|
||||
-1L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast receiver for handling messages sent to the service.
|
||||
* Handles registration, service control, and screen events.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
val serviceControl = serviceControl?.get() ?: return
|
||||
when (intent?.getIntExtra("key", 0)) {
|
||||
AppConfig.MSG_REGISTER_CLIENT -> {
|
||||
if (isRunning()) {
|
||||
MessageHelper.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_RUNNING, "")
|
||||
} else {
|
||||
MessageHelper.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
|
||||
}
|
||||
}
|
||||
|
||||
AppConfig.MSG_UNREGISTER_CLIENT -> {
|
||||
// nothing to do
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_START -> {
|
||||
// nothing to do
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_STOP -> {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Stop service")
|
||||
serviceControl.stopService()
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_RESTART -> {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Restart service")
|
||||
// The UI and daemon run in separate processes, so acknowledge the active
|
||||
// daemon before stopping it instead of relying on possibly stale UI state.
|
||||
if (isOrderedBroadcast) resultCode = Activity.RESULT_OK
|
||||
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
try {
|
||||
serviceControl.stopService()
|
||||
delay(500L)
|
||||
LauncherManager.startService(serviceControl.getService())
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppConfig.MSG_MEASURE_DELAY -> {
|
||||
if (isOrderedBroadcast) resultCode = Activity.RESULT_OK
|
||||
measureV2rayDelay(intent.getStringExtra("content").orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SCREEN_OFF -> {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen off")
|
||||
NotificationManager.stopSpeedNotification()
|
||||
}
|
||||
|
||||
Intent.ACTION_SCREEN_ON -> {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen on")
|
||||
NotificationManager.startSpeedNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.helper.MessageHelper
|
||||
import com.v2ray.ang.root.RootManager
|
||||
import com.v2ray.ang.service.CoreProxyOnlyService
|
||||
import com.v2ray.ang.service.CoreRootService
|
||||
import com.v2ray.ang.service.CoreVpnService
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
object LauncherManager {
|
||||
|
||||
fun startServiceFromToggle(context: Context): Boolean {
|
||||
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
|
||||
context.toast(R.string.app_tile_first_use)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
startContextService(context)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "LauncherManager: ${e.message}", e)
|
||||
context.toast(e.message ?: e.javaClass.simpleName)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun startService(context: Context, guid: String? = null) {
|
||||
LogUtil.i(AppConfig.TAG, "LauncherManager: startService from ${context::class.java.simpleName}")
|
||||
|
||||
if (guid != null) {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
}
|
||||
|
||||
try {
|
||||
startContextService(context)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "LauncherManager: ${e.message}", e)
|
||||
context.toast(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopService(context: Context) {
|
||||
//context.toast(R.string.toast_services_stop)
|
||||
MessageHelper.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
|
||||
}
|
||||
|
||||
/** Restarts the active daemon without starting a stopped service. */
|
||||
fun restartService(context: Context) {
|
||||
MessageHelper.sendMsg2Service(context, AppConfig.MSG_STATE_RESTART, "")
|
||||
}
|
||||
|
||||
/** Restarts the active daemon, or delegates to the caller's permission-aware start flow. */
|
||||
fun restartServiceOrStart(context: Context, startIfStopped: () -> Unit) {
|
||||
MessageHelper.sendMsg2ServiceForResult(context, AppConfig.MSG_STATE_RESTART, "") { handled ->
|
||||
if (!handled) startIfStopped()
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun startContextService(context: Context) {
|
||||
// Note: isRunning check is removed here to avoid loading Native libraries in the UI process.
|
||||
// The check is performed in CoreServiceManager when the service starts in the daemon process.
|
||||
|
||||
val guid = MmkvManager.getSelectServer()
|
||||
?: run {
|
||||
LogUtil.e(AppConfig.TAG, "LauncherManager: No server selected")
|
||||
error(context.getString(R.string.app_tile_first_use))
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(guid)
|
||||
?: run {
|
||||
LogUtil.e(AppConfig.TAG, "LauncherManager: Failed to decode server config")
|
||||
error(context.getString(R.string.toast_config_file_invalid))
|
||||
}
|
||||
|
||||
if (!config.configType.isComplexType()
|
||||
&& !Utils.isValidUrl(config.server)
|
||||
&& !Utils.isPureIpAddress(config.server.orEmpty())
|
||||
) {
|
||||
LogUtil.e(AppConfig.TAG, "LauncherManager: Invalid server configuration")
|
||||
error(context.getString(R.string.toast_config_file_invalid))
|
||||
}
|
||||
|
||||
SettingsManager.refreshRuntimeSocksPort()
|
||||
|
||||
if (config.insecure == true && config.pinnedCA256.isNullOrEmpty()) {
|
||||
context.toastError(R.string.toast_allow_insecure_deprecated)
|
||||
Utils.setClipboard(context, context.getString(R.string.toast_allow_insecure_deprecated))
|
||||
}
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) {
|
||||
context.toast(R.string.toast_warning_pref_proxysharing_short)
|
||||
} else {
|
||||
context.toast(R.string.toast_services_start)
|
||||
}
|
||||
|
||||
val isRootMode = SettingsManager.isRootMode()
|
||||
if (isRootMode && !RootManager.isRootAvailable()) {
|
||||
LogUtil.e(AppConfig.TAG, "LauncherManager: root mode requires root but none available")
|
||||
error(context.getString(R.string.toast_root_required))
|
||||
}
|
||||
|
||||
val intent = if (isRootMode) {
|
||||
LogUtil.i(AppConfig.TAG, "LauncherManager: Starting Root service")
|
||||
Intent(context.applicationContext, CoreRootService::class.java)
|
||||
} else if (SettingsManager.isVpnMode()) {
|
||||
LogUtil.i(AppConfig.TAG, "LauncherManager: Starting VPN service")
|
||||
Intent(context.applicationContext, CoreVpnService::class.java)
|
||||
} else {
|
||||
LogUtil.i(AppConfig.TAG, "LauncherManager: Starting Proxy service")
|
||||
Intent(context.applicationContext, CoreProxyOnlyService::class.java)
|
||||
}
|
||||
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} catch (e: SecurityException) {
|
||||
LogUtil.e(AppConfig.TAG, "LauncherManager: Missing permission to start foreground service", e)
|
||||
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
|
||||
} catch (e: RuntimeException) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
e.javaClass.name == "android.app.ForegroundServiceStartNotAllowedException"
|
||||
) {
|
||||
LogUtil.e(AppConfig.TAG, "LauncherManager: Foreground service start not allowed", e)
|
||||
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
|
||||
data class AppInfo(
|
||||
val appName: String,
|
||||
val packageName: String,
|
||||
val appIcon: Drawable,
|
||||
val isSystemApp: Boolean,
|
||||
var isSelected: Int
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class CertSha256Request(
|
||||
val address: String = "",
|
||||
val port: Int = 443,
|
||||
val serverName: String? = null,
|
||||
val timeoutMs: Long = 5000L,
|
||||
)
|
||||
|
||||
data class CertSha256Result(
|
||||
val sha256: String = "",
|
||||
val error: String = "",
|
||||
)
|
||||
@@ -4,5 +4,6 @@ data class ConfigResult(
|
||||
var status: Boolean,
|
||||
var guid: String? = null,
|
||||
var content: String = "",
|
||||
var errorMessage: String = "",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
/** Locale-neutral result sent from the daemon for presentation by the UI process. */
|
||||
data class ConnectionTestResult(
|
||||
val delayMillis: Long,
|
||||
val errorMessage: String = "",
|
||||
val country: String? = null,
|
||||
val ipAddress: String? = null,
|
||||
) : Serializable
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import android.content.Context
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.CoreResolvedType
|
||||
|
||||
data class CoreConfigContext(
|
||||
val context: Context,
|
||||
val guid: String,
|
||||
val isCustom: Boolean = false,
|
||||
val resolvedOutbounds: List<ResolvedOutbound> = emptyList(),
|
||||
val routingDomainRules: List<RoutingDomainRule> = emptyList(),
|
||||
) {
|
||||
data class ResolvedOutbound(
|
||||
val tag: String,
|
||||
val profile: ProfileItem,
|
||||
val resolvedProfiles: List<ProfileItem>,
|
||||
val resolvedType: CoreResolvedType,
|
||||
)
|
||||
|
||||
data class RoutingDomainRule(
|
||||
val domain: List<String>,
|
||||
val outboundTag: String,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class LocateTarget(
|
||||
val groupId: String,
|
||||
val serverGuid: String,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class OutboundTrafficStat(
|
||||
val tag: String,
|
||||
val direction: String,
|
||||
val value: Long,
|
||||
)
|
||||
@@ -1,130 +0,0 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import com.v2ray.ang.AppConfig.LOOPBACK
|
||||
import com.v2ray.ang.AppConfig.PORT_SOCKS
|
||||
import com.v2ray.ang.AppConfig.TAG_BLOCKED
|
||||
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
||||
import com.v2ray.ang.AppConfig.TAG_PROXY
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
data class ProfileItem(
|
||||
val configVersion: Int = 4,
|
||||
val configType: EConfigType,
|
||||
var subscriptionId: String = "",
|
||||
var addedTime: Long = System.currentTimeMillis(),
|
||||
|
||||
var remarks: String = "",
|
||||
var description: String? = null,
|
||||
var server: String? = null,
|
||||
var serverPort: String? = null,
|
||||
|
||||
var password: String? = null,
|
||||
var method: String? = null,
|
||||
var flow: String? = null,
|
||||
var username: String? = null,
|
||||
|
||||
var network: String? = null,
|
||||
var headerType: String? = null,
|
||||
var host: String? = null,
|
||||
var path: String? = null,
|
||||
var seed: String? = null,
|
||||
var quicSecurity: String? = null,
|
||||
var quicKey: String? = null,
|
||||
var mode: String? = null,
|
||||
var serviceName: String? = null,
|
||||
var authority: String? = null,
|
||||
var xhttpMode: String? = null,
|
||||
var xhttpExtra: String? = null,
|
||||
|
||||
var security: String? = null,
|
||||
var sni: String? = null,
|
||||
var alpn: String? = null,
|
||||
var fingerPrint: String? = null,
|
||||
var insecure: Boolean? = null,
|
||||
var echConfigList: String? = null,
|
||||
var echForceQuery: String? = null,
|
||||
var pinnedCA256: String? = null,
|
||||
|
||||
var publicKey: String? = null,
|
||||
var shortId: String? = null,
|
||||
var spiderX: String? = null,
|
||||
var mldsa65Verify: String? = null,
|
||||
|
||||
var secretKey: String? = null,
|
||||
var preSharedKey: String? = null,
|
||||
var localAddress: String? = null,
|
||||
var reserved: String? = null,
|
||||
var mtu: Int? = null,
|
||||
|
||||
var obfsPassword: String? = null,
|
||||
var portHopping: String? = null,
|
||||
var portHoppingInterval: String? = null,
|
||||
var pinSHA256: String? = null,
|
||||
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 {
|
||||
return ProfileItem(configType = configType)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllOutboundTags(): MutableList<String> {
|
||||
return mutableListOf(TAG_PROXY, TAG_DIRECT, TAG_BLOCKED)
|
||||
}
|
||||
|
||||
fun getServerAddressAndPort(): String {
|
||||
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
|
||||
return "$LOOPBACK:$PORT_SOCKS"
|
||||
}
|
||||
return Utils.getIpv6Address(server) + ":" + serverPort
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null) return false
|
||||
val obj = other as ProfileItem
|
||||
|
||||
return (this.server == obj.server
|
||||
&& this.serverPort == obj.serverPort
|
||||
&& this.password == obj.password
|
||||
&& this.method == obj.method
|
||||
&& this.flow == obj.flow
|
||||
&& this.username == obj.username
|
||||
|
||||
&& this.network == obj.network
|
||||
&& this.headerType == obj.headerType
|
||||
&& this.host == obj.host
|
||||
&& this.path == obj.path
|
||||
&& this.seed == obj.seed
|
||||
&& this.quicSecurity == obj.quicSecurity
|
||||
&& this.quicKey == obj.quicKey
|
||||
&& this.mode == obj.mode
|
||||
&& this.serviceName == obj.serviceName
|
||||
&& this.authority == obj.authority
|
||||
&& this.xhttpMode == obj.xhttpMode
|
||||
|
||||
&& this.security == obj.security
|
||||
&& this.sni == obj.sni
|
||||
&& this.alpn == obj.alpn
|
||||
&& this.fingerPrint == obj.fingerPrint
|
||||
&& this.publicKey == obj.publicKey
|
||||
&& this.shortId == obj.shortId
|
||||
|
||||
&& this.secretKey == obj.secretKey
|
||||
&& this.localAddress == obj.localAddress
|
||||
&& this.reserved == obj.reserved
|
||||
&& this.mtu == obj.mtu
|
||||
|
||||
&& this.obfsPassword == obj.obfsPassword
|
||||
&& this.portHopping == obj.portHopping
|
||||
&& this.portHoppingInterval == obj.portHoppingInterval
|
||||
&& this.pinSHA256 == obj.pinSHA256
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
sealed class RealPingEvent {
|
||||
|
||||
/** Periodic progress update while the batch is still running. */
|
||||
data class Progress(val text: String) : RealPingEvent()
|
||||
|
||||
/** A single server result is available. */
|
||||
data class Result(val guid: String, val delayMillis: Long) : RealPingEvent()
|
||||
|
||||
/** The entire batch has finished or been cancelled. */
|
||||
data class Finish(val status: String) : RealPingEvent()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
/** One persisted delay-test result delivered from the task process to the UI. */
|
||||
data class RealPingResult(
|
||||
val guid: String,
|
||||
val delayMillis: Long,
|
||||
) : Serializable
|
||||
@@ -1,10 +0,0 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
|
||||
fun getTestDelayString(): String {
|
||||
if (testDelayMillis == 0L) {
|
||||
return ""
|
||||
}
|
||||
return testDelayMillis.toString() + "ms"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class ServersCache(
|
||||
val guid: String,
|
||||
val profile: ProfileItem
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
data class SubscriptionUpdateMessage(
|
||||
val key: Int,
|
||||
val forcedUpdate: Boolean,
|
||||
val subIds: List<String> = listOf()
|
||||
) : Serializable
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
/**
|
||||
* Result of subscription update operation
|
||||
*/
|
||||
data class SubscriptionUpdateResult(
|
||||
val configCount: Int = 0, // Total configs updated
|
||||
val successCount: Int = 0, // Subscriptions updated successfully
|
||||
val failureCount: Int = 0, // Subscriptions failed to update
|
||||
val skipCount: Int = 0 // Subscriptions skipped (disabled)
|
||||
) {
|
||||
/**
|
||||
* Combine two results by adding their counts
|
||||
*/
|
||||
operator fun plus(other: SubscriptionUpdateResult): SubscriptionUpdateResult {
|
||||
return SubscriptionUpdateResult(
|
||||
configCount = this.configCount + other.configCount,
|
||||
successCount = this.successCount + other.successCount,
|
||||
failureCount = this.failureCount + other.failureCount,
|
||||
skipCount = this.skipCount + other.skipCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
data class TestServiceMessage(
|
||||
val key: Int,
|
||||
val subscriptionId: String = "",
|
||||
val serverGuids: List<String> = emptyList(),
|
||||
val onlyTcp: Boolean = false
|
||||
) : Serializable
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
data class Contributor(
|
||||
val name: String,
|
||||
val displayName: String? = null,
|
||||
val url: String? = null
|
||||
)
|
||||
|
||||
data class TranslatorsCredit(
|
||||
val language: String,
|
||||
val contributors: List<Contributor>
|
||||
)
|
||||
|
||||
object TranslatorsParser {
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
fun parse(json: String): List<TranslatorsCredit> {
|
||||
if (json.isBlank()) return emptyList()
|
||||
|
||||
return try {
|
||||
val type = object : TypeToken<List<TranslatorsCredit>>() {}.type
|
||||
gson.fromJson<List<TranslatorsCredit>>(json, type).orEmpty()
|
||||
.filter { it.language.isNotBlank() && it.contributors.isNotEmpty() }
|
||||
.distinctBy { it.language }
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse translators.json", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class UrlContentRequest(
|
||||
val url: String?,
|
||||
val timeout: Int = 15000,
|
||||
val httpPort: Int = 0,
|
||||
val proxyUsername: String? = null,
|
||||
val proxyPassword: String? = null,
|
||||
val userAgent: String? = null,
|
||||
val requestHeaders: String? = null
|
||||
)
|
||||
@@ -31,7 +31,7 @@ data class V2rayConfig(
|
||||
|
||||
data class InboundBean(
|
||||
var tag: String,
|
||||
var port: Int,
|
||||
var port: Int?,
|
||||
var protocol: String,
|
||||
var listen: String? = null,
|
||||
var settings: InSettingsBean? = null,
|
||||
@@ -44,10 +44,16 @@ data class V2rayConfig(
|
||||
var auth: String? = null,
|
||||
var udp: Boolean? = null,
|
||||
var userLevel: Int? = null,
|
||||
var accounts: List<SocksAccountBean>? = null,
|
||||
var name: String? = null,
|
||||
@SerializedName("MTU")
|
||||
|
||||
var mtu: Int? = null
|
||||
)
|
||||
) {
|
||||
data class SocksAccountBean(
|
||||
var user: String = "",
|
||||
var pass: String = ""
|
||||
)
|
||||
}
|
||||
|
||||
data class SniffingBean(
|
||||
var enabled: Boolean,
|
||||
@@ -62,85 +68,39 @@ data class V2rayConfig(
|
||||
var protocol: String,
|
||||
var settings: OutSettingsBean? = null,
|
||||
var streamSettings: StreamSettingsBean? = null,
|
||||
val proxySettings: Any? = null,
|
||||
val sendThrough: String? = null,
|
||||
var mux: MuxBean? = MuxBean(false)
|
||||
) {
|
||||
data class OutSettingsBean(
|
||||
var vnext: List<VnextBean>? = null,
|
||||
var fragment: FragmentBean? = null,
|
||||
var noises: List<NoiseBean>? = null,
|
||||
var servers: List<ServersBean>? = null,
|
||||
/*Blackhole*/
|
||||
var response: Response? = null,
|
||||
/*DNS*/
|
||||
val network: String? = null,
|
||||
/*Common */
|
||||
var address: Any? = null,
|
||||
var port: Int? = null,
|
||||
/*Freedom*/
|
||||
var domainStrategy: String? = null,
|
||||
val redirect: String? = null,
|
||||
val userLevel: Int? = null,
|
||||
/*Loopback*/
|
||||
val inboundTag: String? = null,
|
||||
var level: Int? = null,
|
||||
var email: String? = null,
|
||||
/*HTTP/SOCKS*/
|
||||
var user: String? = null,
|
||||
var pass: String? = null,
|
||||
var headers: Map<String, String>? = null,
|
||||
/*VMess/VLESS*/
|
||||
var id: String? = null,
|
||||
var security: String? = null,
|
||||
var encryption: String? = null,
|
||||
/*VLESS*/
|
||||
var flow: String? = null,
|
||||
/*Trojan/Shadowsocks*/
|
||||
var password: String? = null,
|
||||
/*Shadowsocks*/
|
||||
var method: String? = null,
|
||||
/*Hysteria/Hysteria2*/
|
||||
var version: Int? = null,
|
||||
/*Wireguard*/
|
||||
var secretKey: String? = null,
|
||||
val peers: List<WireGuardBean>? = null,
|
||||
var reserved: List<Int>? = null,
|
||||
var mtu: Int? = null,
|
||||
var obfsPassword: String? = null,
|
||||
var version: Int? = null,
|
||||
var remoteDNS: List<String>? = null,
|
||||
var domainStrategy: String? = null,
|
||||
) {
|
||||
|
||||
data class VnextBean(
|
||||
var address: String = "",
|
||||
var port: Int = AppConfig.DEFAULT_PORT,
|
||||
var users: List<UsersBean>
|
||||
) {
|
||||
|
||||
data class UsersBean(
|
||||
var id: String = "",
|
||||
var alterId: Int? = null,
|
||||
var security: String? = null,
|
||||
var level: Int = AppConfig.DEFAULT_LEVEL,
|
||||
var encryption: String? = null,
|
||||
var flow: String? = null
|
||||
)
|
||||
}
|
||||
|
||||
data class FragmentBean(
|
||||
var packets: String? = null,
|
||||
var length: String? = null,
|
||||
var interval: String? = null
|
||||
)
|
||||
|
||||
data class NoiseBean(
|
||||
var type: String? = null,
|
||||
var packet: String? = null,
|
||||
var delay: String? = null
|
||||
)
|
||||
|
||||
data class ServersBean(
|
||||
var address: String = "",
|
||||
var method: String? = null,
|
||||
var ota: Boolean = false,
|
||||
var password: String? = null,
|
||||
var port: Int = AppConfig.DEFAULT_PORT,
|
||||
var level: Int = AppConfig.DEFAULT_LEVEL,
|
||||
val email: String? = null,
|
||||
var flow: String? = null,
|
||||
val ivCheck: Boolean? = null,
|
||||
var users: List<SocksUsersBean>? = null
|
||||
) {
|
||||
data class SocksUsersBean(
|
||||
var user: String = "",
|
||||
var pass: String = "",
|
||||
var level: Int = AppConfig.DEFAULT_LEVEL
|
||||
)
|
||||
}
|
||||
|
||||
data class Response(var type: String)
|
||||
|
||||
data class WireGuardBean(
|
||||
var publicKey: String = "",
|
||||
var preSharedKey: String? = null,
|
||||
@@ -149,7 +109,7 @@ data class V2rayConfig(
|
||||
}
|
||||
|
||||
data class StreamSettingsBean(
|
||||
var network: String = AppConfig.DEFAULT_NETWORK,
|
||||
var network: String? = AppConfig.DEFAULT_NETWORK,
|
||||
var security: String? = null,
|
||||
var tcpSettings: TcpSettingsBean? = null,
|
||||
var kcpSettings: KcpSettingsBean? = null,
|
||||
@@ -162,7 +122,7 @@ data class V2rayConfig(
|
||||
var realitySettings: TlsSettingsBean? = null,
|
||||
var grpcSettings: GrpcSettingsBean? = null,
|
||||
var hysteriaSettings: HysteriaSettingsBean? = null,
|
||||
var finalmask: FinalMaskBean? = null,
|
||||
var finalmask: Any? = null,
|
||||
val dsSettings: Any? = null,
|
||||
var sockopt: SockoptBean? = null
|
||||
) {
|
||||
@@ -189,7 +149,7 @@ data class V2rayConfig(
|
||||
@SerializedName("Accept-Encoding")
|
||||
val acceptEncoding: List<String>? = null,
|
||||
val Connection: List<String>? = null,
|
||||
val Pragma: String? = null
|
||||
val Pragma: Any? = null
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -207,13 +167,12 @@ data class V2rayConfig(
|
||||
|
||||
data class WsSettingsBean(
|
||||
var path: String? = null,
|
||||
var headers: HeadersBean = HeadersBean(),
|
||||
var host: String? = null,
|
||||
var headers: Map<String, String>? = null,
|
||||
val maxEarlyData: Int? = null,
|
||||
val useBrowserForwarding: Boolean? = null,
|
||||
val acceptProxyProtocol: Boolean? = null
|
||||
) {
|
||||
data class HeadersBean(var Host: String = "")
|
||||
}
|
||||
)
|
||||
|
||||
data class HttpupgradeSettingsBean(
|
||||
var path: String? = null,
|
||||
@@ -264,10 +223,9 @@ data class V2rayConfig(
|
||||
val disableSystemRoot: Boolean? = null,
|
||||
val enableSessionResumption: Boolean? = null,
|
||||
var echConfigList: String? = null,
|
||||
var echForceQuery: String? = null,
|
||||
var verifyPeerCertByName: String? = null,
|
||||
var pinnedPeerCertSha256: String? = null,
|
||||
// REALITY settings
|
||||
val show: Boolean = false,
|
||||
var publicKey: String? = null,
|
||||
var shortId: String? = null,
|
||||
var spiderX: String? = null,
|
||||
@@ -292,28 +250,52 @@ data class V2rayConfig(
|
||||
|
||||
data class HysteriaSettingsBean(
|
||||
var version: Int,
|
||||
var auth: String? = null,
|
||||
var up: String? = null,
|
||||
var down: String? = null,
|
||||
var udphop: HysteriaUdpHopBean? = null
|
||||
) {
|
||||
data class HysteriaUdpHopBean(
|
||||
var port: String? = null,
|
||||
var interval: Int? = null
|
||||
)
|
||||
}
|
||||
var auth: String? = null
|
||||
)
|
||||
|
||||
//https://xtls.github.io/config/transport.html#finalmaskobject
|
||||
data class FinalMaskBean(
|
||||
var tcp: List<MaskBean>? = null,
|
||||
var udp: List<MaskBean>? = null
|
||||
var udp: List<MaskBean>? = null,
|
||||
var quicParams: QuicParamsBean? = null
|
||||
) {
|
||||
data class MaskBean(
|
||||
var type: String,
|
||||
var settings: MaskSettingsBean? = null
|
||||
) {
|
||||
data class MaskSettingsBean(
|
||||
var password: String? = null,
|
||||
var domain: String? = null
|
||||
val password: String? = null,
|
||||
val header: String? = null,
|
||||
val value: String? = null,
|
||||
// fragment
|
||||
val packets: String? = null,
|
||||
val length: String? = null,
|
||||
val delay: String? = null,
|
||||
val maxSplit: String? = null,
|
||||
// noise
|
||||
val reset: Int? = null,
|
||||
val noise: List<NoiseMaskBean>? = null
|
||||
) {
|
||||
data class NoiseMaskBean(
|
||||
val rand: String? = null,
|
||||
// val randRange: String? = null,
|
||||
// val type: String? = null,
|
||||
// val packet: String? = null,
|
||||
val delay: String? = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class QuicParamsBean(
|
||||
var congestion: String? = null,
|
||||
var brutalUp: String? = null,
|
||||
var brutalDown: String? = null,
|
||||
var udpHop: UdpHopBean? = null,
|
||||
) {
|
||||
// Nested data class for the udpHop JSON object
|
||||
data class UdpHopBean(
|
||||
var ports: String? = null,
|
||||
var interval: String? = null
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -327,45 +309,19 @@ data class V2rayConfig(
|
||||
)
|
||||
|
||||
fun getServerAddress(): String? {
|
||||
if (protocol.equals(EConfigType.VMESS.name, true)
|
||||
|| protocol.equals(EConfigType.VLESS.name, true)
|
||||
) {
|
||||
return settings?.vnext?.first()?.address
|
||||
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||
) {
|
||||
return settings?.servers?.first()?.address
|
||||
} 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 if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
||||
settings?.peers?.firstOrNull()?.endpoint?.substringBeforeLast(":")
|
||||
} else {
|
||||
settings?.address as? String
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getServerPort(): Int? {
|
||||
if (protocol.equals(EConfigType.VMESS.name, true)
|
||||
|| protocol.equals(EConfigType.VLESS.name, true)
|
||||
) {
|
||||
return settings?.vnext?.first()?.port
|
||||
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||
) {
|
||||
return settings?.servers?.first()?.port
|
||||
} 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 if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
||||
settings?.peers?.firstOrNull()?.endpoint?.substringAfterLast(":")?.toIntOrNull()
|
||||
} else {
|
||||
settings?.port
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun ensureSockopt(): StreamSettingsBean.SockoptBean {
|
||||
@@ -387,6 +343,7 @@ data class V2rayConfig(
|
||||
val clientIp: String? = null,
|
||||
val disableCache: Boolean? = null,
|
||||
val queryStrategy: String? = null,
|
||||
val enableParallelQuery: Boolean? = null,
|
||||
val tag: String? = null
|
||||
) {
|
||||
data class ServersBean(
|
||||
@@ -409,8 +366,9 @@ data class V2rayConfig(
|
||||
|
||||
data class RulesBean(
|
||||
var type: String = "field",
|
||||
var ip: ArrayList<String>? = null,
|
||||
var domain: ArrayList<String>? = null,
|
||||
var ip: List<String>? = null,
|
||||
var domain: List<String>? = null,
|
||||
var process: List<String>? = null,
|
||||
var outboundTag: String? = null,
|
||||
var balancerTag: String? = null,
|
||||
var port: String? = null,
|
||||
@@ -480,6 +438,7 @@ data class V2rayConfig(
|
||||
data class PingConfigObject(
|
||||
val destination: String,
|
||||
val connectivity: String? = null,
|
||||
val httpMethod: String? = null,
|
||||
val interval: String,
|
||||
val sampling: Int,
|
||||
val timeout: String? = null
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.BalancerStrategyType
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
|
||||
@Suppress("PropertyName")
|
||||
data class V2rayNShareItem(
|
||||
val IndexId: String?,
|
||||
val ConfigType: Int?,
|
||||
// val CoreType: Int?,
|
||||
val ConfigVersion: Int?,
|
||||
val Subid: String?,
|
||||
val IsSub: Boolean?,
|
||||
val PreSocksPort: Int?,
|
||||
// val DisplayLog: Boolean?,
|
||||
val Remarks: String?,
|
||||
val Address: String?,
|
||||
val Port: Int?,
|
||||
val Password: String?,
|
||||
val Username: String?,
|
||||
val Network: String?,
|
||||
val StreamSecurity: String?,
|
||||
val AllowInsecure: String?,
|
||||
val Sni: String?,
|
||||
val Alpn: String?,
|
||||
val Fingerprint: String?,
|
||||
val PublicKey: String?,
|
||||
val ShortId: String?,
|
||||
val SpiderX: String?,
|
||||
val Mldsa65Verify: String?,
|
||||
// val MuxEnabled: Boolean?,
|
||||
// val Cert: String?,
|
||||
val CertSha: String?,
|
||||
val EchConfigList: String?,
|
||||
val VerifyPeerCertByName: String?,
|
||||
val Finalmask: String?,
|
||||
val ProtoExtraObj: V2rayNProtocolExtraShareItem?,
|
||||
val TransportExtraObj: V2rayNTransportExtraShareItem?,
|
||||
) {
|
||||
data class V2rayNProtocolExtraShareItem(
|
||||
val AlterId: Int?,
|
||||
val VmessSecurity: String?,
|
||||
val Flow: String?,
|
||||
val VlessEncryption: String?,
|
||||
val SsMethod: String?,
|
||||
val WgPublicKey: String?,
|
||||
val WgPresharedKey: String?,
|
||||
val WgInterfaceAddress: String?,
|
||||
val WgReserved: String?,
|
||||
val WgMtu: Int?,
|
||||
val SalamanderPass: String?,
|
||||
val UpMbps: Int?,
|
||||
val DownMbps: Int?,
|
||||
val Ports: String?,
|
||||
val HopInterval: String?,
|
||||
val GroupType: String?,
|
||||
val ChildItems: String?,
|
||||
val SubChildItems: String?,
|
||||
val Filter: String?,
|
||||
val MultipleLoad: Int?,
|
||||
)
|
||||
|
||||
data class V2rayNTransportExtraShareItem(
|
||||
val RawHeaderType: String?,
|
||||
val Host: String?,
|
||||
val Path: String?,
|
||||
val XhttpMode: String?,
|
||||
val XhttpExtra: String?,
|
||||
val GrpcAuthority: String?,
|
||||
val GrpcServiceName: String?,
|
||||
val GrpcMode: String?,
|
||||
val KcpHeaderType: String?,
|
||||
val KcpSeed: String?,
|
||||
val KcpMtu: Int?,
|
||||
)
|
||||
|
||||
fun toProfileItem(): ProfileItem {
|
||||
val configType = when (ConfigType) {
|
||||
1 -> EConfigType.VMESS
|
||||
2 -> EConfigType.CUSTOM
|
||||
3 -> EConfigType.SHADOWSOCKS
|
||||
4 -> EConfigType.SOCKS
|
||||
5 -> EConfigType.VLESS
|
||||
6 -> EConfigType.TROJAN
|
||||
7 -> EConfigType.HYSTERIA2
|
||||
// 8 -> EConfigType.TUIC
|
||||
9 -> EConfigType.WIREGUARD
|
||||
10 -> EConfigType.HTTP
|
||||
101 -> EConfigType.POLICYGROUP
|
||||
102 -> EConfigType.PROXYCHAIN
|
||||
else -> error("Unknown ConfigType: $ConfigType")
|
||||
}
|
||||
val network = if (configType == EConfigType.HYSTERIA2) "hysteria" else
|
||||
when (Network) {
|
||||
"raw" -> "tcp"
|
||||
else -> NetworkType.fromString(Network).type
|
||||
}
|
||||
val profile = ProfileItem(
|
||||
configType = configType,
|
||||
remarks = Remarks.orEmpty(),
|
||||
server = Address.orEmpty(),
|
||||
serverPort = Port.toString(),
|
||||
password = Password.orEmpty(),
|
||||
method = when (configType) {
|
||||
EConfigType.VMESS -> ProtoExtraObj?.VmessSecurity.orEmpty()
|
||||
EConfigType.SHADOWSOCKS -> ProtoExtraObj?.SsMethod.orEmpty()
|
||||
EConfigType.VLESS -> ProtoExtraObj?.VlessEncryption.orEmpty()
|
||||
else -> null
|
||||
},
|
||||
flow = ProtoExtraObj?.Flow.orEmpty(),
|
||||
username = Username.orEmpty(),
|
||||
network = network,
|
||||
headerType = when (network) {
|
||||
NetworkType.TCP.type -> TransportExtraObj?.RawHeaderType
|
||||
NetworkType.KCP.type -> TransportExtraObj?.KcpHeaderType
|
||||
else -> null
|
||||
},
|
||||
host = TransportExtraObj?.Host,
|
||||
path = TransportExtraObj?.Path,
|
||||
seed = TransportExtraObj?.KcpSeed,
|
||||
kcpMtu = TransportExtraObj?.KcpMtu,
|
||||
mode = TransportExtraObj?.GrpcMode,
|
||||
serviceName = TransportExtraObj?.GrpcServiceName,
|
||||
authority = TransportExtraObj?.GrpcAuthority,
|
||||
xhttpMode = TransportExtraObj?.XhttpMode,
|
||||
xhttpExtra = TransportExtraObj?.XhttpExtra,
|
||||
finalMask = Finalmask,
|
||||
security = StreamSecurity,
|
||||
sni = Sni,
|
||||
alpn = Alpn,
|
||||
fingerPrint = Fingerprint,
|
||||
insecure = AllowInsecure?.toBoolean(),
|
||||
echConfigList = EchConfigList,
|
||||
verifyPeerCertByName = VerifyPeerCertByName,
|
||||
pinnedCA256 = CertSha,
|
||||
publicKey = PublicKey,
|
||||
shortId = ShortId,
|
||||
spiderX = SpiderX,
|
||||
mldsa65Verify = Mldsa65Verify,
|
||||
secretKey = if (configType == EConfigType.WIREGUARD) Password else null,
|
||||
preSharedKey = ProtoExtraObj?.WgPresharedKey,
|
||||
localAddress = ProtoExtraObj?.WgInterfaceAddress,
|
||||
reserved = ProtoExtraObj?.WgReserved,
|
||||
mtu = ProtoExtraObj?.WgMtu,
|
||||
obfsPassword = ProtoExtraObj?.SalamanderPass,
|
||||
portHopping = ProtoExtraObj?.Ports,
|
||||
portHoppingInterval = ProtoExtraObj?.HopInterval,
|
||||
bandwidthDown = ProtoExtraObj?.DownMbps?.takeIf { it > 0 }?.let { "${it}Mbps" },
|
||||
bandwidthUp = ProtoExtraObj?.UpMbps?.takeIf { it > 0 }?.let { "${it}Mbps" },
|
||||
policyGroupType = when (ProtoExtraObj?.MultipleLoad) {
|
||||
2 -> BalancerStrategyType.RANDOM.policyGroupType
|
||||
3 -> BalancerStrategyType.ROUND_ROBIN.policyGroupType
|
||||
4 -> BalancerStrategyType.LEAST_LOAD.policyGroupType
|
||||
else -> BalancerStrategyType.LEAST_PING.policyGroupType
|
||||
},
|
||||
// NOTE: not safe, suggest converting and rewriting
|
||||
// policyGroupSubscriptionId = ProtoExtraObj?.SubChildItems,
|
||||
policyGroupSubscriptionId = if (ProtoExtraObj?.SubChildItems == "self") "self" else null,
|
||||
policyGroupFilter = ProtoExtraObj?.Filter,
|
||||
// NOTE: proxyChainProfiles stores remarks, not IndexId
|
||||
// proxyChainProfiles = ProtoExtraObj?.ChildItems,
|
||||
)
|
||||
// profile.description =
|
||||
return profile
|
||||
}
|
||||
}
|
||||
@@ -16,5 +16,7 @@ data class VmessQRCode(
|
||||
var sni: String = "",
|
||||
var alpn: String = "",
|
||||
var fp: String = "",
|
||||
var insecure: String = ""
|
||||
var insecure: String = "",
|
||||
var vcn: String = "",
|
||||
var pcs: String = ""
|
||||
)
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class AssetUrlCache(
|
||||
val guid: String,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class AssetUrlItem(
|
||||
var remarks: String = "",
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
data class ProfileItem(
|
||||
val configVersion: Int = 4,
|
||||
val configType: EConfigType,
|
||||
var subscriptionId: String = "",
|
||||
var addedTime: Long = System.currentTimeMillis(),
|
||||
|
||||
var remarks: String = "",
|
||||
var description: String? = null,
|
||||
var server: String? = null,
|
||||
var serverPort: String? = null,
|
||||
|
||||
var password: String? = null,
|
||||
var method: String? = null,
|
||||
var flow: String? = null,
|
||||
var username: String? = null,
|
||||
|
||||
var network: String? = null,
|
||||
var headerType: String? = null,
|
||||
var host: String? = null,
|
||||
var path: String? = null,
|
||||
var seed: String? = null,
|
||||
var kcpMtu: Int? = null,
|
||||
var kcpTti: Int? = null,
|
||||
|
||||
var quicSecurity: String? = null,
|
||||
var quicKey: String? = null,
|
||||
var mode: String? = null,
|
||||
var serviceName: String? = null,
|
||||
var authority: String? = null,
|
||||
var xhttpMode: String? = null,
|
||||
var xhttpExtra: String? = null,
|
||||
var finalMask: String? = null,
|
||||
|
||||
var security: String? = null,
|
||||
var sni: String? = null,
|
||||
var alpn: String? = null,
|
||||
var fingerPrint: String? = null,
|
||||
var insecure: Boolean? = null,
|
||||
var echConfigList: String? = null,
|
||||
var verifyPeerCertByName: String? = null,
|
||||
var pinnedCA256: 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,
|
||||
var localAddress: String? = null,
|
||||
var reserved: String? = null,
|
||||
var mtu: Int? = null,
|
||||
var remoteDNS: String? = null,
|
||||
|
||||
var obfsPassword: String? = null,
|
||||
var portHopping: String? = null,
|
||||
var portHoppingInterval: String? = null,
|
||||
@Deprecated("Use pinnedCA256")
|
||||
var pinSHA256: String? = null,
|
||||
var bandwidthDown: String? = null,
|
||||
var bandwidthUp: String? = null,
|
||||
|
||||
var policyGroupType: String? = null,
|
||||
var policyGroupSubscriptionId: String? = null,
|
||||
var policyGroupFilter: String? = null,
|
||||
var policyGroupTestOutbounds: Boolean? = null,
|
||||
var policyGroupFallbackTag: String? = null,
|
||||
var proxyChainProfiles: String? = null,
|
||||
|
||||
var browserDialerMode: String? = null,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun create(configType: EConfigType): ProfileItem =
|
||||
ProfileItem(configType = configType)
|
||||
}
|
||||
|
||||
fun getServerAddressAndPort(): String {
|
||||
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
|
||||
return "${AppConfig.LOOPBACK}:${AppConfig.PORT_SOCKS}"
|
||||
}
|
||||
return "${Utils.getIpv6Address(server)}:$serverPort"
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated identity for "remove duplicate configurations".
|
||||
*
|
||||
* Ignores metadata that does not affect connection:
|
||||
* - configVersion
|
||||
* - subscriptionId
|
||||
* - addedTime
|
||||
* - remarks
|
||||
* - description
|
||||
*
|
||||
* All other fields, including configType, are included in the comparison.
|
||||
*
|
||||
* Returns a copy; the caller must not modify it further.
|
||||
*/
|
||||
fun duplicateIdentity(): ProfileItem =
|
||||
copy(
|
||||
configVersion = 0,
|
||||
subscriptionId = "",
|
||||
addedTime = 0L,
|
||||
remarks = "",
|
||||
description = null
|
||||
)
|
||||
}
|
||||
+3
-1
@@ -1,9 +1,11 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class RulesetItem(
|
||||
var id: String = "",
|
||||
var remarks: String? = "",
|
||||
var ip: List<String>? = null,
|
||||
var domain: List<String>? = null,
|
||||
var process: List<String>? = null,
|
||||
var outboundTag: String = "",
|
||||
var port: String? = null,
|
||||
var network: String? = null,
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L)
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class ServersCache(
|
||||
val guid: String,
|
||||
val profile: ProfileItem,
|
||||
val testDelayMillis: Long = 0L,
|
||||
)
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class SubscriptionCache(
|
||||
val guid: String,
|
||||
+3
-2
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class SubscriptionItem(
|
||||
var remarks: String = "",
|
||||
@@ -7,11 +7,12 @@ data class SubscriptionItem(
|
||||
val addedTime: Long = System.currentTimeMillis(),
|
||||
var lastUpdated: Long = -1,
|
||||
var autoUpdate: Boolean = false,
|
||||
val updateInterval: Int? = null,
|
||||
var updateInterval: Long = 1440, // in minutes, default to 24 hours
|
||||
var prevProfile: String? = null,
|
||||
var nextProfile: String? = null,
|
||||
var filter: String? = null,
|
||||
var allowInsecureUrl: Boolean = false,
|
||||
var userAgent: String? = null,
|
||||
var requestHeaders: String? = null,
|
||||
)
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class WebDavConfig(
|
||||
val baseUrl: String,
|
||||
@@ -6,4 +6,4 @@ data class WebDavConfig(
|
||||
val password: String? = null,
|
||||
val remoteBasePath: String = "/",
|
||||
val timeoutSeconds: Long = 30
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
/**
|
||||
* Load-balancing strategy types supported by xray balancer.
|
||||
*
|
||||
* @param policyGroupType The string value written into the xray config JSON.
|
||||
* @param policyGroupTypeValue The numeric string stored in [com.v2ray.ang.dto.entities.ProfileItem.policyGroupType].
|
||||
* @param requiresBurstObservatory Whether this strategy needs a burstObservatory (leastLoad).
|
||||
* @param requiresObservatory Whether this strategy needs an observatory (leastPing).
|
||||
* @param supportsObservatory Whether an observatory can health-filter this strategy.
|
||||
*/
|
||||
enum class BalancerStrategyType(
|
||||
val policyGroupType: String,
|
||||
val policyGroupTypeValue: String,
|
||||
val requiresBurstObservatory: Boolean = false,
|
||||
val requiresObservatory: Boolean = false,
|
||||
val supportsObservatory: Boolean = false,
|
||||
) {
|
||||
LEAST_LOAD("leastLoad", "1", requiresBurstObservatory = true),
|
||||
RANDOM("random", "2", supportsObservatory = true),
|
||||
ROUND_ROBIN("roundRobin", "3", supportsObservatory = true),
|
||||
LEAST_PING("leastPing", "", requiresObservatory = true); // default / else
|
||||
|
||||
companion object {
|
||||
fun from(policyGroupType: String?): BalancerStrategyType =
|
||||
entries.firstOrNull { it.policyGroupTypeValue == policyGroupType } ?: LEAST_PING
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
enum class BrowserDialerMode(val value: String) {
|
||||
OKHTTP("OkHttp"),
|
||||
WEBVIEW("WebView");
|
||||
|
||||
companion object {
|
||||
fun from(value: String?): BrowserDialerMode? = entries.find { it.value == value }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
/** Runtime type used during config assembly only. */
|
||||
enum class CoreResolvedType {
|
||||
NORMAL,
|
||||
POLICYGROUP,
|
||||
PROXYCHAIN,
|
||||
}
|
||||
@@ -15,7 +15,8 @@ enum class EConfigType(val value: Int, val protocolScheme: String) {
|
||||
HYSTERIA2(9, AppConfig.HYSTERIA2),
|
||||
HYSTERIA(900, AppConfig.HYSTERIA),
|
||||
HTTP(10, AppConfig.HTTP),
|
||||
POLICYGROUP (101, AppConfig.CUSTOM);
|
||||
POLICYGROUP(101, AppConfig.CUSTOM),
|
||||
PROXYCHAIN(102, AppConfig.CUSTOM);
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
|
||||
|
||||
@@ -1,20 +1,57 @@
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
enum class Language(val code: String) {
|
||||
AUTO("auto"),
|
||||
ENGLISH("en"),
|
||||
CHINA("zh-rCN"),
|
||||
TRADITIONAL_CHINESE("zh-rTW"),
|
||||
VIETNAMESE("vi"),
|
||||
RUSSIAN("ru"),
|
||||
PERSIAN("fa"),
|
||||
ARABIC("ar"),
|
||||
BANGLA("bn"),
|
||||
BAKHTIARI("bqi-rIR");
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import java.util.Locale
|
||||
|
||||
enum class Language(val code: String, val languageTag: String) {
|
||||
AUTO("auto", ""),
|
||||
ENGLISH("en", "en"),
|
||||
CHINA("zh-rCN", "zh-CN"),
|
||||
TRADITIONAL_CHINESE("zh-rTW", "zh-TW"),
|
||||
VIETNAMESE("vi", "vi"),
|
||||
RUSSIAN("ru", "ru"),
|
||||
PERSIAN("fa", "fa"),
|
||||
ARABIC("ar", "ar"),
|
||||
BANGLA("bn", "bn"),
|
||||
BAKHTIARI("bqi-rIR", "bqi-IR");
|
||||
|
||||
val locale: Locale?
|
||||
get() = languageTag.takeIf { it.isNotEmpty() }?.let(Locale::forLanguageTag)
|
||||
|
||||
fun toLocaleList(): LocaleListCompat =
|
||||
if (languageTag.isEmpty()) {
|
||||
LocaleListCompat.getEmptyLocaleList()
|
||||
} else {
|
||||
LocaleListCompat.forLanguageTags(languageTag)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String): Language {
|
||||
return entries.find { it.code == code } ?: AUTO
|
||||
}
|
||||
|
||||
fun fromLanguageTag(languageTag: String?): Language {
|
||||
if (languageTag.isNullOrBlank()) return AUTO
|
||||
|
||||
val locale = Locale.forLanguageTag(languageTag)
|
||||
return when (locale.language) {
|
||||
"en" -> ENGLISH
|
||||
"zh" -> if (
|
||||
locale.script == "Hant" || locale.country in setOf("TW", "HK", "MO")
|
||||
) {
|
||||
TRADITIONAL_CHINESE
|
||||
} else {
|
||||
CHINA
|
||||
}
|
||||
|
||||
"vi" -> VIETNAMESE
|
||||
"ru" -> RUSSIAN
|
||||
"fa" -> PERSIAN
|
||||
"ar" -> ARABIC
|
||||
"bn" -> BANGLA
|
||||
"bqi" -> BAKHTIARI
|
||||
else -> AUTO
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.v2ray.ang.R
|
||||
|
||||
/**
|
||||
* Enum defining different notification channels.
|
||||
* Each channel has a unique channelId, notificationId, and display name.
|
||||
*/
|
||||
enum class NotificationChannelType(
|
||||
val channelId: String,
|
||||
@StringRes val channelNameRes: Int,
|
||||
val notificationId: Int
|
||||
) {
|
||||
SUBSCRIPTION_UPDATE(
|
||||
channelId = "subscription_update_channel",
|
||||
channelNameRes = R.string.notification_channel_subscription_updates,
|
||||
notificationId = 13
|
||||
),
|
||||
CORE_TEST(
|
||||
channelId = "core_test_channel",
|
||||
channelNameRes = R.string.notification_channel_connection_checks,
|
||||
notificationId = 12
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.v2ray.ang.enums
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.annotation.StringRes
|
||||
import com.v2ray.ang.R
|
||||
|
||||
/**
|
||||
* Permission types used in the app, handling API level differences.
|
||||
@@ -13,32 +15,28 @@ enum class PermissionType {
|
||||
override fun getPermission(): String = Manifest.permission.CAMERA
|
||||
},
|
||||
|
||||
/** Read storage / media permission (adapts to Android version) */
|
||||
READ_STORAGE {
|
||||
override fun getPermission(): String {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
} else {
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Notification permission (Android 13+) */
|
||||
POST_NOTIFICATIONS {
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
override fun getPermission(): String = Manifest.permission.POST_NOTIFICATIONS
|
||||
},
|
||||
|
||||
/** Local network access permission (Android 17+) */
|
||||
ACCESS_LOCAL_NETWORK {
|
||||
@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
|
||||
override fun getPermission(): String = Manifest.permission.ACCESS_LOCAL_NETWORK
|
||||
};
|
||||
|
||||
/** Return the actual Android permission string */
|
||||
abstract fun getPermission(): String
|
||||
|
||||
/** Return a human-readable label for the permission */
|
||||
fun getLabel(): String {
|
||||
/** Return the string resource for the human-readable permission label. */
|
||||
@StringRes
|
||||
fun getLabelRes(): Int {
|
||||
return when (this) {
|
||||
CAMERA -> "Camera"
|
||||
READ_STORAGE -> "Storage"
|
||||
POST_NOTIFICATIONS -> "Notification"
|
||||
CAMERA -> R.string.permission_camera
|
||||
POST_NOTIFICATIONS -> R.string.permission_notification
|
||||
ACCESS_LOCAL_NETWORK -> R.string.permission_local_network
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,5 @@ enum class RoutingType(val fileName: String) {
|
||||
BLACK("custom_routing_black"),
|
||||
GLOBAL("custom_routing_global"),
|
||||
WHITE_IRAN("custom_routing_white_iran"),
|
||||
WHITE_RUSSIA("custom_routing_white_russia");
|
||||
|
||||
companion object {
|
||||
fun fromIndex(index: Int): RoutingType {
|
||||
return when (index) {
|
||||
0 -> WHITE
|
||||
1 -> BLACK
|
||||
2 -> GLOBAL
|
||||
3 -> WHITE_IRAN
|
||||
4 -> WHITE_RUSSIA
|
||||
else -> WHITE
|
||||
}
|
||||
}
|
||||
}
|
||||
WHITE_RUSSIA("custom_routing_white_russia")
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.v2ray.ang.extension
|
||||
|
||||
/**
|
||||
* Moves an item to another index while preserving the relative order of the remaining items.
|
||||
*/
|
||||
internal fun <T> MutableList<T>.moveItem(fromIndex: Int, toIndex: Int): Boolean {
|
||||
if (fromIndex !in indices || toIndex !in indices || fromIndex == toIndex) return false
|
||||
add(toIndex, removeAt(fromIndex))
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.v2ray.ang.extension
|
||||
|
||||
/**
|
||||
* Removes all whitespace from the string.
|
||||
*
|
||||
* @return The string without whitespace.
|
||||
*/
|
||||
fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
|
||||
|
||||
/**
|
||||
* Returns null if the string is null or blank, otherwise returns the string itself.
|
||||
*
|
||||
* @return The string or null.
|
||||
*/
|
||||
fun String?.nullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* Converts the string to a Long value, or returns 0 if the conversion fails.
|
||||
*
|
||||
* @return The Long value.
|
||||
*/
|
||||
fun String.toLongEx(): Long = toLongOrNull() ?: 0
|
||||
|
||||
/**
|
||||
* Checks if the CharSequence is not null and not empty.
|
||||
*
|
||||
* @return True if the CharSequence is not null and not empty, false otherwise.
|
||||
*/
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to match text either by Regex or literal string.
|
||||
*/
|
||||
fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean = true): Boolean {
|
||||
if (keyword.isNullOrEmpty()) {
|
||||
return true
|
||||
}
|
||||
return regex?.containsMatchIn(this)
|
||||
?: this.contains(keyword, ignoreCase = ignoreCase)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.v2ray.ang.extension
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import com.v2ray.ang.ui.compose.AppSnackbarManager
|
||||
import com.v2ray.ang.ui.compose.ToastType
|
||||
|
||||
private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
|
||||
|
||||
private fun runOnMain(block: () -> Unit) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block()
|
||||
} else {
|
||||
mainHandler.post(block)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.dispatchMessage(
|
||||
message: CharSequence,
|
||||
type: ToastType,
|
||||
long: Boolean = false
|
||||
) {
|
||||
val handledBySnackbar = AppSnackbarManager.show(
|
||||
message = message,
|
||||
type = type,
|
||||
long = long
|
||||
)
|
||||
if (!handledBySnackbar) {
|
||||
val appContext = applicationContext
|
||||
val duration = if (long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT
|
||||
runOnMain {
|
||||
Toast.makeText(appContext, message, duration).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given resource ID.
|
||||
*
|
||||
* @param message The resource ID of the message to show.
|
||||
* @param long Whether to display the message for a longer duration.
|
||||
*/
|
||||
fun Context.toast(message: Int, long: Boolean = false) {
|
||||
dispatchMessage(getString(message), ToastType.NORMAL, long)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given text.
|
||||
*
|
||||
* @param message The text of the message to show.
|
||||
* @param long Whether to display the message for a longer duration.
|
||||
*/
|
||||
fun Context.toast(message: CharSequence, long: Boolean = false) {
|
||||
dispatchMessage(message, ToastType.NORMAL, long)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a success toast message with the given resource ID.
|
||||
*
|
||||
* @param message The resource ID of the message to show.
|
||||
* @param long Whether to display the message for a longer duration.
|
||||
*/
|
||||
fun Context.toastSuccess(message: Int, long: Boolean = false) {
|
||||
dispatchMessage(getString(message), ToastType.SUCCESS, long)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a success toast message with the given text.
|
||||
*
|
||||
* @param message The text of the message to show.
|
||||
* @param long Whether to display the message for a longer duration.
|
||||
*/
|
||||
fun Context.toastSuccess(message: CharSequence, long: Boolean = false) {
|
||||
dispatchMessage(message, ToastType.SUCCESS, long)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an error toast message with the given resource ID.
|
||||
*
|
||||
* @param message The resource ID of the message to show.
|
||||
* @param long Whether to display the message for a longer duration.
|
||||
*/
|
||||
fun Context.toastError(message: Int, long: Boolean = false) {
|
||||
dispatchMessage(getString(message), ToastType.ERROR, long)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an error toast message with the given text.
|
||||
*
|
||||
* @param message The text of the message to show.
|
||||
* @param long Whether to display the message for a longer duration.
|
||||
*/
|
||||
fun Context.toastError(message: CharSequence, long: Boolean = false) {
|
||||
dispatchMessage(message, ToastType.ERROR, long)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an info toast message with the given resource ID.
|
||||
*
|
||||
* @param message The resource ID of the message to show.
|
||||
* @param long Whether to display the message for a longer duration.
|
||||
*/
|
||||
fun Context.toastInfo(message: Int, long: Boolean = false) {
|
||||
dispatchMessage(getString(message), ToastType.INFO, long)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an info toast message with the given text.
|
||||
*
|
||||
* @param message The text of the message to show.
|
||||
* @param long Whether to display the message for a longer duration.
|
||||
*/
|
||||
fun Context.toastInfo(message: CharSequence, long: Boolean = false) {
|
||||
dispatchMessage(message, ToastType.INFO, long)
|
||||
}
|
||||
@@ -1,74 +1,13 @@
|
||||
package com.v2ray.ang.extension
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import com.v2ray.ang.AngApplication
|
||||
import es.dmoral.toasty.Toasty
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import java.io.Serializable
|
||||
import java.net.URI
|
||||
import java.util.Locale
|
||||
|
||||
val Context.v2RayApplication: AngApplication?
|
||||
get() = applicationContext as? AngApplication
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given resource ID.
|
||||
*
|
||||
* @param message The resource ID of the message to show.
|
||||
*/
|
||||
fun Context.toast(message: Int) {
|
||||
Toasty.normal(this, message).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given text.
|
||||
*
|
||||
* @param message The text of the message to show.
|
||||
*/
|
||||
fun Context.toast(message: CharSequence) {
|
||||
Toasty.normal(this, message).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given resource ID.
|
||||
*
|
||||
* @param message The resource ID of the message to show.
|
||||
*/
|
||||
fun Context.toastSuccess(message: Int) {
|
||||
Toasty.success(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.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()
|
||||
}
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
const val THRESHOLD = 1000L
|
||||
const val DIVISOR = 1024.0
|
||||
@@ -99,56 +38,6 @@ fun Long.toTrafficString(): String {
|
||||
val URI.idnHost: String
|
||||
get() = host?.replace("[", "")?.replace("]", "").orEmpty()
|
||||
|
||||
/**
|
||||
* Removes all whitespace from the string.
|
||||
*
|
||||
* @return The string without whitespace.
|
||||
*/
|
||||
fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
|
||||
|
||||
/**
|
||||
* Returns null if the string is null or blank, otherwise returns the string itself.
|
||||
*
|
||||
* @return The string or null.
|
||||
*/
|
||||
fun String?.nullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* Converts the string to a Long value, or returns 0 if the conversion fails.
|
||||
*
|
||||
* @return The Long value.
|
||||
*/
|
||||
fun String.toLongEx(): Long = toLongOrNull() ?: 0
|
||||
|
||||
/**
|
||||
* Listens for package changes and executes a callback when a change occurs.
|
||||
*
|
||||
* @param onetime Whether to unregister the receiver after the first callback.
|
||||
* @param callback The callback to execute when a package change occurs.
|
||||
* @return The BroadcastReceiver that was registered.
|
||||
*/
|
||||
fun Context.listenForPackageChanges(onetime: Boolean = true, callback: () -> Unit) =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
callback()
|
||||
if (onetime) context.unregisterReceiver(this)
|
||||
}
|
||||
}.apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(this, IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
}, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
registerReceiver(this, IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a serializable object from the Bundle.
|
||||
*
|
||||
@@ -172,21 +61,35 @@ inline fun <reified T : Serializable> Intent.serializable(key: String): T? = whe
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the CharSequence is not null and not empty.
|
||||
* Checks if the config type is a group type (PolicyGroup or ProxyChain).
|
||||
*
|
||||
* @return True if the CharSequence is not null and not empty, false otherwise.
|
||||
* @return True if the config type is PolicyGroup or ProxyChain, false otherwise.
|
||||
*/
|
||||
fun CharSequence?.isNotNullEmpty(): Boolean = !this.isNullOrBlank()
|
||||
fun EConfigType.isGroupType(): Boolean {
|
||||
return this == EConfigType.POLICYGROUP || this == EConfigType.PROXYCHAIN
|
||||
}
|
||||
|
||||
fun String.concatUrl(vararg paths: String): String {
|
||||
val builder = StringBuilder(this.trimEnd('/'))
|
||||
/**
|
||||
* Checks if the config type is a complex type (Custom, PolicyGroup, or ProxyChain).
|
||||
*
|
||||
* @return True if the config type is Custom, PolicyGroup, or ProxyChain, false otherwise.
|
||||
*/
|
||||
fun EConfigType.isComplexType(): Boolean {
|
||||
return this == EConfigType.CUSTOM || this == EConfigType.POLICYGROUP || this == EConfigType.PROXYCHAIN
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand for delay with Int milliseconds using Duration to avoid legacy Long overload warning.
|
||||
*/
|
||||
suspend fun delay(millis: Int) {
|
||||
kotlinx.coroutines.delay(millis.toLong().milliseconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand for delay with Long milliseconds using Duration to avoid legacy Long overload warning.
|
||||
*/
|
||||
suspend fun delay(millis: Long) {
|
||||
kotlinx.coroutines.delay(millis.milliseconds)
|
||||
}
|
||||
|
||||
paths.forEach { path ->
|
||||
val trimmedPath = path.trim('/')
|
||||
if (trimmedPath.isNotEmpty()) {
|
||||
builder.append('/').append(trimmedPath)
|
||||
}
|
||||
}
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
|
||||
object CustomFmt : FmtBase() {
|
||||
@@ -12,7 +12,7 @@ object CustomFmt : FmtBase() {
|
||||
* @param str the JSON string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem? {
|
||||
fun parse(str: String): ProfileItem {
|
||||
val config = ProfileItem.create(EConfigType.CUSTOM)
|
||||
|
||||
val fullConfig = JsonUtil.fromJson(str, V2rayConfig::class.java)
|
||||
@@ -20,7 +20,7 @@ object CustomFmt : FmtBase() {
|
||||
|
||||
config.remarks = fullConfig?.remarks ?: System.currentTimeMillis().toString()
|
||||
config.server = outbound?.getServerAddress()
|
||||
config.serverPort = outbound?.getServerPort().toString()
|
||||
config.serverPort = outbound?.getServerPort()?.toString()
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
@@ -51,15 +50,16 @@ open class FmtBase {
|
||||
*
|
||||
* @param config the ProfileItem object to populate
|
||||
* @param queryParam the query parameters to use for populating the ProfileItem
|
||||
* @param allowInsecure whether to allow insecure connections
|
||||
*/
|
||||
fun getItemFormQuery(config: ProfileItem, queryParam: Map<String, String>, allowInsecure: Boolean) {
|
||||
fun getItemFormQuery(config: ProfileItem, queryParam: Map<String, String>) {
|
||||
config.network = queryParam["type"] ?: NetworkType.TCP.type
|
||||
config.headerType = queryParam["headerType"]
|
||||
config.host = queryParam["host"]
|
||||
config.path = queryParam["path"]
|
||||
|
||||
config.seed = queryParam["seed"]
|
||||
config.kcpMtu = queryParam["mtu"]?.toIntOrNull()
|
||||
config.kcpTti = queryParam["tti"]?.toIntOrNull()
|
||||
config.quicSecurity = queryParam["quicSecurity"]
|
||||
config.quicKey = queryParam["key"]
|
||||
config.mode = queryParam["mode"]
|
||||
@@ -67,6 +67,7 @@ open class FmtBase {
|
||||
config.authority = queryParam["authority"]
|
||||
config.xhttpMode = queryParam["mode"]
|
||||
config.xhttpExtra = queryParam["extra"]
|
||||
config.finalMask = queryParam["fm"]
|
||||
|
||||
config.security = queryParam["security"]
|
||||
if (config.security != AppConfig.TLS && config.security != AppConfig.REALITY) {
|
||||
@@ -77,12 +78,13 @@ open class FmtBase {
|
||||
config.insecure = when {
|
||||
allowInsecureKeys.any { queryParam[it] == "1" } -> true
|
||||
allowInsecureKeys.any { queryParam[it] == "0" } -> false
|
||||
else -> allowInsecure
|
||||
else -> false
|
||||
}
|
||||
config.sni = queryParam["sni"]
|
||||
config.fingerPrint = queryParam["fp"]
|
||||
config.alpn = queryParam["alpn"]
|
||||
config.echConfigList = queryParam["ech"]
|
||||
config.verifyPeerCertByName = queryParam["vcn"]
|
||||
config.pinnedCA256 = queryParam["pcs"]
|
||||
config.publicKey = queryParam["pbk"]
|
||||
config.shortId = queryParam["sid"]
|
||||
@@ -103,6 +105,7 @@ open class FmtBase {
|
||||
config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
|
||||
config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
|
||||
config.echConfigList?.nullIfBlank()?.let { dicQuery["ech"] = it }
|
||||
config.verifyPeerCertByName?.nullIfBlank()?.let { dicQuery["vcn"] = it }
|
||||
config.pinnedCA256?.nullIfBlank()?.let { dicQuery["pcs"] = it }
|
||||
config.fingerPrint?.nullIfBlank()?.let { dicQuery["fp"] = it }
|
||||
config.publicKey?.nullIfBlank()?.let { dicQuery["pbk"] = it }
|
||||
@@ -110,6 +113,9 @@ open class FmtBase {
|
||||
config.spiderX?.nullIfBlank()?.let { dicQuery["spx"] = it }
|
||||
config.mldsa65Verify?.nullIfBlank()?.let { dicQuery["pqv"] = it }
|
||||
config.flow?.nullIfBlank()?.let { dicQuery["flow"] = it }
|
||||
config.finalMask?.nullIfBlank()?.let { dicQuery["fm"] = it }
|
||||
config.kcpMtu?.let { dicQuery["mtu"] = it.toString() }
|
||||
config.kcpTti?.let { dicQuery["tti"] = it.toString() }
|
||||
// Add two keys for compatibility: "insecure" and "allowInsecure"
|
||||
if (config.security == AppConfig.TLS) {
|
||||
val insecureFlag = if (config.insecure == true) "1" else "0"
|
||||
@@ -166,21 +172,4 @@ open class FmtBase {
|
||||
|
||||
return dicQuery
|
||||
}
|
||||
|
||||
fun getServerAddress(profileItem: ProfileItem): String {
|
||||
if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
|
||||
return profileItem.server.orEmpty()
|
||||
}
|
||||
|
||||
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
|
||||
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
|
||||
return domain
|
||||
}
|
||||
//Resolve and replace domain
|
||||
val resolvedIps = HttpUtil.resolveHostToIP(domain, MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
|
||||
if (resolvedIps.isNullOrEmpty()) {
|
||||
return domain
|
||||
}
|
||||
return resolvedIps.first()
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
|
||||
object HttpFmt : FmtBase() {
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HTTP)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
if (profileItem.username.isNotNullEmpty()) {
|
||||
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
|
||||
socksUsersBean.user = profileItem.username.orEmpty()
|
||||
socksUsersBean.pass = profileItem.password.orEmpty()
|
||||
server.users = listOf(socksUsersBean)
|
||||
}
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.StreamSettingsBean.FinalMaskBean
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -21,8 +17,7 @@ object Hysteria2Fmt : FmtBase() {
|
||||
* @param str the Hysteria2 URI string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem? {
|
||||
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
||||
fun parse(str: String): ProfileItem {
|
||||
val config = ProfileItem.create(EConfigType.HYSTERIA2)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
@@ -36,15 +31,12 @@ object Hysteria2Fmt : FmtBase() {
|
||||
if (!uri.rawQuery.isNullOrEmpty()) {
|
||||
val queryParam = getQueryParam(uri)
|
||||
|
||||
getItemFormQuery(config, queryParam, allowInsecure)
|
||||
getItemFormQuery(config, queryParam)
|
||||
|
||||
config.security = queryParam["security"] ?: AppConfig.TLS
|
||||
config.obfsPassword = queryParam["obfs-password"]
|
||||
config.portHopping = queryParam["mport"]
|
||||
if (config.portHopping.isNotNullEmpty()) {
|
||||
config.portHoppingInterval = queryParam["mportHopInt"]
|
||||
}
|
||||
config.pinSHA256 = queryParam["pinSHA256"]
|
||||
config.pinnedCA256 = queryParam["pinSHA256"]
|
||||
|
||||
}
|
||||
|
||||
@@ -72,53 +64,10 @@ 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()
|
||||
if (config.pinnedCA256.isNotNullEmpty()) {
|
||||
dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
|
||||
}
|
||||
|
||||
return toUri(config, config.password, dicQuery)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HYSTERIA2) ?: return null
|
||||
profileItem.network = NetworkType.HYSTERIA.type
|
||||
profileItem.alpn = "h3"
|
||||
|
||||
outboundBean.settings?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.version = 2
|
||||
}
|
||||
|
||||
val sni = outboundBean.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
if (profileItem.obfsPassword.isNotNullEmpty()) {
|
||||
outboundBean.streamSettings?.finalmask = FinalMaskBean(
|
||||
udp = listOf(
|
||||
FinalMaskBean.MaskBean(
|
||||
type = "salamander",
|
||||
settings = FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
password = profileItem.obfsPassword
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
return outboundBean
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -85,7 +83,7 @@ object ShadowsocksFmt : FmtBase() {
|
||||
config.remarks =
|
||||
Utils.decodeURIComponent(result.substring(indexSplit + 1, result.length))
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
|
||||
}
|
||||
|
||||
result = result.substring(0, indexSplit)
|
||||
@@ -124,31 +122,4 @@ object ShadowsocksFmt : FmtBase() {
|
||||
|
||||
return toUri(config, Utils.encode(pw, true), null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SHADOWSOCKS)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.password = profileItem.password
|
||||
server.method = profileItem.method
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
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
|
||||
|
||||
@@ -28,7 +26,11 @@ object SocksFmt : FmtBase() {
|
||||
config.serverPort = uri.port.toString()
|
||||
|
||||
if (uri.userInfo?.isEmpty() == false) {
|
||||
val result = Utils.decode(uri.userInfo).split(":", limit = 2)
|
||||
val result = if (uri.userInfo.contains(":")) {
|
||||
uri.userInfo.split(":", limit = 2)
|
||||
} else {
|
||||
Utils.decode(uri.userInfo).split(":", limit = 2)
|
||||
}
|
||||
if (result.count() == 2) {
|
||||
config.username = result.first()
|
||||
config.password = result.last()
|
||||
@@ -53,27 +55,4 @@ object SocksFmt : FmtBase() {
|
||||
|
||||
return toUri(config, Utils.encode(pw, true), null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SOCKS)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
if (profileItem.username.isNotNullEmpty()) {
|
||||
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
|
||||
socksUsersBean.user = profileItem.username.orEmpty()
|
||||
socksUsersBean.pass = profileItem.password.orEmpty()
|
||||
server.users = listOf(socksUsersBean)
|
||||
}
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -18,8 +15,7 @@ object TrojanFmt : FmtBase() {
|
||||
* @param str the Trojan URI string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem? {
|
||||
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
||||
fun parse(str: String): ProfileItem {
|
||||
val config = ProfileItem.create(EConfigType.TROJAN)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
@@ -31,11 +27,11 @@ object TrojanFmt : FmtBase() {
|
||||
if (uri.rawQuery.isNullOrEmpty()) {
|
||||
config.network = NetworkType.TCP.type
|
||||
config.security = AppConfig.TLS
|
||||
config.insecure = allowInsecure
|
||||
config.insecure = false
|
||||
} else {
|
||||
val queryParam = getQueryParam(uri)
|
||||
|
||||
getItemFormQuery(config, queryParam, allowInsecure)
|
||||
getItemFormQuery(config, queryParam)
|
||||
config.security = queryParam["security"] ?: AppConfig.TLS
|
||||
}
|
||||
|
||||
@@ -53,31 +49,4 @@ object TrojanFmt : FmtBase() {
|
||||
|
||||
return toUri(config, config.password, dicQuery)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.TROJAN)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.password = profileItem.password
|
||||
server.flow = profileItem.flow
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.V2rayNShareItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
object V2rayNFmt : FmtBase() {
|
||||
fun parse(list: List<String>, subId: String): List<ProfileItem> {
|
||||
val idMap = mutableMapOf<String, V2rayNShareItem>()
|
||||
list.mapNotNull(::parseShareItem).forEach { item ->
|
||||
idMap.putIfAbsent(item.IndexId.orEmpty(), item)
|
||||
}
|
||||
|
||||
return idMap.values.map { item ->
|
||||
item.toProfileItem().apply {
|
||||
val proto = item.ProtoExtraObj
|
||||
policyGroupSubscriptionId = if (proto?.SubChildItems == "self") {
|
||||
subId
|
||||
} else {
|
||||
null
|
||||
}
|
||||
proto?.ChildItems?.takeIf { it.isNotNullEmpty() }?.let { ids ->
|
||||
val remarks = ids.split(",")
|
||||
.mapNotNull { idMap[it]?.Remarks }
|
||||
.filter { it.isNotNullEmpty() }
|
||||
|
||||
if (remarks.isNotEmpty()) {
|
||||
when (item.ConfigType) {
|
||||
101 -> policyGroupFilter = remarks.joinToString("|", "^(", ")$") { Regex.escape(it) }
|
||||
102 -> proxyChainProfiles = remarks.joinToString(",")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseShareItem(str: String): V2rayNShareItem? = try {
|
||||
JsonUtil.fromJson(Utils.decode(str.substringAfterLast('/')), V2rayNShareItem::class.java)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse V2rayN share item", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
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
|
||||
|
||||
@@ -19,7 +15,6 @@ object VlessFmt : FmtBase() {
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem? {
|
||||
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
||||
val config = ProfileItem.create(EConfigType.VLESS)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
@@ -32,7 +27,7 @@ object VlessFmt : FmtBase() {
|
||||
config.password = uri.userInfo
|
||||
config.method = queryParam["encryption"] ?: "none"
|
||||
|
||||
getItemFormQuery(config, queryParam, allowInsecure)
|
||||
getItemFormQuery(config, queryParam)
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -50,31 +45,4 @@ object VlessFmt : FmtBase() {
|
||||
return toUri(config, config.password, dicQuery)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VLESS)
|
||||
|
||||
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||
vnext.address = getServerAddress(profileItem)
|
||||
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
||||
vnext.users[0].id = profileItem.password.orEmpty()
|
||||
vnext.users[0].encryption = profileItem.method
|
||||
vnext.users[0].flow = profileItem.flow
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.VmessQRCode
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.dto.VmessQRCode
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -28,13 +25,12 @@ object VmessFmt : FmtBase() {
|
||||
return parseVmessStd(str)
|
||||
}
|
||||
|
||||
val allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
||||
val config = ProfileItem.create(EConfigType.VMESS)
|
||||
|
||||
var result = str.replace(EConfigType.VMESS.protocolScheme, "")
|
||||
result = Utils.decode(result)
|
||||
if (TextUtils.isEmpty(result)) {
|
||||
Log.w(AppConfig.TAG, "Toast decoding failed")
|
||||
LogUtil.w(AppConfig.TAG, "Toast decoding failed")
|
||||
return null
|
||||
}
|
||||
val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java) ?: return null
|
||||
@@ -44,7 +40,7 @@ object VmessFmt : FmtBase() {
|
||||
|| TextUtils.isEmpty(vmessQRCode.id)
|
||||
|| TextUtils.isEmpty(vmessQRCode.net)
|
||||
) {
|
||||
Log.w(AppConfig.TAG, "Toast incorrect protocol")
|
||||
LogUtil.w(AppConfig.TAG, "Toast incorrect protocol")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -89,8 +85,11 @@ object VmessFmt : FmtBase() {
|
||||
config.insecure = when (vmessQRCode.insecure) {
|
||||
"1" -> true
|
||||
"0" -> false
|
||||
else -> allowInsecure
|
||||
else -> false
|
||||
}
|
||||
config.verifyPeerCertByName = vmessQRCode.vcn
|
||||
config.pinnedCA256 = vmessQRCode.pcs
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -144,6 +143,8 @@ object VmessFmt : FmtBase() {
|
||||
false -> "0"
|
||||
else -> ""
|
||||
}
|
||||
vmessQRCode.vcn = config.verifyPeerCertByName.orEmpty()
|
||||
vmessQRCode.pcs = config.pinnedCA256.orEmpty()
|
||||
|
||||
val json = JsonUtil.toJson(vmessQRCode)
|
||||
return Utils.encode(json)
|
||||
@@ -156,7 +157,6 @@ object VmessFmt : FmtBase() {
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parseVmessStd(str: String): ProfileItem? {
|
||||
val allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
||||
val config = ProfileItem.create(EConfigType.VMESS)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
@@ -169,36 +169,10 @@ object VmessFmt : FmtBase() {
|
||||
config.password = uri.userInfo
|
||||
config.method = AppConfig.DEFAULT_SECURITY
|
||||
|
||||
getItemFormQuery(config, queryParam, allowInsecure)
|
||||
getItemFormQuery(config, queryParam)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VMESS)
|
||||
|
||||
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||
vnext.address = getServerAddress(profileItem)
|
||||
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
||||
vnext.users[0].id = profileItem.password.orEmpty()
|
||||
vnext.users[0].security = profileItem.method
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.extension.removeWhiteSpace
|
||||
import com.v2ray.ang.handler.V2rayConfigManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
@@ -31,11 +28,13 @@ object WireguardFmt : FmtBase() {
|
||||
config.serverPort = uri.port.toString()
|
||||
|
||||
config.secretKey = uri.userInfo.orEmpty()
|
||||
config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
|
||||
config.localAddress = queryParam["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||
config.publicKey = queryParam["publickey"].orEmpty()
|
||||
config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
|
||||
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
||||
config.remoteDNS = queryParam["dns"] ?: AppConfig.WIREGUARD_LOCAL_REMOTE_DNS
|
||||
config.reserved = queryParam["reserved"] ?: "0,0,0"
|
||||
config.finalMask = (queryParam["fm"] ?: queryParam["finalmask"] ?: queryParam["finalMask"])?.nullIfBlank()
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -46,7 +45,7 @@ object WireguardFmt : FmtBase() {
|
||||
* @param str the Wireguard configuration file string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parseWireguardConfFile(str: String): ProfileItem? {
|
||||
fun parseWireguardConfFile(str: String): ProfileItem {
|
||||
val config = ProfileItem.create(EConfigType.WIREGUARD)
|
||||
|
||||
val interfaceParams: MutableMap<String, String> = mutableMapOf()
|
||||
@@ -82,8 +81,9 @@ object WireguardFmt : FmtBase() {
|
||||
|
||||
config.secretKey = interfaceParams["privatekey"].orEmpty()
|
||||
config.remarks = System.currentTimeMillis().toString()
|
||||
config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
|
||||
config.localAddress = interfaceParams["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
||||
config.remoteDNS = interfaceParams["dns"] ?: AppConfig.WIREGUARD_LOCAL_REMOTE_DNS
|
||||
config.publicKey = peerParams["publickey"].orEmpty()
|
||||
config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
|
||||
val endpoint = peerParams["endpoint"].orEmpty()
|
||||
@@ -96,33 +96,11 @@ object WireguardFmt : FmtBase() {
|
||||
config.serverPort = ""
|
||||
}
|
||||
config.reserved = peerParams["reserved"] ?: "0,0,0"
|
||||
config.finalMask = (peerParams["finalmask"] ?: peerParams["fm"] ?: interfaceParams["finalmask"] ?: interfaceParams["fm"])?.nullIfBlank()
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to an OutboundBean object.
|
||||
*
|
||||
* @param profileItem the ProfileItem object to convert
|
||||
* @return the converted OutboundBean object, or null if conversion fails
|
||||
*/
|
||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.WIREGUARD)
|
||||
|
||||
outboundBean?.settings?.let { wireguard ->
|
||||
wireguard.secretKey = profileItem.secretKey
|
||||
wireguard.address = (profileItem.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4).split(",")
|
||||
wireguard.peers?.firstOrNull()?.let { peer ->
|
||||
peer.publicKey = profileItem.publicKey.orEmpty()
|
||||
peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
|
||||
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
|
||||
}
|
||||
wireguard.mtu = profileItem.mtu
|
||||
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to a URI string.
|
||||
@@ -144,6 +122,10 @@ object WireguardFmt : FmtBase() {
|
||||
if (config.preSharedKey != null) {
|
||||
dicQuery["presharedkey"] = config.preSharedKey.removeWhiteSpace().orEmpty()
|
||||
}
|
||||
if (config.remoteDNS != null) {
|
||||
dicQuery["dns"] = config.remoteDNS.removeWhiteSpace().orEmpty()
|
||||
}
|
||||
config.finalMask?.nullIfBlank()?.let { dicQuery["fm"] = it }
|
||||
|
||||
return toUri(config, config.secretKey, dicQuery)
|
||||
}
|
||||
|
||||
@@ -3,30 +3,53 @@ package com.v2ray.ang.handler
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.HY2
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreConfigManager
|
||||
import com.v2ray.ang.dto.SubscriptionUpdateResult
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.SubscriptionCache
|
||||
import com.v2ray.ang.dto.SubscriptionItem
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.fmt.CustomFmt
|
||||
import com.v2ray.ang.fmt.Hysteria2Fmt
|
||||
import com.v2ray.ang.fmt.ShadowsocksFmt
|
||||
import com.v2ray.ang.fmt.SocksFmt
|
||||
import com.v2ray.ang.fmt.TrojanFmt
|
||||
import com.v2ray.ang.fmt.V2rayNFmt
|
||||
import com.v2ray.ang.fmt.VlessFmt
|
||||
import com.v2ray.ang.fmt.VmessFmt
|
||||
import com.v2ray.ang.fmt.WireguardFmt
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.QRCodeDecoder
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
object AngConfigManager {
|
||||
|
||||
private data class ParsedProfile(
|
||||
val profile: ProfileItem,
|
||||
val rawConfig: String? = null,
|
||||
)
|
||||
|
||||
// Parser mapping for different config types (lazy initialized)
|
||||
private val configFmtParsers: Map<String, (String) -> ProfileItem?> by lazy {
|
||||
mapOf(
|
||||
EConfigType.VMESS.protocolScheme to VmessFmt::parse,
|
||||
EConfigType.SHADOWSOCKS.protocolScheme to ShadowsocksFmt::parse,
|
||||
EConfigType.SOCKS.protocolScheme to SocksFmt::parse,
|
||||
AppConfig.SOCKS4 to SocksFmt::parse,
|
||||
AppConfig.SOCKS5 to SocksFmt::parse,
|
||||
EConfigType.TROJAN.protocolScheme to TrojanFmt::parse,
|
||||
EConfigType.VLESS.protocolScheme to VlessFmt::parse,
|
||||
EConfigType.WIREGUARD.protocolScheme to WireguardFmt::parse,
|
||||
EConfigType.HYSTERIA2.protocolScheme to Hysteria2Fmt::parse,
|
||||
AppConfig.HY2 to Hysteria2Fmt::parse,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares the configuration to the clipboard.
|
||||
@@ -45,7 +68,7 @@ object AngConfigManager {
|
||||
Utils.setClipboard(context, conf)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to share config to clipboard", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to share config to clipboard", e)
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
@@ -74,7 +97,7 @@ object AngConfigManager {
|
||||
}
|
||||
return sb.lines().count() - 1
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to share non-custom configs to clipboard", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to share non-custom configs to clipboard", e)
|
||||
return -1
|
||||
}
|
||||
}
|
||||
@@ -94,7 +117,7 @@ object AngConfigManager {
|
||||
return QRCodeDecoder.createQRCode(conf)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to share config as QR code", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to share config as QR code", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -109,14 +132,14 @@ object AngConfigManager {
|
||||
fun shareFullContent2Clipboard(context: Context, guid: String?): Int {
|
||||
try {
|
||||
if (guid == null) return -1
|
||||
val result = V2rayConfigManager.getV2rayConfig(context, guid)
|
||||
val result = CoreConfigManager.getV2rayConfig(context, guid)
|
||||
if (result.status) {
|
||||
Utils.setClipboard(context, result.content)
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to share full content to clipboard", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to share full content to clipboard", e)
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
@@ -134,19 +157,16 @@ object AngConfigManager {
|
||||
|
||||
return config.configType.protocolScheme + when (config.configType) {
|
||||
EConfigType.VMESS -> VmessFmt.toUri(config)
|
||||
EConfigType.CUSTOM -> ""
|
||||
EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toUri(config)
|
||||
EConfigType.SOCKS -> SocksFmt.toUri(config)
|
||||
EConfigType.HTTP -> ""
|
||||
EConfigType.VLESS -> VlessFmt.toUri(config)
|
||||
EConfigType.TROJAN -> TrojanFmt.toUri(config)
|
||||
EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
|
||||
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
|
||||
EConfigType.POLICYGROUP -> ""
|
||||
else -> {}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -160,23 +180,28 @@ object AngConfigManager {
|
||||
* @return A pair containing the number of configurations and subscriptions imported.
|
||||
*/
|
||||
fun importBatchConfig(server: String?, subid: String, append: Boolean): Pair<Int, Int> {
|
||||
var count = parseBatchConfig(Utils.decode(server), subid, append)
|
||||
if (count <= 0) {
|
||||
count = parseBatchConfig(server, subid, append)
|
||||
}
|
||||
if (count <= 0) {
|
||||
count = parseCustomConfigServer(server, subid)
|
||||
}
|
||||
return try {
|
||||
var count = parseBatchConfig(Utils.decode(server), subid, append)
|
||||
if (count <= 0) {
|
||||
count = parseBatchConfig(server, subid, append)
|
||||
}
|
||||
if (count <= 0) {
|
||||
count = parseCustomConfigServer(server, subid, append)
|
||||
}
|
||||
|
||||
var countSub = parseBatchSubscription(server)
|
||||
if (countSub <= 0) {
|
||||
countSub = parseBatchSubscription(Utils.decode(server))
|
||||
}
|
||||
if (countSub > 0) {
|
||||
updateConfigViaSubAll()
|
||||
}
|
||||
var countSub = parseBatchSubscription(server)
|
||||
if (countSub <= 0) {
|
||||
countSub = parseBatchSubscription(Utils.decode(server))
|
||||
}
|
||||
if (countSub > 0) {
|
||||
updateConfigViaSubAll()
|
||||
}
|
||||
|
||||
return count to countSub
|
||||
count to countSub
|
||||
} catch (e: ProfileStorageException) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to store imported profiles", e)
|
||||
0 to 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,7 +226,7 @@ object AngConfigManager {
|
||||
}
|
||||
return count
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse batch subscription", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse batch subscription", e)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -219,49 +244,84 @@ object AngConfigManager {
|
||||
if (servers == null) {
|
||||
return 0
|
||||
}
|
||||
val removedSelectedServer =
|
||||
if (!TextUtils.isEmpty(subid) && !append) {
|
||||
MmkvManager.decodeServerConfig(
|
||||
MmkvManager.getSelectServer().orEmpty()
|
||||
)?.let {
|
||||
if (it.subscriptionId == subid) {
|
||||
return@let it
|
||||
}
|
||||
return@let null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
|
||||
val subItem = MmkvManager.decodeSubscription(subid)
|
||||
var count = 0
|
||||
|
||||
// Parse all configs first (no I/O during parsing)
|
||||
val configs = mutableListOf<ProfileItem>()
|
||||
val v2raynLines = mutableListOf<String>()
|
||||
|
||||
servers.lines()
|
||||
.distinct()
|
||||
.reversed()
|
||||
.forEach {
|
||||
val resId = parseConfig(it, subid, subItem, removedSelectedServer)
|
||||
if (resId == 0) {
|
||||
count++
|
||||
if (it.startsWith(AppConfig.V2RAYNFMTS, ignoreCase = true)) {
|
||||
v2raynLines.add(it)
|
||||
} else {
|
||||
val config = parseConfig(it, subid, subItem)
|
||||
if (config != null) {
|
||||
configs.add(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
|
||||
val v2raynConfigs = V2rayNFmt.parse(v2raynLines, subid)
|
||||
val allConfigs = v2raynConfigs + configs
|
||||
|
||||
if (allConfigs.isNotEmpty()) {
|
||||
commitProfiles(
|
||||
configs = allConfigs.map(::ParsedProfile),
|
||||
subid = subid,
|
||||
append = append,
|
||||
)
|
||||
}
|
||||
|
||||
return allConfigs.size
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse batch config", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse batch config", e)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits parsed profiles before removing the profiles they replace.
|
||||
*
|
||||
* @param configs The parsed profiles to save.
|
||||
* @param subid The subscription ID.
|
||||
* @param append Whether to append to the existing server list.
|
||||
*/
|
||||
private fun commitProfiles(
|
||||
configs: List<ParsedProfile>,
|
||||
subid: String,
|
||||
append: Boolean,
|
||||
) {
|
||||
val keyToProfile = linkedMapOf<String, ProfileItem>()
|
||||
val rawConfigs = mutableMapOf<String, String>()
|
||||
|
||||
configs.forEach { parsed ->
|
||||
val key = Utils.getUuid()
|
||||
keyToProfile[key] = parsed.profile
|
||||
parsed.rawConfig?.let { raw -> rawConfigs[key] = raw }
|
||||
}
|
||||
|
||||
MmkvManager.saveServerProfiles(
|
||||
profiles = keyToProfile,
|
||||
rawConfigs = rawConfigs,
|
||||
subscriptionId = subid,
|
||||
append = append,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a custom configuration server.
|
||||
*
|
||||
* @param server The server string.
|
||||
* @param subid The subscription ID.
|
||||
* @param append Whether to append the configurations.
|
||||
* @return The number of configurations parsed.
|
||||
*/
|
||||
private fun parseCustomConfigServer(server: String?, subid: String): Int {
|
||||
private fun parseCustomConfigServer(server: String?, subid: String, append: Boolean): Int {
|
||||
if (server == null) {
|
||||
return 0
|
||||
}
|
||||
@@ -274,42 +334,56 @@ object AngConfigManager {
|
||||
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
|
||||
|
||||
if (serverList.isNotEmpty()) {
|
||||
var count = 0
|
||||
for (srv in serverList.reversed()) {
|
||||
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
|
||||
val configs = serverList.reversed().map { srv ->
|
||||
val config = CustomFmt.parse(JsonUtil.toJson(srv))
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
|
||||
count += 1
|
||||
ParsedProfile(
|
||||
profile = config,
|
||||
rawConfig = JsonUtil.toJsonPretty(srv) ?: "",
|
||||
)
|
||||
}
|
||||
return count
|
||||
commitProfiles(configs, subid, append)
|
||||
return configs.size
|
||||
}
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
|
||||
}
|
||||
|
||||
try {
|
||||
// For compatibility
|
||||
val config = CustomFmt.parse(server) ?: return 0
|
||||
val config = CustomFmt.parse(server)
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
commitProfiles(
|
||||
configs = listOf(ParsedProfile(config, server)),
|
||||
subid = subid,
|
||||
append = append,
|
||||
)
|
||||
return 1
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
|
||||
}
|
||||
return 0
|
||||
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
||||
try {
|
||||
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
|
||||
val config = WireguardFmt.parseWireguardConfFile(server)
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
commitProfiles(
|
||||
configs = listOf(ParsedProfile(config, server)),
|
||||
subid = subid,
|
||||
append = append,
|
||||
)
|
||||
return 1
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
|
||||
}
|
||||
return 0
|
||||
} else {
|
||||
@@ -319,145 +393,190 @@ object AngConfigManager {
|
||||
|
||||
/**
|
||||
* Parses the configuration from a QR code or string.
|
||||
* Only parses and returns ProfileItem, does not save.
|
||||
*
|
||||
* @param str The configuration string.
|
||||
* @param subid The subscription ID.
|
||||
* @param subItem The subscription item.
|
||||
* @param removedSelectedServer The removed selected server.
|
||||
* @return The result code.
|
||||
* @return The parsed ProfileItem or null if parsing fails or filtered out.
|
||||
*/
|
||||
private fun parseConfig(
|
||||
str: String?,
|
||||
subid: String,
|
||||
subItem: SubscriptionItem?,
|
||||
removedSelectedServer: ProfileItem?
|
||||
): Int {
|
||||
subItem: SubscriptionItem?
|
||||
): ProfileItem? {
|
||||
try {
|
||||
if (str == null || TextUtils.isEmpty(str)) {
|
||||
return R.string.toast_none_data
|
||||
return null
|
||||
}
|
||||
|
||||
val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
|
||||
VmessFmt.parse(str)
|
||||
} else if (str.startsWith(EConfigType.SHADOWSOCKS.protocolScheme)) {
|
||||
ShadowsocksFmt.parse(str)
|
||||
} else if (str.startsWith(EConfigType.SOCKS.protocolScheme)) {
|
||||
SocksFmt.parse(str)
|
||||
} else if (str.startsWith(EConfigType.TROJAN.protocolScheme)) {
|
||||
TrojanFmt.parse(str)
|
||||
} else if (str.startsWith(EConfigType.VLESS.protocolScheme)) {
|
||||
VlessFmt.parse(str)
|
||||
} else if (str.startsWith(EConfigType.WIREGUARD.protocolScheme)) {
|
||||
WireguardFmt.parse(str)
|
||||
} else if (str.startsWith(EConfigType.HYSTERIA2.protocolScheme) || str.startsWith(HY2)) {
|
||||
Hysteria2Fmt.parse(str)
|
||||
} else {
|
||||
null
|
||||
val config = configFmtParsers.firstNotNullOfOrNull { (scheme, parser) ->
|
||||
if (str.startsWith(scheme)) parser(str) else null
|
||||
}
|
||||
|
||||
if (config == null) {
|
||||
return R.string.toast_incorrect_protocol
|
||||
return null
|
||||
}
|
||||
//filter
|
||||
if (subItem?.filter != null && subItem.filter?.isNotEmpty() == true && config.remarks.isNotEmpty()) {
|
||||
val matched = Regex(pattern = subItem.filter ?: "")
|
||||
|
||||
// Apply filter
|
||||
if (subItem?.filter.isNotNullEmpty() && config.remarks.isNotNullEmpty()) {
|
||||
val matched = Regex(pattern = subItem?.filter.orEmpty())
|
||||
.containsMatchIn(input = config.remarks)
|
||||
if (!matched) return -1
|
||||
if (!matched) return null
|
||||
}
|
||||
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
val guid = MmkvManager.encodeServerConfig("", config)
|
||||
if (removedSelectedServer != null &&
|
||||
config.server == removedSelectedServer.server && config.serverPort == removedSelectedServer.serverPort
|
||||
) {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
}
|
||||
|
||||
return config
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse config", e)
|
||||
return -1
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse config", e)
|
||||
return null
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the configuration via all subscriptions.
|
||||
*
|
||||
* @return The number of configurations updated.
|
||||
* @return Detailed result of the subscription update operation.
|
||||
*/
|
||||
fun updateConfigViaSubAll(): Int {
|
||||
var count = 0
|
||||
try {
|
||||
MmkvManager.decodeSubscriptions().forEach {
|
||||
count += updateConfigViaSub(it)
|
||||
fun updateConfigViaSubAll(): SubscriptionUpdateResult {
|
||||
return try {
|
||||
val subscriptions = MmkvManager.decodeSubscriptions()
|
||||
subscriptions.fold(SubscriptionUpdateResult()) { acc, subscription ->
|
||||
acc + updateConfigViaSub(subscription)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
|
||||
return 0
|
||||
LogUtil.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
|
||||
SubscriptionUpdateResult()
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the configuration via a subscription.
|
||||
*
|
||||
* @param it The subscription item.
|
||||
* @return The number of configurations updated.
|
||||
* @return Subscription update result.
|
||||
*/
|
||||
fun updateConfigViaSub(it: SubscriptionCache): Int {
|
||||
fun updateConfigViaSub(it: SubscriptionCache): SubscriptionUpdateResult {
|
||||
try {
|
||||
// Check if disabled
|
||||
if (!it.subscription.enabled) {
|
||||
return SubscriptionUpdateResult(skipCount = 1)
|
||||
}
|
||||
|
||||
// Validate subscription info
|
||||
if (TextUtils.isEmpty(it.guid)
|
||||
|| TextUtils.isEmpty(it.subscription.remarks)
|
||||
|| TextUtils.isEmpty(it.subscription.url)
|
||||
) {
|
||||
return 0
|
||||
}
|
||||
if (!it.subscription.enabled) {
|
||||
return 0
|
||||
return SubscriptionUpdateResult(skipCount = 1)
|
||||
}
|
||||
|
||||
val url = HttpUtil.toIdnUrl(it.subscription.url)
|
||||
if (!Utils.isValidUrl(url)) {
|
||||
return 0
|
||||
return SubscriptionUpdateResult(failureCount = 1)
|
||||
}
|
||||
if (!it.subscription.allowInsecureUrl) {
|
||||
if (!Utils.isValidSubUrl(url)) {
|
||||
return 0
|
||||
return SubscriptionUpdateResult(failureCount = 1)
|
||||
}
|
||||
}
|
||||
Log.i(AppConfig.TAG, url)
|
||||
LogUtil.i(AppConfig.TAG, url)
|
||||
val userAgent = it.subscription.userAgent
|
||||
val requestHeaders = it.subscription.requestHeaders
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
|
||||
var configText = try {
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort)
|
||||
HttpUtil.getUrlContentWithUserAgent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
userAgent = userAgent,
|
||||
requestHeaders = requestHeaders,
|
||||
timeout = 15000,
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
proxyPassword = proxyPassword
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
|
||||
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
|
||||
""
|
||||
}
|
||||
if (configText.isEmpty()) {
|
||||
configText = try {
|
||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent)
|
||||
HttpUtil.getUrlContentWithUserAgent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
userAgent = userAgent,
|
||||
requestHeaders = requestHeaders
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
|
||||
LogUtil.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
|
||||
""
|
||||
}
|
||||
}
|
||||
if (configText.isEmpty()) {
|
||||
return 0
|
||||
return SubscriptionUpdateResult(failureCount = 1)
|
||||
}
|
||||
|
||||
val count = parseConfigViaSub(configText, it.guid, false)
|
||||
if (count > 0) {
|
||||
it.subscription.lastUpdated = System.currentTimeMillis()
|
||||
MmkvManager.encodeSubscription(it.guid, it.subscription)
|
||||
Log.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
|
||||
LogUtil.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
|
||||
return SubscriptionUpdateResult(
|
||||
configCount = count,
|
||||
successCount = 1
|
||||
)
|
||||
} else {
|
||||
// Got response but no valid configs parsed
|
||||
return SubscriptionUpdateResult(failureCount = 1)
|
||||
}
|
||||
return count
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to update config via subscription", e)
|
||||
return 0
|
||||
LogUtil.e(AppConfig.TAG, "Failed to update config via subscription", e)
|
||||
return SubscriptionUpdateResult(failureCount = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes invalid server configurations for a subscription.
|
||||
*
|
||||
* @param subId The subscription ID.
|
||||
*/
|
||||
fun removeInvalidServer(subId: String) {
|
||||
val serverList = MmkvManager.decodeServerList(subId)
|
||||
val invalidServers = serverList.filter {
|
||||
val aff = MmkvManager.decodeServerAffiliationInfo(it)
|
||||
aff != null && aff.testDelayMillis < 0L
|
||||
}
|
||||
MmkvManager.removeServers(invalidServers, subId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts servers by test results for a subscription.
|
||||
*
|
||||
* @param subId The subscription ID.
|
||||
*/
|
||||
fun sortByTestResultsForSub(subId: String) {
|
||||
val serverList = MmkvManager.decodeServerList(subId)
|
||||
if (serverList.isEmpty()) return
|
||||
|
||||
val sorted = serverList
|
||||
.map { guid ->
|
||||
val delay =
|
||||
MmkvManager.decodeServerAffiliationInfo(guid)?.testDelayMillis ?: 0L
|
||||
guid to if (delay <= 0L) Long.MAX_VALUE else delay
|
||||
}
|
||||
.sortedBy { it.second }
|
||||
.map { it.first }
|
||||
.toMutableList()
|
||||
MmkvManager.encodeServerList(sorted, subId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the configuration via a subscription.
|
||||
*
|
||||
@@ -472,7 +591,7 @@ object AngConfigManager {
|
||||
count = parseBatchConfig(server, subid, append)
|
||||
}
|
||||
if (count <= 0) {
|
||||
count = parseCustomConfigServer(server, subid)
|
||||
count = parseCustomConfigServer(server, subid, append)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.app.LocaleManager
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.app.LocaleManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.enums.Language
|
||||
|
||||
/**
|
||||
* Keeps the legacy in-app language preference synchronized with Android's per-app locale APIs.
|
||||
*/
|
||||
object AppLocaleManager {
|
||||
|
||||
/**
|
||||
* Migrates the existing MMKV language preference and restores it before the first activity.
|
||||
*/
|
||||
fun initialize(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Api33.prepareMigration(context)
|
||||
} else {
|
||||
initializeCompat(context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes the one-time handoff after AppCompat has attached the activity context.
|
||||
*/
|
||||
fun onActivityCreated(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Api33.completeMigration(context)
|
||||
} else {
|
||||
syncLegacyPreference(AppCompatDelegate.getApplicationLocales().get(0)?.toLanguageTag())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a language selected in the app and lets AppCompat recreate the current activity.
|
||||
*/
|
||||
fun setApplicationLanguage(languageCode: String) {
|
||||
val language = Language.fromCode(languageCode)
|
||||
persistLegacyPreference(language)
|
||||
AppCompatDelegate.setApplicationLocales(language.toLocaleList())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a context suitable for resource access outside AppCompatActivity.
|
||||
*/
|
||||
fun localizedContext(context: Context): Context {
|
||||
val localizedContext = ContextCompat.getContextForLanguage(context)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU || localizedContext !== context) {
|
||||
return localizedContext
|
||||
}
|
||||
|
||||
// During the one-time upgrade from the old picker, AppCompat has not written its locale
|
||||
// record yet. Use the still-current MMKV value for non-activity contexts on that launch.
|
||||
val language = storedLanguage()
|
||||
language.locale ?: return context
|
||||
val configuration = Configuration(context.resources.configuration)
|
||||
ConfigurationCompat.setLocales(configuration, language.toLocaleList())
|
||||
return context.createConfigurationContext(configuration)
|
||||
}
|
||||
|
||||
private fun initializeCompat(context: Context) {
|
||||
val storedLocales = LocaleManagerCompat.getApplicationLocales(context)
|
||||
if (storedLocales.isEmpty) {
|
||||
val legacyLanguage = storedLanguage()
|
||||
if (legacyLanguage != Language.AUTO) {
|
||||
AppCompatDelegate.setApplicationLocales(legacyLanguage.toLocaleList())
|
||||
}
|
||||
} else {
|
||||
syncLegacyPreference(storedLocales.get(0)?.toLanguageTag())
|
||||
}
|
||||
}
|
||||
|
||||
private fun storedLanguage(): Language = Language.fromCode(
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE) ?: Language.AUTO.code
|
||||
)
|
||||
|
||||
private fun syncLegacyPreference(languageTag: String?) {
|
||||
persistLegacyPreference(Language.fromLanguageTag(languageTag))
|
||||
}
|
||||
|
||||
private fun persistLegacyPreference(language: Language) {
|
||||
val current = MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE)
|
||||
if (current != language.code) {
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_LANGUAGE, language.code)
|
||||
SettingsChangeManager.notifySettingChanged(AppConfig.PREF_LANGUAGE)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
private object Api33 {
|
||||
fun prepareMigration(context: Context) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_APP_LOCALE_MIGRATED, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
val localeManager = context.getSystemService(LocaleManager::class.java)
|
||||
val applicationLocales = localeManager.applicationLocales
|
||||
if (!applicationLocales.isEmpty) {
|
||||
syncLegacyPreference(applicationLocales.get(0)?.toLanguageTag())
|
||||
return
|
||||
}
|
||||
|
||||
val legacyLanguage = storedLanguage()
|
||||
if (legacyLanguage != Language.AUTO) {
|
||||
// Make application and service contexts correct even before the first activity.
|
||||
localeManager.applicationLocales = android.os.LocaleList(
|
||||
requireNotNull(legacyLanguage.locale)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun completeMigration(context: Context) {
|
||||
val localeManager = context.getSystemService(LocaleManager::class.java)
|
||||
if (!MmkvManager.decodeSettingsBool(AppConfig.PREF_APP_LOCALE_MIGRATED, false)) {
|
||||
val frameworkLanguage = Language.fromLanguageTag(
|
||||
localeManager.applicationLocales.get(0)?.toLanguageTag()
|
||||
)
|
||||
val language = frameworkLanguage.takeUnless { it == Language.AUTO }
|
||||
?: storedLanguage()
|
||||
|
||||
// Android's migration guidance requires this one-time AppCompat handoff after
|
||||
// Activity.onCreate(), including on Android 13 and newer.
|
||||
AppCompatDelegate.setApplicationLocales(language.toLocaleList())
|
||||
persistLegacyPreference(language)
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_APP_LOCALE_MIGRATED, true)
|
||||
return
|
||||
}
|
||||
|
||||
syncLegacyPreference(localeManager.applicationLocales.get(0)?.toLanguageTag())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.CertSha256Request
|
||||
import com.v2ray.ang.dto.CertSha256Result
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import libv2ray.Libv2ray
|
||||
|
||||
object CertificateFingerprintManager {
|
||||
private const val TIMEOUT_MS = 5000L
|
||||
|
||||
fun fetchForManualFill(profile: ProfileItem): String? {
|
||||
val request = buildRequest(profile) ?: return null
|
||||
val result = if (profile.configType == EConfigType.HYSTERIA2) {
|
||||
fetch("quic", request) { Libv2ray.fetchQuicCertSha256(it) }
|
||||
} else {
|
||||
fetch("tls", request) { Libv2ray.fetchTlsCertSha256(it) }
|
||||
}
|
||||
|
||||
return result
|
||||
?.takeIf { it.error.isBlank() }
|
||||
?.sha256
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun buildRequest(profile: ProfileItem): CertSha256Request? {
|
||||
if (!isFetchable(profile)) return null
|
||||
|
||||
val server = profile.server?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
val port = profile.serverPort?.toIntOrNull()?.takeIf { it > 0 } ?: AppConfig.DEFAULT_PORT
|
||||
|
||||
return CertSha256Request(
|
||||
address = resolveDialAddress(server),
|
||||
port = port,
|
||||
serverName = inferServerName(profile),
|
||||
timeoutMs = TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
private fun isFetchable(profile: ProfileItem): Boolean {
|
||||
return profile.configType == EConfigType.HYSTERIA2 || profile.security == AppConfig.TLS
|
||||
}
|
||||
|
||||
private fun fetch(
|
||||
type: String,
|
||||
request: CertSha256Request,
|
||||
fetcher: (String) -> String,
|
||||
): CertSha256Result? {
|
||||
return try {
|
||||
JsonUtil.fromJsonSafe(fetcher(JsonUtil.toJson(request)), CertSha256Result::class.java)
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
LogUtil.e(AppConfig.TAG, "Fetch $type cert SHA-256 API missing in libv2ray", e)
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Fetch $type cert SHA-256 failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveDialAddress(server: String): String {
|
||||
if (Utils.isPureIpAddress(server) || !Utils.isDomainName(server)) return server
|
||||
|
||||
val preferIpv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6, false)
|
||||
return HttpUtil.resolveHostToIP(server, preferIpv6)
|
||||
?.firstOrNull()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: server
|
||||
}
|
||||
|
||||
private fun inferServerName(profile: ProfileItem): String? {
|
||||
val sni = profile.sni?.takeIf { it.isNotBlank() }
|
||||
return sni?.takeUnless { Utils.isPureIpAddress(it) }
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,43 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.tencent.mmkv.MMKVHandler
|
||||
import com.tencent.mmkv.MMKVLogLevel
|
||||
import com.tencent.mmkv.MMKVRecoverStrategic
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
|
||||
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
|
||||
import com.v2ray.ang.dto.AssetUrlCache
|
||||
import com.v2ray.ang.dto.AssetUrlItem
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.dto.RulesetItem
|
||||
import com.v2ray.ang.dto.ServerAffiliationInfo
|
||||
import com.v2ray.ang.dto.SubscriptionCache
|
||||
import com.v2ray.ang.dto.SubscriptionItem
|
||||
import com.v2ray.ang.dto.WebDavConfig
|
||||
import com.v2ray.ang.AppConfig.TAG
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.dto.entities.AssetUrlCache
|
||||
import com.v2ray.ang.dto.entities.AssetUrlItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.extension.isGroupType
|
||||
import com.v2ray.ang.dto.entities.RulesetItem
|
||||
import com.v2ray.ang.dto.entities.ServerAffiliationInfo
|
||||
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.dto.entities.WebDavConfig
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
|
||||
internal class ProfileStorageException(message: String) : IllegalStateException(message)
|
||||
|
||||
object MmkvManager {
|
||||
|
||||
//region private
|
||||
|
||||
//private const val ID_PROFILE_CONFIG = "PROFILE_CONFIG"
|
||||
private const val ID_MAIN = "MAIN"
|
||||
private const val ID_PROFILE_FULL_CONFIG = "PROFILE_FULL_CONFIG"
|
||||
private const val ID_SERVER_RAW = "SERVER_RAW"
|
||||
@@ -28,10 +47,28 @@ object MmkvManager {
|
||||
private const val ID_SETTING = "SETTING"
|
||||
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
|
||||
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
|
||||
private const val KEY_SUB_SERVER_PREFIX = "SUB_SERVERS_"
|
||||
private const val KEY_SUB_IDS = "SUB_IDS"
|
||||
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
|
||||
|
||||
//private val profileStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val recoveryHandler = object : MMKVHandler {
|
||||
override fun onMMKVCRCCheckFail(mmapID: String) =
|
||||
recoverFromStorageError(mmapID, "CRC check")
|
||||
|
||||
override fun onMMKVFileLengthError(mmapID: String) =
|
||||
recoverFromStorageError(mmapID, "file length check")
|
||||
|
||||
override fun wantLogRedirecting(): Boolean = false
|
||||
|
||||
override fun mmkvLog(
|
||||
level: MMKVLogLevel,
|
||||
file: String,
|
||||
line: Int,
|
||||
function: String,
|
||||
message: String
|
||||
) = Unit
|
||||
}
|
||||
|
||||
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
|
||||
@@ -40,10 +77,96 @@ object MmkvManager {
|
||||
private val assetStorage by lazy { MMKV.mmkvWithID(ID_ASSET, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val settingsStorage by lazy { MMKV.mmkvWithID(ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
|
||||
|
||||
private inline fun <T> withProfileIndexLock(block: () -> T): T {
|
||||
return synchronized(mainStorage) {
|
||||
mainStorage.lock()
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
mainStorage.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeProfilePayloads(guids: Collection<String>) {
|
||||
if (guids.isEmpty()) return
|
||||
val keys = guids.toTypedArray()
|
||||
profileFullStorage.removeValuesForKeys(keys)
|
||||
serverAffStorage.removeValuesForKeys(keys)
|
||||
serverRawStorage.removeValuesForKeys(keys)
|
||||
}
|
||||
|
||||
private fun requireStorageWrite(success: Boolean, message: String) {
|
||||
if (!success) throw ProfileStorageException(message)
|
||||
}
|
||||
|
||||
private fun persistServerList(serverList: List<String>, subscriptionId: String): Boolean {
|
||||
return mainStorage.encode(serverListKey(subscriptionId), JsonUtil.toJson(serverList))
|
||||
}
|
||||
|
||||
private fun serverListKey(subscriptionId: String): String {
|
||||
return "$KEY_SUB_SERVER_PREFIX${getSubscriptionId(subscriptionId)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every server referenced outside the target group, or null if the raw indexes
|
||||
* cannot provide a complete view.
|
||||
*/
|
||||
private fun decodeServersReferencedByOtherGroups(subscriptionId: String): Set<String>? {
|
||||
val targetKey = serverListKey(subscriptionId)
|
||||
val keys = mainStorage.allKeys() ?: return null
|
||||
if (targetKey !in keys) return null
|
||||
|
||||
val referencedServers = mutableSetOf<String>()
|
||||
for (key in keys) {
|
||||
if (!key.startsWith(KEY_SUB_SERVER_PREFIX) || key == targetKey) continue
|
||||
|
||||
val json = mainStorage.decodeString(key)
|
||||
if (json.isNullOrBlank()) return null
|
||||
val serverIds = JsonUtil.fromJsonSafe(json, Array<String>::class.java) ?: return null
|
||||
referencedServers.addAll(serverIds)
|
||||
}
|
||||
return referencedServers
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
/**
|
||||
* Initializes MMKV with best-effort recovery so a damaged store is not silently discarded.
|
||||
*/
|
||||
fun initialize(context: Context) {
|
||||
val logLevel = if (BuildConfig.DEBUG) {
|
||||
MMKVLogLevel.LevelDebug
|
||||
} else {
|
||||
MMKVLogLevel.LevelInfo
|
||||
}
|
||||
MMKV.initialize(
|
||||
context,
|
||||
context.filesDir.resolve("mmkv").absolutePath,
|
||||
null,
|
||||
logLevel,
|
||||
recoveryHandler
|
||||
)
|
||||
}
|
||||
|
||||
private fun recoverFromStorageError(mmapID: String, error: String): MMKVRecoverStrategic {
|
||||
Log.e(TAG, "MMKV $error failed for $mmapID; attempting data recovery")
|
||||
return MMKVRecoverStrategic.OnErrorRecover
|
||||
}
|
||||
|
||||
//region Server
|
||||
|
||||
/**
|
||||
* Reads the legacy server list from KEY_ANG_CONFIGS for migration.
|
||||
* This method is for migration purposes only.
|
||||
*
|
||||
* @return The JSON string of legacy server list, or null if not exists.
|
||||
*/
|
||||
fun readLegacyServerList(): String? {
|
||||
return mainStorage.decodeString(KEY_ANG_CONFIGS)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the selected server GUID.
|
||||
*
|
||||
@@ -59,32 +182,66 @@ object MmkvManager {
|
||||
* @param guid The server GUID.
|
||||
*/
|
||||
fun setSelectServer(guid: String) {
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, guid)
|
||||
withProfileIndexLock {
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, guid)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the server list.
|
||||
* Encodes the server list for a given subscription.
|
||||
* Saves to the subscription's serverList (including default subscription for ungrouped servers).
|
||||
*
|
||||
* @param serverList The list of server GUIDs.
|
||||
* @param subscriptionId The subscription ID.
|
||||
*/
|
||||
fun encodeServerList(serverList: MutableList<String>) {
|
||||
mainStorage.encode(KEY_ANG_CONFIGS, JsonUtil.toJson(serverList))
|
||||
fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
|
||||
withProfileIndexLock {
|
||||
persistServerList(serverList, subscriptionId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Decodes the server list.
|
||||
* Decodes the server list for a given subscription.
|
||||
* If subscriptionId is empty, returns ungrouped servers.
|
||||
* Otherwise, returns servers from the specified subscription's serverList.
|
||||
*
|
||||
* @param subscriptionId The subscription ID.
|
||||
* @return The list of server GUIDs.
|
||||
*/
|
||||
fun decodeServerList(): MutableList<String> {
|
||||
val json = mainStorage.decodeString(KEY_ANG_CONFIGS)
|
||||
fun decodeServerList(subscriptionId: String): MutableList<String> {
|
||||
val json = mainStorage.decodeString(serverListKey(subscriptionId))
|
||||
return if (json.isNullOrBlank()) {
|
||||
mutableListOf()
|
||||
} else {
|
||||
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
|
||||
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes all server list (merged from all subscriptions including default subscription).
|
||||
* Use this when you need the complete server list.
|
||||
*
|
||||
* @return The list of all server GUIDs.
|
||||
*/
|
||||
fun decodeAllServerList(): MutableList<String> {
|
||||
val allServers = mutableListOf<String>()
|
||||
val subsList = decodeSubsList()
|
||||
|
||||
// If DEFAULT_SUBSCRIPTION_ID is not in the subscriptions list, add its servers
|
||||
if (!subsList.contains(DEFAULT_SUBSCRIPTION_ID)) {
|
||||
allServers.addAll(decodeServerList(DEFAULT_SUBSCRIPTION_ID))
|
||||
}
|
||||
|
||||
// Add servers from all subscriptions
|
||||
subsList.forEach { guid ->
|
||||
allServers.addAll(decodeServerList(guid))
|
||||
}
|
||||
|
||||
return allServers
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Decodes the server configuration.
|
||||
*
|
||||
@@ -99,19 +256,9 @@ object MmkvManager {
|
||||
if (json.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
return JsonUtil.fromJson(json, ProfileItem::class.java)
|
||||
return JsonUtil.fromJsonSafe(json, ProfileItem::class.java)
|
||||
}
|
||||
|
||||
// fun decodeProfileConfig(guid: String): ProfileLiteItem? {
|
||||
// if (guid.isBlank()) {
|
||||
// return null
|
||||
// }
|
||||
// val json = profileStorage.decodeString(guid)
|
||||
// if (json.isNullOrBlank()) {
|
||||
// return null
|
||||
// }
|
||||
// return JsonUtil.fromJson(json, ProfileLiteItem::class.java)
|
||||
// }
|
||||
|
||||
/**
|
||||
* Encodes the server configuration.
|
||||
@@ -122,26 +269,121 @@ object MmkvManager {
|
||||
*/
|
||||
fun encodeServerConfig(guid: String, config: ProfileItem): String {
|
||||
val key = guid.ifBlank { Utils.getUuid() }
|
||||
profileFullStorage.encode(key, JsonUtil.toJson(config))
|
||||
val serverList = decodeServerList()
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
encodeServerList(serverList)
|
||||
if (getSelectServer().isNullOrBlank()) {
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, key)
|
||||
withProfileIndexLock {
|
||||
requireStorageWrite(
|
||||
profileFullStorage.encode(key, JsonUtil.toJson(config)),
|
||||
"Failed to save profile payload",
|
||||
)
|
||||
|
||||
// Use default subscription for servers without subscription
|
||||
val subId = getSubscriptionId(config.subscriptionId)
|
||||
val serverList = decodeServerList(subId)
|
||||
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
requireStorageWrite(
|
||||
persistServerList(serverList, subId),
|
||||
"Failed to publish profile index",
|
||||
)
|
||||
if (getSelectServer().isNullOrBlank()) {
|
||||
requireStorageWrite(
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, key),
|
||||
"Failed to update selected profile",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// val profile = ProfileLiteItem(
|
||||
// configType = config.configType,
|
||||
// subscriptionId = config.subscriptionId,
|
||||
// remarks = config.remarks,
|
||||
// server = config.getProxyOutbound()?.getServerAddress(),
|
||||
// serverPort = config.getProxyOutbound()?.getServerPort(),
|
||||
// )
|
||||
// profileStorage.encode(key, JsonUtil.toJson(profile))
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a profile batch before publishing its group index and removing replaced payloads.
|
||||
*
|
||||
* @param profiles Generated GUIDs and parsed profiles, in insertion order.
|
||||
* @param rawConfigs Optional raw configuration payloads keyed by profile GUID.
|
||||
* @param subscriptionId The destination subscription ID.
|
||||
* @param append Whether to append to the existing group index.
|
||||
*/
|
||||
internal fun saveServerProfiles(
|
||||
profiles: Map<String, ProfileItem>,
|
||||
rawConfigs: Map<String, String>,
|
||||
subscriptionId: String,
|
||||
append: Boolean,
|
||||
) {
|
||||
if (profiles.isEmpty()) return
|
||||
|
||||
withProfileIndexLock {
|
||||
val replacedServers = if (append) {
|
||||
emptyList()
|
||||
} else {
|
||||
decodeServerList(subscriptionId).toList()
|
||||
}
|
||||
val previousSelection = getSelectServer()
|
||||
val selectedProfile = if (!append &&
|
||||
previousSelection != null &&
|
||||
previousSelection in replacedServers
|
||||
) {
|
||||
decodeServerConfig(previousSelection)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val replacementSelection = ProfileReplacement.findSelectedReplacement(
|
||||
profiles = profiles,
|
||||
currentSelection = previousSelection,
|
||||
selectedProfile = selectedProfile,
|
||||
)
|
||||
|
||||
profiles.forEach { (guid, profile) ->
|
||||
requireStorageWrite(
|
||||
profileFullStorage.encode(guid, JsonUtil.toJson(profile)),
|
||||
"Failed to save profile payload",
|
||||
)
|
||||
rawConfigs[guid]?.let { raw ->
|
||||
requireStorageWrite(
|
||||
serverRawStorage.encode(guid, raw),
|
||||
"Failed to save raw profile payload",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val serverList = if (append) {
|
||||
decodeServerList(subscriptionId)
|
||||
} else {
|
||||
replacedServers.filter { guid ->
|
||||
decodeServerConfig(guid)?.configType?.isGroupType() == true
|
||||
}.toMutableList()
|
||||
}
|
||||
val indexedServers = serverList.toHashSet()
|
||||
profiles.keys.forEach { guid ->
|
||||
if (indexedServers.add(guid)) {
|
||||
serverList.add(0, guid)
|
||||
}
|
||||
}
|
||||
requireStorageWrite(
|
||||
persistServerList(serverList, subscriptionId),
|
||||
"Failed to publish profile index",
|
||||
)
|
||||
replacementSelection?.let { guid ->
|
||||
requireStorageWrite(
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, guid),
|
||||
"Failed to update selected profile",
|
||||
)
|
||||
}
|
||||
if (replacedServers.isEmpty()) return@withProfileIndexLock
|
||||
|
||||
val protectedServer = replacementSelection ?: previousSelection
|
||||
val referencedByOtherGroups = decodeServersReferencedByOtherGroups(subscriptionId)
|
||||
val removablePayloads = ProfileReplacement.findRemovablePayloads(
|
||||
replacedServers = replacedServers,
|
||||
replacementServers = serverList.toSet(),
|
||||
protectedServer = protectedServer,
|
||||
serversReferencedByOtherGroups = referencedByOtherGroups,
|
||||
)
|
||||
removeProfilePayloads(removablePayloads)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the server configuration.
|
||||
*
|
||||
@@ -151,32 +393,68 @@ object MmkvManager {
|
||||
if (guid.isBlank()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get config to determine which subscription to update
|
||||
val config = decodeServerConfig(guid)
|
||||
val subId = getSubscriptionId(config?.subscriptionId)
|
||||
|
||||
// Remove from appropriate server list
|
||||
val serverList = decodeServerList(subId)
|
||||
serverList.remove(guid)
|
||||
encodeServerList(serverList, subId)
|
||||
|
||||
// Clean up storage
|
||||
if (getSelectServer() == guid) {
|
||||
mainStorage.remove(KEY_SELECTED_SERVER)
|
||||
}
|
||||
val serverList = decodeServerList()
|
||||
serverList.remove(guid)
|
||||
encodeServerList(serverList)
|
||||
profileFullStorage.remove(guid)
|
||||
//profileStorage.remove(guid)
|
||||
serverAffStorage.remove(guid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the server configurations via subscription ID.
|
||||
*
|
||||
* @param subid The subscription ID.
|
||||
* @param subscriptionId The subscription ID.
|
||||
*/
|
||||
fun removeServerViaSubid(subid: String) {
|
||||
if (subid.isBlank()) {
|
||||
return
|
||||
}
|
||||
profileFullStorage.allKeys()?.forEach { key ->
|
||||
decodeServerConfig(key)?.let { config ->
|
||||
if (config.subscriptionId == subid) {
|
||||
removeServer(key)
|
||||
}
|
||||
fun removeServerViaSubid(subscriptionId: String?) {
|
||||
val subId = getSubscriptionId(subscriptionId)
|
||||
val serverList = decodeServerList(subId)
|
||||
|
||||
// Remove all servers in the list
|
||||
serverList.forEach { guid ->
|
||||
if (getSelectServer() == guid) {
|
||||
mainStorage.remove(KEY_SELECTED_SERVER)
|
||||
}
|
||||
profileFullStorage.remove(guid)
|
||||
serverAffStorage.remove(guid)
|
||||
}
|
||||
|
||||
serverList.clear()
|
||||
encodeServerList(serverList, subId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes multiple server configurations from a subscription.
|
||||
*
|
||||
* @param guids The list of server GUIDs.
|
||||
* @param subscriptionId The subscription ID.
|
||||
*/
|
||||
fun removeServers(guids: List<String>, subscriptionId: String) {
|
||||
if (guids.isEmpty()) return
|
||||
val subId = getSubscriptionId(subscriptionId)
|
||||
val serverList = decodeServerList(subId)
|
||||
if (serverList.removeAll(guids)) {
|
||||
encodeServerList(serverList, subId)
|
||||
}
|
||||
|
||||
val selectedServer = getSelectServer()
|
||||
guids.forEach { guid ->
|
||||
if (selectedServer == guid) {
|
||||
mainStorage.remove(KEY_SELECTED_SERVER)
|
||||
}
|
||||
profileFullStorage.remove(guid)
|
||||
serverAffStorage.remove(guid)
|
||||
serverRawStorage.remove(guid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +472,7 @@ object MmkvManager {
|
||||
if (json.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
return JsonUtil.fromJson(json, ServerAffiliationInfo::class.java)
|
||||
return JsonUtil.fromJsonSafe(json, ServerAffiliationInfo::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,10 +511,13 @@ object MmkvManager {
|
||||
*/
|
||||
fun removeAllServer(): Int {
|
||||
val count = profileFullStorage.allKeys()?.count() ?: 0
|
||||
mainStorage.clearAll()
|
||||
profileFullStorage.clearAll()
|
||||
//profileStorage.clearAll()
|
||||
serverAffStorage.clearAll()
|
||||
serverRawStorage.clearAll()
|
||||
|
||||
decodeSubscriptions().forEach { sub ->
|
||||
encodeServerList(mutableListOf(), sub.guid)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -288,10 +569,64 @@ object MmkvManager {
|
||||
return serverRawStorage.decodeString(guid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes profile payloads that are provably absent from their raw SUB_SERVERS_* index.
|
||||
*
|
||||
* SUB_IDS and SUB are intentionally ignored: either store can be missing after MMKV
|
||||
* recovery while the group indexes still identify live profiles. If any group index or
|
||||
* profile payload needed for a decision is unreadable, that data is preserved.
|
||||
*
|
||||
* @return The number of profile payloads removed, or null if cleanup could not run safely.
|
||||
*/
|
||||
internal fun removeOrphanedServerProfiles(): Int? = synchronized(mainStorage) {
|
||||
mainStorage.lock()
|
||||
try {
|
||||
val indexedServersBySubscription = mainStorage.allKeys().orEmpty()
|
||||
.asSequence()
|
||||
.filter { key -> key.startsWith(KEY_SUB_SERVER_PREFIX) }
|
||||
.associate { key ->
|
||||
val subscriptionId = key.removePrefix(KEY_SUB_SERVER_PREFIX)
|
||||
val json = mainStorage.decodeString(key)
|
||||
val serverIds = if (json.isNullOrBlank()) {
|
||||
null
|
||||
} else {
|
||||
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toSet()
|
||||
}
|
||||
subscriptionId to serverIds
|
||||
}
|
||||
|
||||
val profiles = profileFullStorage.allKeys().orEmpty().map { guid ->
|
||||
StoredProfileReference(
|
||||
guid = guid,
|
||||
subscriptionId = decodeServerConfig(guid)?.subscriptionId,
|
||||
)
|
||||
}
|
||||
val orphans = OrphanProfileCleaner.findOrphans(
|
||||
profiles = profiles,
|
||||
indexedServersBySubscription = indexedServersBySubscription,
|
||||
selectedServer = getSelectServer(),
|
||||
) ?: return@synchronized null
|
||||
|
||||
if (orphans.isNotEmpty()) {
|
||||
val keys = orphans.toTypedArray()
|
||||
profileFullStorage.removeValuesForKeys(keys)
|
||||
serverAffStorage.removeValuesForKeys(keys)
|
||||
serverRawStorage.removeValuesForKeys(keys)
|
||||
}
|
||||
orphans.size
|
||||
} finally {
|
||||
mainStorage.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Subscriptions
|
||||
|
||||
private fun getSubscriptionId(subscriptionId: String?): String {
|
||||
return subscriptionId?.ifEmpty { DEFAULT_SUBSCRIPTION_ID } ?: DEFAULT_SUBSCRIPTION_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the subscription list.
|
||||
*/
|
||||
@@ -318,7 +653,7 @@ object MmkvManager {
|
||||
decodeSubsList().forEach { key ->
|
||||
val json = subStorage.decodeString(key)
|
||||
if (!json.isNullOrBlank()) {
|
||||
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java)?: SubscriptionItem()
|
||||
val item = JsonUtil.fromJsonSafe(json, SubscriptionItem::class.java) ?: SubscriptionItem()
|
||||
subscriptions.add(SubscriptionCache(key, item))
|
||||
}
|
||||
}
|
||||
@@ -364,7 +699,7 @@ object MmkvManager {
|
||||
*/
|
||||
fun decodeSubscription(subscriptionId: String): SubscriptionItem? {
|
||||
val json = subStorage.decodeString(subscriptionId) ?: return null
|
||||
return JsonUtil.fromJson(json, SubscriptionItem::class.java)
|
||||
return JsonUtil.fromJsonSafe(json, SubscriptionItem::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -386,7 +721,8 @@ object MmkvManager {
|
||||
return if (json.isNullOrBlank()) {
|
||||
mutableListOf()
|
||||
} else {
|
||||
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList()?: mutableListOf()
|
||||
// Keep the first occurrence so a damaged index cannot produce duplicate Compose keys.
|
||||
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.distinct()?.toMutableList() ?: mutableListOf()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +740,7 @@ object MmkvManager {
|
||||
assetStorage.allKeys()?.forEach { key ->
|
||||
val json = assetStorage.decodeString(key)
|
||||
if (!json.isNullOrBlank()) {
|
||||
val item = JsonUtil.fromJson(json, AssetUrlItem::class.java)?: AssetUrlItem()
|
||||
val item = JsonUtil.fromJsonSafe(json, AssetUrlItem::class.java) ?: AssetUrlItem()
|
||||
assetUrlItems.add(AssetUrlCache(key, item))
|
||||
}
|
||||
}
|
||||
@@ -439,7 +775,7 @@ object MmkvManager {
|
||||
*/
|
||||
fun decodeAsset(assetid: String): AssetUrlItem? {
|
||||
val json = assetStorage.decodeString(assetid) ?: return null
|
||||
return JsonUtil.fromJson(json, AssetUrlItem::class.java)
|
||||
return JsonUtil.fromJsonSafe(json, AssetUrlItem::class.java)
|
||||
}
|
||||
|
||||
//endregion
|
||||
@@ -454,7 +790,7 @@ object MmkvManager {
|
||||
fun decodeRoutingRulesets(): MutableList<RulesetItem>? {
|
||||
val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
|
||||
if (ruleset.isNullOrEmpty()) return null
|
||||
return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java)?.toMutableList()?: mutableListOf()
|
||||
return JsonUtil.fromJsonSafe(ruleset, Array<RulesetItem>::class.java)?.toMutableList() ?: mutableListOf()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -623,6 +959,7 @@ object MmkvManager {
|
||||
return settingsStorage.decodeStringSet(key)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Encodes the start on boot setting.
|
||||
*
|
||||
@@ -657,7 +994,59 @@ object MmkvManager {
|
||||
*/
|
||||
fun decodeWebDavConfig(): WebDavConfig? {
|
||||
val json = mainStorage.decodeString(KEY_WEBDAV_CONFIG) ?: return null
|
||||
return JsonUtil.fromJson(json, WebDavConfig::class.java)
|
||||
return JsonUtil.fromJsonSafe(json, WebDavConfig::class.java)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Compose helpers for Settings
|
||||
|
||||
/**
|
||||
* MMKV-backed String state, auto-persists and notifies on change.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberMmkvString(
|
||||
key: String,
|
||||
default: String = ""
|
||||
): MutableState<String> {
|
||||
val state = remember(key) {
|
||||
mutableStateOf(decodeSettingsString(key, default) ?: default)
|
||||
}
|
||||
|
||||
LaunchedEffect(key) {
|
||||
snapshotFlow { state.value }
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { value ->
|
||||
encodeSettings(key, value)
|
||||
SettingsChangeManager.notifySettingChanged(key)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* MMKV-backed Boolean state, auto-persists and notifies on change.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberMmkvBool(
|
||||
key: String,
|
||||
default: Boolean = false
|
||||
): MutableState<Boolean> {
|
||||
val state = remember(key) {
|
||||
mutableStateOf(decodeSettingsBool(key, default))
|
||||
}
|
||||
|
||||
LaunchedEffect(key) {
|
||||
snapshotFlow { state.value }
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { value ->
|
||||
encodeSettings(key, value)
|
||||
SettingsChangeManager.notifySettingChanged(key)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
@@ -13,13 +12,16 @@ import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.extension.delay
|
||||
import com.v2ray.ang.extension.toSpeedString
|
||||
import com.v2ray.ang.ui.MainActivity
|
||||
import com.v2ray.ang.helper.NotificationHelper
|
||||
import com.v2ray.ang.ui.main.MainActivity
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.min
|
||||
@@ -30,6 +32,7 @@ object NotificationManager {
|
||||
private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1
|
||||
private const val NOTIFICATION_PENDING_INTENT_RESTART_V2RAY = 2
|
||||
private const val NOTIFICATION_ICON_THRESHOLD = 3000
|
||||
private const val QUERY_INTERVAL_MS = 3000L
|
||||
|
||||
private var lastQueryTime = 0L
|
||||
private var mBuilder: NotificationCompat.Builder? = null
|
||||
@@ -40,45 +43,16 @@ object NotificationManager {
|
||||
* Starts the speed notification.
|
||||
* @param currentConfig The current profile configuration.
|
||||
*/
|
||||
fun startSpeedNotification(currentConfig: ProfileItem?) {
|
||||
fun startSpeedNotification() {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
|
||||
if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return
|
||||
if (speedNotificationJob != null || CoreServiceManager.isRunning() == false) return
|
||||
|
||||
lastQueryTime = System.currentTimeMillis()
|
||||
var lastZeroSpeed = false
|
||||
val outboundTags = currentConfig?.getAllOutboundTags()
|
||||
outboundTags?.remove(AppConfig.TAG_DIRECT)
|
||||
|
||||
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
while (isActive) {
|
||||
val queryTime = System.currentTimeMillis()
|
||||
val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0
|
||||
var proxyTotal = 0L
|
||||
val text = StringBuilder()
|
||||
outboundTags?.forEach {
|
||||
val up = V2RayServiceManager.queryStats(it, AppConfig.UPLINK)
|
||||
val down = V2RayServiceManager.queryStats(it, AppConfig.DOWNLINK)
|
||||
if (up + down > 0) {
|
||||
appendSpeedString(text, it, up / sinceLastQueryInSeconds, down / sinceLastQueryInSeconds)
|
||||
proxyTotal += up + down
|
||||
}
|
||||
}
|
||||
val directUplink = V2RayServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.UPLINK)
|
||||
val directDownlink = V2RayServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.DOWNLINK)
|
||||
val zeroSpeed = proxyTotal == 0L && directUplink == 0L && directDownlink == 0L
|
||||
if (!zeroSpeed || !lastZeroSpeed) {
|
||||
if (proxyTotal == 0L) {
|
||||
appendSpeedString(text, outboundTags?.firstOrNull(), 0.0, 0.0)
|
||||
}
|
||||
appendSpeedString(
|
||||
text, AppConfig.TAG_DIRECT, directUplink / sinceLastQueryInSeconds,
|
||||
directDownlink / sinceLastQueryInSeconds
|
||||
)
|
||||
updateNotification(text.toString(), proxyTotal, directDownlink + directUplink)
|
||||
}
|
||||
lastZeroSpeed = zeroSpeed
|
||||
lastQueryTime = queryTime
|
||||
delay(3000)
|
||||
lastZeroSpeed = updateSpeedNotificationOnce(lastZeroSpeed)
|
||||
delay(QUERY_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +63,10 @@ object NotificationManager {
|
||||
*/
|
||||
fun showNotification(currentConfig: ProfileItem?) {
|
||||
val service = getService() ?: return
|
||||
|
||||
// Reset last query time to avoid querying stats too soon after showing the notification
|
||||
lastQueryTime = System.currentTimeMillis()
|
||||
|
||||
val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
|
||||
val startMainIntent = Intent(service, MainActivity::class.java)
|
||||
@@ -115,8 +93,8 @@ object NotificationManager {
|
||||
|
||||
mBuilder = NotificationCompat.Builder(service, channelId)
|
||||
.setSmallIcon(R.drawable.ic_stat_name)
|
||||
.setContentTitle(currentConfig?.remarks)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setContentTitle(currentConfig?.remarks ?: service.getString(R.string.app_name))
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setOngoing(true)
|
||||
.setShowWhen(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
@@ -127,7 +105,7 @@ object NotificationManager {
|
||||
stopV2RayPendingIntent
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.ic_delete_24dp,
|
||||
R.drawable.ic_restore_24dp,
|
||||
service.getString(R.string.title_service_restart),
|
||||
restartV2RayPendingIntent
|
||||
)
|
||||
@@ -137,6 +115,17 @@ object NotificationManager {
|
||||
service.startForeground(NOTIFICATION_ID, mBuilder?.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Fulfills or refreshes the foreground-service contract before a start command can
|
||||
* return early. A duplicate startForegroundService call still requires the service
|
||||
* to enter foreground state promptly, even when the core is already running.
|
||||
*/
|
||||
fun ensureForeground() {
|
||||
val service = getService() ?: return
|
||||
val notification = mBuilder?.build()
|
||||
if (notification == null) showNotification(null) else service.startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the notification.
|
||||
*/
|
||||
@@ -152,13 +141,12 @@ object NotificationManager {
|
||||
|
||||
/**
|
||||
* Stops the speed notification.
|
||||
* @param currentConfig The current profile configuration.
|
||||
*/
|
||||
fun stopSpeedNotification(currentConfig: ProfileItem?) {
|
||||
fun stopSpeedNotification() {
|
||||
speedNotificationJob?.let {
|
||||
it.cancel()
|
||||
speedNotificationJob = null
|
||||
updateNotification(currentConfig?.remarks, 0, 0)
|
||||
updateNotification("", 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,15 +157,17 @@ object NotificationManager {
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun createNotificationChannel(): String {
|
||||
val channelId = AppConfig.RAY_NG_CHANNEL_ID
|
||||
val channelName = AppConfig.RAY_NG_CHANNEL_NAME
|
||||
val chan = NotificationChannel(
|
||||
channelId,
|
||||
channelName, NotificationManager.IMPORTANCE_HIGH
|
||||
)
|
||||
chan.lightColor = Color.DKGRAY
|
||||
chan.importance = NotificationManager.IMPORTANCE_NONE
|
||||
chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
getNotificationManager()?.createNotificationChannel(chan)
|
||||
val service = getService() ?: return channelId
|
||||
NotificationHelper.ensureNotificationChannel(
|
||||
context = service,
|
||||
channelId = channelId,
|
||||
channelNameRes = R.string.notification_channel_service,
|
||||
// Foreground-service notifications must remain visible; LOW is silent but valid.
|
||||
importance = NotificationManager.IMPORTANCE_LOW,
|
||||
) {
|
||||
lightColor = Color.DKGRAY
|
||||
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
}
|
||||
return channelId
|
||||
}
|
||||
|
||||
@@ -231,11 +221,75 @@ object NotificationManager {
|
||||
text.append("• ${up.toLong().toSpeedString()}↑ ${down.toLong().toSpeedString()}↓\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the speed notification once.
|
||||
* Queries traffic stats, separates proxy and direct, and updates the notification.
|
||||
* @param lastZeroSpeed The previous zero speed state.
|
||||
* @return The current zero speed state.
|
||||
*/
|
||||
private fun updateSpeedNotificationOnce(lastZeroSpeed: Boolean): Boolean {
|
||||
val queryTime = System.currentTimeMillis()
|
||||
val sinceLastQueryIn = (queryTime - lastQueryTime)
|
||||
|
||||
// If the query interval is too short, skip this round to avoid excessive CPU usage
|
||||
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
|
||||
LogUtil.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
|
||||
lastQueryTime = queryTime
|
||||
return lastZeroSpeed
|
||||
}
|
||||
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
|
||||
|
||||
var proxyUplink = 0L
|
||||
var proxyDownlink = 0L
|
||||
var directUplink = 0L
|
||||
var directDownlink = 0L
|
||||
|
||||
CoreServiceManager.queryAllOutboundTrafficStats().forEach { stat ->
|
||||
when {
|
||||
stat.tag == AppConfig.TAG_DIRECT -> {
|
||||
when (stat.direction) {
|
||||
AppConfig.UPLINK -> directUplink += stat.value
|
||||
AppConfig.DOWNLINK -> directDownlink += stat.value
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulate stats for all proxy outbounds (including custom subscription tags)
|
||||
stat.tag != AppConfig.TAG_BLOCKED -> {
|
||||
when (stat.direction) {
|
||||
AppConfig.UPLINK -> proxyUplink += stat.value
|
||||
AppConfig.DOWNLINK -> proxyDownlink += stat.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val proxyTotal = proxyUplink + proxyDownlink
|
||||
val directTotal = directUplink + directDownlink
|
||||
val zeroSpeed = proxyTotal + directTotal == 0L
|
||||
if (!zeroSpeed || !lastZeroSpeed) {
|
||||
val text = StringBuilder()
|
||||
appendSpeedString(
|
||||
text, AppConfig.TAG_PROXY,
|
||||
proxyUplink / sinceLastQueryInSeconds,
|
||||
proxyDownlink / sinceLastQueryInSeconds
|
||||
)
|
||||
|
||||
appendSpeedString(
|
||||
text, AppConfig.TAG_DIRECT,
|
||||
directUplink / sinceLastQueryInSeconds,
|
||||
directDownlink / sinceLastQueryInSeconds
|
||||
)
|
||||
updateNotification(text.toString(), proxyTotal, directTotal)
|
||||
}
|
||||
lastQueryTime = queryTime
|
||||
return zeroSpeed
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the service instance.
|
||||
* @return The service instance.
|
||||
*/
|
||||
private fun getService(): Service? {
|
||||
return V2RayServiceManager.serviceControl?.get()?.getService()
|
||||
return CoreServiceManager.serviceControl?.get()?.getService()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
|
||||
internal data class StoredProfileReference(
|
||||
val guid: String,
|
||||
val subscriptionId: String?,
|
||||
)
|
||||
|
||||
internal object OrphanProfileCleaner {
|
||||
|
||||
/**
|
||||
* Finds profile payloads that are provably unreachable from the raw group indexes.
|
||||
*
|
||||
* A null subscription ID means that the profile payload could not be decoded and is
|
||||
* preserved. A null server set means that a group index could not be decoded, in which
|
||||
* case the entire classification returns null and no cleanup should run.
|
||||
* Subscription metadata is deliberately not an input because SUB and SUB_IDS can be
|
||||
* missing while the raw SUB_SERVERS_* indexes remain intact.
|
||||
*/
|
||||
fun findOrphans(
|
||||
profiles: Collection<StoredProfileReference>,
|
||||
indexedServersBySubscription: Map<String, Set<String>?>,
|
||||
selectedServer: String?,
|
||||
): Set<String>? {
|
||||
if (profiles.isEmpty()) return emptySet()
|
||||
|
||||
if (indexedServersBySubscription.isEmpty() ||
|
||||
indexedServersBySubscription.values.any { it == null }
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
val indexedServers = indexedServersBySubscription.values
|
||||
.filterNotNull()
|
||||
.flatten()
|
||||
.toSet()
|
||||
|
||||
return profiles.mapNotNullTo(linkedSetOf()) { profile ->
|
||||
if (profile.guid == selectedServer || profile.guid in indexedServers) {
|
||||
return@mapNotNullTo null
|
||||
}
|
||||
|
||||
val subscriptionId = profile.subscriptionId ?: return@mapNotNullTo null
|
||||
val groupId = subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
|
||||
if (!indexedServersBySubscription.containsKey(groupId)) {
|
||||
return@mapNotNullTo null
|
||||
}
|
||||
|
||||
profile.guid
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
|
||||
internal object ProfileReplacement {
|
||||
|
||||
/**
|
||||
* Finds the profile that should become selected after publishing a replacement batch.
|
||||
* The first profile becomes selected when the store has no current selection.
|
||||
*/
|
||||
fun findSelectedReplacement(
|
||||
profiles: Map<String, ProfileItem>,
|
||||
currentSelection: String?,
|
||||
selectedProfile: ProfileItem?,
|
||||
): String? {
|
||||
if (profiles.isEmpty()) return null
|
||||
if (currentSelection.isNullOrBlank()) return profiles.keys.first()
|
||||
if (selectedProfile == null) return null
|
||||
|
||||
if (selectedProfile.remarks.isNotBlank()) {
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.remarks, selectedProfile.remarks) &&
|
||||
isSameText(candidate.server, selectedProfile.server) &&
|
||||
isSameText(candidate.serverPort, selectedProfile.serverPort) &&
|
||||
isSameText(candidate.password, selectedProfile.password)
|
||||
}?.key?.let { return it }
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.remarks, selectedProfile.remarks)
|
||||
}?.key?.let { return it }
|
||||
}
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.server, selectedProfile.server) &&
|
||||
isSameText(candidate.serverPort, selectedProfile.serverPort) &&
|
||||
isSameText(candidate.password, selectedProfile.password)
|
||||
}?.key?.let { return it }
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.server, selectedProfile.server) &&
|
||||
isSameText(candidate.serverPort, selectedProfile.serverPort)
|
||||
}?.key?.let { return it }
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.server, selectedProfile.server)
|
||||
}?.key?.let { return it }
|
||||
|
||||
return profiles.keys.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds replaced payloads that are safe to remove.
|
||||
*
|
||||
* A null cross-group reference set means that at least one raw group index could not
|
||||
* be read. In that case deletion fails closed.
|
||||
*/
|
||||
fun findRemovablePayloads(
|
||||
replacedServers: Collection<String>,
|
||||
replacementServers: Set<String>,
|
||||
protectedServer: String?,
|
||||
serversReferencedByOtherGroups: Set<String>?,
|
||||
): Set<String> {
|
||||
if (serversReferencedByOtherGroups == null) return emptySet()
|
||||
|
||||
return replacedServers.filterTo(linkedSetOf()) { guid ->
|
||||
guid != protectedServer &&
|
||||
guid !in replacementServers &&
|
||||
guid !in serversReferencedByOtherGroups
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSameText(left: String?, right: String?): Boolean {
|
||||
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
|
||||
return left.trim().equals(right.trim(), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,58 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import com.v2ray.ang.AppConfig
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Manages global flags for actions triggered by setting changes.
|
||||
* Uses AtomicBoolean for thread-safe consume operations.
|
||||
*/
|
||||
object SettingsChangeManager {
|
||||
private val _restartService = MutableStateFlow(false)
|
||||
private val _setupGroupTab = MutableStateFlow(false)
|
||||
|
||||
// Mark restartService as requiring a restart
|
||||
private val restartService = AtomicBoolean(false)
|
||||
private val setupGroupTab = AtomicBoolean(false)
|
||||
|
||||
// Keys that affect only UI behavior and do not require core service restart.
|
||||
private val uiOnlyKeys = setOf(
|
||||
AppConfig.PREF_CONFIRM_REMOVE,
|
||||
AppConfig.PREF_DOUBLE_COLUMN_DISPLAY,
|
||||
AppConfig.PREF_GROUP_ALL_DISPLAY,
|
||||
AppConfig.PREF_LANGUAGE,
|
||||
AppConfig.PREF_UI_MODE_NIGHT,
|
||||
AppConfig.PREF_DYNAMIC_COLOR,
|
||||
AppConfig.PREF_IS_BOOTED,
|
||||
)
|
||||
|
||||
/**
|
||||
* Called when a setting value changes.
|
||||
* Triggers service restart if the key is not UI-only, and always refreshes UI tabs.
|
||||
*/
|
||||
fun notifySettingChanged(key: String) {
|
||||
if (key !in uiOnlyKeys) {
|
||||
makeRestartService()
|
||||
}
|
||||
makeSetupGroupTab()
|
||||
}
|
||||
|
||||
fun makeRestartService() {
|
||||
_restartService.value = true
|
||||
restartService.set(true)
|
||||
}
|
||||
|
||||
// Read and clear the restartService flag
|
||||
fun consumeRestartService(): Boolean {
|
||||
val v = _restartService.value
|
||||
_restartService.value = false
|
||||
return v
|
||||
}
|
||||
/**
|
||||
* Atomically consumes the restart flag.
|
||||
* @return true if a restart was requested, false otherwise.
|
||||
*/
|
||||
fun consumeRestartService(): Boolean =
|
||||
restartService.compareAndSet(true, false)
|
||||
|
||||
// Mark reinitGroupTab as requiring tab reinitialization
|
||||
fun makeSetupGroupTab() {
|
||||
_setupGroupTab.value = true
|
||||
setupGroupTab.set(true)
|
||||
}
|
||||
|
||||
// Read and clear the reinitGroupTab flag
|
||||
fun consumeSetupGroupTab(): Boolean {
|
||||
val v = _setupGroupTab.value
|
||||
_setupGroupTab.value = false
|
||||
return v
|
||||
}
|
||||
/**
|
||||
* Atomically consumes the setup-group-tab flag.
|
||||
* @return true if UI refresh was requested, false otherwise.
|
||||
*/
|
||||
fun consumeSetupGroupTab(): Boolean =
|
||||
setupGroupTab.compareAndSet(true, false)
|
||||
}
|
||||
@@ -2,38 +2,54 @@ package com.v2ray.ang.handler
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.AssetManager
|
||||
import android.os.Build
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
|
||||
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
|
||||
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
||||
import com.v2ray.ang.AppConfig.VPN
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.Language
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.enums.RoutingType
|
||||
import com.v2ray.ang.dto.RulesetItem
|
||||
import com.v2ray.ang.dto.V2rayConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.RulesetItem
|
||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.RoutingType
|
||||
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
|
||||
import com.v2ray.ang.extension.moveItem
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeAllServerList
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeServerList
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeSubsList
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeSubscription
|
||||
import com.v2ray.ang.handler.MmkvManager.encodeSubscription
|
||||
import com.v2ray.ang.handler.MmkvManager.removeSubscription
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.Collections
|
||||
import java.util.Locale
|
||||
import kotlin.random.Random
|
||||
|
||||
object SettingsManager {
|
||||
|
||||
@Volatile
|
||||
private var runtimeSocksPort: Int? = null
|
||||
|
||||
fun initApp(context: Context) {
|
||||
ensureDefaultSettings()
|
||||
//ensureDefaultSubscription()
|
||||
initRoutingRulesets(context)
|
||||
migrateServerListToSubscriptions()
|
||||
migrateHysteria2PinSHA256()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize routing rulesets.
|
||||
* @param context The application context.
|
||||
*/
|
||||
fun initRoutingRulesets(context: Context) {
|
||||
private fun initRoutingRulesets(context: Context) {
|
||||
val exist = MmkvManager.decodeRoutingRulesets()
|
||||
if (exist.isNullOrEmpty()) {
|
||||
val rulesetList = getPresetRoutingRulesets(context)
|
||||
@@ -44,26 +60,25 @@ object SettingsManager {
|
||||
/**
|
||||
* Get preset routing rulesets.
|
||||
* @param context The application context.
|
||||
* @param index The index of the routing type.
|
||||
* @param type The routing preset type.
|
||||
* @return A mutable list of RulesetItem.
|
||||
*/
|
||||
private fun getPresetRoutingRulesets(context: Context, index: Int = 0): MutableList<RulesetItem>? {
|
||||
val fileName = RoutingType.fromIndex(index).fileName
|
||||
val assets = Utils.readTextFromAssets(context, fileName)
|
||||
private fun getPresetRoutingRulesets(context: Context, type: RoutingType = RoutingType.WHITE): MutableList<RulesetItem>? {
|
||||
val assets = Utils.readTextFromAssets(context, type.fileName)
|
||||
if (TextUtils.isEmpty(assets)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return JsonUtil.fromJson(assets, Array<RulesetItem>::class.java)?.toMutableList()
|
||||
return JsonUtil.fromJsonSafe(assets, Array<RulesetItem>::class.java)?.toMutableList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset routing rulesets from presets.
|
||||
* @param context The application context.
|
||||
* @param index The index of the routing type.
|
||||
* @param type The routing preset type.
|
||||
*/
|
||||
fun resetRoutingRulesetsFromPresets(context: Context, index: Int) {
|
||||
val rulesetList = getPresetRoutingRulesets(context, index) ?: return
|
||||
fun resetRoutingRulesetsFromPresets(context: Context, type: RoutingType) {
|
||||
val rulesetList = getPresetRoutingRulesets(context, type) ?: return
|
||||
resetRoutingRulesetsCommon(rulesetList)
|
||||
}
|
||||
|
||||
@@ -78,7 +93,7 @@ object SettingsManager {
|
||||
}
|
||||
|
||||
try {
|
||||
val rulesetList = JsonUtil.fromJson(content, Array<RulesetItem>::class.java)?.toMutableList()
|
||||
val rulesetList = JsonUtil.fromJsonSafe(content, Array<RulesetItem>::class.java)?.toMutableList()
|
||||
if (rulesetList.isNullOrEmpty()) {
|
||||
return false
|
||||
}
|
||||
@@ -86,7 +101,7 @@ object SettingsManager {
|
||||
resetRoutingRulesetsCommon(rulesetList)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.e(ANG_PACKAGE, "Failed to reset routing rulesets", e)
|
||||
LogUtil.e(ANG_PACKAGE, "Failed to reset routing rulesets", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -161,7 +176,7 @@ object SettingsManager {
|
||||
* @return True if bypassing LAN, false otherwise.
|
||||
*/
|
||||
fun routingRulesetsBypassLan(): Boolean {
|
||||
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN) ?: "1"
|
||||
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN, AppConfig.DEFAULT_VPN_BYPASS_LAN)
|
||||
if (vpnBypassLan == "1") {
|
||||
return true
|
||||
} else if (vpnBypassLan == "2") {
|
||||
@@ -172,7 +187,7 @@ object SettingsManager {
|
||||
val config = decodeServerConfig(guid) ?: return false
|
||||
if (config.configType == EConfigType.CUSTOM) {
|
||||
val raw = MmkvManager.decodeServerRaw(guid) ?: return false
|
||||
val v2rayConfig = JsonUtil.fromJson(raw, V2rayConfig::class.java)
|
||||
val v2rayConfig = JsonUtil.fromJsonSafe(raw, V2rayConfig::class.java)
|
||||
val exist = v2rayConfig?.routing?.rules?.filter { it.outboundTag == TAG_DIRECT }?.any {
|
||||
it.domain?.contains(GEOSITE_PRIVATE) == true || it.ip?.contains(GEOIP_PRIVATE) == true
|
||||
}
|
||||
@@ -186,32 +201,6 @@ object SettingsManager {
|
||||
return exist == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap routing rulesets.
|
||||
* @param fromPosition The position to swap from.
|
||||
* @param toPosition The position to swap to.
|
||||
*/
|
||||
fun swapRoutingRuleset(fromPosition: Int, toPosition: Int) {
|
||||
val rulesetList = MmkvManager.decodeRoutingRulesets()
|
||||
if (rulesetList.isNullOrEmpty()) return
|
||||
|
||||
Collections.swap(rulesetList, fromPosition, toPosition)
|
||||
MmkvManager.encodeRoutingRulesets(rulesetList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap subscriptions.
|
||||
* @param fromPosition The position to swap from.
|
||||
* @param toPosition The position to swap to.
|
||||
*/
|
||||
fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
|
||||
val subsList = MmkvManager.decodeSubsList()
|
||||
if (subsList.isEmpty()) return
|
||||
|
||||
Collections.swap(subsList, fromPosition, toPosition)
|
||||
MmkvManager.encodeSubsList(subsList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server via remarks.
|
||||
* @param remarks The remarks of the server.
|
||||
@@ -221,14 +210,46 @@ object SettingsManager {
|
||||
if (remarks.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val serverList = decodeServerList()
|
||||
for (guid in serverList) {
|
||||
val profile = decodeServerConfig(guid)
|
||||
if (profile != null && profile.remarks == remarks) {
|
||||
return profile
|
||||
}
|
||||
val serverList = decodeAllServerList()
|
||||
return serverList
|
||||
.mapNotNull { guid -> decodeServerConfig(guid) }
|
||||
.firstOrNull { it.remarks == remarks }
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects non-empty profile remarks while excluding specific config types.
|
||||
*/
|
||||
fun getProfileRemarks(excludeConfigTypes: Set<EConfigType> = setOf(EConfigType.CUSTOM)): List<String> {
|
||||
return decodeAllServerList()
|
||||
.asSequence()
|
||||
.mapNotNull { guid -> decodeServerConfig(guid) }
|
||||
.filter { profile -> profile.configType !in excludeConfigTypes }
|
||||
.map { it.remarks.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the subscription.
|
||||
* If there are no remaining subscriptions,
|
||||
* it creates a new default subscription to ensure that ungroup
|
||||
**/
|
||||
fun removeSubscriptionWithDefault(subid: String) {
|
||||
SubscriptionUpdater.cancelOne(subId = subid)
|
||||
// Remove the subscription
|
||||
removeSubscription(subid)
|
||||
|
||||
// After removal, check if there are any subscriptions left. If not, create a default subscription.
|
||||
val subsList2 = decodeSubsList()
|
||||
if (subsList2.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
return null
|
||||
|
||||
val defaultSub = SubscriptionItem(
|
||||
remarks = "Default",
|
||||
)
|
||||
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,7 +257,30 @@ object SettingsManager {
|
||||
* @return The SOCKS port.
|
||||
*/
|
||||
fun getSocksPort(): Int {
|
||||
return Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
|
||||
val port =
|
||||
if (IsDynamicSocksPort()) {
|
||||
runtimeSocksPort ?: refreshRuntimeSocksPort()
|
||||
} else {
|
||||
Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
|
||||
}
|
||||
return port ?: AppConfig.PORT_SOCKS.toInt()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun refreshRuntimeSocksPort(): Int? {
|
||||
if (IsDynamicSocksPort()) {
|
||||
runtimeSocksPort = generateRandomSocksPort()
|
||||
return runtimeSocksPort
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getSocksUsername(): String? {
|
||||
return MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_USERNAME)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun getSocksPassword(): String? {
|
||||
return MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PASSWORD)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,6 +291,14 @@ object SettingsManager {
|
||||
return getSocksPort() + if (Utils.isXray()) 0 else 1
|
||||
}
|
||||
|
||||
private fun IsDynamicSocksPort(): Boolean {
|
||||
return MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
|
||||
}
|
||||
|
||||
private fun generateRandomSocksPort(): Int {
|
||||
return Random.nextInt(10000, 65535)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize assets.
|
||||
* @param context The application context.
|
||||
@@ -256,7 +308,7 @@ object SettingsManager {
|
||||
val extFolder = Utils.userAssetPath(context)
|
||||
|
||||
try {
|
||||
val geo = arrayOf("geosite.dat", "geoip.dat")
|
||||
val geo = arrayOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
|
||||
assets.list("")
|
||||
?.filter { geo.contains(it) }
|
||||
?.filter { !File(extFolder, it).exists() }
|
||||
@@ -267,10 +319,10 @@ object SettingsManager {
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
Log.i(AppConfig.TAG, "Copied from apk assets folder to ${target.absolutePath}")
|
||||
LogUtil.i(AppConfig.TAG, "Copied from apk assets folder to ${target.absolutePath}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(ANG_PACKAGE, "asset copy failed", e)
|
||||
LogUtil.e(ANG_PACKAGE, "asset copy failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,37 +378,12 @@ object SettingsManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the locale.
|
||||
* @return The locale.
|
||||
* Get real ping concurrency.
|
||||
* @return The number of concurrent real-ping tests (clamped to 1..64).
|
||||
*/
|
||||
fun getLocale(): Locale {
|
||||
val langCode =
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE) ?: Language.AUTO.code
|
||||
val language = Language.fromCode(langCode)
|
||||
|
||||
return when (language) {
|
||||
Language.AUTO -> Utils.getSysLocale()
|
||||
Language.ENGLISH -> Locale.ENGLISH
|
||||
Language.CHINA -> Locale.CHINA
|
||||
Language.TRADITIONAL_CHINESE -> Locale.TRADITIONAL_CHINESE
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set night mode.
|
||||
*/
|
||||
fun setNightMode() {
|
||||
when (MmkvManager.decodeSettingsString(AppConfig.PREF_UI_MODE_NIGHT, "0")) {
|
||||
"0" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
"1" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
"2" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
}
|
||||
fun getRealPingConcurrency(): Int {
|
||||
val value = MmkvManager.decodeSettingsString(AppConfig.PREF_REAL_PING_CONCURRENCY)?.toIntOrNull() ?: 16
|
||||
return value.coerceIn(1, 128)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -396,15 +423,43 @@ object SettingsManager {
|
||||
return mode == null || mode == VPN
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a root (system-wide) run mode is selected.
|
||||
*/
|
||||
fun isRootMode(): Boolean {
|
||||
return MmkvManager.decodeSettingsBool(AppConfig.PREF_ROOT_MODE_ENABLE, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if process routing can be used.
|
||||
*/
|
||||
fun canUseProcessRouting(): Boolean {
|
||||
// Android 10+
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must xray tun
|
||||
if (isUsingHevTun()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have route only enabled
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false) == false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure default settings are present in MMKV.
|
||||
*/
|
||||
fun ensureDefaultSettings() {
|
||||
private fun ensureDefaultSettings() {
|
||||
// Write defaults in the exact order requested by the user
|
||||
ensureDefaultValue(AppConfig.PREF_MODE, AppConfig.VPN)
|
||||
ensureDefaultValue(AppConfig.PREF_MODE, VPN)
|
||||
ensureDefaultValue(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
|
||||
ensureDefaultValue(AppConfig.PREF_VPN_MTU, AppConfig.VPN_MTU.toString())
|
||||
ensureDefaultValue(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL, AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL)
|
||||
ensureDefaultValue(AppConfig.PREF_SOCKS_PORT, AppConfig.PORT_SOCKS)
|
||||
ensureDefaultValue(AppConfig.PREF_REMOTE_DNS, AppConfig.DNS_PROXY)
|
||||
ensureDefaultValue(AppConfig.PREF_DOMESTIC_DNS, AppConfig.DNS_DIRECT)
|
||||
@@ -412,9 +467,15 @@ object SettingsManager {
|
||||
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_MUX_XUDP_CONCURRENCY, AppConfig.DEFAULT_MUX_XUDP_CONCURRENCY)
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_MAXSPLIT, "10")
|
||||
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_PING_INTERVAL, AppConfig.OBSERVATORY_LEAST_PING_INTERVAL)
|
||||
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_INTERVAL, AppConfig.OBSERVATORY_LEAST_LOAD_INTERVAL)
|
||||
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_METHOD, AppConfig.OBSERVATORY_LEAST_LOAD_METHOD)
|
||||
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_SAMPLING, AppConfig.OBSERVATORY_LEAST_LOAD_SAMPLING)
|
||||
ensureDefaultValue(AppConfig.PREF_OBSERVATORY_LEAST_LOAD_TIMEOUT, AppConfig.OBSERVATORY_LEAST_LOAD_TIMEOUT)
|
||||
}
|
||||
|
||||
private fun ensureDefaultValue(key: String, default: String) {
|
||||
@@ -422,4 +483,100 @@ object SettingsManager {
|
||||
MmkvManager.encodeSettings(key, default)
|
||||
}
|
||||
}
|
||||
|
||||
private fun migrateHysteria2PinSHA256() {
|
||||
// Check if migration has already been done
|
||||
val migrationKey = "hysteria2_pin_sha256_migrated"
|
||||
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
val serverList = decodeAllServerList()
|
||||
|
||||
for (guid in serverList) {
|
||||
val profile = decodeServerConfig(guid) ?: continue
|
||||
if (profile.configType != EConfigType.HYSTERIA2) {
|
||||
continue
|
||||
}
|
||||
if (profile.pinSHA256.isNullOrEmpty() || !profile.pinnedCA256.isNullOrEmpty()) {
|
||||
continue
|
||||
}
|
||||
profile.pinnedCA256 = profile.pinSHA256
|
||||
profile.pinSHA256 = null
|
||||
MmkvManager.encodeServerConfig(guid, profile)
|
||||
}
|
||||
|
||||
MmkvManager.encodeSettings(migrationKey, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates server list from legacy KEY_ANG_CONFIGS to subscription-based storage.
|
||||
* This method should be called once during app initialization after the storage structure change.
|
||||
* Servers are grouped by their subscriptionId into respective subscription's serverList.
|
||||
* Servers without subscription are moved to the default subscription.
|
||||
* After migration, KEY_ANG_CONFIGS is removed.
|
||||
*/
|
||||
private fun migrateServerListToSubscriptions() {
|
||||
// Check if migration has already been done
|
||||
val migrationKey = "server_list_to_subscriptions_migrated"
|
||||
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure default subscription exists before migration
|
||||
ensureDefaultSubscription()
|
||||
|
||||
// Read existing server list from legacy KEY_ANG_CONFIGS
|
||||
val oldJson = MmkvManager.readLegacyServerList()
|
||||
if (oldJson.isNullOrBlank()) {
|
||||
// No data to migrate, mark as done
|
||||
MmkvManager.encodeSettings(migrationKey, true)
|
||||
return
|
||||
}
|
||||
|
||||
val guids = JsonUtil.fromJsonSafe(oldJson, Array<String>::class.java) ?: run {
|
||||
MmkvManager.encodeSettings(migrationKey, true)
|
||||
return
|
||||
}
|
||||
|
||||
val subscriptionServerMap = mutableMapOf<String, MutableList<String>>()
|
||||
|
||||
// Group servers by subscription (use default subscription for empty subscriptionId)
|
||||
guids.forEach { guid ->
|
||||
val config = decodeServerConfig(guid) ?: return@forEach
|
||||
val subId = config.subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
|
||||
|
||||
subscriptionServerMap.getOrPut(subId) { mutableListOf() }.add(guid)
|
||||
}
|
||||
|
||||
// Update each subscription's serverList (including default subscription)
|
||||
subscriptionServerMap.forEach { (subId, serverGuids) ->
|
||||
MmkvManager.encodeServerList(serverGuids, subId)
|
||||
}
|
||||
|
||||
|
||||
// Mark migration as complete
|
||||
MmkvManager.encodeSettings(migrationKey, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the default subscription exists for ungrouped servers.
|
||||
* This subscription is used internally to store servers without a subscription.
|
||||
* Made public for migration in SettingsManager.
|
||||
*/
|
||||
private fun ensureDefaultSubscription() {
|
||||
if (decodeSubscription(DEFAULT_SUBSCRIPTION_ID) == null) {
|
||||
val defaultSub = SubscriptionItem(
|
||||
remarks = "Default",
|
||||
)
|
||||
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
|
||||
|
||||
// Move to the top
|
||||
val subsList = decodeSubsList()
|
||||
if (subsList.moveItem(subsList.lastIndex, 0)) {
|
||||
MmkvManager.encodeSubsList(subsList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.IPAPIInfo
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.isActive
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
@@ -17,28 +13,10 @@ import java.net.UnknownHostException
|
||||
|
||||
object SpeedtestManager {
|
||||
|
||||
private val tcpTestingSockets = ArrayList<Socket?>()
|
||||
|
||||
/**
|
||||
* Measures the TCP connection time to a given URL and port.
|
||||
*
|
||||
* @param url The URL to connect to.
|
||||
* @param port The port to connect to.
|
||||
* @return The connection time in milliseconds, or -1 if the connection failed.
|
||||
*/
|
||||
suspend fun tcping(url: String, port: Int): Long {
|
||||
var time = -1L
|
||||
for (k in 0 until 2) {
|
||||
val one = socketConnectTime(url, port)
|
||||
if (!currentCoroutineContext().isActive) {
|
||||
break
|
||||
}
|
||||
if (one != -1L && (time == -1L || one < time)) {
|
||||
time = one
|
||||
}
|
||||
}
|
||||
return time
|
||||
}
|
||||
data class RemoteEndpointInfo(
|
||||
val country: String?,
|
||||
val ipAddress: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Measures the time taken to establish a TCP connection to a given URL and port.
|
||||
@@ -47,86 +25,52 @@ object SpeedtestManager {
|
||||
* @param port The port to connect to.
|
||||
* @return The connection time in milliseconds, or -1 if the connection failed.
|
||||
*/
|
||||
fun socketConnectTime(url: String, port: Int): Long {
|
||||
fun socketConnectTime(url: String, port: Int, timeoutMs: Int = 1500): Long {
|
||||
var socket: Socket? = null
|
||||
val start = System.currentTimeMillis()
|
||||
|
||||
try {
|
||||
val socket = Socket()
|
||||
synchronized(this) {
|
||||
tcpTestingSockets.add(socket)
|
||||
}
|
||||
val start = System.currentTimeMillis()
|
||||
socket.connect(InetSocketAddress(url, port), 3000)
|
||||
val time = System.currentTimeMillis() - start
|
||||
synchronized(this) {
|
||||
tcpTestingSockets.remove(socket)
|
||||
}
|
||||
socket.close()
|
||||
return time
|
||||
socket = Socket()
|
||||
socket.connect(InetSocketAddress(url, port), timeoutMs)
|
||||
|
||||
return System.currentTimeMillis() - start
|
||||
} catch (e: UnknownHostException) {
|
||||
Log.e(AppConfig.TAG, "Unknown host: $url", e)
|
||||
LogUtil.e(AppConfig.TAG, "Unknown host: $url", e)
|
||||
} catch (e: IOException) {
|
||||
Log.e(AppConfig.TAG, "socketConnectTime IOException: $e")
|
||||
LogUtil.e(AppConfig.TAG, "socketConnectTime IOException: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
|
||||
LogUtil.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
|
||||
} finally {
|
||||
socket?.let { s ->
|
||||
try {
|
||||
if (!s.isClosed) {
|
||||
s.close()
|
||||
}
|
||||
} catch (closeEx: IOException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes all TCP sockets that are currently being tested.
|
||||
*/
|
||||
fun closeAllTcpSockets() {
|
||||
synchronized(this) {
|
||||
tcpTestingSockets.forEach {
|
||||
it?.close()
|
||||
}
|
||||
tcpTestingSockets.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the connection to a given URL and port.
|
||||
*
|
||||
* @param context The Context in which the test is running.
|
||||
* @param port The port to connect to.
|
||||
* @return A pair containing the elapsed time in milliseconds and the result message.
|
||||
*/
|
||||
fun testConnection(context: Context, port: Int): Pair<Long, String> {
|
||||
var result: String
|
||||
var elapsed = -1L
|
||||
|
||||
val conn = HttpUtil.createProxyConnection(SettingsManager.getDelayTestUrl(), port, 15000, 15000) ?: return Pair(elapsed, "")
|
||||
try {
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
val code = conn.responseCode
|
||||
elapsed = SystemClock.elapsedRealtime() - start
|
||||
|
||||
result = when (code) {
|
||||
204 -> context.getString(R.string.connection_test_available, elapsed)
|
||||
200 if conn.contentLengthLong == 0L -> context.getString(R.string.connection_test_available, elapsed)
|
||||
else -> throw IOException(
|
||||
context.getString(R.string.connection_test_error_status_code, code)
|
||||
)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(AppConfig.TAG, "Connection test IOException", e)
|
||||
result = context.getString(R.string.connection_test_error, e.message)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Connection test Exception", e)
|
||||
result = context.getString(R.string.connection_test_error, e.message)
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
|
||||
return Pair(elapsed, result)
|
||||
}
|
||||
|
||||
fun getRemoteIPInfo(): String? {
|
||||
fun getRemoteIPInfo(): RemoteEndpointInfo? {
|
||||
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
|
||||
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
|
||||
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
val content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: return null
|
||||
val ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
|
||||
if (httpPort == 0) return null
|
||||
val content = HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000,
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
proxyPassword = proxyPassword
|
||||
)
|
||||
) ?: return null
|
||||
val ipInfo = JsonUtil.fromJsonSafe(content, IPAPIInfo::class.java) ?: return null
|
||||
|
||||
val ip = listOf(
|
||||
ipInfo.ip,
|
||||
@@ -142,6 +86,9 @@ object SpeedtestManager {
|
||||
ipInfo.location?.country_code
|
||||
).firstOrNull { !it.isNullOrBlank() }
|
||||
|
||||
return "(${country ?: "unknown"}) ${ip ?: "unknown"}"
|
||||
return RemoteEndpointInfo(
|
||||
country = country,
|
||||
ipAddress = ip,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,189 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.multiprocess.RemoteWorkManager
|
||||
import androidx.work.workDataOf
|
||||
import com.v2ray.ang.AngApplication
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.dto.SubscriptionUpdateMessage
|
||||
import com.v2ray.ang.helper.MessageHelper
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object SubscriptionUpdater {
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API — the only methods external callers should ever use
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sync all subscription tasks with current settings.
|
||||
*
|
||||
* Startup/boot callers should use the default mode so existing periodic work is kept.
|
||||
* Use forceReschedule=true only when the next run time needs to be recalculated from
|
||||
* the latest persisted subscription state (for example after a manual refresh).
|
||||
* Call from: MainActivity.onCreate(), BootReceiver.onReceive().
|
||||
*/
|
||||
fun sync(
|
||||
context: Context = AngApplication.application,
|
||||
forceReschedule: Boolean = false
|
||||
) {
|
||||
val existingWorkPolicy =
|
||||
if (forceReschedule) {
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
} else {
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
}
|
||||
|
||||
MmkvManager.decodeSubscriptions()
|
||||
.filter { it.subscription.autoUpdate && it.subscription.url.isNotEmpty() }
|
||||
.forEach { sub ->
|
||||
scheduleOne(
|
||||
context = context,
|
||||
subId = sub.guid,
|
||||
existingWorkPolicy = existingWorkPolicy
|
||||
)
|
||||
}
|
||||
LogUtil.i(
|
||||
AppConfig.TAG,
|
||||
"SubscriptionUpdater: sync complete forceReschedule=$forceReschedule"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a single subscription's task.
|
||||
* Call from: SubEditActivity after saving, after a manual update (to reset the timer).
|
||||
*/
|
||||
fun syncOne(context: Context = AngApplication.application, subId: String) {
|
||||
scheduleOne(
|
||||
context = context,
|
||||
subId = subId,
|
||||
existingWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the auto-update task for a single subscription.
|
||||
* Call from: when a subscription is deleted.
|
||||
*/
|
||||
fun cancelOne(context: Context = AngApplication.application, subId: String) {
|
||||
RemoteWorkManager.getInstance(context)
|
||||
.cancelUniqueWork(taskName(subId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last updated timestamp and reschedule the task.
|
||||
* This is used to reset the periodic timer and prevent rapid rescheduling loops.
|
||||
*/
|
||||
fun updateLastUpdatedAndReschedule(context: Context = AngApplication.application, subId: String) {
|
||||
val subItem = MmkvManager.decodeSubscription(subId) ?: return
|
||||
subItem.lastUpdated = System.currentTimeMillis()
|
||||
MmkvManager.encodeSubscription(subId, subItem)
|
||||
syncOne(context, subId)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal scheduling logic
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private fun taskName(subId: String) = "${AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME}_$subId"
|
||||
|
||||
private fun scheduleOne(
|
||||
context: Context,
|
||||
subId: String,
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy
|
||||
) {
|
||||
val subItem = MmkvManager.decodeSubscription(subId) ?: return
|
||||
val rw = RemoteWorkManager.getInstance(context)
|
||||
if (!subItem.autoUpdate) {
|
||||
cancelOne(context, subId)
|
||||
LogUtil.d(AppConfig.TAG, "SubscriptionUpdater: cancelled task for ${subItem.remarks}")
|
||||
return
|
||||
}
|
||||
|
||||
if (subItem.url.isEmpty()) {
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: url isEmpty for ${subItem.remarks}, skip")
|
||||
return
|
||||
}
|
||||
|
||||
val intervalMinutes = maxOf(
|
||||
AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES,
|
||||
subItem.updateInterval
|
||||
)
|
||||
|
||||
// Base initial delay on the last successful update time persisted in subscription.
|
||||
val lastUpdated = subItem.lastUpdated
|
||||
val intervalMillis = intervalMinutes * 60 * 1000L
|
||||
val now = System.currentTimeMillis()
|
||||
var initialDelayMillis = if (lastUpdated <= 0L) {
|
||||
0L
|
||||
} else {
|
||||
maxOf(0L, lastUpdated + intervalMillis - now)
|
||||
}
|
||||
|
||||
// Add a small floor to initial delay to prevent rapid rescheduling loops.
|
||||
if (existingWorkPolicy == ExistingPeriodicWorkPolicy.REPLACE && initialDelayMillis < 5000L) {
|
||||
initialDelayMillis = 5000L
|
||||
}
|
||||
|
||||
val request = PeriodicWorkRequestBuilder<UpdateTask>(intervalMinutes, TimeUnit.MINUTES)
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.setInputData(workDataOf(KEY_SUB_ID to subId))
|
||||
.setInitialDelay(initialDelayMillis, TimeUnit.MILLISECONDS)
|
||||
.addTag(AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME)
|
||||
.build()
|
||||
|
||||
rw.enqueueUniquePeriodicWork(
|
||||
taskName(subId),
|
||||
existingWorkPolicy,
|
||||
request
|
||||
)
|
||||
|
||||
LogUtil.i(
|
||||
AppConfig.TAG,
|
||||
"SubscriptionUpdater: scheduled [${subItem.remarks}] interval=${intervalMinutes}min " +
|
||||
"initialDelay=${initialDelayMillis / 1000}s policy=$existingWorkPolicy"
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Worker
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private const val KEY_SUB_ID = "subId"
|
||||
|
||||
class UpdateTask(context: Context, params: WorkerParameters) :
|
||||
CoroutineWorker(context, params) {
|
||||
|
||||
private val notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
private val notification =
|
||||
NotificationCompat.Builder(applicationContext, AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
|
||||
.setWhen(0)
|
||||
.setTicker("Update")
|
||||
.setContentTitle(context.getString(R.string.title_pref_auto_update_subscription))
|
||||
.setSmallIcon(R.drawable.ic_stat_name)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
|
||||
/**
|
||||
* Performs the subscription update work.
|
||||
* @return The result of the work.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun doWork(): Result {
|
||||
Log.i(AppConfig.TAG, "subscription automatic update starting")
|
||||
val subId = inputData.getString(KEY_SUB_ID)
|
||||
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater update starting via Service: $subId")
|
||||
|
||||
val subs = MmkvManager.decodeSubscriptions().filter { it.subscription.autoUpdate }
|
||||
|
||||
for (sub in subs) {
|
||||
val subItem = sub.subscription
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notification.setChannelId(AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
AppConfig.SUBSCRIPTION_UPDATE_CHANNEL,
|
||||
AppConfig.SUBSCRIPTION_UPDATE_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_MIN
|
||||
)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
notificationManager.notify(3, notification.build())
|
||||
Log.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
|
||||
AngConfigManager.updateConfigViaSub(sub)
|
||||
notification.setContentText("Updating ${subItem.remarks}")
|
||||
if (subId.isNullOrEmpty()) {
|
||||
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: missing subId in worker input")
|
||||
return Result.success()
|
||||
}
|
||||
notificationManager.cancel(3)
|
||||
|
||||
updateLastUpdatedAndReschedule(applicationContext, subId)
|
||||
|
||||
MessageHelper.sendMsg2SubscriptionService(
|
||||
applicationContext,
|
||||
SubscriptionUpdateMessage(AppConfig.MSG_SUB_UPDATE_START, true, listOf(subId))
|
||||
)
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,17 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.dto.CheckUpdateResult
|
||||
import com.v2ray.ang.dto.GitHubRelease
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
import com.v2ray.ang.extension.concatUrl
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
object UpdateCheckerManager {
|
||||
suspend fun checkForUpdate(includePreRelease: Boolean = false): CheckUpdateResult = withContext(Dispatchers.IO) {
|
||||
@@ -23,26 +21,42 @@ object UpdateCheckerManager {
|
||||
AppConfig.APP_API_URL.concatUrl("latest")
|
||||
}
|
||||
|
||||
var response = HttpUtil.getUrlContent(url, 5000)
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
|
||||
var response = HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000
|
||||
)
|
||||
)
|
||||
if (response.isNullOrEmpty()) {
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
response = HttpUtil.getUrlContent(url, 5000, httpPort)
|
||||
response = HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000,
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
proxyPassword = proxyPassword
|
||||
)
|
||||
)
|
||||
?: throw IllegalStateException("Failed to get response")
|
||||
}
|
||||
|
||||
val latestRelease = if (includePreRelease) {
|
||||
JsonUtil.fromJson(response, Array<GitHubRelease>::class.java)
|
||||
JsonUtil.fromJsonSafe(response, Array<GitHubRelease>::class.java)
|
||||
?.firstOrNull()
|
||||
?: throw IllegalStateException("No pre-release found")
|
||||
} else {
|
||||
JsonUtil.fromJson(response, GitHubRelease::class.java)
|
||||
JsonUtil.fromJsonSafe(response, GitHubRelease::class.java)
|
||||
}
|
||||
if (latestRelease == null) {
|
||||
return@withContext CheckUpdateResult(hasUpdate = false)
|
||||
}
|
||||
|
||||
val latestVersion = latestRelease.tagName.removePrefix("v")
|
||||
Log.i(
|
||||
LogUtil.i(
|
||||
AppConfig.TAG,
|
||||
"Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})"
|
||||
)
|
||||
@@ -61,39 +75,6 @@ object UpdateCheckerManager {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun downloadApk(context: Context, downloadUrl: String): File? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
val connection = HttpUtil.createProxyConnection(downloadUrl, httpPort, 10000, 10000, true)
|
||||
?: throw IllegalStateException("Failed to create connection")
|
||||
|
||||
try {
|
||||
val apkFile = File(context.cacheDir, "update.apk")
|
||||
Log.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath}")
|
||||
|
||||
FileOutputStream(apkFile).use { outputStream ->
|
||||
connection.inputStream.use { inputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
Log.i(AppConfig.TAG, "APK download completed")
|
||||
return@withContext apkFile
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to download APK: ${e.message}")
|
||||
return@withContext null
|
||||
} finally {
|
||||
try {
|
||||
connection.disconnect()
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Error closing connection: ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to initiate download: ${e.message}")
|
||||
return@withContext null
|
||||
}
|
||||
}
|
||||
|
||||
private fun compareVersions(version1: String, version2: String): Int {
|
||||
val v1 = version1.split(".")
|
||||
val v2 = version2.split(".")
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
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.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.dto.ProfileItem
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.contracts.ServiceControl
|
||||
import com.v2ray.ang.service.V2RayProxyOnlyService
|
||||
import com.v2ray.ang.service.V2RayVpnService
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import libv2ray.CoreCallbackHandler
|
||||
import libv2ray.CoreController
|
||||
import java.lang.ref.SoftReference
|
||||
|
||||
object V2RayServiceManager {
|
||||
|
||||
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
|
||||
V2RayNativeManager.initCoreEnv(value?.get()?.getService())
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the V2Ray service from a toggle action.
|
||||
* @param context The context from which the service is started.
|
||||
* @return True if the service was started successfully, false otherwise.
|
||||
*/
|
||||
fun startVServiceFromToggle(context: Context): Boolean {
|
||||
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
|
||||
context.toast(R.string.app_tile_first_use)
|
||||
return false
|
||||
}
|
||||
startContextService(context)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the V2Ray service.
|
||||
* @param context The context from which the service is started.
|
||||
* @param guid The GUID of the server configuration to use (optional).
|
||||
*/
|
||||
fun startVService(context: Context, guid: String? = null) {
|
||||
if (guid != null) {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
}
|
||||
startContextService(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the V2Ray service.
|
||||
* @param context The context from which the service is stopped.
|
||||
*/
|
||||
fun stopVService(context: Context) {
|
||||
//context.toast(R.string.toast_services_stop)
|
||||
MessageUtil.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the V2Ray service is running.
|
||||
* @return True if the service is running, false otherwise.
|
||||
*/
|
||||
fun isRunning() = coreController.isRunning
|
||||
|
||||
/**
|
||||
* Gets the name of the currently running server.
|
||||
* @return The name of the running server.
|
||||
*/
|
||||
fun getRunningServerName() = currentConfig?.remarks.orEmpty()
|
||||
|
||||
/**
|
||||
* 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 (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.isPureIpAddress(config.server.orEmpty())
|
||||
) return
|
||||
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
|
||||
// if (!result.status) return
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) {
|
||||
context.toast(R.string.toast_warning_pref_proxysharing_short)
|
||||
} else {
|
||||
context.toast(R.string.toast_services_start)
|
||||
}
|
||||
val intent = if (SettingsManager.isVpnMode()) {
|
||||
Intent(context.applicationContext, V2RayVpnService::class.java)
|
||||
} else {
|
||||
Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
|
||||
}
|
||||
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 core service.
|
||||
*/
|
||||
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 false
|
||||
|
||||
try {
|
||||
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||
mFilter.addAction(Intent.ACTION_SCREEN_ON)
|
||||
mFilter.addAction(Intent.ACTION_SCREEN_OFF)
|
||||
mFilter.addAction(Intent.ACTION_USER_PRESENT)
|
||||
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to register broadcast receiver", e)
|
||||
return false
|
||||
}
|
||||
|
||||
currentConfig = config
|
||||
var tunFd = vpnInterface?.fd ?: 0
|
||||
if (SettingsManager.isUsingHevTun()) {
|
||||
tunFd = 0
|
||||
}
|
||||
|
||||
try {
|
||||
NotificationManager.showNotification(currentConfig)
|
||||
coreController.startLoop(result.content, tunFd)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to start Core loop", e)
|
||||
return false
|
||||
}
|
||||
|
||||
if (coreController.isRunning == false) {
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
|
||||
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 core service.
|
||||
* Unregisters broadcast receivers, stops notifications, and shuts down plugins.
|
||||
* @return True if the core was stopped successfully, false otherwise.
|
||||
*/
|
||||
fun stopCoreLoop(): Boolean {
|
||||
val service = getService() ?: return false
|
||||
|
||||
if (coreController.isRunning) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
coreController.stopLoop()
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to stop V2Ray loop", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
|
||||
NotificationManager.cancelNotification()
|
||||
|
||||
try {
|
||||
service.unregisterReceiver(mMsgReceive)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to unregister broadcast receiver", e)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the statistics for a given tag and link.
|
||||
* @param tag The tag to query.
|
||||
* @param link The link to query.
|
||||
* @return The statistics value.
|
||||
*/
|
||||
fun queryStats(tag: String, link: String): Long {
|
||||
return coreController.queryStats(tag, link)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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"
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current service instance.
|
||||
* @return The current service instance, or null if not available.
|
||||
*/
|
||||
private fun getService(): Service? {
|
||||
return serviceControl?.get()?.getService()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
return try {
|
||||
serviceControl.stopService()
|
||||
0
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to stop service in callback", e)
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
val serviceControl = serviceControl?.get() ?: return
|
||||
when (intent?.getIntExtra("key", 0)) {
|
||||
AppConfig.MSG_REGISTER_CLIENT -> {
|
||||
if (coreController.isRunning) {
|
||||
MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_RUNNING, "")
|
||||
} else {
|
||||
MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
|
||||
}
|
||||
}
|
||||
|
||||
AppConfig.MSG_UNREGISTER_CLIENT -> {
|
||||
// nothing to do
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_START -> {
|
||||
// nothing to do
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_STOP -> {
|
||||
Log.i(AppConfig.TAG, "Stop Service")
|
||||
serviceControl.stopService()
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_RESTART -> {
|
||||
Log.i(AppConfig.TAG, "Restart Service")
|
||||
serviceControl.stopService()
|
||||
Thread.sleep(500L)
|
||||
startVService(serviceControl.getService())
|
||||
}
|
||||
|
||||
AppConfig.MSG_MEASURE_DELAY -> {
|
||||
measureV2rayDelay()
|
||||
}
|
||||
}
|
||||
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SCREEN_OFF -> {
|
||||
Log.i(AppConfig.TAG, "SCREEN_OFF, stop querying stats")
|
||||
NotificationManager.stopSpeedNotification(currentConfig)
|
||||
}
|
||||
|
||||
Intent.ACTION_SCREEN_ON -> {
|
||||
Log.i(AppConfig.TAG, "SCREEN_ON, start querying stats")
|
||||
NotificationManager.startSpeedNotification(currentConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,8 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.WebDavConfig
|
||||
import com.v2ray.ang.dto.entities.WebDavConfig
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Credentials
|
||||
@@ -67,14 +67,14 @@ object WebDavManager {
|
||||
cl.newCall(req).execute().use { resp ->
|
||||
val success = resp.isSuccessful
|
||||
if (success) {
|
||||
Log.i(AppConfig.TAG, "WebDAV upload success: $remote")
|
||||
LogUtil.i(AppConfig.TAG, "WebDAV upload success: $remote")
|
||||
} else {
|
||||
Log.e(AppConfig.TAG, "WebDAV upload failed: $remote (HTTP ${resp.code})")
|
||||
LogUtil.e(AppConfig.TAG, "WebDAV upload failed: $remote (HTTP ${resp.code})")
|
||||
}
|
||||
return@withContext success
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
|
||||
LogUtil.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ object WebDavManager {
|
||||
val req = applyAuth(Request.Builder().url(remote).get()).build()
|
||||
cl.newCall(req).execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
Log.e(AppConfig.TAG, "WebDAV download failed: $remote (HTTP ${resp.code})")
|
||||
LogUtil.e(AppConfig.TAG, "WebDAV download failed: $remote (HTTP ${resp.code})")
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
@@ -104,11 +104,11 @@ object WebDavManager {
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(AppConfig.TAG, "WebDAV download success: $remote")
|
||||
LogUtil.i(AppConfig.TAG, "WebDAV download success: $remote")
|
||||
return@withContext true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
|
||||
LogUtil.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,7 @@ object WebDavManager {
|
||||
cl.newCall(req).execute().use { resp ->
|
||||
// 201 Created or 405 Method Not Allowed (already exists) are acceptable
|
||||
if (resp.code != 201 && resp.code != 405 && resp.code != 409) {
|
||||
Log.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}")
|
||||
LogUtil.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}")
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
@@ -177,7 +177,7 @@ object WebDavManager {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "WebDAV ensureRemoteDirs error", e)
|
||||
LogUtil.e(AppConfig.TAG, "WebDAV ensureRemoteDirs error", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class CustomDividerItemDecoration(
|
||||
private val divider: Drawable,
|
||||
private val orientation: Int
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
|
||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
if (orientation == RecyclerView.VERTICAL) {
|
||||
drawVerticalDividers(canvas, parent)
|
||||
} else {
|
||||
drawHorizontalDividers(canvas, parent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawVerticalDividers(canvas: Canvas, parent: RecyclerView) {
|
||||
val left = parent.paddingLeft
|
||||
val right = parent.width - parent.paddingRight
|
||||
|
||||
val childCount = parent.childCount
|
||||
for (i in 0 until childCount - 1) {
|
||||
val child = parent.getChildAt(i)
|
||||
val params = child.layoutParams as RecyclerView.LayoutParams
|
||||
|
||||
val top = child.bottom + params.bottomMargin
|
||||
val bottom = top + divider.intrinsicHeight
|
||||
|
||||
divider.setBounds(left, top, right, bottom)
|
||||
divider.draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawHorizontalDividers(canvas: Canvas, parent: RecyclerView) {
|
||||
val top = parent.paddingTop
|
||||
val bottom = parent.height - parent.paddingBottom
|
||||
|
||||
val childCount = parent.childCount
|
||||
for (i in 0 until childCount - 1) {
|
||||
val child = parent.getChildAt(i)
|
||||
val params = child.layoutParams as RecyclerView.LayoutParams
|
||||
|
||||
val left = child.right + params.rightMargin
|
||||
val right = left + divider.intrinsicWidth
|
||||
|
||||
divider.setBounds(left, top, right, bottom)
|
||||
divider.draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
if (orientation == RecyclerView.VERTICAL) {
|
||||
outRect.set(0, 0, 0, divider.intrinsicHeight)
|
||||
} else {
|
||||
outRect.set(0, 0, divider.intrinsicWidth, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,29 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
/**
|
||||
* Helper for choosing and creating files using Android Storage Access Framework.
|
||||
* Supports both file selection (ACTION_GET_CONTENT) and file creation (CreateDocument).
|
||||
*/
|
||||
class FileChooserHelper(private val activity: AppCompatActivity) {
|
||||
class FileChooserHelper(private val activity: ComponentActivity) {
|
||||
private var fileChooserCallback: ((Uri?) -> Unit)? = null
|
||||
private var documentCreateCallback: ((Uri?) -> Unit)? = null
|
||||
|
||||
private val fileChooserLauncher: ActivityResultLauncher<Intent> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val uri = result.data?.data
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK && uri != null) {
|
||||
if (result.resultCode == Activity.RESULT_OK && uri != null) {
|
||||
fileChooserCallback?.invoke(uri)
|
||||
} else {
|
||||
fileChooserCallback?.invoke(null)
|
||||
@@ -58,7 +59,7 @@ class FileChooserHelper(private val activity: AppCompatActivity) {
|
||||
Intent.createChooser(intent, activity.getString(R.string.title_file_chooser))
|
||||
)
|
||||
} catch (ex: ActivityNotFoundException) {
|
||||
Log.e(AppConfig.TAG, "File chooser activity not found", ex)
|
||||
LogUtil.e(AppConfig.TAG, "File chooser activity not found", ex)
|
||||
activity.toast(R.string.toast_require_file_manager)
|
||||
fileChooserCallback?.invoke(null)
|
||||
fileChooserCallback = null
|
||||
@@ -79,10 +80,10 @@ class FileChooserHelper(private val activity: AppCompatActivity) {
|
||||
try {
|
||||
documentCreateLauncher.launch(fileName)
|
||||
} catch (ex: ActivityNotFoundException) {
|
||||
Log.e(AppConfig.TAG, "Document creator activity not found", ex)
|
||||
LogUtil.e(AppConfig.TAG, "Document creator activity not found", ex)
|
||||
activity.toast(R.string.toast_require_file_manager)
|
||||
documentCreateCallback?.invoke(null)
|
||||
documentCreateCallback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2015 Paul Burke
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
/**
|
||||
* Interface to listen for a move or dismissal event from a [ItemTouchHelper.Callback].
|
||||
*
|
||||
* @author Paul Burke (ipaulpro)
|
||||
*/
|
||||
interface ItemTouchHelperAdapter {
|
||||
/**
|
||||
* Called when an item has been dragged far enough to trigger a move. This is called every time
|
||||
* an item is shifted, and **not** at the end of a "drop" event.<br></br>
|
||||
* <br></br>
|
||||
* Implementations should call [RecyclerView.Adapter.notifyItemMoved] after
|
||||
* adjusting the underlying data to reflect this move.
|
||||
*
|
||||
* @param fromPosition The start position of the moved item.
|
||||
* @param toPosition Then resolved position of the moved item.
|
||||
* @return True if the item was moved to the new adapter position.
|
||||
* @see RecyclerView.getAdapterPositionFor
|
||||
* @see RecyclerView.ViewHolder.getAdapterPosition
|
||||
*/
|
||||
fun onItemMove(fromPosition: Int, toPosition: Int): Boolean
|
||||
|
||||
|
||||
fun onItemMoveCompleted()
|
||||
|
||||
/**
|
||||
* Called when an item has been dismissed by a swipe.<br></br>
|
||||
* <br></br>
|
||||
* Implementations should call [RecyclerView.Adapter.notifyItemRemoved] after
|
||||
* adjusting the underlying data to reflect this removal.
|
||||
*
|
||||
* @param position The position of the item dismissed.
|
||||
* @see RecyclerView.getAdapterPositionFor
|
||||
* @see RecyclerView.ViewHolder.getAdapterPosition
|
||||
*/
|
||||
fun onItemDismiss(position: Int)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2015 Paul Burke
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
|
||||
/**
|
||||
* Interface to notify an item ViewHolder of relevant callbacks from [ ].
|
||||
*
|
||||
* @author Paul Burke (ipaulpro)
|
||||
*/
|
||||
interface ItemTouchHelperViewHolder {
|
||||
/**
|
||||
* Called when the [ItemTouchHelper] first registers an item as being moved or swiped.
|
||||
* Implementations should update the item view to indicate it's active state.
|
||||
*/
|
||||
fun onItemSelected()
|
||||
|
||||
|
||||
/**
|
||||
* Called when the [ItemTouchHelper] has completed the move or swipe, and the active item
|
||||
* state should be cleared.
|
||||
*/
|
||||
fun onItemClear()
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.SubscriptionUpdateMessage
|
||||
import com.v2ray.ang.dto.TestServiceMessage
|
||||
import com.v2ray.ang.service.CoreTestService
|
||||
import com.v2ray.ang.service.SubscriptionUpdateService
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import java.io.Serializable
|
||||
|
||||
object MessageHelper {
|
||||
const val EXTRA_REQUEST_ID = "requestId"
|
||||
|
||||
/**
|
||||
* Sends a message to the service.
|
||||
*
|
||||
* @param ctx The context.
|
||||
* @param what The message identifier.
|
||||
* @param content The message content.
|
||||
*/
|
||||
fun sendMsg2Service(ctx: Context, what: Int, content: Serializable) {
|
||||
sendMsg(ctx, AppConfig.BROADCAST_ACTION_SERVICE, what, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an ordered service message and reports whether a daemon receiver handled it.
|
||||
* With no running daemon, the initial canceled result reaches [onResult] unchanged.
|
||||
*/
|
||||
internal fun sendMsg2ServiceForResult(
|
||||
ctx: Context,
|
||||
what: Int,
|
||||
content: Serializable,
|
||||
onResult: (handled: Boolean) -> Unit,
|
||||
) {
|
||||
val resultReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
onResult(resultCode == Activity.RESULT_OK)
|
||||
}
|
||||
}
|
||||
try {
|
||||
ctx.sendOrderedBroadcast(
|
||||
messageIntent(AppConfig.BROADCAST_ACTION_SERVICE, what, content),
|
||||
null,
|
||||
resultReceiver,
|
||||
null,
|
||||
Activity.RESULT_CANCELED,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to send ordered message to service", e)
|
||||
onResult(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to the UI.
|
||||
*
|
||||
* @param ctx The context.
|
||||
* @param what The message identifier.
|
||||
* @param content The message content.
|
||||
*/
|
||||
fun sendMsg2UI(ctx: Context, what: Int, content: Serializable, requestId: String? = null) {
|
||||
sendMsg(ctx, AppConfig.BROADCAST_ACTION_ACTIVITY, what, content, requestId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to the test service.
|
||||
*
|
||||
* @param ctx The context.
|
||||
* @param message The test service message containing key, subscriptionId, and serverGuids.
|
||||
*/
|
||||
fun sendMsg2TestService(ctx: Context, message: TestServiceMessage, requestId: String? = null) {
|
||||
try {
|
||||
val intent = Intent()
|
||||
intent.component = ComponentName(ctx, CoreTestService::class.java)
|
||||
intent.putExtra("content", message)
|
||||
requestId?.let { intent.putExtra(EXTRA_REQUEST_ID, it) }
|
||||
when (message.key) {
|
||||
AppConfig.MSG_MEASURE_CONFIG_START -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ContextCompat.startForegroundService(ctx, intent)
|
||||
} else {
|
||||
ctx.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
AppConfig.MSG_MEASURE_CONFIG_CANCEL -> {
|
||||
// Do not wake up service just to cancel; stop only if it is already running.
|
||||
ctx.stopService(intent)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ctx.startService(intent)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to send message to test service", e)
|
||||
if (message.key == AppConfig.MSG_MEASURE_CONFIG_START) {
|
||||
sendMsg2UI(ctx, AppConfig.MSG_MEASURE_CONFIG_CANCEL, "", requestId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to the subscription service.
|
||||
*
|
||||
* @param ctx The context.
|
||||
* @param message The subscription service message containing key and subId.
|
||||
*/
|
||||
fun sendMsg2SubscriptionService(ctx: Context, message: SubscriptionUpdateMessage) {
|
||||
try {
|
||||
val intent = Intent()
|
||||
intent.component = ComponentName(ctx, SubscriptionUpdateService::class.java)
|
||||
intent.putExtra("content", message)
|
||||
when (message.key) {
|
||||
AppConfig.MSG_SUB_UPDATE_START -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ContextCompat.startForegroundService(ctx, intent)
|
||||
} else {
|
||||
ctx.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
AppConfig.MSG_SUB_UPDATE_CANCEL -> {
|
||||
ctx.stopService(intent)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ctx.startService(intent)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to send message to subscription service", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message with the specified action.
|
||||
*
|
||||
* @param ctx The context.
|
||||
* @param action The action string.
|
||||
* @param what The message identifier.
|
||||
* @param content The message content.
|
||||
*/
|
||||
private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable, requestId: String? = null) {
|
||||
try {
|
||||
ctx.sendBroadcast(messageIntent(action, what, content).apply {
|
||||
requestId?.let { putExtra(EXTRA_REQUEST_ID, it) }
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to send message with action: $action", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun messageIntent(action: String, what: Int, content: Serializable): Intent =
|
||||
Intent(action).apply {
|
||||
`package` = AppConfig.ANG_PACKAGE
|
||||
putExtra("key", what)
|
||||
putExtra("content", content)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 365 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user