Compare commits
555 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b103e3aec9 | |||
| 19fc65d128 | |||
| 27cafd1052 | |||
| a155b24c1e | |||
| e805fcf088 | |||
| 95e37db42e | |||
| fe812c4456 | |||
| d5af0fe735 | |||
| 9126aa9c9e | |||
| 4a763d2ec5 | |||
| 08d6de4504 | |||
| 629d410189 | |||
| 39d2513df1 | |||
| 3cb4df474b | |||
| b64bb92c8c | |||
| 7f0e2f801d | |||
| 0f7cee0613 | |||
| b2e10fc60b | |||
| aa3f17fb51 | |||
| d64fc627bb | |||
| 6934ab3e5d | |||
| 028d13aef8 | |||
| 4a78c165bc | |||
| c1033a006f | |||
| e8a42013c0 | |||
| 94e5263250 | |||
| 7c68aa9d83 | |||
| ef13e336da | |||
| d16cb21059 | |||
| 237837e7be | |||
| 2a6edd98be | |||
| 9238b80675 | |||
| bb5e6f1959 | |||
| 1cc0865411 | |||
| cb8d979f23 | |||
| 5932b0db29 | |||
| dbe4fe78b5 | |||
| b5bcd81ae4 | |||
| d45ccd7953 | |||
| 6815563902 | |||
| 7c38ad7c57 | |||
| 885aeb384e | |||
| 57f01412e7 | |||
| fd84ea4114 | |||
| a4e2e6d6a2 | |||
| b8cfe9bc82 | |||
| b945f4a3c7 | |||
| cfb6776e8f | |||
| ebaa5088b6 | |||
| 209679f098 | |||
| 19ad248cfe | |||
| 35f1f58476 | |||
| 4c86e7c26e | |||
| 5d0897cd94 | |||
| fc4ab733c8 | |||
| d606453b83 | |||
| c6c8eccd51 | |||
| 03074fba61 | |||
| ac02d23083 | |||
| 5c6aaf285b | |||
| d3f9809486 | |||
| a12909e33d | |||
| 3e823a7440 | |||
| dc1d3a27dd | |||
| d0f1e21f49 | |||
| 3b8615fad0 | |||
| d9d00f4137 | |||
| 2aefbd43eb | |||
| cf33391785 | |||
| a35ba5dfbd | |||
| 3b4d895c1d | |||
| e8585a82c6 | |||
| 087c08f95a | |||
| 6612ca8256 | |||
| 487692deb7 | |||
| 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 | |||
| abd23b3227 | |||
| 7d3e002140 | |||
| 1adb22944a | |||
| f48253371d | |||
| 74f701096e | |||
| 8eca5fe8af | |||
| ff40b53e2f | |||
| 5080d94bb7 | |||
| 24539be29f | |||
| e951e3d913 | |||
| 83e5f1d10a | |||
| 47a6623e62 | |||
| d6e3fc5c91 | |||
| 685d23f2d1 | |||
| d46bbdc219 | |||
| be95e6a88e | |||
| 2435708628 | |||
| 6cf6057dfa | |||
| af92cf5a7d | |||
| 2277de95c0 | |||
| 3c90ee0206 | |||
| 41fd237a87 | |||
| f6bded88dd | |||
| 8600fdba35 | |||
| 2c47abc219 | |||
| 10b0f8c9de | |||
| 53572877fa | |||
| 69e000b23d | |||
| a8e09611eb | |||
| 4fdc46b8ca | |||
| ac043da8a2 | |||
| a192fec127 | |||
| b0b09273cf | |||
| 01a943b7ea | |||
| 06a3738b59 | |||
| fa4432584e | |||
| bb1c3d915f | |||
| d8983a4761 | |||
| 9815041c77 | |||
| 41b0983582 | |||
| bb1f677d45 | |||
| de1c8bcfcc | |||
| ca6e963575 | |||
| bfb6813dc7 | |||
| cc2b4eec65 | |||
| dd11569969 | |||
| 11c3dd97c3 | |||
| 92f15150cd | |||
| f8e809da8b | |||
| 12b9349e62 | |||
| a580494bde | |||
| 66f100ebb3 | |||
| b0213ffdf6 | |||
| 538b2eb0f8 | |||
| f2093c4c52 | |||
| 02ac57a4c6 | |||
| 379266f205 | |||
| 23dbb35da7 | |||
| 22a605ecda | |||
| c7dd45f4f2 | |||
| 609e688d87 | |||
| a2a4ea79a9 | |||
| c08147c362 | |||
| c99fda9839 | |||
| 587d103518 | |||
| 192a4afe6d | |||
| ee69364f2b | |||
| 6be0bd7b21 | |||
| a3972b98f3 | |||
| a2b6e669d8 | |||
| 55c42fbf2a | |||
| ab4af6bd82 | |||
| c3a8ee59dc | |||
| 18f23d9d3b | |||
| 4a3686fb32 | |||
| 68ed08d36e | |||
| d0612619fd | |||
| 3ddd6ee699 | |||
| 485ea2e28d | |||
| 2fd57baa37 | |||
| 73d1dce4db | |||
| bd60224577 | |||
| 3f1b741d9d | |||
| b71614a9cd | |||
| 31d29d97c0 | |||
| 183391b6e3 | |||
| 3ad6464595 | |||
| e684bef35d | |||
| a87d5d375b | |||
| 545683c9ba | |||
| 1a8631257b | |||
| ebebd3277f | |||
| 5242827baf | |||
| c6799d5bf3 | |||
| 054a3bfa5e | |||
| 02a9d64188 | |||
| 29b67c29f5 | |||
| 79d5cad738 | |||
| de5b714a28 | |||
| 416ee28e80 | |||
| 71f0d98e68 | |||
| 37bd57ffc5 | |||
| b4ac79e4fa | |||
| 8aba43d448 | |||
| e564083352 | |||
| 08bf66350a | |||
| 66d4c21b77 | |||
| 359b0af7ae | |||
| 4b1fa29ade | |||
| e287ac88c8 | |||
| 4812f067db | |||
| 47be68c52f | |||
| 418477267b | |||
| 74ad37d329 | |||
| fd83f88ec2 | |||
| 214ab76459 | |||
| f67bee04ad | |||
| 2cfef7a614 | |||
| bd6b67cbfc | |||
| bb374f2c7e | |||
| f85c382dbd | |||
| 0b76ff0409 | |||
| e3bbd47c81 | |||
| 0672820718 | |||
| e0e58dff45 | |||
| cfae051109 | |||
| 60ddf5ba6b | |||
| 861b1746e3 | |||
| 4dec554b42 | |||
| d7ad91fe8b | |||
| fb51683981 | |||
| e5d14bada9 | |||
| 80fb265960 | |||
| f5493b08ae | |||
| 4f7d6db361 | |||
| 636b20290a | |||
| 7ead97079c | |||
| e42cc3a60a | |||
| d231490917 | |||
| 521d61acb3 | |||
| ed9c26b012 | |||
| e782da7c71 | |||
| 6fd0f7f0db | |||
| 7c14a0c200 | |||
| 2789db2c70 | |||
| 8aee96b5eb | |||
| 02d58dde9a | |||
| 623f5446f2 | |||
| b961c86066 | |||
| 3e4f0e9b5b | |||
| c0141225d3 | |||
| 026bf6a37b | |||
| c117a334ce | |||
| 051520de37 | |||
| b5146e4712 | |||
| 4457b6b2c9 | |||
| 12edb051c3 | |||
| 923e3f32d1 | |||
| 5490db90c0 | |||
| cb68d42291 | |||
| ec3a8e80d6 | |||
| eb0960125b | |||
| 12b70e2088 | |||
| 0c473dea19 | |||
| e71dd7342a | |||
| a765215dda | |||
| edea3027f7 | |||
| 305219c3bc | |||
| 9c4188fba7 | |||
| 0f3124bece | |||
| 2614f72a36 | |||
| 480c7fc8a1 | |||
| 302e7f148e | |||
| 98d6da983b | |||
| f6f60f77d3 | |||
| 7be0fb3ecd | |||
| d53a9f9ecd | |||
| 9eb03eece2 | |||
| 6632ba3b6e | |||
| bd582f1d90 | |||
| f5f1e12565 | |||
| 207d6f4f8c | |||
| 52e0a19826 | |||
| fc0e60a097 | |||
| 65d6b4aaa8 | |||
| 7b11755e7f | |||
| 2d0de4860c | |||
| 7406ef16ff | |||
| a084b21d50 | |||
| bf01fe2bdb | |||
| 519cc2a4b5 | |||
| c21653d40f | |||
| 1919c5e05f | |||
| 8a17d93882 | |||
| 21ed008c7b | |||
| d83cfa28c2 | |||
| d27e2091a7 | |||
| c545678e47 | |||
| 05cba9b0fe | |||
| b472aa0e6b | |||
| 1ad840851a | |||
| ffc74f479c | |||
| 81c0087ac1 | |||
| 97327a0101 | |||
| 6318dd554b | |||
| 785aa7eb8a | |||
| 872e926132 | |||
| b278180eb5 | |||
| ce64ffbf3b | |||
| 5ad8605a41 | |||
| 95f7e99752 | |||
| f703a41778 | |||
| 2251c59c0b | |||
| edd0ce96b1 | |||
| 562283b3cc | |||
| bb04953845 | |||
| 208a93a917 | |||
| 92f33ee3f8 | |||
| 084346b348 | |||
| 15de18b736 | |||
| b60423d1c0 | |||
| 86e38c6963 | |||
| 9ba4d7e691 | |||
| 16e72787c9 | |||
| 3ffac8b29f | |||
| 10df1b44ea | |||
| fa12878258 | |||
| 0f1ea1e119 | |||
| 1959608f24 | |||
| 0e041a6e9a | |||
| c78ef380cc | |||
| 57362a4bde | |||
| 7f24ad534f | |||
| 680832614b | |||
| 4357abbff4 | |||
| 905be66c3f | |||
| 318a7b54a5 | |||
| 5db2df77a0 | |||
| d039cb9edf | |||
| 9a1654bae9 | |||
| 3bf911da9c | |||
| 3f778a1ea2 | |||
| 8e03de8055 | |||
| 1f42d7fc07 | |||
| 0700e834f1 | |||
| 777190e861 | |||
| 33572477fc | |||
| 2fb6e62e13 | |||
| 94cc72d2b9 | |||
| f68c353715 | |||
| e077c18108 | |||
| 1a5e105212 | |||
| e0881caab4 | |||
| 7219425258 | |||
| 51eabe5440 | |||
| 6f0b3ce990 | |||
| 69e27ed3bb | |||
| fff6ab30e6 | |||
| fdb67a86f4 | |||
| ea088376ac | |||
| 52332d960e | |||
| 3ead542e2b | |||
| 9d1f98ff34 | |||
| f305e26a39 | |||
| aa47fba20d | |||
| 69c5bbfd3d | |||
| 90ed02804c | |||
| 822c1de79c | |||
| d910b93525 | |||
| 7e6b1c247b | |||
| f3f2b7fab5 | |||
| e6f260da76 | |||
| 55bc2bf934 | |||
| f22454da5d | |||
| 4a87549fa7 | |||
| d447adc97f | |||
| 3773962b64 | |||
| be0a2506ce | |||
| 7f9cb8dfdd | |||
| 71a5b6e480 | |||
| 02e53ced50 | |||
| 42c27a5e7e | |||
| af04bbcf87 | |||
| 9bedfe8a7b | |||
| 2fdf684ee7 | |||
| 5b79951da7 | |||
| 06aa680d45 | |||
| cdb9b1811c | |||
| 0fc1f2f5d3 | |||
| ef1bb3dd34 | |||
| 1bca321d3f | |||
| 247e2b3ba3 | |||
| 41fd2b0cfb | |||
| 72da42ee40 | |||
| c130d55e8f | |||
| 5ae84f7eac | |||
| df5ea251e1 | |||
| 8890d9f004 | |||
| 4fcb3f9d06 | |||
| 5bf7c98cd3 | |||
| 46bc1a49df | |||
| 21175f41ec | |||
| 864c63987e | |||
| 4ac0547e22 | |||
| 12a9ee262c | |||
| cfa9c19c94 | |||
| 56e33e6cdd | |||
| 02421072c1 | |||
| b862a0dc65 | |||
| 1f25d6a000 | |||
| e1def0616a | |||
| 83fd6efc17 | |||
| f0c0e2e83a | |||
| 6ca3eb769e | |||
| 963d24ab66 | |||
| cfd81441fa | |||
| 4084ae2938 | |||
| 3f9bc098ec | |||
| 9cb28ed969 | |||
| 773ddc5373 | |||
| 38193b5621 | |||
| 358713a2a3 | |||
| 5b9f24c1f0 | |||
| c47c2c3666 | |||
| 49f7c3e7d7 | |||
| 423e5de2c6 | |||
| 3e3387e63e | |||
| debddace8b | |||
| 160b412e0a | |||
| 0f3e0a0ea2 | |||
| c4cf90e807 | |||
| 5db46e81b7 | |||
| 1ef80a3a96 | |||
| a46d9d0d2a | |||
| 7b80536e1e | |||
| 5733ecf20e | |||
| eae33b61cf | |||
| 9e55b525f1 | |||
| 678b3cb505 | |||
| b4c833b039 | |||
| 597bd021b8 | |||
| ba03118a43 | |||
| 82148408b0 | |||
| 042900e065 | |||
| 874fccc351 | |||
| 14f36872e7 | |||
| 3b6ad3052a | |||
| 194fc6b6ed | |||
| 0275ad54ac | |||
| 7ca4044467 | |||
| 1672494ee9 | |||
| bbbbc72d22 | |||
| 1e7f49b756 | |||
| ac4c0f7ee1 | |||
| 6cc91b1a89 | |||
| 45facff41d | |||
| ee703e6c95 | |||
| 87213c34a6 | |||
| 73a7c76183 | |||
| ed5282f2b3 | |||
| 390c657047 | |||
| 7071072862 | |||
| d111328541 | |||
| 76cb2aaf46 | |||
| 7ff1397163 | |||
| bcf5d49a3d | |||
| 4fffb17283 | |||
| 83b8bdfdf4 | |||
| cc1538a24d | |||
| eb19199d18 | |||
| 441e5ef8d5 | |||
| d768774aad | |||
| c3d83907a5 | |||
| b52a98ae5e | |||
| a70e4089e3 |
@@ -0,0 +1,6 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
+28
-58
@@ -16,48 +16,48 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
fetch-depth: '0'
|
fetch-depth: '0'
|
||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3.2.0
|
uses: android-actions/setup-android@v4
|
||||||
with:
|
with:
|
||||||
log-accepted-android-sdk-licenses: false
|
log-accepted-android-sdk-licenses: false
|
||||||
cmdline-tools-version: '12266719'
|
cmdline-tools-version: '14742923'
|
||||||
packages: 'platforms;android-35 build-tools;35.0.0 platform-tools'
|
packages: 'platforms;android-37.0 build-tools;37.0.0 platform-tools'
|
||||||
|
|
||||||
- name: Install NDK
|
- name: Install NDK
|
||||||
run: |
|
run: |
|
||||||
echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
||||||
--channel=3 \
|
--channel=0 \
|
||||||
--install "ndk;29.0.13113456"
|
--install "ndk;29.0.14206865"
|
||||||
echo "NDK_HOME=$ANDROID_HOME/ndk/29.0.13113456" >> $GITHUB_ENV
|
echo "NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
|
||||||
sed -i '10i\
|
sed -i '10i\
|
||||||
\
|
\
|
||||||
ndkVersion = "29.0.13113456"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
|
ndkVersion = "29.0.14206865"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
|
||||||
|
|
||||||
- name: Restore cached libtun2socks
|
- name: Restore cached libhevtun
|
||||||
id: cache-libtun2socks-restore
|
id: cache-libhevtun-restore
|
||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ github.workspace }}/libs
|
path: ${{ github.workspace }}/libs
|
||||||
key: libtun2socks-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/badvpn/HEAD') }}-${{ hashFiles('.git/modules/libancillary/HEAD') }}
|
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
|
||||||
|
|
||||||
- name: Build libtun2socks
|
- name: Build libhevtun
|
||||||
if: steps.cache-libtun2socks-restore.outputs.cache-hit != 'true'
|
if: steps.cache-libhevtun-restore.outputs.cache-hit != 'true'
|
||||||
run: |
|
run: |
|
||||||
bash compile-tun2socks.sh
|
bash compile-hevtun.sh
|
||||||
|
|
||||||
- name: Save libtun2socks
|
- name: Save libhevtun
|
||||||
if: steps.cache-libtun2socks-restore.outputs.cache-hit != 'true'
|
if: steps.cache-libhevtun-restore.outputs.cache-hit != 'true'
|
||||||
uses: actions/cache/save@v4
|
uses: actions/cache/save@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ github.workspace }}/libs
|
path: ${{ github.workspace }}/libs
|
||||||
key: libtun2socks-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/badvpn/HEAD') }}-${{ hashFiles('.git/modules/libancillary/HEAD') }}
|
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
|
||||||
|
|
||||||
- name: Copy libtun2socks
|
- name: Copy libhevtun
|
||||||
run: |
|
run: |
|
||||||
cp -r ${{ github.workspace }}/libs ${{ github.workspace }}/V2rayNG/app
|
cp -r ${{ github.workspace }}/libs ${{ github.workspace }}/V2rayNG/app
|
||||||
|
|
||||||
@@ -70,51 +70,21 @@ jobs:
|
|||||||
popd
|
popd
|
||||||
|
|
||||||
- name: Download libv2ray
|
- name: Download libv2ray
|
||||||
uses: robinraju/release-downloader@v1.12
|
uses: robinraju/release-downloader@v1.13
|
||||||
with:
|
with:
|
||||||
repository: '2dust/AndroidLibXrayLite'
|
repository: '2dust/AndroidLibXrayLite'
|
||||||
tag: ${{ env.CURRENT_TAG }}
|
tag: ${{ env.CURRENT_TAG }}
|
||||||
fileName: 'libv2ray.aar'
|
fileName: 'libv2ray.aar'
|
||||||
out-file-path: V2rayNG/app/libs/
|
out-file-path: V2rayNG/app/libs/
|
||||||
|
|
||||||
- name: Restore cached libhysteria2
|
|
||||||
id: cache-libhysteria2-restore
|
|
||||||
uses: actions/cache/restore@v4
|
|
||||||
with:
|
|
||||||
path: ${{ github.workspace }}/hysteria/libs
|
|
||||||
key: libhysteria2-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hysteria/HEAD') }}-${{ hashFiles('libhysteria2.sh') }}
|
|
||||||
|
|
||||||
- name: Setup Golang
|
|
||||||
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
|
|
||||||
uses: actions/setup-go@v5.4.0
|
|
||||||
with:
|
|
||||||
go-version-file: 'AndroidLibXrayLite/go.mod'
|
|
||||||
cache: false
|
|
||||||
|
|
||||||
- name: Build libhysteria2
|
|
||||||
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
bash libhysteria2.sh
|
|
||||||
|
|
||||||
- name: Save libhysteria2
|
|
||||||
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
|
|
||||||
uses: actions/cache/save@v4
|
|
||||||
with:
|
|
||||||
path: ${{ github.workspace }}/hysteria/libs
|
|
||||||
key: libhysteria2-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hysteria/HEAD') }}-${{ hashFiles('libhysteria2.sh') }}
|
|
||||||
|
|
||||||
- name: Copy libhysteria2
|
|
||||||
run: |
|
|
||||||
cp -r ${{ github.workspace }}/hysteria/libs ${{ github.workspace }}/V2rayNG/app
|
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4.7.0
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Decode Keystore
|
- name: Decode Keystore
|
||||||
uses: timheuer/base64-to-file@v1.2.4
|
uses: timheuer/base64-to-file@v2.0.0
|
||||||
id: android_keystore
|
id: android_keystore
|
||||||
with:
|
with:
|
||||||
fileName: "android_keystore.jks"
|
fileName: "android_keystore.jks"
|
||||||
@@ -129,21 +99,21 @@ jobs:
|
|||||||
./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }}
|
./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }}
|
||||||
|
|
||||||
- name: Upload arm64-v8a APK
|
- name: Upload arm64-v8a APK
|
||||||
uses: actions/upload-artifact@v4.6.2
|
uses: actions/upload-artifact@v7
|
||||||
if: ${{ success() }}
|
if: ${{ success() }}
|
||||||
with:
|
with:
|
||||||
name: arm64-v8a
|
name: arm64-v8a
|
||||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk
|
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk
|
||||||
|
|
||||||
- name: Upload armeabi-v7a APK
|
- name: Upload armeabi-v7a APK
|
||||||
uses: actions/upload-artifact@v4.6.2
|
uses: actions/upload-artifact@v7
|
||||||
if: ${{ success() }}
|
if: ${{ success() }}
|
||||||
with:
|
with:
|
||||||
name: armeabi-v7a
|
name: armeabi-v7a
|
||||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk
|
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk
|
||||||
|
|
||||||
- name: Upload x86 APK
|
- name: Upload x86 APK
|
||||||
uses: actions/upload-artifact@v4.6.2
|
uses: actions/upload-artifact@v7
|
||||||
if: ${{ success() }}
|
if: ${{ success() }}
|
||||||
with:
|
with:
|
||||||
name: x86-apk
|
name: x86-apk
|
||||||
@@ -153,7 +123,7 @@ jobs:
|
|||||||
uses: svenstaro/upload-release-action@v2
|
uses: svenstaro/upload-release-action@v2
|
||||||
if: github.event.inputs.release_tag != ''
|
if: github.event.inputs.release_tag != ''
|
||||||
with:
|
with:
|
||||||
file: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*playstore*/release/*.apk
|
file: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*.apk
|
||||||
tag: ${{ github.event.inputs.release_tag }}
|
tag: ${{ github.event.inputs.release_tag }}
|
||||||
file_glob: true
|
file_glob: true
|
||||||
prerelease: true
|
prerelease: true
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ jobs:
|
|||||||
go:
|
go:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Validate Fastlane Supply Metadata
|
- name: Validate Fastlane Supply Metadata
|
||||||
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.0.0
|
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0
|
||||||
|
|||||||
+60
@@ -1,6 +1,66 @@
|
|||||||
|
# Ignore data and key store files
|
||||||
*.dat
|
*.dat
|
||||||
*.jks
|
*.jks
|
||||||
|
|
||||||
|
# Ignore output JSON file
|
||||||
V2rayNG/app/release/output.json
|
V2rayNG/app/release/output.json
|
||||||
|
|
||||||
|
# Ignore IDE and build system directories
|
||||||
.idea/
|
.idea/
|
||||||
.gradle/
|
.gradle/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# Ignore local properties and DS_Store files
|
||||||
|
/local.properties
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Ignore build directories and captures
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
V2rayNG/app/build
|
||||||
|
V2rayNG/build
|
||||||
|
V2rayNG/local.properties
|
||||||
|
|
||||||
|
# Ignore APK and AAR files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
|
||||||
|
# Ignore signing properties
|
||||||
|
signing.properties
|
||||||
|
|
||||||
|
# Ignore shared object files
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
|
# Ignore Google services JSON
|
||||||
|
V2rayNG/app/google-services.json
|
||||||
|
|
||||||
|
# Additional common Android/Java ignores
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
*.swp
|
||||||
|
*.orig
|
||||||
|
*.class
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
*.ear
|
||||||
|
|
||||||
|
# Ignore executable files
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.obj
|
||||||
|
*.o
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# Ignore files from other IDEs
|
||||||
|
.vscode/
|
||||||
|
.classpath
|
||||||
|
.project
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
*.sublime-project
|
||||||
|
|
||||||
|
# Ignore OS-specific files
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|||||||
+3
-9
@@ -1,12 +1,6 @@
|
|||||||
[submodule "hysteria"]
|
|
||||||
path = hysteria
|
|
||||||
url = https://github.com/apernet/hysteria
|
|
||||||
[submodule "AndroidLibXrayLite"]
|
[submodule "AndroidLibXrayLite"]
|
||||||
path = AndroidLibXrayLite
|
path = AndroidLibXrayLite
|
||||||
url = https://github.com/2dust/AndroidLibXrayLite
|
url = https://github.com/2dust/AndroidLibXrayLite
|
||||||
[submodule "badvpn"]
|
[submodule "hev-socks5-tunnel"]
|
||||||
path = badvpn
|
path = hev-socks5-tunnel
|
||||||
url = https://github.com/XTLS/badvpn
|
url = https://github.com/heiher/hev-socks5-tunnel
|
||||||
[submodule "libancillary"]
|
|
||||||
path = libancillary
|
|
||||||
url = https://github.com/shadowsocks/libancillary
|
|
||||||
|
|||||||
+1
-1
Submodule AndroidLibXrayLite updated: c8a6ca7c5e...e194d22cf1
@@ -2,17 +2,13 @@
|
|||||||
|
|
||||||
A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-core) and [v2fly core](https://github.com/v2fly/v2ray-core)
|
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://developer.android.com/about/versions/lollipop)
|
||||||
[](https://kotlinlang.org)
|
[](https://kotlinlang.org)
|
||||||
[](https://github.com/2dust/v2rayNG/commits/master)
|
[](https://github.com/2dust/v2rayNG/commits/master)
|
||||||
[](https://www.codefactor.io/repository/github/2dust/v2rayng)
|
[](https://www.codefactor.io/repository/github/2dust/v2rayng)
|
||||||
[](https://github.com/2dust/v2rayNG/releases)
|
[](https://github.com/2dust/v2rayNG/releases)
|
||||||
[](https://t.me/v2rayn)
|
[](https://t.me/v2rayn)
|
||||||
|
|
||||||
<a href="https://play.google.com/store/apps/details?id=com.v2ray.ang">
|
|
||||||
<img alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png" width="165" height="64" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
### Telegram Channel
|
### Telegram Channel
|
||||||
[github_2dust](https://t.me/github_2dust)
|
[github_2dust](https://t.me/github_2dust)
|
||||||
|
|
||||||
@@ -21,7 +17,7 @@ A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-cor
|
|||||||
#### Geoip and Geosite
|
#### Geoip and Geosite
|
||||||
- geoip.dat and geosite.dat files are in `Android/data/com.v2ray.ang/files/assets` (path may differ on some Android device)
|
- 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 need a working proxy)
|
||||||
- latest official [domain list](https://github.com/v2fly/domain-list-community) and [ip list](https://github.com/v2fly/geoip) can be imported manually
|
- latest official [domain list](https://github.com/Loyalsoldier/v2ray-rules-dat) and [ip list](https://github.com/Loyalsoldier/geoip) can be imported manually
|
||||||
- possible to use third party dat file in the same folder, like [h2y](https://guide.v2fly.org/routing/sitedata.html#%E5%A4%96%E7%BD%AE%E7%9A%84%E5%9F%9F%E5%90%8D%E6%96%87%E4%BB%B6)
|
- possible to use third party dat file in the same folder, like [h2y](https://guide.v2fly.org/routing/sitedata.html#%E5%A4%96%E7%BD%AE%E7%9A%84%E5%9F%9F%E5%90%8D%E6%96%87%E4%BB%B6)
|
||||||
|
|
||||||
### More in our [wiki](https://github.com/2dust/v2rayNG/wiki)
|
### More in our [wiki](https://github.com/2dust/v2rayNG/wiki)
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
*.iml
|
|
||||||
.gradle
|
|
||||||
/local.properties
|
|
||||||
/.idea
|
|
||||||
.DS_Store
|
|
||||||
/build
|
|
||||||
/captures
|
|
||||||
*.apk
|
|
||||||
signing.properties
|
|
||||||
*.aar
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
/build
|
|
||||||
/google-services.json
|
|
||||||
@@ -6,14 +6,14 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.v2ray.ang"
|
namespace = "com.v2ray.ang"
|
||||||
compileSdk = 35
|
compileSdk = 37
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.v2ray.ang"
|
applicationId = "com.v2ray.ang"
|
||||||
minSdk = 21
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 37
|
||||||
versionCode = 642
|
versionCode = 733
|
||||||
versionName = "1.9.42"
|
versionName = "2.2.3"
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
|
|
||||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||||
@@ -67,14 +67,16 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applicationVariants.all {
|
applicationVariants.all {
|
||||||
@@ -82,8 +84,9 @@ android {
|
|||||||
val isFdroid = variant.productFlavors.any { it.name == "fdroid" }
|
val isFdroid = variant.productFlavors.any { it.name == "fdroid" }
|
||||||
if (isFdroid) {
|
if (isFdroid) {
|
||||||
val versionCodes =
|
val versionCodes =
|
||||||
mapOf("armeabi-v7a" to 2, "arm64-v8a" to 1, "x86" to 4, "x86_64" to 3, "universal" to 0
|
mapOf(
|
||||||
)
|
"armeabi-v7a" to 2, "arm64-v8a" to 1, "x86" to 4, "x86_64" to 3, "universal" to 0
|
||||||
|
)
|
||||||
|
|
||||||
variant.outputs
|
variant.outputs
|
||||||
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
|
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
|
||||||
@@ -145,16 +148,19 @@ dependencies {
|
|||||||
implementation(libs.preference.ktx)
|
implementation(libs.preference.ktx)
|
||||||
implementation(libs.recyclerview)
|
implementation(libs.recyclerview)
|
||||||
implementation(libs.androidx.swiperefreshlayout)
|
implementation(libs.androidx.swiperefreshlayout)
|
||||||
|
implementation(libs.androidx.viewpager2)
|
||||||
|
implementation(libs.androidx.fragment)
|
||||||
|
|
||||||
// UI Libraries
|
// UI Libraries
|
||||||
implementation(libs.material)
|
implementation(libs.material)
|
||||||
implementation(libs.toastcompat)
|
implementation(libs.toasty)
|
||||||
implementation(libs.editorkit)
|
implementation(libs.editorkit)
|
||||||
implementation(libs.flexbox)
|
implementation(libs.flexbox)
|
||||||
|
|
||||||
// Data and Storage Libraries
|
// Data and Storage Libraries
|
||||||
implementation(libs.mmkv.static)
|
implementation(libs.mmkv.static)
|
||||||
implementation(libs.gson)
|
implementation(libs.gson)
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
|
||||||
// Reactive and Utility Libraries
|
// Reactive and Utility Libraries
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name" translatable="false">v2rayNG (F-Droid)</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<shortcut
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@drawable/ic_qu_switch_24dp"
|
||||||
|
android:shortcutDisabledMessage="@string/app_widget_name"
|
||||||
|
android:shortcutId="shortcuts_switch"
|
||||||
|
android:shortcutLongLabel="@string/app_widget_name"
|
||||||
|
android:shortcutShortLabel="@string/app_widget_name">
|
||||||
|
|
||||||
|
<!--suppress AndroidDomInspection -->
|
||||||
|
<intent
|
||||||
|
android:action="android.intent.action.VIEW"
|
||||||
|
android:targetClass="com.v2ray.ang.ui.ScSwitchActivity"
|
||||||
|
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||||
|
<categories android:name="android.shortcut.conversation" />
|
||||||
|
</shortcut>
|
||||||
|
<shortcut
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@drawable/ic_qu_scan_24dp"
|
||||||
|
android:shortcutDisabledMessage="@string/menu_item_import_config_qrcode"
|
||||||
|
android:shortcutId="shortcuts_scan"
|
||||||
|
android:shortcutLongLabel="@string/menu_item_import_config_qrcode"
|
||||||
|
android:shortcutShortLabel="@string/menu_item_import_config_qrcode">
|
||||||
|
|
||||||
|
<!--suppress AndroidDomInspection -->
|
||||||
|
<intent
|
||||||
|
android:action="android.intent.action.VIEW"
|
||||||
|
android:targetClass="com.v2ray.ang.ui.ScScannerActivity"
|
||||||
|
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||||
|
<categories android:name="android.shortcut.conversation" />
|
||||||
|
</shortcut>
|
||||||
|
<shortcut
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@drawable/ic_qu_start_24dp"
|
||||||
|
android:shortcutDisabledMessage="@string/toast_services_start"
|
||||||
|
android:shortcutId="shortcuts_start"
|
||||||
|
android:shortcutLongLabel="@string/toast_services_start"
|
||||||
|
android:shortcutShortLabel="@string/toast_services_start">
|
||||||
|
|
||||||
|
<!--suppress AndroidDomInspection -->
|
||||||
|
<intent
|
||||||
|
android:action="android.intent.action.VIEW"
|
||||||
|
android:targetClass="com.v2ray.ang.ui.ScStartActivity"
|
||||||
|
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||||
|
<categories android:name="android.shortcut.conversation" />
|
||||||
|
</shortcut>
|
||||||
|
<shortcut
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@drawable/ic_qu_stop_24dp"
|
||||||
|
android:shortcutDisabledMessage="@string/toast_services_stop"
|
||||||
|
android:shortcutId="shortcuts_stop"
|
||||||
|
android:shortcutLongLabel="@string/toast_services_stop"
|
||||||
|
android:shortcutShortLabel="@string/toast_services_stop">
|
||||||
|
|
||||||
|
<!--suppress AndroidDomInspection -->
|
||||||
|
<intent
|
||||||
|
android:action="android.intent.action.VIEW"
|
||||||
|
android:targetClass="com.v2ray.ang.ui.ScStopActivity"
|
||||||
|
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||||
|
<categories android:name="android.shortcut.conversation" />
|
||||||
|
</shortcut>
|
||||||
|
</shortcuts>
|
||||||
@@ -10,10 +10,6 @@
|
|||||||
android:smallScreens="true"
|
android:smallScreens="true"
|
||||||
android:xlargeScreens="true" />
|
android:xlargeScreens="true" />
|
||||||
|
|
||||||
<uses-sdk
|
|
||||||
android:minSdkVersion="21"
|
|
||||||
tools:overrideLibrary="com.blacksquircle.ui.editorkit" />
|
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.camera"
|
android:name="android.hardware.camera"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
@@ -30,20 +26,18 @@
|
|||||||
<!-- https://developer.android.com/about/versions/11/privacy/package-visibility -->
|
<!-- https://developer.android.com/about/versions/11/privacy/package-visibility -->
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
tools:ignore="QueryAllPackagesPermission" />
|
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
|
||||||
<!-- <useapplications-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> -->
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"
|
||||||
|
tools:ignore="ForegroundServicesPolicy" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
|
android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
|
||||||
android:minSdkVersion="34" />
|
android:minSdkVersion="34" />
|
||||||
<!-- <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> -->
|
<!-- <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
@@ -52,17 +46,15 @@
|
|||||||
android:banner="@mipmap/ic_banner"
|
android:banner="@mipmap/ic_banner"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
|
||||||
android:theme="@style/AppThemeDayNight"
|
|
||||||
android:usesCleartextTraffic="true"
|
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
tools:targetApi="m">
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppThemeDayNight.NoActionBar"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.MainActivity"
|
android:name=".ui.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask">
|
||||||
android:theme="@style/AppThemeDayNight.NoActionBar">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
@@ -85,12 +77,23 @@
|
|||||||
android:name=".ui.ServerCustomConfigActivity"
|
android:name=".ui.ServerCustomConfigActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="stateUnchanged" />
|
android:windowSoftInputMode="stateUnchanged" />
|
||||||
|
<activity
|
||||||
|
android:name=".ui.ServerGroupActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:windowSoftInputMode="stateUnchanged" />
|
||||||
|
<activity
|
||||||
|
android:name=".ui.ServerProxyChainActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:windowSoftInputMode="stateUnchanged" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.SettingsActivity"
|
android:name=".ui.SettingsActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.PerAppProxyActivity"
|
android:name=".ui.PerAppProxyActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
<activity
|
||||||
|
android:name=".ui.AppPickerActivity"
|
||||||
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.ScannerActivity"
|
android:name=".ui.ScannerActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
@@ -124,7 +127,19 @@
|
|||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:process=":RunSoLibV2RayDaemon"
|
android:process=":RunSoLibV2RayDaemon"
|
||||||
android:theme="@style/AppTheme.NoActionBar.Translucent" />
|
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||||
|
<activity
|
||||||
|
android:name=".ui.ScStartActivity"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:process=":RunSoLibV2RayDaemon"
|
||||||
|
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||||
|
<activity
|
||||||
|
android:name=".ui.ScStopActivity"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:process=":RunSoLibV2RayDaemon"
|
||||||
|
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.UrlSchemeActivity"
|
android:name=".ui.UrlSchemeActivity"
|
||||||
@@ -145,18 +160,25 @@
|
|||||||
<data android:host="install-sub" />
|
<data android:host="install-sub" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".ui.CheckUpdateActivity"
|
||||||
|
android:exported="false" />
|
||||||
|
<activity
|
||||||
|
android:name=".ui.BackupActivity"
|
||||||
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.AboutActivity"
|
android:name=".ui.AboutActivity"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".service.V2RayVpnService"
|
android:name=".service.CoreVpnService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="specialUse"
|
android:foregroundServiceType="specialUse"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||||
android:process=":RunSoLibV2RayDaemon">
|
android:process=":RunSoLibV2RayDaemon"
|
||||||
|
tools:ignore="VpnServicePolicy">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.net.VpnService" />
|
<action android:name="android.net.VpnService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
@@ -169,7 +191,7 @@
|
|||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".service.V2RayProxyOnlyService"
|
android:name=".service.CoreProxyOnlyService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="specialUse"
|
android:foregroundServiceType="specialUse"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -180,9 +202,14 @@
|
|||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".service.V2RayTestService"
|
android:name=".service.CoreTestService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:process=":RunSoLibV2RayDaemon" />
|
android:foregroundServiceType="specialUse"
|
||||||
|
android:process=":RunSoLibV2RayDaemon">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="test" />
|
||||||
|
</service>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".receiver.WidgetProvider"
|
android:name=".receiver.WidgetProvider"
|
||||||
@@ -193,8 +220,8 @@
|
|||||||
android:resource="@xml/app_widget_provider" />
|
android:resource="@xml/app_widget_provider" />
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
<action android:name="com.v2ray.ang.action.widget.click" />
|
<action android:name="${applicationId}.action.widget.click" />
|
||||||
<action android:name="com.v2ray.ang.action.activity" />
|
<action android:name="${applicationId}.action.activity" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver
|
<receiver
|
||||||
@@ -254,6 +281,11 @@
|
|||||||
|
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="androidx.work.multiprocess.RemoteWorkManagerService"
|
||||||
|
android:exported="false"
|
||||||
|
android:process=":bg" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.cache"
|
android:authorities="${applicationId}.cache"
|
||||||
|
|||||||
@@ -6,14 +6,6 @@
|
|||||||
"bittorrent"
|
"bittorrent"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"remarks": "Google cn",
|
|
||||||
"outboundTag": "proxy",
|
|
||||||
"domain": [
|
|
||||||
"domain:googleapis.cn",
|
|
||||||
"domain:gstatic.com"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"remarks": "阻断udp443",
|
"remarks": "阻断udp443",
|
||||||
"outboundTag": "block",
|
"outboundTag": "block",
|
||||||
@@ -21,10 +13,10 @@
|
|||||||
"network": "udp"
|
"network": "udp"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"remarks": "阻断广告",
|
"remarks": "代理Google",
|
||||||
"outboundTag": "block",
|
"outboundTag": "proxy",
|
||||||
"domain": [
|
"domain": [
|
||||||
"geosite:category-ads-all"
|
"geosite:google"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -146,4 +138,4 @@
|
|||||||
"port": "0-65535",
|
"port": "0-65535",
|
||||||
"outboundTag": "direct"
|
"outboundTag": "direct"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,13 +5,6 @@
|
|||||||
"port": "443",
|
"port": "443",
|
||||||
"network": "udp"
|
"network": "udp"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"remarks": "阻断广告",
|
|
||||||
"outboundTag": "block",
|
|
||||||
"domain": [
|
|
||||||
"geosite:category-ads-all"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"remarks": "绕过局域网IP",
|
"remarks": "绕过局域网IP",
|
||||||
"outboundTag": "direct",
|
"outboundTag": "direct",
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
[
|
[
|
||||||
{
|
|
||||||
"remarks": "Google cn",
|
|
||||||
"outboundTag": "proxy",
|
|
||||||
"domain": [
|
|
||||||
"domain:googleapis.cn",
|
|
||||||
"domain:gstatic.com"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"remarks": "阻断udp443",
|
"remarks": "阻断udp443",
|
||||||
"outboundTag": "block",
|
"outboundTag": "block",
|
||||||
@@ -14,10 +6,10 @@
|
|||||||
"network": "udp"
|
"network": "udp"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"remarks": "阻断广告",
|
"remarks": "代理Google",
|
||||||
"outboundTag": "block",
|
"outboundTag": "proxy",
|
||||||
"domain": [
|
"domain": [
|
||||||
"geosite:category-ads-all"
|
"geosite:google"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -100,4 +92,4 @@
|
|||||||
"geosite:cn"
|
"geosite:cn"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,13 +5,6 @@
|
|||||||
"port": "443",
|
"port": "443",
|
||||||
"network": "udp"
|
"network": "udp"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"remarks": "Block ads and trackers",
|
|
||||||
"outboundTag": "block",
|
|
||||||
"domain": [
|
|
||||||
"geosite:category-ads-all"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"remarks": "Direct LAN IP",
|
"remarks": "Direct LAN IP",
|
||||||
"outboundTag": "direct",
|
"outboundTag": "direct",
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"remarks": "Bypass bittorrent",
|
||||||
|
"outboundTag": "direct",
|
||||||
|
"protocol": [
|
||||||
|
"bittorrent"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"remarks": "Block udp443",
|
||||||
|
"outboundTag": "block",
|
||||||
|
"port": "443",
|
||||||
|
"network": "udp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"remarks": "Direct LAN IP",
|
||||||
|
"outboundTag": "direct",
|
||||||
|
"ip": [
|
||||||
|
"geoip:private"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"remarks": "Direct LAN domains",
|
||||||
|
"outboundTag": "direct",
|
||||||
|
"domain": [
|
||||||
|
"geosite:private"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"remarks": "Bypass Russia domains",
|
||||||
|
"outboundTag": "direct",
|
||||||
|
"domain": [
|
||||||
|
"geosite:category-ru"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"remarks": "Bypass Russia IP",
|
||||||
|
"outboundTag": "direct",
|
||||||
|
"ip": [
|
||||||
|
"geoip:ru"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -30,17 +30,10 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"destOverride": [
|
"destOverride": [
|
||||||
"http",
|
"http",
|
||||||
"tls"
|
"tls",
|
||||||
|
"quic"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "http",
|
|
||||||
"port": 10809,
|
|
||||||
"protocol": "http",
|
|
||||||
"settings": {
|
|
||||||
"userLevel": 8
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outbounds": [{
|
"outbounds": [{
|
||||||
@@ -81,8 +74,10 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"protocol": "freedom",
|
"protocol": "freedom",
|
||||||
"settings": {
|
"streamSettings": {
|
||||||
"domainStrategy": "UseIP"
|
"sockopt": {
|
||||||
|
"domainStrategy": "UseIP"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"tag": "direct"
|
"tag": "direct"
|
||||||
},
|
},
|
||||||
@@ -97,7 +92,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"routing": {
|
"routing": {
|
||||||
"domainStrategy": "IPIfNonMatch",
|
"domainStrategy": "AsIs",
|
||||||
"rules": []
|
"rules": []
|
||||||
},
|
},
|
||||||
"dns": {
|
"dns": {
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
{
|
||||||
|
"stats":{},
|
||||||
|
"log": {
|
||||||
|
"loglevel": "warning"
|
||||||
|
},
|
||||||
|
"policy":{
|
||||||
|
"levels": {
|
||||||
|
"8": {
|
||||||
|
"handshake": 4,
|
||||||
|
"connIdle": 300,
|
||||||
|
"uplinkOnly": 1,
|
||||||
|
"downlinkOnly": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"system": {
|
||||||
|
"statsOutboundUplink": true,
|
||||||
|
"statsOutboundDownlink": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inbounds": [{
|
||||||
|
"tag": "socks",
|
||||||
|
"port": 10808,
|
||||||
|
"protocol": "socks",
|
||||||
|
"settings": {
|
||||||
|
"auth": "noauth",
|
||||||
|
"udp": true,
|
||||||
|
"userLevel": 8
|
||||||
|
},
|
||||||
|
"sniffing": {
|
||||||
|
"enabled": true,
|
||||||
|
"destOverride": [
|
||||||
|
"http",
|
||||||
|
"tls",
|
||||||
|
"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": {
|
||||||
|
"vnext": [
|
||||||
|
{
|
||||||
|
"address": "v2ray.cool",
|
||||||
|
"port": 10086,
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"id": "a3482e88-686a-4a58-8126-99c9df64b7bf",
|
||||||
|
"alterId": 0,
|
||||||
|
"security": "auto",
|
||||||
|
"level": 8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"address": "v2ray.cool",
|
||||||
|
"method": "chacha20",
|
||||||
|
"ota": false,
|
||||||
|
"password": "123456",
|
||||||
|
"port": 10086,
|
||||||
|
"level": 8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"streamSettings": {
|
||||||
|
"network": "tcp"
|
||||||
|
},
|
||||||
|
"mux": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"protocol": "freedom",
|
||||||
|
"streamSettings": {
|
||||||
|
"sockopt": {
|
||||||
|
"domainStrategy": "UseIP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tag": "direct"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"protocol": "blackhole",
|
||||||
|
"tag": "block",
|
||||||
|
"settings": {
|
||||||
|
"response": {
|
||||||
|
"type": "http"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routing": {
|
||||||
|
"domainStrategy": "AsIs",
|
||||||
|
"rules": []
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"hosts": {},
|
||||||
|
"servers": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,10 +34,15 @@ class AngApplication : MultiDexApplication() {
|
|||||||
|
|
||||||
MMKV.initialize(this)
|
MMKV.initialize(this)
|
||||||
|
|
||||||
SettingsManager.setNightMode()
|
|
||||||
// Initialize WorkManager with the custom configuration
|
// Initialize WorkManager with the custom configuration
|
||||||
WorkManager.initialize(this, workManagerConfiguration)
|
WorkManager.initialize(this, workManagerConfiguration)
|
||||||
|
|
||||||
SettingsManager.initRoutingRulesets(this)
|
// Ensure critical preference defaults are present in MMKV early
|
||||||
|
SettingsManager.initApp(this)
|
||||||
|
SettingsManager.setNightMode()
|
||||||
|
|
||||||
|
es.dmoral.toasty.Toasty.Config.getInstance()
|
||||||
|
.setGravity(android.view.Gravity.BOTTOM, 0, 300)
|
||||||
|
.apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,20 @@ object AppConfig {
|
|||||||
|
|
||||||
/** The application's package name. */
|
/** The application's package name. */
|
||||||
const val ANG_PACKAGE = BuildConfig.APPLICATION_ID
|
const val ANG_PACKAGE = BuildConfig.APPLICATION_ID
|
||||||
|
const val TAG = BuildConfig.APPLICATION_ID
|
||||||
|
|
||||||
/** Directory names used in the app's file system. */
|
/** Directory names used in the app's file system. */
|
||||||
const val DIR_ASSETS = "assets"
|
const val DIR_ASSETS = "assets"
|
||||||
const val DIR_BACKUPS = "backups"
|
|
||||||
|
const val WEBDAV_BACKUP_DIR = "backups"
|
||||||
|
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
|
||||||
|
|
||||||
/** Legacy configuration keys. */
|
/** Legacy configuration keys. */
|
||||||
const val ANG_CONFIG = "ang_config"
|
const val ANG_CONFIG = "ang_config"
|
||||||
|
|
||||||
|
// Default subscription ID for ungrouped servers
|
||||||
|
const val DEFAULT_SUBSCRIPTION_ID = "__default_subscription__"
|
||||||
|
|
||||||
/** Preferences mapped to MMKV storage. */
|
/** Preferences mapped to MMKV storage. */
|
||||||
const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
|
const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
|
||||||
const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled"
|
const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled"
|
||||||
@@ -25,6 +31,8 @@ object AppConfig {
|
|||||||
const val PREF_LOCAL_DNS_PORT = "pref_local_dns_port"
|
const val PREF_LOCAL_DNS_PORT = "pref_local_dns_port"
|
||||||
const val PREF_VPN_DNS = "pref_vpn_dns"
|
const val PREF_VPN_DNS = "pref_vpn_dns"
|
||||||
const val PREF_VPN_BYPASS_LAN = "pref_vpn_bypass_lan"
|
const val PREF_VPN_BYPASS_LAN = "pref_vpn_bypass_lan"
|
||||||
|
const val PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX = "pref_vpn_interface_address_config_index"
|
||||||
|
const val PREF_VPN_MTU = "pref_vpn_mtu"
|
||||||
const val PREF_ROUTING_DOMAIN_STRATEGY = "pref_routing_domain_strategy"
|
const val PREF_ROUTING_DOMAIN_STRATEGY = "pref_routing_domain_strategy"
|
||||||
const val PREF_ROUTING_RULESET = "pref_routing_ruleset"
|
const val PREF_ROUTING_RULESET = "pref_routing_ruleset"
|
||||||
const val PREF_MUX_ENABLED = "pref_mux_enabled"
|
const val PREF_MUX_ENABLED = "pref_mux_enabled"
|
||||||
@@ -35,39 +43,52 @@ object AppConfig {
|
|||||||
const val PREF_FRAGMENT_PACKETS = "pref_fragment_packets"
|
const val PREF_FRAGMENT_PACKETS = "pref_fragment_packets"
|
||||||
const val PREF_FRAGMENT_LENGTH = "pref_fragment_length"
|
const val PREF_FRAGMENT_LENGTH = "pref_fragment_length"
|
||||||
const val PREF_FRAGMENT_INTERVAL = "pref_fragment_interval"
|
const val PREF_FRAGMENT_INTERVAL = "pref_fragment_interval"
|
||||||
const val SUBSCRIPTION_AUTO_UPDATE = "pref_auto_update_subscription"
|
|
||||||
const val SUBSCRIPTION_AUTO_UPDATE_INTERVAL = "pref_auto_update_interval"
|
|
||||||
const val SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL = "1440" // Default is 24 hours
|
|
||||||
const val SUBSCRIPTION_UPDATE_TASK_NAME = "subscription_updater"
|
const val SUBSCRIPTION_UPDATE_TASK_NAME = "subscription_updater"
|
||||||
|
const val SUBSCRIPTION_MIN_INTERVAL_MINUTES = 15L
|
||||||
const val PREF_SPEED_ENABLED = "pref_speed_enabled"
|
const val PREF_SPEED_ENABLED = "pref_speed_enabled"
|
||||||
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
|
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
|
||||||
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
|
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
|
||||||
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
|
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
|
||||||
|
const val PREF_GROUP_ALL_DISPLAY = "pref_group_all_display"
|
||||||
const val PREF_LANGUAGE = "pref_language"
|
const val PREF_LANGUAGE = "pref_language"
|
||||||
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
|
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
|
||||||
|
const val PREF_IPV6_ENABLED = "pref_ipv6_enabled"
|
||||||
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
|
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
|
||||||
const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
|
const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
|
||||||
const val PREF_ALLOW_INSECURE = "pref_allow_insecure"
|
const val PREF_ENABLE_LOCAL_PROXY = "pref_enable_local_proxy"
|
||||||
const val PREF_SOCKS_PORT = "pref_socks_port"
|
const val PREF_SOCKS_PORT = "pref_socks_port"
|
||||||
|
const val PREF_DYNAMIC_SOCKS_PORT = "pref_dynamic_socks_port"
|
||||||
|
const val PREF_SOCKS_USERNAME = "pref_socks_username"
|
||||||
|
const val PREF_SOCKS_PASSWORD = "pref_socks_password"
|
||||||
|
const val PREF_SOCKS_ENABLE_UDP = "pref_socks_enable_udp"
|
||||||
const val PREF_REMOTE_DNS = "pref_remote_dns"
|
const val PREF_REMOTE_DNS = "pref_remote_dns"
|
||||||
const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
|
const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
|
||||||
const val PREF_DNS_HOSTS = "pref_dns_hosts"
|
const val PREF_DNS_HOSTS = "pref_dns_hosts"
|
||||||
const val PREF_DELAY_TEST_URL = "pref_delay_test_url"
|
const val PREF_DELAY_TEST_URL = "pref_delay_test_url"
|
||||||
|
const val PREF_IP_API_URL = "pref_ip_api_url"
|
||||||
const val PREF_LOGLEVEL = "pref_core_loglevel"
|
const val PREF_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_MODE = "pref_mode"
|
||||||
const val PREF_IS_BOOTED = "pref_is_booted"
|
const val PREF_IS_BOOTED = "pref_is_booted"
|
||||||
|
const val PREF_CHECK_UPDATE_PRE_RELEASE = "pref_check_update_pre_release"
|
||||||
|
const val PREF_GEO_FILES_SOURCES = "pref_geo_files_sources"
|
||||||
|
const val PREF_USE_HEV_TUNNEL = "pref_use_hev_tunnel_v2"
|
||||||
|
const val PREF_HEV_TUNNEL_LOGLEVEL = "pref_hev_tunnel_loglevel"
|
||||||
|
const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout_v2"
|
||||||
|
const val PREF_AUTO_REMOVE_INVALID_AFTER_TEST = "pref_auto_remove_invalid_after_test"
|
||||||
|
const val PREF_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
|
||||||
|
const val PREF_REAL_PING_CONCURRENCY = "pref_real_ping_concurrency"
|
||||||
|
|
||||||
/** Cache keys. */
|
/** Cache keys. */
|
||||||
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
|
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
|
||||||
const val CACHE_KEYWORD_FILTER = "cache_keyword_filter"
|
|
||||||
|
|
||||||
/** Protocol identifiers. */
|
/** Protocol identifiers. */
|
||||||
const val PROTOCOL_FREEDOM: String = "freedom"
|
const val PROTOCOL_FREEDOM = "freedom"
|
||||||
|
|
||||||
/** Broadcast actions. */
|
/** Broadcast actions. */
|
||||||
const val BROADCAST_ACTION_SERVICE = "com.v2ray.ang.action.service"
|
const val BROADCAST_ACTION_SERVICE = "$ANG_PACKAGE.action.service"
|
||||||
const val BROADCAST_ACTION_ACTIVITY = "com.v2ray.ang.action.activity"
|
const val BROADCAST_ACTION_ACTIVITY = "$ANG_PACKAGE.action.activity"
|
||||||
const val BROADCAST_ACTION_WIDGET_CLICK = "com.v2ray.ang.action.widget.click"
|
const val BROADCAST_ACTION_WIDGET_CLICK = "$ANG_PACKAGE.action.widget.click"
|
||||||
|
|
||||||
/** Tasker extras. */
|
/** Tasker extras. */
|
||||||
const val TASKER_EXTRA_BUNDLE = "com.twofortyfouram.locale.intent.extra.BUNDLE"
|
const val TASKER_EXTRA_BUNDLE = "com.twofortyfouram.locale.intent.extra.BUNDLE"
|
||||||
@@ -81,25 +102,32 @@ object AppConfig {
|
|||||||
const val TAG_DIRECT = "direct"
|
const val TAG_DIRECT = "direct"
|
||||||
const val TAG_BLOCKED = "block"
|
const val TAG_BLOCKED = "block"
|
||||||
const val TAG_FRAGMENT = "fragment"
|
const val TAG_FRAGMENT = "fragment"
|
||||||
|
const val TAG_DNS = "dns-module"
|
||||||
|
const val TAG_DOMESTIC_DNS = "domestic-dns"
|
||||||
|
const val TAG_BALANCER = "balancer-main"
|
||||||
|
const val TAG_BALANCER_PRE = "balancer"
|
||||||
|
|
||||||
/** Network-related constants. */
|
/** Network-related constants. */
|
||||||
const val UPLINK = "uplink"
|
const val UPLINK = "uplink"
|
||||||
const val DOWNLINK = "downlink"
|
const val DOWNLINK = "downlink"
|
||||||
|
|
||||||
/** URLs for various resources. */
|
/** URLs for various resources. */
|
||||||
const val androidpackagenamelistUrl =
|
const val GITHUB_URL = "https://github.com"
|
||||||
"https://raw.githubusercontent.com/2dust/androidpackagenamelist/master/proxy.txt"
|
const val GITHUB_RAW_URL = "https://raw.githubusercontent.com"
|
||||||
const val v2rayCustomRoutingListUrl =
|
const val GITHUB_DOWNLOAD_URL = "$GITHUB_URL/%s/releases/latest/download"
|
||||||
"https://raw.githubusercontent.com/2dust/v2rayCustomRoutingList/master/"
|
const val ANDROID_PACKAGE_NAME_LIST_URL = "$GITHUB_RAW_URL/2dust/androidpackagenamelist/master/proxy.txt"
|
||||||
const val v2rayNGUrl = "https://github.com/2dust/v2rayNG"
|
const val APP_URL = "$GITHUB_URL/2dust/v2rayNG"
|
||||||
const val v2rayNGIssues = "$v2rayNGUrl/issues"
|
const val APP_API_URL = "https://api.github.com/repos/2dust/v2rayNG/releases"
|
||||||
const val v2rayNGWikiMode = "$v2rayNGUrl/wiki/Mode"
|
const val APP_ISSUES_URL = "$APP_URL/issues"
|
||||||
const val v2rayNGPrivacyPolicy = "https://raw.githubusercontent.com/2dust/v2rayNG/master/CR.md"
|
const val APP_WIKI_MODE = "$APP_URL/wiki/Mode"
|
||||||
const val PromotionUrl = "aHR0cHM6Ly85LjIzNDQ1Ni54eXovYWJjLmh0bWw="
|
const val APP_PRIVACY_POLICY = "$GITHUB_RAW_URL/2dust/v2rayNG/master/CR.md"
|
||||||
const val GeoUrl = "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/"
|
const val APP_PROMOTION_URL = "aHR0cHM6Ly85LjIzNDQ1Ni54eXovYWJjLmh0bWw="
|
||||||
const val TgChannelUrl = "https://t.me/github_2dust"
|
const val TG_CHANNEL_URL = "https://t.me/github_2dust"
|
||||||
const val DelayTestUrl = "https://www.gstatic.com/generate_204"
|
const val DELAY_TEST_URL = "https://www.gstatic.com/generate_204"
|
||||||
const val DelayTestUrl2 = "https://www.google.com/generate_204"
|
const val DELAY_TEST_URL2 = "https://www.google.com/generate_204"
|
||||||
|
|
||||||
|
// const val IP_API_URL = "https://speed.cloudflare.com/meta"
|
||||||
|
const val IP_API_URL = "https://api.ip.sb/geoip"
|
||||||
|
|
||||||
/** DNS server addresses. */
|
/** DNS server addresses. */
|
||||||
const val DNS_PROXY = "1.1.1.1"
|
const val DNS_PROXY = "1.1.1.1"
|
||||||
@@ -110,6 +138,12 @@ object AppConfig {
|
|||||||
const val GEOIP_PRIVATE = "geoip:private"
|
const val GEOIP_PRIVATE = "geoip:private"
|
||||||
const val GEOIP_CN = "geoip:cn"
|
const val GEOIP_CN = "geoip:cn"
|
||||||
|
|
||||||
|
/** Geo data file names. */
|
||||||
|
const val GEOSITE_DAT = "geosite.dat"
|
||||||
|
const val GEOIP_DAT = "geoip.dat"
|
||||||
|
const val GEOIP_ONLY_CN_PRIVATE_DAT = "geoip-only-cn-private.dat"
|
||||||
|
const val GEOIP_ONLY_CN_PRIVATE_URL = "$GITHUB_RAW_URL/Loyalsoldier/geoip/release/$GEOIP_ONLY_CN_PRIVATE_DAT"
|
||||||
|
|
||||||
/** Ports and addresses for various services. */
|
/** Ports and addresses for various services. */
|
||||||
const val PORT_LOCAL_DNS = "10853"
|
const val PORT_LOCAL_DNS = "10853"
|
||||||
const val PORT_SOCKS = "10808"
|
const val PORT_SOCKS = "10808"
|
||||||
@@ -131,31 +165,38 @@ object AppConfig {
|
|||||||
const val MSG_STATE_RESTART = 5
|
const val MSG_STATE_RESTART = 5
|
||||||
const val MSG_MEASURE_DELAY = 6
|
const val MSG_MEASURE_DELAY = 6
|
||||||
const val MSG_MEASURE_DELAY_SUCCESS = 61
|
const val MSG_MEASURE_DELAY_SUCCESS = 61
|
||||||
const val MSG_MEASURE_CONFIG = 7
|
const val MSG_MEASURE_CONFIG_START = 7
|
||||||
const val MSG_MEASURE_CONFIG_SUCCESS = 71
|
const val MSG_MEASURE_CONFIG_CANCEL = 71
|
||||||
const val MSG_MEASURE_CONFIG_CANCEL = 72
|
const val MSG_MEASURE_CONFIG_SUCCESS = 72
|
||||||
|
const val MSG_MEASURE_CONFIG_NOTIFY = 73
|
||||||
|
const val MSG_MEASURE_CONFIG_FINISH = 74
|
||||||
|
|
||||||
/** Notification channel IDs and names. */
|
/** Notification channel IDs and names. */
|
||||||
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
|
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
|
||||||
const val RAY_NG_CHANNEL_NAME = "v2rayNG Background Service"
|
const val RAY_NG_CHANNEL_NAME = "v2rayNG Background Service"
|
||||||
const val SUBSCRIPTION_UPDATE_CHANNEL = "subscription_update_channel"
|
|
||||||
const val SUBSCRIPTION_UPDATE_CHANNEL_NAME = "Subscription Update Service"
|
|
||||||
|
|
||||||
/** Protocols Scheme **/
|
/** Protocols Scheme **/
|
||||||
const val VMESS = "vmess://"
|
const val VMESS = "vmess://"
|
||||||
const val CUSTOM = ""
|
const val CUSTOM = ""
|
||||||
const val SHADOWSOCKS = "ss://"
|
const val SHADOWSOCKS = "ss://"
|
||||||
const val SOCKS = "socks://"
|
const val SOCKS = "socks://"
|
||||||
|
const val SOCKS4 = "socks4://"
|
||||||
|
const val SOCKS5 = "socks5://"
|
||||||
const val HTTP = "http://"
|
const val HTTP = "http://"
|
||||||
const val VLESS = "vless://"
|
const val VLESS = "vless://"
|
||||||
const val TROJAN = "trojan://"
|
const val TROJAN = "trojan://"
|
||||||
const val WIREGUARD = "wireguard://"
|
const val WIREGUARD = "wireguard://"
|
||||||
const val TUIC = "tuic://"
|
const val TUIC = "tuic://"
|
||||||
|
const val HYSTERIA = "hysteria://"
|
||||||
const val HYSTERIA2 = "hysteria2://"
|
const val HYSTERIA2 = "hysteria2://"
|
||||||
const val HY2 = "hy2://"
|
const val HY2 = "hy2://"
|
||||||
|
|
||||||
/** Give a good name to this, IDK*/
|
/** Give a good name to this, IDK*/
|
||||||
const val VPN = "VPN"
|
const val VPN = "VPN"
|
||||||
|
const val VPN_MTU = 1500
|
||||||
|
|
||||||
|
/** hev-sock5-tunnel read-write-timeout value */
|
||||||
|
const val HEVTUN_RW_TIMEOUT = "300,60"
|
||||||
|
|
||||||
// Google API rule constants
|
// Google API rule constants
|
||||||
const val GOOGLEAPIS_CN_DOMAIN = "domain:googleapis.cn"
|
const val GOOGLEAPIS_CN_DOMAIN = "domain:googleapis.cn"
|
||||||
@@ -164,19 +205,13 @@ object AppConfig {
|
|||||||
// Android Private DNS constants
|
// Android Private DNS constants
|
||||||
const val DNS_DNSPOD_DOMAIN = "dot.pub"
|
const val DNS_DNSPOD_DOMAIN = "dot.pub"
|
||||||
const val DNS_ALIDNS_DOMAIN = "dns.alidns.com"
|
const val DNS_ALIDNS_DOMAIN = "dns.alidns.com"
|
||||||
const val DNS_CLOUDFLARE_DOMAIN = "one.one.one.one"
|
const val DNS_CLOUDFLARE_ONE_DOMAIN = "one.one.one.one"
|
||||||
|
const val DNS_CLOUDFLARE_DNS_COM_DOMAIN = "dns.cloudflare.com"
|
||||||
|
const val DNS_CLOUDFLARE_DNS_DOMAIN = "cloudflare-dns.com"
|
||||||
const val DNS_GOOGLE_DOMAIN = "dns.google"
|
const val DNS_GOOGLE_DOMAIN = "dns.google"
|
||||||
const val DNS_QUAD9_DOMAIN = "dns.quad9.net"
|
const val DNS_QUAD9_DOMAIN = "dns.quad9.net"
|
||||||
const val DNS_YANDEX_DOMAIN = "common.dot.dns.yandex.net"
|
const val DNS_YANDEX_DOMAIN = "common.dot.dns.yandex.net"
|
||||||
|
|
||||||
|
|
||||||
val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
|
|
||||||
val DNS_CLOUDFLARE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
|
|
||||||
val DNS_DNSPOD_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
|
|
||||||
val DNS_GOOGLE_ADDRESSES = arrayListOf("8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844")
|
|
||||||
val DNS_QUAD9_ADDRESSES = arrayListOf("9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9")
|
|
||||||
val DNS_YANDEX_ADDRESSES = arrayListOf("77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff")
|
|
||||||
|
|
||||||
const val DEFAULT_PORT = 443
|
const val DEFAULT_PORT = 443
|
||||||
const val DEFAULT_SECURITY = "auto"
|
const val DEFAULT_SECURITY = "auto"
|
||||||
const val DEFAULT_LEVEL = 8
|
const val DEFAULT_LEVEL = 8
|
||||||
@@ -185,4 +220,71 @@ object AppConfig {
|
|||||||
const val REALITY = "reality"
|
const val REALITY = "reality"
|
||||||
const val HEADER_TYPE_HTTP = "http"
|
const val HEADER_TYPE_HTTP = "http"
|
||||||
|
|
||||||
|
const val UNIDENTIFIED_PACKAGE = "__unknown_app__"
|
||||||
|
|
||||||
|
val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
|
||||||
|
val DNS_CLOUDFLARE_ONE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
|
||||||
|
val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5")
|
||||||
|
val DNS_CLOUDFLARE_DNS_ADDRESSES = arrayListOf("104.16.248.249", "104.16.249.249", "2606:4700::6810:f8f9", "2606:4700::6810:f9f9")
|
||||||
|
val DNS_DNSPOD_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
|
||||||
|
val DNS_GOOGLE_ADDRESSES = arrayListOf("8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844")
|
||||||
|
val DNS_QUAD9_ADDRESSES = arrayListOf("9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9")
|
||||||
|
val DNS_YANDEX_ADDRESSES = arrayListOf("77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff")
|
||||||
|
|
||||||
|
//minimum list https://serverfault.com/a/304791
|
||||||
|
val ROUTED_IP_LIST = arrayListOf(
|
||||||
|
"0.0.0.0/5",
|
||||||
|
"8.0.0.0/7",
|
||||||
|
"11.0.0.0/8",
|
||||||
|
"12.0.0.0/6",
|
||||||
|
"16.0.0.0/4",
|
||||||
|
"32.0.0.0/3",
|
||||||
|
"64.0.0.0/2",
|
||||||
|
"128.0.0.0/3",
|
||||||
|
"160.0.0.0/5",
|
||||||
|
"168.0.0.0/6",
|
||||||
|
"172.0.0.0/12",
|
||||||
|
"172.32.0.0/11",
|
||||||
|
"172.64.0.0/10",
|
||||||
|
"172.128.0.0/9",
|
||||||
|
"173.0.0.0/8",
|
||||||
|
"174.0.0.0/7",
|
||||||
|
"176.0.0.0/4",
|
||||||
|
"192.0.0.0/9",
|
||||||
|
"192.128.0.0/11",
|
||||||
|
"192.160.0.0/13",
|
||||||
|
"192.169.0.0/16",
|
||||||
|
"192.170.0.0/15",
|
||||||
|
"192.172.0.0/14",
|
||||||
|
"192.176.0.0/12",
|
||||||
|
"192.192.0.0/10",
|
||||||
|
"193.0.0.0/8",
|
||||||
|
"194.0.0.0/7",
|
||||||
|
"196.0.0.0/6",
|
||||||
|
"200.0.0.0/5",
|
||||||
|
"208.0.0.0/4",
|
||||||
|
"240.0.0.0/4"
|
||||||
|
)
|
||||||
|
|
||||||
|
val PRIVATE_IP_LIST = arrayListOf(
|
||||||
|
"0.0.0.0/8",
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"127.0.0.0/8",
|
||||||
|
"172.16.0.0/12",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
"169.254.0.0/16",
|
||||||
|
"224.0.0.0/4"
|
||||||
|
)
|
||||||
|
|
||||||
|
val GEO_FILES_SOURCES = arrayListOf(
|
||||||
|
"Loyalsoldier/v2ray-rules-dat",
|
||||||
|
"runetfreedom/russia-v2ray-rules-dat",
|
||||||
|
"Chocolate4U/Iran-v2ray-rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
val BUILTIN_OUTBOUND_TAGS = setOf(
|
||||||
|
TAG_PROXY,
|
||||||
|
TAG_DIRECT,
|
||||||
|
TAG_BLOCKED,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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,13 @@
|
|||||||
|
package com.v2ray.ang.contracts
|
||||||
|
|
||||||
|
import com.v2ray.ang.dto.entities.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
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.v2ray.ang.service
|
package com.v2ray.ang.contracts
|
||||||
|
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.v2ray.ang.contracts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface that defines the control operations for tun2socks implementations.
|
||||||
|
*
|
||||||
|
* This interface is implemented by different tunnel solutions like:
|
||||||
|
*/
|
||||||
|
interface Tun2SocksControl {
|
||||||
|
/**
|
||||||
|
* Starts the tun2socks process with the appropriate parameters.
|
||||||
|
* This initializes the VPN tunnel and connects it to the SOCKS proxy.
|
||||||
|
*/
|
||||||
|
fun startTun2Socks()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the tun2socks process and cleans up resources.
|
||||||
|
*/
|
||||||
|
fun stopTun2Socks()
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package com.v2ray.ang.core
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.dto.CoreConfigContext
|
||||||
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
|
import com.v2ray.ang.enums.CoreResolvedType
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.extension.isComplexType
|
||||||
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
import com.v2ray.ang.util.Utils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build runtime context from the selected profile.
|
||||||
|
*
|
||||||
|
* All outbound type analysis is completed here for both the selected profile
|
||||||
|
* and routing targets. Custom profiles are returned immediately without
|
||||||
|
* entering the normal analysis flow.
|
||||||
|
*/
|
||||||
|
object CoreConfigContextBuilder {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load one profile and produce a fully analyzed context.
|
||||||
|
*
|
||||||
|
* Null is returned only when the selected profile cannot be loaded.
|
||||||
|
*/
|
||||||
|
fun build(context: Context, guid: String): CoreConfigContext? {
|
||||||
|
val config = MmkvManager.decodeServerConfig(guid) ?: return null
|
||||||
|
|
||||||
|
// CUSTOM: return immediately — CoreConfigManager handles this path on its own.
|
||||||
|
if (config.configType == EConfigType.CUSTOM) {
|
||||||
|
return CoreConfigContext(context = context, guid = guid, isCustom = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Resolve the main outbound (always tag = TAG_PROXY).
|
||||||
|
val primaryResolvedOutbound = resolveOutbound(AppConfig.TAG_PROXY, config) ?: run {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to resolve main outbound for '${config.remarks}'")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Resolve all non-builtin routing outbound tags.
|
||||||
|
val routingResolvedOutbounds = resolveRoutingOutbounds()
|
||||||
|
|
||||||
|
return CoreConfigContext(
|
||||||
|
context = context,
|
||||||
|
guid = guid,
|
||||||
|
resolvedOutbounds = listOf(primaryResolvedOutbound) + routingResolvedOutbounds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one outbound target into a normalized outbound entry.
|
||||||
|
*
|
||||||
|
* Custom profiles are ignored at this stage and produce no entry.
|
||||||
|
*/
|
||||||
|
private fun resolveOutbound(tag: String, profile: ProfileItem): CoreConfigContext.ResolvedOutbound? {
|
||||||
|
if (profile.configType == EConfigType.CUSTOM) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val (resolvedProfiles, resolvedType) = when (profile.configType) {
|
||||||
|
EConfigType.POLICYGROUP -> Pair(
|
||||||
|
resolvePolicyGroupProfiles(profile),
|
||||||
|
CoreResolvedType.POLICYGROUP,
|
||||||
|
)
|
||||||
|
|
||||||
|
EConfigType.PROXYCHAIN -> {
|
||||||
|
val chainProfiles = resolveProxyChainProfiles(profile)
|
||||||
|
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
|
||||||
|
Pair(chainProfiles, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val chainProfiles = resolveProxyChainProfilesFromGroup(profile)
|
||||||
|
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
|
||||||
|
Pair(chainProfiles, type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CoreConfigContext.ResolvedOutbound(
|
||||||
|
tag = tag,
|
||||||
|
profile = profile,
|
||||||
|
resolvedProfiles = resolvedProfiles,
|
||||||
|
resolvedType = resolvedType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect and resolve non-builtin routing targets from enabled rules.
|
||||||
|
*
|
||||||
|
* Invalid or empty targets are skipped and handled by fallback logic later.
|
||||||
|
*/
|
||||||
|
private fun resolveRoutingOutbounds(): List<CoreConfigContext.ResolvedOutbound> {
|
||||||
|
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return emptyList()
|
||||||
|
val resolvedOutbounds = mutableListOf<CoreConfigContext.ResolvedOutbound>()
|
||||||
|
val processedTags = mutableSetOf<String>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
rulesetItems
|
||||||
|
.filter { it.enabled }
|
||||||
|
.mapNotNull { it.outboundTag.takeIf { tag -> tag.isNotBlank() } }
|
||||||
|
.filter { tag -> tag !in AppConfig.BUILTIN_OUTBOUND_TAGS }
|
||||||
|
.distinct()
|
||||||
|
.forEach { tag ->
|
||||||
|
if (tag in processedTags) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
processedTags.add(tag)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val profile = SettingsManager.getServerViaRemarks(tag) ?: run {
|
||||||
|
LogUtil.w(AppConfig.TAG, "Routing tag '$tag' has no matching profile — will fall back to proxy at routing time")
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
val resolvedOutbound = resolveOutbound(tag, profile) ?: run {
|
||||||
|
LogUtil.w(AppConfig.TAG, "Cannot use CUSTOM profile as routing outbound for tag '$tag', skipping")
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (resolvedOutbound.resolvedProfiles.isEmpty()) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "Routing outbound '$tag' resolved to empty list, skipping")
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
resolvedOutbounds.add(resolvedOutbound)
|
||||||
|
LogUtil.d(AppConfig.TAG, "Resolved routing outbound: tag='$tag', type='${resolvedOutbound.resolvedType}', profiles=${resolvedOutbound.resolvedProfiles.size}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to resolve routing outbound for tag '$tag', skipping", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to resolve routing outbounds from rulesets", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedOutbounds
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolvePolicyGroupProfiles(config: ProfileItem): List<ProfileItem> {
|
||||||
|
try {
|
||||||
|
val serverList = MmkvManager.decodeAllServerList()
|
||||||
|
return serverList
|
||||||
|
.asSequence()
|
||||||
|
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
|
||||||
|
.filter { profile ->
|
||||||
|
val subscriptionId = config.policyGroupSubscriptionId
|
||||||
|
if (subscriptionId.isNullOrBlank()) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
profile.subscriptionId == subscriptionId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.filter { profile ->
|
||||||
|
val filter = config.policyGroupFilter
|
||||||
|
if (filter.isNullOrBlank()) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
Regex(filter).containsMatchIn(profile.remarks)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
profile.remarks.contains(filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.filter { it.server.isNotNullEmpty() }
|
||||||
|
.filter { Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
|
||||||
|
.filter { !it.configType.isComplexType() }
|
||||||
|
.toList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to resolve policy group profiles for '${config.remarks}'", e)
|
||||||
|
return listOf(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveProxyChainProfiles(config: ProfileItem): List<ProfileItem> {
|
||||||
|
if (config.proxyChainProfiles.isNullOrBlank()) {
|
||||||
|
return listOf(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return config.proxyChainProfiles.orEmpty().split(",")
|
||||||
|
.asSequence()
|
||||||
|
.mapNotNull { remark -> SettingsManager.getServerViaRemarks(remark) }
|
||||||
|
.filter { it.server.isNotNullEmpty() }
|
||||||
|
.filter { Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
|
||||||
|
.filter { !it.configType.isComplexType() }
|
||||||
|
.toList()
|
||||||
|
.reversed()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles for '${config.remarks}'", e)
|
||||||
|
return listOf(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve chain nodes from subscription neighbors in order: next, current, prev.
|
||||||
|
*
|
||||||
|
* When no chain is available, return a single-node result.
|
||||||
|
*/
|
||||||
|
private fun resolveProxyChainProfilesFromGroup(config: ProfileItem): List<ProfileItem> {
|
||||||
|
if (config.subscriptionId.isEmpty()) {
|
||||||
|
return listOf(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val subItem = MmkvManager.decodeSubscription(config.subscriptionId) ?: return listOf(config)
|
||||||
|
val resolved = mutableListOf<ProfileItem>()
|
||||||
|
SettingsManager.getServerViaRemarks(subItem.nextProfile)?.let { resolved.add(it) }
|
||||||
|
resolved.add(config)
|
||||||
|
SettingsManager.getServerViaRemarks(subItem.prevProfile)?.let { resolved.add(it) }
|
||||||
|
return resolved
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain from group for '${config.remarks}'", e)
|
||||||
|
return listOf(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,997 @@
|
|||||||
|
package com.v2ray.ang.core
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.text.TextUtils
|
||||||
|
import com.google.gson.JsonArray
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.dto.ConfigResult
|
||||||
|
import com.v2ray.ang.dto.CoreConfigContext
|
||||||
|
import com.v2ray.ang.dto.V2rayConfig
|
||||||
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
|
import com.v2ray.ang.dto.entities.RulesetItem
|
||||||
|
import com.v2ray.ang.enums.BalancerStrategyType
|
||||||
|
import com.v2ray.ang.enums.CoreResolvedType
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
|
import com.v2ray.ang.util.HttpUtil
|
||||||
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
import com.v2ray.ang.util.PackageUidResolver
|
||||||
|
import com.v2ray.ang.util.Utils
|
||||||
|
|
||||||
|
object CoreConfigManager {
|
||||||
|
private var initConfigCache: String? = null
|
||||||
|
private var initConfigCacheWithTun: String? = null
|
||||||
|
|
||||||
|
//region get config function
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the runtime configuration for normal startup.
|
||||||
|
*/
|
||||||
|
fun getV2rayConfig(context: Context, guid: String): ConfigResult {
|
||||||
|
try {
|
||||||
|
val configContext = CoreConfigContextBuilder.build(context, guid)
|
||||||
|
?: return ConfigResult(status = false, guid = guid, errorMessage = "Failed to build config context")
|
||||||
|
if (configContext.isCustom) {
|
||||||
|
return buildV2rayCustomConfig(configContext)
|
||||||
|
}
|
||||||
|
return toConfigResult(configContext, buildUnifiedConfig(configContext))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config", e)
|
||||||
|
return ConfigResult(
|
||||||
|
status = false,
|
||||||
|
guid = guid,
|
||||||
|
errorMessage = "Failed to get V2ray config: ${e.message ?: e.javaClass.simpleName}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a lightweight configuration for latency testing.
|
||||||
|
*
|
||||||
|
* The core flow is reused, then non-essential sections are removed.
|
||||||
|
*/
|
||||||
|
fun getV2rayConfig4Speedtest(context: Context, guid: String): ConfigResult {
|
||||||
|
try {
|
||||||
|
val configContext = CoreConfigContextBuilder.build(context, guid)
|
||||||
|
?: return ConfigResult(status = false, guid = guid, errorMessage = "Failed to build config context")
|
||||||
|
if (configContext.isCustom) {
|
||||||
|
return buildV2rayCustomConfig(configContext)
|
||||||
|
}
|
||||||
|
val v2rayConfig = buildUnifiedConfig(configContext)
|
||||||
|
postProcessForSpeedtest(v2rayConfig)
|
||||||
|
|
||||||
|
return toConfigResult(configContext, v2rayConfig)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config for speedtest", e)
|
||||||
|
return ConfigResult(
|
||||||
|
status = false,
|
||||||
|
guid = guid,
|
||||||
|
errorMessage = "Failed to get V2ray config for speedtest: ${e.message ?: e.javaClass.simpleName}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build configuration for custom profiles.
|
||||||
|
*/
|
||||||
|
private fun buildV2rayCustomConfig(configContext: CoreConfigContext): ConfigResult {
|
||||||
|
val context = configContext.context
|
||||||
|
val raw = MmkvManager.decodeServerRaw(configContext.guid)
|
||||||
|
?: return ConfigResult(status = false, guid = configContext.guid, errorMessage = "Custom config is empty")
|
||||||
|
val result = ConfigResult(true, configContext.guid, raw)
|
||||||
|
if (!needTun()) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
val json = JsonUtil.parseString(raw)?.takeIf { it.isJsonObject }?.asJsonObject ?: return result
|
||||||
|
|
||||||
|
// Check whether package names need to be replaced with UIDs
|
||||||
|
if (SettingsManager.canUseProcessRouting()) {
|
||||||
|
val rulesJson = json.get("routing")?.takeIf { it.isJsonObject }?.asJsonObject
|
||||||
|
?.get("rules")?.takeIf { it.isJsonArray }?.asJsonArray
|
||||||
|
?: JsonArray()
|
||||||
|
|
||||||
|
for (elem in rulesJson) {
|
||||||
|
val rule = elem.takeIf { it.isJsonObject }?.asJsonObject ?: continue
|
||||||
|
val process = rule.get("process")?.takeIf { it.isJsonArray }?.asJsonArray ?: continue
|
||||||
|
val packages = process.mapNotNull {
|
||||||
|
it.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }?.asString
|
||||||
|
}.takeIf { it.isNotEmpty() } ?: continue
|
||||||
|
val uids = PackageUidResolver.packageNamesToUids(context, packages).takeIf { it.isNotEmpty() } ?: continue
|
||||||
|
|
||||||
|
rule.add("process", JsonArray().apply { uids.forEach { add(it) } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if tun inbound exists
|
||||||
|
val inboundsJson = json.get("inbounds")?.takeIf { it.isJsonArray }?.asJsonArray
|
||||||
|
?: JsonArray().also { json.add("inbounds", it) }
|
||||||
|
val tunNotExists = inboundsJson.none { elem ->
|
||||||
|
elem.isJsonObject && elem.asJsonObject.get("protocol")
|
||||||
|
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
|
||||||
|
?.asString == "tun"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tunNotExists) {
|
||||||
|
// add tun inbound from template
|
||||||
|
val templateConfig = initV2rayConfig(configContext)
|
||||||
|
templateConfig.inbounds.firstOrNull { it.tag == "tun" }?.let { inboundTun ->
|
||||||
|
inboundTun.settings?.mtu = SettingsManager.getVpnMtu()
|
||||||
|
inboundsJson.add(JsonUtil.parseString(JsonUtil.toJson(inboundTun)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonUtil.toJsonPretty(json)?.let { ConfigResult(true, configContext.guid, it) } ?: result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build one unified configuration for every non-custom profile type.
|
||||||
|
*
|
||||||
|
* The analyzed outbound plan is consumed in order and converted to concrete
|
||||||
|
* outbounds before routing, DNS, and runtime extras are assembled.
|
||||||
|
*/
|
||||||
|
private fun buildUnifiedConfig(configContext: CoreConfigContext): V2rayConfig {
|
||||||
|
require(configContext.resolvedOutbounds.isNotEmpty()) { "resolvedOutbounds must not be empty for a non-CUSTOM context" }
|
||||||
|
val primaryResolvedOutbound = configContext.resolvedOutbounds.first()
|
||||||
|
|
||||||
|
val v2rayConfig = initV2rayConfig(configContext)
|
||||||
|
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
|
||||||
|
v2rayConfig.remarks = primaryResolvedOutbound.profile.remarks
|
||||||
|
|
||||||
|
configureInbounds(v2rayConfig)
|
||||||
|
|
||||||
|
if (v2rayConfig.outbounds.isNotEmpty()) {
|
||||||
|
v2rayConfig.outbounds.removeAt(0)
|
||||||
|
}
|
||||||
|
val existingTags = v2rayConfig.outbounds.mapTo(mutableSetOf()) { it.tag }
|
||||||
|
val policyGroupBalancerTags = mutableMapOf<String, String>()
|
||||||
|
val balancerStrategies = mutableListOf<BalancerStrategy>()
|
||||||
|
|
||||||
|
// resolvedOutbounds is a single ordered plan: index 0 is primary and must be prepended,
|
||||||
|
// the rest are routing outbounds and can be appended.
|
||||||
|
configContext.resolvedOutbounds.forEachIndexed { index, spec ->
|
||||||
|
buildOutbounds(
|
||||||
|
resolvedOutbound = spec,
|
||||||
|
prepend = index == 0,
|
||||||
|
existingTags = existingTags,
|
||||||
|
v2rayConfig = v2rayConfig,
|
||||||
|
policyGroupBalancerTags = policyGroupBalancerTags,
|
||||||
|
balancerStrategies = balancerStrategies,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User routing rules (policyGroupBalancerTags rewrites TAG_PROXY→balancer when main is POLICYGROUP).
|
||||||
|
configureRouting(configContext, v2rayConfig, policyGroupBalancerTags)
|
||||||
|
configureFakeDns(v2rayConfig)
|
||||||
|
configureDns(v2rayConfig, policyGroupBalancerTags)
|
||||||
|
configureLocalDns(v2rayConfig)
|
||||||
|
|
||||||
|
// (added by getDns / getCustomLocalDns) to use the balancer, then add
|
||||||
|
// the catch-all balancer rule.
|
||||||
|
if (primaryResolvedOutbound.resolvedType == CoreResolvedType.POLICYGROUP) {
|
||||||
|
if (v2rayConfig.routing.domainStrategy == "IPIfNonMatch") {
|
||||||
|
v2rayConfig.routing.rules.add(
|
||||||
|
V2rayConfig.RoutingBean.RulesBean(
|
||||||
|
ip = arrayListOf("0.0.0.0/0", "::/0"),
|
||||||
|
balancerTag = AppConfig.TAG_BALANCER,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
v2rayConfig.routing.rules.add(
|
||||||
|
V2rayConfig.RoutingBean.RulesBean(
|
||||||
|
network = "tcp,udp",
|
||||||
|
balancerTag = AppConfig.TAG_BALANCER,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyObservability(v2rayConfig, balancerStrategies)
|
||||||
|
applySpeedDisabled(v2rayConfig)
|
||||||
|
resolveOutboundDomainsToHosts(v2rayConfig)
|
||||||
|
|
||||||
|
return v2rayConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert one analyzed outbound entry into concrete outbounds and register
|
||||||
|
* them to the runtime configuration.
|
||||||
|
*/
|
||||||
|
private fun buildOutbounds(
|
||||||
|
resolvedOutbound: CoreConfigContext.ResolvedOutbound,
|
||||||
|
prepend: Boolean,
|
||||||
|
existingTags: MutableSet<String>,
|
||||||
|
v2rayConfig: V2rayConfig,
|
||||||
|
policyGroupBalancerTags: MutableMap<String, String>,
|
||||||
|
balancerStrategies: MutableList<BalancerStrategy>,
|
||||||
|
) {
|
||||||
|
if (resolvedOutbound.tag in existingTags) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "Resolved outbound tag '${resolvedOutbound.tag}' already exists, skipping duplicated entry")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
when (resolvedOutbound.resolvedType) {
|
||||||
|
CoreResolvedType.NORMAL -> handleNormalResolvedOutbound(
|
||||||
|
resolvedOutbound = resolvedOutbound,
|
||||||
|
prepend = prepend,
|
||||||
|
existingTags = existingTags,
|
||||||
|
v2rayConfig = v2rayConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
CoreResolvedType.PROXYCHAIN -> handleProxyChainResolvedOutbound(
|
||||||
|
resolvedOutbound = resolvedOutbound,
|
||||||
|
prepend = prepend,
|
||||||
|
existingTags = existingTags,
|
||||||
|
v2rayConfig = v2rayConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
CoreResolvedType.POLICYGROUP -> handlePolicyGroupResolvedOutbound(
|
||||||
|
resolvedOutbound = resolvedOutbound,
|
||||||
|
prepend = prepend,
|
||||||
|
existingTags = existingTags,
|
||||||
|
v2rayConfig = v2rayConfig,
|
||||||
|
policyGroupBalancerTags = policyGroupBalancerTags,
|
||||||
|
balancerStrategies = balancerStrategies,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build and insert a single-node outbound entry.
|
||||||
|
*/
|
||||||
|
private fun handleNormalResolvedOutbound(
|
||||||
|
resolvedOutbound: CoreConfigContext.ResolvedOutbound,
|
||||||
|
prepend: Boolean,
|
||||||
|
existingTags: MutableSet<String>,
|
||||||
|
v2rayConfig: V2rayConfig,
|
||||||
|
) {
|
||||||
|
val profile = resolvedOutbound.resolvedProfiles.firstOrNull() ?: run {
|
||||||
|
LogUtil.w(AppConfig.TAG, "NORMAL resolved outbound '${resolvedOutbound.tag}' has empty resolvedProfiles, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val outbound = convertProfile2Outbound(profile) ?: run {
|
||||||
|
LogUtil.w(AppConfig.TAG, "Could not convert NORMAL resolved outbound '${resolvedOutbound.tag}' profile to outbound, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outbound.tag = resolvedOutbound.tag
|
||||||
|
if (prepend) {
|
||||||
|
v2rayConfig.outbounds.add(0, outbound)
|
||||||
|
} else {
|
||||||
|
v2rayConfig.outbounds.add(outbound)
|
||||||
|
}
|
||||||
|
existingTags.add(resolvedOutbound.tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build and insert a multi-hop chain entry.
|
||||||
|
*/
|
||||||
|
private fun handleProxyChainResolvedOutbound(
|
||||||
|
resolvedOutbound: CoreConfigContext.ResolvedOutbound,
|
||||||
|
prepend: Boolean,
|
||||||
|
existingTags: MutableSet<String>,
|
||||||
|
v2rayConfig: V2rayConfig,
|
||||||
|
) {
|
||||||
|
val chainOutbounds = resolvedOutbound.resolvedProfiles
|
||||||
|
.mapNotNull { convertProfile2Outbound(it) }
|
||||||
|
.toMutableList()
|
||||||
|
if (chainOutbounds.isEmpty()) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "PROXYCHAIN resolved outbound '${resolvedOutbound.tag}' has no valid profiles, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (chainOutbounds.size == 1) {
|
||||||
|
val outbound = chainOutbounds.first()
|
||||||
|
outbound.tag = resolvedOutbound.tag
|
||||||
|
if (prepend) {
|
||||||
|
v2rayConfig.outbounds.add(0, outbound)
|
||||||
|
} else {
|
||||||
|
v2rayConfig.outbounds.add(outbound)
|
||||||
|
}
|
||||||
|
existingTags.add(resolvedOutbound.tag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val chainTags = chainOutbounds.mapIndexed { index, _ ->
|
||||||
|
if (index == 0) {
|
||||||
|
resolvedOutbound.tag
|
||||||
|
} else {
|
||||||
|
"${AppConfig.TAG_PROXY}-${resolvedOutbound.tag}-$index"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chainTags.any { it in existingTags }) {
|
||||||
|
LogUtil.w(
|
||||||
|
AppConfig.TAG,
|
||||||
|
"PROXYCHAIN resolved outbound '${resolvedOutbound.tag}' has colliding hop tags, skipping"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chainOutbounds.forEachIndexed { index, outbound ->
|
||||||
|
outbound.tag = chainTags[index]
|
||||||
|
}
|
||||||
|
for (i in 0 until chainOutbounds.size - 1) {
|
||||||
|
chainOutbounds[i].ensureSockopt().dialerProxy = chainOutbounds[i + 1].tag
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prepend) {
|
||||||
|
v2rayConfig.outbounds.addAll(0, chainOutbounds)
|
||||||
|
} else {
|
||||||
|
v2rayConfig.outbounds.addAll(chainOutbounds)
|
||||||
|
}
|
||||||
|
chainOutbounds.forEach { existingTags.add(it.tag) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build and insert a policy-group entry and its balancer metadata.
|
||||||
|
*/
|
||||||
|
private fun handlePolicyGroupResolvedOutbound(
|
||||||
|
resolvedOutbound: CoreConfigContext.ResolvedOutbound,
|
||||||
|
prepend: Boolean,
|
||||||
|
existingTags: MutableSet<String>,
|
||||||
|
v2rayConfig: V2rayConfig,
|
||||||
|
policyGroupBalancerTags: MutableMap<String, String>,
|
||||||
|
balancerStrategies: MutableList<BalancerStrategy>,
|
||||||
|
) {
|
||||||
|
val memberPairs = resolvedOutbound.resolvedProfiles.mapNotNull { profile ->
|
||||||
|
convertProfile2Outbound(profile)?.let { ob -> ob to profile }
|
||||||
|
}
|
||||||
|
if (memberPairs.isEmpty()) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "POLICYGROUP resolved outbound '${resolvedOutbound.tag}' has no valid member outbounds, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val memberTagPrefix = "${AppConfig.TAG_PROXY}-${resolvedOutbound.tag}-"
|
||||||
|
val membersToAdd = mutableListOf<V2rayConfig.OutboundBean>()
|
||||||
|
memberPairs.forEachIndexed { index, (outbound, profile) ->
|
||||||
|
val memberTag = "$memberTagPrefix${index + 1}-${profile.remarks.trim()}"
|
||||||
|
if (memberTag in existingTags) {
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
outbound.tag = memberTag
|
||||||
|
membersToAdd.add(outbound)
|
||||||
|
existingTags.add(memberTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (membersToAdd.isEmpty()) {
|
||||||
|
LogUtil.w(
|
||||||
|
AppConfig.TAG,
|
||||||
|
"POLICYGROUP resolved outbound '${resolvedOutbound.tag}' produced no unique member tags, skipping"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prepend) {
|
||||||
|
v2rayConfig.outbounds.addAll(0, membersToAdd)
|
||||||
|
} else {
|
||||||
|
v2rayConfig.outbounds.addAll(membersToAdd)
|
||||||
|
}
|
||||||
|
|
||||||
|
val balancerTag = if (resolvedOutbound.tag == AppConfig.TAG_PROXY) {
|
||||||
|
AppConfig.TAG_BALANCER
|
||||||
|
} else {
|
||||||
|
"${AppConfig.TAG_BALANCER_PRE}-${resolvedOutbound.tag}"
|
||||||
|
}
|
||||||
|
val strategy = buildBalancerStrategy(
|
||||||
|
policyGroupType = resolvedOutbound.profile.policyGroupType,
|
||||||
|
selector = listOf(memberTagPrefix),
|
||||||
|
balancerTag = balancerTag,
|
||||||
|
)
|
||||||
|
val existingBalancers = v2rayConfig.routing.balancers?.toMutableList() ?: mutableListOf()
|
||||||
|
if (existingBalancers.none { it.tag == balancerTag }) {
|
||||||
|
existingBalancers.add(strategy.balancer)
|
||||||
|
v2rayConfig.routing.balancers = existingBalancers
|
||||||
|
}
|
||||||
|
balancerStrategies.add(strategy)
|
||||||
|
policyGroupBalancerTags[resolvedOutbound.tag] = balancerTag
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim runtime sections that are not needed for latency testing.
|
||||||
|
*/
|
||||||
|
private fun postProcessForSpeedtest(v2rayConfig: V2rayConfig) {
|
||||||
|
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
|
||||||
|
v2rayConfig.inbounds.clear()
|
||||||
|
v2rayConfig.routing.rules.clear()
|
||||||
|
v2rayConfig.dns = null
|
||||||
|
v2rayConfig.fakedns = null
|
||||||
|
v2rayConfig.stats = null
|
||||||
|
v2rayConfig.policy = null
|
||||||
|
v2rayConfig.outbounds.forEach { key -> key.mux = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a runtime configuration into a standard result object.
|
||||||
|
*/
|
||||||
|
private fun toConfigResult(configContext: CoreConfigContext, v2rayConfig: V2rayConfig): ConfigResult {
|
||||||
|
return ConfigResult(
|
||||||
|
status = true,
|
||||||
|
guid = configContext.guid,
|
||||||
|
content = JsonUtil.toJsonPretty(v2rayConfig) ?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the base template from cache or assets and parse it.
|
||||||
|
*/
|
||||||
|
private fun initV2rayConfig(configContext: CoreConfigContext): V2rayConfig {
|
||||||
|
val context = configContext.context
|
||||||
|
val assets: String
|
||||||
|
if (needTun()) {
|
||||||
|
assets = initConfigCacheWithTun ?: Utils.readTextFromAssets(context, "v2ray_config_with_tun.json")
|
||||||
|
if (TextUtils.isEmpty(assets)) {
|
||||||
|
error("Missing asset: v2ray_config_with_tun.json")
|
||||||
|
}
|
||||||
|
initConfigCacheWithTun = assets
|
||||||
|
} else {
|
||||||
|
assets = initConfigCache ?: Utils.readTextFromAssets(context, "v2ray_config.json")
|
||||||
|
if (TextUtils.isEmpty(assets)) {
|
||||||
|
error("Missing asset: v2ray_config.json")
|
||||||
|
}
|
||||||
|
initConfigCache = assets
|
||||||
|
}
|
||||||
|
return JsonUtil.fromJson(assets, V2rayConfig::class.java)
|
||||||
|
?: error("Failed to parse config template")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
|
||||||
|
//region some sub function
|
||||||
|
|
||||||
|
private fun needTun(): Boolean {
|
||||||
|
return SettingsManager.isVpnMode() && !SettingsManager.isUsingHevTun()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure inbound listeners and related runtime options.
|
||||||
|
*/
|
||||||
|
private fun configureInbounds(v2rayConfig: V2rayConfig) {
|
||||||
|
val vpn = SettingsManager.isVpnMode()
|
||||||
|
val useHev = SettingsManager.isUsingHevTun()
|
||||||
|
val forcedByHev = vpn && useHev
|
||||||
|
|
||||||
|
val enableLocalProxy = forcedByHev || MmkvManager.decodeSettingsBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true)
|
||||||
|
|
||||||
|
val socksPort = SettingsManager.getSocksPort()
|
||||||
|
val socksUsername = SettingsManager.getSocksUsername()
|
||||||
|
val socksPassword = SettingsManager.getSocksPassword()
|
||||||
|
val inbound1 = v2rayConfig.inbounds[0]
|
||||||
|
if (inbound1.settings == null) {
|
||||||
|
inbound1.settings = V2rayConfig.InboundBean.InSettingsBean()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING) != true) {
|
||||||
|
inbound1.listen = AppConfig.LOOPBACK
|
||||||
|
}
|
||||||
|
inbound1.port = socksPort
|
||||||
|
inbound1.settings?.udp = MmkvManager.decodeSettingsBool(AppConfig.PREF_SOCKS_ENABLE_UDP, true)
|
||||||
|
if (socksUsername != null && socksPassword != null) {
|
||||||
|
inbound1.settings?.auth = "password"
|
||||||
|
inbound1.settings?.accounts = listOf(
|
||||||
|
V2rayConfig.InboundBean.InSettingsBean.SocksAccountBean(
|
||||||
|
user = socksUsername,
|
||||||
|
pass = socksPassword
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
inbound1.settings?.auth = "noauth"
|
||||||
|
inbound1.settings?.accounts = null
|
||||||
|
}
|
||||||
|
val fakedns = MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
|
||||||
|
val sniffAllTlsAndHttp =
|
||||||
|
MmkvManager.decodeSettingsBool(AppConfig.PREF_SNIFFING_ENABLED, true) != false
|
||||||
|
inbound1.sniffing?.enabled = fakedns || sniffAllTlsAndHttp
|
||||||
|
inbound1.sniffing?.routeOnly =
|
||||||
|
MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false)
|
||||||
|
if (!sniffAllTlsAndHttp) {
|
||||||
|
inbound1.sniffing?.destOverride?.clear()
|
||||||
|
}
|
||||||
|
if (fakedns) {
|
||||||
|
inbound1.sniffing?.destOverride?.add("fakedns")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Utils.isXray()) {
|
||||||
|
val inbound2 = JsonUtil.fromJson(JsonUtil.toJson(inbound1), V2rayConfig.InboundBean::class.java)
|
||||||
|
?: error("Failed to clone inbound template")
|
||||||
|
inbound2.tag = EConfigType.HTTP.name.lowercase()
|
||||||
|
inbound2.port = SettingsManager.getHttpPort()
|
||||||
|
inbound2.protocol = EConfigType.HTTP.name.lowercase()
|
||||||
|
inbound2.settings?.auth = null
|
||||||
|
inbound2.settings?.udp = null
|
||||||
|
v2rayConfig.inbounds.add(inbound2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enableLocalProxy) {
|
||||||
|
v2rayConfig.inbounds.removeIf { it.protocol == "socks" || it.protocol == "http" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needTun()) {
|
||||||
|
val inboundTun = v2rayConfig.inbounds.firstOrNull { e -> e.tag == "tun" }
|
||||||
|
inboundTun?.settings?.mtu = SettingsManager.getVpnMtu()
|
||||||
|
inboundTun?.sniffing = inbound1.sniffing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable fake DNS when local DNS and fake DNS are both enabled.
|
||||||
|
*/
|
||||||
|
private fun configureFakeDns(v2rayConfig: V2rayConfig) {
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true
|
||||||
|
&& MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
|
||||||
|
) {
|
||||||
|
v2rayConfig.fakedns = listOf(V2rayConfig.FakednsBean())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect domain rules that target one outbound tag.
|
||||||
|
*/
|
||||||
|
private fun collectUserRuleDomainsByTag(tag: String): ArrayList<String> {
|
||||||
|
val domain = ArrayList<String>()
|
||||||
|
|
||||||
|
val rulesetItems = MmkvManager.decodeRoutingRulesets()
|
||||||
|
rulesetItems?.forEach { key ->
|
||||||
|
if (key.enabled && key.outboundTag == tag && !key.domain.isNullOrEmpty()) {
|
||||||
|
key.domain?.forEach {
|
||||||
|
domain.add(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect domain rules that target non-builtin outbound tags.
|
||||||
|
*/
|
||||||
|
private fun collectCustomOutboundDomains(): ArrayList<String> {
|
||||||
|
val domain = ArrayList<String>()
|
||||||
|
|
||||||
|
val rulesetItems = MmkvManager.decodeRoutingRulesets()
|
||||||
|
rulesetItems?.forEach { key ->
|
||||||
|
if (key.enabled && !AppConfig.BUILTIN_OUTBOUND_TAGS.contains(key.outboundTag)
|
||||||
|
&& !key.domain.isNullOrEmpty()
|
||||||
|
) {
|
||||||
|
key.domain?.forEach {
|
||||||
|
domain.add(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure local DNS inbounds, outbounds, and routing rules.
|
||||||
|
*/
|
||||||
|
private fun configureLocalDns(v2rayConfig: V2rayConfig) {
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) != true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true) {
|
||||||
|
val geositeCn = arrayListOf(AppConfig.GEOSITE_CN)
|
||||||
|
val proxyDomain = collectUserRuleDomainsByTag(AppConfig.TAG_PROXY)
|
||||||
|
val directDomain = collectUserRuleDomainsByTag(AppConfig.TAG_DIRECT)
|
||||||
|
val finalDomain = geositeCn.plus(proxyDomain).plus(directDomain).distinct()
|
||||||
|
// fakedns with all domains to make it always top priority
|
||||||
|
v2rayConfig.dns?.servers?.add(
|
||||||
|
0,
|
||||||
|
V2rayConfig.DnsBean.ServersBean(
|
||||||
|
address = "fakedns",
|
||||||
|
domains = finalDomain
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SettingsManager.isVpnMode()) {
|
||||||
|
if (SettingsManager.isUsingHevTun()) {
|
||||||
|
//hev-socks5-tunnel dns routing
|
||||||
|
v2rayConfig.routing.rules.add(
|
||||||
|
0, V2rayConfig.RoutingBean.RulesBean(
|
||||||
|
inboundTag = arrayListOf("socks"),
|
||||||
|
outboundTag = "dns-out",
|
||||||
|
port = "53",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
v2rayConfig.routing.rules.add(
|
||||||
|
0, V2rayConfig.RoutingBean.RulesBean(
|
||||||
|
inboundTag = arrayListOf("tun"),
|
||||||
|
outboundTag = "dns-out",
|
||||||
|
port = "53",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS outbound
|
||||||
|
if (v2rayConfig.outbounds.none { e -> e.protocol == "dns" && e.tag == "dns-out" }) {
|
||||||
|
v2rayConfig.outbounds.add(
|
||||||
|
V2rayConfig.OutboundBean(
|
||||||
|
protocol = "dns",
|
||||||
|
tag = "dns-out",
|
||||||
|
settings = null,
|
||||||
|
streamSettings = null,
|
||||||
|
mux = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove speed-test runtime sections when the feature is disabled.
|
||||||
|
*/
|
||||||
|
private fun applySpeedDisabled(v2rayConfig: V2rayConfig) {
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
|
||||||
|
v2rayConfig.stats = null
|
||||||
|
v2rayConfig.policy = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure DNS servers, hosts, and DNS routing rules.
|
||||||
|
*/
|
||||||
|
private fun configureDns(
|
||||||
|
v2rayConfig: V2rayConfig,
|
||||||
|
policyGroupBalancerTags: Map<String, String>,
|
||||||
|
) {
|
||||||
|
val hosts = mutableMapOf<String, Any>()
|
||||||
|
val servers = ArrayList<Any>()
|
||||||
|
|
||||||
|
//remote Dns
|
||||||
|
val remoteDns = SettingsManager.getRemoteDnsServers()
|
||||||
|
val proxyDomain = (collectUserRuleDomainsByTag(AppConfig.TAG_PROXY) + collectCustomOutboundDomains()).distinct()
|
||||||
|
remoteDns.forEach {
|
||||||
|
servers.add(it)
|
||||||
|
}
|
||||||
|
if (proxyDomain.isNotEmpty()) {
|
||||||
|
servers.add(
|
||||||
|
V2rayConfig.DnsBean.ServersBean(
|
||||||
|
address = remoteDns.first(),
|
||||||
|
domains = proxyDomain,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// domestic DNS
|
||||||
|
val domesticDns = SettingsManager.getDomesticDnsServers()
|
||||||
|
val directDomain = collectUserRuleDomainsByTag(AppConfig.TAG_DIRECT)
|
||||||
|
val isCnRoutingMode = directDomain.contains(AppConfig.GEOSITE_CN)
|
||||||
|
val cnRegionFilter = { domain: String ->
|
||||||
|
domain.startsWith("geosite:") && (domain.endsWith("-cn") || domain.endsWith("@cn"))
|
||||||
|
|| domain == AppConfig.GEOSITE_CN
|
||||||
|
}
|
||||||
|
val finalDirectDomain = if (isCnRoutingMode) directDomain.filterNot {
|
||||||
|
cnRegionFilter(it)
|
||||||
|
} else directDomain
|
||||||
|
val domesticDnsTags = mutableListOf<String>()
|
||||||
|
domesticDns.forEachIndexed { index, element ->
|
||||||
|
val tag = AppConfig.TAG_DOMESTIC_DNS + index
|
||||||
|
servers.add(
|
||||||
|
V2rayConfig.DnsBean.ServersBean(
|
||||||
|
address = element,
|
||||||
|
domains = finalDirectDomain,
|
||||||
|
skipFallback = true,
|
||||||
|
tag = tag
|
||||||
|
)
|
||||||
|
)
|
||||||
|
domesticDnsTags.add(tag)
|
||||||
|
}
|
||||||
|
if (isCnRoutingMode) {
|
||||||
|
val geoipCn = arrayListOf(AppConfig.GEOIP_CN)
|
||||||
|
val cnRegionDomain = directDomain.filter { cnRegionFilter(it) }
|
||||||
|
domesticDns.forEachIndexed { index, element ->
|
||||||
|
val geositeCnDnsTag = AppConfig.TAG_DOMESTIC_DNS + index + "_cn_expect"
|
||||||
|
servers.add(
|
||||||
|
V2rayConfig.DnsBean.ServersBean(
|
||||||
|
address = element,
|
||||||
|
domains = cnRegionDomain,
|
||||||
|
expectIPs = geoipCn,
|
||||||
|
skipFallback = true,
|
||||||
|
tag = geositeCnDnsTag
|
||||||
|
)
|
||||||
|
)
|
||||||
|
domesticDnsTags.add(geositeCnDnsTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//block dns
|
||||||
|
val blkDomain = collectUserRuleDomainsByTag(AppConfig.TAG_BLOCKED)
|
||||||
|
if (blkDomain.isNotEmpty()) {
|
||||||
|
hosts.putAll(blkDomain.map { it to AppConfig.LOOPBACK })
|
||||||
|
}
|
||||||
|
|
||||||
|
// hardcode googleapi rule to fix play store problems
|
||||||
|
hosts[AppConfig.GOOGLEAPIS_CN_DOMAIN] = AppConfig.GOOGLEAPIS_COM_DOMAIN
|
||||||
|
|
||||||
|
// hardcode popular Android Private DNS rule to fix localhost DNS problem
|
||||||
|
hosts[AppConfig.DNS_ALIDNS_DOMAIN] = AppConfig.DNS_ALIDNS_ADDRESSES
|
||||||
|
hosts[AppConfig.DNS_CLOUDFLARE_ONE_DOMAIN] = AppConfig.DNS_CLOUDFLARE_ONE_ADDRESSES
|
||||||
|
hosts[AppConfig.DNS_CLOUDFLARE_DNS_COM_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_COM_ADDRESSES
|
||||||
|
hosts[AppConfig.DNS_CLOUDFLARE_DNS_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_ADDRESSES
|
||||||
|
hosts[AppConfig.DNS_DNSPOD_DOMAIN] = AppConfig.DNS_DNSPOD_ADDRESSES
|
||||||
|
hosts[AppConfig.DNS_GOOGLE_DOMAIN] = AppConfig.DNS_GOOGLE_ADDRESSES
|
||||||
|
hosts[AppConfig.DNS_QUAD9_DOMAIN] = AppConfig.DNS_QUAD9_ADDRESSES
|
||||||
|
hosts[AppConfig.DNS_YANDEX_DOMAIN] = AppConfig.DNS_YANDEX_ADDRESSES
|
||||||
|
|
||||||
|
//User DNS hosts
|
||||||
|
val userHosts = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
|
||||||
|
if (userHosts.isNotNullEmpty()) {
|
||||||
|
val userHostsMap = userHosts?.split(",")
|
||||||
|
?.filter { it.isNotEmpty() }
|
||||||
|
?.filter { it.contains(":") }
|
||||||
|
?.associate { it.split(":").let { (k, v) -> k to v } }
|
||||||
|
if (userHostsMap != null) {
|
||||||
|
hosts.putAll(userHostsMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS dns
|
||||||
|
v2rayConfig.dns = V2rayConfig.DnsBean(
|
||||||
|
servers = servers,
|
||||||
|
hosts = hosts,
|
||||||
|
tag = AppConfig.TAG_DNS,
|
||||||
|
enableParallelQuery = if ((domesticDns.size + remoteDns.size) > 2) true else null
|
||||||
|
)
|
||||||
|
|
||||||
|
// DNS routing
|
||||||
|
v2rayConfig.routing.rules.add(
|
||||||
|
V2rayConfig.RoutingBean.RulesBean(
|
||||||
|
outboundTag = AppConfig.TAG_DIRECT,
|
||||||
|
inboundTag = domesticDnsTags,
|
||||||
|
domain = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val dnsProxyBalancerTag = policyGroupBalancerTags[AppConfig.TAG_PROXY]
|
||||||
|
if (dnsProxyBalancerTag != null) {
|
||||||
|
v2rayConfig.routing.rules.add(
|
||||||
|
V2rayConfig.RoutingBean.RulesBean(
|
||||||
|
balancerTag = dnsProxyBalancerTag,
|
||||||
|
inboundTag = arrayListOf(AppConfig.TAG_DNS),
|
||||||
|
domain = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
v2rayConfig.routing.rules.add(
|
||||||
|
V2rayConfig.RoutingBean.RulesBean(
|
||||||
|
outboundTag = AppConfig.TAG_PROXY,
|
||||||
|
inboundTag = arrayListOf(AppConfig.TAG_DNS),
|
||||||
|
domain = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
|
||||||
|
//region outbound related functions
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve outbound domains to IPs and write resolved hosts to DNS map.
|
||||||
|
*/
|
||||||
|
private fun resolveOutboundDomainsToHosts(v2rayConfig: V2rayConfig) {
|
||||||
|
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "1") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val proxyOutboundList = v2rayConfig.getAllProxyOutbound()
|
||||||
|
val dns = v2rayConfig.dns ?: return
|
||||||
|
val newHosts = dns.hosts?.toMutableMap() ?: mutableMapOf()
|
||||||
|
val preferIpv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true
|
||||||
|
|
||||||
|
for (item in proxyOutboundList) {
|
||||||
|
val domain = item.getServerAddress()
|
||||||
|
if (domain.isNullOrEmpty()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newHosts.containsKey(domain)) {
|
||||||
|
item.ensureSockopt().domainStrategy = "UseIP"
|
||||||
|
item.ensureSockopt().happyEyeballs = V2rayConfig.OutboundBean.StreamSettingsBean.HappyEyeballsBean(
|
||||||
|
prioritizeIPv6 = preferIpv6,
|
||||||
|
interleave = 2
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val resolvedIps = HttpUtil.resolveHostToIP(domain, preferIpv6)
|
||||||
|
if (resolvedIps.isNullOrEmpty()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
item.ensureSockopt().domainStrategy = "UseIP"
|
||||||
|
item.ensureSockopt().happyEyeballs = V2rayConfig.OutboundBean.StreamSettingsBean.HappyEyeballsBean(
|
||||||
|
prioritizeIPv6 = preferIpv6,
|
||||||
|
interleave = 2
|
||||||
|
)
|
||||||
|
newHosts[domain] = if (resolvedIps.size == 1) {
|
||||||
|
resolvedIps[0]
|
||||||
|
} else {
|
||||||
|
resolvedIps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dns.hosts = newHosts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert one profile object into one outbound object.
|
||||||
|
*/
|
||||||
|
private fun convertProfile2Outbound(profileItem: ProfileItem): V2rayConfig.OutboundBean? {
|
||||||
|
return CoreOutboundBuilder.convert(profileItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
|
||||||
|
//region routing related functions
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge probe settings from all balancer strategies into the runtime config.
|
||||||
|
*/
|
||||||
|
private fun applyObservability(v2rayConfig: V2rayConfig, strategies: List<BalancerStrategy>) {
|
||||||
|
val allObsSelectors = strategies
|
||||||
|
.mapNotNull { it.observatory?.subjectSelector }
|
||||||
|
.flatten()
|
||||||
|
.distinct()
|
||||||
|
val obsTemplate = strategies.firstNotNullOfOrNull { it.observatory }
|
||||||
|
if (obsTemplate != null && allObsSelectors.isNotEmpty()) {
|
||||||
|
v2rayConfig.observatory = V2rayConfig.ObservatoryObject(
|
||||||
|
subjectSelector = allObsSelectors,
|
||||||
|
probeUrl = obsTemplate.probeUrl,
|
||||||
|
probeInterval = obsTemplate.probeInterval,
|
||||||
|
enableConcurrency = obsTemplate.enableConcurrency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val allBurstSelectors = strategies
|
||||||
|
.mapNotNull { it.burstObservatory?.subjectSelector }
|
||||||
|
.flatten()
|
||||||
|
.distinct()
|
||||||
|
val burstTemplate = strategies.firstNotNullOfOrNull { it.burstObservatory }
|
||||||
|
if (burstTemplate != null && allBurstSelectors.isNotEmpty()) {
|
||||||
|
v2rayConfig.burstObservatory = V2rayConfig.BurstObservatoryObject(
|
||||||
|
subjectSelector = allBurstSelectors,
|
||||||
|
pingConfig = burstTemplate.pingConfig
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure routing domain strategy and append enabled user rules.
|
||||||
|
*/
|
||||||
|
private fun configureRouting(
|
||||||
|
configContext: CoreConfigContext,
|
||||||
|
v2rayConfig: V2rayConfig,
|
||||||
|
policyGroupBalancerTags: Map<String, String>
|
||||||
|
) {
|
||||||
|
|
||||||
|
v2rayConfig.routing.domainStrategy =
|
||||||
|
MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY)
|
||||||
|
?: "AsIs"
|
||||||
|
|
||||||
|
val rulesetItems = MmkvManager.decodeRoutingRulesets()
|
||||||
|
rulesetItems?.forEach { key ->
|
||||||
|
appendRoutingUserRule(configContext, key, v2rayConfig, policyGroupBalancerTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert one rule item and append it to routing rules.
|
||||||
|
*/
|
||||||
|
private fun appendRoutingUserRule(
|
||||||
|
configContext: CoreConfigContext,
|
||||||
|
item: RulesetItem?,
|
||||||
|
v2rayConfig: V2rayConfig,
|
||||||
|
policyGroupBalancerTags: Map<String, String>
|
||||||
|
) {
|
||||||
|
val context = configContext.context
|
||||||
|
if (item == null || !item.enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val rule = JsonUtil.fromJson(JsonUtil.toJson(item), V2rayConfig.RoutingBean.RulesBean::class.java) ?: return
|
||||||
|
|
||||||
|
// Replace specific geoip rules with ext versions
|
||||||
|
rule.ip?.let { ipList ->
|
||||||
|
val updatedIpList = ArrayList<String>()
|
||||||
|
ipList.forEach { ip ->
|
||||||
|
when (ip) {
|
||||||
|
AppConfig.GEOIP_CN -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:cn")
|
||||||
|
AppConfig.GEOIP_PRIVATE -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:private")
|
||||||
|
else -> updatedIpList.add(ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.ip = updatedIpList
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SettingsManager.canUseProcessRouting()) {
|
||||||
|
// Convert process package names to UIDs
|
||||||
|
rule.process?.let { processList ->
|
||||||
|
if (processList.isNotEmpty()) {
|
||||||
|
val uids = PackageUidResolver.packageNamesToUids(context, processList)
|
||||||
|
rule.process = uids.ifEmpty { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rule.process = null
|
||||||
|
}
|
||||||
|
|
||||||
|
val outboundTag = rule.outboundTag
|
||||||
|
|
||||||
|
// Route rules targeting a custom policy-group tag should hit its balancer.
|
||||||
|
policyGroupBalancerTags[outboundTag]?.let { balancerTag ->
|
||||||
|
rule.outboundTag = null
|
||||||
|
rule.balancerTag = balancerTag
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the outbound tag is a custom one that failed to inject, fall back to proxy
|
||||||
|
if (!outboundTag.isNullOrBlank()
|
||||||
|
&& outboundTag !in policyGroupBalancerTags
|
||||||
|
&& outboundTag !in AppConfig.BUILTIN_OUTBOUND_TAGS
|
||||||
|
&& v2rayConfig.outbounds.none { it.tag == outboundTag }
|
||||||
|
) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "Outbound tag '$outboundTag' not found, falling back to '${AppConfig.TAG_PROXY}'")
|
||||||
|
rule.outboundTag = AppConfig.TAG_PROXY
|
||||||
|
}
|
||||||
|
|
||||||
|
v2rayConfig.routing.rules.add(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build balancer and probe settings from one policy-group strategy value.
|
||||||
|
*/
|
||||||
|
private fun buildBalancerStrategy(
|
||||||
|
policyGroupType: String?,
|
||||||
|
selector: List<String>,
|
||||||
|
balancerTag: String = AppConfig.TAG_BALANCER,
|
||||||
|
): BalancerStrategy {
|
||||||
|
val probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL
|
||||||
|
val strategyType = BalancerStrategyType.from(policyGroupType)
|
||||||
|
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
||||||
|
tag = balancerTag,
|
||||||
|
selector = selector,
|
||||||
|
strategy = V2rayConfig.RoutingBean.StrategyObject(type = strategyType.policyGroupType)
|
||||||
|
)
|
||||||
|
val observatory = if (strategyType.requiresObservatory) {
|
||||||
|
V2rayConfig.ObservatoryObject(
|
||||||
|
subjectSelector = selector,
|
||||||
|
probeUrl = probeUrl,
|
||||||
|
probeInterval = "3m",
|
||||||
|
enableConcurrency = true
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
val burstObservatory = if (strategyType.requiresBurstObservatory) {
|
||||||
|
V2rayConfig.BurstObservatoryObject(
|
||||||
|
subjectSelector = selector,
|
||||||
|
pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject(
|
||||||
|
destination = probeUrl,
|
||||||
|
interval = "5m",
|
||||||
|
sampling = 2,
|
||||||
|
timeout = "30s"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
return BalancerStrategy(balancer, observatory, burstObservatory)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carry balancer data plus optional probe settings for later merge.
|
||||||
|
*/
|
||||||
|
private data class BalancerStrategy(
|
||||||
|
val balancer: V2rayConfig.RoutingBean.BalancerBean,
|
||||||
|
val observatory: V2rayConfig.ObservatoryObject? = null,
|
||||||
|
val burstObservatory: V2rayConfig.BurstObservatoryObject? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
//endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.v2ray.ang.core
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
import com.v2ray.ang.util.Utils
|
||||||
|
import go.Seq
|
||||||
|
import libv2ray.CoreCallbackHandler
|
||||||
|
import libv2ray.CoreController
|
||||||
|
import libv2ray.Libv2ray
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2Ray Native Library Manager
|
||||||
|
*
|
||||||
|
* Thread-safe singleton wrapper for Libv2ray native methods.
|
||||||
|
* Provides initialization protection and unified API for V2Ray core operations.
|
||||||
|
*/
|
||||||
|
object CoreNativeManager {
|
||||||
|
private val initialized = AtomicBoolean(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize V2Ray core environment.
|
||||||
|
* This method is thread-safe and ensures initialization happens only once.
|
||||||
|
* Subsequent calls will be ignored silently.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
fun initCoreEnv(context: Context?) {
|
||||||
|
if (initialized.compareAndSet(false, true)) {
|
||||||
|
try {
|
||||||
|
Seq.setContext(context?.applicationContext)
|
||||||
|
val assetPath = Utils.userAssetPath(context)
|
||||||
|
val deviceId = Utils.getDeviceIdForXUDPBaseKey()
|
||||||
|
Libv2ray.initCoreEnv(assetPath, deviceId)
|
||||||
|
LogUtil.i(AppConfig.TAG, "V2Ray core environment initialized successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to initialize V2Ray core environment", e)
|
||||||
|
initialized.set(false)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get V2Ray core version.
|
||||||
|
*
|
||||||
|
* @return Version string of the V2Ray core
|
||||||
|
*/
|
||||||
|
fun getLibVersion(): String {
|
||||||
|
return try {
|
||||||
|
Libv2ray.checkVersionX()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to check V2Ray version", e)
|
||||||
|
"Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure outbound connection delay.
|
||||||
|
*
|
||||||
|
* @param config The configuration JSON string
|
||||||
|
* @param testUrl The URL to test against
|
||||||
|
* @return Delay in milliseconds, or -1 if test failed
|
||||||
|
*/
|
||||||
|
fun measureOutboundDelay(config: String, testUrl: String): Long {
|
||||||
|
return try {
|
||||||
|
Libv2ray.measureOutboundDelay(config, testUrl)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to measure outbound delay", e)
|
||||||
|
-1L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new core controller instance.
|
||||||
|
*
|
||||||
|
* @param handler The callback handler for core events
|
||||||
|
* @return A new CoreController instance
|
||||||
|
*/
|
||||||
|
fun newCoreController(handler: CoreCallbackHandler): CoreController {
|
||||||
|
return try {
|
||||||
|
Libv2ray.newCoreController(handler)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to create core controller", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
package com.v2ray.ang.core
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||||
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.enums.NetworkType
|
||||||
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.util.HttpUtil
|
||||||
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
import com.v2ray.ang.util.Utils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralizes ProfileItem -> OutboundBean conversion.
|
||||||
|
* Most protocol builders mirror the previous *Fmt.toOutbound behavior.
|
||||||
|
*/
|
||||||
|
object CoreOutboundBuilder {
|
||||||
|
|
||||||
|
/** Dispatches a profile to protocol-specific outbound builder. */
|
||||||
|
fun convert(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outbound = when (profileItem.configType) {
|
||||||
|
EConfigType.VMESS -> toOutboundVmess(profileItem)
|
||||||
|
EConfigType.SHADOWSOCKS -> toOutboundShadowsocks(profileItem)
|
||||||
|
EConfigType.SOCKS -> toOutboundSocks(profileItem)
|
||||||
|
EConfigType.VLESS -> toOutboundVless(profileItem)
|
||||||
|
EConfigType.TROJAN -> toOutboundTrojan(profileItem)
|
||||||
|
EConfigType.WIREGUARD -> toOutboundWireguard(profileItem)
|
||||||
|
EConfigType.HYSTERIA2 -> toOutboundHysteria2(profileItem)
|
||||||
|
EConfigType.HTTP -> toOutboundHttp(profileItem)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
outbound ?: return null
|
||||||
|
val ret = updateOutboundWithGlobalSettings(outbound)
|
||||||
|
if (!ret) return null
|
||||||
|
return outbound
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies global outbound options (mux, protocol-specific tweaks, etc.). */
|
||||||
|
private fun updateOutboundWithGlobalSettings(outbound: OutboundBean): Boolean {
|
||||||
|
try {
|
||||||
|
var muxEnabled = MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false)
|
||||||
|
val protocol = outbound.protocol
|
||||||
|
if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||||
|
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||||
|
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||||
|
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||||
|
|| protocol.equals(EConfigType.WIREGUARD.name, true)
|
||||||
|
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||||
|
|| protocol.equals(EConfigType.HYSTERIA.name, true)
|
||||||
|
) {
|
||||||
|
muxEnabled = false
|
||||||
|
} else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) {
|
||||||
|
muxEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (muxEnabled) {
|
||||||
|
outbound.mux?.enabled = true
|
||||||
|
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
|
||||||
|
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
|
||||||
|
outbound.mux?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
|
||||||
|
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.vnext?.first()?.users?.first()?.flow?.isNotEmpty() == true) {
|
||||||
|
outbound.mux?.concurrency = -1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
outbound.mux?.enabled = false
|
||||||
|
outbound.mux?.concurrency = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to update outbound with global settings", e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates an initial outbound template for a protocol type. */
|
||||||
|
fun createInitOutbound(configType: EConfigType): OutboundBean? {
|
||||||
|
return when (configType) {
|
||||||
|
EConfigType.VMESS,
|
||||||
|
EConfigType.VLESS ->
|
||||||
|
return OutboundBean(
|
||||||
|
protocol = configType.name.lowercase(),
|
||||||
|
settings = OutboundBean.OutSettingsBean(
|
||||||
|
vnext = listOf(
|
||||||
|
OutboundBean.OutSettingsBean.VnextBean(
|
||||||
|
users = listOf(OutboundBean.OutSettingsBean.VnextBean.UsersBean())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
streamSettings = OutboundBean.StreamSettingsBean()
|
||||||
|
)
|
||||||
|
|
||||||
|
EConfigType.SHADOWSOCKS,
|
||||||
|
EConfigType.SOCKS,
|
||||||
|
EConfigType.HTTP,
|
||||||
|
EConfigType.TROJAN ->
|
||||||
|
return OutboundBean(
|
||||||
|
protocol = configType.name.lowercase(),
|
||||||
|
settings = OutboundBean.OutSettingsBean(
|
||||||
|
servers = listOf(OutboundBean.OutSettingsBean.ServersBean())
|
||||||
|
),
|
||||||
|
streamSettings = OutboundBean.StreamSettingsBean()
|
||||||
|
)
|
||||||
|
|
||||||
|
EConfigType.WIREGUARD ->
|
||||||
|
return OutboundBean(
|
||||||
|
protocol = configType.name.lowercase(),
|
||||||
|
settings = OutboundBean.OutSettingsBean(
|
||||||
|
secretKey = "",
|
||||||
|
peers = listOf(OutboundBean.OutSettingsBean.WireGuardBean())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
EConfigType.HYSTERIA,
|
||||||
|
EConfigType.HYSTERIA2 ->
|
||||||
|
return OutboundBean(
|
||||||
|
protocol = EConfigType.HYSTERIA.name.lowercase(),
|
||||||
|
settings = OutboundBean.OutSettingsBean(
|
||||||
|
servers = null
|
||||||
|
),
|
||||||
|
streamSettings = OutboundBean.StreamSettingsBean()
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-protocol builders — implementations are identical to each *Fmt.toOutbound ──
|
||||||
|
|
||||||
|
private fun toOutboundVmess(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outboundBean = createInitOutbound(EConfigType.VMESS)
|
||||||
|
|
||||||
|
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||||
|
vnext.address = getServerAddress(profileItem)
|
||||||
|
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
||||||
|
vnext.users[0].id = profileItem.password.orEmpty()
|
||||||
|
vnext.users[0].security = profileItem.method
|
||||||
|
}
|
||||||
|
|
||||||
|
val sni = outboundBean?.streamSettings?.let {
|
||||||
|
populateTransportSettings(it, profileItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
outboundBean?.streamSettings?.let {
|
||||||
|
populateTlsSettings(it, profileItem, sni)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outboundBean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toOutboundVless(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outboundBean = createInitOutbound(EConfigType.VLESS)
|
||||||
|
|
||||||
|
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||||
|
vnext.address = getServerAddress(profileItem)
|
||||||
|
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
||||||
|
vnext.users[0].id = profileItem.password.orEmpty()
|
||||||
|
vnext.users[0].encryption = profileItem.method
|
||||||
|
vnext.users[0].flow = profileItem.flow
|
||||||
|
}
|
||||||
|
|
||||||
|
val sni = outboundBean?.streamSettings?.let {
|
||||||
|
populateTransportSettings(it, profileItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
outboundBean?.streamSettings?.let {
|
||||||
|
populateTlsSettings(it, profileItem, sni)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outboundBean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toOutboundShadowsocks(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outboundBean = createInitOutbound(EConfigType.SHADOWSOCKS)
|
||||||
|
|
||||||
|
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||||
|
server.address = getServerAddress(profileItem)
|
||||||
|
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||||
|
server.password = profileItem.password
|
||||||
|
server.method = profileItem.method
|
||||||
|
}
|
||||||
|
|
||||||
|
val sni = outboundBean?.streamSettings?.let {
|
||||||
|
populateTransportSettings(it, profileItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
outboundBean?.streamSettings?.let {
|
||||||
|
populateTlsSettings(it, profileItem, sni)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outboundBean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toOutboundTrojan(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outboundBean = createInitOutbound(EConfigType.TROJAN)
|
||||||
|
|
||||||
|
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||||
|
server.address = getServerAddress(profileItem)
|
||||||
|
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||||
|
server.password = profileItem.password
|
||||||
|
server.flow = profileItem.flow
|
||||||
|
}
|
||||||
|
|
||||||
|
val sni = outboundBean?.streamSettings?.let {
|
||||||
|
populateTransportSettings(it, profileItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
outboundBean?.streamSettings?.let {
|
||||||
|
populateTlsSettings(it, profileItem, sni)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outboundBean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toOutboundSocks(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outboundBean = createInitOutbound(EConfigType.SOCKS)
|
||||||
|
|
||||||
|
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||||
|
server.address = getServerAddress(profileItem)
|
||||||
|
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||||
|
if (profileItem.username.isNotNullEmpty()) {
|
||||||
|
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
|
||||||
|
socksUsersBean.user = profileItem.username.orEmpty()
|
||||||
|
socksUsersBean.pass = profileItem.password.orEmpty()
|
||||||
|
server.users = listOf(socksUsersBean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outboundBean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toOutboundHttp(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outboundBean = createInitOutbound(EConfigType.HTTP)
|
||||||
|
|
||||||
|
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||||
|
server.address = getServerAddress(profileItem)
|
||||||
|
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||||
|
if (profileItem.username.isNotNullEmpty()) {
|
||||||
|
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
|
||||||
|
socksUsersBean.user = profileItem.username.orEmpty()
|
||||||
|
socksUsersBean.pass = profileItem.password.orEmpty()
|
||||||
|
server.users = listOf(socksUsersBean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outboundBean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toOutboundWireguard(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outboundBean = createInitOutbound(EConfigType.WIREGUARD)
|
||||||
|
|
||||||
|
val rawAddresses = profileItem.localAddress
|
||||||
|
?.split(",")
|
||||||
|
?.map { it.trim() }
|
||||||
|
?.filter { it.isNotEmpty() }
|
||||||
|
?.ifEmpty { null }
|
||||||
|
?: listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4)
|
||||||
|
|
||||||
|
val addresses = if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
|
||||||
|
rawAddresses
|
||||||
|
} else {
|
||||||
|
val ipv4Addresses = rawAddresses.filter { !it.contains(":") }
|
||||||
|
ipv4Addresses.ifEmpty { listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4) }
|
||||||
|
}
|
||||||
|
|
||||||
|
outboundBean?.settings?.let { wireguard ->
|
||||||
|
wireguard.secretKey = profileItem.secretKey
|
||||||
|
wireguard.address = addresses
|
||||||
|
wireguard.peers?.firstOrNull()?.let { peer ->
|
||||||
|
peer.publicKey = profileItem.publicKey.orEmpty()
|
||||||
|
peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
|
||||||
|
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
|
||||||
|
}
|
||||||
|
wireguard.mtu = profileItem.mtu
|
||||||
|
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
|
||||||
|
}
|
||||||
|
|
||||||
|
return outboundBean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toOutboundHysteria2(profileItem: ProfileItem): OutboundBean? {
|
||||||
|
val outboundBean = createInitOutbound(EConfigType.HYSTERIA2) ?: return null
|
||||||
|
profileItem.network = NetworkType.HYSTERIA.type
|
||||||
|
profileItem.alpn = "h3"
|
||||||
|
|
||||||
|
outboundBean.settings?.let { server ->
|
||||||
|
server.address = getServerAddress(profileItem)
|
||||||
|
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||||
|
server.version = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
val sni = outboundBean.streamSettings?.let {
|
||||||
|
populateTransportSettings(it, profileItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
outboundBean.streamSettings?.let {
|
||||||
|
populateTlsSettings(it, profileItem, sni)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outboundBean
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createTcpHttpRequest(
|
||||||
|
host: String?,
|
||||||
|
path: String?
|
||||||
|
): OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean {
|
||||||
|
val requestString =
|
||||||
|
"""{"version":"1.1","method":"GET","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.122 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate"],"Connection":["keep-alive"],"Pragma":"no-cache"}}"""
|
||||||
|
val request = JsonUtil.fromJson(
|
||||||
|
requestString,
|
||||||
|
OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean::class.java
|
||||||
|
) ?: OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean()
|
||||||
|
|
||||||
|
val parsedHost = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
request.headers.Host = parsedHost.ifEmpty { null }
|
||||||
|
request.path = path.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }.ifEmpty { listOf("/") }
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures transport settings for an outbound connection.
|
||||||
|
*
|
||||||
|
* Sets up protocol-specific transport options based on the profile settings.
|
||||||
|
*
|
||||||
|
* @param streamSettings The stream settings to configure
|
||||||
|
* @param profileItem The profile containing transport configuration
|
||||||
|
* @return The Server Name Indication (SNI) value to use, or null if not applicable
|
||||||
|
*/
|
||||||
|
fun populateTransportSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem): String? {
|
||||||
|
val transport = profileItem.network.orEmpty()
|
||||||
|
val headerType = profileItem.headerType
|
||||||
|
val host = profileItem.host
|
||||||
|
val path = profileItem.path
|
||||||
|
val seed = profileItem.seed
|
||||||
|
// val quicSecurity = profileItem.quicSecurity
|
||||||
|
// val key = profileItem.quicKey
|
||||||
|
val mode = profileItem.mode
|
||||||
|
val serviceName = profileItem.serviceName
|
||||||
|
val authority = profileItem.authority
|
||||||
|
val xhttpMode = profileItem.xhttpMode
|
||||||
|
val xhttpExtra = profileItem.xhttpExtra
|
||||||
|
val finalMask = profileItem.finalMask
|
||||||
|
var sni: String? = null
|
||||||
|
streamSettings.network = transport.ifEmpty { NetworkType.TCP.type }
|
||||||
|
when (streamSettings.network) {
|
||||||
|
NetworkType.TCP.type -> {
|
||||||
|
val tcpSetting = OutboundBean.StreamSettingsBean.TcpSettingsBean()
|
||||||
|
if (headerType == AppConfig.HEADER_TYPE_HTTP) {
|
||||||
|
tcpSetting.header.type = AppConfig.HEADER_TYPE_HTTP
|
||||||
|
val requestObj = createTcpHttpRequest(host, path)
|
||||||
|
tcpSetting.header.request = requestObj
|
||||||
|
sni = requestObj.headers.Host?.getOrNull(0)
|
||||||
|
} else {
|
||||||
|
tcpSetting.header.type = "none"
|
||||||
|
sni = host
|
||||||
|
}
|
||||||
|
streamSettings.tcpSettings = tcpSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkType.KCP.type -> {
|
||||||
|
val kcpSetting = OutboundBean.StreamSettingsBean.KcpSettingsBean()
|
||||||
|
profileItem.kcpMtu?.let { kcpSetting.mtu = it }
|
||||||
|
profileItem.kcpTti?.let { kcpSetting.tti = it }
|
||||||
|
streamSettings.kcpSettings = kcpSetting
|
||||||
|
val udpMaskList = mutableListOf<OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean>()
|
||||||
|
if (!headerType.isNullOrEmpty() && headerType != "none") {
|
||||||
|
val kcpHeaderType = when {
|
||||||
|
headerType == "wechat-video" -> "header-wechat"
|
||||||
|
else -> "header-$headerType"
|
||||||
|
}
|
||||||
|
udpMaskList.add(
|
||||||
|
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||||
|
type = kcpHeaderType,
|
||||||
|
settings = if (headerType == "dns" && !host.isNullOrEmpty()) {
|
||||||
|
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||||
|
domain = host
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (seed.isNullOrEmpty()) {
|
||||||
|
udpMaskList.add(
|
||||||
|
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||||
|
type = "mkcp-original"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
udpMaskList.add(
|
||||||
|
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||||
|
type = "mkcp-aes128gcm",
|
||||||
|
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||||
|
password = seed
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
streamSettings.finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
|
||||||
|
udp = udpMaskList.toList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkType.WS.type -> {
|
||||||
|
val wssetting = OutboundBean.StreamSettingsBean.WsSettingsBean()
|
||||||
|
wssetting.host = host.orEmpty()
|
||||||
|
sni = host
|
||||||
|
wssetting.path = path ?: "/"
|
||||||
|
streamSettings.wsSettings = wssetting
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkType.HTTP_UPGRADE.type -> {
|
||||||
|
val httpupgradeSetting = OutboundBean.StreamSettingsBean.HttpupgradeSettingsBean()
|
||||||
|
httpupgradeSetting.host = host.orEmpty()
|
||||||
|
sni = host
|
||||||
|
httpupgradeSetting.path = path ?: "/"
|
||||||
|
streamSettings.httpupgradeSettings = httpupgradeSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkType.XHTTP.type -> {
|
||||||
|
val xhttpSetting = OutboundBean.StreamSettingsBean.XhttpSettingsBean()
|
||||||
|
xhttpSetting.host = host.orEmpty()
|
||||||
|
sni = host
|
||||||
|
xhttpSetting.path = path ?: "/"
|
||||||
|
xhttpSetting.mode = xhttpMode
|
||||||
|
xhttpSetting.extra = JsonUtil.parseString(xhttpExtra)
|
||||||
|
streamSettings.xhttpSettings = xhttpSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkType.H2.type, NetworkType.HTTP.type -> {
|
||||||
|
streamSettings.network = NetworkType.H2.type
|
||||||
|
val h2Setting = OutboundBean.StreamSettingsBean.HttpSettingsBean()
|
||||||
|
h2Setting.host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
sni = h2Setting.host.getOrNull(0)
|
||||||
|
h2Setting.path = path ?: "/"
|
||||||
|
streamSettings.httpSettings = h2Setting
|
||||||
|
}
|
||||||
|
|
||||||
|
// "quic" -> {
|
||||||
|
// val quicsetting = QuicSettingBean()
|
||||||
|
// quicsetting.security = quicSecurity ?: "none"
|
||||||
|
// quicsetting.key = key.orEmpty()
|
||||||
|
// quicsetting.header.type = headerType ?: "none"
|
||||||
|
// quicSettings = quicsetting
|
||||||
|
// }
|
||||||
|
|
||||||
|
NetworkType.GRPC.type -> {
|
||||||
|
val grpcSetting = OutboundBean.StreamSettingsBean.GrpcSettingsBean()
|
||||||
|
grpcSetting.multiMode = mode == "multi"
|
||||||
|
grpcSetting.serviceName = serviceName.orEmpty()
|
||||||
|
grpcSetting.authority = authority.orEmpty()
|
||||||
|
grpcSetting.idle_timeout = 60
|
||||||
|
grpcSetting.health_check_timeout = 20
|
||||||
|
sni = authority
|
||||||
|
streamSettings.grpcSettings = grpcSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkType.HYSTERIA.type -> {
|
||||||
|
val hysteriaSetting = OutboundBean.StreamSettingsBean.HysteriaSettingsBean(
|
||||||
|
version = 2,
|
||||||
|
auth = profileItem.password.orEmpty(),
|
||||||
|
)
|
||||||
|
val quicParams = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean(
|
||||||
|
brutalUp = profileItem.bandwidthUp?.nullIfBlank(),
|
||||||
|
brutalDown = profileItem.bandwidthDown?.nullIfBlank(),
|
||||||
|
)
|
||||||
|
quicParams.congestion = if (quicParams.brutalUp != null || quicParams.brutalDown != null) "brutal" else null
|
||||||
|
if (profileItem.portHopping.isNotNullEmpty()) {
|
||||||
|
val rawInterval = profileItem.portHoppingInterval?.trim().nullIfBlank()
|
||||||
|
val interval = if (rawInterval == null) {
|
||||||
|
"30"
|
||||||
|
} else {
|
||||||
|
val singleValue = rawInterval.toIntOrNull()
|
||||||
|
if (singleValue != null) {
|
||||||
|
if (singleValue < 5) {
|
||||||
|
"30"
|
||||||
|
} else {
|
||||||
|
rawInterval
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val parts = rawInterval.split('-')
|
||||||
|
if (parts.size == 2) {
|
||||||
|
val start = parts[0].trim().toIntOrNull()
|
||||||
|
val end = parts[1].trim().toIntOrNull()
|
||||||
|
if (start != null && end != null) {
|
||||||
|
val minStart = maxOf(5, start)
|
||||||
|
val minEnd = maxOf(minStart, end)
|
||||||
|
"$minStart-$minEnd"
|
||||||
|
} else {
|
||||||
|
"30"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"30"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quicParams.udpHop = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean.UdpHopBean(
|
||||||
|
ports = profileItem.portHopping,
|
||||||
|
interval = interval
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
|
||||||
|
quicParams = quicParams
|
||||||
|
)
|
||||||
|
if (profileItem.obfsPassword.isNotNullEmpty()) {
|
||||||
|
finalmask.udp = listOf(
|
||||||
|
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||||
|
type = "salamander",
|
||||||
|
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||||
|
password = profileItem.obfsPassword.orEmpty()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
streamSettings.hysteriaSettings = hysteriaSetting
|
||||||
|
streamSettings.finalmask = finalmask
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalMask?.let {
|
||||||
|
val parsedFinalMask = JsonUtil.parseString(finalMask)
|
||||||
|
if (parsedFinalMask != null) {
|
||||||
|
streamSettings.finalmask = parsedFinalMask
|
||||||
|
} else {
|
||||||
|
LogUtil.w("V2rayConfigManager", "Invalid finalMask JSON, keeping previously generated finalmask")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sni
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures TLS or REALITY security settings for an outbound connection.
|
||||||
|
*
|
||||||
|
* Sets up security-related parameters like certificates, fingerprints, and SNI.
|
||||||
|
*
|
||||||
|
* @param streamSettings The stream settings to configure
|
||||||
|
* @param profileItem The profile containing security configuration
|
||||||
|
* @param sniExt An external SNI value to use if the profile doesn't specify one
|
||||||
|
*/
|
||||||
|
fun populateTlsSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem, sniExt: String?) {
|
||||||
|
val streamSecurity = profileItem.security.orEmpty()
|
||||||
|
val allowInsecure = profileItem.insecure == true && profileItem.pinnedCA256.isNullOrEmpty()
|
||||||
|
val sni = if (profileItem.sni.isNullOrEmpty()) {
|
||||||
|
when {
|
||||||
|
sniExt.isNotNullEmpty() && Utils.isDomainName(sniExt) -> sniExt
|
||||||
|
profileItem.server.isNotNullEmpty() && Utils.isDomainName(profileItem.server) -> profileItem.server
|
||||||
|
else -> sniExt
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
profileItem.sni
|
||||||
|
}
|
||||||
|
|
||||||
|
streamSettings.security = streamSecurity.nullIfBlank()
|
||||||
|
if (streamSettings.security == null) return
|
||||||
|
val tlsSetting = OutboundBean.StreamSettingsBean.TlsSettingsBean(
|
||||||
|
allowInsecure = allowInsecure,
|
||||||
|
serverName = sni.nullIfBlank(),
|
||||||
|
fingerprint = profileItem.fingerPrint.nullIfBlank(),
|
||||||
|
alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() },
|
||||||
|
echConfigList = profileItem.echConfigList.nullIfBlank(),
|
||||||
|
pinnedPeerCertSha256 = profileItem.pinnedCA256.nullIfBlank(),
|
||||||
|
publicKey = profileItem.publicKey.nullIfBlank(),
|
||||||
|
shortId = profileItem.shortId.nullIfBlank(),
|
||||||
|
spiderX = profileItem.spiderX.nullIfBlank(),
|
||||||
|
mldsa65Verify = profileItem.mldsa65Verify.nullIfBlank(),
|
||||||
|
)
|
||||||
|
if (streamSettings.security == AppConfig.TLS) {
|
||||||
|
streamSettings.tlsSettings = tlsSetting
|
||||||
|
streamSettings.realitySettings = null
|
||||||
|
} else if (streamSettings.security == AppConfig.REALITY) {
|
||||||
|
streamSettings.tlsSettings = null
|
||||||
|
streamSettings.realitySettings = tlsSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileItem.finalMask.isNullOrEmpty()) {
|
||||||
|
updateOutboundFragment(streamSettings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the outbound with fragment settings for traffic optimization.
|
||||||
|
*
|
||||||
|
* Configures packet fragmentation for TLS and REALITY protocols if enabled.
|
||||||
|
*
|
||||||
|
* @param streamSettings The streamSettings object to be modified
|
||||||
|
* @return true if fragment configuration was successful, false otherwise
|
||||||
|
*/
|
||||||
|
private fun updateOutboundFragment(streamSettings: OutboundBean.StreamSettingsBean): Boolean {
|
||||||
|
try {
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == false) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (streamSettings.security != AppConfig.TLS
|
||||||
|
&& streamSettings.security != AppConfig.REALITY
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (streamSettings.sockopt?.dialerProxy.isNotNullEmpty()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var packets =
|
||||||
|
MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS) ?: "tlshello"
|
||||||
|
if (streamSettings.security == AppConfig.REALITY
|
||||||
|
&& packets == "tlshello"
|
||||||
|
) {
|
||||||
|
packets = "1-3"
|
||||||
|
} else if (streamSettings.security == AppConfig.TLS
|
||||||
|
&& packets != "tlshello"
|
||||||
|
) {
|
||||||
|
packets = "tlshello"
|
||||||
|
}
|
||||||
|
|
||||||
|
val fragmentMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||||
|
type = "fragment",
|
||||||
|
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||||
|
packets = packets,
|
||||||
|
length = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH)
|
||||||
|
?: "50-100",
|
||||||
|
delay = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL)
|
||||||
|
?: "10-20"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val noiseMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||||
|
type = "noise",
|
||||||
|
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||||
|
noise = listOf(
|
||||||
|
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean.NoiseMaskBean(
|
||||||
|
rand = "10-20",
|
||||||
|
delay = "10-16",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val finalMaskObj = streamSettings.finalmask?.let { existingFinalMask ->
|
||||||
|
JsonUtil.parseString(JsonUtil.toJson(existingFinalMask))
|
||||||
|
} ?: JsonObject()
|
||||||
|
|
||||||
|
// finalmask.tcp / finalmask.udp are arrays; prepend mask at index 0.
|
||||||
|
fun prependMask(scope: String, mask: OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean) {
|
||||||
|
val current = finalMaskObj.get(scope)
|
||||||
|
if (current != null && current.isJsonArray && current.asJsonArray.size() > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val newArray = JsonArray()
|
||||||
|
newArray.add(JsonUtil.parseString(JsonUtil.toJson(mask)))
|
||||||
|
|
||||||
|
if (current != null && current.isJsonArray) {
|
||||||
|
current.asJsonArray.forEach { newArray.add(it) }
|
||||||
|
}
|
||||||
|
finalMaskObj.add(scope, newArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
prependMask("tcp", fragmentMask)
|
||||||
|
prependMask("udp", noiseMask)
|
||||||
|
streamSettings.finalmask = finalMaskObj
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Failed to update outbound fragment", e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getServerAddress(profileItem: ProfileItem): String {
|
||||||
|
if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
|
||||||
|
return profileItem.server.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
|
||||||
|
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
//Resolve and replace domain
|
||||||
|
val resolvedIps = HttpUtil.resolveHostToIP(domain, MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
|
||||||
|
if (resolvedIps.isNullOrEmpty()) {
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
return resolvedIps.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
package com.v2ray.ang.core
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import android.system.OsConstants
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.contracts.ServiceControl
|
||||||
|
import com.v2ray.ang.dto.OutboundTrafficStat
|
||||||
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.extension.isComplexType
|
||||||
|
import com.v2ray.ang.extension.toast
|
||||||
|
import com.v2ray.ang.extension.toastError
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.handler.NotificationManager
|
||||||
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
|
import com.v2ray.ang.handler.SpeedtestManager
|
||||||
|
import com.v2ray.ang.service.CoreProxyOnlyService
|
||||||
|
import com.v2ray.ang.service.CoreVpnService
|
||||||
|
import com.v2ray.ang.service.DialerNativeService
|
||||||
|
import com.v2ray.ang.service.DialerWebviewService
|
||||||
|
import com.v2ray.ang.service.IDialerService
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
import com.v2ray.ang.util.MessageUtil
|
||||||
|
import com.v2ray.ang.util.Utils
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import libv2ray.CoreCallbackHandler
|
||||||
|
import libv2ray.CoreController
|
||||||
|
import libv2ray.ProcessFinder
|
||||||
|
import java.lang.ref.SoftReference
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
|
||||||
|
object 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
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
startContextService(context)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: ${e.message}", e)
|
||||||
|
context.toast(e.message ?: e.javaClass.simpleName)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
LogUtil.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
|
||||||
|
|
||||||
|
if (guid != null) {
|
||||||
|
MmkvManager.setSelectServer(guid)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
startContextService(context)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: ${e.message}", e)
|
||||||
|
context.toast(e.message ?: e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* @throws IllegalStateException if the core is already running, no server is selected,
|
||||||
|
* server config cannot be decoded, or server configuration is invalid.
|
||||||
|
* @throws Exception if the foreground service fails to start.
|
||||||
|
*/
|
||||||
|
@Throws(Exception::class)
|
||||||
|
private fun startContextService(context: Context) {
|
||||||
|
if (coreController.isRunning) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val guid = MmkvManager.getSelectServer()
|
||||||
|
?: run {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
|
||||||
|
error(context.getString(R.string.app_tile_first_use))
|
||||||
|
}
|
||||||
|
|
||||||
|
val config = MmkvManager.decodeServerConfig(guid)
|
||||||
|
?: run {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: 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, "StartCore-Manager: Invalid server configuration")
|
||||||
|
error(context.getString(R.string.toast_config_file_invalid))
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh socks port when enabled dynamic socks port
|
||||||
|
SettingsManager.refreshRuntimeSocksPort()
|
||||||
|
|
||||||
|
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
|
||||||
|
// if (!result.status) error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
|
||||||
|
|
||||||
|
if (config.insecure == true) {
|
||||||
|
context.toastError(R.string.toast_allow_insecure_deprecated)
|
||||||
|
context.toastError(R.string.toast_allow_insecure_deprecated)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) {
|
||||||
|
context.toast(R.string.toast_warning_pref_proxysharing_short)
|
||||||
|
} else {
|
||||||
|
context.toast(R.string.toast_services_start)
|
||||||
|
}
|
||||||
|
|
||||||
|
val isVpnMode = SettingsManager.isVpnMode()
|
||||||
|
val intent = if (isVpnMode) {
|
||||||
|
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
|
||||||
|
Intent(context.applicationContext, CoreVpnService::class.java)
|
||||||
|
} else {
|
||||||
|
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
|
||||||
|
Intent(context.applicationContext, CoreProxyOnlyService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Missing permission to start foreground service", e)
|
||||||
|
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
|
||||||
|
} catch (e: RuntimeException) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||||
|
e.javaClass.name == "android.app.ForegroundServiceStartNotAllowedException"
|
||||||
|
) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Foreground service start not allowed", e)
|
||||||
|
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
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)
|
||||||
|
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, message)
|
||||||
|
NotificationManager.cancelNotification()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(Exception::class)
|
||||||
|
private fun doStartCoreLoop(service: Service, vpnInterface: ParcelFileDescriptor?) {
|
||||||
|
val guid = MmkvManager.getSelectServer() ?: error("No server selected")
|
||||||
|
val config = MmkvManager.decodeServerConfig(guid) ?: error("Failed to decode server config")
|
||||||
|
|
||||||
|
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting core loop for ${config.remarks}")
|
||||||
|
val result = CoreConfigManager.getV2rayConfig(service, guid)
|
||||||
|
LogUtil.d(AppConfig.TAG, result.content)
|
||||||
|
if (!result.status) {
|
||||||
|
error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
|
||||||
|
}
|
||||||
|
|
||||||
|
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||||
|
mFilter.addAction(Intent.ACTION_SCREEN_ON)
|
||||||
|
mFilter.addAction(Intent.ACTION_SCREEN_OFF)
|
||||||
|
mFilter.addAction(Intent.ACTION_USER_PRESENT)
|
||||||
|
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
|
||||||
|
|
||||||
|
currentConfig = config
|
||||||
|
var tunFd = vpnInterface?.fd ?: 0
|
||||||
|
val dialerAddr = if (currentConfig?.browserDialerMode.isNullOrEmpty()) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
"127.0.0.1:${Utils.findRandomFreePort()}"
|
||||||
|
}
|
||||||
|
if (SettingsManager.isUsingHevTun()) {
|
||||||
|
tunFd = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationManager.showNotification(currentConfig)
|
||||||
|
CoreNativeManager.reconcileBrowserDialer(dialerAddr)
|
||||||
|
coreController.startLoop(result.content, tunFd)
|
||||||
|
|
||||||
|
if (!coreController.isRunning) {
|
||||||
|
error("Core failed to start")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (browserDialer != null) {
|
||||||
|
browserDialer!!.stop()
|
||||||
|
browserDialer = null
|
||||||
|
}
|
||||||
|
if (config.browserDialerMode == "OkHttp") {
|
||||||
|
browserDialer = DialerNativeService()
|
||||||
|
browserDialer!!.start(service, dialerAddr)
|
||||||
|
} else if (config.browserDialerMode == "WebView") {
|
||||||
|
browserDialer = DialerWebviewService()
|
||||||
|
browserDialer!!.start(service, dialerAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageUtil.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 {
|
||||||
|
val service = getService() ?: return false
|
||||||
|
|
||||||
|
if (coreController.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
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageUtil.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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queries and resets all outbound traffic counters in one core call.
|
||||||
|
* Go side format: tag,direction,value;tag,direction,value;
|
||||||
|
*/
|
||||||
|
fun queryAllOutboundTrafficStats(): List<OutboundTrafficStat> {
|
||||||
|
val payload = coreController.queryAllOutboundTrafficStats()
|
||||||
|
|
||||||
|
val result = ArrayList<OutboundTrafficStat>()
|
||||||
|
|
||||||
|
payload.split(';').forEach { entry ->
|
||||||
|
if (entry.isBlank()) return@forEach
|
||||||
|
|
||||||
|
val parts = entry.split(',', limit = 3)
|
||||||
|
if (parts.size != 3) return@forEach
|
||||||
|
|
||||||
|
val value = parts[2].toLongOrNull() ?: return@forEach
|
||||||
|
|
||||||
|
result.add(
|
||||||
|
OutboundTrafficStat(
|
||||||
|
tag = parts[0],
|
||||||
|
direction = parts[1],
|
||||||
|
value = value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// LogUtil.d(AppConfig.TAG, "Queried outbound traffic stats: $result")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||||
|
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
||||||
|
}
|
||||||
|
if (time == -1L) {
|
||||||
|
try {
|
||||||
|
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", 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) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (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 -> {
|
||||||
|
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Stop service")
|
||||||
|
serviceControl.stopService()
|
||||||
|
}
|
||||||
|
|
||||||
|
AppConfig.MSG_STATE_RESTART -> {
|
||||||
|
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Restart service")
|
||||||
|
serviceControl.stopService()
|
||||||
|
Thread.sleep(500L)
|
||||||
|
startVService(serviceControl.getService())
|
||||||
|
}
|
||||||
|
|
||||||
|
AppConfig.MSG_MEASURE_DELAY -> {
|
||||||
|
measureV2rayDelay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,10 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
data class CheckUpdateResult(
|
||||||
|
val hasUpdate: Boolean,
|
||||||
|
val latestVersion: String? = null,
|
||||||
|
val releaseNotes: String? = null,
|
||||||
|
val downloadUrl: String? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
val isPreRelease: Boolean = false
|
||||||
|
)
|
||||||
@@ -4,6 +4,6 @@ data class ConfigResult(
|
|||||||
var status: Boolean,
|
var status: Boolean,
|
||||||
var guid: String? = null,
|
var guid: String? = null,
|
||||||
var content: String = "",
|
var content: String = "",
|
||||||
var domainPort: String? = null,
|
var errorMessage: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
|
import com.v2ray.ang.enums.CoreResolvedType
|
||||||
|
|
||||||
|
data class CoreConfigContext(
|
||||||
|
val context: Context,
|
||||||
|
val guid: String,
|
||||||
|
val isCustom: Boolean = false,
|
||||||
|
val resolvedOutbounds: List<ResolvedOutbound> = emptyList(),
|
||||||
|
) {
|
||||||
|
data class ResolvedOutbound(
|
||||||
|
val tag: String,
|
||||||
|
val profile: ProfileItem,
|
||||||
|
val resolvedProfiles: List<ProfileItem>,
|
||||||
|
val resolvedType: CoreResolvedType,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
data class GitHubRelease(
|
||||||
|
@SerializedName("tag_name")
|
||||||
|
val tagName: String,
|
||||||
|
@SerializedName("body")
|
||||||
|
val body: String,
|
||||||
|
@SerializedName("assets")
|
||||||
|
val assets: List<Asset>,
|
||||||
|
@SerializedName("prerelease")
|
||||||
|
val prerelease: Boolean = false,
|
||||||
|
@SerializedName("published_at")
|
||||||
|
val publishedAt: String = ""
|
||||||
|
) {
|
||||||
|
data class Asset(
|
||||||
|
@SerializedName("name")
|
||||||
|
val name: String,
|
||||||
|
@SerializedName("browser_download_url")
|
||||||
|
val browserDownloadUrl: String
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
data class GroupMapItem(
|
||||||
|
var id: String,
|
||||||
|
var remarks: String,
|
||||||
|
)
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package com.v2ray.ang.dto
|
|
||||||
|
|
||||||
data class Hysteria2Bean(
|
|
||||||
val server: String?,
|
|
||||||
val auth: String?,
|
|
||||||
val lazy: Boolean? = true,
|
|
||||||
val obfs: ObfsBean? = null,
|
|
||||||
val socks5: Socks5Bean? = null,
|
|
||||||
val http: Socks5Bean? = null,
|
|
||||||
val tls: TlsBean? = null,
|
|
||||||
val transport: TransportBean? = null,
|
|
||||||
val bandwidth: BandwidthBean? = null,
|
|
||||||
) {
|
|
||||||
data class ObfsBean(
|
|
||||||
val type: String?,
|
|
||||||
val salamander: SalamanderBean?
|
|
||||||
) {
|
|
||||||
data class SalamanderBean(
|
|
||||||
val password: String?,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Socks5Bean(
|
|
||||||
val listen: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class TlsBean(
|
|
||||||
val sni: String?,
|
|
||||||
val insecure: Boolean?,
|
|
||||||
val pinSHA256: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class TransportBean(
|
|
||||||
val type: String?,
|
|
||||||
val udp: TransportUdpBean?
|
|
||||||
) {
|
|
||||||
data class TransportUdpBean(
|
|
||||||
val hopInterval: String?,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class BandwidthBean(
|
|
||||||
val down: String?,
|
|
||||||
val up: String?,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
data class IPAPIInfo(
|
||||||
|
var ip: String? = null,
|
||||||
|
var clientIp: String? = null,
|
||||||
|
var ip_addr: String? = null,
|
||||||
|
var query: String? = null,
|
||||||
|
var country: String? = null,
|
||||||
|
var country_name: String? = null,
|
||||||
|
var country_code: String? = null,
|
||||||
|
var countryCode: String? = null,
|
||||||
|
var location: LocationBean? = null
|
||||||
|
) {
|
||||||
|
data class LocationBean(
|
||||||
|
var country_code: String? = null
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
data class OutboundTrafficStat(
|
||||||
|
val tag: String,
|
||||||
|
val direction: String,
|
||||||
|
val value: Long,
|
||||||
|
)
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package com.v2ray.ang.dto
|
|
||||||
|
|
||||||
data class ProfileLiteItem(
|
|
||||||
val configType: EConfigType,
|
|
||||||
var subscriptionId: String = "",
|
|
||||||
var remarks: String = "",
|
|
||||||
var server: String?,
|
|
||||||
var serverPort: Int?,
|
|
||||||
)
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package com.v2ray.ang.dto
|
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig.TAG_BLOCKED
|
|
||||||
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
|
||||||
import com.v2ray.ang.AppConfig.TAG_PROXY
|
|
||||||
|
|
||||||
data class ServerConfig(
|
|
||||||
val configVersion: Int = 3,
|
|
||||||
val configType: EConfigType,
|
|
||||||
var subscriptionId: String = "",
|
|
||||||
val addedTime: Long = System.currentTimeMillis(),
|
|
||||||
var remarks: String = "",
|
|
||||||
val outboundBean: V2rayConfig.OutboundBean? = null,
|
|
||||||
var fullConfig: V2rayConfig? = null
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun create(configType: EConfigType): ServerConfig {
|
|
||||||
when (configType) {
|
|
||||||
EConfigType.VMESS,
|
|
||||||
EConfigType.VLESS ->
|
|
||||||
return ServerConfig(
|
|
||||||
configType = configType,
|
|
||||||
outboundBean = V2rayConfig.OutboundBean(
|
|
||||||
protocol = configType.name.lowercase(),
|
|
||||||
settings = V2rayConfig.OutboundBean.OutSettingsBean(
|
|
||||||
vnext = listOf(
|
|
||||||
V2rayConfig.OutboundBean.OutSettingsBean.VnextBean(
|
|
||||||
users = listOf(V2rayConfig.OutboundBean.OutSettingsBean.VnextBean.UsersBean())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
streamSettings = V2rayConfig.OutboundBean.StreamSettingsBean()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
EConfigType.CUSTOM ->
|
|
||||||
return ServerConfig(configType = configType)
|
|
||||||
|
|
||||||
EConfigType.SHADOWSOCKS,
|
|
||||||
EConfigType.SOCKS,
|
|
||||||
EConfigType.HTTP,
|
|
||||||
EConfigType.TROJAN,
|
|
||||||
EConfigType.HYSTERIA2 ->
|
|
||||||
return ServerConfig(
|
|
||||||
configType = configType,
|
|
||||||
outboundBean = V2rayConfig.OutboundBean(
|
|
||||||
protocol = configType.name.lowercase(),
|
|
||||||
settings = V2rayConfig.OutboundBean.OutSettingsBean(
|
|
||||||
servers = listOf(V2rayConfig.OutboundBean.OutSettingsBean.ServersBean())
|
|
||||||
),
|
|
||||||
streamSettings = V2rayConfig.OutboundBean.StreamSettingsBean()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
EConfigType.WIREGUARD ->
|
|
||||||
return ServerConfig(
|
|
||||||
configType = configType,
|
|
||||||
outboundBean = V2rayConfig.OutboundBean(
|
|
||||||
protocol = configType.name.lowercase(),
|
|
||||||
settings = V2rayConfig.OutboundBean.OutSettingsBean(
|
|
||||||
secretKey = "",
|
|
||||||
peers = listOf(V2rayConfig.OutboundBean.OutSettingsBean.WireGuardBean())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getProxyOutbound(): V2rayConfig.OutboundBean? {
|
|
||||||
if (configType != EConfigType.CUSTOM) {
|
|
||||||
return outboundBean
|
|
||||||
}
|
|
||||||
return fullConfig?.getProxyOutbound()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAllOutboundTags(): MutableList<String> {
|
|
||||||
if (configType != EConfigType.CUSTOM) {
|
|
||||||
return mutableListOf(TAG_PROXY, TAG_DIRECT, TAG_BLOCKED)
|
|
||||||
}
|
|
||||||
fullConfig?.let { config ->
|
|
||||||
return config.outbounds.map { it.tag }.toMutableList()
|
|
||||||
}
|
|
||||||
return mutableListOf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of subscription update operation
|
||||||
|
*/
|
||||||
|
data class SubscriptionUpdateResult(
|
||||||
|
val configCount: Int = 0, // Total configs updated
|
||||||
|
val successCount: Int = 0, // Subscriptions updated successfully
|
||||||
|
val failureCount: Int = 0, // Subscriptions failed to update
|
||||||
|
val skipCount: Int = 0 // Subscriptions skipped (disabled)
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Combine two results by adding their counts
|
||||||
|
*/
|
||||||
|
operator fun plus(other: SubscriptionUpdateResult): SubscriptionUpdateResult {
|
||||||
|
return SubscriptionUpdateResult(
|
||||||
|
configCount = this.configCount + other.configCount,
|
||||||
|
successCount = this.successCount + other.successCount,
|
||||||
|
failureCount = this.failureCount + other.failureCount,
|
||||||
|
skipCount = this.skipCount + other.skipCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
import java.io.Serializable
|
||||||
|
|
||||||
|
data class TestServiceMessage(
|
||||||
|
val key: Int,
|
||||||
|
val subscriptionId: String = "",
|
||||||
|
val serverGuids: List<String> = emptyList()
|
||||||
|
) : Serializable
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
|
data class UrlContentRequest(
|
||||||
|
val url: String?,
|
||||||
|
val timeout: Int = 15000,
|
||||||
|
val httpPort: Int = 0,
|
||||||
|
val proxyUsername: String? = null,
|
||||||
|
val proxyPassword: String? = null,
|
||||||
|
val userAgent: String? = null
|
||||||
|
)
|
||||||
@@ -1,28 +1,17 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.dto
|
||||||
|
|
||||||
import android.text.TextUtils
|
|
||||||
import com.google.gson.GsonBuilder
|
|
||||||
import com.google.gson.JsonPrimitive
|
|
||||||
import com.google.gson.JsonSerializationContext
|
|
||||||
import com.google.gson.JsonSerializer
|
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean.ServersBean
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean.VnextBean
|
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean.VnextBean.UsersBean
|
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean.WireGuardBean
|
|
||||||
import com.v2ray.ang.util.Utils
|
|
||||||
import java.lang.reflect.Type
|
|
||||||
|
|
||||||
data class V2rayConfig(
|
data class V2rayConfig(
|
||||||
var remarks: String? = null,
|
var remarks: String? = null,
|
||||||
var stats: Any? = null,
|
var stats: Any? = null,
|
||||||
val log: LogBean,
|
val log: LogBean,
|
||||||
var policy: PolicyBean?,
|
var policy: PolicyBean? = null,
|
||||||
val inbounds: ArrayList<InboundBean>,
|
val inbounds: ArrayList<InboundBean>,
|
||||||
var outbounds: ArrayList<OutboundBean>,
|
var outbounds: ArrayList<OutboundBean>,
|
||||||
var dns: DnsBean,
|
var dns: DnsBean? = null,
|
||||||
val routing: RoutingBean,
|
val routing: RoutingBean,
|
||||||
val api: Any? = null,
|
val api: Any? = null,
|
||||||
val transport: Any? = null,
|
val transport: Any? = null,
|
||||||
@@ -34,31 +23,37 @@ data class V2rayConfig(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
data class LogBean(
|
data class LogBean(
|
||||||
val access: String,
|
val access: String? = null,
|
||||||
val error: String,
|
val error: String? = null,
|
||||||
var loglevel: String?,
|
var loglevel: String? = null,
|
||||||
val dnsLog: Boolean? = null
|
val dnsLog: Boolean? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class InboundBean(
|
data class InboundBean(
|
||||||
var tag: String,
|
var tag: String,
|
||||||
var port: Int,
|
var port: Int?,
|
||||||
var protocol: String,
|
var protocol: String,
|
||||||
var listen: String? = null,
|
var listen: String? = null,
|
||||||
val settings: Any? = null,
|
var settings: InSettingsBean? = null,
|
||||||
val sniffing: SniffingBean?,
|
var sniffing: SniffingBean? = null,
|
||||||
val streamSettings: Any? = null,
|
val streamSettings: Any? = null,
|
||||||
val allocate: Any? = null
|
val allocate: Any? = null
|
||||||
) {
|
) {
|
||||||
|
|
||||||
data class InSettingsBean(
|
data class InSettingsBean(
|
||||||
val auth: String? = null,
|
var auth: String? = null,
|
||||||
val udp: Boolean? = null,
|
var udp: Boolean? = null,
|
||||||
val userLevel: Int? = null,
|
var userLevel: Int? = null,
|
||||||
val address: String? = null,
|
var accounts: List<SocksAccountBean>? = null,
|
||||||
val port: Int? = null,
|
var name: String? = null,
|
||||||
val network: String? = null
|
|
||||||
)
|
var mtu: Int? = null
|
||||||
|
) {
|
||||||
|
data class SocksAccountBean(
|
||||||
|
var user: String = "",
|
||||||
|
var pass: String = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
data class SniffingBean(
|
data class SniffingBean(
|
||||||
var enabled: Boolean,
|
var enabled: Boolean,
|
||||||
@@ -73,67 +68,19 @@ data class V2rayConfig(
|
|||||||
var protocol: String,
|
var protocol: String,
|
||||||
var settings: OutSettingsBean? = null,
|
var settings: OutSettingsBean? = null,
|
||||||
var streamSettings: StreamSettingsBean? = null,
|
var streamSettings: StreamSettingsBean? = null,
|
||||||
val proxySettings: Any? = null,
|
|
||||||
val sendThrough: String? = null,
|
val sendThrough: String? = null,
|
||||||
var mux: MuxBean? = MuxBean(false)
|
var mux: MuxBean? = MuxBean(false)
|
||||||
) {
|
) {
|
||||||
companion object {
|
|
||||||
fun create(configType: EConfigType): OutboundBean? {
|
|
||||||
return when (configType) {
|
|
||||||
EConfigType.VMESS,
|
|
||||||
EConfigType.VLESS ->
|
|
||||||
return OutboundBean(
|
|
||||||
protocol = configType.name.lowercase(),
|
|
||||||
settings = OutSettingsBean(
|
|
||||||
vnext = listOf(
|
|
||||||
VnextBean(
|
|
||||||
users = listOf(UsersBean())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
streamSettings = StreamSettingsBean()
|
|
||||||
)
|
|
||||||
|
|
||||||
EConfigType.SHADOWSOCKS,
|
|
||||||
EConfigType.SOCKS,
|
|
||||||
EConfigType.HTTP,
|
|
||||||
EConfigType.TROJAN,
|
|
||||||
EConfigType.HYSTERIA2 ->
|
|
||||||
return OutboundBean(
|
|
||||||
protocol = configType.name.lowercase(),
|
|
||||||
settings = OutSettingsBean(
|
|
||||||
servers = listOf(ServersBean())
|
|
||||||
),
|
|
||||||
streamSettings = StreamSettingsBean()
|
|
||||||
)
|
|
||||||
|
|
||||||
EConfigType.WIREGUARD ->
|
|
||||||
return OutboundBean(
|
|
||||||
protocol = configType.name.lowercase(),
|
|
||||||
settings = OutSettingsBean(
|
|
||||||
secretKey = "",
|
|
||||||
peers = listOf(WireGuardBean())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
EConfigType.CUSTOM -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class OutSettingsBean(
|
data class OutSettingsBean(
|
||||||
var vnext: List<VnextBean>? = null,
|
var vnext: List<VnextBean>? = null,
|
||||||
var fragment: FragmentBean? = null,
|
|
||||||
var noises: List<NoiseBean>? = null,
|
|
||||||
var servers: List<ServersBean>? = null,
|
var servers: List<ServersBean>? = null,
|
||||||
/*Blackhole*/
|
/*Blackhole*/
|
||||||
var response: Response? = null,
|
var response: Response? = null,
|
||||||
/*DNS*/
|
/*DNS*/
|
||||||
val network: String? = null,
|
val network: String? = null,
|
||||||
var address: Any? = null,
|
var address: Any? = null,
|
||||||
val port: Int? = null,
|
var port: Int? = null,
|
||||||
/*Freedom*/
|
/*Freedom*/
|
||||||
var domainStrategy: String? = null,
|
|
||||||
val redirect: String? = null,
|
val redirect: String? = null,
|
||||||
val userLevel: Int? = null,
|
val userLevel: Int? = null,
|
||||||
/*Loopback*/
|
/*Loopback*/
|
||||||
@@ -144,6 +91,7 @@ data class V2rayConfig(
|
|||||||
var reserved: List<Int>? = null,
|
var reserved: List<Int>? = null,
|
||||||
var mtu: Int? = null,
|
var mtu: Int? = null,
|
||||||
var obfsPassword: String? = null,
|
var obfsPassword: String? = null,
|
||||||
|
var version: Int? = null,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
data class VnextBean(
|
data class VnextBean(
|
||||||
@@ -162,18 +110,6 @@ data class V2rayConfig(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class FragmentBean(
|
|
||||||
var packets: String? = null,
|
|
||||||
var length: String? = null,
|
|
||||||
var interval: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class NoiseBean(
|
|
||||||
var type: String? = null,
|
|
||||||
var packet: String? = null,
|
|
||||||
var delay: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ServersBean(
|
data class ServersBean(
|
||||||
var address: String = "",
|
var address: String = "",
|
||||||
var method: String? = null,
|
var method: String? = null,
|
||||||
@@ -197,7 +133,7 @@ data class V2rayConfig(
|
|||||||
|
|
||||||
data class WireGuardBean(
|
data class WireGuardBean(
|
||||||
var publicKey: String = "",
|
var publicKey: String = "",
|
||||||
var preSharedKey: String = "",
|
var preSharedKey: String? = null,
|
||||||
var endpoint: String = ""
|
var endpoint: String = ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -215,7 +151,8 @@ data class V2rayConfig(
|
|||||||
var quicSettings: QuicSettingBean? = null,
|
var quicSettings: QuicSettingBean? = null,
|
||||||
var realitySettings: TlsSettingsBean? = null,
|
var realitySettings: TlsSettingsBean? = null,
|
||||||
var grpcSettings: GrpcSettingsBean? = null,
|
var grpcSettings: GrpcSettingsBean? = null,
|
||||||
var hy2steriaSettings: Hy2steriaSettingsBean? = null,
|
var hysteriaSettings: HysteriaSettingsBean? = null,
|
||||||
|
var finalmask: Any? = null,
|
||||||
val dsSettings: Any? = null,
|
val dsSettings: Any? = null,
|
||||||
var sockopt: SockoptBean? = null
|
var sockopt: SockoptBean? = null
|
||||||
) {
|
) {
|
||||||
@@ -242,7 +179,7 @@ data class V2rayConfig(
|
|||||||
@SerializedName("Accept-Encoding")
|
@SerializedName("Accept-Encoding")
|
||||||
val acceptEncoding: List<String>? = null,
|
val acceptEncoding: List<String>? = null,
|
||||||
val Connection: List<String>? = null,
|
val Connection: List<String>? = null,
|
||||||
val Pragma: String? = null
|
val Pragma: Any? = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,24 +192,17 @@ data class V2rayConfig(
|
|||||||
var downlinkCapacity: Int = 100,
|
var downlinkCapacity: Int = 100,
|
||||||
var congestion: Boolean = false,
|
var congestion: Boolean = false,
|
||||||
var readBufferSize: Int = 1,
|
var readBufferSize: Int = 1,
|
||||||
var writeBufferSize: Int = 1,
|
var writeBufferSize: Int = 1
|
||||||
var header: HeaderBean = HeaderBean(),
|
)
|
||||||
var seed: String? = null
|
|
||||||
) {
|
|
||||||
data class HeaderBean(
|
|
||||||
var type: String = "none",
|
|
||||||
var domain: String? = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class WsSettingsBean(
|
data class WsSettingsBean(
|
||||||
var path: String? = null,
|
var path: String? = null,
|
||||||
var headers: HeadersBean = HeadersBean(),
|
var host: String? = null,
|
||||||
|
var headers: Map<String, String>? = null,
|
||||||
val maxEarlyData: Int? = null,
|
val maxEarlyData: Int? = null,
|
||||||
val useBrowserForwarding: Boolean? = null,
|
val useBrowserForwarding: Boolean? = null,
|
||||||
val acceptProxyProtocol: Boolean? = null
|
val acceptProxyProtocol: Boolean? = null
|
||||||
) {
|
)
|
||||||
data class HeadersBean(var Host: String = "")
|
|
||||||
}
|
|
||||||
|
|
||||||
data class HttpupgradeSettingsBean(
|
data class HttpupgradeSettingsBean(
|
||||||
var path: String? = null,
|
var path: String? = null,
|
||||||
@@ -298,7 +228,16 @@ data class V2rayConfig(
|
|||||||
var tcpFastOpen: Boolean? = null,
|
var tcpFastOpen: Boolean? = null,
|
||||||
var tproxy: String? = null,
|
var tproxy: String? = null,
|
||||||
var mark: Int? = null,
|
var mark: Int? = null,
|
||||||
var dialerProxy: String? = null
|
var dialerProxy: String? = null,
|
||||||
|
var domainStrategy: String? = null,
|
||||||
|
var happyEyeballs: HappyEyeballsBean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HappyEyeballsBean(
|
||||||
|
var prioritizeIPv6: Boolean? = null,
|
||||||
|
var maxConcurrentTry: Int? = 4,
|
||||||
|
var tryDelayMs: Int? = 250, // ms
|
||||||
|
var interleave: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class TlsSettingsBean(
|
data class TlsSettingsBean(
|
||||||
@@ -313,11 +252,14 @@ data class V2rayConfig(
|
|||||||
val certificates: List<Any>? = null,
|
val certificates: List<Any>? = null,
|
||||||
val disableSystemRoot: Boolean? = null,
|
val disableSystemRoot: Boolean? = null,
|
||||||
val enableSessionResumption: Boolean? = null,
|
val enableSessionResumption: Boolean? = null,
|
||||||
|
var echConfigList: String? = null,
|
||||||
|
var pinnedPeerCertSha256: String? = null,
|
||||||
// REALITY settings
|
// REALITY settings
|
||||||
val show: Boolean = false,
|
val show: Boolean = false,
|
||||||
var publicKey: String? = null,
|
var publicKey: String? = null,
|
||||||
var shortId: String? = null,
|
var shortId: String? = null,
|
||||||
var spiderX: String? = null
|
var spiderX: String? = null,
|
||||||
|
var mldsa65Verify: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class QuicSettingBean(
|
data class QuicSettingBean(
|
||||||
@@ -336,149 +278,54 @@ data class V2rayConfig(
|
|||||||
var health_check_timeout: Int? = null
|
var health_check_timeout: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Hy2steriaSettingsBean(
|
data class HysteriaSettingsBean(
|
||||||
var password: String? = null,
|
var version: Int,
|
||||||
var use_udp_extension: Boolean? = true,
|
var auth: String? = null
|
||||||
var congestion: Hy2CongestionBean? = null
|
)
|
||||||
|
|
||||||
|
//https://xtls.github.io/config/transport.html#finalmaskobject
|
||||||
|
data class FinalMaskBean(
|
||||||
|
var tcp: List<MaskBean>? = null,
|
||||||
|
var udp: List<MaskBean>? = null,
|
||||||
|
var quicParams: QuicParamsBean? = null
|
||||||
) {
|
) {
|
||||||
data class Hy2CongestionBean(
|
data class MaskBean(
|
||||||
var type: String? = "bbr",
|
var type: String,
|
||||||
var up_mbps: Int? = null,
|
var settings: MaskSettingsBean? = null
|
||||||
var down_mbps: Int? = null,
|
) {
|
||||||
)
|
data class MaskSettingsBean(
|
||||||
}
|
val password: String? = null,
|
||||||
|
val domain: String? = null,
|
||||||
fun populateTransportSettings(
|
// fragment
|
||||||
transport: String,
|
val packets: String? = null,
|
||||||
headerType: String?,
|
val length: String? = null,
|
||||||
host: String?,
|
val delay: String? = null,
|
||||||
path: String?,
|
// val maxSplit: String? = null,
|
||||||
seed: String?,
|
// noise
|
||||||
quicSecurity: String?,
|
val reset: Int? = null,
|
||||||
key: String?,
|
val noise: List<NoiseMaskBean>? = null
|
||||||
mode: String?,
|
) {
|
||||||
serviceName: String?,
|
data class NoiseMaskBean(
|
||||||
authority: String?
|
val rand: String? = null,
|
||||||
): String? {
|
// val randRange: String? = null,
|
||||||
var sni: String? = null
|
// val type: String? = null,
|
||||||
network = if (transport.isEmpty()) NetworkType.TCP.type else transport
|
// val packet: String? = null,
|
||||||
when (network) {
|
val delay: String? = null,
|
||||||
NetworkType.TCP.type -> {
|
)
|
||||||
val tcpSetting = TcpSettingsBean()
|
|
||||||
if (headerType == AppConfig.HEADER_TYPE_HTTP) {
|
|
||||||
tcpSetting.header.type = AppConfig.HEADER_TYPE_HTTP
|
|
||||||
if (!TextUtils.isEmpty(host) || !TextUtils.isEmpty(path)) {
|
|
||||||
val requestObj = TcpSettingsBean.HeaderBean.RequestBean()
|
|
||||||
requestObj.headers.Host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
|
||||||
requestObj.path = path.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
|
||||||
tcpSetting.header.request = requestObj
|
|
||||||
sni = requestObj.headers.Host?.getOrNull(0)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tcpSetting.header.type = "none"
|
|
||||||
sni = host
|
|
||||||
}
|
|
||||||
tcpSettings = tcpSetting
|
|
||||||
}
|
|
||||||
|
|
||||||
NetworkType.KCP.type -> {
|
|
||||||
val kcpsetting = KcpSettingsBean()
|
|
||||||
kcpsetting.header.type = headerType ?: "none"
|
|
||||||
if (seed.isNullOrEmpty()) {
|
|
||||||
kcpsetting.seed = null
|
|
||||||
} else {
|
|
||||||
kcpsetting.seed = seed
|
|
||||||
}
|
|
||||||
if (host.isNullOrEmpty()) {
|
|
||||||
kcpsetting.header.domain = null
|
|
||||||
} else {
|
|
||||||
kcpsetting.header.domain = host
|
|
||||||
}
|
|
||||||
kcpSettings = kcpsetting
|
|
||||||
}
|
|
||||||
|
|
||||||
NetworkType.WS.type -> {
|
|
||||||
val wssetting = WsSettingsBean()
|
|
||||||
wssetting.headers.Host = host.orEmpty()
|
|
||||||
sni = host
|
|
||||||
wssetting.path = path ?: "/"
|
|
||||||
wsSettings = wssetting
|
|
||||||
}
|
|
||||||
|
|
||||||
NetworkType.HTTP_UPGRADE.type -> {
|
|
||||||
val httpupgradeSetting = HttpupgradeSettingsBean()
|
|
||||||
httpupgradeSetting.host = host.orEmpty()
|
|
||||||
sni = host
|
|
||||||
httpupgradeSetting.path = path ?: "/"
|
|
||||||
httpupgradeSettings = httpupgradeSetting
|
|
||||||
}
|
|
||||||
|
|
||||||
NetworkType.XHTTP.type -> {
|
|
||||||
val xhttpSetting = XhttpSettingsBean()
|
|
||||||
xhttpSetting.host = host.orEmpty()
|
|
||||||
sni = host
|
|
||||||
xhttpSetting.path = path ?: "/"
|
|
||||||
xhttpSettings = xhttpSetting
|
|
||||||
}
|
|
||||||
|
|
||||||
NetworkType.H2.type, NetworkType.HTTP.type -> {
|
|
||||||
network = NetworkType.H2.type
|
|
||||||
val h2Setting = HttpSettingsBean()
|
|
||||||
h2Setting.host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
|
||||||
sni = h2Setting.host.getOrNull(0)
|
|
||||||
h2Setting.path = path ?: "/"
|
|
||||||
httpSettings = h2Setting
|
|
||||||
}
|
|
||||||
|
|
||||||
// "quic" -> {
|
|
||||||
// val quicsetting = QuicSettingBean()
|
|
||||||
// quicsetting.security = quicSecurity ?: "none"
|
|
||||||
// quicsetting.key = key.orEmpty()
|
|
||||||
// quicsetting.header.type = headerType ?: "none"
|
|
||||||
// quicSettings = quicsetting
|
|
||||||
// }
|
|
||||||
|
|
||||||
NetworkType.GRPC.type -> {
|
|
||||||
val grpcSetting = GrpcSettingsBean()
|
|
||||||
grpcSetting.multiMode = mode == "multi"
|
|
||||||
grpcSetting.serviceName = serviceName.orEmpty()
|
|
||||||
grpcSetting.authority = authority.orEmpty()
|
|
||||||
grpcSetting.idle_timeout = 60
|
|
||||||
grpcSetting.health_check_timeout = 20
|
|
||||||
sni = authority
|
|
||||||
grpcSettings = grpcSetting
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sni
|
|
||||||
}
|
|
||||||
|
|
||||||
fun populateTlsSettings(
|
data class QuicParamsBean(
|
||||||
streamSecurity: String,
|
var congestion: String? = null,
|
||||||
allowInsecure: Boolean,
|
var brutalUp: String? = null,
|
||||||
sni: String?,
|
var brutalDown: String? = null,
|
||||||
fingerprint: String?,
|
var udpHop: UdpHopBean? = null,
|
||||||
alpns: String?,
|
) {
|
||||||
publicKey: String?,
|
// Nested data class for the udpHop JSON object
|
||||||
shortId: String?,
|
data class UdpHopBean(
|
||||||
spiderX: String?
|
var ports: String? = null,
|
||||||
) {
|
var interval: String? = null
|
||||||
security = if (streamSecurity.isEmpty()) null else streamSecurity
|
)
|
||||||
if (security == null) return
|
|
||||||
val tlsSetting = TlsSettingsBean(
|
|
||||||
allowInsecure = allowInsecure,
|
|
||||||
serverName = if (sni.isNullOrEmpty()) null else sni,
|
|
||||||
fingerprint = if (fingerprint.isNullOrEmpty()) null else fingerprint,
|
|
||||||
alpn = if (alpns.isNullOrEmpty()) null else alpns.split(",").map { it.trim() }.filter { it.isNotEmpty() },
|
|
||||||
publicKey = if (publicKey.isNullOrEmpty()) null else publicKey,
|
|
||||||
shortId = if (shortId.isNullOrEmpty()) null else shortId,
|
|
||||||
spiderX = if (spiderX.isNullOrEmpty()) null else spiderX,
|
|
||||||
)
|
|
||||||
if (security == AppConfig.TLS) {
|
|
||||||
tlsSettings = tlsSetting
|
|
||||||
realitySettings = null
|
|
||||||
} else if (security == AppConfig.REALITY) {
|
|
||||||
tlsSettings = null
|
|
||||||
realitySettings = tlsSetting
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -494,16 +341,19 @@ data class V2rayConfig(
|
|||||||
if (protocol.equals(EConfigType.VMESS.name, true)
|
if (protocol.equals(EConfigType.VMESS.name, true)
|
||||||
|| protocol.equals(EConfigType.VLESS.name, true)
|
|| protocol.equals(EConfigType.VLESS.name, true)
|
||||||
) {
|
) {
|
||||||
return settings?.vnext?.first()?.address
|
return settings?.vnext?.firstOrNull()?.address ?: settings?.address as? String
|
||||||
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||||
|| protocol.equals(EConfigType.SOCKS.name, true)
|
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||||
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|
|
||||||
) {
|
) {
|
||||||
return settings?.servers?.first()?.address
|
return settings?.servers?.firstOrNull()?.address
|
||||||
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
||||||
return settings?.peers?.first()?.endpoint?.substringBeforeLast(":")
|
return settings?.peers?.firstOrNull()?.endpoint?.substringBeforeLast(":")
|
||||||
|
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||||
|
|| protocol.equals(EConfigType.HYSTERIA.name, true)
|
||||||
|
) {
|
||||||
|
return settings?.address as? String
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -512,139 +362,33 @@ data class V2rayConfig(
|
|||||||
if (protocol.equals(EConfigType.VMESS.name, true)
|
if (protocol.equals(EConfigType.VMESS.name, true)
|
||||||
|| protocol.equals(EConfigType.VLESS.name, true)
|
|| protocol.equals(EConfigType.VLESS.name, true)
|
||||||
) {
|
) {
|
||||||
return settings?.vnext?.first()?.port
|
return settings?.vnext?.firstOrNull()?.port ?: settings?.port
|
||||||
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||||
|| protocol.equals(EConfigType.SOCKS.name, true)
|
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||||
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|
|
||||||
) {
|
) {
|
||||||
return settings?.servers?.first()?.port
|
return settings?.servers?.firstOrNull()?.port
|
||||||
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
||||||
return settings?.peers?.first()?.endpoint?.substringAfterLast(":")?.toInt()
|
return settings?.peers?.firstOrNull()?.endpoint?.substringAfterLast(":")?.toInt()
|
||||||
|
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||||
|
|| protocol.equals(EConfigType.HYSTERIA.name, true)
|
||||||
|
) {
|
||||||
|
return settings?.port
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getServerAddressAndPort(): String {
|
fun ensureSockopt(): StreamSettingsBean.SockoptBean {
|
||||||
val address = getServerAddress().orEmpty()
|
val stream = streamSettings ?: StreamSettingsBean().also {
|
||||||
val port = getServerPort()
|
streamSettings = it
|
||||||
return Utils.getIpv6Address(address) + ":" + port
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPassword(): String? {
|
|
||||||
if (protocol.equals(EConfigType.VMESS.name, true)
|
|
||||||
|| protocol.equals(EConfigType.VLESS.name, true)
|
|
||||||
) {
|
|
||||||
return settings?.vnext?.first()?.users?.first()?.id
|
|
||||||
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
|
||||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
|
||||||
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|
|
||||||
) {
|
|
||||||
return settings?.servers?.first()?.password
|
|
||||||
} else if (protocol.equals(EConfigType.SOCKS.name, true)
|
|
||||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
|
||||||
) {
|
|
||||||
return settings?.servers?.first()?.users?.first()?.pass
|
|
||||||
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
|
||||||
return settings?.secretKey
|
|
||||||
}
|
}
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSecurityEncryption(): String? {
|
val sockopt = stream.sockopt ?: StreamSettingsBean.SockoptBean().also {
|
||||||
return when {
|
stream.sockopt = it
|
||||||
protocol.equals(EConfigType.VMESS.name, true) -> settings?.vnext?.first()?.users?.first()?.security
|
|
||||||
protocol.equals(EConfigType.VLESS.name, true) -> settings?.vnext?.first()?.users?.first()?.encryption
|
|
||||||
protocol.equals(EConfigType.SHADOWSOCKS.name, true) -> settings?.servers?.first()?.method
|
|
||||||
else -> null
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun getTransportSettingDetails(): List<String?>? {
|
return sockopt
|
||||||
if (protocol.equals(EConfigType.VMESS.name, true)
|
|
||||||
|| protocol.equals(EConfigType.VLESS.name, true)
|
|
||||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
|
||||||
|| protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
|
||||||
) {
|
|
||||||
val transport = streamSettings?.network ?: return null
|
|
||||||
return when (transport) {
|
|
||||||
NetworkType.TCP.type -> {
|
|
||||||
val tcpSetting = streamSettings?.tcpSettings ?: return null
|
|
||||||
listOf(
|
|
||||||
tcpSetting.header.type,
|
|
||||||
tcpSetting.header.request?.headers?.Host?.joinToString(",").orEmpty(),
|
|
||||||
tcpSetting.header.request?.path?.joinToString(",").orEmpty()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
NetworkType.KCP.type -> {
|
|
||||||
val kcpSetting = streamSettings?.kcpSettings ?: return null
|
|
||||||
listOf(
|
|
||||||
kcpSetting.header.type,
|
|
||||||
"",
|
|
||||||
kcpSetting.seed.orEmpty()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
NetworkType.WS.type -> {
|
|
||||||
val wsSetting = streamSettings?.wsSettings ?: return null
|
|
||||||
listOf(
|
|
||||||
"",
|
|
||||||
wsSetting.headers.Host,
|
|
||||||
wsSetting.path
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
NetworkType.HTTP_UPGRADE.type -> {
|
|
||||||
val httpupgradeSetting = streamSettings?.httpupgradeSettings ?: return null
|
|
||||||
listOf(
|
|
||||||
"",
|
|
||||||
httpupgradeSetting.host,
|
|
||||||
httpupgradeSetting.path
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
NetworkType.XHTTP.type -> {
|
|
||||||
val xhttpSettings = streamSettings?.xhttpSettings ?: return null
|
|
||||||
listOf(
|
|
||||||
"",
|
|
||||||
xhttpSettings.host,
|
|
||||||
xhttpSettings.path
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
NetworkType.H2.type -> {
|
|
||||||
val h2Setting = streamSettings?.httpSettings ?: return null
|
|
||||||
listOf(
|
|
||||||
"",
|
|
||||||
h2Setting.host.joinToString(","),
|
|
||||||
h2Setting.path
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// "quic" -> {
|
|
||||||
// val quicSetting = streamSettings?.quicSettings ?: return null
|
|
||||||
// listOf(
|
|
||||||
// quicSetting.header.type,
|
|
||||||
// quicSetting.security,
|
|
||||||
// quicSetting.key
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
NetworkType.GRPC.type -> {
|
|
||||||
val grpcSetting = streamSettings?.grpcSettings ?: return null
|
|
||||||
listOf(
|
|
||||||
if (grpcSetting.multiMode == true) "multi" else "gun",
|
|
||||||
grpcSetting.authority.orEmpty(),
|
|
||||||
grpcSetting.serviceName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,6 +398,7 @@ data class V2rayConfig(
|
|||||||
val clientIp: String? = null,
|
val clientIp: String? = null,
|
||||||
val disableCache: Boolean? = null,
|
val disableCache: Boolean? = null,
|
||||||
val queryStrategy: String? = null,
|
val queryStrategy: String? = null,
|
||||||
|
val enableParallelQuery: Boolean? = null,
|
||||||
val tag: String? = null
|
val tag: String? = null
|
||||||
) {
|
) {
|
||||||
data class ServersBean(
|
data class ServersBean(
|
||||||
@@ -663,6 +408,7 @@ data class V2rayConfig(
|
|||||||
var expectIPs: List<String>? = null,
|
var expectIPs: List<String>? = null,
|
||||||
val clientIp: String? = null,
|
val clientIp: String? = null,
|
||||||
val skipFallback: Boolean? = null,
|
val skipFallback: Boolean? = null,
|
||||||
|
val tag: String? = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,14 +416,15 @@ data class V2rayConfig(
|
|||||||
var domainStrategy: String,
|
var domainStrategy: String,
|
||||||
var domainMatcher: String? = null,
|
var domainMatcher: String? = null,
|
||||||
var rules: ArrayList<RulesBean>,
|
var rules: ArrayList<RulesBean>,
|
||||||
val balancers: List<Any>? = null
|
var balancers: List<BalancerBean>? = null
|
||||||
) {
|
) {
|
||||||
|
|
||||||
data class RulesBean(
|
data class RulesBean(
|
||||||
var type: String = "field",
|
var type: String = "field",
|
||||||
var ip: ArrayList<String>? = null,
|
var ip: List<String>? = null,
|
||||||
var domain: ArrayList<String>? = null,
|
var domain: List<String>? = null,
|
||||||
var outboundTag: String = "",
|
var process: List<String>? = null,
|
||||||
|
var outboundTag: String? = null,
|
||||||
var balancerTag: String? = null,
|
var balancerTag: String? = null,
|
||||||
var port: String? = null,
|
var port: String? = null,
|
||||||
val sourcePort: String? = null,
|
val sourcePort: String? = null,
|
||||||
@@ -689,6 +436,32 @@ data class V2rayConfig(
|
|||||||
val attrs: String? = null,
|
val attrs: String? = null,
|
||||||
val domainMatcher: String? = null
|
val domainMatcher: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class BalancerBean(
|
||||||
|
val tag: String,
|
||||||
|
val selector: List<String>,
|
||||||
|
val fallbackTag: String? = null,
|
||||||
|
val strategy: StrategyObject? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class StrategyObject(
|
||||||
|
val type: String = "random", // "random" | "roundRobin" | "leastPing" | "leastLoad"
|
||||||
|
val settings: StrategySettingsObject? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class StrategySettingsObject(
|
||||||
|
val expected: Int? = null,
|
||||||
|
val maxRTT: String? = null,
|
||||||
|
val tolerance: Double? = null,
|
||||||
|
val baselines: List<String>? = null,
|
||||||
|
val costs: List<CostObject>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CostObject(
|
||||||
|
val regexp: Boolean = false,
|
||||||
|
val match: String,
|
||||||
|
val value: Double
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class PolicyBean(
|
data class PolicyBean(
|
||||||
@@ -706,6 +479,26 @@ data class V2rayConfig(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class ObservatoryObject(
|
||||||
|
val subjectSelector: List<String>,
|
||||||
|
val probeUrl: String,
|
||||||
|
val probeInterval: String,
|
||||||
|
val enableConcurrency: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BurstObservatoryObject(
|
||||||
|
val subjectSelector: List<String>,
|
||||||
|
val pingConfig: PingConfigObject
|
||||||
|
) {
|
||||||
|
data class PingConfigObject(
|
||||||
|
val destination: String,
|
||||||
|
val connectivity: String? = null,
|
||||||
|
val interval: String,
|
||||||
|
val sampling: Int,
|
||||||
|
val timeout: String? = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
data class FakednsBean(
|
data class FakednsBean(
|
||||||
var ipPool: String = "198.18.0.0/15",
|
var ipPool: String = "198.18.0.0/15",
|
||||||
var poolSize: Int = 10000
|
var poolSize: Int = 10000
|
||||||
@@ -722,15 +515,9 @@ data class V2rayConfig(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toPrettyPrinting(): String {
|
fun getAllProxyOutbound(): List<OutboundBean> {
|
||||||
return GsonBuilder()
|
return outbounds.filter { outbound ->
|
||||||
.setPrettyPrinting()
|
EConfigType.entries.any { it.name.equals(outbound.protocol, ignoreCase = true) }
|
||||||
.disableHtmlEscaping()
|
}
|
||||||
.registerTypeAdapter( // custom serialiser is needed here since JSON by default parse number as Double, core will fail to start
|
|
||||||
object : TypeToken<Double>() {}.type,
|
|
||||||
JsonSerializer { src: Double?, _: Type?, _: JsonSerializationContext? -> JsonPrimitive(src?.toInt()) }
|
|
||||||
)
|
|
||||||
.create()
|
|
||||||
.toJson(this)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ data class VmessQRCode(
|
|||||||
var tls: String = "",
|
var tls: String = "",
|
||||||
var sni: String = "",
|
var sni: String = "",
|
||||||
var alpn: String = "",
|
var alpn: String = "",
|
||||||
var fp: String = ""
|
var fp: String = "",
|
||||||
|
var insecure: String = ""
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.v2ray.ang.dto.entities
|
||||||
|
|
||||||
|
data class AssetUrlCache(
|
||||||
|
val guid: String,
|
||||||
|
val assetUrl: AssetUrlItem
|
||||||
|
)
|
||||||
+3
-2
@@ -1,8 +1,9 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.dto.entities
|
||||||
|
|
||||||
data class AssetUrlItem(
|
data class AssetUrlItem(
|
||||||
var remarks: String = "",
|
var remarks: String = "",
|
||||||
var url: String = "",
|
var url: String = "",
|
||||||
val addedTime: Long = System.currentTimeMillis(),
|
val addedTime: Long = System.currentTimeMillis(),
|
||||||
var lastUpdated: Long = -1
|
var lastUpdated: Long = -1,
|
||||||
|
var locked: Boolean? = false,
|
||||||
)
|
)
|
||||||
+23
-13
@@ -1,10 +1,7 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.dto.entities
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig.LOOPBACK
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.PORT_SOCKS
|
import com.v2ray.ang.enums.EConfigType
|
||||||
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.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
|
|
||||||
data class ProfileItem(
|
data class ProfileItem(
|
||||||
@@ -14,6 +11,7 @@ data class ProfileItem(
|
|||||||
var addedTime: Long = System.currentTimeMillis(),
|
var addedTime: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
var remarks: String = "",
|
var remarks: String = "",
|
||||||
|
var description: String? = null,
|
||||||
var server: String? = null,
|
var server: String? = null,
|
||||||
var serverPort: String? = null,
|
var serverPort: String? = null,
|
||||||
|
|
||||||
@@ -27,6 +25,9 @@ data class ProfileItem(
|
|||||||
var host: String? = null,
|
var host: String? = null,
|
||||||
var path: String? = null,
|
var path: String? = null,
|
||||||
var seed: String? = null,
|
var seed: String? = null,
|
||||||
|
var kcpMtu: Int? = null,
|
||||||
|
var kcpTti: Int? = null,
|
||||||
|
|
||||||
var quicSecurity: String? = null,
|
var quicSecurity: String? = null,
|
||||||
var quicKey: String? = null,
|
var quicKey: String? = null,
|
||||||
var mode: String? = null,
|
var mode: String? = null,
|
||||||
@@ -34,16 +35,19 @@ data class ProfileItem(
|
|||||||
var authority: String? = null,
|
var authority: String? = null,
|
||||||
var xhttpMode: String? = null,
|
var xhttpMode: String? = null,
|
||||||
var xhttpExtra: String? = null,
|
var xhttpExtra: String? = null,
|
||||||
|
var finalMask: String? = null,
|
||||||
var security: String? = null,
|
var security: String? = null,
|
||||||
var sni: String? = null,
|
var sni: String? = null,
|
||||||
var alpn: String? = null,
|
var alpn: String? = null,
|
||||||
var fingerPrint: String? = null,
|
var fingerPrint: String? = null,
|
||||||
var insecure: Boolean? = null,
|
var insecure: Boolean? = null,
|
||||||
|
var echConfigList: String? = null,
|
||||||
|
var pinnedCA256: String? = null,
|
||||||
|
|
||||||
var publicKey: String? = null,
|
var publicKey: String? = null,
|
||||||
var shortId: String? = null,
|
var shortId: String? = null,
|
||||||
var spiderX: String? = null,
|
var spiderX: String? = null,
|
||||||
|
var mldsa65Verify: String? = null,
|
||||||
|
|
||||||
var secretKey: String? = null,
|
var secretKey: String? = null,
|
||||||
var preSharedKey: String? = null,
|
var preSharedKey: String? = null,
|
||||||
@@ -58,6 +62,13 @@ data class ProfileItem(
|
|||||||
var bandwidthDown: String? = null,
|
var bandwidthDown: String? = null,
|
||||||
var bandwidthUp: String? = null,
|
var bandwidthUp: String? = null,
|
||||||
|
|
||||||
|
var policyGroupType: String? = null,
|
||||||
|
var policyGroupSubscriptionId: String? = null,
|
||||||
|
var policyGroupFilter: String? = null,
|
||||||
|
var proxyChainProfiles: String? = null,
|
||||||
|
|
||||||
|
var browserDialerMode: String? = null,
|
||||||
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun create(configType: EConfigType): ProfileItem {
|
fun create(configType: EConfigType): ProfileItem {
|
||||||
@@ -65,13 +76,9 @@ data class ProfileItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAllOutboundTags(): MutableList<String> {
|
|
||||||
return mutableListOf(TAG_PROXY, TAG_DIRECT, TAG_BLOCKED)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getServerAddressAndPort(): String {
|
fun getServerAddressAndPort(): String {
|
||||||
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
|
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
|
||||||
return "$LOOPBACK:$PORT_SOCKS"
|
return "${AppConfig.LOOPBACK}:${AppConfig.PORT_SOCKS}"
|
||||||
}
|
}
|
||||||
return Utils.getIpv6Address(server) + ":" + serverPort
|
return Utils.getIpv6Address(server) + ":" + serverPort
|
||||||
}
|
}
|
||||||
@@ -92,6 +99,8 @@ data class ProfileItem(
|
|||||||
&& this.host == obj.host
|
&& this.host == obj.host
|
||||||
&& this.path == obj.path
|
&& this.path == obj.path
|
||||||
&& this.seed == obj.seed
|
&& this.seed == obj.seed
|
||||||
|
&& this.kcpMtu == obj.kcpMtu
|
||||||
|
&& this.kcpTti == obj.kcpTti
|
||||||
&& this.quicSecurity == obj.quicSecurity
|
&& this.quicSecurity == obj.quicSecurity
|
||||||
&& this.quicKey == obj.quicKey
|
&& this.quicKey == obj.quicKey
|
||||||
&& this.mode == obj.mode
|
&& this.mode == obj.mode
|
||||||
@@ -114,7 +123,8 @@ data class ProfileItem(
|
|||||||
&& this.obfsPassword == obj.obfsPassword
|
&& this.obfsPassword == obj.obfsPassword
|
||||||
&& this.portHopping == obj.portHopping
|
&& this.portHopping == obj.portHopping
|
||||||
&& this.portHoppingInterval == obj.portHoppingInterval
|
&& this.portHoppingInterval == obj.portHoppingInterval
|
||||||
&& this.pinSHA256 == obj.pinSHA256
|
&& this.pinnedCA256 == obj.pinnedCA256
|
||||||
|
&& this.proxyChainProfiles == obj.proxyChainProfiles
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-1
@@ -1,9 +1,10 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.dto.entities
|
||||||
|
|
||||||
data class RulesetItem(
|
data class RulesetItem(
|
||||||
var remarks: String? = "",
|
var remarks: String? = "",
|
||||||
var ip: List<String>? = null,
|
var ip: List<String>? = null,
|
||||||
var domain: List<String>? = null,
|
var domain: List<String>? = null,
|
||||||
|
var process: List<String>? = null,
|
||||||
var outboundTag: String = "",
|
var outboundTag: String = "",
|
||||||
var port: String? = null,
|
var port: String? = null,
|
||||||
var network: String? = null,
|
var network: String? = null,
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.dto.entities
|
||||||
|
|
||||||
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
|
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
|
||||||
fun getTestDelayString(): String {
|
fun getTestDelayString(): String {
|
||||||
@@ -7,4 +7,4 @@ data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
|
|||||||
}
|
}
|
||||||
return testDelayMillis.toString() + "ms"
|
return testDelayMillis.toString() + "ms"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.dto.entities
|
||||||
|
|
||||||
data class ServersCache(
|
data class ServersCache(
|
||||||
val guid: String,
|
val guid: String,
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.v2ray.ang.dto.entities
|
||||||
|
|
||||||
|
data class SubscriptionCache(
|
||||||
|
val guid: String,
|
||||||
|
val subscription: SubscriptionItem
|
||||||
|
)
|
||||||
+4
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.dto.entities
|
||||||
|
|
||||||
data class SubscriptionItem(
|
data class SubscriptionItem(
|
||||||
var remarks: String = "",
|
var remarks: String = "",
|
||||||
@@ -7,9 +7,11 @@ data class SubscriptionItem(
|
|||||||
val addedTime: Long = System.currentTimeMillis(),
|
val addedTime: Long = System.currentTimeMillis(),
|
||||||
var lastUpdated: Long = -1,
|
var lastUpdated: Long = -1,
|
||||||
var autoUpdate: Boolean = false,
|
var autoUpdate: Boolean = false,
|
||||||
val updateInterval: Int? = null,
|
var updateInterval: Long = 1440, // in minutes, default to 24 hours
|
||||||
var prevProfile: String? = null,
|
var prevProfile: String? = null,
|
||||||
var nextProfile: String? = null,
|
var nextProfile: String? = null,
|
||||||
var filter: String? = null,
|
var filter: String? = null,
|
||||||
|
var allowInsecureUrl: Boolean = false,
|
||||||
|
var userAgent: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.v2ray.ang.dto.entities
|
||||||
|
|
||||||
|
data class WebDavConfig(
|
||||||
|
val baseUrl: String,
|
||||||
|
val username: String? = null,
|
||||||
|
val password: String? = null,
|
||||||
|
val remoteBasePath: String = "/",
|
||||||
|
val timeoutSeconds: Long = 30
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.v2ray.ang.enums
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load-balancing strategy types supported by xray balancer.
|
||||||
|
*
|
||||||
|
* @param policyGroupType The string value written into the xray config JSON.
|
||||||
|
* @param policyGroupTypeValue The numeric string stored in [com.v2ray.ang.dto.entities.ProfileItem.policyGroupType].
|
||||||
|
* @param requiresBurstObservatory Whether this strategy needs a burstObservatory (leastLoad).
|
||||||
|
* @param requiresObservatory Whether this strategy needs an observatory (leastPing).
|
||||||
|
*/
|
||||||
|
enum class BalancerStrategyType(
|
||||||
|
val policyGroupType: String,
|
||||||
|
val policyGroupTypeValue: String,
|
||||||
|
val requiresBurstObservatory: Boolean = false,
|
||||||
|
val requiresObservatory: Boolean = false,
|
||||||
|
) {
|
||||||
|
LEAST_LOAD("leastLoad", "1", requiresBurstObservatory = true),
|
||||||
|
RANDOM("random", "2"),
|
||||||
|
ROUND_ROBIN("roundRobin", "3"),
|
||||||
|
LEAST_PING("leastPing", "", requiresObservatory = true); // default / else
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(policyGroupType: String?): BalancerStrategyType =
|
||||||
|
entries.firstOrNull { it.policyGroupTypeValue == policyGroupType } ?: LEAST_PING
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.v2ray.ang.enums
|
||||||
|
|
||||||
|
/** Runtime type used during config assembly only. */
|
||||||
|
enum class CoreResolvedType {
|
||||||
|
NORMAL,
|
||||||
|
POLICYGROUP,
|
||||||
|
PROXYCHAIN,
|
||||||
|
}
|
||||||
+8
-5
@@ -1,8 +1,7 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.enums
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
|
|
||||||
|
|
||||||
enum class EConfigType(val value: Int, val protocolScheme: String) {
|
enum class EConfigType(val value: Int, val protocolScheme: String) {
|
||||||
VMESS(1, AppConfig.VMESS),
|
VMESS(1, AppConfig.VMESS),
|
||||||
CUSTOM(2, AppConfig.CUSTOM),
|
CUSTOM(2, AppConfig.CUSTOM),
|
||||||
@@ -11,11 +10,15 @@ enum class EConfigType(val value: Int, val protocolScheme: String) {
|
|||||||
VLESS(5, AppConfig.VLESS),
|
VLESS(5, AppConfig.VLESS),
|
||||||
TROJAN(6, AppConfig.TROJAN),
|
TROJAN(6, AppConfig.TROJAN),
|
||||||
WIREGUARD(7, AppConfig.WIREGUARD),
|
WIREGUARD(7, AppConfig.WIREGUARD),
|
||||||
// TUIC(8, AppConfig.TUIC),
|
|
||||||
|
// TUIC(8, AppConfig.TUIC),
|
||||||
HYSTERIA2(9, AppConfig.HYSTERIA2),
|
HYSTERIA2(9, AppConfig.HYSTERIA2),
|
||||||
HTTP(10, AppConfig.HTTP);
|
HYSTERIA(900, AppConfig.HYSTERIA),
|
||||||
|
HTTP(10, AppConfig.HTTP),
|
||||||
|
POLICYGROUP(101, AppConfig.CUSTOM),
|
||||||
|
PROXYCHAIN(102, AppConfig.CUSTOM);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
|
fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.enums
|
||||||
|
|
||||||
enum class Language(val code: String) {
|
enum class Language(val code: String) {
|
||||||
AUTO("auto"),
|
AUTO("auto"),
|
||||||
@@ -8,6 +8,7 @@ enum class Language(val code: String) {
|
|||||||
VIETNAMESE("vi"),
|
VIETNAMESE("vi"),
|
||||||
RUSSIAN("ru"),
|
RUSSIAN("ru"),
|
||||||
PERSIAN("fa"),
|
PERSIAN("fa"),
|
||||||
|
ARABIC("ar"),
|
||||||
BANGLA("bn"),
|
BANGLA("bn"),
|
||||||
BAKHTIARI("bqi-rIR");
|
BAKHTIARI("bqi-rIR");
|
||||||
|
|
||||||
+4
-2
@@ -1,4 +1,4 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.enums
|
||||||
|
|
||||||
enum class NetworkType(val type: String) {
|
enum class NetworkType(val type: String) {
|
||||||
TCP("tcp"),
|
TCP("tcp"),
|
||||||
@@ -8,8 +8,10 @@ enum class NetworkType(val type: String) {
|
|||||||
XHTTP("xhttp"),
|
XHTTP("xhttp"),
|
||||||
HTTP("http"),
|
HTTP("http"),
|
||||||
H2("h2"),
|
H2("h2"),
|
||||||
|
|
||||||
//QUIC("quic"),
|
//QUIC("quic"),
|
||||||
GRPC("grpc");
|
GRPC("grpc"),
|
||||||
|
HYSTERIA("hysteria");
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromString(type: String?) = entries.find { it.type == type } ?: TCP
|
fun fromString(type: String?) = entries.find { it.type == type } ?: TCP
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.v2ray.ang.enums
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum defining different notification channels.
|
||||||
|
* Each channel has a unique channelId, notificationId, and display name.
|
||||||
|
*/
|
||||||
|
enum class NotificationChannelType(
|
||||||
|
val channelId: String,
|
||||||
|
val channelName: String,
|
||||||
|
val notificationId: Int
|
||||||
|
) {
|
||||||
|
SUBSCRIPTION_UPDATE(
|
||||||
|
channelId = "subscription_update_channel",
|
||||||
|
channelName = "Subscription Update Service",
|
||||||
|
notificationId = 13
|
||||||
|
),
|
||||||
|
CORE_TEST(
|
||||||
|
channelId = "core_test_channel",
|
||||||
|
channelName = "Core Test Service",
|
||||||
|
notificationId = 12
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.v2ray.ang.enums
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission types used in the app, handling API level differences.
|
||||||
|
*/
|
||||||
|
enum class PermissionType {
|
||||||
|
/** Camera permission (used for scanning QR codes) */
|
||||||
|
CAMERA {
|
||||||
|
override fun getPermission(): String = Manifest.permission.CAMERA
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Notification permission (Android 13+) */
|
||||||
|
POST_NOTIFICATIONS {
|
||||||
|
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||||
|
override fun getPermission(): String = Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Return the actual Android permission string */
|
||||||
|
abstract fun getPermission(): String
|
||||||
|
|
||||||
|
/** Return a human-readable label for the permission */
|
||||||
|
fun getLabel(): String {
|
||||||
|
return when (this) {
|
||||||
|
CAMERA -> "Camera"
|
||||||
|
POST_NOTIFICATIONS -> "Notification"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-2
@@ -1,10 +1,11 @@
|
|||||||
package com.v2ray.ang.dto
|
package com.v2ray.ang.enums
|
||||||
|
|
||||||
enum class RoutingType(val fileName: String) {
|
enum class RoutingType(val fileName: String) {
|
||||||
WHITE("custom_routing_white"),
|
WHITE("custom_routing_white"),
|
||||||
BLACK("custom_routing_black"),
|
BLACK("custom_routing_black"),
|
||||||
GLOBAL("custom_routing_global"),
|
GLOBAL("custom_routing_global"),
|
||||||
WHITE_IRAN("custom_routing_white_iran");
|
WHITE_IRAN("custom_routing_white_iran"),
|
||||||
|
WHITE_RUSSIA("custom_routing_white_russia");
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromIndex(index: Int): RoutingType {
|
fun fromIndex(index: Int): RoutingType {
|
||||||
@@ -13,6 +14,7 @@ enum class RoutingType(val fileName: String) {
|
|||||||
1 -> BLACK
|
1 -> BLACK
|
||||||
2 -> GLOBAL
|
2 -> GLOBAL
|
||||||
3 -> WHITE_IRAN
|
3 -> WHITE_IRAN
|
||||||
|
4 -> WHITE_RUSSIA
|
||||||
else -> WHITE
|
else -> WHITE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.v2ray.ang.enums
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VPN interface address configuration enum class
|
||||||
|
* Defines predefined IPv4 and IPv6 address pairs for VPN TUN interface configuration.
|
||||||
|
* Each option provides client and router addresses to establish point-to-point VPN tunnels.
|
||||||
|
*/
|
||||||
|
enum class VpnInterfaceAddressConfig(
|
||||||
|
val displayName: String,
|
||||||
|
val ipv4Client: String,
|
||||||
|
val ipv4Router: String,
|
||||||
|
val ipv6Client: String,
|
||||||
|
val ipv6Router: String
|
||||||
|
) {
|
||||||
|
OPTION_1("10.10.14.x", "10.10.14.1", "10.10.14.2", "fc00::10:10:14:1", "fc00::10:10:14:2"),
|
||||||
|
OPTION_2("10.1.0.x", "10.1.0.1", "10.1.0.2", "fc00::10:1:0:1", "fc00::10:1:0:2"),
|
||||||
|
OPTION_3("10.0.0.x", "10.0.0.1", "10.0.0.2", "fc00::10:0:0:1", "fc00::10:0:0:2"),
|
||||||
|
OPTION_4("172.31.0.x", "172.31.0.1", "172.31.0.2", "fc00::172:31:0:1", "fc00::172:31:0:2"),
|
||||||
|
OPTION_5("172.20.0.x", "172.20.0.1", "172.20.0.2", "fc00::172:20:0:1", "fc00::172:20:0:2"),
|
||||||
|
OPTION_6("172.16.0.x", "172.16.0.1", "172.16.0.2", "fc00::172:16:0:1", "fc00::172:16:0:2"),
|
||||||
|
OPTION_7("192.168.100.x", "192.168.100.1", "192.168.100.2", "fc00::192:168:100:1", "fc00::192:168:100:2");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Retrieves the VPN interface address configuration based on the specified index.
|
||||||
|
*
|
||||||
|
* @param index The configuration index (0-based) corresponding to user selection
|
||||||
|
* @return The VpnInterfaceAddressConfig instance at the specified index,
|
||||||
|
* or OPTION_1 (default) if the index is out of bounds
|
||||||
|
*/
|
||||||
|
fun getConfigByIndex(index: Int): VpnInterfaceAddressConfig {
|
||||||
|
return if (index in entries.toTypedArray().indices) {
|
||||||
|
entries[index]
|
||||||
|
} else {
|
||||||
|
OPTION_1 // Default to the first configuration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,11 @@ import android.os.Build
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import com.v2ray.ang.AngApplication
|
import com.v2ray.ang.AngApplication
|
||||||
import me.drakeet.support.toast.ToastCompat
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import org.json.JSONObject
|
import es.dmoral.toasty.Toasty
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.net.URLConnection
|
import java.util.Locale
|
||||||
|
|
||||||
val Context.v2RayApplication: AngApplication?
|
val Context.v2RayApplication: AngApplication?
|
||||||
get() = applicationContext as? AngApplication
|
get() = applicationContext as? AngApplication
|
||||||
@@ -23,7 +23,7 @@ val Context.v2RayApplication: AngApplication?
|
|||||||
* @param message The resource ID of the message to show.
|
* @param message The resource ID of the message to show.
|
||||||
*/
|
*/
|
||||||
fun Context.toast(message: Int) {
|
fun Context.toast(message: Int) {
|
||||||
ToastCompat.makeText(this, message, Toast.LENGTH_SHORT).show()
|
Toasty.normal(this, message).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,25 +32,43 @@ fun Context.toast(message: Int) {
|
|||||||
* @param message The text of the message to show.
|
* @param message The text of the message to show.
|
||||||
*/
|
*/
|
||||||
fun Context.toast(message: CharSequence) {
|
fun Context.toast(message: CharSequence) {
|
||||||
ToastCompat.makeText(this, message, Toast.LENGTH_SHORT).show()
|
Toasty.normal(this, message).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Puts a key-value pair into the JSONObject.
|
* Shows a toast message with the given resource ID.
|
||||||
*
|
*
|
||||||
* @param pair The key-value pair to put.
|
* @param message The resource ID of the message to show.
|
||||||
*/
|
*/
|
||||||
fun JSONObject.putOpt(pair: Pair<String, Any?>) {
|
fun Context.toastSuccess(message: Int) {
|
||||||
put(pair.first, pair.second)
|
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Puts multiple key-value pairs into the JSONObject.
|
* Shows a toast message with the given text.
|
||||||
*
|
*
|
||||||
* @param pairs The map of key-value pairs to put.
|
* @param message The text of the message to show.
|
||||||
*/
|
*/
|
||||||
fun JSONObject.putOpt(pairs: Map<String, Any?>) {
|
fun Context.toastSuccess(message: CharSequence) {
|
||||||
pairs.forEach { put(it.key, it.value) }
|
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a toast message with the given resource ID.
|
||||||
|
*
|
||||||
|
* @param message The resource ID of the message to show.
|
||||||
|
*/
|
||||||
|
fun Context.toastError(message: Int) {
|
||||||
|
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a toast message with the given text.
|
||||||
|
*
|
||||||
|
* @param message The text of the message to show.
|
||||||
|
*/
|
||||||
|
fun Context.toastError(message: CharSequence) {
|
||||||
|
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
const val THRESHOLD = 1000L
|
const val THRESHOLD = 1000L
|
||||||
@@ -76,16 +94,9 @@ fun Long.toTrafficString(): String {
|
|||||||
size /= DIVISOR
|
size /= DIVISOR
|
||||||
unitIndex++
|
unitIndex++
|
||||||
}
|
}
|
||||||
return String.format("%.1f %s", size, units[unitIndex])
|
return String.format(Locale.getDefault(), "%.1f %s", size, units[unitIndex])
|
||||||
}
|
}
|
||||||
|
|
||||||
val URLConnection.responseLength: Long
|
|
||||||
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
||||||
contentLengthLong
|
|
||||||
} else {
|
|
||||||
contentLength.toLong()
|
|
||||||
}
|
|
||||||
|
|
||||||
val URI.idnHost: String
|
val URI.idnHost: String
|
||||||
get() = host?.replace("[", "")?.replace("]", "").orEmpty()
|
get() = host?.replace("[", "")?.replace("]", "").orEmpty()
|
||||||
|
|
||||||
@@ -96,6 +107,13 @@ val URI.idnHost: String
|
|||||||
*/
|
*/
|
||||||
fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
|
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.
|
* Converts the string to a Long value, or returns 0 if the conversion fails.
|
||||||
*
|
*
|
||||||
@@ -159,4 +177,46 @@ inline fun <reified T : Serializable> Intent.serializable(key: String): T? = whe
|
|||||||
*
|
*
|
||||||
* @return True if the CharSequence is not null and not empty, false otherwise.
|
* @return True if the CharSequence is not null and not empty, false otherwise.
|
||||||
*/
|
*/
|
||||||
fun CharSequence?.isNotNullEmpty(): Boolean = (this != null && this.isNotEmpty())
|
fun CharSequence?.isNotNullEmpty(): Boolean = !this.isNullOrBlank()
|
||||||
|
|
||||||
|
fun String.concatUrl(vararg paths: String): String {
|
||||||
|
val builder = StringBuilder(this.trimEnd('/'))
|
||||||
|
|
||||||
|
paths.forEach { path ->
|
||||||
|
val trimmedPath = path.trim('/')
|
||||||
|
if (trimmedPath.isNotEmpty()) {
|
||||||
|
builder.append('/').append(trimmedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to match text either by Regex or literal string.
|
||||||
|
*/
|
||||||
|
fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean = true): Boolean {
|
||||||
|
if (keyword.isNullOrEmpty()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return regex?.containsMatchIn(this)
|
||||||
|
?: this.contains(keyword, ignoreCase = ignoreCase)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the config type is a group type (PolicyGroup or ProxyChain).
|
||||||
|
*
|
||||||
|
* @return True if the config type is PolicyGroup or ProxyChain, false otherwise.
|
||||||
|
*/
|
||||||
|
fun EConfigType.isGroupType(): Boolean {
|
||||||
|
return this == EConfigType.POLICYGROUP || this == EConfigType.PROXYCHAIN
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the config type is a complex type (Custom, PolicyGroup, or ProxyChain).
|
||||||
|
*
|
||||||
|
* @return True if the config type is Custom, PolicyGroup, or ProxyChain, false otherwise.
|
||||||
|
*/
|
||||||
|
fun EConfigType.isComplexType(): Boolean {
|
||||||
|
return this == EConfigType.CUSTOM || this == EConfigType.POLICYGROUP || this == EConfigType.PROXYCHAIN
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
|
||||||
import com.v2ray.ang.dto.V2rayConfig
|
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
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
|
||||||
object CustomFmt : FmtBase() {
|
object CustomFmt : FmtBase() {
|
||||||
@@ -12,15 +12,15 @@ object CustomFmt : FmtBase() {
|
|||||||
* @param str the JSON string to parse
|
* @param str the JSON string to parse
|
||||||
* @return the parsed ProfileItem object, or null if parsing fails
|
* @return the parsed ProfileItem object, or null if parsing fails
|
||||||
*/
|
*/
|
||||||
fun parse(str: String): ProfileItem? {
|
fun parse(str: String): ProfileItem {
|
||||||
val config = ProfileItem.create(EConfigType.CUSTOM)
|
val config = ProfileItem.create(EConfigType.CUSTOM)
|
||||||
|
|
||||||
val fullConfig = JsonUtil.fromJson(str, V2rayConfig::class.java)
|
val fullConfig = JsonUtil.fromJson(str, V2rayConfig::class.java)
|
||||||
val outbound = fullConfig.getProxyOutbound()
|
val outbound = fullConfig?.getProxyOutbound()
|
||||||
|
|
||||||
config.remarks = fullConfig?.remarks ?: System.currentTimeMillis().toString()
|
config.remarks = fullConfig?.remarks ?: System.currentTimeMillis().toString()
|
||||||
config.server = outbound?.getServerAddress()
|
config.server = outbound?.getServerAddress()
|
||||||
config.serverPort = outbound?.getServerPort().toString()
|
config.serverPort = outbound?.getServerPort()?.toString()
|
||||||
|
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.dto.NetworkType
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
|
import com.v2ray.ang.util.HttpUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
@@ -18,19 +19,19 @@ open class FmtBase {
|
|||||||
*/
|
*/
|
||||||
fun toUri(config: ProfileItem, userInfo: String?, dicQuery: HashMap<String, String>?): String {
|
fun toUri(config: ProfileItem, userInfo: String?, dicQuery: HashMap<String, String>?): String {
|
||||||
val query = if (dicQuery != null)
|
val query = if (dicQuery != null)
|
||||||
("?" + dicQuery.toList().joinToString(
|
"?" + dicQuery.toList().joinToString(
|
||||||
separator = "&",
|
separator = "&",
|
||||||
transform = { it.first + "=" + Utils.urlEncode(it.second) }))
|
transform = { it.first + "=" + Utils.encodeURIComponent(it.second) })
|
||||||
else ""
|
else ""
|
||||||
|
|
||||||
val url = String.format(
|
val url = String.format(
|
||||||
"%s@%s:%s",
|
"%s@%s:%s",
|
||||||
Utils.urlEncode(userInfo ?: ""),
|
Utils.encodeURIComponent(userInfo ?: ""),
|
||||||
Utils.getIpv6Address(config.server),
|
Utils.getIpv6Address(HttpUtil.toIdnDomain(config.server.orEmpty())),
|
||||||
config.serverPort
|
config.serverPort
|
||||||
)
|
)
|
||||||
|
|
||||||
return "${url}${query}#${Utils.urlEncode(config.remarks)}"
|
return "${url}${query}#${Utils.encodeURIComponent(config.remarks)}"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,7 +42,7 @@ open class FmtBase {
|
|||||||
*/
|
*/
|
||||||
fun getQueryParam(uri: URI): Map<String, String> {
|
fun getQueryParam(uri: URI): Map<String, String> {
|
||||||
return uri.rawQuery.split("&")
|
return uri.rawQuery.split("&")
|
||||||
.associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } }
|
.associate { it.split("=").let { (k, v) -> k to Utils.decodeURIComponent(v) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,15 +50,16 @@ open class FmtBase {
|
|||||||
*
|
*
|
||||||
* @param config the ProfileItem object to populate
|
* @param config the ProfileItem object to populate
|
||||||
* @param queryParam the query parameters to use for populating the ProfileItem
|
* @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.network = queryParam["type"] ?: NetworkType.TCP.type
|
||||||
config.headerType = queryParam["headerType"]
|
config.headerType = queryParam["headerType"]
|
||||||
config.host = queryParam["host"]
|
config.host = queryParam["host"]
|
||||||
config.path = queryParam["path"]
|
config.path = queryParam["path"]
|
||||||
|
|
||||||
config.seed = queryParam["seed"]
|
config.seed = queryParam["seed"]
|
||||||
|
config.kcpMtu = queryParam["mtu"]?.toIntOrNull()
|
||||||
|
config.kcpTti = queryParam["tti"]?.toIntOrNull()
|
||||||
config.quicSecurity = queryParam["quicSecurity"]
|
config.quicSecurity = queryParam["quicSecurity"]
|
||||||
config.quicKey = queryParam["key"]
|
config.quicKey = queryParam["key"]
|
||||||
config.mode = queryParam["mode"]
|
config.mode = queryParam["mode"]
|
||||||
@@ -65,22 +67,28 @@ open class FmtBase {
|
|||||||
config.authority = queryParam["authority"]
|
config.authority = queryParam["authority"]
|
||||||
config.xhttpMode = queryParam["mode"]
|
config.xhttpMode = queryParam["mode"]
|
||||||
config.xhttpExtra = queryParam["extra"]
|
config.xhttpExtra = queryParam["extra"]
|
||||||
|
config.finalMask = queryParam["fm"]
|
||||||
|
|
||||||
config.security = queryParam["security"]
|
config.security = queryParam["security"]
|
||||||
if (config.security != AppConfig.TLS && config.security != AppConfig.REALITY) {
|
if (config.security != AppConfig.TLS && config.security != AppConfig.REALITY) {
|
||||||
config.security = null
|
config.security = null
|
||||||
}
|
}
|
||||||
config.insecure = if (queryParam["allowInsecure"].isNullOrEmpty()) {
|
// Support multiple possible query keys for allowInsecure like the C# implementation
|
||||||
allowInsecure
|
val allowInsecureKeys = arrayOf("insecure", "allowInsecure", "allow_insecure")
|
||||||
} else {
|
config.insecure = when {
|
||||||
queryParam["allowInsecure"].orEmpty() == "1"
|
allowInsecureKeys.any { queryParam[it] == "1" } -> true
|
||||||
|
allowInsecureKeys.any { queryParam[it] == "0" } -> false
|
||||||
|
else -> false
|
||||||
}
|
}
|
||||||
config.sni = queryParam["sni"]
|
config.sni = queryParam["sni"]
|
||||||
config.fingerPrint = queryParam["fp"]
|
config.fingerPrint = queryParam["fp"]
|
||||||
config.alpn = queryParam["alpn"]
|
config.alpn = queryParam["alpn"]
|
||||||
|
config.echConfigList = queryParam["ech"]
|
||||||
|
config.pinnedCA256 = queryParam["pcs"]
|
||||||
config.publicKey = queryParam["pbk"]
|
config.publicKey = queryParam["pbk"]
|
||||||
config.shortId = queryParam["sid"]
|
config.shortId = queryParam["sid"]
|
||||||
config.spiderX = queryParam["spx"]
|
config.spiderX = queryParam["spx"]
|
||||||
|
config.mldsa65Verify = queryParam["pqv"]
|
||||||
config.flow = queryParam["flow"]
|
config.flow = queryParam["flow"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,13 +101,25 @@ open class FmtBase {
|
|||||||
fun getQueryDic(config: ProfileItem): HashMap<String, String> {
|
fun getQueryDic(config: ProfileItem): HashMap<String, String> {
|
||||||
val dicQuery = HashMap<String, String>()
|
val dicQuery = HashMap<String, String>()
|
||||||
dicQuery["security"] = config.security?.ifEmpty { "none" }.orEmpty()
|
dicQuery["security"] = config.security?.ifEmpty { "none" }.orEmpty()
|
||||||
config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() }
|
config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
|
||||||
config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() }
|
config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
|
||||||
config.fingerPrint.let { if (it.isNotNullEmpty()) dicQuery["fp"] = it.orEmpty() }
|
config.echConfigList?.nullIfBlank()?.let { dicQuery["ech"] = it }
|
||||||
config.publicKey.let { if (it.isNotNullEmpty()) dicQuery["pbk"] = it.orEmpty() }
|
config.pinnedCA256?.nullIfBlank()?.let { dicQuery["pcs"] = it }
|
||||||
config.shortId.let { if (it.isNotNullEmpty()) dicQuery["sid"] = it.orEmpty() }
|
config.fingerPrint?.nullIfBlank()?.let { dicQuery["fp"] = it }
|
||||||
config.spiderX.let { if (it.isNotNullEmpty()) dicQuery["spx"] = it.orEmpty() }
|
config.publicKey?.nullIfBlank()?.let { dicQuery["pbk"] = it }
|
||||||
config.flow.let { if (it.isNotNullEmpty()) dicQuery["flow"] = it.orEmpty() }
|
config.shortId?.nullIfBlank()?.let { dicQuery["sid"] = it }
|
||||||
|
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"
|
||||||
|
dicQuery["insecure"] = insecureFlag
|
||||||
|
dicQuery["allowInsecure"] = insecureFlag
|
||||||
|
}
|
||||||
|
|
||||||
val networkType = NetworkType.fromString(config.network)
|
val networkType = NetworkType.fromString(config.network)
|
||||||
dicQuery["type"] = networkType.type
|
dicQuery["type"] = networkType.type
|
||||||
@@ -107,45 +127,47 @@ open class FmtBase {
|
|||||||
when (networkType) {
|
when (networkType) {
|
||||||
NetworkType.TCP -> {
|
NetworkType.TCP -> {
|
||||||
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
|
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
|
||||||
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
|
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkType.KCP -> {
|
NetworkType.KCP -> {
|
||||||
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
|
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
|
||||||
config.seed.let { if (it.isNotNullEmpty()) dicQuery["seed"] = it.orEmpty() }
|
config.seed?.nullIfBlank()?.let { dicQuery["seed"] = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkType.WS, NetworkType.HTTP_UPGRADE -> {
|
NetworkType.WS, NetworkType.HTTP_UPGRADE -> {
|
||||||
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
|
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
|
||||||
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
|
config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkType.XHTTP -> {
|
NetworkType.XHTTP -> {
|
||||||
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
|
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
|
||||||
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
|
config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
|
||||||
config.xhttpMode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() }
|
config.xhttpMode?.nullIfBlank()?.let { dicQuery["mode"] = it }
|
||||||
config.xhttpExtra.let { if (it.isNotNullEmpty()) dicQuery["extra"] = it.orEmpty() }
|
config.xhttpExtra?.nullIfBlank()?.let { dicQuery["extra"] = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkType.HTTP, NetworkType.H2 -> {
|
NetworkType.HTTP, NetworkType.H2 -> {
|
||||||
dicQuery["type"] = "http"
|
dicQuery["type"] = "http"
|
||||||
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
|
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
|
||||||
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
|
config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkType.QUIC -> {
|
// NetworkType.QUIC -> {
|
||||||
// dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
|
// dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
|
||||||
// config.quicSecurity.let { if (it.isNotNullEmpty()) dicQuery["quicSecurity"] = it.orEmpty() }
|
// config.quicSecurity?.nullIfBlank()?.let { dicQuery["quicSecurity"] = it }
|
||||||
// config.quicKey.let { if (it.isNotNullEmpty()) dicQuery["key"] = it.orEmpty() }
|
// config.quicKey?.nullIfBlank()?.let { dicQuery["key"] = it }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
NetworkType.GRPC -> {
|
NetworkType.GRPC -> {
|
||||||
config.mode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() }
|
config.mode?.nullIfBlank()?.let { dicQuery["mode"] = it }
|
||||||
config.authority.let { if (it.isNotNullEmpty()) dicQuery["authority"] = it.orEmpty() }
|
config.authority?.nullIfBlank()?.let { dicQuery["authority"] = it }
|
||||||
config.serviceName.let { if (it.isNotNullEmpty()) dicQuery["serviceName"] = it.orEmpty() }
|
config.serviceName?.nullIfBlank()?.let { dicQuery["serviceName"] = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return dicQuery
|
return dicQuery
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
package com.v2ray.ang.fmt
|
|
||||||
|
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
|
||||||
|
|
||||||
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 = OutboundBean.create(EConfigType.HTTP)
|
|
||||||
|
|
||||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
|
||||||
server.address = profileItem.server.orEmpty()
|
|
||||||
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,12 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.LOOPBACK
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
import com.v2ray.ang.dto.EConfigType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.dto.Hysteria2Bean
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
@@ -19,32 +18,29 @@ object Hysteria2Fmt : FmtBase() {
|
|||||||
* @param str the Hysteria2 URI string to parse
|
* @param str the Hysteria2 URI string to parse
|
||||||
* @return the parsed ProfileItem object, or null if parsing fails
|
* @return the parsed ProfileItem object, or null if parsing fails
|
||||||
*/
|
*/
|
||||||
fun parse(str: String): ProfileItem? {
|
fun parse(str: String): ProfileItem {
|
||||||
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
|
||||||
val config = ProfileItem.create(EConfigType.HYSTERIA2)
|
val config = ProfileItem.create(EConfigType.HYSTERIA2)
|
||||||
|
|
||||||
val uri = URI(Utils.fixIllegalUrl(str))
|
val uri = URI(Utils.fixIllegalUrl(str))
|
||||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
|
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||||
config.server = uri.idnHost
|
config.server = uri.idnHost
|
||||||
config.serverPort = uri.port.toString()
|
config.serverPort = uri.port.toString()
|
||||||
config.password = uri.userInfo
|
config.password = uri.userInfo
|
||||||
config.security = AppConfig.TLS
|
config.security = AppConfig.TLS
|
||||||
|
config.network = NetworkType.HYSTERIA.type
|
||||||
|
|
||||||
if (!uri.rawQuery.isNullOrEmpty()) {
|
if (!uri.rawQuery.isNullOrEmpty()) {
|
||||||
val queryParam = getQueryParam(uri)
|
val queryParam = getQueryParam(uri)
|
||||||
|
|
||||||
config.security = queryParam["security"] ?: AppConfig.TLS
|
getItemFormQuery(config, queryParam)
|
||||||
config.insecure = if (queryParam["insecure"].isNullOrEmpty()) {
|
|
||||||
allowInsecure
|
|
||||||
} else {
|
|
||||||
queryParam["insecure"].orEmpty() == "1"
|
|
||||||
}
|
|
||||||
config.sni = queryParam["sni"]
|
|
||||||
config.alpn = queryParam["alpn"]
|
|
||||||
|
|
||||||
|
config.security = queryParam["security"] ?: AppConfig.TLS
|
||||||
config.obfsPassword = queryParam["obfs-password"]
|
config.obfsPassword = queryParam["obfs-password"]
|
||||||
config.portHopping = queryParam["mport"]
|
config.portHopping = queryParam["mport"]
|
||||||
config.pinSHA256 = queryParam["pinSHA256"]
|
if (config.portHopping.isNotNullEmpty()) {
|
||||||
|
config.portHoppingInterval = queryParam["mportHopInt"]
|
||||||
|
}
|
||||||
|
config.pinnedCA256 = queryParam["pinSHA256"]
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +57,8 @@ object Hysteria2Fmt : FmtBase() {
|
|||||||
val dicQuery = HashMap<String, String>()
|
val dicQuery = HashMap<String, String>()
|
||||||
|
|
||||||
config.security.let { if (it != null) dicQuery["security"] = it }
|
config.security.let { if (it != null) dicQuery["security"] = it }
|
||||||
config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() }
|
config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
|
||||||
config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() }
|
config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
|
||||||
config.insecure.let { dicQuery["insecure"] = if (it == true) "1" else "0" }
|
config.insecure.let { dicQuery["insecure"] = if (it == true) "1" else "0" }
|
||||||
|
|
||||||
if (config.obfsPassword.isNotNullEmpty()) {
|
if (config.obfsPassword.isNotNullEmpty()) {
|
||||||
@@ -72,79 +68,43 @@ object Hysteria2Fmt : FmtBase() {
|
|||||||
if (config.portHopping.isNotNullEmpty()) {
|
if (config.portHopping.isNotNullEmpty()) {
|
||||||
dicQuery["mport"] = config.portHopping.orEmpty()
|
dicQuery["mport"] = config.portHopping.orEmpty()
|
||||||
}
|
}
|
||||||
if (config.pinSHA256.isNotNullEmpty()) {
|
if (config.portHoppingInterval.isNotNullEmpty()) {
|
||||||
dicQuery["pinSHA256"] = config.pinSHA256.orEmpty()
|
val rawInterval = config.portHoppingInterval?.trim().nullIfBlank()
|
||||||
|
val interval = if (rawInterval == null) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val singleValue = rawInterval.toIntOrNull()
|
||||||
|
if (singleValue != null) {
|
||||||
|
if (singleValue < 5) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
rawInterval
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val parts = rawInterval.split('-')
|
||||||
|
if (parts.size == 2) {
|
||||||
|
val start = parts[0].trim().toIntOrNull()
|
||||||
|
val end = parts[1].trim().toIntOrNull()
|
||||||
|
if (start != null && end != null) {
|
||||||
|
val minStart = maxOf(5, start)
|
||||||
|
val minEnd = maxOf(minStart, end)
|
||||||
|
(minStart + minEnd) / 2
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (interval != null) {
|
||||||
|
dicQuery["mportHopInt"] = interval.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (config.pinnedCA256.isNotNullEmpty()) {
|
||||||
|
dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
return toUri(config, config.password, dicQuery)
|
return toUri(config, config.password, dicQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a ProfileItem object to a Hysteria2Bean object.
|
|
||||||
*
|
|
||||||
* @param config the ProfileItem object to convert
|
|
||||||
* @param socksPort the port number for the socks5 proxy
|
|
||||||
* @return the converted Hysteria2Bean object, or null if conversion fails
|
|
||||||
*/
|
|
||||||
fun toNativeConfig(config: ProfileItem, socksPort: Int): Hysteria2Bean? {
|
|
||||||
|
|
||||||
val obfs = if (config.obfsPassword.isNullOrEmpty()) null else
|
|
||||||
Hysteria2Bean.ObfsBean(
|
|
||||||
type = "salamander",
|
|
||||||
salamander = Hysteria2Bean.ObfsBean.SalamanderBean(
|
|
||||||
password = config.obfsPassword
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val transport = if (config.portHopping.isNullOrEmpty()) null else
|
|
||||||
Hysteria2Bean.TransportBean(
|
|
||||||
type = "udp",
|
|
||||||
udp = Hysteria2Bean.TransportBean.TransportUdpBean(
|
|
||||||
hopInterval = (config.portHoppingInterval ?: "30") + "s"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val bandwidth = if (config.bandwidthDown.isNullOrEmpty() || config.bandwidthUp.isNullOrEmpty()) null else
|
|
||||||
Hysteria2Bean.BandwidthBean(
|
|
||||||
down = config.bandwidthDown,
|
|
||||||
up = config.bandwidthUp,
|
|
||||||
)
|
|
||||||
|
|
||||||
val server =
|
|
||||||
if (config.portHopping.isNullOrEmpty())
|
|
||||||
config.getServerAddressAndPort()
|
|
||||||
else
|
|
||||||
Utils.getIpv6Address(config.server) + ":" + config.portHopping
|
|
||||||
|
|
||||||
val bean = Hysteria2Bean(
|
|
||||||
server = server,
|
|
||||||
auth = config.password,
|
|
||||||
obfs = obfs,
|
|
||||||
transport = transport,
|
|
||||||
bandwidth = bandwidth,
|
|
||||||
socks5 = Hysteria2Bean.Socks5Bean(
|
|
||||||
listen = "$LOOPBACK:${socksPort}",
|
|
||||||
),
|
|
||||||
http = Hysteria2Bean.Socks5Bean(
|
|
||||||
listen = "$LOOPBACK:${socksPort}",
|
|
||||||
),
|
|
||||||
tls = Hysteria2Bean.TlsBean(
|
|
||||||
sni = config.sni ?: config.server,
|
|
||||||
insecure = config.insecure,
|
|
||||||
pinSHA256 = if (config.pinSHA256.isNullOrEmpty()) null else config.pinSHA256
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return bean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a ProfileItem object to an OutboundBean object.
|
|
||||||
*
|
|
||||||
* @param profileItem the ProfileItem object to convert
|
|
||||||
* @return the converted OutboundBean object, or null if conversion fails
|
|
||||||
*/
|
|
||||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
|
||||||
val outboundBean = OutboundBean.create(EConfigType.HYSTERIA2)
|
|
||||||
return outboundBean
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.dto.EConfigType
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.dto.NetworkType
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ object ShadowsocksFmt : FmtBase() {
|
|||||||
if (uri.port <= 0) return null
|
if (uri.port <= 0) return null
|
||||||
if (uri.userInfo.isNullOrEmpty()) return null
|
if (uri.userInfo.isNullOrEmpty()) return null
|
||||||
|
|
||||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
|
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||||
config.server = uri.idnHost
|
config.server = uri.idnHost
|
||||||
config.serverPort = uri.port.toString()
|
config.serverPort = uri.port.toString()
|
||||||
|
|
||||||
@@ -80,9 +81,9 @@ object ShadowsocksFmt : FmtBase() {
|
|||||||
if (indexSplit > 0) {
|
if (indexSplit > 0) {
|
||||||
try {
|
try {
|
||||||
config.remarks =
|
config.remarks =
|
||||||
Utils.urlDecode(result.substring(indexSplit + 1, result.length))
|
Utils.decodeURIComponent(result.substring(indexSplit + 1, result.length))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
result = result.substring(0, indexSplit)
|
result = result.substring(0, indexSplit)
|
||||||
@@ -119,49 +120,6 @@ object ShadowsocksFmt : FmtBase() {
|
|||||||
fun toUri(config: ProfileItem): String {
|
fun toUri(config: ProfileItem): String {
|
||||||
val pw = "${config.method}:${config.password}"
|
val pw = "${config.method}:${config.password}"
|
||||||
|
|
||||||
return toUri(config, Utils.encode(pw), null)
|
return toUri(config, Utils.encode(pw, true), null)
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a ProfileItem object to an OutboundBean object.
|
|
||||||
*
|
|
||||||
* @param profileItem the ProfileItem object to convert
|
|
||||||
* @return the converted OutboundBean object, or null if conversion fails
|
|
||||||
*/
|
|
||||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
|
||||||
val outboundBean = OutboundBean.create(EConfigType.SHADOWSOCKS)
|
|
||||||
|
|
||||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
|
||||||
server.address = profileItem.server.orEmpty()
|
|
||||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
|
||||||
server.password = profileItem.password
|
|
||||||
server.method = profileItem.method
|
|
||||||
}
|
|
||||||
|
|
||||||
val sni = outboundBean?.streamSettings?.populateTransportSettings(
|
|
||||||
profileItem.network.orEmpty(),
|
|
||||||
profileItem.headerType,
|
|
||||||
profileItem.host,
|
|
||||||
profileItem.path,
|
|
||||||
profileItem.seed,
|
|
||||||
profileItem.quicSecurity,
|
|
||||||
profileItem.quicKey,
|
|
||||||
profileItem.mode,
|
|
||||||
profileItem.serviceName,
|
|
||||||
profileItem.authority,
|
|
||||||
)
|
|
||||||
|
|
||||||
outboundBean?.streamSettings?.populateTlsSettings(
|
|
||||||
profileItem.security.orEmpty(),
|
|
||||||
profileItem.insecure == true,
|
|
||||||
if (profileItem.sni.isNullOrEmpty()) sni else profileItem.sni,
|
|
||||||
profileItem.fingerPrint,
|
|
||||||
profileItem.alpn,
|
|
||||||
profileItem.publicKey,
|
|
||||||
profileItem.shortId,
|
|
||||||
profileItem.spiderX,
|
|
||||||
)
|
|
||||||
|
|
||||||
return outboundBean
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.dto.EConfigType
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
@@ -22,12 +21,16 @@ object SocksFmt : FmtBase() {
|
|||||||
if (uri.idnHost.isEmpty()) return null
|
if (uri.idnHost.isEmpty()) return null
|
||||||
if (uri.port <= 0) return null
|
if (uri.port <= 0) return null
|
||||||
|
|
||||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
|
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||||
config.server = uri.idnHost
|
config.server = uri.idnHost
|
||||||
config.serverPort = uri.port.toString()
|
config.serverPort = uri.port.toString()
|
||||||
|
|
||||||
if (uri.userInfo?.isEmpty() == false) {
|
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) {
|
if (result.count() == 2) {
|
||||||
config.username = result.first()
|
config.username = result.first()
|
||||||
config.password = result.last()
|
config.password = result.last()
|
||||||
@@ -50,29 +53,6 @@ object SocksFmt : FmtBase() {
|
|||||||
else
|
else
|
||||||
":"
|
":"
|
||||||
|
|
||||||
return toUri(config, Utils.encode(pw), null)
|
return toUri(config, Utils.encode(pw, true), null)
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a ProfileItem object to an OutboundBean object.
|
|
||||||
*
|
|
||||||
* @param profileItem the ProfileItem object to convert
|
|
||||||
* @return the converted OutboundBean object, or null if conversion fails
|
|
||||||
*/
|
|
||||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
|
||||||
val outboundBean = OutboundBean.create(EConfigType.SOCKS)
|
|
||||||
|
|
||||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
|
||||||
server.address = profileItem.server.orEmpty()
|
|
||||||
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,10 +1,9 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.dto.EConfigType
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
import com.v2ray.ang.dto.NetworkType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
@@ -17,12 +16,11 @@ object TrojanFmt : FmtBase() {
|
|||||||
* @param str the Trojan URI string to parse
|
* @param str the Trojan URI string to parse
|
||||||
* @return the parsed ProfileItem object, or null if parsing fails
|
* @return the parsed ProfileItem object, or null if parsing fails
|
||||||
*/
|
*/
|
||||||
fun parse(str: String): ProfileItem? {
|
fun parse(str: String): ProfileItem {
|
||||||
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
|
||||||
val config = ProfileItem.create(EConfigType.TROJAN)
|
val config = ProfileItem.create(EConfigType.TROJAN)
|
||||||
|
|
||||||
val uri = URI(Utils.fixIllegalUrl(str))
|
val uri = URI(Utils.fixIllegalUrl(str))
|
||||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
|
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||||
config.server = uri.idnHost
|
config.server = uri.idnHost
|
||||||
config.serverPort = uri.port.toString()
|
config.serverPort = uri.port.toString()
|
||||||
config.password = uri.userInfo
|
config.password = uri.userInfo
|
||||||
@@ -30,11 +28,11 @@ object TrojanFmt : FmtBase() {
|
|||||||
if (uri.rawQuery.isNullOrEmpty()) {
|
if (uri.rawQuery.isNullOrEmpty()) {
|
||||||
config.network = NetworkType.TCP.type
|
config.network = NetworkType.TCP.type
|
||||||
config.security = AppConfig.TLS
|
config.security = AppConfig.TLS
|
||||||
config.insecure = allowInsecure
|
config.insecure = false
|
||||||
} else {
|
} else {
|
||||||
val queryParam = getQueryParam(uri)
|
val queryParam = getQueryParam(uri)
|
||||||
|
|
||||||
getItemFormQuery(config, queryParam, allowInsecure)
|
getItemFormQuery(config, queryParam)
|
||||||
config.security = queryParam["security"] ?: AppConfig.TLS
|
config.security = queryParam["security"] ?: AppConfig.TLS
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,47 +50,4 @@ object TrojanFmt : FmtBase() {
|
|||||||
|
|
||||||
return toUri(config, config.password, dicQuery)
|
return toUri(config, config.password, dicQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a ProfileItem object to an OutboundBean object.
|
|
||||||
*
|
|
||||||
* @param profileItem the ProfileItem object to convert
|
|
||||||
* @return the converted OutboundBean object, or null if conversion fails
|
|
||||||
*/
|
|
||||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
|
||||||
val outboundBean = OutboundBean.create(EConfigType.TROJAN)
|
|
||||||
|
|
||||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
|
||||||
server.address = profileItem.server.orEmpty()
|
|
||||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
|
||||||
server.password = profileItem.password
|
|
||||||
server.flow = profileItem.flow
|
|
||||||
}
|
|
||||||
|
|
||||||
val sni = outboundBean?.streamSettings?.populateTransportSettings(
|
|
||||||
profileItem.network.orEmpty(),
|
|
||||||
profileItem.headerType,
|
|
||||||
profileItem.host,
|
|
||||||
profileItem.path,
|
|
||||||
profileItem.seed,
|
|
||||||
profileItem.quicSecurity,
|
|
||||||
profileItem.quicKey,
|
|
||||||
profileItem.mode,
|
|
||||||
profileItem.serviceName,
|
|
||||||
profileItem.authority,
|
|
||||||
)
|
|
||||||
|
|
||||||
outboundBean?.streamSettings?.populateTlsSettings(
|
|
||||||
profileItem.security.orEmpty(),
|
|
||||||
profileItem.insecure == true,
|
|
||||||
if (profileItem.sni.isNullOrEmpty()) sni else profileItem.sni,
|
|
||||||
profileItem.fingerPrint,
|
|
||||||
profileItem.alpn,
|
|
||||||
profileItem.publicKey,
|
|
||||||
profileItem.shortId,
|
|
||||||
profileItem.spiderX,
|
|
||||||
)
|
|
||||||
|
|
||||||
return outboundBean
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.dto.EConfigType
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.util.JsonUtil
|
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
@@ -19,20 +17,19 @@ object VlessFmt : FmtBase() {
|
|||||||
* @return the parsed ProfileItem object, or null if parsing fails
|
* @return the parsed ProfileItem object, or null if parsing fails
|
||||||
*/
|
*/
|
||||||
fun parse(str: String): ProfileItem? {
|
fun parse(str: String): ProfileItem? {
|
||||||
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
|
||||||
val config = ProfileItem.create(EConfigType.VLESS)
|
val config = ProfileItem.create(EConfigType.VLESS)
|
||||||
|
|
||||||
val uri = URI(Utils.fixIllegalUrl(str))
|
val uri = URI(Utils.fixIllegalUrl(str))
|
||||||
if (uri.rawQuery.isNullOrEmpty()) return null
|
if (uri.rawQuery.isNullOrEmpty()) return null
|
||||||
val queryParam = getQueryParam(uri)
|
val queryParam = getQueryParam(uri)
|
||||||
|
|
||||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
|
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||||
config.server = uri.idnHost
|
config.server = uri.idnHost
|
||||||
config.serverPort = uri.port.toString()
|
config.serverPort = uri.port.toString()
|
||||||
config.password = uri.userInfo
|
config.password = uri.userInfo
|
||||||
config.method = queryParam["encryption"] ?: "none"
|
config.method = queryParam["encryption"] ?: "none"
|
||||||
|
|
||||||
getItemFormQuery(config, queryParam, allowInsecure)
|
getItemFormQuery(config, queryParam)
|
||||||
|
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
@@ -50,49 +47,4 @@ object VlessFmt : FmtBase() {
|
|||||||
return toUri(config, config.password, dicQuery)
|
return toUri(config, config.password, dicQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a ProfileItem object to an OutboundBean object.
|
|
||||||
*
|
|
||||||
* @param profileItem the ProfileItem object to convert
|
|
||||||
* @return the converted OutboundBean object, or null if conversion fails
|
|
||||||
*/
|
|
||||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
|
||||||
val outboundBean = OutboundBean.create(EConfigType.VLESS)
|
|
||||||
|
|
||||||
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
|
||||||
vnext.address = profileItem.server.orEmpty()
|
|
||||||
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
|
||||||
vnext.users[0].id = profileItem.password.orEmpty()
|
|
||||||
vnext.users[0].encryption = profileItem.method
|
|
||||||
vnext.users[0].flow = profileItem.flow
|
|
||||||
}
|
|
||||||
|
|
||||||
val sni = outboundBean?.streamSettings?.populateTransportSettings(
|
|
||||||
profileItem.network.orEmpty(),
|
|
||||||
profileItem.headerType,
|
|
||||||
profileItem.host,
|
|
||||||
profileItem.path,
|
|
||||||
profileItem.seed,
|
|
||||||
profileItem.quicSecurity,
|
|
||||||
profileItem.quicKey,
|
|
||||||
profileItem.mode,
|
|
||||||
profileItem.serviceName,
|
|
||||||
profileItem.authority,
|
|
||||||
)
|
|
||||||
outboundBean?.streamSettings?.xhttpSettings?.mode = profileItem.xhttpMode
|
|
||||||
outboundBean?.streamSettings?.xhttpSettings?.extra = JsonUtil.parseString(profileItem.xhttpExtra)
|
|
||||||
|
|
||||||
outboundBean?.streamSettings?.populateTlsSettings(
|
|
||||||
profileItem.security.orEmpty(),
|
|
||||||
profileItem.insecure == true,
|
|
||||||
if (profileItem.sni.isNullOrEmpty()) sni else profileItem.sni,
|
|
||||||
profileItem.fingerPrint,
|
|
||||||
profileItem.alpn,
|
|
||||||
profileItem.publicKey,
|
|
||||||
profileItem.shortId,
|
|
||||||
profileItem.spiderX,
|
|
||||||
)
|
|
||||||
|
|
||||||
return outboundBean
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.NetworkType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
|
||||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
|
||||||
import com.v2ray.ang.dto.VmessQRCode
|
import com.v2ray.ang.dto.VmessQRCode
|
||||||
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.enums.NetworkType
|
||||||
import com.v2ray.ang.extension.idnHost
|
import com.v2ray.ang.extension.idnHost
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.util.JsonUtil
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
@@ -27,23 +26,22 @@ object VmessFmt : FmtBase() {
|
|||||||
return parseVmessStd(str)
|
return parseVmessStd(str)
|
||||||
}
|
}
|
||||||
|
|
||||||
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
|
||||||
val config = ProfileItem.create(EConfigType.VMESS)
|
val config = ProfileItem.create(EConfigType.VMESS)
|
||||||
|
|
||||||
var result = str.replace(EConfigType.VMESS.protocolScheme, "")
|
var result = str.replace(EConfigType.VMESS.protocolScheme, "")
|
||||||
result = Utils.decode(result)
|
result = Utils.decode(result)
|
||||||
if (TextUtils.isEmpty(result)) {
|
if (TextUtils.isEmpty(result)) {
|
||||||
Log.d(AppConfig.ANG_PACKAGE, "R.string.toast_decoding_failed")
|
LogUtil.w(AppConfig.TAG, "Toast decoding failed")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java)
|
val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java) ?: return null
|
||||||
// Although VmessQRCode fields are non null, looks like Gson may still create null fields
|
// Although VmessQRCode fields are non null, looks like Gson may still create null fields
|
||||||
if (TextUtils.isEmpty(vmessQRCode.add)
|
if (TextUtils.isEmpty(vmessQRCode.add)
|
||||||
|| TextUtils.isEmpty(vmessQRCode.port)
|
|| TextUtils.isEmpty(vmessQRCode.port)
|
||||||
|| TextUtils.isEmpty(vmessQRCode.id)
|
|| TextUtils.isEmpty(vmessQRCode.id)
|
||||||
|| TextUtils.isEmpty(vmessQRCode.net)
|
|| TextUtils.isEmpty(vmessQRCode.net)
|
||||||
) {
|
) {
|
||||||
Log.d(AppConfig.ANG_PACKAGE, "R.string.toast_incorrect_protocol")
|
LogUtil.w(AppConfig.TAG, "Toast incorrect protocol")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,9 +49,13 @@ object VmessFmt : FmtBase() {
|
|||||||
config.server = vmessQRCode.add
|
config.server = vmessQRCode.add
|
||||||
config.serverPort = vmessQRCode.port
|
config.serverPort = vmessQRCode.port
|
||||||
config.password = vmessQRCode.id
|
config.password = vmessQRCode.id
|
||||||
config.method = if (TextUtils.isEmpty(vmessQRCode.scy)) AppConfig.DEFAULT_SECURITY else vmessQRCode.scy
|
config.method =
|
||||||
|
if (TextUtils.isEmpty(vmessQRCode.scy)) AppConfig.DEFAULT_SECURITY else vmessQRCode.scy
|
||||||
|
|
||||||
config.network = vmessQRCode.net ?: NetworkType.TCP.type
|
config.network = vmessQRCode.net
|
||||||
|
if (config.network.isNullOrEmpty()) {
|
||||||
|
config.network = NetworkType.TCP.type
|
||||||
|
}
|
||||||
config.headerType = vmessQRCode.type
|
config.headerType = vmessQRCode.type
|
||||||
config.host = vmessQRCode.host
|
config.host = vmessQRCode.host
|
||||||
config.path = vmessQRCode.path
|
config.path = vmessQRCode.path
|
||||||
@@ -73,15 +75,19 @@ object VmessFmt : FmtBase() {
|
|||||||
config.serviceName = vmessQRCode.path
|
config.serviceName = vmessQRCode.path
|
||||||
config.authority = vmessQRCode.host
|
config.authority = vmessQRCode.host
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
config.security = vmessQRCode.tls
|
config.security = vmessQRCode.tls
|
||||||
config.insecure = allowInsecure
|
|
||||||
config.sni = vmessQRCode.sni
|
config.sni = vmessQRCode.sni
|
||||||
config.fingerPrint = vmessQRCode.fp
|
config.fingerPrint = vmessQRCode.fp
|
||||||
config.alpn = vmessQRCode.alpn
|
config.alpn = vmessQRCode.alpn
|
||||||
|
config.insecure = when (vmessQRCode.insecure) {
|
||||||
|
"1" -> true
|
||||||
|
"0" -> false
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,16 +125,22 @@ object VmessFmt : FmtBase() {
|
|||||||
vmessQRCode.path = config.serviceName.orEmpty()
|
vmessQRCode.path = config.serviceName.orEmpty()
|
||||||
vmessQRCode.host = config.authority.orEmpty()
|
vmessQRCode.host = config.authority.orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
config.host.let { if (it.isNotNullEmpty()) vmessQRCode.host = it.orEmpty() }
|
config.host?.nullIfBlank()?.let { vmessQRCode.host = it }
|
||||||
config.path.let { if (it.isNotNullEmpty()) vmessQRCode.path = it.orEmpty() }
|
config.path?.nullIfBlank()?.let { vmessQRCode.path = it }
|
||||||
|
|
||||||
vmessQRCode.tls = config.security.orEmpty()
|
vmessQRCode.tls = config.security.orEmpty()
|
||||||
vmessQRCode.sni = config.sni.orEmpty()
|
vmessQRCode.sni = config.sni.orEmpty()
|
||||||
vmessQRCode.fp = config.fingerPrint.orEmpty()
|
vmessQRCode.fp = config.fingerPrint.orEmpty()
|
||||||
vmessQRCode.alpn = config.alpn.orEmpty()
|
vmessQRCode.alpn = config.alpn.orEmpty()
|
||||||
|
vmessQRCode.insecure = when (config.insecure) {
|
||||||
|
true -> "1"
|
||||||
|
false -> "0"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
val json = JsonUtil.toJson(vmessQRCode)
|
val json = JsonUtil.toJson(vmessQRCode)
|
||||||
return Utils.encode(json)
|
return Utils.encode(json)
|
||||||
@@ -141,65 +153,22 @@ object VmessFmt : FmtBase() {
|
|||||||
* @return the parsed ProfileItem object, or null if parsing fails
|
* @return the parsed ProfileItem object, or null if parsing fails
|
||||||
*/
|
*/
|
||||||
fun parseVmessStd(str: String): ProfileItem? {
|
fun parseVmessStd(str: String): ProfileItem? {
|
||||||
val allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
|
|
||||||
val config = ProfileItem.create(EConfigType.VMESS)
|
val config = ProfileItem.create(EConfigType.VMESS)
|
||||||
|
|
||||||
val uri = URI(Utils.fixIllegalUrl(str))
|
val uri = URI(Utils.fixIllegalUrl(str))
|
||||||
if (uri.rawQuery.isNullOrEmpty()) return null
|
if (uri.rawQuery.isNullOrEmpty()) return null
|
||||||
val queryParam = getQueryParam(uri)
|
val queryParam = getQueryParam(uri)
|
||||||
|
|
||||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
|
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||||
config.server = uri.idnHost
|
config.server = uri.idnHost
|
||||||
config.serverPort = uri.port.toString()
|
config.serverPort = uri.port.toString()
|
||||||
config.password = uri.userInfo
|
config.password = uri.userInfo
|
||||||
config.method = AppConfig.DEFAULT_SECURITY
|
config.method = AppConfig.DEFAULT_SECURITY
|
||||||
|
|
||||||
getItemFormQuery(config, queryParam, allowInsecure)
|
getItemFormQuery(config, queryParam)
|
||||||
|
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a ProfileItem object to an OutboundBean object.
|
|
||||||
*
|
|
||||||
* @param profileItem the ProfileItem object to convert
|
|
||||||
* @return the converted OutboundBean object, or null if conversion fails
|
|
||||||
*/
|
|
||||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
|
||||||
val outboundBean = OutboundBean.create(EConfigType.VMESS)
|
|
||||||
|
|
||||||
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
|
||||||
vnext.address = profileItem.server.orEmpty()
|
|
||||||
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
|
||||||
vnext.users[0].id = profileItem.password.orEmpty()
|
|
||||||
vnext.users[0].security = profileItem.method
|
|
||||||
}
|
|
||||||
|
|
||||||
val sni = outboundBean?.streamSettings?.populateTransportSettings(
|
|
||||||
profileItem.network.orEmpty(),
|
|
||||||
profileItem.headerType,
|
|
||||||
profileItem.host,
|
|
||||||
profileItem.path,
|
|
||||||
profileItem.seed,
|
|
||||||
profileItem.quicSecurity,
|
|
||||||
profileItem.quicKey,
|
|
||||||
profileItem.mode,
|
|
||||||
profileItem.serviceName,
|
|
||||||
profileItem.authority,
|
|
||||||
)
|
|
||||||
|
|
||||||
outboundBean?.streamSettings?.populateTlsSettings(
|
|
||||||
profileItem.security.orEmpty(),
|
|
||||||
profileItem.insecure == true,
|
|
||||||
if (profileItem.sni.isNullOrEmpty()) sni else profileItem.sni,
|
|
||||||
profileItem.fingerPrint,
|
|
||||||
profileItem.alpn,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
|
|
||||||
return outboundBean
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
package com.v2ray.ang.fmt
|
package com.v2ray.ang.fmt
|
||||||
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
import com.v2ray.ang.dto.EConfigType
|
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.idnHost
|
||||||
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.extension.removeWhiteSpace
|
import com.v2ray.ang.extension.removeWhiteSpace
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
@@ -24,16 +23,16 @@ object WireguardFmt : FmtBase() {
|
|||||||
if (uri.rawQuery.isNullOrEmpty()) return null
|
if (uri.rawQuery.isNullOrEmpty()) return null
|
||||||
val queryParam = getQueryParam(uri)
|
val queryParam = getQueryParam(uri)
|
||||||
|
|
||||||
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
|
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||||
config.server = uri.idnHost
|
config.server = uri.idnHost
|
||||||
config.serverPort = uri.port.toString()
|
config.serverPort = uri.port.toString()
|
||||||
|
|
||||||
config.secretKey = uri.userInfo.orEmpty()
|
config.secretKey = uri.userInfo.orEmpty()
|
||||||
config.localAddress = (queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4)
|
config.localAddress = queryParam["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||||
config.publicKey = queryParam["publickey"].orEmpty()
|
config.publicKey = queryParam["publickey"].orEmpty()
|
||||||
config.preSharedKey = queryParam["presharedkey"].orEmpty()
|
config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
|
||||||
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
||||||
config.reserved = (queryParam["reserved"] ?: "0,0,0")
|
config.reserved = queryParam["reserved"] ?: "0,0,0"
|
||||||
|
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
@@ -44,7 +43,7 @@ object WireguardFmt : FmtBase() {
|
|||||||
* @param str the Wireguard configuration file string to parse
|
* @param str the Wireguard configuration file string to parse
|
||||||
* @return the parsed ProfileItem object, or null if parsing fails
|
* @return the parsed ProfileItem object, or null if parsing fails
|
||||||
*/
|
*/
|
||||||
fun parseWireguardConfFile(str: String): ProfileItem? {
|
fun parseWireguardConfFile(str: String): ProfileItem {
|
||||||
val config = ProfileItem.create(EConfigType.WIREGUARD)
|
val config = ProfileItem.create(EConfigType.WIREGUARD)
|
||||||
|
|
||||||
val interfaceParams: MutableMap<String, String> = mutableMapOf()
|
val interfaceParams: MutableMap<String, String> = mutableMapOf()
|
||||||
@@ -80,10 +79,10 @@ object WireguardFmt : FmtBase() {
|
|||||||
|
|
||||||
config.secretKey = interfaceParams["privatekey"].orEmpty()
|
config.secretKey = interfaceParams["privatekey"].orEmpty()
|
||||||
config.remarks = System.currentTimeMillis().toString()
|
config.remarks = System.currentTimeMillis().toString()
|
||||||
config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
|
config.localAddress = interfaceParams["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||||
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
||||||
config.publicKey = peerParams["publickey"].orEmpty()
|
config.publicKey = peerParams["publickey"].orEmpty()
|
||||||
config.preSharedKey = peerParams["presharedkey"].orEmpty()
|
config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
|
||||||
val endpoint = peerParams["endpoint"].orEmpty()
|
val endpoint = peerParams["endpoint"].orEmpty()
|
||||||
val endpointParts = endpoint.split(":", limit = 2)
|
val endpointParts = endpoint.split(":", limit = 2)
|
||||||
if (endpointParts.size == 2) {
|
if (endpointParts.size == 2) {
|
||||||
@@ -98,29 +97,6 @@ object WireguardFmt : FmtBase() {
|
|||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a ProfileItem object to an OutboundBean object.
|
|
||||||
*
|
|
||||||
* @param profileItem the ProfileItem object to convert
|
|
||||||
* @return the converted OutboundBean object, or null if conversion fails
|
|
||||||
*/
|
|
||||||
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
|
|
||||||
val outboundBean = OutboundBean.create(EConfigType.WIREGUARD)
|
|
||||||
|
|
||||||
outboundBean?.settings?.let { wireguard ->
|
|
||||||
wireguard.secretKey = profileItem.secretKey
|
|
||||||
wireguard.address = (profileItem.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4).split(",")
|
|
||||||
wireguard.peers?.firstOrNull()?.let { peer ->
|
|
||||||
peer.publicKey = profileItem.publicKey.orEmpty()
|
|
||||||
peer.preSharedKey = profileItem.preSharedKey.orEmpty()
|
|
||||||
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
|
|
||||||
}
|
|
||||||
wireguard.mtu = profileItem.mtu
|
|
||||||
wireguard.reserved = profileItem.reserved?.split(",")?.map { it.toInt() }
|
|
||||||
}
|
|
||||||
|
|
||||||
return outboundBean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a ProfileItem object to a URI string.
|
* Converts a ProfileItem object to a URI string.
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ package com.v2ray.ang.handler
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.HY2
|
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.dto.*
|
import com.v2ray.ang.core.CoreConfigManager
|
||||||
|
import com.v2ray.ang.dto.SubscriptionUpdateResult
|
||||||
|
import com.v2ray.ang.dto.UrlContentRequest
|
||||||
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
|
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||||
|
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
import com.v2ray.ang.fmt.CustomFmt
|
import com.v2ray.ang.fmt.CustomFmt
|
||||||
import com.v2ray.ang.fmt.Hysteria2Fmt
|
import com.v2ray.ang.fmt.Hysteria2Fmt
|
||||||
import com.v2ray.ang.fmt.ShadowsocksFmt
|
import com.v2ray.ang.fmt.ShadowsocksFmt
|
||||||
@@ -18,12 +23,28 @@ import com.v2ray.ang.fmt.VmessFmt
|
|||||||
import com.v2ray.ang.fmt.WireguardFmt
|
import com.v2ray.ang.fmt.WireguardFmt
|
||||||
import com.v2ray.ang.util.HttpUtil
|
import com.v2ray.ang.util.HttpUtil
|
||||||
import com.v2ray.ang.util.JsonUtil
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.QRCodeDecoder
|
import com.v2ray.ang.util.QRCodeDecoder
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
object AngConfigManager {
|
object AngConfigManager {
|
||||||
|
|
||||||
|
// Parser mapping for different config types (lazy initialized)
|
||||||
|
private val configFmtParsers: Map<String, (String) -> ProfileItem?> by lazy {
|
||||||
|
mapOf(
|
||||||
|
EConfigType.VMESS.protocolScheme to VmessFmt::parse,
|
||||||
|
EConfigType.SHADOWSOCKS.protocolScheme to ShadowsocksFmt::parse,
|
||||||
|
EConfigType.SOCKS.protocolScheme to SocksFmt::parse,
|
||||||
|
AppConfig.SOCKS4 to SocksFmt::parse,
|
||||||
|
AppConfig.SOCKS5 to SocksFmt::parse,
|
||||||
|
EConfigType.TROJAN.protocolScheme to TrojanFmt::parse,
|
||||||
|
EConfigType.VLESS.protocolScheme to VlessFmt::parse,
|
||||||
|
EConfigType.WIREGUARD.protocolScheme to WireguardFmt::parse,
|
||||||
|
EConfigType.HYSTERIA2.protocolScheme to Hysteria2Fmt::parse,
|
||||||
|
AppConfig.HY2 to Hysteria2Fmt::parse
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shares the configuration to the clipboard.
|
* Shares the configuration to the clipboard.
|
||||||
@@ -42,7 +63,7 @@ object AngConfigManager {
|
|||||||
Utils.setClipboard(context, conf)
|
Utils.setClipboard(context, conf)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Failed to share config to clipboard", e)
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
@@ -69,9 +90,9 @@ object AngConfigManager {
|
|||||||
if (sb.count() > 0) {
|
if (sb.count() > 0) {
|
||||||
Utils.setClipboard(context, sb.toString())
|
Utils.setClipboard(context, sb.toString())
|
||||||
}
|
}
|
||||||
return sb.lines().count()
|
return sb.lines().count() - 1
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Failed to share non-custom configs to clipboard", e)
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,7 +112,7 @@ object AngConfigManager {
|
|||||||
return QRCodeDecoder.createQRCode(conf)
|
return QRCodeDecoder.createQRCode(conf)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Failed to share config as QR code", e)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,21 +127,14 @@ object AngConfigManager {
|
|||||||
fun shareFullContent2Clipboard(context: Context, guid: String?): Int {
|
fun shareFullContent2Clipboard(context: Context, guid: String?): Int {
|
||||||
try {
|
try {
|
||||||
if (guid == null) return -1
|
if (guid == null) return -1
|
||||||
val result = V2rayConfigManager.getV2rayConfig(context, guid)
|
val result = CoreConfigManager.getV2rayConfig(context, guid)
|
||||||
if (result.status) {
|
if (result.status) {
|
||||||
val config = MmkvManager.decodeServerConfig(guid)
|
|
||||||
if (config?.configType == EConfigType.HYSTERIA2) {
|
|
||||||
val socksPort = Utils.findFreePort(listOf(100 + SettingsManager.getSocksPort(), 0))
|
|
||||||
val hy2Config = Hysteria2Fmt.toNativeConfig(config, socksPort)
|
|
||||||
Utils.setClipboard(context, JsonUtil.toJsonPretty(hy2Config) + "\n" + result.content)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
Utils.setClipboard(context, result.content)
|
Utils.setClipboard(context, result.content)
|
||||||
} else {
|
} else {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Failed to share full content to clipboard", e)
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
@@ -138,17 +152,16 @@ object AngConfigManager {
|
|||||||
|
|
||||||
return config.configType.protocolScheme + when (config.configType) {
|
return config.configType.protocolScheme + when (config.configType) {
|
||||||
EConfigType.VMESS -> VmessFmt.toUri(config)
|
EConfigType.VMESS -> VmessFmt.toUri(config)
|
||||||
EConfigType.CUSTOM -> ""
|
|
||||||
EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toUri(config)
|
EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toUri(config)
|
||||||
EConfigType.SOCKS -> SocksFmt.toUri(config)
|
EConfigType.SOCKS -> SocksFmt.toUri(config)
|
||||||
EConfigType.HTTP -> ""
|
|
||||||
EConfigType.VLESS -> VlessFmt.toUri(config)
|
EConfigType.VLESS -> VlessFmt.toUri(config)
|
||||||
EConfigType.TROJAN -> TrojanFmt.toUri(config)
|
EConfigType.TROJAN -> TrojanFmt.toUri(config)
|
||||||
EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
|
EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
|
||||||
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
|
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
|
||||||
|
else -> {}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +180,7 @@ object AngConfigManager {
|
|||||||
count = parseBatchConfig(server, subid, append)
|
count = parseBatchConfig(server, subid, append)
|
||||||
}
|
}
|
||||||
if (count <= 0) {
|
if (count <= 0) {
|
||||||
count = parseCustomConfigServer(server, subid)
|
count = parseCustomConfigServer(server, subid, append)
|
||||||
}
|
}
|
||||||
|
|
||||||
var countSub = parseBatchSubscription(server)
|
var countSub = parseBatchSubscription(server)
|
||||||
@@ -203,7 +216,7 @@ object AngConfigManager {
|
|||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Failed to parse batch subscription", e)
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -221,49 +234,158 @@ object AngConfigManager {
|
|||||||
if (servers == null) {
|
if (servers == null) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
val removedSelectedServer =
|
// Find the currently selected server that belongs to the same subscription before replacement.
|
||||||
if (!TextUtils.isEmpty(subid) && !append) {
|
val removedSelected = getRemovedSelectedProfile(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)
|
val subItem = MmkvManager.decodeSubscription(subid)
|
||||||
var count = 0
|
|
||||||
|
// Parse all configs first (no I/O during parsing)
|
||||||
|
val configs = mutableListOf<ProfileItem>()
|
||||||
servers.lines()
|
servers.lines()
|
||||||
.distinct()
|
.distinct()
|
||||||
.reversed()
|
.reversed()
|
||||||
.forEach {
|
.forEach {
|
||||||
val resId = parseConfig(it, subid, subItem, removedSelectedServer)
|
val config = parseConfig(it, subid, subItem)
|
||||||
if (resId == 0) {
|
if (config != null) {
|
||||||
count++
|
configs.add(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return count
|
|
||||||
|
// Batch save all parsed configs (only one serverList read/write)
|
||||||
|
if (configs.isNotEmpty()) {
|
||||||
|
if (!append) {
|
||||||
|
MmkvManager.removeServerViaSubid(subid)
|
||||||
|
}
|
||||||
|
val keyToProfile = batchSaveConfigs(configs, subid)
|
||||||
|
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
||||||
|
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs.size
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Failed to parse batch config", e)
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch save configurations to reduce serverList read/write operations.
|
||||||
|
* Reads serverList once, saves all configs, then writes serverList once.
|
||||||
|
*
|
||||||
|
* @param configs The list of ProfileItem to save.
|
||||||
|
* @param subid The subscription ID.
|
||||||
|
* @return Map of generated keys to their corresponding ProfileItem.
|
||||||
|
*/
|
||||||
|
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): Map<String, ProfileItem> {
|
||||||
|
val keyToProfile = mutableMapOf<String, ProfileItem>()
|
||||||
|
|
||||||
|
// Read serverList once
|
||||||
|
val serverList = MmkvManager.decodeServerList(subid)
|
||||||
|
|
||||||
|
configs.forEach { config ->
|
||||||
|
val key = Utils.getUuid()
|
||||||
|
// Save profile directly without updating serverList
|
||||||
|
MmkvManager.encodeProfileDirect(key, JsonUtil.toJson(config))
|
||||||
|
|
||||||
|
if (!serverList.contains(key)) {
|
||||||
|
serverList.add(0, key)
|
||||||
|
}
|
||||||
|
keyToProfile[key] = config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write serverList once
|
||||||
|
MmkvManager.encodeServerList(serverList, subid)
|
||||||
|
return keyToProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a matched profile key from the given key-profile map using multi-level matching.
|
||||||
|
* Matching priority (from highest to lowest):
|
||||||
|
* 1. Exact match: server + port + password
|
||||||
|
* 2. Match by remarks (exact match)
|
||||||
|
* 3. Match by server + port
|
||||||
|
* 4. Match by server only
|
||||||
|
*
|
||||||
|
* @param keyToProfile Map of server keys to their ProfileItem
|
||||||
|
* @param target Target profile to match
|
||||||
|
* @return Matched key or null
|
||||||
|
*/
|
||||||
|
private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? {
|
||||||
|
if (keyToProfile.isEmpty()) return null
|
||||||
|
if (target == null) return null
|
||||||
|
|
||||||
|
// Level 0: Full match (remarks + server + port + password)
|
||||||
|
if (target.remarks.isNotBlank()) {
|
||||||
|
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||||
|
isSameText(saved.remarks, target.remarks) &&
|
||||||
|
isSameText(saved.server, target.server) &&
|
||||||
|
isSameText(saved.serverPort, target.serverPort) &&
|
||||||
|
isSameText(saved.password, target.password)
|
||||||
|
}?.key?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level 1: Match by remarks
|
||||||
|
if (target.remarks.isNotBlank()) {
|
||||||
|
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||||
|
isSameText(saved.remarks, target.remarks)
|
||||||
|
}?.key?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level 2: Exact match (server + port + password)
|
||||||
|
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||||
|
isSameText(saved.server, target.server) &&
|
||||||
|
isSameText(saved.serverPort, target.serverPort) &&
|
||||||
|
isSameText(saved.password, target.password)
|
||||||
|
}?.key?.let { return it }
|
||||||
|
|
||||||
|
// Level 3: Match by server + port
|
||||||
|
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||||
|
isSameText(saved.server, target.server) &&
|
||||||
|
isSameText(saved.serverPort, target.serverPort)
|
||||||
|
}?.key?.let { return it }
|
||||||
|
|
||||||
|
// Level 4: Match by server only
|
||||||
|
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||||
|
isSameText(saved.server, target.server)
|
||||||
|
}?.key?.let { return it }
|
||||||
|
|
||||||
|
// If old selected node cannot be matched, fall back to the first imported config.
|
||||||
|
return keyToProfile.keys.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the currently selected profile if it belongs to the target subscription and will be replaced.
|
||||||
|
*/
|
||||||
|
private fun getRemovedSelectedProfile(subid: String, append: Boolean): ProfileItem? {
|
||||||
|
if (subid.isBlank() || append) return null
|
||||||
|
|
||||||
|
return MmkvManager.getSelectServer()
|
||||||
|
.takeIf { it?.isNotBlank() == true }
|
||||||
|
?.let { MmkvManager.decodeServerConfig(it) }
|
||||||
|
?.takeIf { it.subscriptionId == subid }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Case-insensitive trimmed string comparison.
|
||||||
|
*
|
||||||
|
* @param left First string
|
||||||
|
* @param right Second string
|
||||||
|
* @return True if both are non-empty and equal (case-insensitive, trimmed)
|
||||||
|
*/
|
||||||
|
private fun isSameText(left: String?, right: String?): Boolean {
|
||||||
|
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
|
||||||
|
return left.trim().equals(right.trim(), ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a custom configuration server.
|
* Parses a custom configuration server.
|
||||||
*
|
*
|
||||||
* @param server The server string.
|
* @param server The server string.
|
||||||
* @param subid The subscription ID.
|
* @param subid The subscription ID.
|
||||||
|
* @param append Whether to append the configurations.
|
||||||
* @return The number of configurations parsed.
|
* @return The number of configurations parsed.
|
||||||
*/
|
*/
|
||||||
private fun parseCustomConfigServer(server: String?, subid: String): Int {
|
private fun parseCustomConfigServer(server: String?, subid: String, append: Boolean): Int {
|
||||||
if (server == null) {
|
if (server == null) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -273,42 +395,61 @@ object AngConfigManager {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
val serverList: Array<Any> =
|
val serverList: Array<Any> =
|
||||||
JsonUtil.fromJson(server, Array<Any>::class.java)
|
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
|
||||||
|
|
||||||
if (serverList.isNotEmpty()) {
|
if (serverList.isNotEmpty()) {
|
||||||
|
val removedSelected = getRemovedSelectedProfile(subid, append)
|
||||||
|
if (!append) {
|
||||||
|
MmkvManager.removeServerViaSubid(subid)
|
||||||
|
}
|
||||||
var count = 0
|
var count = 0
|
||||||
|
val keyToProfile = mutableMapOf<String, ProfileItem>()
|
||||||
for (srv in serverList.reversed()) {
|
for (srv in serverList.reversed()) {
|
||||||
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
|
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
|
||||||
config.subscriptionId = subid
|
config.subscriptionId = subid
|
||||||
|
config.description = generateDescription(config)
|
||||||
val key = MmkvManager.encodeServerConfig("", config)
|
val key = MmkvManager.encodeServerConfig("", config)
|
||||||
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
|
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
|
||||||
|
keyToProfile[key] = config
|
||||||
count += 1
|
count += 1
|
||||||
}
|
}
|
||||||
|
if (count > 0) {
|
||||||
|
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
||||||
|
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||||
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For compatibility
|
// For compatibility
|
||||||
val config = CustomFmt.parse(server) ?: return 0
|
val config = CustomFmt.parse(server) ?: return 0
|
||||||
config.subscriptionId = subid
|
config.subscriptionId = subid
|
||||||
|
config.description = generateDescription(config)
|
||||||
|
if (!append) {
|
||||||
|
MmkvManager.removeServerViaSubid(subid)
|
||||||
|
}
|
||||||
val key = MmkvManager.encodeServerConfig("", config)
|
val key = MmkvManager.encodeServerConfig("", config)
|
||||||
MmkvManager.encodeServerRaw(key, server)
|
MmkvManager.encodeServerRaw(key, server)
|
||||||
return 1
|
return 1
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
||||||
try {
|
try {
|
||||||
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
|
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
|
||||||
|
config.description = generateDescription(config)
|
||||||
|
if (!append) {
|
||||||
|
MmkvManager.removeServerViaSubid(subid)
|
||||||
|
}
|
||||||
val key = MmkvManager.encodeServerConfig("", config)
|
val key = MmkvManager.encodeServerConfig("", config)
|
||||||
MmkvManager.encodeServerRaw(key, server)
|
MmkvManager.encodeServerRaw(key, server)
|
||||||
return 1
|
return 1
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
} else {
|
} else {
|
||||||
@@ -318,130 +459,149 @@ object AngConfigManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the configuration from a QR code or string.
|
* Parses the configuration from a QR code or string.
|
||||||
|
* Only parses and returns ProfileItem, does not save.
|
||||||
*
|
*
|
||||||
* @param str The configuration string.
|
* @param str The configuration string.
|
||||||
* @param subid The subscription ID.
|
* @param subid The subscription ID.
|
||||||
* @param subItem The subscription item.
|
* @param subItem The subscription item.
|
||||||
* @param removedSelectedServer The removed selected server.
|
* @return The parsed ProfileItem or null if parsing fails or filtered out.
|
||||||
* @return The result code.
|
|
||||||
*/
|
*/
|
||||||
private fun parseConfig(
|
private fun parseConfig(
|
||||||
str: String?,
|
str: String?,
|
||||||
subid: String,
|
subid: String,
|
||||||
subItem: SubscriptionItem?,
|
subItem: SubscriptionItem?
|
||||||
removedSelectedServer: ProfileItem?
|
): ProfileItem? {
|
||||||
): Int {
|
|
||||||
try {
|
try {
|
||||||
if (str == null || TextUtils.isEmpty(str)) {
|
if (str == null || TextUtils.isEmpty(str)) {
|
||||||
return R.string.toast_none_data
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
|
val config = configFmtParsers.firstNotNullOfOrNull { (scheme, parser) ->
|
||||||
VmessFmt.parse(str)
|
if (str.startsWith(scheme)) parser(str) else null
|
||||||
} 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
return R.string.toast_incorrect_protocol
|
return null
|
||||||
}
|
}
|
||||||
//filter
|
|
||||||
if (subItem?.filter != null && subItem.filter?.isNotEmpty() == true && config.remarks.isNotEmpty()) {
|
// Apply filter
|
||||||
val matched = Regex(pattern = subItem.filter ?: "")
|
if (subItem?.filter.isNotNullEmpty() && config.remarks.isNotNullEmpty()) {
|
||||||
|
val matched = Regex(pattern = subItem?.filter.orEmpty())
|
||||||
.containsMatchIn(input = config.remarks)
|
.containsMatchIn(input = config.remarks)
|
||||||
if (!matched) return -1
|
if (!matched) return null
|
||||||
}
|
}
|
||||||
|
|
||||||
config.subscriptionId = subid
|
config.subscriptionId = subid
|
||||||
val guid = MmkvManager.encodeServerConfig("", config)
|
config.description = generateDescription(config)
|
||||||
if (removedSelectedServer != null &&
|
|
||||||
config.server == removedSelectedServer.server && config.serverPort == removedSelectedServer.serverPort
|
return config
|
||||||
) {
|
|
||||||
MmkvManager.setSelectServer(guid)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Failed to parse config", e)
|
||||||
return -1
|
return null
|
||||||
}
|
}
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the configuration via all subscriptions.
|
* Updates the configuration via all subscriptions.
|
||||||
*
|
*
|
||||||
* @return The number of configurations updated.
|
* @return Detailed result of the subscription update operation.
|
||||||
*/
|
*/
|
||||||
fun updateConfigViaSubAll(): Int {
|
fun updateConfigViaSubAll(): SubscriptionUpdateResult {
|
||||||
var count = 0
|
return try {
|
||||||
try {
|
val subscriptions = MmkvManager.decodeSubscriptions()
|
||||||
MmkvManager.decodeSubscriptions().forEach {
|
subscriptions.fold(SubscriptionUpdateResult()) { acc, subscription ->
|
||||||
count += updateConfigViaSub(it)
|
acc + updateConfigViaSub(subscription)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
|
||||||
return 0
|
SubscriptionUpdateResult()
|
||||||
}
|
}
|
||||||
return count
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the configuration via a subscription.
|
* Updates the configuration via a subscription.
|
||||||
*
|
*
|
||||||
* @param it The subscription item.
|
* @param it The subscription item.
|
||||||
* @return The number of configurations updated.
|
* @return Subscription update result.
|
||||||
*/
|
*/
|
||||||
fun updateConfigViaSub(it: Pair<String, SubscriptionItem>): Int {
|
fun updateConfigViaSub(it: SubscriptionCache): SubscriptionUpdateResult {
|
||||||
try {
|
try {
|
||||||
if (TextUtils.isEmpty(it.first)
|
// Check if disabled
|
||||||
|| TextUtils.isEmpty(it.second.remarks)
|
if (!it.subscription.enabled) {
|
||||||
|| TextUtils.isEmpty(it.second.url)
|
return SubscriptionUpdateResult(skipCount = 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate subscription info
|
||||||
|
if (TextUtils.isEmpty(it.guid)
|
||||||
|
|| TextUtils.isEmpty(it.subscription.remarks)
|
||||||
|
|| TextUtils.isEmpty(it.subscription.url)
|
||||||
) {
|
) {
|
||||||
return 0
|
return SubscriptionUpdateResult(skipCount = 1)
|
||||||
}
|
}
|
||||||
if (!it.second.enabled) {
|
|
||||||
return 0
|
val url = HttpUtil.toIdnUrl(it.subscription.url)
|
||||||
}
|
|
||||||
val url = HttpUtil.idnToASCII(it.second.url)
|
|
||||||
if (!Utils.isValidUrl(url)) {
|
if (!Utils.isValidUrl(url)) {
|
||||||
return 0
|
return SubscriptionUpdateResult(failureCount = 1)
|
||||||
}
|
}
|
||||||
Log.d(AppConfig.ANG_PACKAGE, url)
|
if (!it.subscription.allowInsecureUrl) {
|
||||||
|
if (!Utils.isValidSubUrl(url)) {
|
||||||
|
return SubscriptionUpdateResult(failureCount = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LogUtil.i(AppConfig.TAG, url)
|
||||||
|
val userAgent = it.subscription.userAgent
|
||||||
|
val proxyUsername = SettingsManager.getSocksUsername()
|
||||||
|
val proxyPassword = SettingsManager.getSocksPassword()
|
||||||
|
|
||||||
var configText = try {
|
var configText = try {
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
val httpPort = SettingsManager.getHttpPort()
|
||||||
HttpUtil.getUrlContentWithUserAgent(url, 15000, httpPort)
|
HttpUtil.getUrlContentWithUserAgent(
|
||||||
|
UrlContentRequest(
|
||||||
|
url = url,
|
||||||
|
userAgent = userAgent,
|
||||||
|
timeout = 15000,
|
||||||
|
httpPort = httpPort,
|
||||||
|
proxyUsername = proxyUsername,
|
||||||
|
proxyPassword = proxyPassword
|
||||||
|
)
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error, try……")
|
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
|
||||||
//e.printStackTrace()
|
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
if (configText.isEmpty()) {
|
if (configText.isEmpty()) {
|
||||||
configText = try {
|
configText = try {
|
||||||
HttpUtil.getUrlContentWithUserAgent(url)
|
HttpUtil.getUrlContentWithUserAgent(
|
||||||
|
UrlContentRequest(
|
||||||
|
url = url,
|
||||||
|
userAgent = userAgent
|
||||||
|
)
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (configText.isEmpty()) {
|
if (configText.isEmpty()) {
|
||||||
return 0
|
return SubscriptionUpdateResult(failureCount = 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val count = parseConfigViaSub(configText, it.guid, false)
|
||||||
|
if (count > 0) {
|
||||||
|
it.subscription.lastUpdated = System.currentTimeMillis()
|
||||||
|
MmkvManager.encodeSubscription(it.guid, it.subscription)
|
||||||
|
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 parseConfigViaSub(configText, it.first, false)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Failed to update config via subscription", e)
|
||||||
return 0
|
return SubscriptionUpdateResult(failureCount = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +619,7 @@ object AngConfigManager {
|
|||||||
count = parseBatchConfig(server, subid, append)
|
count = parseBatchConfig(server, subid, append)
|
||||||
}
|
}
|
||||||
if (count <= 0) {
|
if (count <= 0) {
|
||||||
count = parseCustomConfigServer(server, subid)
|
count = parseCustomConfigServer(server, subid, append)
|
||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
@@ -473,7 +633,7 @@ object AngConfigManager {
|
|||||||
private fun importUrlAsSubscription(url: String): Int {
|
private fun importUrlAsSubscription(url: String): Int {
|
||||||
val subscriptions = MmkvManager.decodeSubscriptions()
|
val subscriptions = MmkvManager.decodeSubscriptions()
|
||||||
subscriptions.forEach {
|
subscriptions.forEach {
|
||||||
if (it.second.url == url) {
|
if (it.subscription.url == url) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,4 +644,25 @@ object AngConfigManager {
|
|||||||
MmkvManager.encodeSubscription("", subItem)
|
MmkvManager.encodeSubscription("", subItem)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generates a description for the profile.
|
||||||
|
*
|
||||||
|
* @param profile The profile item.
|
||||||
|
* @return The generated description.
|
||||||
|
*/
|
||||||
|
fun generateDescription(profile: ProfileItem): String {
|
||||||
|
// Hide xxx:xxx:***/xxx.xxx.xxx.***
|
||||||
|
val server = profile.server
|
||||||
|
val port = profile.serverPort
|
||||||
|
if (server.isNullOrBlank() && port.isNullOrBlank()) return ""
|
||||||
|
|
||||||
|
val addrPart = server?.let {
|
||||||
|
if (it.contains(":"))
|
||||||
|
it.split(":").take(2).joinToString(":", postfix = ":***")
|
||||||
|
else
|
||||||
|
it.split('.').dropLast(1).joinToString(".", postfix = ".***")
|
||||||
|
} ?: ""
|
||||||
|
|
||||||
|
return "$addrPart : ${port ?: ""}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
package com.v2ray.ang.handler
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.tencent.mmkv.MMKV
|
|
||||||
import com.v2ray.ang.AppConfig
|
|
||||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.NetworkType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
|
||||||
import com.v2ray.ang.dto.ServerConfig
|
|
||||||
import com.v2ray.ang.extension.removeWhiteSpace
|
|
||||||
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
|
|
||||||
import com.v2ray.ang.util.JsonUtil
|
|
||||||
|
|
||||||
object MigrateManager {
|
|
||||||
private const val ID_SERVER_CONFIG = "SERVER_CONFIG"
|
|
||||||
private val serverStorage by lazy { MMKV.mmkvWithID(ID_SERVER_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrates server configurations to profile items.
|
|
||||||
*
|
|
||||||
* @return True if migration was successful, false otherwise.
|
|
||||||
*/
|
|
||||||
fun migrateServerConfig2Profile(): Boolean {
|
|
||||||
if (serverStorage.count().toInt() == 0) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val serverList = serverStorage.allKeys() ?: return false
|
|
||||||
Log.d(ANG_PACKAGE, "migrateServerConfig2Profile-" + serverList.count())
|
|
||||||
|
|
||||||
for (guid in serverList) {
|
|
||||||
var configOld = decodeServerConfigOld(guid) ?: continue
|
|
||||||
var config = decodeServerConfig(guid)
|
|
||||||
if (config != null) {
|
|
||||||
serverStorage.remove(guid)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
config = migrateServerConfig2ProfileSub(configOld) ?: continue
|
|
||||||
config.subscriptionId = configOld.subscriptionId
|
|
||||||
|
|
||||||
MmkvManager.encodeServerConfig(guid, config)
|
|
||||||
|
|
||||||
//check and remove old
|
|
||||||
decodeServerConfig(guid) ?: continue
|
|
||||||
serverStorage.remove(guid)
|
|
||||||
Log.d(ANG_PACKAGE, "migrateServerConfig2Profile-" + config.remarks)
|
|
||||||
}
|
|
||||||
Log.d(ANG_PACKAGE, "migrateServerConfig2Profile-end")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrates a server configuration to a profile item.
|
|
||||||
*
|
|
||||||
* @param configOld The old server configuration.
|
|
||||||
* @return The profile item.
|
|
||||||
*/
|
|
||||||
private fun migrateServerConfig2ProfileSub(configOld: ServerConfig): ProfileItem? {
|
|
||||||
return when (configOld.getProxyOutbound()?.protocol) {
|
|
||||||
EConfigType.VMESS.name.lowercase() -> migrate2ProfileCommon(configOld)
|
|
||||||
EConfigType.VLESS.name.lowercase() -> migrate2ProfileCommon(configOld)
|
|
||||||
EConfigType.TROJAN.name.lowercase() -> migrate2ProfileCommon(configOld)
|
|
||||||
EConfigType.SHADOWSOCKS.name.lowercase() -> migrate2ProfileCommon(configOld)
|
|
||||||
|
|
||||||
EConfigType.SOCKS.name.lowercase() -> migrate2ProfileSocks(configOld)
|
|
||||||
EConfigType.HTTP.name.lowercase() -> migrate2ProfileHttp(configOld)
|
|
||||||
EConfigType.WIREGUARD.name.lowercase() -> migrate2ProfileWireguard(configOld)
|
|
||||||
EConfigType.HYSTERIA2.name.lowercase() -> migrate2ProfileHysteria2(configOld)
|
|
||||||
|
|
||||||
EConfigType.CUSTOM.name.lowercase() -> migrate2ProfileCustom(configOld)
|
|
||||||
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrates a common server configuration to a profile item.
|
|
||||||
*
|
|
||||||
* @param configOld The old server configuration.
|
|
||||||
* @return The profile item.
|
|
||||||
*/
|
|
||||||
private fun migrate2ProfileCommon(configOld: ServerConfig): ProfileItem? {
|
|
||||||
val config = ProfileItem.create(configOld.configType)
|
|
||||||
|
|
||||||
val outbound = configOld.getProxyOutbound() ?: return null
|
|
||||||
config.remarks = configOld.remarks
|
|
||||||
config.server = outbound.getServerAddress()
|
|
||||||
config.serverPort = outbound.getServerPort().toString()
|
|
||||||
config.method = outbound.getSecurityEncryption()
|
|
||||||
config.password = outbound.getPassword()
|
|
||||||
config.flow = outbound?.settings?.vnext?.first()?.users?.first()?.flow ?: outbound?.settings?.servers?.first()?.flow
|
|
||||||
|
|
||||||
config.network = outbound?.streamSettings?.network ?: NetworkType.TCP.type
|
|
||||||
outbound.getTransportSettingDetails()?.let { transportDetails ->
|
|
||||||
config.headerType = transportDetails[0].orEmpty()
|
|
||||||
config.host = transportDetails[1].orEmpty()
|
|
||||||
config.path = transportDetails[2].orEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
config.seed = outbound?.streamSettings?.kcpSettings?.seed
|
|
||||||
config.quicSecurity = outbound?.streamSettings?.quicSettings?.security
|
|
||||||
config.quicKey = outbound?.streamSettings?.quicSettings?.key
|
|
||||||
config.mode = if (outbound?.streamSettings?.grpcSettings?.multiMode == true) "multi" else "gun"
|
|
||||||
config.serviceName = outbound?.streamSettings?.grpcSettings?.serviceName
|
|
||||||
config.authority = outbound?.streamSettings?.grpcSettings?.authority
|
|
||||||
|
|
||||||
config.security = outbound.streamSettings?.security
|
|
||||||
val tlsSettings = outbound?.streamSettings?.realitySettings ?: outbound?.streamSettings?.tlsSettings
|
|
||||||
config.insecure = tlsSettings?.allowInsecure
|
|
||||||
config.sni = tlsSettings?.serverName
|
|
||||||
config.fingerPrint = tlsSettings?.fingerprint
|
|
||||||
config.alpn = tlsSettings?.alpn?.joinToString(",").removeWhiteSpace().toString()
|
|
||||||
|
|
||||||
config.publicKey = tlsSettings?.publicKey
|
|
||||||
config.shortId = tlsSettings?.shortId
|
|
||||||
config.spiderX = tlsSettings?.spiderX
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrates a SOCKS server configuration to a profile item.
|
|
||||||
*
|
|
||||||
* @param configOld The old server configuration.
|
|
||||||
* @return The profile item.
|
|
||||||
*/
|
|
||||||
private fun migrate2ProfileSocks(configOld: ServerConfig): ProfileItem? {
|
|
||||||
val config = ProfileItem.create(EConfigType.SOCKS)
|
|
||||||
|
|
||||||
val outbound = configOld.getProxyOutbound() ?: return null
|
|
||||||
config.remarks = configOld.remarks
|
|
||||||
config.server = outbound.getServerAddress()
|
|
||||||
config.serverPort = outbound.getServerPort().toString()
|
|
||||||
config.username = outbound.settings?.servers?.first()?.users?.first()?.user
|
|
||||||
config.password = outbound.getPassword()
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrates an HTTP server configuration to a profile item.
|
|
||||||
*
|
|
||||||
* @param configOld The old server configuration.
|
|
||||||
* @return The profile item.
|
|
||||||
*/
|
|
||||||
private fun migrate2ProfileHttp(configOld: ServerConfig): ProfileItem? {
|
|
||||||
val config = ProfileItem.create(EConfigType.HTTP)
|
|
||||||
|
|
||||||
val outbound = configOld.getProxyOutbound() ?: return null
|
|
||||||
config.remarks = configOld.remarks
|
|
||||||
config.server = outbound.getServerAddress()
|
|
||||||
config.serverPort = outbound.getServerPort().toString()
|
|
||||||
config.username = outbound.settings?.servers?.first()?.users?.first()?.user
|
|
||||||
config.password = outbound.getPassword()
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrates a WireGuard server configuration to a profile item.
|
|
||||||
*
|
|
||||||
* @param configOld The old server configuration.
|
|
||||||
* @return The profile item.
|
|
||||||
*/
|
|
||||||
private fun migrate2ProfileWireguard(configOld: ServerConfig): ProfileItem? {
|
|
||||||
val config = ProfileItem.create(EConfigType.WIREGUARD)
|
|
||||||
|
|
||||||
val outbound = configOld.getProxyOutbound() ?: return null
|
|
||||||
config.remarks = configOld.remarks
|
|
||||||
config.server = outbound.getServerAddress()
|
|
||||||
config.serverPort = outbound.getServerPort().toString()
|
|
||||||
|
|
||||||
outbound.settings?.let { wireguard ->
|
|
||||||
config.secretKey = wireguard.secretKey
|
|
||||||
config.localAddress = (wireguard.address as List<*>).joinToString(",").removeWhiteSpace().toString()
|
|
||||||
config.publicKey = wireguard.peers?.getOrNull(0)?.publicKey
|
|
||||||
config.mtu = wireguard.mtu
|
|
||||||
config.reserved = wireguard.reserved?.joinToString(",").removeWhiteSpace().toString()
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrates a Hysteria2 server configuration to a profile item.
|
|
||||||
*
|
|
||||||
* @param configOld The old server configuration.
|
|
||||||
* @return The profile item.
|
|
||||||
*/
|
|
||||||
private fun migrate2ProfileHysteria2(configOld: ServerConfig): ProfileItem? {
|
|
||||||
val config = ProfileItem.create(EConfigType.HYSTERIA2)
|
|
||||||
|
|
||||||
val outbound = configOld.getProxyOutbound() ?: return null
|
|
||||||
config.remarks = configOld.remarks
|
|
||||||
config.server = outbound.getServerAddress()
|
|
||||||
config.serverPort = outbound.getServerPort().toString()
|
|
||||||
config.password = outbound.getPassword()
|
|
||||||
|
|
||||||
config.security = AppConfig.TLS
|
|
||||||
outbound.streamSettings?.tlsSettings?.let { tlsSetting ->
|
|
||||||
config.insecure = tlsSetting.allowInsecure
|
|
||||||
config.sni = tlsSetting.serverName
|
|
||||||
config.alpn = tlsSetting.alpn?.joinToString(",").removeWhiteSpace().orEmpty()
|
|
||||||
|
|
||||||
}
|
|
||||||
config.obfsPassword = outbound.settings?.obfsPassword
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrates a custom server configuration to a profile item.
|
|
||||||
*
|
|
||||||
* @param configOld The old server configuration.
|
|
||||||
* @return The profile item.
|
|
||||||
*/
|
|
||||||
private fun migrate2ProfileCustom(configOld: ServerConfig): ProfileItem? {
|
|
||||||
val config = ProfileItem.create(EConfigType.CUSTOM)
|
|
||||||
|
|
||||||
val outbound = configOld.getProxyOutbound() ?: return null
|
|
||||||
config.remarks = configOld.remarks
|
|
||||||
config.server = outbound.getServerAddress()
|
|
||||||
config.serverPort = outbound.getServerPort().toString()
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decodes the old server configuration.
|
|
||||||
*
|
|
||||||
* @param guid The server GUID.
|
|
||||||
* @return The old server configuration.
|
|
||||||
*/
|
|
||||||
private fun decodeServerConfigOld(guid: String): ServerConfig? {
|
|
||||||
if (guid.isBlank()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val json = serverStorage.decodeString(guid)
|
|
||||||
if (json.isNullOrBlank()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return JsonUtil.fromJson(json, ServerConfig::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
package com.v2ray.ang.handler
|
package com.v2ray.ang.handler
|
||||||
|
|
||||||
import com.tencent.mmkv.MMKV
|
import com.tencent.mmkv.MMKV
|
||||||
|
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||||
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
|
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
|
||||||
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
|
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
|
||||||
import com.v2ray.ang.dto.AssetUrlItem
|
import com.v2ray.ang.dto.entities.AssetUrlCache
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.dto.entities.AssetUrlItem
|
||||||
import com.v2ray.ang.dto.RulesetItem
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
import com.v2ray.ang.dto.ServerAffiliationInfo
|
import com.v2ray.ang.dto.entities.RulesetItem
|
||||||
import com.v2ray.ang.dto.SubscriptionItem
|
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.JsonUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
|
|
||||||
@@ -15,7 +19,6 @@ object MmkvManager {
|
|||||||
|
|
||||||
//region private
|
//region private
|
||||||
|
|
||||||
//private const val ID_PROFILE_CONFIG = "PROFILE_CONFIG"
|
|
||||||
private const val ID_MAIN = "MAIN"
|
private const val ID_MAIN = "MAIN"
|
||||||
private const val ID_PROFILE_FULL_CONFIG = "PROFILE_FULL_CONFIG"
|
private const val ID_PROFILE_FULL_CONFIG = "PROFILE_FULL_CONFIG"
|
||||||
private const val ID_SERVER_RAW = "SERVER_RAW"
|
private const val ID_SERVER_RAW = "SERVER_RAW"
|
||||||
@@ -25,9 +28,10 @@ object MmkvManager {
|
|||||||
private const val ID_SETTING = "SETTING"
|
private const val ID_SETTING = "SETTING"
|
||||||
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
|
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
|
||||||
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
|
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
|
||||||
|
private const val KEY_SUB_SERVER_PREFIX = "SUB_SERVERS_"
|
||||||
private const val KEY_SUB_IDS = "SUB_IDS"
|
private const val KEY_SUB_IDS = "SUB_IDS"
|
||||||
|
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
|
||||||
|
|
||||||
//private val profileStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
|
||||||
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
|
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
|
||||||
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
||||||
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
|
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
|
||||||
@@ -40,6 +44,17 @@ object MmkvManager {
|
|||||||
|
|
||||||
//region Server
|
//region Server
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the legacy server list from KEY_ANG_CONFIGS for migration.
|
||||||
|
* This method is for migration purposes only.
|
||||||
|
*
|
||||||
|
* @return The JSON string of legacy server list, or null if not exists.
|
||||||
|
*/
|
||||||
|
fun readLegacyServerList(): String? {
|
||||||
|
return mainStorage.decodeString(KEY_ANG_CONFIGS)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the selected server GUID.
|
* Gets the selected server GUID.
|
||||||
*
|
*
|
||||||
@@ -59,28 +74,62 @@ object MmkvManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes the server list.
|
* Encodes the server list for a given subscription.
|
||||||
|
* Saves to the subscription's serverList (including default subscription for ungrouped servers).
|
||||||
*
|
*
|
||||||
* @param serverList The list of server GUIDs.
|
* @param serverList The list of server GUIDs.
|
||||||
|
* @param subscriptionId The subscription ID.
|
||||||
*/
|
*/
|
||||||
fun encodeServerList(serverList: MutableList<String>) {
|
fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
|
||||||
mainStorage.encode(KEY_ANG_CONFIGS, JsonUtil.toJson(serverList))
|
val subId = getSubscriptionId(subscriptionId)
|
||||||
|
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
||||||
|
mainStorage.encode(key, JsonUtil.toJson(serverList))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes the server list.
|
* Decodes the server list for a given subscription.
|
||||||
|
* If subscriptionId is empty, returns ungrouped servers.
|
||||||
|
* Otherwise, returns servers from the specified subscription's serverList.
|
||||||
*
|
*
|
||||||
|
* @param subscriptionId The subscription ID.
|
||||||
* @return The list of server GUIDs.
|
* @return The list of server GUIDs.
|
||||||
*/
|
*/
|
||||||
fun decodeServerList(): MutableList<String> {
|
fun decodeServerList(subscriptionId: String): MutableList<String> {
|
||||||
val json = mainStorage.decodeString(KEY_ANG_CONFIGS)
|
val subId = getSubscriptionId(subscriptionId)
|
||||||
|
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
||||||
|
val json = mainStorage.decodeString(key)
|
||||||
return if (json.isNullOrBlank()) {
|
return if (json.isNullOrBlank()) {
|
||||||
mutableListOf()
|
mutableListOf()
|
||||||
} else {
|
} else {
|
||||||
JsonUtil.fromJson(json, Array<String>::class.java).toMutableList()
|
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.
|
* Decodes the server configuration.
|
||||||
*
|
*
|
||||||
@@ -95,19 +144,9 @@ object MmkvManager {
|
|||||||
if (json.isNullOrBlank()) {
|
if (json.isNullOrBlank()) {
|
||||||
return null
|
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.
|
* Encodes the server configuration.
|
||||||
@@ -119,25 +158,32 @@ object MmkvManager {
|
|||||||
fun encodeServerConfig(guid: String, config: ProfileItem): String {
|
fun encodeServerConfig(guid: String, config: ProfileItem): String {
|
||||||
val key = guid.ifBlank { Utils.getUuid() }
|
val key = guid.ifBlank { Utils.getUuid() }
|
||||||
profileFullStorage.encode(key, JsonUtil.toJson(config))
|
profileFullStorage.encode(key, JsonUtil.toJson(config))
|
||||||
val serverList = decodeServerList()
|
|
||||||
|
// Use default subscription for servers without subscription
|
||||||
|
val subId = getSubscriptionId(config.subscriptionId)
|
||||||
|
val serverList = decodeServerList(subId)
|
||||||
|
|
||||||
if (!serverList.contains(key)) {
|
if (!serverList.contains(key)) {
|
||||||
serverList.add(0, key)
|
serverList.add(0, key)
|
||||||
encodeServerList(serverList)
|
encodeServerList(serverList, subId)
|
||||||
if (getSelectServer().isNullOrBlank()) {
|
if (getSelectServer().isNullOrBlank()) {
|
||||||
mainStorage.encode(KEY_SELECTED_SERVER, key)
|
mainStorage.encode(KEY_SELECTED_SERVER, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// val profile = ProfileLiteItem(
|
|
||||||
// configType = config.configType,
|
|
||||||
// subscriptionId = config.subscriptionId,
|
|
||||||
// remarks = config.remarks,
|
|
||||||
// server = config.getProxyOutbound()?.getServerAddress(),
|
|
||||||
// serverPort = config.getProxyOutbound()?.getServerPort(),
|
|
||||||
// )
|
|
||||||
// profileStorage.encode(key, JsonUtil.toJson(profile))
|
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes the server configuration directly without updating serverList.
|
||||||
|
*
|
||||||
|
* @param key The server GUID.
|
||||||
|
* @param configJson The server configuration JSON string.
|
||||||
|
*/
|
||||||
|
fun encodeProfileDirect(key: String, configJson: String) {
|
||||||
|
profileFullStorage.encode(key, configJson)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the server configuration.
|
* Removes the server configuration.
|
||||||
*
|
*
|
||||||
@@ -147,33 +193,44 @@ object MmkvManager {
|
|||||||
if (guid.isBlank()) {
|
if (guid.isBlank()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get config to determine which subscription to update
|
||||||
|
val config = decodeServerConfig(guid)
|
||||||
|
val subId = getSubscriptionId(config?.subscriptionId)
|
||||||
|
|
||||||
|
// Remove from appropriate server list
|
||||||
|
val serverList = decodeServerList(subId)
|
||||||
|
serverList.remove(guid)
|
||||||
|
encodeServerList(serverList, subId)
|
||||||
|
|
||||||
|
// Clean up storage
|
||||||
if (getSelectServer() == guid) {
|
if (getSelectServer() == guid) {
|
||||||
mainStorage.remove(KEY_SELECTED_SERVER)
|
mainStorage.remove(KEY_SELECTED_SERVER)
|
||||||
}
|
}
|
||||||
val serverList = decodeServerList()
|
|
||||||
serverList.remove(guid)
|
|
||||||
encodeServerList(serverList)
|
|
||||||
profileFullStorage.remove(guid)
|
profileFullStorage.remove(guid)
|
||||||
//profileStorage.remove(guid)
|
|
||||||
serverAffStorage.remove(guid)
|
serverAffStorage.remove(guid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the server configurations via subscription ID.
|
* Removes the server configurations via subscription ID.
|
||||||
*
|
*
|
||||||
* @param subid The subscription ID.
|
* @param subscriptionId The subscription ID.
|
||||||
*/
|
*/
|
||||||
fun removeServerViaSubid(subid: String) {
|
fun removeServerViaSubid(subscriptionId: String?) {
|
||||||
if (subid.isBlank()) {
|
val subId = getSubscriptionId(subscriptionId)
|
||||||
return
|
val serverList = decodeServerList(subId)
|
||||||
}
|
|
||||||
profileFullStorage.allKeys()?.forEach { key ->
|
// Remove all servers in the list
|
||||||
decodeServerConfig(key)?.let { config ->
|
serverList.forEach { guid ->
|
||||||
if (config.subscriptionId == subid) {
|
if (getSelectServer() == guid) {
|
||||||
removeServer(key)
|
mainStorage.remove(KEY_SELECTED_SERVER)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
profileFullStorage.remove(guid)
|
||||||
|
serverAffStorage.remove(guid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
serverList.clear()
|
||||||
|
encodeServerList(serverList, subId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,7 +247,7 @@ object MmkvManager {
|
|||||||
if (json.isNullOrBlank()) {
|
if (json.isNullOrBlank()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return JsonUtil.fromJson(json, ServerAffiliationInfo::class.java)
|
return JsonUtil.fromJsonSafe(json, ServerAffiliationInfo::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -231,7 +288,6 @@ object MmkvManager {
|
|||||||
val count = profileFullStorage.allKeys()?.count() ?: 0
|
val count = profileFullStorage.allKeys()?.count() ?: 0
|
||||||
mainStorage.clearAll()
|
mainStorage.clearAll()
|
||||||
profileFullStorage.clearAll()
|
profileFullStorage.clearAll()
|
||||||
//profileStorage.clearAll()
|
|
||||||
serverAffStorage.clearAll()
|
serverAffStorage.clearAll()
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
@@ -288,6 +344,10 @@ object MmkvManager {
|
|||||||
|
|
||||||
//region Subscriptions
|
//region Subscriptions
|
||||||
|
|
||||||
|
private fun getSubscriptionId(subscriptionId: String?): String {
|
||||||
|
return subscriptionId?.ifEmpty { DEFAULT_SUBSCRIPTION_ID } ?: DEFAULT_SUBSCRIPTION_ID
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the subscription list.
|
* Initializes the subscription list.
|
||||||
*/
|
*/
|
||||||
@@ -307,14 +367,15 @@ object MmkvManager {
|
|||||||
*
|
*
|
||||||
* @return The list of subscriptions.
|
* @return The list of subscriptions.
|
||||||
*/
|
*/
|
||||||
fun decodeSubscriptions(): List<Pair<String, SubscriptionItem>> {
|
fun decodeSubscriptions(): List<SubscriptionCache> {
|
||||||
initSubsList()
|
initSubsList()
|
||||||
|
|
||||||
val subscriptions = mutableListOf<Pair<String, SubscriptionItem>>()
|
val subscriptions = mutableListOf<SubscriptionCache>()
|
||||||
decodeSubsList().forEach { key ->
|
decodeSubsList().forEach { key ->
|
||||||
val json = subStorage.decodeString(key)
|
val json = subStorage.decodeString(key)
|
||||||
if (!json.isNullOrBlank()) {
|
if (!json.isNullOrBlank()) {
|
||||||
subscriptions.add(Pair(key, JsonUtil.fromJson(json, SubscriptionItem::class.java)))
|
val item = JsonUtil.fromJsonSafe(json, SubscriptionItem::class.java) ?: SubscriptionItem()
|
||||||
|
subscriptions.add(SubscriptionCache(key, item))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return subscriptions
|
return subscriptions
|
||||||
@@ -359,7 +420,7 @@ object MmkvManager {
|
|||||||
*/
|
*/
|
||||||
fun decodeSubscription(subscriptionId: String): SubscriptionItem? {
|
fun decodeSubscription(subscriptionId: String): SubscriptionItem? {
|
||||||
val json = subStorage.decodeString(subscriptionId) ?: return null
|
val json = subStorage.decodeString(subscriptionId) ?: return null
|
||||||
return JsonUtil.fromJson(json, SubscriptionItem::class.java)
|
return JsonUtil.fromJsonSafe(json, SubscriptionItem::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -381,7 +442,7 @@ object MmkvManager {
|
|||||||
return if (json.isNullOrBlank()) {
|
return if (json.isNullOrBlank()) {
|
||||||
mutableListOf()
|
mutableListOf()
|
||||||
} else {
|
} else {
|
||||||
JsonUtil.fromJson(json, Array<String>::class.java).toMutableList()
|
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,15 +455,16 @@ object MmkvManager {
|
|||||||
*
|
*
|
||||||
* @return The list of asset URLs.
|
* @return The list of asset URLs.
|
||||||
*/
|
*/
|
||||||
fun decodeAssetUrls(): List<Pair<String, AssetUrlItem>> {
|
fun decodeAssetUrls(): List<AssetUrlCache> {
|
||||||
val assetUrlItems = mutableListOf<Pair<String, AssetUrlItem>>()
|
val assetUrlItems = mutableListOf<AssetUrlCache>()
|
||||||
assetStorage.allKeys()?.forEach { key ->
|
assetStorage.allKeys()?.forEach { key ->
|
||||||
val json = assetStorage.decodeString(key)
|
val json = assetStorage.decodeString(key)
|
||||||
if (!json.isNullOrBlank()) {
|
if (!json.isNullOrBlank()) {
|
||||||
assetUrlItems.add(Pair(key, JsonUtil.fromJson(json, AssetUrlItem::class.java)))
|
val item = JsonUtil.fromJsonSafe(json, AssetUrlItem::class.java) ?: AssetUrlItem()
|
||||||
|
assetUrlItems.add(AssetUrlCache(key, item))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return assetUrlItems.sortedBy { (_, value) -> value.addedTime }
|
return assetUrlItems.sortedBy { it.assetUrl.addedTime }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -433,7 +495,7 @@ object MmkvManager {
|
|||||||
*/
|
*/
|
||||||
fun decodeAsset(assetid: String): AssetUrlItem? {
|
fun decodeAsset(assetid: String): AssetUrlItem? {
|
||||||
val json = assetStorage.decodeString(assetid) ?: return null
|
val json = assetStorage.decodeString(assetid) ?: return null
|
||||||
return JsonUtil.fromJson(json, AssetUrlItem::class.java)
|
return JsonUtil.fromJsonSafe(json, AssetUrlItem::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
//endregion
|
//endregion
|
||||||
@@ -448,7 +510,7 @@ object MmkvManager {
|
|||||||
fun decodeRoutingRulesets(): MutableList<RulesetItem>? {
|
fun decodeRoutingRulesets(): MutableList<RulesetItem>? {
|
||||||
val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
|
val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
|
||||||
if (ruleset.isNullOrEmpty()) return null
|
if (ruleset.isNullOrEmpty()) return null
|
||||||
return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java).toMutableList()
|
return JsonUtil.fromJsonSafe(ruleset, Array<RulesetItem>::class.java)?.toMutableList() ?: mutableListOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -465,6 +527,7 @@ object MmkvManager {
|
|||||||
|
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region settings
|
||||||
/**
|
/**
|
||||||
* Encodes the settings.
|
* Encodes the settings.
|
||||||
*
|
*
|
||||||
@@ -487,6 +550,28 @@ object MmkvManager {
|
|||||||
return settingsStorage.encode(key, value)
|
return settingsStorage.encode(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes the settings.
|
||||||
|
*
|
||||||
|
* @param key The settings key.
|
||||||
|
* @param value The settings value.
|
||||||
|
* @return Whether the encoding was successful.
|
||||||
|
*/
|
||||||
|
fun encodeSettings(key: String, value: Long): Boolean {
|
||||||
|
return settingsStorage.encode(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes the settings.
|
||||||
|
*
|
||||||
|
* @param key The settings key.
|
||||||
|
* @param value The settings value.
|
||||||
|
* @return Whether the encoding was successful.
|
||||||
|
*/
|
||||||
|
fun encodeSettings(key: String, value: Float): Boolean {
|
||||||
|
return settingsStorage.encode(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes the settings.
|
* Encodes the settings.
|
||||||
*
|
*
|
||||||
@@ -530,6 +615,39 @@ object MmkvManager {
|
|||||||
return settingsStorage.decodeString(key, defaultValue)
|
return settingsStorage.decodeString(key, defaultValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes the settings integer.
|
||||||
|
*
|
||||||
|
* @param key The settings key.
|
||||||
|
* @param defaultValue The default value.
|
||||||
|
* @return The settings value.
|
||||||
|
*/
|
||||||
|
fun decodeSettingsInt(key: String, defaultValue: Int): Int {
|
||||||
|
return settingsStorage.decodeInt(key, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes the settings long.
|
||||||
|
*
|
||||||
|
* @param key The settings key.
|
||||||
|
* @param defaultValue The default value.
|
||||||
|
* @return The settings value.
|
||||||
|
*/
|
||||||
|
fun decodeSettingsLong(key: String, defaultValue: Long): Long {
|
||||||
|
return settingsStorage.decodeLong(key, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes the settings float.
|
||||||
|
*
|
||||||
|
* @param key The settings key.
|
||||||
|
* @param defaultValue The default value.
|
||||||
|
* @return The settings value.
|
||||||
|
*/
|
||||||
|
fun decodeSettingsFloat(key: String, defaultValue: Float): Float {
|
||||||
|
return settingsStorage.decodeFloat(key, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes the settings boolean.
|
* Decodes the settings boolean.
|
||||||
*
|
*
|
||||||
@@ -561,9 +679,6 @@ object MmkvManager {
|
|||||||
return settingsStorage.decodeStringSet(key)
|
return settingsStorage.decodeStringSet(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
//endregion
|
|
||||||
|
|
||||||
//region Others
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes the start on boot setting.
|
* Encodes the start on boot setting.
|
||||||
@@ -571,7 +686,7 @@ object MmkvManager {
|
|||||||
* @param startOnBoot Whether to start on boot.
|
* @param startOnBoot Whether to start on boot.
|
||||||
*/
|
*/
|
||||||
fun encodeStartOnBoot(startOnBoot: Boolean) {
|
fun encodeStartOnBoot(startOnBoot: Boolean) {
|
||||||
MmkvManager.encodeSettings(PREF_IS_BOOTED, startOnBoot)
|
encodeSettings(PREF_IS_BOOTED, startOnBoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -585,4 +700,22 @@ object MmkvManager {
|
|||||||
|
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region WebDAV
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes the WebDAV config as JSON into storage.
|
||||||
|
*/
|
||||||
|
fun encodeWebDavConfig(config: WebDavConfig): Boolean {
|
||||||
|
return mainStorage.encode(KEY_WEBDAV_CONFIG, JsonUtil.toJson(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes the WebDAV config from storage.
|
||||||
|
*/
|
||||||
|
fun decodeWebDavConfig(): WebDavConfig? {
|
||||||
|
val json = mainStorage.decodeString(KEY_WEBDAV_CONFIG) ?: return null
|
||||||
|
return JsonUtil.fromJsonSafe(json, WebDavConfig::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
//endregion
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-56
@@ -1,4 +1,4 @@
|
|||||||
package com.v2ray.ang.service
|
package com.v2ray.ang.handler
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
@@ -12,13 +12,12 @@ import android.os.Build
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
|
||||||
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
import com.v2ray.ang.core.CoreServiceManager
|
||||||
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
import com.v2ray.ang.extension.toSpeedString
|
import com.v2ray.ang.extension.toSpeedString
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
|
||||||
import com.v2ray.ang.ui.MainActivity
|
import com.v2ray.ang.ui.MainActivity
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -27,12 +26,13 @@ import kotlinx.coroutines.isActive
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
object NotificationService {
|
object NotificationManager {
|
||||||
private const val NOTIFICATION_ID = 1
|
private const val NOTIFICATION_ID = 1
|
||||||
private const val NOTIFICATION_PENDING_INTENT_CONTENT = 0
|
private const val NOTIFICATION_PENDING_INTENT_CONTENT = 0
|
||||||
private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1
|
private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1
|
||||||
private const val NOTIFICATION_PENDING_INTENT_RESTART_V2RAY = 2
|
private const val NOTIFICATION_PENDING_INTENT_RESTART_V2RAY = 2
|
||||||
private const val NOTIFICATION_ICON_THRESHOLD = 3000
|
private const val NOTIFICATION_ICON_THRESHOLD = 3000
|
||||||
|
private const val QUERY_INTERVAL_MS = 3000L
|
||||||
|
|
||||||
private var lastQueryTime = 0L
|
private var lastQueryTime = 0L
|
||||||
private var mBuilder: NotificationCompat.Builder? = null
|
private var mBuilder: NotificationCompat.Builder? = null
|
||||||
@@ -43,45 +43,16 @@ object NotificationService {
|
|||||||
* Starts the speed notification.
|
* Starts the speed notification.
|
||||||
* @param currentConfig The current profile configuration.
|
* @param currentConfig The current profile configuration.
|
||||||
*/
|
*/
|
||||||
fun startSpeedNotification(currentConfig: ProfileItem?) {
|
fun startSpeedNotification() {
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
|
||||||
if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return
|
if (speedNotificationJob != null || CoreServiceManager.isRunning() == false) return
|
||||||
|
|
||||||
lastQueryTime = System.currentTimeMillis()
|
|
||||||
var lastZeroSpeed = false
|
var lastZeroSpeed = false
|
||||||
val outboundTags = currentConfig?.getAllOutboundTags()
|
|
||||||
outboundTags?.remove(TAG_DIRECT)
|
|
||||||
|
|
||||||
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
|
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
val queryTime = System.currentTimeMillis()
|
lastZeroSpeed = updateSpeedNotificationOnce(lastZeroSpeed)
|
||||||
val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0
|
delay(QUERY_INTERVAL_MS)
|
||||||
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(TAG_DIRECT, AppConfig.UPLINK)
|
|
||||||
val directDownlink = V2RayServiceManager.queryStats(TAG_DIRECT, AppConfig.DOWNLINK)
|
|
||||||
val zeroSpeed = proxyTotal == 0L && directUplink == 0L && directDownlink == 0L
|
|
||||||
if (!zeroSpeed || !lastZeroSpeed) {
|
|
||||||
if (proxyTotal == 0L) {
|
|
||||||
appendSpeedString(text, outboundTags?.firstOrNull(), 0.0, 0.0)
|
|
||||||
}
|
|
||||||
appendSpeedString(
|
|
||||||
text, TAG_DIRECT, directUplink / sinceLastQueryInSeconds,
|
|
||||||
directDownlink / sinceLastQueryInSeconds
|
|
||||||
)
|
|
||||||
updateNotification(text.toString(), proxyTotal, directDownlink + directUplink)
|
|
||||||
}
|
|
||||||
lastZeroSpeed = zeroSpeed
|
|
||||||
lastQueryTime = queryTime
|
|
||||||
delay(3000)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,22 +63,22 @@ object NotificationService {
|
|||||||
*/
|
*/
|
||||||
fun showNotification(currentConfig: ProfileItem?) {
|
fun showNotification(currentConfig: ProfileItem?) {
|
||||||
val service = getService() ?: return
|
val service = getService() ?: return
|
||||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
// Reset last query time to avoid querying stats too soon after showing the notification
|
||||||
} else {
|
lastQueryTime = System.currentTimeMillis()
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
}
|
val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
|
||||||
val startMainIntent = Intent(service, MainActivity::class.java)
|
val startMainIntent = Intent(service, MainActivity::class.java)
|
||||||
val contentPendingIntent = PendingIntent.getActivity(service, NOTIFICATION_PENDING_INTENT_CONTENT, startMainIntent, flags)
|
val contentPendingIntent = PendingIntent.getActivity(service, NOTIFICATION_PENDING_INTENT_CONTENT, startMainIntent, flags)
|
||||||
|
|
||||||
val stopV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
|
val stopV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||||
stopV2RayIntent.`package` = ANG_PACKAGE
|
stopV2RayIntent.`package` = AppConfig.ANG_PACKAGE
|
||||||
stopV2RayIntent.putExtra("key", AppConfig.MSG_STATE_STOP)
|
stopV2RayIntent.putExtra("key", AppConfig.MSG_STATE_STOP)
|
||||||
val stopV2RayPendingIntent = PendingIntent.getBroadcast(service, NOTIFICATION_PENDING_INTENT_STOP_V2RAY, stopV2RayIntent, flags)
|
val stopV2RayPendingIntent = PendingIntent.getBroadcast(service, NOTIFICATION_PENDING_INTENT_STOP_V2RAY, stopV2RayIntent, flags)
|
||||||
|
|
||||||
val restartV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
|
val restartV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||||
restartV2RayIntent.`package` = ANG_PACKAGE
|
restartV2RayIntent.`package` = AppConfig.ANG_PACKAGE
|
||||||
restartV2RayIntent.putExtra("key", AppConfig.MSG_STATE_RESTART)
|
restartV2RayIntent.putExtra("key", AppConfig.MSG_STATE_RESTART)
|
||||||
val restartV2RayPendingIntent = PendingIntent.getBroadcast(service, NOTIFICATION_PENDING_INTENT_RESTART_V2RAY, restartV2RayIntent, flags)
|
val restartV2RayPendingIntent = PendingIntent.getBroadcast(service, NOTIFICATION_PENDING_INTENT_RESTART_V2RAY, restartV2RayIntent, flags)
|
||||||
|
|
||||||
@@ -149,26 +120,22 @@ object NotificationService {
|
|||||||
*/
|
*/
|
||||||
fun cancelNotification() {
|
fun cancelNotification() {
|
||||||
val service = getService() ?: return
|
val service = getService() ?: return
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
service.stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
service.stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
|
||||||
} else {
|
|
||||||
service.stopForeground(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
mBuilder = null
|
mBuilder = null
|
||||||
speedNotificationJob?.cancel()
|
speedNotificationJob?.cancel()
|
||||||
speedNotificationJob = null
|
speedNotificationJob = null
|
||||||
|
mNotificationManager = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops the speed notification.
|
* Stops the speed notification.
|
||||||
* @param currentConfig The current profile configuration.
|
|
||||||
*/
|
*/
|
||||||
fun stopSpeedNotification(currentConfig: ProfileItem?) {
|
fun stopSpeedNotification() {
|
||||||
speedNotificationJob?.let {
|
speedNotificationJob?.let {
|
||||||
it.cancel()
|
it.cancel()
|
||||||
speedNotificationJob = null
|
speedNotificationJob = null
|
||||||
updateNotification(currentConfig?.remarks, 0, 0)
|
updateNotification("", 0, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +200,7 @@ object NotificationService {
|
|||||||
*/
|
*/
|
||||||
private fun appendSpeedString(text: StringBuilder, name: String?, up: Double, down: Double) {
|
private fun appendSpeedString(text: StringBuilder, name: String?, up: Double, down: Double) {
|
||||||
var n = name ?: "no tag"
|
var n = name ?: "no tag"
|
||||||
n = n.substring(0, min(n.length, 6))
|
n = n.take(min(n.length, 6))
|
||||||
text.append(n)
|
text.append(n)
|
||||||
for (i in n.length..6 step 2) {
|
for (i in n.length..6 step 2) {
|
||||||
text.append("\t")
|
text.append("\t")
|
||||||
@@ -241,11 +208,74 @@ object NotificationService {
|
|||||||
text.append("• ${up.toLong().toSpeedString()}↑ ${down.toLong().toSpeedString()}↓\n")
|
text.append("• ${up.toLong().toSpeedString()}↑ ${down.toLong().toSpeedString()}↓\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the speed notification once.
|
||||||
|
* Queries traffic stats, separates proxy and direct, and updates the notification.
|
||||||
|
* @param lastZeroSpeed The previous zero speed state.
|
||||||
|
* @return The current zero speed state.
|
||||||
|
*/
|
||||||
|
private fun updateSpeedNotificationOnce(lastZeroSpeed: Boolean): Boolean {
|
||||||
|
val queryTime = System.currentTimeMillis()
|
||||||
|
val sinceLastQueryIn = (queryTime - lastQueryTime)
|
||||||
|
|
||||||
|
// If the query interval is too short, skip this round to avoid excessive CPU usage
|
||||||
|
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
|
||||||
|
lastQueryTime = queryTime
|
||||||
|
return lastZeroSpeed
|
||||||
|
}
|
||||||
|
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
|
||||||
|
|
||||||
|
var proxyUplink = 0L
|
||||||
|
var proxyDownlink = 0L
|
||||||
|
var directUplink = 0L
|
||||||
|
var directDownlink = 0L
|
||||||
|
|
||||||
|
CoreServiceManager.queryAllOutboundTrafficStats().forEach { stat ->
|
||||||
|
when {
|
||||||
|
stat.tag == AppConfig.TAG_DIRECT -> {
|
||||||
|
when (stat.direction) {
|
||||||
|
AppConfig.UPLINK -> directUplink += stat.value
|
||||||
|
AppConfig.DOWNLINK -> directDownlink += stat.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stat.tag.startsWith(AppConfig.TAG_PROXY) -> {
|
||||||
|
when (stat.direction) {
|
||||||
|
AppConfig.UPLINK -> proxyUplink += stat.value
|
||||||
|
AppConfig.DOWNLINK -> proxyDownlink += stat.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val proxyTotal = proxyUplink + proxyDownlink
|
||||||
|
val directTotal = directUplink + directDownlink
|
||||||
|
val zeroSpeed = proxyTotal + directTotal == 0L
|
||||||
|
if (!zeroSpeed || !lastZeroSpeed) {
|
||||||
|
val text = StringBuilder()
|
||||||
|
appendSpeedString(
|
||||||
|
text, AppConfig.TAG_PROXY,
|
||||||
|
proxyUplink / sinceLastQueryInSeconds,
|
||||||
|
proxyDownlink / sinceLastQueryInSeconds
|
||||||
|
)
|
||||||
|
|
||||||
|
appendSpeedString(
|
||||||
|
text, AppConfig.TAG_DIRECT,
|
||||||
|
directUplink / sinceLastQueryInSeconds,
|
||||||
|
directDownlink / sinceLastQueryInSeconds
|
||||||
|
)
|
||||||
|
updateNotification(text.toString(), proxyTotal, directTotal)
|
||||||
|
}
|
||||||
|
lastQueryTime = queryTime
|
||||||
|
return zeroSpeed
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the service instance.
|
* Gets the service instance.
|
||||||
* @return The service instance.
|
* @return The service instance.
|
||||||
*/
|
*/
|
||||||
private fun getService(): Service? {
|
private fun getService(): Service? {
|
||||||
return V2RayServiceManager.serviceControl?.get()?.getService()
|
return CoreServiceManager.serviceControl?.get()?.getService()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.v2ray.ang.handler
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
object SettingsChangeManager {
|
||||||
|
private val _restartService = MutableStateFlow(false)
|
||||||
|
private val _setupGroupTab = MutableStateFlow(false)
|
||||||
|
|
||||||
|
// Mark restartService as requiring a restart
|
||||||
|
fun makeRestartService() {
|
||||||
|
_restartService.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and clear the restartService flag
|
||||||
|
fun consumeRestartService(): Boolean {
|
||||||
|
val v = _restartService.value
|
||||||
|
_restartService.value = false
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark reinitGroupTab as requiring tab reinitialization
|
||||||
|
fun makeSetupGroupTab() {
|
||||||
|
_setupGroupTab.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and clear the reinitGroupTab flag
|
||||||
|
fun consumeSetupGroupTab(): Boolean {
|
||||||
|
val v = _setupGroupTab.value
|
||||||
|
_setupGroupTab.value = false
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,36 +2,57 @@ package com.v2ray.ang.handler
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.AssetManager
|
import android.content.res.AssetManager
|
||||||
|
import android.os.Build
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.util.Log
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||||
|
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||||
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
|
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
|
||||||
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
|
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
|
||||||
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
||||||
import com.v2ray.ang.dto.EConfigType
|
import com.v2ray.ang.AppConfig.VPN
|
||||||
import com.v2ray.ang.dto.Language
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
|
||||||
import com.v2ray.ang.dto.RoutingType
|
|
||||||
import com.v2ray.ang.dto.RulesetItem
|
|
||||||
import com.v2ray.ang.dto.V2rayConfig
|
import com.v2ray.ang.dto.V2rayConfig
|
||||||
|
import com.v2ray.ang.dto.entities.ProfileItem
|
||||||
|
import com.v2ray.ang.dto.entities.RulesetItem
|
||||||
|
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||||
|
import com.v2ray.ang.enums.EConfigType
|
||||||
|
import com.v2ray.ang.enums.Language
|
||||||
|
import com.v2ray.ang.enums.RoutingType
|
||||||
|
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
|
||||||
|
import com.v2ray.ang.handler.MmkvManager.decodeAllServerList
|
||||||
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
|
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
|
||||||
import com.v2ray.ang.handler.MmkvManager.decodeServerList
|
import com.v2ray.ang.handler.MmkvManager.decodeSubsList
|
||||||
|
import com.v2ray.ang.handler.MmkvManager.decodeSubscription
|
||||||
|
import com.v2ray.ang.handler.MmkvManager.encodeSubscription
|
||||||
|
import com.v2ray.ang.handler.MmkvManager.removeSubscription
|
||||||
import com.v2ray.ang.util.JsonUtil
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
object SettingsManager {
|
object SettingsManager {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var runtimeSocksPort: Int? = null
|
||||||
|
|
||||||
|
fun initApp(context: Context) {
|
||||||
|
ensureDefaultSettings()
|
||||||
|
//ensureDefaultSubscription()
|
||||||
|
initRoutingRulesets(context)
|
||||||
|
migrateServerListToSubscriptions()
|
||||||
|
migrateHysteria2PinSHA256()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize routing rulesets.
|
* Initialize routing rulesets.
|
||||||
* @param context The application context.
|
* @param context The application context.
|
||||||
*/
|
*/
|
||||||
fun initRoutingRulesets(context: Context) {
|
private fun initRoutingRulesets(context: Context) {
|
||||||
val exist = MmkvManager.decodeRoutingRulesets()
|
val exist = MmkvManager.decodeRoutingRulesets()
|
||||||
if (exist.isNullOrEmpty()) {
|
if (exist.isNullOrEmpty()) {
|
||||||
val rulesetList = getPresetRoutingRulesets(context)
|
val rulesetList = getPresetRoutingRulesets(context)
|
||||||
@@ -52,7 +73,7 @@ object SettingsManager {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return JsonUtil.fromJson(assets, Array<RulesetItem>::class.java).toMutableList()
|
return JsonUtil.fromJsonSafe(assets, Array<RulesetItem>::class.java)?.toMutableList()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,7 +97,7 @@ object SettingsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val rulesetList = JsonUtil.fromJson(content, Array<RulesetItem>::class.java).toMutableList()
|
val rulesetList = JsonUtil.fromJsonSafe(content, Array<RulesetItem>::class.java)?.toMutableList()
|
||||||
if (rulesetList.isNullOrEmpty()) {
|
if (rulesetList.isNullOrEmpty()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -84,7 +105,7 @@ object SettingsManager {
|
|||||||
resetRoutingRulesetsCommon(rulesetList)
|
resetRoutingRulesetsCommon(rulesetList)
|
||||||
return true
|
return true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(ANG_PACKAGE, "Failed to reset routing rulesets", e)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +180,7 @@ object SettingsManager {
|
|||||||
* @return True if bypassing LAN, false otherwise.
|
* @return True if bypassing LAN, false otherwise.
|
||||||
*/
|
*/
|
||||||
fun routingRulesetsBypassLan(): Boolean {
|
fun routingRulesetsBypassLan(): Boolean {
|
||||||
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN) ?: "0"
|
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN) ?: "1"
|
||||||
if (vpnBypassLan == "1") {
|
if (vpnBypassLan == "1") {
|
||||||
return true
|
return true
|
||||||
} else if (vpnBypassLan == "2") {
|
} else if (vpnBypassLan == "2") {
|
||||||
@@ -167,11 +188,11 @@ object SettingsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val guid = MmkvManager.getSelectServer() ?: return false
|
val guid = MmkvManager.getSelectServer() ?: return false
|
||||||
val config = MmkvManager.decodeServerConfig(guid) ?: return false
|
val config = decodeServerConfig(guid) ?: return false
|
||||||
if (config.configType == EConfigType.CUSTOM) {
|
if (config.configType == EConfigType.CUSTOM) {
|
||||||
val raw = MmkvManager.decodeServerRaw(guid) ?: return false
|
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 {
|
val exist = v2rayConfig?.routing?.rules?.filter { it.outboundTag == TAG_DIRECT }?.any {
|
||||||
it.domain?.contains(GEOSITE_PRIVATE) == true || it.ip?.contains(GEOIP_PRIVATE) == true
|
it.domain?.contains(GEOSITE_PRIVATE) == true || it.ip?.contains(GEOIP_PRIVATE) == true
|
||||||
}
|
}
|
||||||
return exist == true
|
return exist == true
|
||||||
@@ -203,8 +224,8 @@ object SettingsManager {
|
|||||||
* @param toPosition The position to swap to.
|
* @param toPosition The position to swap to.
|
||||||
*/
|
*/
|
||||||
fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
|
fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
|
||||||
val subsList = MmkvManager.decodeSubsList()
|
val subsList = decodeSubsList()
|
||||||
if (subsList.isNullOrEmpty()) return
|
if (subsList.isEmpty()) return
|
||||||
|
|
||||||
Collections.swap(subsList, fromPosition, toPosition)
|
Collections.swap(subsList, fromPosition, toPosition)
|
||||||
MmkvManager.encodeSubsList(subsList)
|
MmkvManager.encodeSubsList(subsList)
|
||||||
@@ -216,17 +237,49 @@ object SettingsManager {
|
|||||||
* @return The ProfileItem.
|
* @return The ProfileItem.
|
||||||
*/
|
*/
|
||||||
fun getServerViaRemarks(remarks: String?): ProfileItem? {
|
fun getServerViaRemarks(remarks: String?): ProfileItem? {
|
||||||
if (remarks == null) {
|
if (remarks.isNullOrEmpty()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val serverList = decodeServerList()
|
val serverList = decodeAllServerList()
|
||||||
for (guid in serverList) {
|
return serverList
|
||||||
val profile = decodeServerConfig(guid)
|
.mapNotNull { guid -> decodeServerConfig(guid) }
|
||||||
if (profile != null && profile.remarks == remarks) {
|
.firstOrNull { it.remarks == remarks }
|
||||||
return profile
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -234,7 +287,30 @@ object SettingsManager {
|
|||||||
* @return The SOCKS port.
|
* @return The SOCKS port.
|
||||||
*/
|
*/
|
||||||
fun getSocksPort(): Int {
|
fun getSocksPort(): Int {
|
||||||
return Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
|
val port =
|
||||||
|
if (IsDynamicSocksPort()) {
|
||||||
|
runtimeSocksPort ?: refreshRuntimeSocksPort()
|
||||||
|
} else {
|
||||||
|
Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
|
||||||
|
}
|
||||||
|
return port ?: AppConfig.PORT_SOCKS.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun refreshRuntimeSocksPort(): Int? {
|
||||||
|
if (IsDynamicSocksPort()) {
|
||||||
|
runtimeSocksPort = generateRandomSocksPort()
|
||||||
|
return runtimeSocksPort
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSocksUsername(): String? {
|
||||||
|
return MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_USERNAME)?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSocksPassword(): String? {
|
||||||
|
return MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PASSWORD)?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -242,7 +318,15 @@ object SettingsManager {
|
|||||||
* @return The HTTP port.
|
* @return The HTTP port.
|
||||||
*/
|
*/
|
||||||
fun getHttpPort(): Int {
|
fun getHttpPort(): Int {
|
||||||
return getSocksPort() + (if (Utils.isXray()) 0 else 1)
|
return getSocksPort() + if (Utils.isXray()) 0 else 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun IsDynamicSocksPort(): Boolean {
|
||||||
|
return MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateRandomSocksPort(): Int {
|
||||||
|
return Random.nextInt(10000, 65535)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -254,7 +338,7 @@ object SettingsManager {
|
|||||||
val extFolder = Utils.userAssetPath(context)
|
val extFolder = Utils.userAssetPath(context)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val geo = arrayOf("geosite.dat", "geoip.dat")
|
val geo = arrayOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
|
||||||
assets.list("")
|
assets.list("")
|
||||||
?.filter { geo.contains(it) }
|
?.filter { geo.contains(it) }
|
||||||
?.filter { !File(extFolder, it).exists() }
|
?.filter { !File(extFolder, it).exists() }
|
||||||
@@ -265,13 +349,10 @@ object SettingsManager {
|
|||||||
input.copyTo(output)
|
input.copyTo(output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.i(
|
LogUtil.i(AppConfig.TAG, "Copied from apk assets folder to ${target.absolutePath}")
|
||||||
ANG_PACKAGE,
|
|
||||||
"Copied from apk assets folder to ${target.absolutePath}"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(ANG_PACKAGE, "asset copy failed", e)
|
LogUtil.e(ANG_PACKAGE, "asset copy failed", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,13 +400,22 @@ object SettingsManager {
|
|||||||
*/
|
*/
|
||||||
fun getDelayTestUrl(second: Boolean = false): String {
|
fun getDelayTestUrl(second: Boolean = false): String {
|
||||||
return if (second) {
|
return if (second) {
|
||||||
AppConfig.DelayTestUrl2
|
AppConfig.DELAY_TEST_URL2
|
||||||
} else {
|
} else {
|
||||||
MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL)
|
MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL)
|
||||||
?: AppConfig.DelayTestUrl
|
?: AppConfig.DELAY_TEST_URL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get real ping concurrency.
|
||||||
|
* @return The number of concurrent real-ping tests (clamped to 1..64).
|
||||||
|
*/
|
||||||
|
fun getRealPingConcurrency(): Int {
|
||||||
|
val value = MmkvManager.decodeSettingsString(AppConfig.PREF_REAL_PING_CONCURRENCY)?.toIntOrNull() ?: 16
|
||||||
|
return value.coerceIn(1, 128)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the locale.
|
* Get the locale.
|
||||||
* @return The locale.
|
* @return The locale.
|
||||||
@@ -340,11 +430,12 @@ object SettingsManager {
|
|||||||
Language.ENGLISH -> Locale.ENGLISH
|
Language.ENGLISH -> Locale.ENGLISH
|
||||||
Language.CHINA -> Locale.CHINA
|
Language.CHINA -> Locale.CHINA
|
||||||
Language.TRADITIONAL_CHINESE -> Locale.TRADITIONAL_CHINESE
|
Language.TRADITIONAL_CHINESE -> Locale.TRADITIONAL_CHINESE
|
||||||
Language.VIETNAMESE -> Locale("vi")
|
Language.VIETNAMESE -> Locale.forLanguageTag("vi")
|
||||||
Language.RUSSIAN -> Locale("ru")
|
Language.RUSSIAN -> Locale.forLanguageTag("ru")
|
||||||
Language.PERSIAN -> Locale("fa")
|
Language.PERSIAN -> Locale.forLanguageTag("fa")
|
||||||
Language.BANGLA -> Locale("bn")
|
Language.ARABIC -> Locale.forLanguageTag("ar")
|
||||||
Language.BAKHTIARI -> Locale("bqi", "IR")
|
Language.BANGLA -> Locale.forLanguageTag("bn")
|
||||||
|
Language.BAKHTIARI -> Locale.forLanguageTag("bqi-IR")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,4 +449,185 @@ object SettingsManager {
|
|||||||
"2" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
"2" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the currently selected VPN interface address configuration.
|
||||||
|
* This method reads the user's preference for VPN interface addressing and returns
|
||||||
|
* the corresponding configuration containing IPv4 and IPv6 addresses.
|
||||||
|
*
|
||||||
|
* @return The selected VpnInterfaceAddressConfig instance, or the default configuration
|
||||||
|
* if no valid selection is found or if the stored index is invalid.
|
||||||
|
*/
|
||||||
|
fun getCurrentVpnInterfaceAddressConfig(): VpnInterfaceAddressConfig {
|
||||||
|
val selectedIndex = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX, "0")?.toInt()
|
||||||
|
return VpnInterfaceAddressConfig.getConfigByIndex(selectedIndex ?: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the VPN MTU from settings, defaulting to AppConfig.VPN_MTU.
|
||||||
|
*/
|
||||||
|
fun getVpnMtu(): Int {
|
||||||
|
return Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_MTU), AppConfig.VPN_MTU)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if HEV TUN is being used.
|
||||||
|
* @return True if HEV TUN is used, false otherwise.
|
||||||
|
*/
|
||||||
|
fun isUsingHevTun(): Boolean {
|
||||||
|
return MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if VPN mode is enabled.
|
||||||
|
* @return True if VPN mode is enabled, false otherwise.
|
||||||
|
*/
|
||||||
|
fun isVpnMode(): Boolean {
|
||||||
|
val mode = MmkvManager.decodeSettingsString(AppConfig.PREF_MODE)
|
||||||
|
return mode == null || mode == VPN
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if process routing can be used.
|
||||||
|
*/
|
||||||
|
fun canUseProcessRouting(): Boolean {
|
||||||
|
// Android 10+
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must xray tun
|
||||||
|
if (isUsingHevTun()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have route only enabled
|
||||||
|
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false) == false) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure default settings are present in MMKV.
|
||||||
|
*/
|
||||||
|
private fun ensureDefaultSettings() {
|
||||||
|
// Write defaults in the exact order requested by the user
|
||||||
|
ensureDefaultValue(AppConfig.PREF_MODE, VPN)
|
||||||
|
ensureDefaultValue(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
|
||||||
|
ensureDefaultValue(AppConfig.PREF_VPN_MTU, AppConfig.VPN_MTU.toString())
|
||||||
|
ensureDefaultValue(AppConfig.PREF_SOCKS_PORT, AppConfig.PORT_SOCKS)
|
||||||
|
ensureDefaultValue(AppConfig.PREF_REMOTE_DNS, AppConfig.DNS_PROXY)
|
||||||
|
ensureDefaultValue(AppConfig.PREF_DOMESTIC_DNS, AppConfig.DNS_DIRECT)
|
||||||
|
ensureDefaultValue(AppConfig.PREF_DELAY_TEST_URL, AppConfig.DELAY_TEST_URL)
|
||||||
|
ensureDefaultValue(AppConfig.PREF_IP_API_URL, AppConfig.IP_API_URL)
|
||||||
|
ensureDefaultValue(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, AppConfig.HEVTUN_RW_TIMEOUT)
|
||||||
|
ensureDefaultValue(AppConfig.PREF_MUX_CONCURRENCY, "8")
|
||||||
|
ensureDefaultValue(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
|
||||||
|
ensureDefaultValue(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
|
||||||
|
ensureDefaultValue(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureDefaultValue(key: String, default: String) {
|
||||||
|
if (MmkvManager.decodeSettingsString(key).isNullOrEmpty()) {
|
||||||
|
MmkvManager.encodeSettings(key, default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 top
|
||||||
|
val subsList = decodeSubsList()
|
||||||
|
if (subsList.count() > 1) {
|
||||||
|
swapSubscriptions(0, subsList.count() - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
package com.v2ray.ang.handler
|
package com.v2ray.ang.handler
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.SystemClock
|
|
||||||
import android.text.TextUtils
|
|
||||||
import android.util.Log
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.dto.IPAPIInfo
|
||||||
import com.v2ray.ang.extension.responseLength
|
import com.v2ray.ang.dto.UrlContentRequest
|
||||||
import com.v2ray.ang.util.HttpUtil
|
import com.v2ray.ang.util.HttpUtil
|
||||||
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import libv2ray.Libv2ray
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
import kotlin.coroutines.coroutineContext
|
|
||||||
|
|
||||||
object SpeedtestManager {
|
object SpeedtestManager {
|
||||||
|
|
||||||
@@ -31,7 +28,7 @@ object SpeedtestManager {
|
|||||||
var time = -1L
|
var time = -1L
|
||||||
for (k in 0 until 2) {
|
for (k in 0 until 2) {
|
||||||
val one = socketConnectTime(url, port)
|
val one = socketConnectTime(url, port)
|
||||||
if (!coroutineContext.isActive) {
|
if (!currentCoroutineContext().isActive) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (one != -1L && (time == -1L || one < time)) {
|
if (one != -1L && (time == -1L || one < time)) {
|
||||||
@@ -41,46 +38,6 @@ object SpeedtestManager {
|
|||||||
return time
|
return time
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Measures the real ping time using the V2Ray library.
|
|
||||||
*
|
|
||||||
* @param config The configuration string for the V2Ray library.
|
|
||||||
* @return The ping time in milliseconds, or -1 if the ping failed.
|
|
||||||
*/
|
|
||||||
fun realPing(config: String): Long {
|
|
||||||
return try {
|
|
||||||
Libv2ray.measureOutboundDelay(config, SettingsManager.getDelayTestUrl())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.d(AppConfig.ANG_PACKAGE, "realPing: $e")
|
|
||||||
-1L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Measures the ping time to a given URL using the system ping command.
|
|
||||||
*
|
|
||||||
* @param url The URL to ping.
|
|
||||||
* @return The ping time in milliseconds as a string, or "-1ms" if the ping failed.
|
|
||||||
*/
|
|
||||||
fun ping(url: String): String {
|
|
||||||
try {
|
|
||||||
val command = "/system/bin/ping -c 3 $url"
|
|
||||||
val process = Runtime.getRuntime().exec(command)
|
|
||||||
val allText = process.inputStream.bufferedReader().use { it.readText() }
|
|
||||||
if (!TextUtils.isEmpty(allText)) {
|
|
||||||
val tempInfo = allText.substring(allText.indexOf("min/avg/max/mdev") + 19)
|
|
||||||
val temps =
|
|
||||||
tempInfo.split("/".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
|
||||||
if (temps.count() > 0 && temps[0].length < 10) {
|
|
||||||
return temps[0].toFloat().toInt().toString() + "ms"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
return "-1ms"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Measures the time taken to establish a TCP connection to a given URL and port.
|
* Measures the time taken to establish a TCP connection to a given URL and port.
|
||||||
*
|
*
|
||||||
@@ -103,11 +60,11 @@ object SpeedtestManager {
|
|||||||
socket.close()
|
socket.close()
|
||||||
return time
|
return time
|
||||||
} catch (e: UnknownHostException) {
|
} catch (e: UnknownHostException) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Unknown host: $url", e)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.d(AppConfig.ANG_PACKAGE, "socketConnectTime IOException: $e")
|
LogUtil.e(AppConfig.TAG, "socketConnectTime IOException: $e")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtil.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
|
||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
@@ -124,53 +81,39 @@ object SpeedtestManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun getRemoteIPInfo(): String? {
|
||||||
* Tests the connection to a given URL and port.
|
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
|
||||||
*
|
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
|
||||||
* @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, "")
|
val proxyUsername = SettingsManager.getSocksUsername()
|
||||||
try {
|
val proxyPassword = SettingsManager.getSocksPassword()
|
||||||
val start = SystemClock.elapsedRealtime()
|
val httpPort = SettingsManager.getHttpPort()
|
||||||
val code = conn.responseCode
|
if (httpPort == 0) return null
|
||||||
elapsed = SystemClock.elapsedRealtime() - start
|
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
|
||||||
|
|
||||||
if (code == 204 || code == 200 && conn.responseLength == 0L) {
|
val ip = listOf(
|
||||||
result = context.getString(R.string.connection_test_available, elapsed)
|
ipInfo.ip,
|
||||||
} else {
|
ipInfo.clientIp,
|
||||||
throw IOException(
|
ipInfo.ip_addr,
|
||||||
context.getString(
|
ipInfo.query
|
||||||
R.string.connection_test_error_status_code,
|
).firstOrNull { !it.isNullOrBlank() }
|
||||||
code
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
Log.d(AppConfig.ANG_PACKAGE, "testConnection IOException: " + Log.getStackTraceString(e))
|
|
||||||
result = context.getString(R.string.connection_test_error, e.message)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.d(AppConfig.ANG_PACKAGE, "testConnection Exception: " + Log.getStackTraceString(e))
|
|
||||||
result = context.getString(R.string.connection_test_error, e.message)
|
|
||||||
} finally {
|
|
||||||
conn?.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
return Pair(elapsed, result)
|
val country = listOf(
|
||||||
|
ipInfo.country_code,
|
||||||
|
ipInfo.country,
|
||||||
|
ipInfo.countryCode,
|
||||||
|
ipInfo.location?.country_code
|
||||||
|
).firstOrNull { !it.isNullOrBlank() }
|
||||||
|
|
||||||
|
return "(${country ?: "unknown"}) ${ip ?: "unknown"}"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the version of the V2Ray library.
|
|
||||||
*
|
|
||||||
* @return The version of the V2Ray library.
|
|
||||||
*/
|
|
||||||
fun getLibVersion(): String {
|
|
||||||
return Libv2ray.checkVersionX()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package com.v2ray.ang.handler
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import androidx.work.multiprocess.RemoteWorkManager
|
||||||
|
import androidx.work.workDataOf
|
||||||
|
import com.v2ray.ang.AngApplication
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||||
|
import com.v2ray.ang.enums.NotificationChannelType
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
import com.v2ray.ang.util.NotificationHelper
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
object SubscriptionUpdater {
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Public API — the only methods external callers should ever use
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync all subscription tasks with current settings.
|
||||||
|
*
|
||||||
|
* Startup/boot callers should use the default mode so existing periodic work is kept.
|
||||||
|
* Use forceReschedule=true only when the next run time needs to be recalculated from
|
||||||
|
* the latest persisted subscription state (for example after a manual refresh).
|
||||||
|
* Call from: MainActivity.onCreate(), BootReceiver.onReceive().
|
||||||
|
*/
|
||||||
|
fun sync(
|
||||||
|
context: Context = AngApplication.application,
|
||||||
|
forceReschedule: Boolean = false
|
||||||
|
) {
|
||||||
|
val existingWorkPolicy =
|
||||||
|
if (forceReschedule) {
|
||||||
|
ExistingPeriodicWorkPolicy.REPLACE
|
||||||
|
} else {
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP
|
||||||
|
}
|
||||||
|
|
||||||
|
MmkvManager.decodeSubscriptions().forEach { sub ->
|
||||||
|
scheduleOne(
|
||||||
|
context = context,
|
||||||
|
subId = sub.guid,
|
||||||
|
shouldRun = sub.subscription.autoUpdate,
|
||||||
|
existingWorkPolicy = existingWorkPolicy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LogUtil.i(
|
||||||
|
AppConfig.TAG,
|
||||||
|
"SubscriptionUpdater: sync complete forceReschedule=$forceReschedule"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a single subscription's task.
|
||||||
|
* Call from: SubEditActivity after saving, after a manual update (to reset the timer).
|
||||||
|
*/
|
||||||
|
fun syncOne(context: Context = AngApplication.application, subId: String) {
|
||||||
|
val subItem = MmkvManager.decodeSubscription(subId) ?: return
|
||||||
|
scheduleOne(
|
||||||
|
context = context,
|
||||||
|
subId = subId,
|
||||||
|
shouldRun = subItem.autoUpdate,
|
||||||
|
existingWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel the auto-update task for a single subscription.
|
||||||
|
* Call from: when a subscription is deleted.
|
||||||
|
*/
|
||||||
|
fun cancelOne(context: Context = AngApplication.application, subId: String) {
|
||||||
|
RemoteWorkManager.getInstance(context)
|
||||||
|
.cancelUniqueWork(taskName(subId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Internal scheduling logic
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun taskName(subId: String) = "${AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME}_$subId"
|
||||||
|
|
||||||
|
private fun scheduleOne(
|
||||||
|
context: Context,
|
||||||
|
subId: String,
|
||||||
|
shouldRun: Boolean,
|
||||||
|
existingWorkPolicy: ExistingPeriodicWorkPolicy
|
||||||
|
) {
|
||||||
|
val rw = RemoteWorkManager.getInstance(context)
|
||||||
|
if (!shouldRun) {
|
||||||
|
rw.cancelUniqueWork(taskName(subId))
|
||||||
|
LogUtil.d(AppConfig.TAG, "SubscriptionUpdater: cancelled task for $subId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val subItem = MmkvManager.decodeSubscription(subId) ?: return
|
||||||
|
|
||||||
|
val intervalMinutes = maxOf(
|
||||||
|
AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES,
|
||||||
|
subItem.updateInterval
|
||||||
|
)
|
||||||
|
|
||||||
|
// Base initial delay on the last successful update time persisted in subscription.
|
||||||
|
val lastUpdated = subItem.lastUpdated
|
||||||
|
val intervalMillis = intervalMinutes * 60 * 1000L
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val initialDelayMillis = if (lastUpdated <= 0L) {
|
||||||
|
0L
|
||||||
|
} else {
|
||||||
|
maxOf(0L, lastUpdated + intervalMillis - now)
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = PeriodicWorkRequestBuilder<UpdateTask>(intervalMinutes, TimeUnit.MINUTES)
|
||||||
|
.setConstraints(
|
||||||
|
Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.setInputData(workDataOf(KEY_SUB_ID to subId))
|
||||||
|
.setInitialDelay(initialDelayMillis, TimeUnit.MILLISECONDS)
|
||||||
|
.addTag(AppConfig.SUBSCRIPTION_UPDATE_TASK_NAME)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
rw.enqueueUniquePeriodicWork(
|
||||||
|
taskName(subId),
|
||||||
|
existingWorkPolicy,
|
||||||
|
request
|
||||||
|
)
|
||||||
|
|
||||||
|
LogUtil.i(
|
||||||
|
AppConfig.TAG,
|
||||||
|
"SubscriptionUpdater: scheduled [$subId] interval=${intervalMinutes}min " +
|
||||||
|
"initialDelay=${initialDelayMillis / 1000}s policy=$existingWorkPolicy"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Worker
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private const val KEY_SUB_ID = "subId"
|
||||||
|
|
||||||
|
class UpdateTask(context: Context, params: WorkerParameters) :
|
||||||
|
CoroutineWorker(context, params) {
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val subId = inputData.getString(KEY_SUB_ID)
|
||||||
|
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater automatic update starting: $subId")
|
||||||
|
|
||||||
|
if (subId.isNullOrEmpty()) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: missing subId in worker input")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
val subItem = MmkvManager.decodeSubscription(subId)
|
||||||
|
if (subItem == null) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "SubscriptionUpdater: no subscription found for $subId")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subItem.autoUpdate) {
|
||||||
|
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater: auto-update disabled for $subId, skip")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
val sub = SubscriptionCache(subId, subItem)
|
||||||
|
|
||||||
|
// Notify about update start
|
||||||
|
NotificationHelper.notify(
|
||||||
|
NotificationChannelType.SUBSCRIPTION_UPDATE,
|
||||||
|
applicationContext,
|
||||||
|
applicationContext.getString(R.string.title_pref_auto_update_subscription),
|
||||||
|
"Updating ${sub.subscription.remarks}"
|
||||||
|
)
|
||||||
|
|
||||||
|
LogUtil.i(AppConfig.TAG, "SubscriptionUpdater automatic update: ---${sub.subscription.remarks}")
|
||||||
|
AngConfigManager.updateConfigViaSub(sub)
|
||||||
|
|
||||||
|
// Clear notification
|
||||||
|
NotificationHelper.cancel(NotificationChannelType.SUBSCRIPTION_UPDATE, applicationContext)
|
||||||
|
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.v2ray.ang.handler
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.BuildConfig
|
||||||
|
import com.v2ray.ang.dto.CheckUpdateResult
|
||||||
|
import com.v2ray.ang.dto.GitHubRelease
|
||||||
|
import com.v2ray.ang.dto.UrlContentRequest
|
||||||
|
import com.v2ray.ang.extension.concatUrl
|
||||||
|
import com.v2ray.ang.util.HttpUtil
|
||||||
|
import com.v2ray.ang.util.JsonUtil
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
object UpdateCheckerManager {
|
||||||
|
suspend fun checkForUpdate(includePreRelease: Boolean = false): CheckUpdateResult = withContext(Dispatchers.IO) {
|
||||||
|
val url = if (includePreRelease) {
|
||||||
|
AppConfig.APP_API_URL
|
||||||
|
} else {
|
||||||
|
AppConfig.APP_API_URL.concatUrl("latest")
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
UrlContentRequest(
|
||||||
|
url = url,
|
||||||
|
timeout = 5000,
|
||||||
|
httpPort = httpPort,
|
||||||
|
proxyUsername = proxyUsername,
|
||||||
|
proxyPassword = proxyPassword
|
||||||
|
)
|
||||||
|
)
|
||||||
|
?: throw IllegalStateException("Failed to get response")
|
||||||
|
}
|
||||||
|
|
||||||
|
val latestRelease = if (includePreRelease) {
|
||||||
|
JsonUtil.fromJsonSafe(response, Array<GitHubRelease>::class.java)
|
||||||
|
?.firstOrNull()
|
||||||
|
?: throw IllegalStateException("No pre-release found")
|
||||||
|
} else {
|
||||||
|
JsonUtil.fromJsonSafe(response, GitHubRelease::class.java)
|
||||||
|
}
|
||||||
|
if (latestRelease == null) {
|
||||||
|
return@withContext CheckUpdateResult(hasUpdate = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val latestVersion = latestRelease.tagName.removePrefix("v")
|
||||||
|
LogUtil.i(
|
||||||
|
AppConfig.TAG,
|
||||||
|
"Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return@withContext if (compareVersions(latestVersion, BuildConfig.VERSION_NAME) > 0) {
|
||||||
|
val downloadUrl = getDownloadUrl(latestRelease, Build.SUPPORTED_ABIS[0])
|
||||||
|
CheckUpdateResult(
|
||||||
|
hasUpdate = true,
|
||||||
|
latestVersion = latestVersion,
|
||||||
|
releaseNotes = latestRelease.body,
|
||||||
|
downloadUrl = downloadUrl,
|
||||||
|
isPreRelease = latestRelease.prerelease
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CheckUpdateResult(hasUpdate = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareVersions(version1: String, version2: String): Int {
|
||||||
|
val v1 = version1.split(".")
|
||||||
|
val v2 = version2.split(".")
|
||||||
|
|
||||||
|
for (i in 0 until maxOf(v1.size, v2.size)) {
|
||||||
|
val num1 = if (i < v1.size) v1[i].toInt() else 0
|
||||||
|
val num2 = if (i < v2.size) v2[i].toInt() else 0
|
||||||
|
if (num1 != num2) return num1 - num2
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDownloadUrl(release: GitHubRelease, abi: String): String {
|
||||||
|
val fDroid = "fdroid"
|
||||||
|
|
||||||
|
val assetsByAbi = release.assets.filter {
|
||||||
|
(it.name.contains(abi, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
val asset = if (BuildConfig.APPLICATION_ID.contains(fDroid, ignoreCase = true)) {
|
||||||
|
assetsByAbi.firstOrNull { it.name.contains(fDroid) }
|
||||||
|
} else {
|
||||||
|
assetsByAbi.firstOrNull { !it.name.contains(fDroid) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset?.browserDownloadUrl
|
||||||
|
?: throw IllegalStateException("No compatible APK found")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,667 +0,0 @@
|
|||||||
package com.v2ray.ang.handler
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.text.TextUtils
|
|
||||||
import android.util.Log
|
|
||||||
import com.v2ray.ang.AppConfig
|
|
||||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
|
||||||
import com.v2ray.ang.AppConfig.DEFAULT_NETWORK
|
|
||||||
import com.v2ray.ang.AppConfig.DNS_ALIDNS_ADDRESSES
|
|
||||||
import com.v2ray.ang.AppConfig.DNS_ALIDNS_DOMAIN
|
|
||||||
import com.v2ray.ang.AppConfig.DNS_CLOUDFLARE_ADDRESSES
|
|
||||||
import com.v2ray.ang.AppConfig.DNS_CLOUDFLARE_DOMAIN
|
|
||||||
import com.v2ray.ang.AppConfig.DNS_DNSPOD_ADDRESSES
|
|
||||||
import com.v2ray.ang.AppConfig.DNS_DNSPOD_DOMAIN
|
|
||||||
import com.v2ray.ang.AppConfig.DNS_GOOGLE_ADDRESSES
|
|
||||||
import com.v2ray.ang.AppConfig.DNS_GOOGLE_DOMAIN
|
|
||||||
import com.v2ray.ang.AppConfig.DNS_QUAD9_ADDRESSES
|
|
||||||
import com.v2ray.ang.AppConfig.DNS_QUAD9_DOMAIN
|
|
||||||
import com.v2ray.ang.AppConfig.DNS_YANDEX_ADDRESSES
|
|
||||||
import com.v2ray.ang.AppConfig.DNS_YANDEX_DOMAIN
|
|
||||||
import com.v2ray.ang.AppConfig.GEOIP_CN
|
|
||||||
import com.v2ray.ang.AppConfig.GEOSITE_CN
|
|
||||||
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
|
|
||||||
import com.v2ray.ang.AppConfig.GOOGLEAPIS_CN_DOMAIN
|
|
||||||
import com.v2ray.ang.AppConfig.GOOGLEAPIS_COM_DOMAIN
|
|
||||||
import com.v2ray.ang.AppConfig.HEADER_TYPE_HTTP
|
|
||||||
import com.v2ray.ang.AppConfig.LOOPBACK
|
|
||||||
import com.v2ray.ang.AppConfig.PROTOCOL_FREEDOM
|
|
||||||
import com.v2ray.ang.AppConfig.TAG_BLOCKED
|
|
||||||
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
|
||||||
import com.v2ray.ang.AppConfig.TAG_FRAGMENT
|
|
||||||
import com.v2ray.ang.AppConfig.TAG_PROXY
|
|
||||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
|
||||||
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V6
|
|
||||||
import com.v2ray.ang.dto.ConfigResult
|
|
||||||
import com.v2ray.ang.dto.EConfigType
|
|
||||||
import com.v2ray.ang.dto.NetworkType
|
|
||||||
import com.v2ray.ang.dto.ProfileItem
|
|
||||||
import com.v2ray.ang.dto.RulesetItem
|
|
||||||
import com.v2ray.ang.dto.V2rayConfig
|
|
||||||
import com.v2ray.ang.dto.V2rayConfig.RoutingBean.RulesBean
|
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
|
||||||
import com.v2ray.ang.fmt.HttpFmt
|
|
||||||
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.VlessFmt
|
|
||||||
import com.v2ray.ang.fmt.VmessFmt
|
|
||||||
import com.v2ray.ang.fmt.WireguardFmt
|
|
||||||
import com.v2ray.ang.util.JsonUtil
|
|
||||||
import com.v2ray.ang.util.Utils
|
|
||||||
|
|
||||||
object V2rayConfigManager {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the V2ray configuration for the given GUID.
|
|
||||||
*
|
|
||||||
* @param context The context of the caller.
|
|
||||||
* @param guid The unique identifier for the V2ray configuration.
|
|
||||||
* @return A ConfigResult object containing the configuration details or indicating failure.
|
|
||||||
*/
|
|
||||||
fun getV2rayConfig(context: Context, guid: String): ConfigResult {
|
|
||||||
try {
|
|
||||||
val config = MmkvManager.decodeServerConfig(guid) ?: return ConfigResult(false)
|
|
||||||
if (config.configType == EConfigType.CUSTOM) {
|
|
||||||
val raw = MmkvManager.decodeServerRaw(guid) ?: return ConfigResult(false)
|
|
||||||
val domainPort = config.getServerAddressAndPort()
|
|
||||||
return ConfigResult(true, guid, raw, domainPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
val result = getV2rayNonCustomConfig(context, config)
|
|
||||||
//Log.d(ANG_PACKAGE, result.content)
|
|
||||||
result.guid = guid
|
|
||||||
return result
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
return ConfigResult(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the non-custom V2ray configuration.
|
|
||||||
*
|
|
||||||
* @param context The context in which the function is called.
|
|
||||||
* @param config The profile item containing the configuration details.
|
|
||||||
* @return A ConfigResult object containing the result of the configuration retrieval.
|
|
||||||
*/
|
|
||||||
private fun getV2rayNonCustomConfig(context: Context, config: ProfileItem): ConfigResult {
|
|
||||||
val result = ConfigResult(false)
|
|
||||||
|
|
||||||
val address = config.server ?: return result
|
|
||||||
if (!Utils.isIpAddress(address)) {
|
|
||||||
if (!Utils.isValidUrl(address)) {
|
|
||||||
Log.d(ANG_PACKAGE, "$address is an invalid ip or domain")
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val assets = Utils.readTextFromAssets(context, "v2ray_config.json")
|
|
||||||
if (TextUtils.isEmpty(assets)) {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
val v2rayConfig = JsonUtil.fromJson(assets, V2rayConfig::class.java) ?: return result
|
|
||||||
v2rayConfig.log.loglevel =
|
|
||||||
MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
|
|
||||||
v2rayConfig.remarks = config.remarks
|
|
||||||
|
|
||||||
inbounds(v2rayConfig)
|
|
||||||
|
|
||||||
val isPlugin = config.configType == EConfigType.HYSTERIA2
|
|
||||||
val retOut = outbounds(v2rayConfig, config, isPlugin) ?: return result
|
|
||||||
val retMore = moreOutbounds(v2rayConfig, config.subscriptionId, isPlugin)
|
|
||||||
|
|
||||||
routing(v2rayConfig)
|
|
||||||
|
|
||||||
fakedns(v2rayConfig)
|
|
||||||
|
|
||||||
dns(v2rayConfig)
|
|
||||||
|
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
|
|
||||||
customLocalDns(v2rayConfig)
|
|
||||||
}
|
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
|
|
||||||
v2rayConfig.stats = null
|
|
||||||
v2rayConfig.policy = null
|
|
||||||
}
|
|
||||||
|
|
||||||
result.status = true
|
|
||||||
result.content = v2rayConfig.toPrettyPrinting()
|
|
||||||
result.domainPort = if (retMore.first) retMore.second else retOut.second
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun inbounds(v2rayConfig: V2rayConfig): Boolean {
|
|
||||||
try {
|
|
||||||
val socksPort = SettingsManager.getSocksPort()
|
|
||||||
|
|
||||||
v2rayConfig.inbounds.forEach { curInbound ->
|
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING) != true) {
|
|
||||||
//bind all inbounds to localhost if the user requests
|
|
||||||
curInbound.listen = LOOPBACK
|
|
||||||
}
|
|
||||||
}
|
|
||||||
v2rayConfig.inbounds[0].port = socksPort
|
|
||||||
val fakedns = MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
|
|
||||||
val sniffAllTlsAndHttp =
|
|
||||||
MmkvManager.decodeSettingsBool(AppConfig.PREF_SNIFFING_ENABLED, true) != false
|
|
||||||
v2rayConfig.inbounds[0].sniffing?.enabled = fakedns || sniffAllTlsAndHttp
|
|
||||||
v2rayConfig.inbounds[0].sniffing?.routeOnly =
|
|
||||||
MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false)
|
|
||||||
if (!sniffAllTlsAndHttp) {
|
|
||||||
v2rayConfig.inbounds[0].sniffing?.destOverride?.clear()
|
|
||||||
}
|
|
||||||
if (fakedns) {
|
|
||||||
v2rayConfig.inbounds[0].sniffing?.destOverride?.add("fakedns")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Utils.isXray()) {
|
|
||||||
v2rayConfig.inbounds.removeAt(1)
|
|
||||||
} else {
|
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
|
||||||
v2rayConfig.inbounds[1].port = httpPort
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun outbounds(v2rayConfig: V2rayConfig, config: ProfileItem, isPlugin: Boolean): Pair<Boolean, String>? {
|
|
||||||
if (isPlugin) {
|
|
||||||
val socksPort = Utils.findFreePort(listOf(100 + SettingsManager.getSocksPort(), 0))
|
|
||||||
val outboundNew = V2rayConfig.OutboundBean(
|
|
||||||
mux = null,
|
|
||||||
protocol = EConfigType.SOCKS.name.lowercase(),
|
|
||||||
settings = V2rayConfig.OutboundBean.OutSettingsBean(
|
|
||||||
servers = listOf(
|
|
||||||
V2rayConfig.OutboundBean.OutSettingsBean.ServersBean(
|
|
||||||
address = LOOPBACK,
|
|
||||||
port = socksPort
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (v2rayConfig.outbounds.isNotEmpty()) {
|
|
||||||
v2rayConfig.outbounds[0] = outboundNew
|
|
||||||
} else {
|
|
||||||
v2rayConfig.outbounds.add(outboundNew)
|
|
||||||
}
|
|
||||||
return Pair(true, outboundNew.getServerAddressAndPort())
|
|
||||||
}
|
|
||||||
|
|
||||||
val outbound = getProxyOutbound(config) ?: return null
|
|
||||||
val ret = updateOutboundWithGlobalSettings(outbound)
|
|
||||||
if (!ret) return null
|
|
||||||
|
|
||||||
if (v2rayConfig.outbounds.isNotEmpty()) {
|
|
||||||
v2rayConfig.outbounds[0] = outbound
|
|
||||||
} else {
|
|
||||||
v2rayConfig.outbounds.add(outbound)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateOutboundFragment(v2rayConfig)
|
|
||||||
return Pair(true, config.getServerAddressAndPort())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fakedns(v2rayConfig: V2rayConfig) {
|
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true
|
|
||||||
&& MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
|
|
||||||
) {
|
|
||||||
v2rayConfig.fakedns = listOf(V2rayConfig.FakednsBean())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun routing(v2rayConfig: V2rayConfig): Boolean {
|
|
||||||
try {
|
|
||||||
|
|
||||||
v2rayConfig.routing.domainStrategy =
|
|
||||||
MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY)
|
|
||||||
?: "IPIfNonMatch"
|
|
||||||
|
|
||||||
val rulesetItems = MmkvManager.decodeRoutingRulesets()
|
|
||||||
rulesetItems?.forEach { key ->
|
|
||||||
routingUserRule(key, v2rayConfig)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun routingUserRule(item: RulesetItem?, v2rayConfig: V2rayConfig) {
|
|
||||||
try {
|
|
||||||
if (item == null || !item.enabled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val rule = JsonUtil.fromJson(JsonUtil.toJson(item), RulesBean::class.java) ?: return
|
|
||||||
|
|
||||||
v2rayConfig.routing.rules.add(rule)
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun userRule2Domain(tag: String): ArrayList<String> {
|
|
||||||
val domain = ArrayList<String>()
|
|
||||||
|
|
||||||
val rulesetItems = MmkvManager.decodeRoutingRulesets()
|
|
||||||
rulesetItems?.forEach { key ->
|
|
||||||
if (key.enabled && key.outboundTag == tag && !key.domain.isNullOrEmpty()) {
|
|
||||||
key.domain?.forEach {
|
|
||||||
if (it != GEOSITE_PRIVATE
|
|
||||||
&& (it.startsWith("geosite:") || it.startsWith("domain:"))
|
|
||||||
) {
|
|
||||||
domain.add(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return domain
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun customLocalDns(v2rayConfig: V2rayConfig): Boolean {
|
|
||||||
try {
|
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true) {
|
|
||||||
val geositeCn = arrayListOf(GEOSITE_CN)
|
|
||||||
val proxyDomain = userRule2Domain(TAG_PROXY)
|
|
||||||
val directDomain = userRule2Domain(TAG_DIRECT)
|
|
||||||
// fakedns with all domains to make it always top priority
|
|
||||||
v2rayConfig.dns.servers?.add(
|
|
||||||
0,
|
|
||||||
V2rayConfig.DnsBean.ServersBean(
|
|
||||||
address = "fakedns",
|
|
||||||
domains = geositeCn.plus(proxyDomain).plus(directDomain)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DNS inbound
|
|
||||||
val remoteDns = SettingsManager.getRemoteDnsServers()
|
|
||||||
if (v2rayConfig.inbounds.none { e -> e.protocol == "dokodemo-door" && e.tag == "dns-in" }) {
|
|
||||||
val dnsInboundSettings = V2rayConfig.InboundBean.InSettingsBean(
|
|
||||||
address = if (Utils.isPureIpAddress(remoteDns.first())) remoteDns.first() else AppConfig.DNS_PROXY,
|
|
||||||
port = 53,
|
|
||||||
network = "tcp,udp"
|
|
||||||
)
|
|
||||||
|
|
||||||
val localDnsPort = Utils.parseInt(
|
|
||||||
MmkvManager.decodeSettingsString(AppConfig.PREF_LOCAL_DNS_PORT),
|
|
||||||
AppConfig.PORT_LOCAL_DNS.toInt()
|
|
||||||
)
|
|
||||||
v2rayConfig.inbounds.add(
|
|
||||||
V2rayConfig.InboundBean(
|
|
||||||
tag = "dns-in",
|
|
||||||
port = localDnsPort,
|
|
||||||
listen = LOOPBACK,
|
|
||||||
protocol = "dokodemo-door",
|
|
||||||
settings = dnsInboundSettings,
|
|
||||||
sniffing = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DNS outbound
|
|
||||||
if (v2rayConfig.outbounds.none { e -> e.protocol == "dns" && e.tag == "dns-out" }) {
|
|
||||||
v2rayConfig.outbounds.add(
|
|
||||||
V2rayConfig.OutboundBean(
|
|
||||||
protocol = "dns",
|
|
||||||
tag = "dns-out",
|
|
||||||
settings = null,
|
|
||||||
streamSettings = null,
|
|
||||||
mux = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DNS routing tag
|
|
||||||
v2rayConfig.routing.rules.add(
|
|
||||||
0, RulesBean(
|
|
||||||
inboundTag = arrayListOf("dns-in"),
|
|
||||||
outboundTag = "dns-out",
|
|
||||||
domain = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dns(v2rayConfig: V2rayConfig): Boolean {
|
|
||||||
try {
|
|
||||||
val hosts = mutableMapOf<String, Any>()
|
|
||||||
val servers = ArrayList<Any>()
|
|
||||||
|
|
||||||
//remote Dns
|
|
||||||
val remoteDns = SettingsManager.getRemoteDnsServers()
|
|
||||||
val proxyDomain = userRule2Domain(TAG_PROXY)
|
|
||||||
remoteDns.forEach {
|
|
||||||
servers.add(it)
|
|
||||||
}
|
|
||||||
if (proxyDomain.isNotEmpty()) {
|
|
||||||
servers.add(
|
|
||||||
V2rayConfig.DnsBean.ServersBean(
|
|
||||||
address = remoteDns.first(),
|
|
||||||
domains = proxyDomain,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// domestic DNS
|
|
||||||
val domesticDns = SettingsManager.getDomesticDnsServers()
|
|
||||||
val directDomain = userRule2Domain(TAG_DIRECT)
|
|
||||||
val isCnRoutingMode = directDomain.contains(GEOSITE_CN)
|
|
||||||
val geoipCn = arrayListOf(GEOIP_CN)
|
|
||||||
if (directDomain.isNotEmpty()) {
|
|
||||||
servers.add(
|
|
||||||
V2rayConfig.DnsBean.ServersBean(
|
|
||||||
address = domesticDns.first(),
|
|
||||||
domains = directDomain,
|
|
||||||
expectIPs = if (isCnRoutingMode) geoipCn else null,
|
|
||||||
skipFallback = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Utils.isPureIpAddress(domesticDns.first())) {
|
|
||||||
v2rayConfig.routing.rules.add(
|
|
||||||
0, RulesBean(
|
|
||||||
outboundTag = TAG_DIRECT,
|
|
||||||
port = "53",
|
|
||||||
ip = arrayListOf(domesticDns.first()),
|
|
||||||
domain = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
//User DNS hosts
|
|
||||||
try {
|
|
||||||
val userHosts = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
|
|
||||||
if (userHosts.isNotNullEmpty()) {
|
|
||||||
var userHostsMap = userHosts?.split(",")
|
|
||||||
?.filter { it.isNotEmpty() }
|
|
||||||
?.filter { it.contains(":") }
|
|
||||||
?.associate { it.split(":").let { (k, v) -> k to v } }
|
|
||||||
if (userHostsMap != null) hosts.putAll(userHostsMap)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
|
|
||||||
//block dns
|
|
||||||
val blkDomain = userRule2Domain(TAG_BLOCKED)
|
|
||||||
if (blkDomain.isNotEmpty()) {
|
|
||||||
hosts.putAll(blkDomain.map { it to LOOPBACK })
|
|
||||||
}
|
|
||||||
|
|
||||||
// hardcode googleapi rule to fix play store problems
|
|
||||||
hosts[GOOGLEAPIS_CN_DOMAIN] = GOOGLEAPIS_COM_DOMAIN
|
|
||||||
|
|
||||||
// hardcode popular Android Private DNS rule to fix localhost DNS problem
|
|
||||||
hosts[DNS_ALIDNS_DOMAIN] = DNS_ALIDNS_ADDRESSES
|
|
||||||
hosts[DNS_CLOUDFLARE_DOMAIN] = DNS_CLOUDFLARE_ADDRESSES
|
|
||||||
hosts[DNS_DNSPOD_DOMAIN] = DNS_DNSPOD_ADDRESSES
|
|
||||||
hosts[DNS_GOOGLE_DOMAIN] = DNS_GOOGLE_ADDRESSES
|
|
||||||
hosts[DNS_QUAD9_DOMAIN] = DNS_QUAD9_ADDRESSES
|
|
||||||
hosts[DNS_YANDEX_DOMAIN] = DNS_YANDEX_ADDRESSES
|
|
||||||
|
|
||||||
|
|
||||||
// DNS dns
|
|
||||||
v2rayConfig.dns = V2rayConfig.DnsBean(
|
|
||||||
servers = servers,
|
|
||||||
hosts = hosts
|
|
||||||
)
|
|
||||||
|
|
||||||
// DNS routing
|
|
||||||
if (Utils.isPureIpAddress(remoteDns.first())) {
|
|
||||||
v2rayConfig.routing.rules.add(
|
|
||||||
0, RulesBean(
|
|
||||||
outboundTag = TAG_PROXY,
|
|
||||||
port = "53",
|
|
||||||
ip = arrayListOf(remoteDns.first()),
|
|
||||||
domain = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateOutboundWithGlobalSettings(outbound: V2rayConfig.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)
|
|
||||||
) {
|
|
||||||
muxEnabled = false
|
|
||||||
} else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) {
|
|
||||||
muxEnabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (muxEnabled == true) {
|
|
||||||
outbound.mux?.enabled = true
|
|
||||||
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
|
|
||||||
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
|
|
||||||
outbound.mux?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC,"reject")
|
|
||||||
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.vnext?.first()?.users?.first()?.flow?.isNotEmpty() == true) {
|
|
||||||
outbound.mux?.concurrency = -1
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
outbound.mux?.enabled = false
|
|
||||||
outbound.mux?.concurrency = -1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
|
||||||
var localTunAddr = if (outbound.settings?.address == null) {
|
|
||||||
listOf(WIREGUARD_LOCAL_ADDRESS_V4, WIREGUARD_LOCAL_ADDRESS_V6)
|
|
||||||
} else {
|
|
||||||
outbound.settings?.address as List<*>
|
|
||||||
}
|
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) != true) {
|
|
||||||
localTunAddr = listOf(localTunAddr.first())
|
|
||||||
}
|
|
||||||
outbound.settings?.address = localTunAddr
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outbound.streamSettings?.network == DEFAULT_NETWORK
|
|
||||||
&& outbound.streamSettings?.tcpSettings?.header?.type == HEADER_TYPE_HTTP
|
|
||||||
) {
|
|
||||||
val path = outbound.streamSettings?.tcpSettings?.header?.request?.path
|
|
||||||
val host = outbound.streamSettings?.tcpSettings?.header?.request?.headers?.Host
|
|
||||||
|
|
||||||
val requestString: String by lazy {
|
|
||||||
"""{"version":"1.1","method":"GET","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.122 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate"],"Connection":["keep-alive"],"Pragma":"no-cache"}}"""
|
|
||||||
}
|
|
||||||
outbound.streamSettings?.tcpSettings?.header?.request = JsonUtil.fromJson(
|
|
||||||
requestString,
|
|
||||||
V2rayConfig.OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean::class.java
|
|
||||||
)
|
|
||||||
outbound.streamSettings?.tcpSettings?.header?.request?.path =
|
|
||||||
if (path.isNullOrEmpty()) {
|
|
||||||
listOf("/")
|
|
||||||
} else {
|
|
||||||
path
|
|
||||||
}
|
|
||||||
outbound.streamSettings?.tcpSettings?.header?.request?.headers?.Host = host
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateOutboundFragment(v2rayConfig: V2rayConfig): Boolean {
|
|
||||||
try {
|
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == false) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (v2rayConfig.outbounds[0].streamSettings?.security != AppConfig.TLS
|
|
||||||
&& v2rayConfig.outbounds[0].streamSettings?.security != AppConfig.REALITY
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
val fragmentOutbound =
|
|
||||||
V2rayConfig.OutboundBean(
|
|
||||||
protocol = PROTOCOL_FREEDOM,
|
|
||||||
tag = TAG_FRAGMENT,
|
|
||||||
mux = null
|
|
||||||
)
|
|
||||||
|
|
||||||
var packets =
|
|
||||||
MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS) ?: "tlshello"
|
|
||||||
if (v2rayConfig.outbounds[0].streamSettings?.security == AppConfig.REALITY
|
|
||||||
&& packets == "tlshello"
|
|
||||||
) {
|
|
||||||
packets = "1-3"
|
|
||||||
} else if (v2rayConfig.outbounds[0].streamSettings?.security == AppConfig.TLS
|
|
||||||
&& packets != "tlshello"
|
|
||||||
) {
|
|
||||||
packets = "tlshello"
|
|
||||||
}
|
|
||||||
|
|
||||||
fragmentOutbound.settings = V2rayConfig.OutboundBean.OutSettingsBean(
|
|
||||||
fragment = V2rayConfig.OutboundBean.OutSettingsBean.FragmentBean(
|
|
||||||
packets = packets,
|
|
||||||
length = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH)
|
|
||||||
?: "50-100",
|
|
||||||
interval = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL)
|
|
||||||
?: "10-20"
|
|
||||||
),
|
|
||||||
noises = listOf(
|
|
||||||
V2rayConfig.OutboundBean.OutSettingsBean.NoiseBean(
|
|
||||||
type = "rand",
|
|
||||||
packet = "10-20",
|
|
||||||
delay = "10-16",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
fragmentOutbound.streamSettings = V2rayConfig.OutboundBean.StreamSettingsBean(
|
|
||||||
sockopt = V2rayConfig.OutboundBean.StreamSettingsBean.SockoptBean(
|
|
||||||
TcpNoDelay = true,
|
|
||||||
mark = 255
|
|
||||||
)
|
|
||||||
)
|
|
||||||
v2rayConfig.outbounds.add(fragmentOutbound)
|
|
||||||
|
|
||||||
//proxy chain
|
|
||||||
v2rayConfig.outbounds[0].streamSettings?.sockopt =
|
|
||||||
V2rayConfig.OutboundBean.StreamSettingsBean.SockoptBean(
|
|
||||||
dialerProxy = TAG_FRAGMENT
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun moreOutbounds(
|
|
||||||
v2rayConfig: V2rayConfig,
|
|
||||||
subscriptionId: String,
|
|
||||||
isPlugin: Boolean
|
|
||||||
): Pair<Boolean, String> {
|
|
||||||
val returnPair = Pair(false, "")
|
|
||||||
var domainPort: String = ""
|
|
||||||
|
|
||||||
if (isPlugin) {
|
|
||||||
return returnPair
|
|
||||||
}
|
|
||||||
//fragment proxy
|
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == true) {
|
|
||||||
return returnPair
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subscriptionId.isEmpty()) {
|
|
||||||
return returnPair
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return returnPair
|
|
||||||
|
|
||||||
//current proxy
|
|
||||||
val outbound = v2rayConfig.outbounds[0]
|
|
||||||
|
|
||||||
//Previous proxy
|
|
||||||
val prevNode = SettingsManager.getServerViaRemarks(subItem.prevProfile)
|
|
||||||
if (prevNode != null) {
|
|
||||||
val prevOutbound = getProxyOutbound(prevNode)
|
|
||||||
if (prevOutbound != null) {
|
|
||||||
updateOutboundWithGlobalSettings(prevOutbound)
|
|
||||||
prevOutbound.tag = TAG_PROXY + "2"
|
|
||||||
v2rayConfig.outbounds.add(prevOutbound)
|
|
||||||
outbound.streamSettings?.sockopt =
|
|
||||||
V2rayConfig.OutboundBean.StreamSettingsBean.SockoptBean(
|
|
||||||
dialerProxy = prevOutbound.tag
|
|
||||||
)
|
|
||||||
domainPort = prevNode.getServerAddressAndPort()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//Next proxy
|
|
||||||
val nextNode = SettingsManager.getServerViaRemarks(subItem.nextProfile)
|
|
||||||
if (nextNode != null) {
|
|
||||||
val nextOutbound = getProxyOutbound(nextNode)
|
|
||||||
if (nextOutbound != null) {
|
|
||||||
updateOutboundWithGlobalSettings(nextOutbound)
|
|
||||||
nextOutbound.tag = TAG_PROXY
|
|
||||||
v2rayConfig.outbounds.add(0, nextOutbound)
|
|
||||||
outbound.tag = TAG_PROXY + "1"
|
|
||||||
nextOutbound.streamSettings?.sockopt =
|
|
||||||
V2rayConfig.OutboundBean.StreamSettingsBean.SockoptBean(
|
|
||||||
dialerProxy = outbound.tag
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
return returnPair
|
|
||||||
}
|
|
||||||
|
|
||||||
if (domainPort.isNotEmpty()) {
|
|
||||||
return Pair(true, domainPort)
|
|
||||||
}
|
|
||||||
return returnPair
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the proxy outbound configuration for the given profile item.
|
|
||||||
*
|
|
||||||
* @param profileItem The profile item for which to get the proxy outbound configuration.
|
|
||||||
* @return The proxy outbound configuration as a V2rayConfig.OutboundBean, or null if not found.
|
|
||||||
*/
|
|
||||||
fun getProxyOutbound(profileItem: ProfileItem): V2rayConfig.OutboundBean? {
|
|
||||||
return when (profileItem.configType) {
|
|
||||||
EConfigType.VMESS -> VmessFmt.toOutbound(profileItem)
|
|
||||||
EConfigType.CUSTOM -> null
|
|
||||||
EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toOutbound(profileItem)
|
|
||||||
EConfigType.SOCKS -> SocksFmt.toOutbound(profileItem)
|
|
||||||
EConfigType.VLESS -> VlessFmt.toOutbound(profileItem)
|
|
||||||
EConfigType.TROJAN -> TrojanFmt.toOutbound(profileItem)
|
|
||||||
EConfigType.WIREGUARD -> WireguardFmt.toOutbound(profileItem)
|
|
||||||
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toOutbound(profileItem)
|
|
||||||
EConfigType.HTTP -> HttpFmt.toOutbound(profileItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package com.v2ray.ang.handler
|
||||||
|
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.dto.entities.WebDavConfig
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
object WebDavManager {
|
||||||
|
private var cfg: WebDavConfig? = null
|
||||||
|
private var client: OkHttpClient? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the WebDAV manager with a configuration and build an OkHttp client.
|
||||||
|
*
|
||||||
|
* @param config WebDavConfig containing baseUrl, credentials, remoteBasePath and timeoutSeconds.
|
||||||
|
*/
|
||||||
|
fun init(config: WebDavConfig) {
|
||||||
|
cfg = config
|
||||||
|
client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
|
||||||
|
.callTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a local file to a remote file name under the configured remoteBasePath.
|
||||||
|
* The provided `remoteFileName` should be a file name (e.g. "backup_ng.zip").
|
||||||
|
* The method will attempt to create parent directories via MKCOL before PUT.
|
||||||
|
*
|
||||||
|
* @param localFile File to upload.
|
||||||
|
* @param remoteFileName Remote file name relative to configured remoteBasePath.
|
||||||
|
* @return true if upload succeeded (HTTP 2xx), false otherwise.
|
||||||
|
*/
|
||||||
|
suspend fun uploadFile(localFile: File, remoteFileName: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val remote = buildRemoteUrl(remoteFileName)
|
||||||
|
try {
|
||||||
|
val cl = client ?: return@withContext false
|
||||||
|
|
||||||
|
// Ensure parent directories exist
|
||||||
|
val dirPath = remote.substringBeforeLast('/')
|
||||||
|
if (dirPath != remote) {
|
||||||
|
ensureRemoteDirs(dirPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine content type based on file extension
|
||||||
|
val mediaType = when (localFile.extension.lowercase()) {
|
||||||
|
"zip" -> "application/zip"
|
||||||
|
"json" -> "application/json"
|
||||||
|
"txt" -> "text/plain"
|
||||||
|
else -> "application/octet-stream"
|
||||||
|
}.toMediaTypeOrNull()
|
||||||
|
|
||||||
|
val body = localFile.asRequestBody(mediaType)
|
||||||
|
val req = applyAuth(Request.Builder().url(remote).put(body)).build()
|
||||||
|
cl.newCall(req).execute().use { resp ->
|
||||||
|
val success = resp.isSuccessful
|
||||||
|
if (success) {
|
||||||
|
LogUtil.i(AppConfig.TAG, "WebDAV upload success: $remote")
|
||||||
|
} else {
|
||||||
|
LogUtil.e(AppConfig.TAG, "WebDAV upload failed: $remote (HTTP ${resp.code})")
|
||||||
|
}
|
||||||
|
return@withContext success
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a remote file (relative to configured remoteBasePath) into a local file.
|
||||||
|
*
|
||||||
|
* @param remoteFileName Remote file name relative to configured remoteBasePath.
|
||||||
|
* @param destFile Local destination file to write to.
|
||||||
|
* @return true if download and write succeeded, false otherwise.
|
||||||
|
*/
|
||||||
|
suspend fun downloadFile(remoteFileName: String, destFile: File): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val remote = buildRemoteUrl(remoteFileName)
|
||||||
|
try {
|
||||||
|
val cl = client ?: return@withContext false
|
||||||
|
val req = applyAuth(Request.Builder().url(remote).get()).build()
|
||||||
|
cl.newCall(req).execute().use { resp ->
|
||||||
|
if (!resp.isSuccessful) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "WebDAV download failed: $remote (HTTP ${resp.code})")
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.body.byteStream().use { input ->
|
||||||
|
destFile.parentFile?.mkdirs()
|
||||||
|
FileOutputStream(destFile).use { fos ->
|
||||||
|
input.copyTo(fos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LogUtil.i(AppConfig.TAG, "WebDAV download success: $remote")
|
||||||
|
return@withContext true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a full remote URL by combining the configured base URL, the configured
|
||||||
|
* remote base path and a file name provided by the caller.
|
||||||
|
*
|
||||||
|
* Example: baseUrl="https://example.com/remote.php/dav", remoteBasePath="backups",
|
||||||
|
* remoteFileName="backup_ng.zip" => "https://example.com/remote.php/dav/backups/backup_ng.zip"
|
||||||
|
*
|
||||||
|
* @param remoteFileName A file name relative to the configured remoteBasePath (no leading slash required).
|
||||||
|
* @return Full URL string used for HTTP operations.
|
||||||
|
*/
|
||||||
|
private fun buildRemoteUrl(remoteFileName: String): String {
|
||||||
|
val base = cfg?.baseUrl?.trimEnd('/') ?: ""
|
||||||
|
// Use configured remoteBasePath when not empty; otherwise fallback to AppConfig.WEBDAV_BACKUP_DIR
|
||||||
|
val basePathConfigured = cfg?.remoteBasePath?.trim('/')?.takeIf { it.isNotEmpty() }
|
||||||
|
val basePath = basePathConfigured ?: AppConfig.WEBDAV_BACKUP_DIR
|
||||||
|
val rel = remoteFileName.trimStart('/')
|
||||||
|
return if (basePath.isEmpty()) "$base/$rel" else "$base/$basePath/$rel"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply HTTP Basic authentication headers to the given request builder when
|
||||||
|
* username is configured in `cfg`.
|
||||||
|
*
|
||||||
|
* @param builder OkHttp Request.Builder to modify.
|
||||||
|
* @return The same builder instance with Authorization header applied if credentials exist.
|
||||||
|
*/
|
||||||
|
private fun applyAuth(builder: Request.Builder): Request.Builder {
|
||||||
|
val username = cfg?.username
|
||||||
|
val password = cfg?.password
|
||||||
|
if (!username.isNullOrEmpty()) {
|
||||||
|
builder.header("Authorization", Credentials.basic(username, password ?: ""))
|
||||||
|
}
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure that each directory segment in the given directory URL exists on the
|
||||||
|
* WebDAV server. This issues MKCOL requests for each segment in a best-effort
|
||||||
|
* manner and ignores errors for segments that already exist.
|
||||||
|
*
|
||||||
|
* @param dirUrl Absolute URL to the directory that should exist (e.g. https://.../backups)
|
||||||
|
*/
|
||||||
|
private fun ensureRemoteDirs(dirUrl: String) {
|
||||||
|
try {
|
||||||
|
val cl = client ?: return
|
||||||
|
val url = URL(dirUrl)
|
||||||
|
val segments = url.path.split("/").filter { it.isNotEmpty() }
|
||||||
|
var accum = ""
|
||||||
|
for (seg in segments) {
|
||||||
|
accum += "/$seg"
|
||||||
|
val mkUrl = URL(url.protocol, url.host, if (url.port == -1) -1 else url.port, accum).toString()
|
||||||
|
try {
|
||||||
|
val req = applyAuth(Request.Builder().url(mkUrl).method("MKCOL", null)).build()
|
||||||
|
cl.newCall(req).execute().use { resp ->
|
||||||
|
// 201 Created or 405 Method Not Allowed (already exists) are acceptable
|
||||||
|
if (resp.code != 201 && resp.code != 405 && resp.code != 409) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// best-effort, continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "WebDAV ensureRemoteDirs error", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.v2ray.ang.helper
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
fileChooserCallback?.invoke(uri)
|
||||||
|
} else {
|
||||||
|
fileChooserCallback?.invoke(null)
|
||||||
|
}
|
||||||
|
fileChooserCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private val documentCreateLauncher: ActivityResultLauncher<String> =
|
||||||
|
activity.registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
|
||||||
|
documentCreateCallback?.invoke(uri)
|
||||||
|
documentCreateCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch file chooser with ACTION_GET_CONTENT intent to select an existing file.
|
||||||
|
*
|
||||||
|
* @param mimeType MIME type filter for files
|
||||||
|
* @param onResult Callback invoked with the selected file URI (null if cancelled)
|
||||||
|
*/
|
||||||
|
fun launch(
|
||||||
|
mimeType: String = "*/*",
|
||||||
|
onResult: (Uri?) -> Unit
|
||||||
|
) {
|
||||||
|
fileChooserCallback = onResult
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||||
|
type = mimeType
|
||||||
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fileChooserLauncher.launch(
|
||||||
|
Intent.createChooser(intent, activity.getString(R.string.title_file_chooser))
|
||||||
|
)
|
||||||
|
} catch (ex: ActivityNotFoundException) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "File chooser activity not found", ex)
|
||||||
|
activity.toast(R.string.toast_require_file_manager)
|
||||||
|
fileChooserCallback?.invoke(null)
|
||||||
|
fileChooserCallback = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch document creator to create a new file at user-selected location.
|
||||||
|
*
|
||||||
|
* @param fileName Default file name for the new document
|
||||||
|
* @param onResult Callback invoked with the created file URI (null if cancelled)
|
||||||
|
*/
|
||||||
|
fun createDocument(
|
||||||
|
fileName: String,
|
||||||
|
onResult: (Uri?) -> Unit
|
||||||
|
) {
|
||||||
|
documentCreateCallback = onResult
|
||||||
|
try {
|
||||||
|
documentCreateLauncher.launch(fileName)
|
||||||
|
} catch (ex: ActivityNotFoundException) {
|
||||||
|
LogUtil.e(AppConfig.TAG, "Document creator activity not found", ex)
|
||||||
|
activity.toast(R.string.toast_require_file_manager)
|
||||||
|
documentCreateCallback?.invoke(null)
|
||||||
|
documentCreateCallback = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.v2ray.ang.helper
|
||||||
|
|
||||||
|
import androidx.preference.PreferenceDataStore
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
|
import com.v2ray.ang.handler.SettingsChangeManager
|
||||||
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
|
||||||
|
* This ensures that all Preference UI operations read/write directly from/to MMKV,
|
||||||
|
* avoiding inconsistencies between SharedPreferences and MMKV.
|
||||||
|
*/
|
||||||
|
class MmkvPreferenceDataStore : PreferenceDataStore() {
|
||||||
|
|
||||||
|
override fun putString(key: String, value: String?) {
|
||||||
|
MmkvManager.encodeSettings(key, value)
|
||||||
|
notifySettingChanged(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getString(key: String, defaultValue: String?): String? {
|
||||||
|
return MmkvManager.decodeSettingsString(key, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putInt(key: String, value: Int) {
|
||||||
|
MmkvManager.encodeSettings(key, value)
|
||||||
|
notifySettingChanged(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getInt(key: String, defaultValue: Int): Int {
|
||||||
|
return MmkvManager.decodeSettingsInt(key, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putLong(key: String, value: Long) {
|
||||||
|
MmkvManager.encodeSettings(key, value)
|
||||||
|
notifySettingChanged(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLong(key: String, defaultValue: Long): Long {
|
||||||
|
return MmkvManager.decodeSettingsLong(key, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putFloat(key: String, value: Float) {
|
||||||
|
MmkvManager.encodeSettings(key, value)
|
||||||
|
notifySettingChanged(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFloat(key: String, defaultValue: Float): Float {
|
||||||
|
return MmkvManager.decodeSettingsFloat(key, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putBoolean(key: String, value: Boolean) {
|
||||||
|
MmkvManager.encodeSettings(key, value)
|
||||||
|
notifySettingChanged(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getBoolean(key: String, defaultValue: Boolean): Boolean {
|
||||||
|
return MmkvManager.decodeSettingsBool(key, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putStringSet(key: String, values: MutableSet<String>?) {
|
||||||
|
if (values == null) {
|
||||||
|
MmkvManager.encodeSettings(key, null as String?)
|
||||||
|
} else {
|
||||||
|
MmkvManager.encodeSettings(key, values)
|
||||||
|
}
|
||||||
|
notifySettingChanged(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getStringSet(key: String, defaultValues: MutableSet<String>?): MutableSet<String>? {
|
||||||
|
return MmkvManager.decodeSettingsStringSet(key) ?: defaultValues
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal helper: notify other modules about setting changes
|
||||||
|
private fun notifySettingChanged(key: String) {
|
||||||
|
if (key == AppConfig.PREF_LOGLEVEL) {
|
||||||
|
LogUtil.refreshLogLevel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call SettingsManager.setNightMode if UI mode changed
|
||||||
|
if (key == AppConfig.PREF_UI_MODE_NIGHT) {
|
||||||
|
SettingsManager.setNightMode()
|
||||||
|
}
|
||||||
|
// Notify listeners that require service restart or reinit
|
||||||
|
SettingsChangeManager.makeRestartService()
|
||||||
|
SettingsChangeManager.makeSetupGroupTab()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.v2ray.ang.helper
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.v2ray.ang.R
|
||||||
|
import com.v2ray.ang.enums.PermissionType
|
||||||
|
import com.v2ray.ang.extension.toast
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for requesting permissions.
|
||||||
|
*/
|
||||||
|
class PermissionHelper(private val activity: AppCompatActivity) {
|
||||||
|
private var permissionCallback: ((Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
private val permissionLauncher: ActivityResultLauncher<String> =
|
||||||
|
activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
|
||||||
|
permissionCallback?.invoke(isGranted)
|
||||||
|
permissionCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the permission and request it if not granted.
|
||||||
|
*
|
||||||
|
* @param permissionType the type of permission
|
||||||
|
* @param onGranted called when permission is granted (called immediately if already granted)
|
||||||
|
*/
|
||||||
|
fun request(permissionType: PermissionType, onGranted: () -> Unit) {
|
||||||
|
val permission = permissionType.getPermission()
|
||||||
|
if (ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
onGranted()
|
||||||
|
} else {
|
||||||
|
permissionCallback = { isGranted ->
|
||||||
|
if (isGranted) {
|
||||||
|
onGranted()
|
||||||
|
} else {
|
||||||
|
val message = "${activity.getString(R.string.toast_permission_denied)} ${permissionType.getLabel()}"
|
||||||
|
activity.toast(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
permissionLauncher.launch(permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.v2ray.ang.helper
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import com.v2ray.ang.ui.ScannerActivity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for scanning QR codes.
|
||||||
|
*
|
||||||
|
* This class encapsulates the logic for launching the QR code scanner activity
|
||||||
|
* and handling the scan result.
|
||||||
|
*/
|
||||||
|
class QRCodeScannerHelper(private val activity: AppCompatActivity) {
|
||||||
|
private var scanCallback: ((String?) -> Unit)? = null
|
||||||
|
|
||||||
|
private val scanLauncher: ActivityResultLauncher<Intent> =
|
||||||
|
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||||
|
val scanResult = result.data?.getStringExtra("SCAN_RESULT")
|
||||||
|
scanCallback?.invoke(scanResult)
|
||||||
|
} else {
|
||||||
|
scanCallback?.invoke(null)
|
||||||
|
}
|
||||||
|
scanCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch the QR code scanner activity.
|
||||||
|
*
|
||||||
|
* @param onResult Callback invoked with the scan result (null if cancelled or failed)
|
||||||
|
*/
|
||||||
|
fun launch(onResult: (String?) -> Unit) {
|
||||||
|
scanCallback = onResult
|
||||||
|
scanLauncher.launch(Intent(activity, ScannerActivity::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,12 +34,12 @@ import kotlin.math.sign
|
|||||||
*
|
*
|
||||||
* @author Paul Burke (ipaulpro)
|
* @author Paul Burke (ipaulpro)
|
||||||
*/
|
*/
|
||||||
class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter) : ItemTouchHelper.Callback() {
|
class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter, private val allowSwipe: Boolean = false) : ItemTouchHelper.Callback() {
|
||||||
private var mReturnAnimator: ValueAnimator? = null
|
private var mReturnAnimator: ValueAnimator? = null
|
||||||
|
|
||||||
override fun isLongPressDragEnabled(): Boolean = true
|
override fun isLongPressDragEnabled(): Boolean = true
|
||||||
|
|
||||||
override fun isItemViewSwipeEnabled(): Boolean = true
|
override fun isItemViewSwipeEnabled(): Boolean = allowSwipe
|
||||||
|
|
||||||
override fun getMovementFlags(
|
override fun getMovementFlags(
|
||||||
recyclerView: RecyclerView,
|
recyclerView: RecyclerView,
|
||||||
@@ -49,10 +49,10 @@ class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter
|
|||||||
val swipeFlags: Int
|
val swipeFlags: Int
|
||||||
if (recyclerView.layoutManager is GridLayoutManager) {
|
if (recyclerView.layoutManager is GridLayoutManager) {
|
||||||
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
|
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
|
||||||
swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END
|
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
|
||||||
} else {
|
} else {
|
||||||
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
|
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
|
||||||
swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END
|
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
|
||||||
}
|
}
|
||||||
return makeMovementFlags(dragFlags, swipeFlags)
|
return makeMovementFlags(dragFlags, swipeFlags)
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,7 @@ class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter
|
|||||||
addUpdateListener { animation ->
|
addUpdateListener { animation ->
|
||||||
val value = animation.animatedValue as Float
|
val value = animation.animatedValue as Float
|
||||||
viewHolder.itemView.translationX = value
|
viewHolder.itemView.translationX = value
|
||||||
viewHolder.itemView.alpha = (1f - abs(value) / (viewHolder.itemView.width * SWIPE_THRESHOLD))
|
viewHolder.itemView.alpha = 1f - abs(value) / (viewHolder.itemView.width * SWIPE_THRESHOLD)
|
||||||
}
|
}
|
||||||
interpolator = DecelerateInterpolator()
|
interpolator = DecelerateInterpolator()
|
||||||
duration = ANIMATION_DURATION
|
duration = ANIMATION_DURATION
|
||||||
@@ -144,4 +144,4 @@ class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter
|
|||||||
private const val SWIPE_THRESHOLD = 0.25f
|
private const val SWIPE_THRESHOLD = 0.25f
|
||||||
private const val ANIMATION_DURATION: Long = 200
|
private const val ANIMATION_DURATION: Long = 200
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
/******************************************************************************
|
|
||||||
* *
|
|
||||||
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
|
|
||||||
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
|
|
||||||
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
|
|
||||||
* *
|
|
||||||
* This program is free software: you can redistribute it and/or modify *
|
|
||||||
* it under the terms of the GNU General Public License as published by *
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or *
|
|
||||||
* (at your option) any later version. *
|
|
||||||
* *
|
|
||||||
* This program is distributed in the hope that it will be useful, *
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
|
||||||
* GNU General Public License for more details. *
|
|
||||||
* *
|
|
||||||
* You should have received a copy of the GNU General Public License *
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
|
||||||
* *
|
|
||||||
******************************************************************************/
|
|
||||||
|
|
||||||
package com.v2ray.ang.plugin
|
|
||||||
|
|
||||||
import android.content.pm.ResolveInfo
|
|
||||||
|
|
||||||
class NativePlugin(resolveInfo: ResolveInfo) : ResolvedPlugin(resolveInfo) {
|
|
||||||
init {
|
|
||||||
check(resolveInfo.providerInfo != null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override val componentInfo get() = resolveInfo.providerInfo!!
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/******************************************************************************
|
|
||||||
* *
|
|
||||||
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
|
|
||||||
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
|
|
||||||
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
|
|
||||||
* *
|
|
||||||
* This program is free software: you can redistribute it and/or modify *
|
|
||||||
* it under the terms of the GNU General Public License as published by *
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or *
|
|
||||||
* (at your option) any later version. *
|
|
||||||
* *
|
|
||||||
* This program is distributed in the hope that it will be useful, *
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
|
||||||
* GNU General Public License for more details. *
|
|
||||||
* *
|
|
||||||
* You should have received a copy of the GNU General Public License *
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
|
||||||
* *
|
|
||||||
******************************************************************************/
|
|
||||||
|
|
||||||
package com.v2ray.ang.plugin
|
|
||||||
|
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
|
|
||||||
abstract class Plugin {
|
|
||||||
abstract val id: String
|
|
||||||
abstract val label: CharSequence
|
|
||||||
abstract val version: Int
|
|
||||||
abstract val versionName: String
|
|
||||||
open val icon: Drawable? get() = null
|
|
||||||
open val defaultConfig: String? get() = null
|
|
||||||
open val packageName: String get() = ""
|
|
||||||
open val directBootAware: Boolean get() = true
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (this === other) return true
|
|
||||||
if (javaClass != other?.javaClass) return false
|
|
||||||
return id == (other as Plugin).id
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode() = id.hashCode()
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
/******************************************************************************
|
|
||||||
* *
|
|
||||||
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
|
|
||||||
* *
|
|
||||||
* This program is free software: you can redistribute it and/or modify *
|
|
||||||
* it under the terms of the GNU General Public License as published by *
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or *
|
|
||||||
* (at your option) any later version. *
|
|
||||||
* *
|
|
||||||
* This program is distributed in the hope that it will be useful, *
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
|
||||||
* GNU General Public License for more details. *
|
|
||||||
* *
|
|
||||||
* You should have received a copy of the GNU General Public License *
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
|
||||||
* *
|
|
||||||
******************************************************************************/
|
|
||||||
|
|
||||||
package com.v2ray.ang.plugin
|
|
||||||
|
|
||||||
object PluginContract {
|
|
||||||
|
|
||||||
const val ACTION_NATIVE_PLUGIN = "io.nekohasekai.sagernet.plugin.ACTION_NATIVE_PLUGIN"
|
|
||||||
const val EXTRA_ENTRY = "io.nekohasekai.sagernet.plugin.EXTRA_ENTRY"
|
|
||||||
const val METADATA_KEY_ID = "io.nekohasekai.sagernet.plugin.id"
|
|
||||||
const val METADATA_KEY_EXECUTABLE_PATH = "io.nekohasekai.sagernet.plugin.executable_path"
|
|
||||||
const val METHOD_GET_EXECUTABLE = "sagernet:getExecutable"
|
|
||||||
|
|
||||||
const val COLUMN_PATH = "path"
|
|
||||||
const val COLUMN_MODE = "mode"
|
|
||||||
const val SCHEME = "plugin"
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/******************************************************************************
|
|
||||||
* *
|
|
||||||
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
|
|
||||||
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
|
|
||||||
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
|
|
||||||
* *
|
|
||||||
* This program is free software: you can redistribute it and/or modify *
|
|
||||||
* it under the terms of the GNU General Public License as published by *
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or *
|
|
||||||
* (at your option) any later version. *
|
|
||||||
* *
|
|
||||||
* This program is distributed in the hope that it will be useful, *
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
|
||||||
* GNU General Public License for more details. *
|
|
||||||
* *
|
|
||||||
* You should have received a copy of the GNU General Public License *
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
|
||||||
* *
|
|
||||||
******************************************************************************/
|
|
||||||
|
|
||||||
package com.v2ray.ang.plugin
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import com.v2ray.ang.AngApplication
|
|
||||||
|
|
||||||
class PluginList : ArrayList<Plugin>() {
|
|
||||||
init {
|
|
||||||
addAll(
|
|
||||||
AngApplication.application.packageManager.queryIntentContentProviders(
|
|
||||||
Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA
|
|
||||||
)
|
|
||||||
.filter { it.providerInfo.exported }.map { NativePlugin(it) })
|
|
||||||
}
|
|
||||||
|
|
||||||
val lookup = mutableMapOf<String, Plugin>().apply {
|
|
||||||
for (plugin in this@PluginList.toList()) {
|
|
||||||
fun check(old: Plugin?) {
|
|
||||||
if (old != null && old != plugin) {
|
|
||||||
this@PluginList.remove(old)
|
|
||||||
}
|
|
||||||
/* if (old != null && old !== plugin) {
|
|
||||||
val packages = this@PluginList.filter { it.id == plugin.id }
|
|
||||||
.joinToString { it.packageName }
|
|
||||||
val message = "Conflicting plugins found from: $packages"
|
|
||||||
Toast.makeText(SagerNet.application, message, Toast.LENGTH_LONG).show()
|
|
||||||
throw IllegalStateException(message)
|
|
||||||
}*/
|
|
||||||
}
|
|
||||||
check(put(plugin.id, plugin))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
/******************************************************************************
|
|
||||||
* *
|
|
||||||
* Copyright (C) 2021 by nekohasekai <contact-AngApplication@sekai.icu> *
|
|
||||||
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
|
|
||||||
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
|
|
||||||
* *
|
|
||||||
* This program is free software: you can redistribute it and/or modify *
|
|
||||||
* it under the terms of the GNU General Public License as published by *
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or *
|
|
||||||
* (at your option) any later version. *
|
|
||||||
* *
|
|
||||||
* This program is distributed in the hope that it will be useful, *
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
|
||||||
* GNU General Public License for more details. *
|
|
||||||
* *
|
|
||||||
* You should have received a copy of the GNU General Public License *
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
|
||||||
* *
|
|
||||||
******************************************************************************/
|
|
||||||
|
|
||||||
package com.v2ray.ang.plugin
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ComponentInfo
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.content.pm.ProviderInfo
|
|
||||||
import android.database.Cursor
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.system.Os
|
|
||||||
import androidx.core.os.bundleOf
|
|
||||||
import com.v2ray.ang.AngApplication
|
|
||||||
import com.v2ray.ang.extension.listenForPackageChanges
|
|
||||||
import com.v2ray.ang.extension.toast
|
|
||||||
import com.v2ray.ang.plugin.PluginContract.METADATA_KEY_ID
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileNotFoundException
|
|
||||||
|
|
||||||
object PluginManager {
|
|
||||||
|
|
||||||
class PluginNotFoundException(val plugin: String) : FileNotFoundException(plugin)
|
|
||||||
|
|
||||||
private var receiver: BroadcastReceiver? = null
|
|
||||||
private var cachedPlugins: PluginList? = null
|
|
||||||
fun fetchPlugins() = synchronized(this) {
|
|
||||||
if (receiver == null) receiver = AngApplication.application.listenForPackageChanges {
|
|
||||||
synchronized(this) {
|
|
||||||
receiver = null
|
|
||||||
cachedPlugins = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cachedPlugins == null) cachedPlugins = PluginList()
|
|
||||||
cachedPlugins!!
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildUri(id: String, authority: String) = Uri.Builder()
|
|
||||||
.scheme(PluginContract.SCHEME)
|
|
||||||
.authority(authority)
|
|
||||||
.path("/$id")
|
|
||||||
.build()
|
|
||||||
|
|
||||||
data class InitResult(
|
|
||||||
val path: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Throws(Throwable::class)
|
|
||||||
fun init(pluginId: String): InitResult? {
|
|
||||||
if (pluginId.isEmpty()) return null
|
|
||||||
var throwable: Throwable? = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
val result = initNative(pluginId)
|
|
||||||
if (result != null) return result
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
if (throwable == null) throwable = t //Logs.w(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw throwable ?: PluginNotFoundException(pluginId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initNative(pluginId: String): InitResult? {
|
|
||||||
var flags = PackageManager.GET_META_DATA
|
|
||||||
if (Build.VERSION.SDK_INT >= 24) {
|
|
||||||
flags =
|
|
||||||
flags or PackageManager.MATCH_DIRECT_BOOT_UNAWARE or PackageManager.MATCH_DIRECT_BOOT_AWARE
|
|
||||||
}
|
|
||||||
var providers = AngApplication.application.packageManager.queryIntentContentProviders(
|
|
||||||
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "com.github.dyhkwong.AngApplication")), flags
|
|
||||||
)
|
|
||||||
.filter { it.providerInfo.exported }
|
|
||||||
if (providers.isEmpty()) {
|
|
||||||
providers = AngApplication.application.packageManager.queryIntentContentProviders(
|
|
||||||
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "io.nekohasekai.AngApplication")), flags
|
|
||||||
)
|
|
||||||
.filter { it.providerInfo.exported }
|
|
||||||
}
|
|
||||||
if (providers.isEmpty()) {
|
|
||||||
providers = AngApplication.application.packageManager.queryIntentContentProviders(
|
|
||||||
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "moe.matsuri.lite")), flags
|
|
||||||
)
|
|
||||||
.filter { it.providerInfo.exported }
|
|
||||||
}
|
|
||||||
if (providers.isEmpty()) {
|
|
||||||
providers = AngApplication.application.packageManager.queryIntentContentProviders(
|
|
||||||
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "fr.husi")), flags
|
|
||||||
)
|
|
||||||
.filter { it.providerInfo.exported }
|
|
||||||
}
|
|
||||||
if (providers.isEmpty()) {
|
|
||||||
providers = AngApplication.application.packageManager.queryIntentContentProviders(
|
|
||||||
Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA
|
|
||||||
).filter {
|
|
||||||
it.providerInfo.exported &&
|
|
||||||
it.providerInfo.metaData.containsKey(METADATA_KEY_ID) &&
|
|
||||||
it.providerInfo.metaData.getString(METADATA_KEY_ID) == pluginId
|
|
||||||
}
|
|
||||||
if (providers.size > 1) {
|
|
||||||
providers = listOf(providers[0]) // What if there is more than one?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (providers.isEmpty()) return null
|
|
||||||
if (providers.size > 1) {
|
|
||||||
val message =
|
|
||||||
"Conflicting plugins found from: ${providers.joinToString { it.providerInfo.packageName }}"
|
|
||||||
AngApplication.application.toast(message)
|
|
||||||
throw IllegalStateException(message)
|
|
||||||
}
|
|
||||||
val provider = providers.single().providerInfo
|
|
||||||
var failure: Throwable? = null
|
|
||||||
try {
|
|
||||||
initNativeFaster(provider)?.also { return InitResult(it) }
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
// Logs.w("Initializing native plugin faster mode failed")
|
|
||||||
failure = t
|
|
||||||
}
|
|
||||||
|
|
||||||
val uri = Uri.Builder().apply {
|
|
||||||
scheme(ContentResolver.SCHEME_CONTENT)
|
|
||||||
authority(provider.authority)
|
|
||||||
}.build()
|
|
||||||
try {
|
|
||||||
return initNativeFast(
|
|
||||||
AngApplication.application.contentResolver,
|
|
||||||
pluginId,
|
|
||||||
uri
|
|
||||||
)?.let { InitResult(it) }
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
// Logs.w("Initializing native plugin fast mode failed")
|
|
||||||
failure?.also { t.addSuppressed(it) }
|
|
||||||
failure = t
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return initNativeSlow(
|
|
||||||
AngApplication.application.contentResolver,
|
|
||||||
pluginId,
|
|
||||||
uri
|
|
||||||
)?.let { InitResult(it) }
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
failure?.also { t.addSuppressed(it) }
|
|
||||||
throw t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initNativeFaster(provider: ProviderInfo): String? {
|
|
||||||
return provider.loadString(PluginContract.METADATA_KEY_EXECUTABLE_PATH)
|
|
||||||
?.let { relativePath ->
|
|
||||||
File(provider.applicationInfo.nativeLibraryDir).resolve(relativePath).apply {
|
|
||||||
check(canExecute())
|
|
||||||
}.absolutePath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initNativeFast(cr: ContentResolver, pluginId: String, uri: Uri): String? {
|
|
||||||
return cr.call(uri, PluginContract.METHOD_GET_EXECUTABLE, null, bundleOf())
|
|
||||||
?.getString(PluginContract.EXTRA_ENTRY)?.also {
|
|
||||||
check(File(it).canExecute())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("Recycle")
|
|
||||||
private fun initNativeSlow(cr: ContentResolver, pluginId: String, uri: Uri): String? {
|
|
||||||
var initialized = false
|
|
||||||
fun entryNotFound(): Nothing =
|
|
||||||
throw IndexOutOfBoundsException("Plugin entry binary not found")
|
|
||||||
|
|
||||||
val pluginDir = File(AngApplication.application.noBackupFilesDir, "plugin")
|
|
||||||
(cr.query(
|
|
||||||
uri,
|
|
||||||
arrayOf(PluginContract.COLUMN_PATH, PluginContract.COLUMN_MODE),
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
)
|
|
||||||
?: return null).use { cursor ->
|
|
||||||
if (!cursor.moveToFirst()) entryNotFound()
|
|
||||||
pluginDir.deleteRecursively()
|
|
||||||
if (!pluginDir.mkdirs()) throw FileNotFoundException("Unable to create plugin directory")
|
|
||||||
val pluginDirPath = pluginDir.absolutePath + '/'
|
|
||||||
do {
|
|
||||||
val path = cursor.getString(0)
|
|
||||||
val file = File(pluginDir, path)
|
|
||||||
check(file.absolutePath.startsWith(pluginDirPath))
|
|
||||||
cr.openInputStream(uri.buildUpon().path(path).build())!!.use { inStream ->
|
|
||||||
file.outputStream().use { outStream -> inStream.copyTo(outStream) }
|
|
||||||
}
|
|
||||||
Os.chmod(
|
|
||||||
file.absolutePath, when (cursor.getType(1)) {
|
|
||||||
Cursor.FIELD_TYPE_INTEGER -> cursor.getInt(1)
|
|
||||||
Cursor.FIELD_TYPE_STRING -> cursor.getString(1).toInt(8)
|
|
||||||
else -> throw IllegalArgumentException("File mode should be of type int")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (path == pluginId) initialized = true
|
|
||||||
} while (cursor.moveToNext())
|
|
||||||
}
|
|
||||||
if (!initialized) entryNotFound()
|
|
||||||
return File(pluginDir, pluginId).absolutePath
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ComponentInfo.loadString(key: String) = when (val value = metaData.getString(key)) {
|
|
||||||
is String -> value
|
|
||||||
// is Int -> AngApplication.application.packageManager.getResourcesForApplication(applicationInfo)
|
|
||||||
// .getString(value)
|
|
||||||
|
|
||||||
null -> null
|
|
||||||
else -> error("meta-data $key has invalid type ${value.javaClass}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
/******************************************************************************
|
|
||||||
* *
|
|
||||||
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
|
|
||||||
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
|
|
||||||
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
|
|
||||||
* *
|
|
||||||
* This program is free software: you can redistribute it and/or modify *
|
|
||||||
* it under the terms of the GNU General Public License as published by *
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or *
|
|
||||||
* (at your option) any later version. *
|
|
||||||
* *
|
|
||||||
* This program is distributed in the hope that it will be useful, *
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
|
||||||
* GNU General Public License for more details. *
|
|
||||||
* *
|
|
||||||
* You should have received a copy of the GNU General Public License *
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
|
||||||
* *
|
|
||||||
******************************************************************************/
|
|
||||||
|
|
||||||
package com.v2ray.ang.plugin
|
|
||||||
|
|
||||||
import android.content.pm.ComponentInfo
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.content.pm.ResolveInfo
|
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.os.Build
|
|
||||||
import com.v2ray.ang.AngApplication
|
|
||||||
import com.v2ray.ang.plugin.PluginManager.loadString
|
|
||||||
|
|
||||||
abstract class ResolvedPlugin(protected val resolveInfo: ResolveInfo) : Plugin() {
|
|
||||||
protected abstract val componentInfo: ComponentInfo
|
|
||||||
|
|
||||||
override val id by lazy { componentInfo.loadString(PluginContract.METADATA_KEY_ID)!! }
|
|
||||||
override val version by lazy {
|
|
||||||
getPackageInfo(componentInfo.packageName).versionCode
|
|
||||||
}
|
|
||||||
override val versionName: String by lazy {
|
|
||||||
getPackageInfo(componentInfo.packageName).versionName!!
|
|
||||||
}
|
|
||||||
override val label: CharSequence get() = resolveInfo.loadLabel(AngApplication.application.packageManager)
|
|
||||||
override val icon: Drawable get() = resolveInfo.loadIcon(AngApplication.application.packageManager)
|
|
||||||
override val packageName: String get() = componentInfo.packageName
|
|
||||||
override val directBootAware get() = Build.VERSION.SDK_INT < 24 || componentInfo.directBootAware
|
|
||||||
|
|
||||||
fun getPackageInfo(packageName: String) = AngApplication.application.packageManager.getPackageInfo(
|
|
||||||
packageName, if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES
|
|
||||||
else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
|
|
||||||
)!!
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,11 @@ package com.v2ray.ang.receiver
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import com.v2ray.ang.AppConfig
|
||||||
|
import com.v2ray.ang.core.CoreServiceManager
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.service.V2RayServiceManager
|
import com.v2ray.ang.handler.SubscriptionUpdater
|
||||||
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
|
||||||
class BootReceiver : BroadcastReceiver() {
|
class BootReceiver : BroadcastReceiver() {
|
||||||
/**
|
/**
|
||||||
@@ -16,8 +19,25 @@ class BootReceiver : BroadcastReceiver() {
|
|||||||
* @param intent The Intent being received.
|
* @param intent The Intent being received.
|
||||||
*/
|
*/
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) return
|
LogUtil.i(AppConfig.TAG, "BootReceiver received: ${intent?.action}")
|
||||||
if (!MmkvManager.decodeStartOnBoot() || MmkvManager.getSelectServer().isNullOrEmpty()) return
|
|
||||||
V2RayServiceManager.startVService(context)
|
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "BootReceiver: Invalid context or action")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!MmkvManager.decodeStartOnBoot()) {
|
||||||
|
LogUtil.i(AppConfig.TAG, "BootReceiver: Auto-start on boot is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
|
||||||
|
LogUtil.w(AppConfig.TAG, "BootReceiver: No server selected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LogUtil.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
|
||||||
|
CoreServiceManager.startVService(context)
|
||||||
|
SubscriptionUpdater.sync(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user