mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-10-29 14:39:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15b4fff8e4 | ||
|
|
1007f54743 | ||
|
|
5369303e18 | ||
|
|
318afdbafc | ||
|
|
ec04df4693 | ||
|
|
53cccd9089 | ||
|
|
f226ace30d | ||
|
|
10a4221b25 | ||
|
|
ffac1476c0 | ||
|
|
7a0472b63d | ||
|
|
62cecc4d0d | ||
|
|
eb56b2e89e | ||
|
|
e83dba94a0 | ||
|
|
42c12fdef4 | ||
|
|
226b36903e | ||
|
|
de351d4438 | ||
|
|
5436d67e47 | ||
|
|
1c3d247071 | ||
|
|
d3e1e7c6fe | ||
|
|
a65c57ea0d | ||
|
|
8586c2550f | ||
|
|
fa6c4807f3 | ||
|
|
6e65f46e3f | ||
|
|
98e7b8e1a1 | ||
|
|
7dd42528db | ||
|
|
e580b76e9e | ||
|
|
c4d02c00ce | ||
|
|
007a4e674f | ||
|
|
46af9d803c | ||
|
|
32a9263e0a | ||
|
|
253e171fec | ||
|
|
31b66a705b | ||
|
|
23c7cd5b95 | ||
|
|
32a37dd626 | ||
|
|
53e84d5fd8 | ||
|
|
0519d02001 | ||
|
|
6dfed5bbc2 | ||
|
|
dcd060e350 | ||
|
|
71f94db929 | ||
|
|
d06b9cb999 | ||
|
|
f53a91241c | ||
|
|
3fb067dc99 | ||
|
|
02103287e5 | ||
|
|
b103e3aec9 | ||
|
|
19fc65d128 | ||
|
|
27cafd1052 | ||
|
|
a155b24c1e | ||
|
|
e805fcf088 | ||
|
|
95e37db42e | ||
|
|
fe812c4456 | ||
|
|
d5af0fe735 | ||
|
|
9126aa9c9e | ||
|
|
4a763d2ec5 | ||
|
|
08d6de4504 | ||
|
|
629d410189 | ||
|
|
39d2513df1 | ||
|
|
3cb4df474b | ||
|
|
b64bb92c8c | ||
|
|
7f0e2f801d | ||
|
|
0f7cee0613 | ||
|
|
b2e10fc60b | ||
|
|
aa3f17fb51 | ||
|
|
d64fc627bb | ||
|
|
6934ab3e5d | ||
|
|
028d13aef8 | ||
|
|
4a78c165bc | ||
|
|
c1033a006f | ||
|
|
e8a42013c0 | ||
|
|
94e5263250 | ||
|
|
7c68aa9d83 | ||
|
|
ef13e336da | ||
|
|
d16cb21059 | ||
|
|
237837e7be | ||
|
|
2a6edd98be | ||
|
|
9238b80675 | ||
|
|
bb5e6f1959 | ||
|
|
1cc0865411 | ||
|
|
cb8d979f23 | ||
|
|
5932b0db29 | ||
|
|
dbe4fe78b5 | ||
|
|
b5bcd81ae4 | ||
|
|
d45ccd7953 | ||
|
|
6815563902 | ||
|
|
7c38ad7c57 | ||
|
|
885aeb384e | ||
|
|
57f01412e7 | ||
|
|
fd84ea4114 | ||
|
|
a4e2e6d6a2 | ||
|
|
b8cfe9bc82 | ||
|
|
b945f4a3c7 | ||
|
|
cfb6776e8f | ||
|
|
ebaa5088b6 | ||
|
|
209679f098 | ||
|
|
19ad248cfe | ||
|
|
35f1f58476 | ||
|
|
4c86e7c26e | ||
|
|
5d0897cd94 | ||
|
|
fc4ab733c8 | ||
|
|
d606453b83 | ||
|
|
c6c8eccd51 | ||
|
|
03074fba61 | ||
|
|
ac02d23083 | ||
|
|
5c6aaf285b | ||
|
|
d3f9809486 | ||
|
|
a12909e33d | ||
|
|
3e823a7440 | ||
|
|
dc1d3a27dd | ||
|
|
d0f1e21f49 | ||
|
|
3b8615fad0 | ||
|
|
d9d00f4137 | ||
|
|
2aefbd43eb | ||
|
|
cf33391785 | ||
|
|
a35ba5dfbd | ||
|
|
3b4d895c1d | ||
|
|
e8585a82c6 | ||
|
|
087c08f95a | ||
|
|
6612ca8256 | ||
|
|
487692deb7 | ||
|
|
e9332168bf | ||
|
|
15fcfec4c8 | ||
|
|
a4baeeb0fa | ||
|
|
093e51186d | ||
|
|
14a29884e7 | ||
|
|
30b0bd8d0d | ||
|
|
ae84df7367 | ||
|
|
b673167731 | ||
|
|
659f2ded09 | ||
|
|
585d64b0be | ||
|
|
2ae2e747e2 | ||
|
|
4199cee4e5 | ||
|
|
8be8a912c4 | ||
|
|
851f22680a | ||
|
|
12abc99bfc | ||
|
|
07e50637a2 | ||
|
|
c1824c7598 | ||
|
|
18eebf999d | ||
|
|
e37d6b7523 | ||
|
|
8c5375ec62 | ||
|
|
30792299e8 | ||
|
|
e6db0f6f9e | ||
|
|
04478ab490 | ||
|
|
f371da490a | ||
|
|
0a0ef6e6b5 | ||
|
|
fd57019e73 | ||
|
|
04ac6cb2d7 | ||
|
|
29a0be9629 | ||
|
|
7aa27fd3de | ||
|
|
fcc478ff63 | ||
|
|
54d5ad2342 | ||
|
|
ba25e05536 | ||
|
|
5a5a81fa0b | ||
|
|
322c4b305e | ||
|
|
590f11e671 | ||
|
|
872ec80292 | ||
|
|
f19f153f9c | ||
|
|
d7abb8f49e | ||
|
|
e54e713db7 | ||
|
|
de91fb27db | ||
|
|
db8f46eae5 | ||
|
|
42e7f95e4f | ||
|
|
0a52a79fa0 | ||
|
|
9dc1b1f2cf | ||
|
|
b61cad5a5f | ||
|
|
66cd746ad2 | ||
|
|
ae4a92b66b | ||
|
|
55c0ccf1b0 | ||
|
|
deedd26fac | ||
|
|
e6c8307b96 | ||
|
|
d6d5753283 | ||
|
|
014d510774 | ||
|
|
42f89e69a7 | ||
|
|
d5a0997775 | ||
|
|
af7db6f86b | ||
|
|
7b7f43e12e | ||
|
|
8dd1df1451 | ||
|
|
d4a73493b5 | ||
|
|
9fce03d0ed | ||
|
|
a12ca3ea04 | ||
|
|
4e9df84d3e | ||
|
|
d3118b53f2 | ||
|
|
cd45b51487 | ||
|
|
6aa26d2621 | ||
|
|
0b452707c3 | ||
|
|
303b642ade | ||
|
|
80eec036dd | ||
|
|
f9a85366ea | ||
|
|
378c891399 | ||
|
|
d04b5eee37 | ||
|
|
405cd7f55e | ||
|
|
850096789b | ||
|
|
3d42ac9dba | ||
|
|
d0265265f3 | ||
|
|
2d6dd33a7b | ||
|
|
3a32373abe | ||
|
|
befb2937d1 | ||
|
|
0a375bec67 | ||
|
|
029f8b67e7 | ||
|
|
7475d1757b | ||
|
|
5a16614a7b | ||
|
|
edb06b466a | ||
|
|
30b5391895 | ||
|
|
6cd04b831f | ||
|
|
1e616198f0 | ||
|
|
a710146e4f | ||
|
|
9cd530d223 | ||
|
|
fc89f25591 | ||
|
|
84ab54fea2 | ||
|
|
d642d09844 | ||
|
|
6ae46c50e2 | ||
|
|
fe4b451988 | ||
|
|
5c9b634039 | ||
|
|
09461663cb | ||
|
|
50c1145815 | ||
|
|
edc4856cb8 | ||
|
|
4dd7eee8fe | ||
|
|
516637954c | ||
|
|
9e714bfcb3 | ||
|
|
858dc5237a | ||
|
|
2b4a4f7ecc | ||
|
|
f9f0c905a4 | ||
|
|
f997335113 | ||
|
|
ed344cd281 | ||
|
|
94775f120a | ||
|
|
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 | ||
|
|
397989769c | ||
|
|
796676abdc | ||
|
|
3c93ccb86c | ||
|
|
fc8c74184a | ||
|
|
1939e6b5cf | ||
|
|
1c2ac9385d | ||
|
|
7513f1fe07 | ||
|
|
63e2c02daa | ||
|
|
fec76385e1 | ||
|
|
bf21428746 | ||
|
|
01ab8dad17 | ||
|
|
8974f73841 | ||
|
|
d3a2a2413c | ||
|
|
9b52863270 | ||
|
|
733914a7a7 | ||
|
|
7b0ab1ea4d | ||
|
|
51ff95f071 | ||
|
|
fc7aca46ce | ||
|
|
78e34caff3 | ||
|
|
d2aecd4dee | ||
|
|
9be1083495 | ||
|
|
38eb9ee13f | ||
|
|
15b36dfc57 | ||
|
|
40a83f7cac | ||
|
|
fd9f912c18 | ||
|
|
eef6e60dbb | ||
|
|
f0de5275b9 | ||
|
|
4eb5c0263c | ||
|
|
7a0d997a81 | ||
|
|
17b7c6d357 | ||
|
|
206f2cb306 | ||
|
|
1bc433097b | ||
|
|
9bee5fbe99 | ||
|
|
e819798d80 | ||
|
|
14ff9eb527 | ||
|
|
a60f45ce31 | ||
|
|
0b9a96209f | ||
|
|
3747e58e4e | ||
|
|
28b1788dc1 | ||
|
|
12ab2954b0 | ||
|
|
d0e8937f03 | ||
|
|
1a7ab97a3a | ||
|
|
ef4145787b | ||
|
|
899e4c1b14 | ||
|
|
faa4385087 | ||
|
|
172d9fd093 | ||
|
|
093716baaa | ||
|
|
0165ad54b3 | ||
|
|
b52dd33102 | ||
|
|
00aed90f2f | ||
|
|
5f3d7c0213 | ||
|
|
401f051774 | ||
|
|
e14b48f3eb | ||
|
|
1972f83b86 | ||
|
|
55a11bbeee | ||
|
|
ca32ee3a0e | ||
|
|
159a370286 | ||
|
|
d1904d52d9 | ||
|
|
5e993fd91b | ||
|
|
bada7c93d7 | ||
|
|
38850597f3 | ||
|
|
da347492d3 | ||
|
|
2ec5d8db3c | ||
|
|
fd9c5040bf | ||
|
|
aa328f0add | ||
|
|
9743d7b87b | ||
|
|
98fb0c433e | ||
|
|
7c9fcd9f43 | ||
|
|
54c76d9968 | ||
|
|
40b3f0fedc | ||
|
|
dcfcf83430 | ||
|
|
e46b354643 | ||
|
|
f497e4e301 | ||
|
|
b65e4b3819 | ||
|
|
d166b036fc | ||
|
|
ddf5f22037 | ||
|
|
7d8a9f2b6d | ||
|
|
0a1695e3d7 | ||
|
|
4a653d4935 | ||
|
|
2bc31a10c5 | ||
|
|
e8d2c6214b | ||
|
|
3a0f2687e9 | ||
|
|
04c98326b2 | ||
|
|
eb22c7f303 | ||
|
|
d51a4d7a7e | ||
|
|
0fb705e1e2 | ||
|
|
10b849ef09 | ||
|
|
d7d3b23cea | ||
|
|
c3786d434e | ||
|
|
9e3b92014a | ||
|
|
f4e088131b | ||
|
|
e55e069fe3 | ||
|
|
d8d3767798 | ||
|
|
7e99b1ac78 | ||
|
|
6ff3a73bf2 | ||
|
|
2a43b52344 | ||
|
|
abff80ec23 | ||
|
|
a4edf86195 | ||
|
|
0d0da6bfec | ||
|
|
e0c8ece9b5 | ||
|
|
4d875bc3d4 | ||
|
|
3a6e23bcef | ||
|
|
efd0716707 | ||
|
|
c94a5fb743 | ||
|
|
047011f60b | ||
|
|
a54ed3a51a | ||
|
|
c37f09bfcd | ||
|
|
1c7042463d | ||
|
|
dcb003f9ab | ||
|
|
7dbda3cee7 | ||
|
|
26bee229a1 | ||
|
|
5bf2beb179 | ||
|
|
4a5c551678 | ||
|
|
277894215d | ||
|
|
684e08a3a1 | ||
|
|
19dbc2f9b9 | ||
|
|
833a1e06f0 | ||
|
|
daca0831a4 | ||
|
|
337889c5f1 | ||
|
|
244d2d3866 | ||
|
|
c0fed0ba4f | ||
|
|
affb107b9d | ||
|
|
f96073af99 | ||
|
|
496a0483d2 | ||
|
|
e11dca00bb | ||
|
|
fde39bf34e | ||
|
|
4f11bae238 | ||
|
|
f6282ba71f | ||
|
|
3edf1f4e1b | ||
|
|
41bc064083 | ||
|
|
eb8562e6b0 | ||
|
|
68fbdd92c3 | ||
|
|
02038a5d93 | ||
|
|
4fb8c2f4b2 | ||
|
|
7afffa60c3 | ||
|
|
0e6c860360 | ||
|
|
ebfbbfa08b | ||
|
|
b5f182dfec | ||
|
|
4cf28d0ad0 | ||
|
|
149bb049a5 | ||
|
|
124702f0a2 | ||
|
|
c1cebe578b | ||
|
|
e46c1ee849 | ||
|
|
70f1743114 | ||
|
|
54d520727e | ||
|
|
1f1e4db486 | ||
|
|
e536236634 | ||
|
|
140c236da5 | ||
|
|
69ede34274 | ||
|
|
fcf6e22132 | ||
|
|
7438ee8308 | ||
|
|
f01cf7fcb5 | ||
|
|
7a852f78e4 | ||
|
|
0923659a49 | ||
|
|
f9feb08607 | ||
|
|
3b43fe39e5 | ||
|
|
de30fa15b3 | ||
|
|
25a4d7c14d | ||
|
|
c8d3607efe | ||
|
|
a0e73a9aa9 | ||
|
|
eaccf237a4 | ||
|
|
5124266346 | ||
|
|
5cf2ea5a1e | ||
|
|
7a1af5914e | ||
|
|
e61f5eeb76 | ||
|
|
6d92106f9d | ||
|
|
8b06745e86 | ||
|
|
85ad999975 | ||
|
|
4c0f2d84cc | ||
|
|
6fc9803431 | ||
|
|
59a710bae5 | ||
|
|
98c642e1a8 | ||
|
|
e91f4470fb | ||
|
|
b33cc5284f | ||
|
|
eea6db6814 | ||
|
|
ba622c7edf | ||
|
|
b52e89d614 | ||
|
|
49cd3a0494 | ||
|
|
75cc16c5e0 | ||
|
|
e674d22ecd | ||
|
|
dca5011eb1 | ||
|
|
4d8e38b704 | ||
|
|
5ba4352641 | ||
|
|
1b2cc11a97 | ||
|
|
a3591e4bbb | ||
|
|
aa0f5639b1 | ||
|
|
f3abd0d9fc | ||
|
|
4a62aff7d2 | ||
|
|
c78e624eaf | ||
|
|
934cf5d21c | ||
|
|
f252d1395a | ||
|
|
2dc0472c69 | ||
|
|
3e09adc4d1 | ||
|
|
11750b9382 | ||
|
|
30a4c2199a | ||
|
|
406a9f996e | ||
|
|
5373579bd5 | ||
|
|
9b4cc201e7 | ||
|
|
6f2c96c2b6 | ||
|
|
1f6104de8b | ||
|
|
e078a2ab27 | ||
|
|
5695c17908 | ||
|
|
e2c1081d5a | ||
|
|
bcbcbc91c7 | ||
|
|
5eb3566e8d | ||
|
|
d75eca8dd4 | ||
|
|
640c16d8dc | ||
|
|
1ba5c5a7a6 | ||
|
|
f67af69dda | ||
|
|
5d47777307 | ||
|
|
ab22bb9804 | ||
|
|
2626462e49 | ||
|
|
41893d79c0 | ||
|
|
834c1ba63d | ||
|
|
633ee63891 | ||
|
|
eb5627c0d0 | ||
|
|
6db38f6e3d | ||
|
|
c69a758429 | ||
|
|
cee3a0ffec | ||
|
|
18c0143186 | ||
|
|
bbf0b05b49 | ||
|
|
44723c56ad | ||
|
|
e53c36b53b | ||
|
|
80f26cd4b8 | ||
|
|
b023414cd0 | ||
|
|
2f56104565 | ||
|
|
9cd5fefdca | ||
|
|
25c42c475f | ||
|
|
96e66da071 | ||
|
|
6914b9ee1b | ||
|
|
cfc6546c97 | ||
|
|
0880313659 | ||
|
|
875ca02126 | ||
|
|
c2d5925053 | ||
|
|
547bbf8e95 | ||
|
|
2218251b03 | ||
|
|
4da3a23162 | ||
|
|
28a90baf88 | ||
|
|
7bbdda2f2f | ||
|
|
884b444a41 | ||
|
|
e1ff2df36e | ||
|
|
61c0111778 | ||
|
|
bfc9a64e07 | ||
|
|
2ba92045cc | ||
|
|
aeca9f51c8 | ||
|
|
b107c0ac1d | ||
|
|
65a04b4784 | ||
|
|
eab9f50cfd | ||
|
|
b8bb83b524 | ||
|
|
93eb9fe3b9 | ||
|
|
5f167512f5 | ||
|
|
a727b81263 | ||
|
|
f27c9192d1 | ||
|
|
90153fa17f | ||
|
|
cdfaa01852 | ||
|
|
33d2c3b00d | ||
|
|
b7f992cdc0 | ||
|
|
0a6a24e309 | ||
|
|
153b4cffef | ||
|
|
f09a413232 | ||
|
|
cba58f6ae2 | ||
|
|
2bf4b91488 | ||
|
|
b60b7f4307 | ||
|
|
e4ca04a096 | ||
|
|
d0f7ecec44 | ||
|
|
f488811f01 | ||
|
|
d212cda1e1 | ||
|
|
ba760eac59 | ||
|
|
8549b5ea46 | ||
|
|
0b3c106c6f | ||
|
|
84e7ee4ef3 | ||
|
|
e5d498ea6e | ||
|
|
6da835a2ca | ||
|
|
1f9a71e6ac | ||
|
|
9c92fdc257 | ||
|
|
da219228fa | ||
|
|
c0a6455d08 | ||
|
|
709e2a9ed4 | ||
|
|
c3ac9f01d2 | ||
|
|
65eba3795f | ||
|
|
341cdb5dbe | ||
|
|
4f43c2ce45 | ||
|
|
2ec691fc6b | ||
|
|
81ed321654 | ||
|
|
6ad37c70f1 | ||
|
|
c7ffd6d82d | ||
|
|
ae4b0fd8d3 | ||
|
|
beceaba44d | ||
|
|
f5987d9767 | ||
|
|
616712b338 | ||
|
|
076a968476 | ||
|
|
e60eb703c6 | ||
|
|
cdede639f2 | ||
|
|
23264d71f0 | ||
|
|
9caafc4303 | ||
|
|
48a3690a39 | ||
|
|
b66a8ca44d | ||
|
|
82087e1187 | ||
|
|
fdfd0438c3 | ||
|
|
28027b5288 | ||
|
|
ba54005753 | ||
|
|
c6758b11b5 | ||
|
|
6c29e5e9a4 | ||
|
|
17e0db2ffc | ||
|
|
490ea59499 | ||
|
|
f4db6bcf63 | ||
|
|
90f89de957 | ||
|
|
9612b868f2 | ||
|
|
5f8ea93f36 | ||
|
|
fc132f7282 | ||
|
|
6f9bb6caa7 | ||
|
|
0e5b88de8f | ||
|
|
5b4f51981e | ||
|
|
3dcee45e9f | ||
|
|
3573a3bec3 | ||
|
|
796bad1c1c | ||
|
|
77042f6fae | ||
|
|
013ac308f7 | ||
|
|
d703582f19 | ||
|
|
1db80f740d | ||
|
|
a0d2740280 | ||
|
|
297083f3c4 | ||
|
|
15a4ad978a | ||
|
|
63f4cfac83 | ||
|
|
ca849fb19e | ||
|
|
3de3070ab7 | ||
|
|
cbea4bab7c | ||
|
|
fe8b825c34 | ||
|
|
daa0394960 | ||
|
|
e5aba5d99b | ||
|
|
c4847eb3de | ||
|
|
5a5f911453 | ||
|
|
22cef29c27 | ||
|
|
ef41641680 | ||
|
|
69135e8707 | ||
|
|
5daef71147 | ||
|
|
5ffc5ec502 | ||
|
|
35063db3e6 | ||
|
|
868c24bb8b | ||
|
|
7367baffb8 | ||
|
|
819ff2995a | ||
|
|
3b5d04b717 | ||
|
|
b673cd73ac | ||
|
|
649c1a022b | ||
|
|
034e58bc9d | ||
|
|
a95f280102 | ||
|
|
df8da05f32 | ||
|
|
635581719b | ||
|
|
77d5e203e8 | ||
|
|
370d002b25 | ||
|
|
18f0fe47ff | ||
|
|
cccd6139fc | ||
|
|
1fadca8524 | ||
|
|
af01e2ac06 | ||
|
|
de22e16cd4 | ||
|
|
e073b19343 | ||
|
|
a81a05cd45 | ||
|
|
fc67281d2a | ||
|
|
ece35b9a1c | ||
|
|
ed8fb7fa82 | ||
|
|
3688dd4634 | ||
|
|
7ff445ef55 | ||
|
|
e4847b4c76 | ||
|
|
5f5d8f1d2f | ||
|
|
a45aa489e8 | ||
|
|
9b86ba9e35 | ||
|
|
f5b74ecd78 | ||
|
|
2d0ab355d6 | ||
|
|
e41235f76b | ||
|
|
3045f7000e | ||
|
|
c0c2dfb657 | ||
|
|
622cafbfd6 | ||
|
|
d8a1f66af9 | ||
|
|
c34cce63b0 | ||
|
|
f8f17b5d38 | ||
|
|
1d3a194d89 | ||
|
|
056384fdab | ||
|
|
7c40d074f3 | ||
|
|
c61595eb0d | ||
|
|
7192c970fa | ||
|
|
cfa709c651 | ||
|
|
97c467af41 | ||
|
|
6039426bac | ||
|
|
862fb90de9 | ||
|
|
842c32f29f | ||
|
|
0501de1658 | ||
|
|
dbe26847c7 | ||
|
|
806290f0a5 | ||
|
|
325c643314 | ||
|
|
4adc0affbe | ||
|
|
b5026095a0 | ||
|
|
fa7da3be10 | ||
|
|
35b44f1955 | ||
|
|
4708ee8823 | ||
|
|
52471f2ace | ||
|
|
0b065c745d | ||
|
|
9ce8244065 | ||
|
|
b7fafa1bf9 | ||
|
|
114c974ce5 | ||
|
|
e035925d25 | ||
|
|
c0fda6fcba | ||
|
|
75c90e3c45 | ||
|
|
9960f49698 | ||
|
|
1a2c4cc9a1 | ||
|
|
ee4f05b07d | ||
|
|
17ef476ede | ||
|
|
141b98631c | ||
|
|
845562bca3 | ||
|
|
105a41eeea | ||
|
|
9a9d315e62 | ||
|
|
c42aa93bf7 | ||
|
|
a7664f03aa | ||
|
|
fa341c9a5a | ||
|
|
a15ab4759e | ||
|
|
b8939763d4 | ||
|
|
51adca8568 | ||
|
|
f646eff048 | ||
|
|
fee0a016d8 | ||
|
|
f040fa5c08 | ||
|
|
7f3c6b4665 | ||
|
|
8b806fe0be | ||
|
|
b37d8c2369 | ||
|
|
b5451e9d3d | ||
|
|
b2235d4c38 | ||
|
|
c8ba5d727e | ||
|
|
a3de44cd0a | ||
|
|
214d9e1c53 | ||
|
|
92900c3f74 | ||
|
|
e17e566daa | ||
|
|
df4e232087 | ||
|
|
828a39331b | ||
|
|
a0223a3eee | ||
|
|
7f6a526b25 | ||
|
|
9983ea25d2 | ||
|
|
47166b937f | ||
|
|
0a09966e81 | ||
|
|
7ec34e934e | ||
|
|
f2b03e7492 | ||
|
|
5208bd62c5 | ||
|
|
d9f0854c27 | ||
|
|
6be125b5cb | ||
|
|
c884c098fd | ||
|
|
f77fe05c92 | ||
|
|
f3bfa8ceba | ||
|
|
ae7d9d87d2 | ||
|
|
1652040c1c | ||
|
|
818b7cdff4 | ||
|
|
48ce359d2d | ||
|
|
e115bf0c6d | ||
|
|
0415b60ba5 | ||
|
|
4570fdb05f | ||
|
|
9d109e7ca9 | ||
|
|
2574553180 | ||
|
|
66e77d50bd | ||
|
|
253bd793d7 | ||
|
|
be30de6728 | ||
|
|
a1455bbb1c | ||
|
|
1ac19ae3e9 | ||
|
|
6e6ca209df | ||
|
|
52699967cd | ||
|
|
146d20ce86 | ||
|
|
b6959b5990 | ||
|
|
06649df8b1 | ||
|
|
0174ed9082 | ||
|
|
adabb281b1 | ||
|
|
63e710d1ab | ||
|
|
509a568446 | ||
|
|
6fd94b53f0 | ||
|
|
164412fa34 | ||
|
|
514ca0810e | ||
|
|
7582f86482 | ||
|
|
4b4c46e5ae | ||
|
|
162e156b33 | ||
|
|
bc7d1971ef | ||
|
|
bbdee92f37 | ||
|
|
cf9e830cc7 | ||
|
|
804e425a87 | ||
|
|
cc21383928 | ||
|
|
413f4efd69 | ||
|
|
de69605eff | ||
|
|
9338ba3525 | ||
|
|
322b6ec615 | ||
|
|
304232d029 | ||
|
|
f80c3bfe07 | ||
|
|
bb0a62fc8b | ||
|
|
5bfdca6cd9 | ||
|
|
447e712a9d | ||
|
|
8bb03f189d | ||
|
|
3b0554cd9b | ||
|
|
858101b0d9 | ||
|
|
a7cf8bee28 | ||
|
|
af1ec7bea9 | ||
|
|
002bf7ef22 | ||
|
|
6919e2336d | ||
|
|
5a5bd22073 | ||
|
|
a726f00f35 | ||
|
|
79297f8a42 | ||
|
|
e3f70ac253 | ||
|
|
838b346fcc | ||
|
|
eba9545ccf | ||
|
|
890ade9495 | ||
|
|
518ef1e0ec | ||
|
|
bdcecfca72 | ||
|
|
1363846ac4 | ||
|
|
30347546a2 | ||
|
|
ac7eb28e91 | ||
|
|
748405473b | ||
|
|
1080390bed | ||
|
|
c48725c7dd | ||
|
|
a5287dbadc | ||
|
|
ee5a3b0dd9 | ||
|
|
3d001541e5 | ||
|
|
b376b229b9 | ||
|
|
33b6203978 | ||
|
|
2d803e009c | ||
|
|
2ddbe38781 | ||
|
|
96181a2b8d | ||
|
|
8308b8eaf2 | ||
|
|
00f26ff529 | ||
|
|
49dcdf3ae5 | ||
|
|
409b431d1c | ||
|
|
6da988e3db | ||
|
|
fd8f8306ee | ||
|
|
74b342f5c6 | ||
|
|
2504ec79ee | ||
|
|
3e7b211b17 | ||
|
|
13d5514a4c | ||
|
|
f0f9da0f1b | ||
|
|
f6d2c5f473 | ||
|
|
6e8dd5b250 | ||
|
|
f4779bc50c | ||
|
|
432baf262d | ||
|
|
a1d68fcde3 | ||
|
|
e053db3dff | ||
|
|
f624bd651e | ||
|
|
84964c7f91 | ||
|
|
96f56b468e | ||
|
|
bdc3212f38 | ||
|
|
508ddf6df2 | ||
|
|
17af24d179 | ||
|
|
c260e447ea | ||
|
|
0eb40ae993 | ||
|
|
18f3e39346 | ||
|
|
311af02726 | ||
|
|
b799969de8 | ||
|
|
93541883bb | ||
|
|
33430bff8d | ||
|
|
3bc2081540 | ||
|
|
a3b1feabff | ||
|
|
7af8d3843e | ||
|
|
2235805800 | ||
|
|
364b521ec3 | ||
|
|
3cdaf4a8ee | ||
|
|
b2fc6dcdd0 | ||
|
|
70e9320463 | ||
|
|
304c7ed068 | ||
|
|
ddb908f937 | ||
|
|
accd17afd4 | ||
|
|
9f598b77b4 | ||
|
|
d82fa974b1 | ||
|
|
fcbd4a0d48 | ||
|
|
5cd2b8845e | ||
|
|
723ab70170 | ||
|
|
a26bf3eeda | ||
|
|
c33b6463c6 | ||
|
|
df995a3ab2 | ||
|
|
817f844212 | ||
|
|
fa8113b8d7 | ||
|
|
99b95d8369 | ||
|
|
fc2e4ff210 | ||
|
|
1063bf71d6 | ||
|
|
f2af5c45e9 | ||
|
|
b132b0d2f0 | ||
|
|
7869f99fc8 | ||
|
|
122f2eb400 | ||
|
|
3c0f6eeb21 | ||
|
|
19a109355b | ||
|
|
703965a0dd | ||
|
|
66f92c6c60 | ||
|
|
a1cdf6b7a5 | ||
|
|
554c7b5687 | ||
|
|
2d987313a7 | ||
|
|
9eebe32bdf | ||
|
|
8e9da0ad6f | ||
|
|
2f20dea611 | ||
|
|
167baf64a9 | ||
|
|
bd7a214f7f | ||
|
|
f16d2d9a74 | ||
|
|
e984d2c274 | ||
|
|
8720d087ea | ||
|
|
a1e19b9fcd | ||
|
|
6b9728dc84 | ||
|
|
1ce9b7c0c8 | ||
|
|
ff75d3fdc2 | ||
|
|
8154812570 | ||
|
|
1dcd2478fc | ||
|
|
0515806e92 | ||
|
|
f286506ba4 | ||
|
|
48be736275 | ||
|
|
af0faeab16 | ||
|
|
fb6edee842 | ||
|
|
1209c4b92a | ||
|
|
e4e668b492 | ||
|
|
92d3136a23 | ||
|
|
f6e74ddb45 | ||
|
|
c34f332771 | ||
|
|
eb1d71bda6 | ||
|
|
3cdd5822cb | ||
|
|
8f924b5ce1 | ||
|
|
2ae645dce5 | ||
|
|
f848a7119f | ||
|
|
2fa41db32f | ||
|
|
9af4516761 | ||
|
|
189b07124d | ||
|
|
e6e5cdcdcd | ||
|
|
a31e4dab85 | ||
|
|
b2a9397dc7 | ||
|
|
a536df1a02 | ||
|
|
1a2751563d | ||
|
|
2dc82b7e83 | ||
|
|
b02d81ae53 | ||
|
|
773bcc5658 | ||
|
|
9142b9cfb4 | ||
|
|
376882d975 | ||
|
|
f330c32ccc | ||
|
|
58dbb71b53 | ||
|
|
ebd0257f6e | ||
|
|
945c584fc6 | ||
|
|
19cd24c37a | ||
|
|
3994810c4e | ||
|
|
d86e68c77a | ||
|
|
cc7bdefe54 | ||
|
|
51b32a030a | ||
|
|
6c76ddd145 | ||
|
|
0d12cc5dc8 | ||
|
|
cfb756723c | ||
|
|
0e9f198341 | ||
|
|
1fa1325630 | ||
|
|
dc79d3a897 | ||
|
|
486f3ffc96 | ||
|
|
bf030e12f5 | ||
|
|
e80cce9696 | ||
|
|
5bb9ecce47 | ||
|
|
5eb09aa54e | ||
|
|
ff261d9939 | ||
|
|
1624ec87b2 | ||
|
|
f1062f2f45 | ||
|
|
39341c27bc | ||
|
|
31a90cec2b | ||
|
|
617fc63393 | ||
|
|
802f2cf3eb | ||
|
|
c7efcde868 | ||
|
|
e858179204 | ||
|
|
6871b0b950 | ||
|
|
e9a27a1585 | ||
|
|
976b765629 | ||
|
|
126b9b6516 | ||
|
|
94dab02b54 | ||
|
|
a893b87730 | ||
|
|
7db2ddd1f7 | ||
|
|
748980aa1a | ||
|
|
96d416066e | ||
|
|
3955bb16bc | ||
|
|
de9bbf842f | ||
|
|
336b673746 | ||
|
|
f1b6b1e871 | ||
|
|
fba4c03bb5 | ||
|
|
a32ae5b53f | ||
|
|
589e0f38fd | ||
|
|
e21116680e | ||
|
|
e1960f5aff | ||
|
|
327ba57088 | ||
|
|
04e1b024e2 | ||
|
|
b59fe9b57b | ||
|
|
fab0b756de | ||
|
|
aa2727d0d0 | ||
|
|
ce3dd73a81 | ||
|
|
2eab209fea | ||
|
|
02476657bf | ||
|
|
35602120e8 | ||
|
|
9e800e08ab | ||
|
|
09fc20794e | ||
|
|
3cd95fbfdb | ||
|
|
c243fadacf | ||
|
|
9c06412ceb | ||
|
|
8d1f0d5df9 | ||
|
|
a035f42008 | ||
|
|
838fb041aa | ||
|
|
7fbab63227 | ||
|
|
ad871723fe | ||
|
|
8887b44bf7 | ||
|
|
176beced3a | ||
|
|
a3561ddc6c | ||
|
|
097bd06021 | ||
|
|
dfdd1efcc8 | ||
|
|
1ac5a410d4 | ||
|
|
8c44e849c9 | ||
|
|
2684bd2af4 | ||
|
|
01a860aab5 | ||
|
|
e1faf4e54e | ||
|
|
14dd4d6b99 | ||
|
|
bab21bc8a5 | ||
|
|
8393b3ce86 | ||
|
|
e15eec9cff | ||
|
|
32741ed7ab | ||
|
|
0d77a65bbb | ||
|
|
6eaac2d7e9 | ||
|
|
496a0ec92c | ||
|
|
826329e996 | ||
|
|
4f57da4a38 | ||
|
|
b3f49d0a34 | ||
|
|
b78b370408 | ||
|
|
82afcdddd0 | ||
|
|
a33a698f38 | ||
|
|
3aff1800cd | ||
|
|
ea816ca981 | ||
|
|
a2bace4ede | ||
|
|
5589d1058b | ||
|
|
a12fc32ff0 | ||
|
|
ce2d1c5e0d | ||
|
|
eb75666c85 | ||
|
|
4b970cedcc | ||
|
|
fbd9d92f5e | ||
|
|
cdaff4da06 | ||
|
|
fbb17390f2 | ||
|
|
52273db482 | ||
|
|
a6af25ae88 | ||
|
|
3ac89d68fb | ||
|
|
267a43fd97 | ||
|
|
6d0384b6f1 | ||
|
|
7ae4be402f | ||
|
|
46f0b7be5b | ||
|
|
b253f2d947 | ||
|
|
444ade8afe | ||
|
|
522dbdd170 | ||
|
|
d89238e1aa | ||
|
|
52710020ea | ||
|
|
a18b8f4f2b | ||
|
|
01e0a6570d | ||
|
|
cd6ef8f062 | ||
|
|
ea383c43bd | ||
|
|
68e08e3866 | ||
|
|
329ed26e85 | ||
|
|
c830a4cea4 | ||
|
|
e7e088ec83 | ||
|
|
cf6c814eb4 | ||
|
|
8f62e42ae8 | ||
|
|
89b4060ba2 | ||
|
|
e4d4b329a0 | ||
|
|
09c5f41995 | ||
|
|
02b25787c1 | ||
|
|
cdb050b6c5 | ||
|
|
21f47b536d | ||
|
|
f14484c986 | ||
|
|
358dd78dc3 | ||
|
|
d79e072316 | ||
|
|
c24dee567a | ||
|
|
9f75bafcea | ||
|
|
2150452629 | ||
|
|
507a32a08e | ||
|
|
44dee8e850 | ||
|
|
5e4d9246c2 | ||
|
|
9fe7419467 | ||
|
|
41b2251dfe | ||
|
|
73fad43573 | ||
|
|
4676717582 | ||
|
|
834766e6e7 | ||
|
|
e304dce347 | ||
|
|
61654aefeb | ||
|
|
1664aaa25b | ||
|
|
68f1f64f3d | ||
|
|
7bad57ca52 | ||
|
|
1c8e1f0993 | ||
|
|
6037ae6fc4 | ||
|
|
dc1c5400b8 | ||
|
|
9ae4688171 | ||
|
|
e3f39234b2 | ||
|
|
0df0b2d6ac | ||
|
|
9ce96d0591 | ||
|
|
a7ef0618ba | ||
|
|
cc9b083e5d | ||
|
|
5af322552c | ||
|
|
fc852281dd | ||
|
|
0b2f036a22 | ||
|
|
cf2becb5e9 | ||
|
|
82d8eba1b9 | ||
|
|
286ad34d94 | ||
|
|
ff0bc6594d | ||
|
|
7b8113aef1 | ||
|
|
81d2ef5db5 | ||
|
|
bdea3ef88c | ||
|
|
c62c86fc29 | ||
|
|
59f698f755 | ||
|
|
9ac979006e | ||
|
|
da6291a965 | ||
|
|
bf6555e57c | ||
|
|
35b114220e | ||
|
|
800bb6a4e9 | ||
|
|
2c80521f5b | ||
|
|
6351ce5991 | ||
|
|
683362f0ee | ||
|
|
84fc909339 | ||
|
|
74171e26db | ||
|
|
f25c0cc890 | ||
|
|
29848053a4 | ||
|
|
0d9856919e | ||
|
|
0ae7f2f7b3 | ||
|
|
3dde6b0ca3 | ||
|
|
6b28208044 | ||
|
|
13f855e3c4 | ||
|
|
c870595e98 | ||
|
|
ae19a3f68d | ||
|
|
545afc41b3 | ||
|
|
7177d88144 | ||
|
|
294ed50afd | ||
|
|
0105fe48f7 | ||
|
|
c7ff23e3d5 | ||
|
|
23e9d7fde5 | ||
|
|
c93edd8875 | ||
|
|
bde37e38a7 | ||
|
|
c401d63d2f | ||
|
|
52416dd43d | ||
|
|
59bd7128ae | ||
|
|
87f16467bb | ||
|
|
bed0fd00bd | ||
|
|
0672af98f8 | ||
|
|
2341eceb65 | ||
|
|
ec5f7245bf | ||
|
|
caa2edcf05 | ||
|
|
dbe78d0aa5 | ||
|
|
71f2f590a7 | ||
|
|
ed26120581 | ||
|
|
71bd684b46 | ||
|
|
02ae19f0c7 | ||
|
|
a46b0d58eb | ||
|
|
d1262d169b | ||
|
|
4a85c95b02 | ||
|
|
a368927b9f | ||
|
|
80feb69af5 | ||
|
|
c09f0d5787 | ||
|
|
ff667546b8 | ||
|
|
6a255cdfa4 | ||
|
|
2b5784df6f | ||
|
|
a9665dd2d8 | ||
|
|
1e52877e93 | ||
|
|
062c0d8ddb | ||
|
|
a7a70b448f | ||
|
|
3e52aeb804 | ||
|
|
4b4f5d145b | ||
|
|
775fa5ea62 | ||
|
|
34d8329c8a | ||
|
|
2e7ae732aa | ||
|
|
424287e258 | ||
|
|
40d03bbb96 | ||
|
|
d768694445 | ||
|
|
56c7f2ef69 | ||
|
|
3839b0c59c | ||
|
|
77cab14ae8 | ||
|
|
b0dbd4c7ca | ||
|
|
50ca2e0e69 | ||
|
|
0bf8beda94 | ||
|
|
4d334929c9 | ||
|
|
ccfdf096f9 | ||
|
|
b4f2af2778 | ||
|
|
32b9e4855c | ||
|
|
bdbce5147e | ||
|
|
a4833506ae | ||
|
|
d4e8072248 | ||
|
|
207ca93735 | ||
|
|
99307ab8f0 | ||
|
|
a5bb39ac8a | ||
|
|
073c7c0410 | ||
|
|
7e88e3ba4f | ||
|
|
1ec23a7b39 | ||
|
|
685f9e220c | ||
|
|
e77b7eb52e | ||
|
|
29014704e0 | ||
|
|
b3570d9c0b | ||
|
|
6e427cee82 | ||
|
|
0a1b6d00d9 | ||
|
|
00ffe66f36 | ||
|
|
63661fbdaa | ||
|
|
4d4a4543c5 | ||
|
|
2349805968 |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
@@ -0,0 +1,155 @@
|
||||
name: Build APK
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
required: false
|
||||
type: string
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
fetch-depth: '0'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v4
|
||||
with:
|
||||
log-accepted-android-sdk-licenses: false
|
||||
cmdline-tools-version: '14742923'
|
||||
packages: 'platforms;android-37.0 build-tools;37.0.0 platform-tools'
|
||||
|
||||
- name: Install NDK
|
||||
run: |
|
||||
echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
||||
--channel=0 \
|
||||
--install "ndk;29.0.14206865"
|
||||
echo "NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
|
||||
sed -i '10i\
|
||||
\
|
||||
ndkVersion = "29.0.14206865"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
|
||||
|
||||
- name: Restore cached libhevtun
|
||||
id: cache-libhevtun-restore
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/libs
|
||||
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
|
||||
|
||||
- name: Build libhevtun
|
||||
if: steps.cache-libhevtun-restore.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
bash compile-hevtun.sh
|
||||
|
||||
- name: Save libhevtun
|
||||
if: steps.cache-libhevtun-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/libs
|
||||
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
|
||||
|
||||
- name: Copy libhevtun
|
||||
run: |
|
||||
cp -r ${{ github.workspace }}/libs ${{ github.workspace }}/V2rayNG/app
|
||||
|
||||
- name: Fetch AndroidLibXrayLite tag
|
||||
run: |
|
||||
pushd AndroidLibXrayLite
|
||||
CURRENT_TAG=$(git describe --tags --abbrev=0)
|
||||
echo "Current tag in this repo: $CURRENT_TAG"
|
||||
echo "CURRENT_TAG=$CURRENT_TAG" >> $GITHUB_ENV
|
||||
popd
|
||||
|
||||
- name: Download libv2ray
|
||||
uses: robinraju/release-downloader@v1.13
|
||||
with:
|
||||
repository: '2dust/AndroidLibXrayLite'
|
||||
tag: ${{ env.CURRENT_TAG }}
|
||||
fileName: 'libv2ray.aar'
|
||||
out-file-path: V2rayNG/app/libs/
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
- name: Decode Keystore
|
||||
uses: timheuer/base64-to-file@v2.0.0
|
||||
id: android_keystore
|
||||
with:
|
||||
fileName: "android_keystore.jks"
|
||||
encodedString: ${{ secrets.APP_KEYSTORE_BASE64 }}
|
||||
|
||||
- name: Build APK
|
||||
run: |
|
||||
cd ${{ github.workspace }}/V2rayNG
|
||||
echo "sdk.dir=${ANDROID_HOME}" > local.properties
|
||||
chmod 755 gradlew
|
||||
./gradlew licenseFdroidReleaseReport
|
||||
./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }}
|
||||
|
||||
- name: Upload arm64-v8a APK
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ success() }}
|
||||
with:
|
||||
name: arm64-v8a
|
||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk
|
||||
|
||||
- name: Upload armeabi-v7a APK
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ success() }}
|
||||
with:
|
||||
name: armeabi-v7a
|
||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk
|
||||
|
||||
- name: Upload x86 APK
|
||||
uses: actions/upload-artifact@v7
|
||||
if: ${{ success() }}
|
||||
with:
|
||||
name: x86-apk
|
||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*x86*.apk
|
||||
|
||||
sign-and-release:
|
||||
needs: build
|
||||
runs-on: ubuntu-26.04
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
|
||||
|
||||
steps:
|
||||
- name: Download APK artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: release
|
||||
|
||||
- name: Sign APK files
|
||||
run: |
|
||||
printf '%s' "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --batch --import
|
||||
KEY=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/ {print $10; exit}')
|
||||
mkdir -p release-files
|
||||
|
||||
find release -type f -name '*.apk' | while read apk; do
|
||||
cp "$apk" release-files/
|
||||
gpg --batch --yes --local-user "$KEY" --detach-sign --output "release-files/$(basename "$apk").sig" "$apk"
|
||||
done
|
||||
|
||||
gpg --armor --export "$KEY" > release-files/v2rayN-public-key.asc
|
||||
|
||||
- name: Upload APKs, signatures and public key to release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
file: release-files/*
|
||||
tag: ${{ inputs.release_tag }}
|
||||
file_glob: true
|
||||
prerelease: true
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Validate Fastlane metadata
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
jobs:
|
||||
go:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Validate Fastlane Supply Metadata
|
||||
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0
|
||||
+61
@@ -1,5 +1,66 @@
|
||||
# Ignore data and key store files
|
||||
*.dat
|
||||
*.jks
|
||||
|
||||
# Ignore output JSON file
|
||||
V2rayNG/app/release/output.json
|
||||
|
||||
# Ignore IDE and build system directories
|
||||
.idea/
|
||||
.gradle/
|
||||
*.iml
|
||||
|
||||
# Ignore local properties and DS_Store files
|
||||
/local.properties
|
||||
.DS_Store
|
||||
|
||||
# Ignore build directories and captures
|
||||
/build
|
||||
/captures
|
||||
V2rayNG/app/build
|
||||
V2rayNG/build
|
||||
V2rayNG/local.properties
|
||||
|
||||
# Ignore APK and AAR files
|
||||
*.apk
|
||||
*.aar
|
||||
|
||||
# Ignore signing properties
|
||||
signing.properties
|
||||
|
||||
# Ignore shared object files
|
||||
*.so
|
||||
|
||||
# Ignore Google services JSON
|
||||
V2rayNG/app/google-services.json
|
||||
|
||||
# Additional common Android/Java ignores
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
*.swp
|
||||
*.orig
|
||||
*.class
|
||||
*.jar
|
||||
*.war
|
||||
*.ear
|
||||
|
||||
# Ignore executable files
|
||||
*.exe
|
||||
*.dll
|
||||
*.obj
|
||||
*.o
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# Ignore files from other IDEs
|
||||
.vscode/
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
|
||||
# Ignore OS-specific files
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
@@ -0,0 +1,6 @@
|
||||
[submodule "AndroidLibXrayLite"]
|
||||
path = AndroidLibXrayLite
|
||||
url = https://github.com/2dust/AndroidLibXrayLite
|
||||
[submodule "hev-socks5-tunnel"]
|
||||
path = hev-socks5-tunnel
|
||||
url = https://github.com/heiher/hev-socks5-tunnel
|
||||
@@ -1,20 +0,0 @@
|
||||
# AndroidLibV2rayLite
|
||||
|
||||
### Preparation
|
||||
- latest Ubuntu environment
|
||||
- At lease 30G free space
|
||||
- Get Repo [AndroidLibV2rayLite](https://github.com/2dust/AndroidLibV2rayLite) or [AndroidLibXrayLite](https://github.com/2dust/AndroidLibXrayLite)
|
||||
### Prepare Go
|
||||
- Go to https://golang.org/doc/install and install latest go
|
||||
- Make sure `go version` works as expected
|
||||
### Prepare gomobile
|
||||
- Go to https://pkg.go.dev/golang.org/x/mobile/cmd/gomobile and install gomobile
|
||||
- export PATH=$PATH:~/go/bin
|
||||
- Make sure `gomobile init` works as expected
|
||||
### Prepare NDK
|
||||
- Go to https://developer.android.com/ndk/downloads and install latest NDK
|
||||
- export PATH=$PATH:<wherever you ndk is located>
|
||||
- Make sure `ndk-build -v` works as expected
|
||||
### Make
|
||||
- sudo apt install make
|
||||
- Read and understand [build script](https://github.com/2dust/AndroidLibV2rayLite/blob/master/Makefile)
|
||||
Submodule
+1
Submodule AndroidLibXrayLite added at 3b5a9c858c.
@@ -1,29 +1,40 @@
|
||||
v2rayNG 隐私条款
|
||||
**v2rayNG 隐私权政策**
|
||||
|
||||
最后更新 2017-11-22
|
||||
本政策自2023年11月17日起施行
|
||||
|
||||
v2rayNG 尊重并保护所有用户的个人隐私权,为此我们向大众公开这份隐私条款。**您使用 v2rayNG 即代表您以阅读并同意了这份条款,如果您不同意这份条款请立即停止使用并卸载 v2rayNG。**
|
||||
2dust 将 v2rayNG 应用程序构建为开源应用程序。 本服务由 2dust 免费提供,并且旨在按原样使用。
|
||||
|
||||
1. 信息收集
|
||||
v2rayNG 尊重并保护所有用户的个人隐私权,为此我们向大众公开这份隐私权政策。**您使用 v2rayNG 即代表您以阅读并同意了这份条款,如果您不同意这份条款请立即停止使用并卸载 v2rayNG。**
|
||||
|
||||
v2rayNG 软件自身不会发送任何信息到开发者,但是您下载软件的应用市场(如 Google Play)可能会收集关于应用运行状态的相关信息并提供给 v2rayNG 开发者。有关这些信息,请阅读您使用的应用市场所提供的隐私条款。
|
||||
**信息收集**
|
||||
|
||||
v2rayNG 软件中可能包含需要通过 IAP 支付解锁的功能,您的支付信息将由相关的 IAP 渠道进行处理,而我们对支付信息没有访问权。
|
||||
v2rayNG 软件自身不会发送任何信息到开发者,但是您下载软件的应用市场(如 Google Play)可能会收集关于应用运行状态的相关信息并提供给 v2rayNG 开发者。有关这些信息,请阅读您使用的应用市场所提供的隐私权政策。
|
||||
|
||||
当您向 v2rayNG 开发者反馈软件运行中的错误时,开发者可能会要求您提供软件以及系统的日志以帮助确认问题的原因。因日志中可能包括敏感信息,此类信息只能由您自己操作发送。**我们不对任何传输服务的安全性和隐私性做任何明示或暗示的担保,请您在传送相关信息时选择可以您自身可以接受的方式。**
|
||||
v2rayNG 软件中可能包含需要通过 IAP 支付解锁的功能,您的支付信息将由相关的 IAP 渠道进行处理,而我们对支付信息没有访问权。
|
||||
|
||||
2. 信息共享
|
||||
当您向 v2rayNG 开发者反馈软件运行中的错误时,开发者可能会要求您提供软件以及系统的日志以帮助确认问题的原因。因日志中可能包括敏感信息,此类信息只能由您自己操作发送。**我们不对任何传输服务的安全性和隐私性做任何明示或暗示的担保,请您在传送相关信息时选择可以您自身可以接受的方式。**
|
||||
|
||||
我们不会向任何第三方出售收集到的用户数据。我们可能向外部开发者提供信息以协助软件的开发,但是在提供信息之前我们会传达相关保密义务并确定其可以遵守。
|
||||
**信息共享**
|
||||
|
||||
3. 信息存留
|
||||
我们不会向任何第三方出售收集到的用户数据。我们可能向外部开发者提供信息以协助软件的开发,但是在提供信息之前我们会传达相关保密义务并确定其可以遵守。
|
||||
|
||||
除非有相关法律规定,我们会在 30 天内清除不需要继续使用的用户数据,或将统计数据整合为无法识别单个用户的综合报告。
|
||||
**信息存留**
|
||||
|
||||
4. 信息泄露
|
||||
除非有相关法律规定,我们会在 30 天内清除不需要继续使用的用户数据,或将统计数据整合为无法识别单个用户的综合报告。
|
||||
|
||||
我们会使用合理的技术和安全手段尽力保护用户的数据,但是无法保证数据的绝对安全。如果我们确认数据发生了泄露,我们会在 7 天内通过可用的渠道通知用户。**您同意不向我们追责任何因不可抗力而造成的损失。**
|
||||
**信息泄露**
|
||||
|
||||
5. 条款修改
|
||||
我们会使用合理的技术和安全手段尽力保护用户的数据,但是无法保证数据的绝对安全。如果我们确认数据发生了泄露,我们会在 7 天内通过可用的渠道通知用户。**您同意不向我们追责任何因不可抗力而造成的损失。**
|
||||
|
||||
**儿童隐私**
|
||||
|
||||
这些服务不针对 13 岁以下的任何人。我不会故意收集 13 岁以下儿童的个人身份信息。 如果我发现 13 岁以下的儿童向我提供了个人信息,我会立即从我们的服务器中删除该信息。 如果您是父母或监护人,并且您知道您的孩子向我们提供了个人信息,请与我联系,以便我能够采取必要的行动。
|
||||
|
||||
**条款修改**
|
||||
|
||||
我们保留修改这份隐私权政策的权利,但是会确保在更新条款前至少 30 天通过我们的可用渠道和应用内提示来通知用户。**在新条款生效后继续使用软件即表示您同意修改后的隐私权政策。**
|
||||
|
||||
**联系我们**
|
||||
|
||||
如果您对我的隐私政策有任何疑问或建议,请随时通过 CaptainIronng@protonmail.com 与我联系。
|
||||
|
||||
我们保留修改这份隐私条款的权利,但是会确保在更新条款前至少 30 天通过我们的可用渠道和应用内提示来通知用户。**在新条款生效后继续使用软件即表示您同意修改后的隐私条款。**
|
||||
@@ -2,35 +2,89 @@
|
||||
|
||||
A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-core) and [v2fly core](https://github.com/v2fly/v2ray-core)
|
||||
|
||||
[](https://developer.android.com/about/versions/lollipop)
|
||||
[](https://kotlinlang.org)
|
||||
[](https://developer.android.com/about/versions/lollipop)
|
||||
[](https://kotlinlang.org)
|
||||
[](https://github.com/2dust/v2rayNG/commits/master)
|
||||
[](https://www.codefactor.io/repository/github/2dust/v2rayng)
|
||||
[](https://github.com/2dust/v2rayNG/releases)
|
||||
[](https://t.me/v2rayn)
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=com.v2ray.ang">
|
||||
<img alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png" width="165" height="64" />
|
||||
</a>
|
||||
---
|
||||
|
||||
### Telegram Channel
|
||||
[github_2dust](https://t.me/github_2dust)
|
||||
## Download / 下载
|
||||
|
||||
### Usage
|
||||
Download the latest release here:
|
||||
|
||||
在这里下载最新版本:
|
||||
|
||||
[https://github.com/2dust/v2rayNG/releases](https://github.com/2dust/v2rayNG/releases)
|
||||
|
||||
> [!TIP]
|
||||
> v2rayNG is the mobile version. For the desktop version, please visit the v2rayN \
|
||||
> v2rayNG 是手机版,电脑版请访问 v2rayN
|
||||
>
|
||||
> https://github.com/2dust/v2rayN
|
||||
|
||||
---
|
||||
|
||||
### Geoip and Geosite
|
||||
|
||||
#### Geoip and Geosite
|
||||
- geoip.dat and geosite.dat files are in `Android/data/com.v2ray.ang/files/assets` (path may differ on some Android device)
|
||||
- download feature will get enhanced version in this [repo](https://github.com/Loyalsoldier/v2ray-rules-dat) (Note it need a working proxy)
|
||||
- latest official [domain list](https://github.com/v2fly/domain-list-community) and [ip list](https://github.com/v2fly/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)
|
||||
- download feature will get enhanced version in this [repo](https://github.com/Loyalsoldier/v2ray-rules-dat) (note: it needs a working proxy)
|
||||
- latest official [domain list](https://github.com/Loyalsoldier/v2ray-rules-dat) and [ip list](https://github.com/Loyalsoldier/geoip) can be imported manually
|
||||
- possible to use a third-party dat file in the same folder, like [h2y](https://guide.v2fly.org/routing/sitedata.html#%E5%A4%96%E7%BD%AE%E7%9A%84%E5%9F%9F%E5%90%8D%E6%96%87%E4%BB%B6)
|
||||
|
||||
### More in our [wiki](https://github.com/2dust/v2rayNG/wiki)
|
||||
More in our [wiki](https://github.com/2dust/v2rayNG/wiki)
|
||||
|
||||
### Development guide
|
||||
### Geoip 与 Geosite
|
||||
|
||||
Android project under V2rayNG folder can be compiled directly in Android Studio, or using Gradle wrapper. But the v2ray core inside the aar is (probably) outdated.
|
||||
The aar can be compiled from the Golang project [AndroidLibV2rayLite](https://github.com/2dust/AndroidLibV2rayLite) or [AndroidLibXrayLite](https://github.com/2dust/AndroidLibXrayLite).
|
||||
For a quick start, read guide for [Go Mobile](https://github.com/golang/go/wiki/Mobile) and [Makefiles for Go Developers](https://tutorialedge.net/golang/makefiles-for-go-developers/)
|
||||
- geoip.dat 和 geosite.dat 文件位于 `Android/data/com.v2ray.ang/files/assets`(部分设备路径可能不同)
|
||||
- 下载功能将获取该 [仓库](https://github.com/Loyalsoldier/v2ray-rules-dat) 中的增强版本(注意:此功能需要一个可用的代理)
|
||||
- 最新官方 [域名列表](https://github.com/Loyalsoldier/v2ray-rules-dat) 和 [IP 列表](https://github.com/Loyalsoldier/geoip) 可手动导入
|
||||
- 也可在同一文件夹中使用第三方 dat 文件,例如 [h2y](https://guide.v2fly.org/routing/sitedata.html#%E5%A4%96%E7%BD%AE%E7%9A%84%E5%9F%9F%E5%90%8D%E6%96%87%E4%BB%B6)
|
||||
|
||||
v2rayNG can run on Android Emulators. For WSA, VPN permission need to be granted via
|
||||
`appops set [package name] ACTIVATE_VPN allow`
|
||||
更多内容请见我们的 [wiki](https://github.com/2dust/v2rayNG/wiki)
|
||||
|
||||
---
|
||||
|
||||
## Development guide / 开发指南
|
||||
|
||||
### Note
|
||||
|
||||
- Android project under the V2rayNG folder can be compiled directly in Android Studio, or using the Gradle wrapper. But the v2ray core inside the aar is (probably) outdated.
|
||||
- The aar can be compiled from the Golang project [AndroidLibV2rayLite](https://github.com/2dust/AndroidLibV2rayLite) or [AndroidLibXrayLite](https://github.com/2dust/AndroidLibXrayLite). For a quick start, read the guides for [Go Mobile](https://github.com/golang/go/wiki/Mobile) and [Makefiles for Go Developers](https://tutorialedge.net/golang/makefiles-for-go-developers/).
|
||||
- v2rayNG can run on Android Emulators. For WSA, VPN permission needs to be granted via `appops set [package name] ACTIVATE_VPN allow`.
|
||||
|
||||
### 提示
|
||||
|
||||
- V2rayNG 文件夹下的 Android 项目可直接在 Android Studio 中编译,或使用 Gradle wrapper 编译。但 aar 内置的 v2ray core(可能)已过时。
|
||||
- aar 可由 Golang 项目 [AndroidLibV2rayLite](https://github.com/2dust/AndroidLibV2rayLite) 或 [AndroidLibXrayLite](https://github.com/2dust/AndroidLibXrayLite) 编译而成。快速入门可参考 [Go Mobile](https://github.com/golang/go/wiki/Mobile) 指南和 [Makefiles for Go Developers](https://tutorialedge.net/golang/makefiles-for-go-developers/)。
|
||||
- v2rayNG 可在 Android 模拟器上运行。对于 WSA,需要通过 `appops set [package name] ACTIVATE_VPN allow` 授予 VPN 权限。
|
||||
|
||||
---
|
||||
|
||||
|
||||
## GPG Verification / GPG 签名校验
|
||||
|
||||
Release files are signed with GPG to verify authenticity and integrity, helping prevent mirror, ISP, or CDN hijacking.
|
||||
|
||||
发布文件已使用 GPG 签名,可用于校验文件真实性与完整性,预防镜像站、运营商或 CDN 劫持。
|
||||
|
||||
### Fingerprint / 公钥指纹
|
||||
|
||||
```text
|
||||
7694 5E9F 3E9A 168F 8070 F195 805D 661C
|
||||
134D FAF6 8903 C199 463C 31E5 AE90 3AE0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Community / 社区
|
||||
|
||||
Telegram Group / Telegram 群组:
|
||||
|
||||
[https://t.me/v2rayN](https://t.me/v2rayN)
|
||||
|
||||
Telegram Channel / Telegram 频道:
|
||||
|
||||
[https://t.me/github_2dust](https://t.me/github_2dust)
|
||||
@@ -1,10 +0,0 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
*.apk
|
||||
signing.properties
|
||||
*.aar
|
||||
@@ -1,2 +0,0 @@
|
||||
/build
|
||||
/google-services.json
|
||||
@@ -1,119 +0,0 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion Integer.parseInt("$compileSdkVer")
|
||||
buildToolsVersion "$buildToolsVer"
|
||||
|
||||
compileOptions {
|
||||
targetCompatibility = "8"
|
||||
sourceCompatibility = "8"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.v2ray.ang"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion Integer.parseInt("$targetSdkVer")
|
||||
multiDexEnabled true
|
||||
versionCode 516
|
||||
versionName "1.8.5"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
zipAlignEnabled false
|
||||
shrinkResources false
|
||||
ndk.abiFilters 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a'
|
||||
}
|
||||
debug {
|
||||
minifyEnabled false
|
||||
zipAlignEnabled false
|
||||
shrinkResources false
|
||||
ndk.abiFilters 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a'
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
jniLibs.srcDirs = ['libs']
|
||||
java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
include 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a' //select ABIs to build APKs for
|
||||
universalApk true //generate an additional APK that contains all the ABIs
|
||||
}
|
||||
}
|
||||
|
||||
// map for the version code
|
||||
project.ext.versionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
|
||||
|
||||
android.applicationVariants.all { variant ->
|
||||
// assign different version code for each output
|
||||
variant.outputs.each { output ->
|
||||
output.outputFileName = "v2rayNG_" + variant.versionName + "_" + output.getFilter(com.android.build.OutputFile.ABI) + ".apk"
|
||||
|
||||
output.versionCodeOverride =
|
||||
project.ext.versionCodes.get(output.getFilter(com.android.build.OutputFile.ABI), 0) *
|
||||
1000000 + android.defaultConfig.versionCode
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
namespace 'com.v2ray.ang'
|
||||
testNamespace 'com.v2ray.angTest'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.aar', '*.jar'], exclude: [])
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
|
||||
// Androidx
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.8.0'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.5.6'
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||
|
||||
// Androidx ktx
|
||||
implementation 'androidx.activity:activity-ktx:1.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
|
||||
|
||||
//kotlin
|
||||
implementation "org.jetbrains.kotlin:kotlin-reflect:1.8.0"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
|
||||
|
||||
implementation 'com.tencent:mmkv-static:1.2.15'
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
implementation 'io.reactivex:rxjava:1.3.8'
|
||||
implementation 'io.reactivex:rxandroid:1.2.1'
|
||||
implementation 'com.tbruyelle.rxpermissions:rxpermissions:0.9.4@aar'
|
||||
implementation 'com.github.jorgecastilloprz:fabprogresscircle:1.01@aar'
|
||||
implementation 'me.drakeet.support:toastcompat:1.1.0'
|
||||
implementation 'com.blacksquircle.ui:editorkit:2.1.1'
|
||||
implementation 'com.blacksquircle.ui:language-base:2.1.1'
|
||||
implementation 'com.blacksquircle.ui:language-json:2.1.1'
|
||||
implementation 'io.github.g00fy2.quickie:quickie-bundled:1.6.0'
|
||||
implementation 'com.google.zxing:core:3.5.1'
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
id("com.jaredsburrows.license")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.v2ray.ang"
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 736
|
||||
versionName = "2.2.6"
|
||||
multiDexEnabled = true
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
reset()
|
||||
if (!abiFilterList.isNullOrEmpty()) {
|
||||
include(*abiFilterList.toTypedArray())
|
||||
} else {
|
||||
include(
|
||||
"arm64-v8a",
|
||||
"armeabi-v7a",
|
||||
"x86_64",
|
||||
"x86"
|
||||
)
|
||||
}
|
||||
isUniversalApk = abiFilterList.isNullOrEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions.add("distribution")
|
||||
productFlavors {
|
||||
create("fdroid") {
|
||||
dimension = "distribution"
|
||||
applicationIdSuffix = ".fdroid"
|
||||
buildConfigField("String", "DISTRIBUTION", "\"F-Droid\"")
|
||||
}
|
||||
create("playstore") {
|
||||
dimension = "distribution"
|
||||
buildConfigField("String", "DISTRIBUTION", "\"Play Store\"")
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
jniLibs.srcDirs("libs")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
val variant = this
|
||||
val isFdroid = variant.productFlavors.any { it.name == "fdroid" }
|
||||
if (isFdroid) {
|
||||
val versionCodes =
|
||||
mapOf(
|
||||
"armeabi-v7a" to 2, "arm64-v8a" to 1, "x86" to 4, "x86_64" to 3, "universal" to 0
|
||||
)
|
||||
|
||||
variant.outputs
|
||||
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
|
||||
.forEach { output ->
|
||||
val abi = output.getFilter("ABI") ?: "universal"
|
||||
output.outputFileName = "v2rayNG_${variant.versionName}-fdroid_${abi}.apk"
|
||||
if (versionCodes.containsKey(abi)) {
|
||||
output.versionCodeOverride =
|
||||
(100 * variant.versionCode + versionCodes[abi]!!).plus(5000000)
|
||||
} else {
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val versionCodes =
|
||||
mapOf("armeabi-v7a" to 4, "arm64-v8a" to 4, "x86" to 4, "x86_64" to 4, "universal" to 4)
|
||||
|
||||
variant.outputs
|
||||
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
|
||||
.forEach { output ->
|
||||
val abi = if (output.getFilter("ABI") != null)
|
||||
output.getFilter("ABI")
|
||||
else
|
||||
"universal"
|
||||
|
||||
output.outputFileName = "v2rayNG_${variant.versionName}_${abi}.apk"
|
||||
if (versionCodes.containsKey(abi)) {
|
||||
output.versionCodeOverride =
|
||||
(1000000 * versionCodes[abi]!!).plus(variant.versionCode)
|
||||
} else {
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core Libraries
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar", "*.jar"))))
|
||||
|
||||
// AndroidX Core Libraries
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.preference.ktx)
|
||||
implementation(libs.recyclerview)
|
||||
implementation(libs.androidx.swiperefreshlayout)
|
||||
implementation(libs.androidx.viewpager2)
|
||||
implementation(libs.androidx.fragment)
|
||||
|
||||
// UI Libraries
|
||||
implementation(libs.material)
|
||||
implementation(libs.toasty)
|
||||
implementation(libs.editorkit)
|
||||
implementation(libs.flexbox)
|
||||
|
||||
// Data and Storage Libraries
|
||||
implementation(libs.mmkv.static)
|
||||
implementation(libs.gson)
|
||||
implementation(libs.okhttp)
|
||||
|
||||
// Reactive and Utility Libraries
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
// Language and Processing Libraries
|
||||
implementation(libs.language.base)
|
||||
implementation(libs.language.json)
|
||||
|
||||
// Intent and Utility Libraries
|
||||
implementation(libs.quickie.foss)
|
||||
implementation(libs.core)
|
||||
|
||||
// AndroidX Lifecycle and Architecture Components
|
||||
implementation(libs.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.lifecycle.livedata.ktx)
|
||||
implementation(libs.lifecycle.runtime.ktx)
|
||||
|
||||
// Background Task Libraries
|
||||
implementation(libs.work.runtime.ktx)
|
||||
implementation(libs.work.multiprocess)
|
||||
|
||||
// Multidex Support
|
||||
implementation(libs.multidex)
|
||||
|
||||
// Testing Libraries
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
testImplementation(libs.org.mockito.mockito.inline)
|
||||
testImplementation(libs.mockito.kotlin)
|
||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.v2ray.ang;
|
||||
|
||||
import android.app.Application;
|
||||
import android.test.ApplicationTestCase;
|
||||
|
||||
/**
|
||||
* <a href="http://d.android.com/tools/testing/testing_android.html">Testing Fundamentals</a>
|
||||
*/
|
||||
public class ApplicationTest extends ApplicationTestCase<Application> {
|
||||
public ApplicationTest() {
|
||||
super(Application.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<item name="app_name" type="string">v2rayNG (DEV)</item>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -1,190 +1,312 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:ignore="MissingLeanbackLauncher">
|
||||
|
||||
<supports-screens
|
||||
android:anyDensity="true"
|
||||
android:smallScreens="true"
|
||||
android:normalScreens="true"
|
||||
android:largeScreens="true"
|
||||
android:xlargeScreens="true"/>
|
||||
android:normalScreens="true"
|
||||
android:smallScreens="true"
|
||||
android:xlargeScreens="true" />
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false"/>
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera.autofocus"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
|
||||
<!-- https://developer.android.com/about/versions/11/privacy/package-visibility -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<!-- <useapplications-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"
|
||||
tools:ignore="ForegroundServicesPolicy" />
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
|
||||
android:minSdkVersion="34" />
|
||||
<!-- <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:name=".AngApplication"
|
||||
android:allowBackup="true"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppThemeDayNight"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="m">
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar">
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
<activity
|
||||
android:exported="false"
|
||||
android:name=".ui.ServerActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged" />
|
||||
<activity
|
||||
android:exported="false"
|
||||
android:name=".ui.ServerCustomConfigActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="stateUnchanged" />
|
||||
<activity
|
||||
android:name=".ui.ServerGroupActivity"
|
||||
android:exported="false"
|
||||
android:name=".ui.SettingsActivity" />
|
||||
<activity
|
||||
android:exported="false"
|
||||
android:name=".ui.PerAppProxyActivity" />
|
||||
<activity
|
||||
android:exported="false"
|
||||
android:name=".ui.ScannerActivity" />
|
||||
<activity
|
||||
android:exported="false"
|
||||
android:name=".ui.LogcatActivity" />
|
||||
<activity
|
||||
android:exported="false"
|
||||
android:name=".ui.RoutingSettingsActivity"
|
||||
android:windowSoftInputMode="stateUnchanged" />
|
||||
<activity
|
||||
android:name=".ui.ServerProxyChainActivity"
|
||||
android:exported="false"
|
||||
android:name=".ui.SubSettingActivity" />
|
||||
android:windowSoftInputMode="stateUnchanged" />
|
||||
<activity
|
||||
android:exported="false"
|
||||
android:name=".ui.UserAssetActivity" />
|
||||
android:name=".ui.SettingsActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.PerAppProxyActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.AppPickerActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.ScannerActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.LogcatActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.RoutingSettingActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.RoutingEditActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.SubSettingActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.UserAssetActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.UserAssetUrlActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:exported="false"
|
||||
android:name=".ui.SubEditActivity" />
|
||||
android:name=".ui.SubEditActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:exported="false"
|
||||
android:name=".ui.ScScannerActivity" />
|
||||
android:name=".ui.ScScannerActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:exported="false"
|
||||
android:name=".ui.ScSwitchActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:theme="@style/AppTheme.NoActionBar.Translucent" />
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
<activity
|
||||
android:name=".ui.ScStartActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
<activity
|
||||
android:name=".ui.ScStopActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
|
||||
|
||||
<activity
|
||||
android:exported="true"
|
||||
android:name=".ui.UrlSchemeActivity">
|
||||
android:name=".ui.UrlSchemeActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:scheme="v2rayng"
|
||||
android:host="install-config" />
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:scheme="v2rayng" />
|
||||
<data android:host="install-config" />
|
||||
<data android:host="install-sub" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.CheckUpdateActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.BackupActivity"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".ui.AboutActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".service.V2RayVpnService"
|
||||
android:name=".service.CoreVpnService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
tools:ignore="VpnServicePolicy">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
|
||||
android:value="true" />
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="vpn" />
|
||||
</service>
|
||||
|
||||
<service android:name=".service.V2RayProxyOnlyService"
|
||||
android:exported="false"
|
||||
android:label="@string/app_name"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
</service>
|
||||
|
||||
<service android:name=".service.V2RayTestService"
|
||||
<service
|
||||
android:name=".service.CoreProxyOnlyService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:label="@string/app_name"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="proxy" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".service.CoreRootService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:label="@string/app_name"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="proxy" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".service.CoreTestService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="test" />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:exported="true"
|
||||
android:name=".receiver.WidgetProvider"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:name=".receiver.WidgetProvider"
|
||||
android:exported="true"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/app_widget_provider" />
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/app_widget_provider" />
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
<action android:name="com.v2ray.ang.action.widget.click" />
|
||||
<action android:name="com.v2ray.ang.action.activity" />
|
||||
<action android:name="${applicationId}.action.widget.click" />
|
||||
<action android:name="${applicationId}.action.activity" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".receiver.BootReceiver"
|
||||
android:exported="true"
|
||||
android:label="BootReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:exported="true"
|
||||
android:name=".service.QSTileService"
|
||||
android:icon="@drawable/ic_stat_name"
|
||||
android:label="@string/app_tile_name"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:name=".service.QSTileService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:icon="@drawable/ic_stat_name"
|
||||
android:label="@string/app_tile_name"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="tile" />
|
||||
</service>
|
||||
<!-- =====================Tasker===================== -->
|
||||
<activity
|
||||
android:exported="true"
|
||||
android:name=".ui.TaskerActivity"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name">
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<intent-filter>
|
||||
<action android:name="com.twofortyfouram.locale.intent.action.EDIT_SETTING" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:exported="true"
|
||||
android:name=".receiver.TaskerReceiver"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:exported="true"
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
tools:ignore="ExportedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="com.twofortyfouram.locale.intent.action.FIRE_SETTING" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<!-- =====================Tasker===================== -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup"
|
||||
tools:node="remove" />
|
||||
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name="androidx.work.multiprocess.RemoteWorkManagerService"
|
||||
android:exported="false"
|
||||
android:process=":bg" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.cache"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/cache_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
[
|
||||
{
|
||||
"remarks": "绕过bittorrent",
|
||||
"outboundTag": "direct",
|
||||
"protocol": [
|
||||
"bittorrent"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "阻断udp443",
|
||||
"outboundTag": "block",
|
||||
"port": "443",
|
||||
"network": "udp"
|
||||
},
|
||||
{
|
||||
"remarks": "代理Google",
|
||||
"outboundTag": "proxy",
|
||||
"domain": [
|
||||
"geosite:google"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "绕过局域网IP",
|
||||
"outboundTag": "direct",
|
||||
"ip": [
|
||||
"geoip:private"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "绕过局域网域名",
|
||||
"outboundTag": "direct",
|
||||
"domain": [
|
||||
"geosite:private"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "代理海外公共DNSIP",
|
||||
"outboundTag": "proxy",
|
||||
"ip": [
|
||||
"1.1.1.1",
|
||||
"1.0.0.1",
|
||||
"2606:4700:4700::1111",
|
||||
"2606:4700:4700::1001",
|
||||
"1.1.1.2",
|
||||
"1.0.0.2",
|
||||
"2606:4700:4700::1112",
|
||||
"2606:4700:4700::1002",
|
||||
"1.1.1.3",
|
||||
"1.0.0.3",
|
||||
"2606:4700:4700::1113",
|
||||
"2606:4700:4700::1003",
|
||||
"8.8.8.8",
|
||||
"8.8.4.4",
|
||||
"2001:4860:4860::8888",
|
||||
"2001:4860:4860::8844",
|
||||
"94.140.14.14",
|
||||
"94.140.15.15",
|
||||
"2a10:50c0::ad1:ff",
|
||||
"2a10:50c0::ad2:ff",
|
||||
"94.140.14.15",
|
||||
"94.140.15.16",
|
||||
"2a10:50c0::bad1:ff",
|
||||
"2a10:50c0::bad2:ff",
|
||||
"94.140.14.140",
|
||||
"94.140.14.141",
|
||||
"2a10:50c0::1:ff",
|
||||
"2a10:50c0::2:ff",
|
||||
"208.67.222.222",
|
||||
"208.67.220.220",
|
||||
"2620:119:35::35",
|
||||
"2620:119:53::53",
|
||||
"208.67.222.123",
|
||||
"208.67.220.123",
|
||||
"2620:119:35::123",
|
||||
"2620:119:53::123",
|
||||
"9.9.9.9",
|
||||
"149.112.112.112",
|
||||
"2620:fe::9",
|
||||
"2620:fe::fe",
|
||||
"9.9.9.11",
|
||||
"149.112.112.11",
|
||||
"2620:fe::11",
|
||||
"2620:fe::fe:11",
|
||||
"9.9.9.10",
|
||||
"149.112.112.10",
|
||||
"2620:fe::10",
|
||||
"2620:fe::fe:10",
|
||||
"77.88.8.8",
|
||||
"77.88.8.1",
|
||||
"2a02:6b8::feed:0ff",
|
||||
"2a02:6b8:0:1::feed:0ff",
|
||||
"77.88.8.88",
|
||||
"77.88.8.2",
|
||||
"2a02:6b8::feed:bad",
|
||||
"2a02:6b8:0:1::feed:bad",
|
||||
"77.88.8.7",
|
||||
"77.88.8.3",
|
||||
"2a02:6b8::feed:a11",
|
||||
"2a02:6b8:0:1::feed:a11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "代理海外公共DNS域名",
|
||||
"outboundTag": "proxy",
|
||||
"domain": [
|
||||
"domain:cloudflare-dns.com",
|
||||
"domain:one.one.one.one",
|
||||
"domain:dns.google",
|
||||
"domain:adguard-dns.com",
|
||||
"domain:opendns.com",
|
||||
"domain:umbrella.com",
|
||||
"domain:quad9.net",
|
||||
"domain:yandex.net"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "代理IP",
|
||||
"outboundTag": "proxy",
|
||||
"ip": [
|
||||
"geoip:facebook",
|
||||
"geoip:fastly",
|
||||
"geoip:google",
|
||||
"geoip:netflix",
|
||||
"geoip:telegram",
|
||||
"geoip:twitter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "代理GFW",
|
||||
"outboundTag": "proxy",
|
||||
"domain": [
|
||||
"geosite:gfw",
|
||||
"geosite:greatfire"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "最终直连",
|
||||
"port": "0-65535",
|
||||
"outboundTag": "direct"
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
geosite:category-ads-all,
|
||||
@@ -1,132 +0,0 @@
|
||||
domain:12306.com,
|
||||
domain:51ym.me,
|
||||
domain:52pojie.cn,
|
||||
domain:8686c.com,
|
||||
domain:abercrombie.com,
|
||||
domain:adobesc.com,
|
||||
domain:air-matters.com,
|
||||
domain:air-matters.io,
|
||||
domain:airtable.com,
|
||||
domain:akadns.net,
|
||||
domain:apache.org,
|
||||
domain:api.crisp.chat,
|
||||
domain:api.termius.com,
|
||||
domain:appshike.com,
|
||||
domain:appstore.com,
|
||||
domain:aweme.snssdk.com,
|
||||
domain:bababian.com,
|
||||
domain:battle.net,
|
||||
domain:beatsbydre.com,
|
||||
domain:bet365.com,
|
||||
domain:bilibili.cn,
|
||||
domain:ccgslb.com,
|
||||
domain:ccgslb.net,
|
||||
domain:chunbo.com,
|
||||
domain:chunboimg.com,
|
||||
domain:clashroyaleapp.com,
|
||||
domain:cloudsigma.com,
|
||||
domain:cloudxns.net,
|
||||
domain:cmfu.com,
|
||||
domain:culturedcode.com,
|
||||
domain:dct-cloud.com,
|
||||
domain:didialift.com,
|
||||
domain:douyutv.com,
|
||||
domain:duokan.com,
|
||||
domain:dytt8.net,
|
||||
domain:easou.com,
|
||||
domain:ecitic.net,
|
||||
domain:eclipse.org,
|
||||
domain:eudic.net,
|
||||
domain:ewqcxz.com,
|
||||
domain:fir.im,
|
||||
domain:frdic.com,
|
||||
domain:fresh-ideas.cc,
|
||||
domain:godic.net,
|
||||
domain:goodread.com,
|
||||
domain:haibian.com,
|
||||
domain:hdslb.net,
|
||||
domain:hollisterco.com,
|
||||
domain:hongxiu.com,
|
||||
domain:hxcdn.net,
|
||||
domain:images.unsplash.com,
|
||||
domain:img4me.com,
|
||||
domain:ipify.org,
|
||||
domain:ixdzs.com,
|
||||
domain:jd.hk,
|
||||
domain:jianshuapi.com,
|
||||
domain:jomodns.com,
|
||||
domain:jsboxbbs.com,
|
||||
domain:knewone.com,
|
||||
domain:kuaidi100.com,
|
||||
domain:lemicp.com,
|
||||
domain:letvcloud.com,
|
||||
domain:lizhi.io,
|
||||
domain:localizecdn.com,
|
||||
domain:lucifr.com,
|
||||
domain:luoo.net,
|
||||
domain:mai.tn,
|
||||
domain:maven.org,
|
||||
domain:miwifi.com,
|
||||
domain:moji.com,
|
||||
domain:moke.com,
|
||||
domain:mtalk.google.com,
|
||||
domain:mxhichina.com,
|
||||
domain:myqcloud.com,
|
||||
domain:myunlu.com,
|
||||
domain:netease.com,
|
||||
domain:nfoservers.com,
|
||||
domain:nssurge.com,
|
||||
domain:nuomi.com,
|
||||
domain:ourdvs.com,
|
||||
domain:overcast.fm,
|
||||
domain:paypal.com,
|
||||
domain:paypalobjects.com,
|
||||
domain:pgyer.com,
|
||||
domain:qdaily.com,
|
||||
domain:qdmm.com,
|
||||
domain:qin.io,
|
||||
domain:qingmang.me,
|
||||
domain:qingmang.mobi,
|
||||
domain:qqurl.com,
|
||||
domain:rarbg.to,
|
||||
domain:rrmj.tv,
|
||||
domain:ruguoapp.com,
|
||||
domain:sm.ms,
|
||||
domain:snwx.com,
|
||||
domain:soku.com,
|
||||
domain:startssl.com,
|
||||
domain:store.steampowered.com,
|
||||
domain:symcd.com,
|
||||
domain:teamviewer.com,
|
||||
domain:tmzvps.com,
|
||||
domain:trello.com,
|
||||
domain:trellocdn.com,
|
||||
domain:ttmeiju.com,
|
||||
domain:udache.com,
|
||||
domain:uxengine.net,
|
||||
domain:weather.bjango.com,
|
||||
domain:weather.com,
|
||||
domain:webqxs.com,
|
||||
domain:weico.cc,
|
||||
domain:wenku8.net,
|
||||
domain:werewolf.53site.com,
|
||||
domain:windowsupdate.com,
|
||||
domain:wkcdn.com,
|
||||
domain:workflowy.com,
|
||||
domain:xdrig.com,
|
||||
domain:xiaojukeji.com,
|
||||
domain:xiaomi.net,
|
||||
domain:xiaomicp.com,
|
||||
domain:ximalaya.com,
|
||||
domain:xitek.com,
|
||||
domain:xmcdn.com,
|
||||
domain:xslb.net,
|
||||
domain:xteko.com,
|
||||
domain:yach.me,
|
||||
domain:yixia.com,
|
||||
domain:yunjiasu-cdn.net,
|
||||
domain:zealer.com,
|
||||
domain:zgslb.net,
|
||||
domain:zimuzu.tv,
|
||||
domain:zmz002.com,
|
||||
domain:samsungdm.com,
|
||||
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"remarks": "阻断udp443",
|
||||
"outboundTag": "block",
|
||||
"port": "443",
|
||||
"network": "udp"
|
||||
},
|
||||
{
|
||||
"remarks": "绕过局域网IP",
|
||||
"outboundTag": "direct",
|
||||
"ip": [
|
||||
"geoip:private"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "绕过局域网域名",
|
||||
"outboundTag": "direct",
|
||||
"domain": [
|
||||
"geosite:private"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "最终代理",
|
||||
"port": "0-65535",
|
||||
"outboundTag": "proxy"
|
||||
}
|
||||
]
|
||||
@@ -1,33 +0,0 @@
|
||||
geosite:google,
|
||||
geosite:github,
|
||||
geosite:netflix,
|
||||
geosite:steam,
|
||||
geosite:telegram,
|
||||
geosite:tumblr,
|
||||
geosite:speedtest,
|
||||
geosite:bbc,
|
||||
domain:gvt1.com,
|
||||
domain:textnow.com,
|
||||
domain:twitch.tv,
|
||||
domain:wikileaks.org,
|
||||
domain:naver.com,
|
||||
91.108.4.0/22,
|
||||
91.108.8.0/22,
|
||||
91.108.12.0/22,
|
||||
91.108.20.0/22,
|
||||
91.108.36.0/23,
|
||||
91.108.38.0/23,
|
||||
91.108.56.0/22,
|
||||
149.154.160.0/20,
|
||||
149.154.164.0/22,
|
||||
149.154.172.0/22,
|
||||
74.125.0.0/16,
|
||||
173.194.0.0/16,
|
||||
172.217.0.0/16,
|
||||
216.58.200.0/24,
|
||||
216.58.220.0/24,
|
||||
91.108.56.116,
|
||||
91.108.56.0/24,
|
||||
109.239.140.0/24,
|
||||
149.154.167.0/24,
|
||||
149.154.175.0/24,
|
||||
@@ -0,0 +1,95 @@
|
||||
[
|
||||
{
|
||||
"remarks": "阻断udp443",
|
||||
"outboundTag": "block",
|
||||
"port": "443",
|
||||
"network": "udp"
|
||||
},
|
||||
{
|
||||
"remarks": "代理Google",
|
||||
"outboundTag": "proxy",
|
||||
"domain": [
|
||||
"geosite:google"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "绕过局域网IP",
|
||||
"outboundTag": "direct",
|
||||
"ip": [
|
||||
"geoip:private"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "绕过局域网域名",
|
||||
"outboundTag": "direct",
|
||||
"domain": [
|
||||
"geosite:private"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "绕过中国公共DNSIP",
|
||||
"outboundTag": "direct",
|
||||
"ip": [
|
||||
"223.5.5.5",
|
||||
"223.6.6.6",
|
||||
"2400:3200::1",
|
||||
"2400:3200:baba::1",
|
||||
"119.29.29.29",
|
||||
"1.12.12.12",
|
||||
"120.53.53.53",
|
||||
"2402:4e00::",
|
||||
"2402:4e00:1::",
|
||||
"180.76.76.76",
|
||||
"2400:da00::6666",
|
||||
"114.114.114.114",
|
||||
"114.114.115.115",
|
||||
"114.114.114.119",
|
||||
"114.114.115.119",
|
||||
"114.114.114.110",
|
||||
"114.114.115.110",
|
||||
"180.184.1.1",
|
||||
"180.184.2.2",
|
||||
"101.226.4.6",
|
||||
"218.30.118.6",
|
||||
"123.125.81.6",
|
||||
"140.207.198.6",
|
||||
"1.2.4.8",
|
||||
"210.2.4.8",
|
||||
"52.80.66.66",
|
||||
"117.50.22.22",
|
||||
"2400:7fc0:849e:200::4",
|
||||
"2404:c2c0:85d8:901::4",
|
||||
"117.50.10.10",
|
||||
"52.80.52.52",
|
||||
"2400:7fc0:849e:200::8",
|
||||
"2404:c2c0:85d8:901::8",
|
||||
"117.50.60.30",
|
||||
"52.80.60.30"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "绕过中国公共DNS域名",
|
||||
"outboundTag": "direct",
|
||||
"domain": [
|
||||
"domain:alidns.com",
|
||||
"domain:doh.pub",
|
||||
"domain:dot.pub",
|
||||
"domain:360.cn",
|
||||
"domain:onedns.net"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "绕过中国IP",
|
||||
"outboundTag": "direct",
|
||||
"ip": [
|
||||
"geoip:cn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "绕过中国域名",
|
||||
"outboundTag": "direct",
|
||||
"domain": [
|
||||
"geosite:cn"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"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 Iran domains",
|
||||
"outboundTag": "direct",
|
||||
"domain": [
|
||||
"domain:ir",
|
||||
"geosite:category-ir"
|
||||
]
|
||||
},
|
||||
{
|
||||
"remarks": "Bypass Iran IP",
|
||||
"outboundTag": "direct",
|
||||
"ip": [
|
||||
"geoip:ir"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large.
Load diff
+173
-1
@@ -4,6 +4,7 @@ au.com.shiftyjelly.pocketcasts
|
||||
bbc.mobile.news.ww
|
||||
be.mygod.vpnhotspot
|
||||
ch.protonmail.android
|
||||
cm.aptoide.pt
|
||||
co.wanqu.android
|
||||
com.alphainventor.filemanager
|
||||
com.amazon.kindle
|
||||
@@ -34,7 +35,9 @@ com.chrome.canary
|
||||
com.chrome.dev
|
||||
com.cl.newt66y
|
||||
com.cradle.iitc_mobile
|
||||
org.exarhteam.iitc_mobile
|
||||
com.cygames.shadowverse
|
||||
com.dcard.freedom
|
||||
com.devhd.feedly
|
||||
com.devolver.reigns2
|
||||
com.discord
|
||||
@@ -108,6 +111,7 @@ com.ifttt.ifttt
|
||||
com.imgur.mobile
|
||||
com.innologica.inoreader
|
||||
com.instagram.android
|
||||
com.instagram.lite
|
||||
com.instapaper.android
|
||||
com.jarvanh.vpntether
|
||||
com.kapp.youtube.final
|
||||
@@ -115,6 +119,7 @@ com.klinker.android.twitter_l
|
||||
com.lastpass.lpandroid
|
||||
com.linecorp.linelite
|
||||
com.lingodeer
|
||||
com.ltnnews.news
|
||||
com.mediapods.tumbpods
|
||||
com.mgoogle.android.gms
|
||||
com.microsoft.emmx
|
||||
@@ -159,6 +164,7 @@ com.slack
|
||||
com.snaptube.premium
|
||||
com.sololearn
|
||||
com.sonelli.juicessh
|
||||
com.sparkslab.dcardreader
|
||||
com.spotify.music
|
||||
com.tencent.huatuo
|
||||
com.termux
|
||||
@@ -173,10 +179,13 @@ com.twitter.android
|
||||
com.u91porn
|
||||
com.u9porn
|
||||
com.ubisoft.dance.justdance2015companion
|
||||
com.udn.news
|
||||
com.utopia.pxview
|
||||
com.valvesoftware.android.steam.communimunity
|
||||
com.valvesoftware.android.steam.community
|
||||
com.vanced.manager
|
||||
com.vanced.android.youtube
|
||||
com.vanced.android.apps.youtube.music
|
||||
com.mgoogle.android.gms
|
||||
com.vimeo.android.videoapp
|
||||
com.vivaldi.browser
|
||||
com.vivaldi.browser.snapshot
|
||||
@@ -186,10 +195,12 @@ com.wire
|
||||
com.wuxiangai.refactor
|
||||
com.xda.labs
|
||||
com.xvideos.app
|
||||
com.yahoo.mobile.client.android.superapp
|
||||
com.yandex.browser
|
||||
com.yandex.browser.beta
|
||||
com.yandex.browser.alpha
|
||||
com.z28j.feel
|
||||
com.zhiliaoapp.musically
|
||||
con.medium.reader
|
||||
de.apkgrabber
|
||||
de.robv.android.xposed.installer
|
||||
@@ -210,6 +221,7 @@ jp.bokete.app.android
|
||||
jp.naver.line.android
|
||||
jp.pxv.android
|
||||
luo.speedometergpspro
|
||||
m.cna.com.tw.App
|
||||
mark.via.gp
|
||||
me.tshine.easymark
|
||||
net.teeha.android.url_shortener
|
||||
@@ -226,6 +238,7 @@ org.mozilla.firefox_beta
|
||||
org.mozilla.focus
|
||||
org.schabi.newpipe
|
||||
org.telegram.messenger
|
||||
org.telegram.messenger.web
|
||||
org.telegram.multi
|
||||
org.telegram.plus
|
||||
org.thunderdog.challegram
|
||||
@@ -239,3 +252,162 @@ tw.com.gamer.android.activecenter
|
||||
videodownloader.downloadvideo.downloader
|
||||
uk.co.bbc.learningenglish
|
||||
com.ted.android
|
||||
de.danoeh.antennapod
|
||||
com.kiwibrowser.browser
|
||||
nekox.messenger
|
||||
com.nextcloud.client
|
||||
com.aurora.store
|
||||
com.aurora.adroid
|
||||
chat.simplex.app
|
||||
im.vector.app
|
||||
network.loki.messenger
|
||||
eu.siacs.conversations
|
||||
xyz.nextalone.nagram
|
||||
net.programmierecke.radiodroid2
|
||||
im.fdx.v2ex
|
||||
ml.docilealligator.infinityforreddit
|
||||
com.bytemyth.ama
|
||||
app.vanadium.browser
|
||||
com.cakewallet.cake_wallet
|
||||
org.purplei2p.i2pd
|
||||
dk.tacit.android.foldersync.lite
|
||||
com.nononsenseapps.feeder
|
||||
com.m2049r.xmrwallet
|
||||
com.paypal.android.p2pmobile
|
||||
com.google.android.apps.googlevoice
|
||||
com.readdle.spark
|
||||
org.torproject.torbrowser
|
||||
com.deepl.mobiletranslator
|
||||
com.microsoft.bing
|
||||
com.keylesspalace.tusky
|
||||
com.ottplay.ottplay
|
||||
ru.iptvremote.android.iptv.pro
|
||||
jp.naver.line.android
|
||||
com.xmflsct.app.tooot
|
||||
com.forem.android
|
||||
app.revanced.android.youtube
|
||||
com.mgoogle.android.gms
|
||||
com.pionex.client
|
||||
vip.mytokenpocket
|
||||
im.token.app
|
||||
com.linekong.mars24
|
||||
com.feixiaohao
|
||||
com.aicoin.appandroid
|
||||
com.binance.dev
|
||||
com.kraken.trade
|
||||
com.okinc.okex.gp
|
||||
com.authy.authy
|
||||
air.com.rosettastone.mobile.CoursePlayer
|
||||
com.blizzard.bma
|
||||
com.amazon.kindle
|
||||
com.google.android.apps.fitness
|
||||
net.tsapps.appsales
|
||||
com.wemesh.android
|
||||
com.google.android.apps.googleassistant
|
||||
allen.town.focus.reader
|
||||
me.hyliu.fluent_reader_lite
|
||||
com.aljazeera.mobile
|
||||
com.ft.news
|
||||
de.marmaro.krt.ffupdater
|
||||
myradio.radio.fmradio.liveradio.radiostation
|
||||
com.google.earth
|
||||
eu.kanade.tachiyomi.j2k
|
||||
com.audials
|
||||
com.microsoft.skydrive
|
||||
com.mb.android.tg
|
||||
com.melodis.midomiMusicIdentifier.freemium
|
||||
com.foxnews.android
|
||||
ch.threema.app
|
||||
com.briarproject.briar.android
|
||||
foundation.e.apps
|
||||
com.valvesoftware.android.steam.friendsui
|
||||
com.imback.yeetalk
|
||||
so.onekey.app.wallet
|
||||
com.xc3fff0e.xmanager
|
||||
meditofoundation.medito
|
||||
com.picol.client
|
||||
com.streetwriters.notesnook
|
||||
shanghai.panewsApp.com
|
||||
org.coursera.android
|
||||
com.positron_it.zlib
|
||||
com.blizzard.messenger
|
||||
com.javdb.javrocket
|
||||
com.picacomic.fregata
|
||||
com.fxl.chacha
|
||||
me.proton.android.drive
|
||||
com.lastpass.lpandroid
|
||||
com.tradingview.tradingviewapp
|
||||
com.deviantart.android.damobile
|
||||
com.fusionmedia.investing
|
||||
com.ewa.ewaapp
|
||||
com.duolingo
|
||||
com.hellotalk
|
||||
io.github.huskydg.magisk
|
||||
com.jsy.xpgbox
|
||||
com.hostloc.app.hostloc
|
||||
com.dena.pokota
|
||||
com.vitorpamplona.amethyst
|
||||
com.zhiliaoapp.musically
|
||||
us.spotco.fennec_dos
|
||||
com.fongmi.android.tv
|
||||
com.pocketprep.android.itcybersecurity
|
||||
com.cloudtv
|
||||
com.glassdoor.app
|
||||
com.indeed.android.jobsearch
|
||||
com.linkedin.android
|
||||
com.github.tvbox.osc.bh
|
||||
com.example.douban
|
||||
com.sipnetic.app
|
||||
com.microsoft.rdc.androidx
|
||||
org.zwanoo.android.speedtest
|
||||
com.sonelli.juicessh
|
||||
com.scmp.newspulse
|
||||
org.lsposed.manager
|
||||
mnn.Android
|
||||
com.thomsonretuers.reuters
|
||||
com.guardian
|
||||
com.ttxapps.onesyncv2
|
||||
org.fcitx.fcitx5.android.updater
|
||||
com.tailscale.ipn
|
||||
tw.nekomimi.nekogram
|
||||
com.nexon.kartdrift
|
||||
io.syncapps.lemmy_sync
|
||||
com.seazon.feedme
|
||||
com.readwise
|
||||
de.spiritcroc.riotx
|
||||
com.openai.chatgpt
|
||||
io.changenow.changenow
|
||||
com.poe.android
|
||||
com.twingate
|
||||
com.blinkslabs.blinkist.android
|
||||
com.ichi2.anki
|
||||
md.obsidian
|
||||
com.musixmatch.android.lyrify
|
||||
com.cyber.turbo
|
||||
com.offsec.nethunter
|
||||
me.ghui.v2er
|
||||
com.samruston.twitter
|
||||
org.adaway
|
||||
org.swiftapps.swiftbackup
|
||||
com.zerotier.one
|
||||
com.quietmobile
|
||||
com.instagram.barcelona
|
||||
im.molly.app
|
||||
com.rvx.android.youtube
|
||||
com.deepl.mobiletranslator
|
||||
com.qingsong.yingmi
|
||||
com.lemurbrowser.exts
|
||||
com.silverdev.dnartdroid
|
||||
me.ash.reader
|
||||
de.tutao.tutanota
|
||||
dev.imranr.obtainium
|
||||
com.getsomeheadspace.android
|
||||
org.cromite.cromite
|
||||
com.nutomic.syncthingandroid
|
||||
com.bumble.app
|
||||
com.cnn.mobile.android.phone
|
||||
com.google.android.apps.authenticator2
|
||||
com.microsoft.copilot
|
||||
com.netflix.NGP.Storyteller
|
||||
com.Slack
|
||||
com.server.auditor.ssh.client
|
||||
@@ -30,17 +30,10 @@
|
||||
"enabled": true,
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls"
|
||||
"tls",
|
||||
"quic"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "http",
|
||||
"port": 10809,
|
||||
"protocol": "http",
|
||||
"settings": {
|
||||
"userLevel": 8
|
||||
}
|
||||
}
|
||||
],
|
||||
"outbounds": [{
|
||||
@@ -81,7 +74,11 @@
|
||||
},
|
||||
{
|
||||
"protocol": "freedom",
|
||||
"settings": {},
|
||||
"streamSettings": {
|
||||
"sockopt": {
|
||||
"domainStrategy": "UseIP"
|
||||
}
|
||||
},
|
||||
"tag": "direct"
|
||||
},
|
||||
{
|
||||
@@ -95,7 +92,7 @@
|
||||
}
|
||||
],
|
||||
"routing": {
|
||||
"domainStrategy": "IPIfNonMatch",
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": []
|
||||
},
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.v2ray.ang
|
||||
|
||||
import android.content.Context
|
||||
import androidx.multidex.MultiDexApplication
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
|
||||
class AngApplication : MultiDexApplication() {
|
||||
companion object {
|
||||
lateinit var application: AngApplication
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches the base context to the application.
|
||||
* @param base The base context.
|
||||
*/
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
application = this
|
||||
}
|
||||
|
||||
private val workManagerConfiguration: Configuration = Configuration.Builder()
|
||||
.setDefaultProcessName("${ANG_PACKAGE}:bg")
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Initializes the application.
|
||||
*/
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
MMKV.initialize(this)
|
||||
|
||||
// Initialize WorkManager with the custom configuration
|
||||
WorkManager.initialize(this, workManagerConfiguration)
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package com.v2ray.ang
|
||||
|
||||
|
||||
object AppConfig {
|
||||
|
||||
/** The application's package name. */
|
||||
const val ANG_PACKAGE = BuildConfig.APPLICATION_ID
|
||||
const val TAG = BuildConfig.APPLICATION_ID
|
||||
|
||||
/** Directory names used in the app's file system. */
|
||||
const val DIR_ASSETS = "assets"
|
||||
|
||||
const val WEBDAV_BACKUP_DIR = "backups"
|
||||
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
|
||||
|
||||
/** Legacy configuration keys. */
|
||||
const val ANG_CONFIG = "ang_config"
|
||||
|
||||
// Default subscription ID for ungrouped servers
|
||||
const val DEFAULT_SUBSCRIPTION_ID = "__default_subscription__"
|
||||
|
||||
/** Preferences mapped to MMKV storage. */
|
||||
const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
|
||||
const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled"
|
||||
const val PREF_PER_APP_PROXY = "pref_per_app_proxy"
|
||||
const val PREF_PER_APP_PROXY_SET = "pref_per_app_proxy_set"
|
||||
const val PREF_BYPASS_APPS = "pref_bypass_apps"
|
||||
const val PREF_LOCAL_DNS_ENABLED = "pref_local_dns_enabled"
|
||||
const val PREF_FAKE_DNS_ENABLED = "pref_fake_dns_enabled"
|
||||
const val PREF_APPEND_HTTP_PROXY = "pref_append_http_proxy"
|
||||
const val PREF_LOCAL_DNS_PORT = "pref_local_dns_port"
|
||||
const val PREF_VPN_DNS = "pref_vpn_dns"
|
||||
const val PREF_VPN_BYPASS_LAN = "pref_vpn_bypass_lan"
|
||||
const val PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX = "pref_vpn_interface_address_config_index"
|
||||
const val PREF_VPN_MTU = "pref_vpn_mtu"
|
||||
const val PREF_ROUTING_DOMAIN_STRATEGY = "pref_routing_domain_strategy"
|
||||
const val PREF_ROUTING_RULESET = "pref_routing_ruleset"
|
||||
const val PREF_MUX_ENABLED = "pref_mux_enabled"
|
||||
const val PREF_MUX_CONCURRENCY = "pref_mux_concurrency"
|
||||
const val PREF_MUX_XUDP_CONCURRENCY = "pref_mux_xudp_concurrency"
|
||||
const val PREF_MUX_XUDP_QUIC = "pref_mux_xudp_quic"
|
||||
const val PREF_FRAGMENT_ENABLED = "pref_fragment_enabled"
|
||||
const val PREF_FRAGMENT_PACKETS = "pref_fragment_packets"
|
||||
const val PREF_FRAGMENT_LENGTH = "pref_fragment_length"
|
||||
const val PREF_FRAGMENT_INTERVAL = "pref_fragment_interval"
|
||||
const val PREF_FRAGMENT_MAXSPLIT = "pref_fragment_maxsplit"
|
||||
const val SUBSCRIPTION_UPDATE_TASK_NAME = "subscription_updater"
|
||||
const val SUBSCRIPTION_MIN_INTERVAL_MINUTES = 15L
|
||||
const val PREF_SPEED_ENABLED = "pref_speed_enabled"
|
||||
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
|
||||
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
|
||||
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
|
||||
const val PREF_GROUP_ALL_DISPLAY = "pref_group_all_display"
|
||||
const val PREF_LANGUAGE = "pref_language"
|
||||
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
|
||||
const val PREF_IPV6_ENABLED = "pref_ipv6_enabled"
|
||||
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
|
||||
const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
|
||||
const val PREF_ENABLE_LOCAL_PROXY = "pref_enable_local_proxy"
|
||||
const val PREF_SOCKS_PORT = "pref_socks_port"
|
||||
const val PREF_DYNAMIC_SOCKS_PORT = "pref_dynamic_socks_port"
|
||||
const val PREF_SOCKS_USERNAME = "pref_socks_username"
|
||||
const val PREF_SOCKS_PASSWORD = "pref_socks_password"
|
||||
const val PREF_SOCKS_ENABLE_UDP = "pref_socks_enable_udp"
|
||||
const val PREF_REMOTE_DNS = "pref_remote_dns"
|
||||
const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
|
||||
const val PREF_DNS_HOSTS = "pref_dns_hosts"
|
||||
const val PREF_DELAY_TEST_URL = "pref_delay_test_url"
|
||||
const val PREF_IP_API_URL = "pref_ip_api_url"
|
||||
const val PREF_LOGLEVEL = "pref_core_loglevel"
|
||||
const val PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD = "pref_outbound_domain_resolve_method"
|
||||
const val PREF_MODE = "pref_mode"
|
||||
const val PREF_ROOT_MODE_ENABLE = "pref_root_mode_enabled"
|
||||
const val PREF_ROOT_LAN_SHARING = "pref_root_lan_sharing"
|
||||
const val PREF_IS_BOOTED = "pref_is_booted"
|
||||
const val PREF_CHECK_UPDATE_PRE_RELEASE = "pref_check_update_pre_release"
|
||||
const val PREF_GEO_FILES_SOURCES = "pref_geo_files_sources"
|
||||
const val PREF_USE_HEV_TUNNEL = "pref_use_hev_tunnel_v2"
|
||||
const val PREF_HEV_TUNNEL_LOGLEVEL = "pref_hev_tunnel_loglevel"
|
||||
const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout_v2"
|
||||
const val PREF_AUTO_REMOVE_INVALID_AFTER_TEST = "pref_auto_remove_invalid_after_test"
|
||||
const val PREF_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
|
||||
const val PREF_REAL_PING_CONCURRENCY = "pref_real_ping_concurrency"
|
||||
|
||||
/** Cache keys. */
|
||||
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
|
||||
|
||||
/** Protocol identifiers. */
|
||||
const val PROTOCOL_FREEDOM = "freedom"
|
||||
|
||||
/** Broadcast actions. */
|
||||
const val BROADCAST_ACTION_SERVICE = "$ANG_PACKAGE.action.service"
|
||||
const val BROADCAST_ACTION_ACTIVITY = "$ANG_PACKAGE.action.activity"
|
||||
const val BROADCAST_ACTION_WIDGET_CLICK = "$ANG_PACKAGE.action.widget.click"
|
||||
|
||||
/** Tasker extras. */
|
||||
const val TASKER_EXTRA_BUNDLE = "com.twofortyfouram.locale.intent.extra.BUNDLE"
|
||||
const val TASKER_EXTRA_STRING_BLURB = "com.twofortyfouram.locale.intent.extra.BLURB"
|
||||
const val TASKER_EXTRA_BUNDLE_SWITCH = "tasker_extra_bundle_switch"
|
||||
const val TASKER_EXTRA_BUNDLE_GUID = "tasker_extra_bundle_guid"
|
||||
const val TASKER_DEFAULT_GUID = "Default"
|
||||
|
||||
/** Tags for different proxy modes. */
|
||||
const val TAG_PROXY = "proxy"
|
||||
const val TAG_DIRECT = "direct"
|
||||
const val TAG_BLOCKED = "block"
|
||||
const val TAG_FRAGMENT = "fragment"
|
||||
const val TAG_DNS = "dns-module"
|
||||
const val TAG_DOMESTIC_DNS = "domestic-dns"
|
||||
const val TAG_BALANCER = "balancer-main"
|
||||
const val TAG_BALANCER_PRE = "balancer"
|
||||
|
||||
/** Network-related constants. */
|
||||
const val UPLINK = "uplink"
|
||||
const val DOWNLINK = "downlink"
|
||||
|
||||
/** URLs for various resources. */
|
||||
const val GITHUB_URL = "https://github.com"
|
||||
const val GITHUB_RAW_URL = "https://raw.githubusercontent.com"
|
||||
const val GITHUB_DOWNLOAD_URL = "$GITHUB_URL/%s/releases/latest/download"
|
||||
const val ANDROID_PACKAGE_NAME_LIST_URL = "$GITHUB_RAW_URL/2dust/androidpackagenamelist/master/proxy.txt"
|
||||
const val APP_URL = "$GITHUB_URL/2dust/v2rayNG"
|
||||
const val APP_API_URL = "https://api.github.com/repos/2dust/v2rayNG/releases"
|
||||
const val APP_ISSUES_URL = "$APP_URL/issues"
|
||||
const val APP_WIKI_MODE = "$APP_URL/wiki/Mode"
|
||||
const val APP_PRIVACY_POLICY = "$GITHUB_RAW_URL/2dust/v2rayNG/master/CR.md"
|
||||
const val APP_PROMOTION_URL = "aHR0cHM6Ly85LjIzNDQ1Ni54eXovYWJjLmh0bWw="
|
||||
const val TG_CHANNEL_URL = "https://t.me/github_2dust"
|
||||
const val DELAY_TEST_URL = "https://www.gstatic.com/generate_204"
|
||||
const val DELAY_TEST_URL2 = "https://www.google.com/generate_204"
|
||||
|
||||
// const val IP_API_URL = "https://speed.cloudflare.com/meta"
|
||||
const val IP_API_URL = "https://api.ip.sb/geoip"
|
||||
|
||||
/** DNS server addresses. */
|
||||
const val DNS_PROXY = "1.1.1.1"
|
||||
const val DNS_DIRECT = "223.5.5.5"
|
||||
const val DNS_VPN = "1.1.1.1"
|
||||
const val GEOSITE_PRIVATE = "geosite:private"
|
||||
const val GEOSITE_CN = "geosite:cn"
|
||||
const val GEOIP_PRIVATE = "geoip:private"
|
||||
const val GEOIP_CN = "geoip:cn"
|
||||
|
||||
/** Geo data file names. */
|
||||
const val GEOSITE_DAT = "geosite.dat"
|
||||
const val GEOIP_DAT = "geoip.dat"
|
||||
const val GEOIP_ONLY_CN_PRIVATE_DAT = "geoip-only-cn-private.dat"
|
||||
const val GEOIP_ONLY_CN_PRIVATE_URL = "$GITHUB_RAW_URL/Loyalsoldier/geoip/release/$GEOIP_ONLY_CN_PRIVATE_DAT"
|
||||
|
||||
/** Ports and addresses for various services. */
|
||||
const val PORT_LOCAL_DNS = "10853"
|
||||
const val PORT_SOCKS = "10808"
|
||||
const val WIREGUARD_LOCAL_ADDRESS_V4 = "172.16.0.2/32"
|
||||
const val WIREGUARD_LOCAL_ADDRESS_V6 = "2606:4700:110:8f81:d551:a0:532e:a2b3/128"
|
||||
const val WIREGUARD_LOCAL_MTU = "1420"
|
||||
const val LOOPBACK = "127.0.0.1"
|
||||
|
||||
/** Message constants for communication. */
|
||||
const val MSG_REGISTER_CLIENT = 1
|
||||
const val MSG_STATE_RUNNING = 11
|
||||
const val MSG_STATE_NOT_RUNNING = 12
|
||||
const val MSG_UNREGISTER_CLIENT = 2
|
||||
const val MSG_STATE_START = 3
|
||||
const val MSG_STATE_START_SUCCESS = 31
|
||||
const val MSG_STATE_START_FAILURE = 32
|
||||
const val MSG_STATE_STOP = 4
|
||||
const val MSG_STATE_STOP_SUCCESS = 41
|
||||
const val MSG_STATE_RESTART = 5
|
||||
const val MSG_MEASURE_DELAY = 6
|
||||
const val MSG_MEASURE_DELAY_SUCCESS = 61
|
||||
const val MSG_MEASURE_CONFIG_START = 7
|
||||
const val MSG_MEASURE_CONFIG_CANCEL = 71
|
||||
const val MSG_MEASURE_CONFIG_SUCCESS = 72
|
||||
const val MSG_MEASURE_CONFIG_NOTIFY = 73
|
||||
const val MSG_MEASURE_CONFIG_FINISH = 74
|
||||
|
||||
/** Notification channel IDs and names. */
|
||||
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
|
||||
const val RAY_NG_CHANNEL_NAME = "v2rayNG Background Service"
|
||||
|
||||
/** Protocols Scheme **/
|
||||
const val VMESS = "vmess://"
|
||||
const val CUSTOM = ""
|
||||
const val SHADOWSOCKS = "ss://"
|
||||
const val SOCKS = "socks://"
|
||||
const val SOCKS4 = "socks4://"
|
||||
const val SOCKS5 = "socks5://"
|
||||
const val HTTP = "http://"
|
||||
const val VLESS = "vless://"
|
||||
const val TROJAN = "trojan://"
|
||||
const val WIREGUARD = "wireguard://"
|
||||
const val TUIC = "tuic://"
|
||||
const val HYSTERIA = "hysteria://"
|
||||
const val HYSTERIA2 = "hysteria2://"
|
||||
const val HY2 = "hy2://"
|
||||
|
||||
/** Give a good name to this, IDK*/
|
||||
const val VPN = "VPN"
|
||||
const val VPN_MTU = 1500
|
||||
|
||||
/** Root (system-wide) mode runtime constants. */
|
||||
const val ROOT_RUNTIME_DIR = "root"
|
||||
const val ROOT_IPTABLES_CHAIN = "V2RAY_NG"
|
||||
const val ROOT_FWMARK = 255 // defensive RETURN tag; hev's only upstream socket is loopback (already bypassed)
|
||||
const val ROOT_MARK_ROUTE = 1 // packets we want pushed into the tun device
|
||||
const val ROOT_ROUTE_TABLE = 2024
|
||||
const val ROOT_RULE_PRIORITY = 1000
|
||||
const val ROOT_TUN_NAME = "v2raytun0"
|
||||
const val ROOT_TUN_ADDR_V4 = "198.18.0.1/15"
|
||||
const val ROOT_TUN_ADDR_V6 = "fdfe:dcba:9876::1/64"
|
||||
|
||||
// hev-socks5-tunnel run as a standalone root binary (reuses the same project already
|
||||
// bundled for the VPN hev path; distinct filename from the JNI lib to avoid collision).
|
||||
const val ROOT_TUN2SOCKS_BIN = "libhevsockstun.so"
|
||||
const val ROOT_FWD_CHAIN = "V2RAY_NG_FWD" // FORWARD chain for LAN/tethering sharing
|
||||
const val ROOT_DNS_CHAIN = "V2RAY_NG_DNS" // nat chain for tethered-client DNS DNAT
|
||||
const val ROOT_V6_CHAIN = "V2RAY_NG6" // ip6tables filter/OUTPUT chain: blackhole native IPv6 when it isn't tunneled
|
||||
const val ROOT_V6_FWD_CHAIN = "V2RAY_NG6_FWD" // ip6tables FORWARD chain: route or reject tethered clients' native IPv6
|
||||
const val ROOT_V6_PRE_CHAIN = "V2RAY_NG6_PRE" // ip6tables mangle/PREROUTING chain: mark forwarded clients' IPv6 into the tun
|
||||
const val ROOT_LAN_DNS = "1.1.1.1" // fallback resolver for tethered clients when no plain-IPv4 DNS is configured
|
||||
const val ROOT_OOM_SCORE = "-1000" // oom_score_adj that makes the LMK never kill us
|
||||
|
||||
/** hev-sock5-tunnel read-write-timeout value */
|
||||
const val HEVTUN_RW_TIMEOUT = "300,60"
|
||||
|
||||
// Google API rule constants
|
||||
const val GOOGLEAPIS_CN_DOMAIN = "domain:googleapis.cn"
|
||||
const val GOOGLEAPIS_COM_DOMAIN = "googleapis.com"
|
||||
|
||||
// Android Private DNS constants
|
||||
const val DNS_ALIDNS_DOMAIN = "dns.alidns.com"
|
||||
const val DNS_CISCO_SSE_DOMAIN = "dns.sse.cisco.com"
|
||||
const val DNS_CISCO_UMBRELLA_DOMAIN = "dns.umbrella.com"
|
||||
const val DNS_CLOUDFLARE_ONE_DOMAIN = "one.one.one.one"
|
||||
const val DNS_CLOUDFLARE_ONEDOT_DNS_DOMAIN = "1dot1dot1dot1.cloudflare-dns.com"
|
||||
const val DNS_CLOUDFLARE_DNS_COM_DOMAIN = "dns.cloudflare.com"
|
||||
const val DNS_CLOUDFLARE_DNS_DOMAIN = "cloudflare-dns.com"
|
||||
const val DNS_CLOUDFLARE_WARP_DOMAIN = "engage.cloudflareclient.com"
|
||||
const val DNS_DNSPOD_DOH_DOMAIN = "doh.pub"
|
||||
const val DNS_DNSPOD_DOT_DOMAIN = "dot.pub"
|
||||
const val DNS_GOOGLE_DOMAIN = "dns.google"
|
||||
const val DNS_QUAD9_DOMAIN = "dns.quad9.net"
|
||||
const val DNS_SB_DOMAIN = "dns.sb"
|
||||
const val DNS_YANDEX_DOMAIN = "common.dot.dns.yandex.net"
|
||||
|
||||
const val DEFAULT_PORT = 443
|
||||
const val DEFAULT_SECURITY = "auto"
|
||||
const val DEFAULT_LEVEL = 8
|
||||
const val DEFAULT_NETWORK = "tcp"
|
||||
const val TLS = "tls"
|
||||
const val REALITY = "reality"
|
||||
const val HEADER_TYPE_HTTP = "http"
|
||||
|
||||
const val UNIDENTIFIED_PACKAGE = "__unknown_app__"
|
||||
|
||||
val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
|
||||
val DNS_CISCO_SSE_ADDRESSES = arrayListOf("208.67.220.220", "208.67.222.222", "2620:119:35::35", "2620:119:53::53")
|
||||
val DNS_CISCO_UMBRELLA_ADDRESSES = arrayListOf("208.67.220.220", "208.67.222.222", "2620:119:35::35", "2620:119:53::53")
|
||||
val DNS_CLOUDFLARE_ONE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
|
||||
val DNS_CLOUDFLARE_ONEDOT_DNS_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
|
||||
val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("162.159.61.8", "172.64.41.8", "2a06:98c1:52::8", "2803:f800:53::8")
|
||||
val DNS_CLOUDFLARE_DNS_ADDRESSES = arrayListOf("104.16.248.249", "104.16.249.249", "2606:4700::6810:f8f9", "2606:4700::6810:f9f9")
|
||||
val DNS_CLOUDFLARE_WARP_ADDRESSES = arrayListOf("162.159.192.1", "2606:4700:d0::a29f:c001")
|
||||
val DNS_DNSPOD_DOH_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
|
||||
val DNS_DNSPOD_DOT_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
|
||||
val DNS_GOOGLE_ADDRESSES = arrayListOf("8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844")
|
||||
val DNS_QUAD9_ADDRESSES = arrayListOf("9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9")
|
||||
val DNS_SB_ADDRESSES = arrayListOf("45.11.45.11", "185.222.222.222", "2a09::", "2a11::")
|
||||
val DNS_YANDEX_ADDRESSES = arrayListOf("77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff")
|
||||
|
||||
//minimum list https://serverfault.com/a/304791
|
||||
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)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
import android.app.Service
|
||||
|
||||
interface ServiceControl {
|
||||
/**
|
||||
* Gets the service instance.
|
||||
* @return The service instance.
|
||||
*/
|
||||
fun getService(): Service
|
||||
|
||||
/**
|
||||
* Starts the service.
|
||||
*/
|
||||
fun startService()
|
||||
|
||||
/**
|
||||
* Stops the service.
|
||||
*/
|
||||
fun stopService()
|
||||
|
||||
/**
|
||||
* Protects the VPN socket.
|
||||
* @param socket The socket to protect.
|
||||
* @return True if the socket is protected, false otherwise.
|
||||
*/
|
||||
fun vpnProtect(socket: Int): Boolean
|
||||
}
|
||||
@@ -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,249 @@
|
||||
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()
|
||||
val routingDomainRules = collectRoutingDomainRulesForDns()
|
||||
|
||||
return CoreConfigContext(
|
||||
context = context,
|
||||
guid = guid,
|
||||
resolvedOutbounds = listOf(primaryResolvedOutbound) + routingResolvedOutbounds,
|
||||
routingDomainRules = routingDomainRules,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one outbound target into a normalized outbound entry.
|
||||
*
|
||||
* Custom profiles are ignored at this stage and produce no entry.
|
||||
*/
|
||||
private fun resolveOutbound(tag: String, profile: ProfileItem): CoreConfigContext.ResolvedOutbound? {
|
||||
if (profile.configType == EConfigType.CUSTOM) {
|
||||
return null
|
||||
}
|
||||
|
||||
val (resolvedProfiles, resolvedType) = when (profile.configType) {
|
||||
EConfigType.POLICYGROUP -> Pair(
|
||||
resolvePolicyGroupProfiles(profile),
|
||||
CoreResolvedType.POLICYGROUP,
|
||||
)
|
||||
|
||||
EConfigType.PROXYCHAIN -> {
|
||||
val chainProfiles = resolveProxyChainProfiles(profile)
|
||||
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
|
||||
Pair(chainProfiles, type)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val chainProfiles = resolveProxyChainProfilesFromGroup(profile)
|
||||
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
|
||||
Pair(chainProfiles, type)
|
||||
}
|
||||
}
|
||||
|
||||
return CoreConfigContext.ResolvedOutbound(
|
||||
tag = tag,
|
||||
profile = profile,
|
||||
resolvedProfiles = resolvedProfiles,
|
||||
resolvedType = resolvedType,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect and resolve non-builtin routing targets from enabled rules.
|
||||
*
|
||||
* Invalid or empty targets are skipped and handled by fallback logic later.
|
||||
*/
|
||||
private fun resolveRoutingOutbounds(): List<CoreConfigContext.ResolvedOutbound> {
|
||||
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return emptyList()
|
||||
val resolvedOutbounds = mutableListOf<CoreConfigContext.ResolvedOutbound>()
|
||||
val processedTags = mutableSetOf<String>()
|
||||
|
||||
try {
|
||||
rulesetItems
|
||||
.filter { it.enabled }
|
||||
.mapNotNull { it.outboundTag.takeIf { tag -> tag.isNotBlank() } }
|
||||
.filter { tag -> tag !in AppConfig.BUILTIN_OUTBOUND_TAGS }
|
||||
.distinct()
|
||||
.forEach { tag ->
|
||||
if (tag in processedTags) {
|
||||
return@forEach
|
||||
}
|
||||
processedTags.add(tag)
|
||||
|
||||
try {
|
||||
val profile = SettingsManager.getServerViaRemarks(tag) ?: run {
|
||||
LogUtil.w(AppConfig.TAG, "Routing tag '$tag' has no matching profile — will fall back to proxy at routing time")
|
||||
return@forEach
|
||||
}
|
||||
val resolvedOutbound = resolveOutbound(tag, profile) ?: run {
|
||||
LogUtil.w(AppConfig.TAG, "Cannot use CUSTOM profile as routing outbound for tag '$tag', skipping")
|
||||
return@forEach
|
||||
}
|
||||
if (resolvedOutbound.resolvedProfiles.isEmpty()) {
|
||||
LogUtil.w(AppConfig.TAG, "Routing outbound '$tag' resolved to empty list, skipping")
|
||||
return@forEach
|
||||
}
|
||||
resolvedOutbounds.add(resolvedOutbound)
|
||||
LogUtil.d(AppConfig.TAG, "Resolved routing outbound: tag='$tag', type='${resolvedOutbound.resolvedType}', profiles=${resolvedOutbound.resolvedProfiles.size}")
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve routing outbound for tag '$tag', skipping", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve routing outbounds from rulesets", e)
|
||||
}
|
||||
|
||||
return resolvedOutbounds
|
||||
}
|
||||
|
||||
private fun resolvePolicyGroupProfiles(config: ProfileItem): List<ProfileItem> {
|
||||
try {
|
||||
val serverList = MmkvManager.decodeAllServerList()
|
||||
return serverList
|
||||
.asSequence()
|
||||
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
|
||||
.filter { profile ->
|
||||
val subscriptionId = config.policyGroupSubscriptionId
|
||||
if (subscriptionId.isNullOrBlank()) {
|
||||
true
|
||||
} else {
|
||||
profile.subscriptionId == subscriptionId
|
||||
}
|
||||
}
|
||||
.filter { profile ->
|
||||
val filter = config.policyGroupFilter
|
||||
if (filter.isNullOrBlank()) {
|
||||
true
|
||||
} else {
|
||||
try {
|
||||
Regex(filter).containsMatchIn(profile.remarks)
|
||||
} catch (_: Exception) {
|
||||
profile.remarks.contains(filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
.filter { it.server.isNotNullEmpty() }
|
||||
.filter { Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
|
||||
.filter { !it.configType.isComplexType() }
|
||||
.toList()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve policy group profiles for '${config.remarks}'", e)
|
||||
return listOf(config)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveProxyChainProfiles(config: ProfileItem): List<ProfileItem> {
|
||||
if (config.proxyChainProfiles.isNullOrBlank()) {
|
||||
return listOf(config)
|
||||
}
|
||||
|
||||
try {
|
||||
return config.proxyChainProfiles.orEmpty().split(",")
|
||||
.asSequence()
|
||||
.mapNotNull { remark -> SettingsManager.getServerViaRemarks(remark) }
|
||||
.filter { it.server.isNotNullEmpty() }
|
||||
.filter { Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
|
||||
.filter { !it.configType.isComplexType() }
|
||||
.toList()
|
||||
.reversed()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles for '${config.remarks}'", e)
|
||||
return listOf(config)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve chain nodes from subscription neighbors in order: next, current, prev.
|
||||
*
|
||||
* When no chain is available, return a single-node result.
|
||||
*/
|
||||
private fun resolveProxyChainProfilesFromGroup(config: ProfileItem): List<ProfileItem> {
|
||||
if (config.subscriptionId.isEmpty()) {
|
||||
return listOf(config)
|
||||
}
|
||||
|
||||
try {
|
||||
val subItem = MmkvManager.decodeSubscription(config.subscriptionId) ?: return listOf(config)
|
||||
val resolved = mutableListOf<ProfileItem>()
|
||||
SettingsManager.getServerViaRemarks(subItem.nextProfile)?.let { resolved.add(it) }
|
||||
resolved.add(config)
|
||||
SettingsManager.getServerViaRemarks(subItem.prevProfile)?.let { resolved.add(it) }
|
||||
return resolved
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain from group for '${config.remarks}'", e)
|
||||
return listOf(config)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect enabled routing domain rules in original order for DNS segmentation.
|
||||
*
|
||||
* outbounds are normalized into three tags only: proxy / direct / block.
|
||||
*/
|
||||
private fun collectRoutingDomainRulesForDns(): List<CoreConfigContext.RoutingDomainRule> {
|
||||
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return emptyList()
|
||||
val result = mutableListOf<CoreConfigContext.RoutingDomainRule>()
|
||||
|
||||
rulesetItems
|
||||
.asSequence()
|
||||
.filter { it.enabled }
|
||||
.filter { !it.domain.isNullOrEmpty() }
|
||||
.forEach { rule ->
|
||||
val normalizedOutboundTag = when (rule.outboundTag) {
|
||||
AppConfig.TAG_DIRECT -> AppConfig.TAG_DIRECT
|
||||
AppConfig.TAG_BLOCKED -> AppConfig.TAG_BLOCKED
|
||||
else -> AppConfig.TAG_PROXY
|
||||
}
|
||||
result.add(
|
||||
CoreConfigContext.RoutingDomainRule(
|
||||
domain = rule.domain.orEmpty(),
|
||||
outboundTag = normalizedOutboundTag
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,690 @@
|
||||
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" -> "wechat"
|
||||
else -> headerType
|
||||
}
|
||||
udpMaskList.add(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "mkcp-legacy",
|
||||
settings =
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
header = kcpHeaderType,
|
||||
value = if (headerType == "dns" && !host.isNullOrEmpty()) {
|
||||
host
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (seed.isNullOrEmpty()) {
|
||||
udpMaskList.add(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "mkcp-legacy"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
udpMaskList.add(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "mkcp-legacy",
|
||||
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
value = seed
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
streamSettings.finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
|
||||
udp = udpMaskList.toList()
|
||||
)
|
||||
}
|
||||
|
||||
NetworkType.WS.type -> {
|
||||
val wssetting = OutboundBean.StreamSettingsBean.WsSettingsBean()
|
||||
wssetting.host = host.orEmpty()
|
||||
sni = host
|
||||
wssetting.path = path ?: "/"
|
||||
streamSettings.wsSettings = wssetting
|
||||
}
|
||||
|
||||
NetworkType.HTTP_UPGRADE.type -> {
|
||||
val httpupgradeSetting = OutboundBean.StreamSettingsBean.HttpupgradeSettingsBean()
|
||||
httpupgradeSetting.host = host.orEmpty()
|
||||
sni = host
|
||||
httpupgradeSetting.path = path ?: "/"
|
||||
streamSettings.httpupgradeSettings = httpupgradeSetting
|
||||
}
|
||||
|
||||
NetworkType.XHTTP.type -> {
|
||||
val xhttpSetting = OutboundBean.StreamSettingsBean.XhttpSettingsBean()
|
||||
xhttpSetting.host = host.orEmpty()
|
||||
sni = host
|
||||
xhttpSetting.path = path ?: "/"
|
||||
xhttpSetting.mode = xhttpMode
|
||||
xhttpSetting.extra = JsonUtil.parseString(xhttpExtra)
|
||||
streamSettings.xhttpSettings = xhttpSetting
|
||||
}
|
||||
|
||||
NetworkType.H2.type, NetworkType.HTTP.type -> {
|
||||
streamSettings.network = NetworkType.H2.type
|
||||
val h2Setting = OutboundBean.StreamSettingsBean.HttpSettingsBean()
|
||||
h2Setting.host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
sni = h2Setting.host.getOrNull(0)
|
||||
h2Setting.path = path ?: "/"
|
||||
streamSettings.httpSettings = h2Setting
|
||||
}
|
||||
|
||||
// "quic" -> {
|
||||
// val quicsetting = QuicSettingBean()
|
||||
// quicsetting.security = quicSecurity ?: "none"
|
||||
// quicsetting.key = key.orEmpty()
|
||||
// quicsetting.header.type = headerType ?: "none"
|
||||
// quicSettings = quicsetting
|
||||
// }
|
||||
|
||||
NetworkType.GRPC.type -> {
|
||||
val grpcSetting = OutboundBean.StreamSettingsBean.GrpcSettingsBean()
|
||||
grpcSetting.multiMode = mode == "multi"
|
||||
grpcSetting.serviceName = serviceName.orEmpty()
|
||||
grpcSetting.authority = authority.orEmpty()
|
||||
grpcSetting.idle_timeout = 60
|
||||
grpcSetting.health_check_timeout = 20
|
||||
sni = authority
|
||||
streamSettings.grpcSettings = grpcSetting
|
||||
}
|
||||
|
||||
NetworkType.HYSTERIA.type -> {
|
||||
val hysteriaSetting = OutboundBean.StreamSettingsBean.HysteriaSettingsBean(
|
||||
version = 2,
|
||||
auth = profileItem.password.orEmpty(),
|
||||
)
|
||||
val quicParams = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean(
|
||||
brutalUp = profileItem.bandwidthUp?.nullIfBlank(),
|
||||
brutalDown = profileItem.bandwidthDown?.nullIfBlank(),
|
||||
)
|
||||
quicParams.congestion = if (quicParams.brutalUp != null || quicParams.brutalDown != null) "brutal" else null
|
||||
if (profileItem.portHopping.isNotNullEmpty()) {
|
||||
val rawInterval = profileItem.portHoppingInterval?.trim().nullIfBlank()
|
||||
val interval = if (rawInterval == null) {
|
||||
"30"
|
||||
} else {
|
||||
val singleValue = rawInterval.toIntOrNull()
|
||||
if (singleValue != null) {
|
||||
if (singleValue < 5) {
|
||||
"30"
|
||||
} else {
|
||||
rawInterval
|
||||
}
|
||||
} else {
|
||||
val parts = rawInterval.split('-')
|
||||
if (parts.size == 2) {
|
||||
val start = parts[0].trim().toIntOrNull()
|
||||
val end = parts[1].trim().toIntOrNull()
|
||||
if (start != null && end != null) {
|
||||
val minStart = maxOf(5, start)
|
||||
val minEnd = maxOf(minStart, end)
|
||||
"$minStart-$minEnd"
|
||||
} else {
|
||||
"30"
|
||||
}
|
||||
} else {
|
||||
"30"
|
||||
}
|
||||
}
|
||||
}
|
||||
quicParams.udpHop = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean.UdpHopBean(
|
||||
ports = profileItem.portHopping,
|
||||
interval = interval
|
||||
)
|
||||
}
|
||||
val finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
|
||||
quicParams = quicParams
|
||||
)
|
||||
if (profileItem.obfsPassword.isNotNullEmpty()) {
|
||||
finalmask.udp = listOf(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "salamander",
|
||||
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
password = profileItem.obfsPassword.orEmpty()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
streamSettings.hysteriaSettings = hysteriaSetting
|
||||
streamSettings.finalmask = finalmask
|
||||
}
|
||||
}
|
||||
finalMask?.let {
|
||||
val parsedFinalMask = JsonUtil.parseString(finalMask)
|
||||
if (parsedFinalMask != null) {
|
||||
streamSettings.finalmask = parsedFinalMask
|
||||
} else {
|
||||
LogUtil.w("V2rayConfigManager", "Invalid finalMask JSON, keeping previously generated finalmask")
|
||||
}
|
||||
}
|
||||
return sni
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures TLS or REALITY security settings for an outbound connection.
|
||||
*
|
||||
* Sets up security-related parameters like certificates, fingerprints, and SNI.
|
||||
*
|
||||
* @param streamSettings The stream settings to configure
|
||||
* @param profileItem The profile containing security configuration
|
||||
* @param sniExt An external SNI value to use if the profile doesn't specify one
|
||||
*/
|
||||
fun populateTlsSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem, sniExt: String?) {
|
||||
val streamSecurity = profileItem.security.orEmpty()
|
||||
val allowInsecure = profileItem.insecure == true && profileItem.pinnedCA256.isNullOrEmpty()
|
||||
val sni = if (profileItem.sni.isNullOrEmpty()) {
|
||||
when {
|
||||
sniExt.isNotNullEmpty() && Utils.isDomainName(sniExt) -> sniExt
|
||||
profileItem.server.isNotNullEmpty() && Utils.isDomainName(profileItem.server) -> profileItem.server
|
||||
else -> sniExt
|
||||
}
|
||||
} else {
|
||||
profileItem.sni
|
||||
}
|
||||
|
||||
streamSettings.security = streamSecurity.nullIfBlank()
|
||||
if (streamSettings.security == null) return
|
||||
val tlsSetting = OutboundBean.StreamSettingsBean.TlsSettingsBean(
|
||||
allowInsecure = allowInsecure,
|
||||
serverName = sni.nullIfBlank(),
|
||||
fingerprint = profileItem.fingerPrint.nullIfBlank(),
|
||||
alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() },
|
||||
echConfigList = profileItem.echConfigList.nullIfBlank(),
|
||||
verifyPeerCertByName = profileItem.verifyPeerCertByName.nullIfBlank(),
|
||||
pinnedPeerCertSha256 = profileItem.pinnedCA256.nullIfBlank(),
|
||||
publicKey = profileItem.publicKey.nullIfBlank(),
|
||||
shortId = profileItem.shortId.nullIfBlank(),
|
||||
spiderX = profileItem.spiderX.nullIfBlank(),
|
||||
mldsa65Verify = profileItem.mldsa65Verify.nullIfBlank(),
|
||||
)
|
||||
if (streamSettings.security == AppConfig.TLS) {
|
||||
streamSettings.tlsSettings = tlsSetting
|
||||
streamSettings.realitySettings = null
|
||||
} else if (streamSettings.security == AppConfig.REALITY) {
|
||||
streamSettings.tlsSettings = null
|
||||
streamSettings.realitySettings = tlsSetting
|
||||
}
|
||||
|
||||
if (profileItem.finalMask.isNullOrEmpty()) {
|
||||
updateOutboundFragment(streamSettings)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the outbound with fragment settings for traffic optimization.
|
||||
*
|
||||
* Configures packet fragmentation for TLS and REALITY protocols if enabled.
|
||||
*
|
||||
* @param streamSettings The streamSettings object to be modified
|
||||
* @return true if fragment configuration was successful, false otherwise
|
||||
*/
|
||||
private fun updateOutboundFragment(streamSettings: OutboundBean.StreamSettingsBean): Boolean {
|
||||
try {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == false) {
|
||||
return true
|
||||
}
|
||||
if (streamSettings.security != AppConfig.TLS
|
||||
&& streamSettings.security != AppConfig.REALITY
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (streamSettings.sockopt?.dialerProxy.isNotNullEmpty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
var packets =
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS) ?: "tlshello"
|
||||
if (streamSettings.security == AppConfig.REALITY
|
||||
&& packets == "tlshello"
|
||||
) {
|
||||
packets = "1-3"
|
||||
}
|
||||
|
||||
val fragmentMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "fragment",
|
||||
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
packets = packets,
|
||||
length = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH)
|
||||
?: "50-100",
|
||||
delay = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL)
|
||||
?: "10-20",
|
||||
maxSplit = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_MAXSPLIT)
|
||||
?: "10"
|
||||
)
|
||||
)
|
||||
val noiseMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "noise",
|
||||
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
noise = listOf(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean.NoiseMaskBean(
|
||||
rand = "10-20",
|
||||
delay = "10-16",
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val finalMaskObj = streamSettings.finalmask?.let { existingFinalMask ->
|
||||
JsonUtil.parseString(JsonUtil.toJson(existingFinalMask))
|
||||
} ?: JsonObject()
|
||||
|
||||
// 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,548 @@
|
||||
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.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.root.RootManager
|
||||
import com.v2ray.ang.service.CoreProxyOnlyService
|
||||
import com.v2ray.ang.service.CoreRootService
|
||||
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 isRootMode = SettingsManager.isRootMode()
|
||||
if (isRootMode && !RootManager.isRootAvailable()) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: root mode requires root but none available")
|
||||
error(context.getString(R.string.toast_root_required))
|
||||
}
|
||||
|
||||
val intent = if (isRootMode) {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Root service")
|
||||
Intent(context.applicationContext, CoreRootService::class.java)
|
||||
} else if (SettingsManager.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,11 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
|
||||
data class AppInfo(
|
||||
val appName: String,
|
||||
val packageName: String,
|
||||
val appIcon: Drawable,
|
||||
val isSystemApp: Boolean,
|
||||
var isSelected: Int
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class CertSha256Request(
|
||||
val address: String = "",
|
||||
val port: Int = 443,
|
||||
val serverName: String? = null,
|
||||
val timeoutMs: Long = 5000L,
|
||||
)
|
||||
|
||||
data class CertSha256Result(
|
||||
val sha256: String = "",
|
||||
val error: String = "",
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class ConfigResult(
|
||||
var status: Boolean,
|
||||
var guid: String? = null,
|
||||
var content: String = "",
|
||||
var errorMessage: String = "",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import android.content.Context
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.CoreResolvedType
|
||||
|
||||
data class CoreConfigContext(
|
||||
val context: Context,
|
||||
val guid: String,
|
||||
val isCustom: Boolean = false,
|
||||
val resolvedOutbounds: List<ResolvedOutbound> = emptyList(),
|
||||
val routingDomainRules: List<RoutingDomainRule> = emptyList(),
|
||||
) {
|
||||
data class ResolvedOutbound(
|
||||
val tag: String,
|
||||
val profile: ProfileItem,
|
||||
val resolvedProfiles: List<ProfileItem>,
|
||||
val resolvedType: CoreResolvedType,
|
||||
)
|
||||
|
||||
data class RoutingDomainRule(
|
||||
val domain: List<String>,
|
||||
val outboundTag: String,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
sealed class RealPingEvent {
|
||||
|
||||
/** Periodic progress update while the batch is still running. */
|
||||
data class Progress(val text: String) : RealPingEvent()
|
||||
|
||||
/** A single server result is available. */
|
||||
data class Result(val guid: String, val delayMillis: Long) : RealPingEvent()
|
||||
|
||||
/** The entire batch has finished or been cancelled. */
|
||||
data class Finish(val status: String) : RealPingEvent()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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
|
||||
)
|
||||
@@ -0,0 +1,524 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
|
||||
data class V2rayConfig(
|
||||
var remarks: String? = null,
|
||||
var stats: Any? = null,
|
||||
val log: LogBean,
|
||||
var policy: PolicyBean? = null,
|
||||
val inbounds: ArrayList<InboundBean>,
|
||||
var outbounds: ArrayList<OutboundBean>,
|
||||
var dns: DnsBean? = null,
|
||||
val routing: RoutingBean,
|
||||
val api: Any? = null,
|
||||
val transport: Any? = null,
|
||||
val reverse: Any? = null,
|
||||
var fakedns: Any? = null,
|
||||
val browserForwarder: Any? = null,
|
||||
var observatory: Any? = null,
|
||||
var burstObservatory: Any? = null
|
||||
) {
|
||||
|
||||
data class LogBean(
|
||||
val access: String? = null,
|
||||
val error: String? = null,
|
||||
var loglevel: String? = null,
|
||||
val dnsLog: Boolean? = null
|
||||
)
|
||||
|
||||
data class InboundBean(
|
||||
var tag: String,
|
||||
var port: Int?,
|
||||
var protocol: String,
|
||||
var listen: String? = null,
|
||||
var settings: InSettingsBean? = null,
|
||||
var sniffing: SniffingBean? = null,
|
||||
val streamSettings: Any? = null,
|
||||
val allocate: Any? = null
|
||||
) {
|
||||
|
||||
data class InSettingsBean(
|
||||
var auth: String? = null,
|
||||
var udp: Boolean? = null,
|
||||
var userLevel: Int? = null,
|
||||
var accounts: List<SocksAccountBean>? = null,
|
||||
var name: String? = null,
|
||||
|
||||
var mtu: Int? = null
|
||||
) {
|
||||
data class SocksAccountBean(
|
||||
var user: String = "",
|
||||
var pass: String = ""
|
||||
)
|
||||
}
|
||||
|
||||
data class SniffingBean(
|
||||
var enabled: Boolean,
|
||||
val destOverride: ArrayList<String>,
|
||||
val metadataOnly: Boolean? = null,
|
||||
var routeOnly: Boolean? = null
|
||||
)
|
||||
}
|
||||
|
||||
data class OutboundBean(
|
||||
var tag: String = "proxy",
|
||||
var protocol: String,
|
||||
var settings: OutSettingsBean? = null,
|
||||
var streamSettings: StreamSettingsBean? = null,
|
||||
val sendThrough: String? = null,
|
||||
var mux: MuxBean? = MuxBean(false)
|
||||
) {
|
||||
data class OutSettingsBean(
|
||||
var vnext: List<VnextBean>? = null,
|
||||
var servers: List<ServersBean>? = null,
|
||||
/*Blackhole*/
|
||||
var response: Response? = null,
|
||||
/*DNS*/
|
||||
val network: String? = null,
|
||||
var address: Any? = null,
|
||||
var port: Int? = null,
|
||||
/*Freedom*/
|
||||
val redirect: String? = null,
|
||||
val userLevel: Int? = null,
|
||||
/*Loopback*/
|
||||
val inboundTag: String? = null,
|
||||
/*Wireguard*/
|
||||
var secretKey: String? = null,
|
||||
val peers: List<WireGuardBean>? = null,
|
||||
var reserved: List<Int>? = null,
|
||||
var mtu: Int? = null,
|
||||
var obfsPassword: String? = null,
|
||||
var version: Int? = null,
|
||||
) {
|
||||
|
||||
data class VnextBean(
|
||||
var address: String = "",
|
||||
var port: Int = AppConfig.DEFAULT_PORT,
|
||||
var users: List<UsersBean>
|
||||
) {
|
||||
|
||||
data class UsersBean(
|
||||
var id: String = "",
|
||||
var alterId: Int? = null,
|
||||
var security: String? = null,
|
||||
var level: Int = AppConfig.DEFAULT_LEVEL,
|
||||
var encryption: String? = null,
|
||||
var flow: String? = null
|
||||
)
|
||||
}
|
||||
|
||||
data class ServersBean(
|
||||
var address: String = "",
|
||||
var method: String? = null,
|
||||
var ota: Boolean = false,
|
||||
var password: String? = null,
|
||||
var port: Int = AppConfig.DEFAULT_PORT,
|
||||
var level: Int = AppConfig.DEFAULT_LEVEL,
|
||||
val email: String? = null,
|
||||
var flow: String? = null,
|
||||
val ivCheck: Boolean? = null,
|
||||
var users: List<SocksUsersBean>? = null
|
||||
) {
|
||||
data class SocksUsersBean(
|
||||
var user: String = "",
|
||||
var pass: String = "",
|
||||
var level: Int = AppConfig.DEFAULT_LEVEL
|
||||
)
|
||||
}
|
||||
|
||||
data class Response(var type: String)
|
||||
|
||||
data class WireGuardBean(
|
||||
var publicKey: String = "",
|
||||
var preSharedKey: String? = null,
|
||||
var endpoint: String = ""
|
||||
)
|
||||
}
|
||||
|
||||
data class StreamSettingsBean(
|
||||
var network: String = AppConfig.DEFAULT_NETWORK,
|
||||
var security: String? = null,
|
||||
var tcpSettings: TcpSettingsBean? = null,
|
||||
var kcpSettings: KcpSettingsBean? = null,
|
||||
var wsSettings: WsSettingsBean? = null,
|
||||
var httpupgradeSettings: HttpupgradeSettingsBean? = null,
|
||||
var xhttpSettings: XhttpSettingsBean? = null,
|
||||
var httpSettings: HttpSettingsBean? = null,
|
||||
var tlsSettings: TlsSettingsBean? = null,
|
||||
var quicSettings: QuicSettingBean? = null,
|
||||
var realitySettings: TlsSettingsBean? = null,
|
||||
var grpcSettings: GrpcSettingsBean? = null,
|
||||
var hysteriaSettings: HysteriaSettingsBean? = null,
|
||||
var finalmask: Any? = null,
|
||||
val dsSettings: Any? = null,
|
||||
var sockopt: SockoptBean? = null
|
||||
) {
|
||||
|
||||
data class TcpSettingsBean(
|
||||
var header: HeaderBean = HeaderBean(),
|
||||
val acceptProxyProtocol: Boolean? = null
|
||||
) {
|
||||
data class HeaderBean(
|
||||
var type: String = "none",
|
||||
var request: RequestBean? = null,
|
||||
var response: Any? = null
|
||||
) {
|
||||
data class RequestBean(
|
||||
var path: List<String> = ArrayList(),
|
||||
var headers: HeadersBean = HeadersBean(),
|
||||
val version: String? = null,
|
||||
val method: String? = null
|
||||
) {
|
||||
data class HeadersBean(
|
||||
var Host: List<String>? = ArrayList(),
|
||||
@SerializedName("User-Agent")
|
||||
val userAgent: List<String>? = null,
|
||||
@SerializedName("Accept-Encoding")
|
||||
val acceptEncoding: List<String>? = null,
|
||||
val Connection: List<String>? = null,
|
||||
val Pragma: Any? = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class KcpSettingsBean(
|
||||
var mtu: Int = 1350,
|
||||
var tti: Int = 50,
|
||||
var uplinkCapacity: Int = 12,
|
||||
var downlinkCapacity: Int = 100,
|
||||
var congestion: Boolean = false,
|
||||
var readBufferSize: Int = 1,
|
||||
var writeBufferSize: Int = 1
|
||||
)
|
||||
|
||||
data class WsSettingsBean(
|
||||
var path: String? = null,
|
||||
var host: String? = null,
|
||||
var headers: Map<String, String>? = null,
|
||||
val maxEarlyData: Int? = null,
|
||||
val useBrowserForwarding: Boolean? = null,
|
||||
val acceptProxyProtocol: Boolean? = null
|
||||
)
|
||||
|
||||
data class HttpupgradeSettingsBean(
|
||||
var path: String? = null,
|
||||
var host: String? = null,
|
||||
val acceptProxyProtocol: Boolean? = null
|
||||
)
|
||||
|
||||
data class XhttpSettingsBean(
|
||||
var path: String? = null,
|
||||
var host: String? = null,
|
||||
var mode: String? = null,
|
||||
var extra: Any? = null,
|
||||
)
|
||||
|
||||
data class HttpSettingsBean(
|
||||
var host: List<String> = ArrayList(),
|
||||
var path: String? = null
|
||||
)
|
||||
|
||||
data class SockoptBean(
|
||||
var TcpNoDelay: Boolean? = null,
|
||||
var tcpKeepAliveIdle: Int? = null,
|
||||
var tcpFastOpen: Boolean? = null,
|
||||
var tproxy: String? = null,
|
||||
var mark: Int? = 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(
|
||||
var allowInsecure: Boolean = false,
|
||||
var serverName: String? = null,
|
||||
val alpn: List<String>? = null,
|
||||
val minVersion: String? = null,
|
||||
val maxVersion: String? = null,
|
||||
val preferServerCipherSuites: Boolean? = null,
|
||||
val cipherSuites: String? = null,
|
||||
val fingerprint: String? = null,
|
||||
val certificates: List<Any>? = null,
|
||||
val disableSystemRoot: Boolean? = null,
|
||||
val enableSessionResumption: Boolean? = null,
|
||||
var echConfigList: String? = null,
|
||||
var verifyPeerCertByName: String? = null,
|
||||
var pinnedPeerCertSha256: String? = null,
|
||||
// REALITY settings
|
||||
var publicKey: String? = null,
|
||||
var shortId: String? = null,
|
||||
var spiderX: String? = null,
|
||||
var mldsa65Verify: String? = null
|
||||
)
|
||||
|
||||
data class QuicSettingBean(
|
||||
var security: String = "none",
|
||||
var key: String = "",
|
||||
var header: HeaderBean = HeaderBean()
|
||||
) {
|
||||
data class HeaderBean(var type: String = "none")
|
||||
}
|
||||
|
||||
data class GrpcSettingsBean(
|
||||
var serviceName: String = "",
|
||||
var authority: String? = null,
|
||||
var multiMode: Boolean? = null,
|
||||
var idle_timeout: Int? = null,
|
||||
var health_check_timeout: Int? = null
|
||||
)
|
||||
|
||||
data class HysteriaSettingsBean(
|
||||
var version: Int,
|
||||
var auth: String? = null
|
||||
)
|
||||
|
||||
//https://xtls.github.io/config/transport.html#finalmaskobject
|
||||
data class FinalMaskBean(
|
||||
var tcp: List<MaskBean>? = null,
|
||||
var udp: List<MaskBean>? = null,
|
||||
var quicParams: QuicParamsBean? = null
|
||||
) {
|
||||
data class MaskBean(
|
||||
var type: String,
|
||||
var settings: MaskSettingsBean? = null
|
||||
) {
|
||||
data class MaskSettingsBean(
|
||||
val password: String? = null,
|
||||
val header: String? = null,
|
||||
val value: String? = null,
|
||||
// fragment
|
||||
val packets: String? = null,
|
||||
val length: String? = null,
|
||||
val delay: String? = null,
|
||||
val maxSplit: String? = null,
|
||||
// noise
|
||||
val reset: Int? = null,
|
||||
val noise: List<NoiseMaskBean>? = null
|
||||
) {
|
||||
data class NoiseMaskBean(
|
||||
val rand: String? = null,
|
||||
// val randRange: String? = null,
|
||||
// val type: String? = null,
|
||||
// val packet: String? = null,
|
||||
val delay: String? = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class QuicParamsBean(
|
||||
var congestion: String? = null,
|
||||
var brutalUp: String? = null,
|
||||
var brutalDown: String? = null,
|
||||
var udpHop: UdpHopBean? = null,
|
||||
) {
|
||||
// Nested data class for the udpHop JSON object
|
||||
data class UdpHopBean(
|
||||
var ports: String? = null,
|
||||
var interval: String? = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class MuxBean(
|
||||
var enabled: Boolean,
|
||||
var concurrency: Int? = null,
|
||||
var xudpConcurrency: Int? = null,
|
||||
var xudpProxyUDP443: String? = null,
|
||||
)
|
||||
|
||||
fun getServerAddress(): String? {
|
||||
if (protocol.equals(EConfigType.VMESS.name, true)
|
||||
|| protocol.equals(EConfigType.VLESS.name, true)
|
||||
) {
|
||||
return settings?.vnext?.firstOrNull()?.address ?: settings?.address as? String
|
||||
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||
) {
|
||||
return settings?.servers?.firstOrNull()?.address
|
||||
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
||||
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
|
||||
}
|
||||
|
||||
fun getServerPort(): Int? {
|
||||
if (protocol.equals(EConfigType.VMESS.name, true)
|
||||
|| protocol.equals(EConfigType.VLESS.name, true)
|
||||
) {
|
||||
return settings?.vnext?.firstOrNull()?.port ?: settings?.port
|
||||
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||
) {
|
||||
return settings?.servers?.firstOrNull()?.port
|
||||
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
|
||||
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
|
||||
}
|
||||
|
||||
fun ensureSockopt(): StreamSettingsBean.SockoptBean {
|
||||
val stream = streamSettings ?: StreamSettingsBean().also {
|
||||
streamSettings = it
|
||||
}
|
||||
|
||||
val sockopt = stream.sockopt ?: StreamSettingsBean.SockoptBean().also {
|
||||
stream.sockopt = it
|
||||
}
|
||||
|
||||
return sockopt
|
||||
}
|
||||
}
|
||||
|
||||
data class DnsBean(
|
||||
var servers: ArrayList<Any>? = null,
|
||||
var hosts: Map<String, Any>? = null,
|
||||
val clientIp: String? = null,
|
||||
val disableCache: Boolean? = null,
|
||||
val queryStrategy: String? = null,
|
||||
val enableParallelQuery: Boolean? = null,
|
||||
val tag: String? = null
|
||||
) {
|
||||
data class ServersBean(
|
||||
var address: String = "",
|
||||
var port: Int? = null,
|
||||
var domains: List<String>? = null,
|
||||
var expectIPs: List<String>? = null,
|
||||
val clientIp: String? = null,
|
||||
val skipFallback: Boolean? = null,
|
||||
val tag: String? = null,
|
||||
)
|
||||
}
|
||||
|
||||
data class RoutingBean(
|
||||
var domainStrategy: String,
|
||||
var domainMatcher: String? = null,
|
||||
var rules: ArrayList<RulesBean>,
|
||||
var balancers: List<BalancerBean>? = null
|
||||
) {
|
||||
|
||||
data class RulesBean(
|
||||
var type: String = "field",
|
||||
var ip: List<String>? = null,
|
||||
var domain: List<String>? = null,
|
||||
var process: List<String>? = null,
|
||||
var outboundTag: String? = null,
|
||||
var balancerTag: String? = null,
|
||||
var port: String? = null,
|
||||
val sourcePort: String? = null,
|
||||
val network: String? = null,
|
||||
val source: List<String>? = null,
|
||||
val user: List<String>? = null,
|
||||
var inboundTag: List<String>? = null,
|
||||
val protocol: List<String>? = null,
|
||||
val attrs: String? = null,
|
||||
val domainMatcher: String? = null
|
||||
)
|
||||
|
||||
data class BalancerBean(
|
||||
val tag: String,
|
||||
val selector: List<String>,
|
||||
val fallbackTag: String? = null,
|
||||
val strategy: StrategyObject? = null
|
||||
)
|
||||
|
||||
data class StrategyObject(
|
||||
val type: String = "random", // "random" | "roundRobin" | "leastPing" | "leastLoad"
|
||||
val settings: StrategySettingsObject? = null
|
||||
)
|
||||
|
||||
data class StrategySettingsObject(
|
||||
val expected: Int? = null,
|
||||
val maxRTT: String? = null,
|
||||
val tolerance: Double? = null,
|
||||
val baselines: List<String>? = null,
|
||||
val costs: List<CostObject>? = null
|
||||
)
|
||||
|
||||
data class CostObject(
|
||||
val regexp: Boolean = false,
|
||||
val match: String,
|
||||
val value: Double
|
||||
)
|
||||
}
|
||||
|
||||
data class PolicyBean(
|
||||
var levels: Map<String, LevelBean>,
|
||||
var system: Any? = null
|
||||
) {
|
||||
data class LevelBean(
|
||||
var handshake: Int? = null,
|
||||
var connIdle: Int? = null,
|
||||
var uplinkOnly: Int? = null,
|
||||
var downlinkOnly: Int? = null,
|
||||
val statsUserUplink: Boolean? = null,
|
||||
val statsUserDownlink: Boolean? = null,
|
||||
var bufferSize: Int? = null
|
||||
)
|
||||
}
|
||||
|
||||
data class ObservatoryObject(
|
||||
val subjectSelector: List<String>,
|
||||
val probeUrl: String,
|
||||
val probeInterval: String,
|
||||
val enableConcurrency: Boolean = false
|
||||
)
|
||||
|
||||
data class BurstObservatoryObject(
|
||||
val subjectSelector: List<String>,
|
||||
val pingConfig: PingConfigObject
|
||||
) {
|
||||
data class PingConfigObject(
|
||||
val destination: String,
|
||||
val connectivity: String? = null,
|
||||
val interval: String,
|
||||
val sampling: Int,
|
||||
val timeout: String? = null
|
||||
)
|
||||
}
|
||||
|
||||
data class FakednsBean(
|
||||
var ipPool: String = "198.18.0.0/15",
|
||||
var poolSize: Int = 10000
|
||||
) // roughly 10 times smaller than total ip pool
|
||||
|
||||
fun getProxyOutbound(): OutboundBean? {
|
||||
outbounds.forEach { outbound ->
|
||||
EConfigType.entries.forEach {
|
||||
if (outbound.protocol.equals(it.name, true)) {
|
||||
return outbound
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getAllProxyOutbound(): List<OutboundBean> {
|
||||
return outbounds.filter { outbound ->
|
||||
EConfigType.entries.any { it.name.equals(outbound.protocol, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class VmessQRCode(
|
||||
var v: String = "",
|
||||
var ps: String = "",
|
||||
var add: String = "",
|
||||
var port: String = "",
|
||||
var id: String = "",
|
||||
var aid: String = "0",
|
||||
var scy: String = "",
|
||||
var net: String = "",
|
||||
var type: String = "",
|
||||
var host: String = "",
|
||||
var path: String = "",
|
||||
var tls: String = "",
|
||||
var sni: String = "",
|
||||
var alpn: String = "",
|
||||
var fp: String = "",
|
||||
var insecure: String = "",
|
||||
var vcn: String = "",
|
||||
var pcs: String = ""
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class AssetUrlCache(
|
||||
val guid: String,
|
||||
val assetUrl: AssetUrlItem
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class AssetUrlItem(
|
||||
var remarks: String = "",
|
||||
var url: String = "",
|
||||
val addedTime: Long = System.currentTimeMillis(),
|
||||
var lastUpdated: Long = -1,
|
||||
var locked: Boolean? = false,
|
||||
)
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
data class ProfileItem(
|
||||
val configVersion: Int = 4,
|
||||
val configType: EConfigType,
|
||||
var subscriptionId: String = "",
|
||||
var addedTime: Long = System.currentTimeMillis(),
|
||||
|
||||
var remarks: String = "",
|
||||
var description: String? = null,
|
||||
var server: String? = null,
|
||||
var serverPort: String? = null,
|
||||
|
||||
var password: String? = null,
|
||||
var method: String? = null,
|
||||
var flow: String? = null,
|
||||
var username: String? = null,
|
||||
|
||||
var network: String? = null,
|
||||
var headerType: String? = null,
|
||||
var host: String? = null,
|
||||
var path: String? = null,
|
||||
var seed: String? = null,
|
||||
var kcpMtu: Int? = null,
|
||||
var kcpTti: Int? = null,
|
||||
|
||||
var quicSecurity: String? = null,
|
||||
var quicKey: String? = null,
|
||||
var mode: String? = null,
|
||||
var serviceName: String? = null,
|
||||
var authority: String? = null,
|
||||
var xhttpMode: String? = null,
|
||||
var xhttpExtra: String? = null,
|
||||
var finalMask: String? = null,
|
||||
var security: String? = null,
|
||||
var sni: String? = null,
|
||||
var alpn: String? = null,
|
||||
var fingerPrint: String? = null,
|
||||
var insecure: Boolean? = null,
|
||||
var echConfigList: String? = null,
|
||||
var verifyPeerCertByName: String? = null,
|
||||
var pinnedCA256: String? = null,
|
||||
|
||||
var publicKey: String? = null,
|
||||
var shortId: String? = null,
|
||||
var spiderX: String? = null,
|
||||
var mldsa65Verify: String? = null,
|
||||
|
||||
var secretKey: String? = null,
|
||||
var preSharedKey: String? = null,
|
||||
var localAddress: String? = null,
|
||||
var reserved: String? = null,
|
||||
var mtu: Int? = null,
|
||||
|
||||
var obfsPassword: String? = null,
|
||||
var portHopping: String? = null,
|
||||
var portHoppingInterval: String? = null,
|
||||
var pinSHA256: String? = null,
|
||||
var bandwidthDown: String? = null,
|
||||
var bandwidthUp: String? = null,
|
||||
|
||||
var policyGroupType: String? = null,
|
||||
var policyGroupSubscriptionId: String? = null,
|
||||
var policyGroupFilter: String? = null,
|
||||
var proxyChainProfiles: String? = null,
|
||||
|
||||
var browserDialerMode: String? = null,
|
||||
|
||||
) {
|
||||
companion object {
|
||||
fun create(configType: EConfigType): ProfileItem {
|
||||
return ProfileItem(configType = configType)
|
||||
}
|
||||
}
|
||||
|
||||
fun getServerAddressAndPort(): String {
|
||||
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
|
||||
return "${AppConfig.LOOPBACK}:${AppConfig.PORT_SOCKS}"
|
||||
}
|
||||
return Utils.getIpv6Address(server) + ":" + serverPort
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null) return false
|
||||
val obj = other as ProfileItem
|
||||
|
||||
return (this.server == obj.server
|
||||
&& this.serverPort == obj.serverPort
|
||||
&& this.password == obj.password
|
||||
&& this.method == obj.method
|
||||
&& this.flow == obj.flow
|
||||
&& this.username == obj.username
|
||||
|
||||
&& this.network == obj.network
|
||||
&& this.headerType == obj.headerType
|
||||
&& this.host == obj.host
|
||||
&& this.path == obj.path
|
||||
&& this.seed == obj.seed
|
||||
&& this.kcpMtu == obj.kcpMtu
|
||||
&& this.kcpTti == obj.kcpTti
|
||||
&& this.quicSecurity == obj.quicSecurity
|
||||
&& this.quicKey == obj.quicKey
|
||||
&& this.mode == obj.mode
|
||||
&& this.serviceName == obj.serviceName
|
||||
&& this.authority == obj.authority
|
||||
&& this.xhttpMode == obj.xhttpMode
|
||||
|
||||
&& this.security == obj.security
|
||||
&& this.sni == obj.sni
|
||||
&& this.alpn == obj.alpn
|
||||
&& this.fingerPrint == obj.fingerPrint
|
||||
&& this.publicKey == obj.publicKey
|
||||
&& this.shortId == obj.shortId
|
||||
|
||||
&& this.secretKey == obj.secretKey
|
||||
&& this.localAddress == obj.localAddress
|
||||
&& this.reserved == obj.reserved
|
||||
&& this.mtu == obj.mtu
|
||||
|
||||
&& this.obfsPassword == obj.obfsPassword
|
||||
&& this.portHopping == obj.portHopping
|
||||
&& this.portHoppingInterval == obj.portHoppingInterval
|
||||
&& this.pinnedCA256 == obj.pinnedCA256
|
||||
&& this.proxyChainProfiles == obj.proxyChainProfiles
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class RulesetItem(
|
||||
var remarks: String? = "",
|
||||
var ip: List<String>? = null,
|
||||
var domain: List<String>? = null,
|
||||
var process: List<String>? = null,
|
||||
var outboundTag: String = "",
|
||||
var port: String? = null,
|
||||
var network: String? = null,
|
||||
var protocol: List<String>? = null,
|
||||
var enabled: Boolean = true,
|
||||
var locked: Boolean? = false,
|
||||
)
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
|
||||
fun getTestDelayString(): String {
|
||||
@@ -7,4 +7,4 @@ data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
|
||||
}
|
||||
return testDelayMillis.toString() + "ms"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class ServersCache(
|
||||
val guid: String,
|
||||
val profile: ProfileItem
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class SubscriptionCache(
|
||||
val guid: String,
|
||||
val subscription: SubscriptionItem
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class SubscriptionItem(
|
||||
var remarks: String = "",
|
||||
var url: String = "",
|
||||
var enabled: Boolean = true,
|
||||
val addedTime: Long = System.currentTimeMillis(),
|
||||
var lastUpdated: Long = -1,
|
||||
var autoUpdate: Boolean = false,
|
||||
var updateInterval: Long = 1440, // in minutes, default to 24 hours
|
||||
var prevProfile: String? = null,
|
||||
var nextProfile: String? = null,
|
||||
var filter: String? = null,
|
||||
var allowInsecureUrl: Boolean = false,
|
||||
var userAgent: String? = null,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
|
||||
enum class EConfigType(val value: Int, val protocolScheme: String) {
|
||||
VMESS(1, AppConfig.VMESS),
|
||||
CUSTOM(2, AppConfig.CUSTOM),
|
||||
SHADOWSOCKS(3, AppConfig.SHADOWSOCKS),
|
||||
SOCKS(4, AppConfig.SOCKS),
|
||||
VLESS(5, AppConfig.VLESS),
|
||||
TROJAN(6, AppConfig.TROJAN),
|
||||
WIREGUARD(7, AppConfig.WIREGUARD),
|
||||
|
||||
// TUIC(8, AppConfig.TUIC),
|
||||
HYSTERIA2(9, AppConfig.HYSTERIA2),
|
||||
HYSTERIA(900, AppConfig.HYSTERIA),
|
||||
HTTP(10, AppConfig.HTTP),
|
||||
POLICYGROUP(101, AppConfig.CUSTOM),
|
||||
PROXYCHAIN(102, AppConfig.CUSTOM);
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
enum class Language(val code: String) {
|
||||
AUTO("auto"),
|
||||
ENGLISH("en"),
|
||||
CHINA("zh-rCN"),
|
||||
TRADITIONAL_CHINESE("zh-rTW"),
|
||||
VIETNAMESE("vi"),
|
||||
RUSSIAN("ru"),
|
||||
PERSIAN("fa"),
|
||||
ARABIC("ar"),
|
||||
BANGLA("bn"),
|
||||
BAKHTIARI("bqi-rIR");
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String): Language {
|
||||
return entries.find { it.code == code } ?: AUTO
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
enum class NetworkType(val type: String) {
|
||||
TCP("tcp"),
|
||||
KCP("kcp"),
|
||||
WS("ws"),
|
||||
HTTP_UPGRADE("httpupgrade"),
|
||||
XHTTP("xhttp"),
|
||||
HTTP("http"),
|
||||
H2("h2"),
|
||||
|
||||
//QUIC("quic"),
|
||||
GRPC("grpc"),
|
||||
HYSTERIA("hysteria");
|
||||
|
||||
companion object {
|
||||
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,39 @@
|
||||
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
|
||||
},
|
||||
|
||||
/** Local network access permission (Android 17+) */
|
||||
ACCESS_LOCAL_NETWORK {
|
||||
@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
|
||||
override fun getPermission(): String = Manifest.permission.ACCESS_LOCAL_NETWORK
|
||||
};
|
||||
|
||||
/** Return the actual Android permission string */
|
||||
abstract fun getPermission(): String
|
||||
|
||||
/** Return a human-readable label for the permission */
|
||||
fun getLabel(): String {
|
||||
return when (this) {
|
||||
CAMERA -> "Camera"
|
||||
POST_NOTIFICATIONS -> "Notification"
|
||||
ACCESS_LOCAL_NETWORK -> "Local Network"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
enum class RoutingType(val fileName: String) {
|
||||
WHITE("custom_routing_white"),
|
||||
BLACK("custom_routing_black"),
|
||||
GLOBAL("custom_routing_global"),
|
||||
WHITE_IRAN("custom_routing_white_iran"),
|
||||
WHITE_RUSSIA("custom_routing_white_russia");
|
||||
|
||||
companion object {
|
||||
fun fromIndex(index: Int): RoutingType {
|
||||
return when (index) {
|
||||
0 -> WHITE
|
||||
1 -> BLACK
|
||||
2 -> GLOBAL
|
||||
3 -> WHITE_IRAN
|
||||
4 -> WHITE_RUSSIA
|
||||
else -> WHITE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.v2ray.ang.extension
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import com.v2ray.ang.AngApplication
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import es.dmoral.toasty.Toasty
|
||||
import java.io.Serializable
|
||||
import java.net.URI
|
||||
import java.util.Locale
|
||||
|
||||
val Context.v2RayApplication: AngApplication?
|
||||
get() = applicationContext as? AngApplication
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given resource ID.
|
||||
*
|
||||
* @param message The resource ID of the message to show.
|
||||
*/
|
||||
fun Context.toast(message: Int) {
|
||||
Toasty.normal(this, message).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given text.
|
||||
*
|
||||
* @param message The text of the message to show.
|
||||
*/
|
||||
fun Context.toast(message: CharSequence) {
|
||||
Toasty.normal(this, message).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given resource ID.
|
||||
*
|
||||
* @param message The resource ID of the message to show.
|
||||
*/
|
||||
fun Context.toastSuccess(message: Int) {
|
||||
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given text.
|
||||
*
|
||||
* @param message The text of the message to show.
|
||||
*/
|
||||
fun Context.toastSuccess(message: CharSequence) {
|
||||
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given resource ID.
|
||||
*
|
||||
* @param message The resource ID of the message to show.
|
||||
*/
|
||||
fun Context.toastError(message: Int) {
|
||||
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given text.
|
||||
*
|
||||
* @param message The text of the message to show.
|
||||
*/
|
||||
fun Context.toastError(message: CharSequence) {
|
||||
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
|
||||
}
|
||||
|
||||
const val THRESHOLD = 1000L
|
||||
const val DIVISOR = 1024.0
|
||||
|
||||
/**
|
||||
* Converts a Long value to a speed string.
|
||||
*
|
||||
* @return The speed string.
|
||||
*/
|
||||
fun Long.toSpeedString(): String = this.toTrafficString() + "/s"
|
||||
|
||||
/**
|
||||
* Converts a Long value to a traffic string.
|
||||
*
|
||||
* @return The traffic string.
|
||||
*/
|
||||
fun Long.toTrafficString(): String {
|
||||
val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB")
|
||||
var size = this.toDouble()
|
||||
var unitIndex = 0
|
||||
while (size >= THRESHOLD && unitIndex < units.size - 1) {
|
||||
size /= DIVISOR
|
||||
unitIndex++
|
||||
}
|
||||
return String.format(Locale.getDefault(), "%.1f %s", size, units[unitIndex])
|
||||
}
|
||||
|
||||
val URI.idnHost: String
|
||||
get() = host?.replace("[", "")?.replace("]", "").orEmpty()
|
||||
|
||||
/**
|
||||
* Removes all whitespace from the string.
|
||||
*
|
||||
* @return The string without whitespace.
|
||||
*/
|
||||
fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
|
||||
|
||||
/**
|
||||
* Returns null if the string is null or blank, otherwise returns the string itself.
|
||||
*
|
||||
* @return The string or null.
|
||||
*/
|
||||
fun String?.nullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* Converts the string to a Long value, or returns 0 if the conversion fails.
|
||||
*
|
||||
* @return The Long value.
|
||||
*/
|
||||
fun String.toLongEx(): Long = toLongOrNull() ?: 0
|
||||
|
||||
/**
|
||||
* Listens for package changes and executes a callback when a change occurs.
|
||||
*
|
||||
* @param onetime Whether to unregister the receiver after the first callback.
|
||||
* @param callback The callback to execute when a package change occurs.
|
||||
* @return The BroadcastReceiver that was registered.
|
||||
*/
|
||||
fun Context.listenForPackageChanges(onetime: Boolean = true, callback: () -> Unit) =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
callback()
|
||||
if (onetime) context.unregisterReceiver(this)
|
||||
}
|
||||
}.apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(this, IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
}, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
registerReceiver(this, IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addDataScheme("package")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a serializable object from the Bundle.
|
||||
*
|
||||
* @param key The key of the serializable object.
|
||||
* @return The serializable object, or null if not found.
|
||||
*/
|
||||
inline fun <reified T : Serializable> Bundle.serializable(key: String): T? = when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getSerializable(key, T::class.java)
|
||||
else -> @Suppress("DEPRECATION") getSerializable(key) as? T
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a serializable object from the Intent.
|
||||
*
|
||||
* @param key The key of the serializable object.
|
||||
* @return The serializable object, or null if not found.
|
||||
*/
|
||||
inline fun <reified T : Serializable> Intent.serializable(key: String): T? = when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getSerializableExtra(key, T::class.java)
|
||||
else -> @Suppress("DEPRECATION") getSerializableExtra(key) as? T
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the CharSequence is not null and not empty.
|
||||
*
|
||||
* @return True if the CharSequence is not null and not empty, false otherwise.
|
||||
*/
|
||||
fun CharSequence?.isNotNullEmpty(): Boolean = !this.isNullOrBlank()
|
||||
|
||||
fun String.concatUrl(vararg paths: String): String {
|
||||
val builder = StringBuilder(this.trimEnd('/'))
|
||||
|
||||
paths.forEach { path ->
|
||||
val trimmedPath = path.trim('/')
|
||||
if (trimmedPath.isNotEmpty()) {
|
||||
builder.append('/').append(trimmedPath)
|
||||
}
|
||||
}
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to match text either by Regex or literal string.
|
||||
*/
|
||||
fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean = true): Boolean {
|
||||
if (keyword.isNullOrEmpty()) {
|
||||
return true
|
||||
}
|
||||
return regex?.containsMatchIn(this)
|
||||
?: this.contains(keyword, ignoreCase = ignoreCase)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.dto.V2rayConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
|
||||
object CustomFmt : FmtBase() {
|
||||
/**
|
||||
* Parses a JSON string into a ProfileItem object.
|
||||
*
|
||||
* @param str the JSON string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem {
|
||||
val config = ProfileItem.create(EConfigType.CUSTOM)
|
||||
|
||||
val fullConfig = JsonUtil.fromJson(str, V2rayConfig::class.java)
|
||||
val outbound = fullConfig?.getProxyOutbound()
|
||||
|
||||
config.remarks = fullConfig?.remarks ?: System.currentTimeMillis().toString()
|
||||
config.server = outbound?.getServerAddress()
|
||||
config.serverPort = outbound?.getServerPort()?.toString()
|
||||
|
||||
return config
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
open class FmtBase {
|
||||
/**
|
||||
* Converts a ProfileItem object to a URI string.
|
||||
*
|
||||
* @param config the ProfileItem object to convert
|
||||
* @param userInfo the user information to include in the URI
|
||||
* @param dicQuery the query parameters to include in the URI
|
||||
* @return the converted URI string
|
||||
*/
|
||||
fun toUri(config: ProfileItem, userInfo: String?, dicQuery: HashMap<String, String>?): String {
|
||||
val query = if (dicQuery != null)
|
||||
"?" + dicQuery.toList().joinToString(
|
||||
separator = "&",
|
||||
transform = { it.first + "=" + Utils.encodeURIComponent(it.second) })
|
||||
else ""
|
||||
|
||||
val url = String.format(
|
||||
"%s@%s:%s",
|
||||
Utils.encodeURIComponent(userInfo ?: ""),
|
||||
Utils.getIpv6Address(HttpUtil.toIdnDomain(config.server.orEmpty())),
|
||||
config.serverPort
|
||||
)
|
||||
|
||||
return "${url}${query}#${Utils.encodeURIComponent(config.remarks)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts query parameters from a URI.
|
||||
*
|
||||
* @param uri the URI to extract query parameters from
|
||||
* @return a map of query parameters
|
||||
*/
|
||||
fun getQueryParam(uri: URI): Map<String, String> {
|
||||
return uri.rawQuery.split("&")
|
||||
.associate { it.split("=").let { (k, v) -> k to Utils.decodeURIComponent(v) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates a ProfileItem object with values from query parameters.
|
||||
*
|
||||
* @param config the ProfileItem object to populate
|
||||
* @param queryParam the query parameters to use for populating the ProfileItem
|
||||
*/
|
||||
fun getItemFormQuery(config: ProfileItem, queryParam: Map<String, String>) {
|
||||
config.network = queryParam["type"] ?: NetworkType.TCP.type
|
||||
config.headerType = queryParam["headerType"]
|
||||
config.host = queryParam["host"]
|
||||
config.path = queryParam["path"]
|
||||
|
||||
config.seed = queryParam["seed"]
|
||||
config.kcpMtu = queryParam["mtu"]?.toIntOrNull()
|
||||
config.kcpTti = queryParam["tti"]?.toIntOrNull()
|
||||
config.quicSecurity = queryParam["quicSecurity"]
|
||||
config.quicKey = queryParam["key"]
|
||||
config.mode = queryParam["mode"]
|
||||
config.serviceName = queryParam["serviceName"]
|
||||
config.authority = queryParam["authority"]
|
||||
config.xhttpMode = queryParam["mode"]
|
||||
config.xhttpExtra = queryParam["extra"]
|
||||
config.finalMask = queryParam["fm"]
|
||||
|
||||
config.security = queryParam["security"]
|
||||
if (config.security != AppConfig.TLS && config.security != AppConfig.REALITY) {
|
||||
config.security = null
|
||||
}
|
||||
// Support multiple possible query keys for allowInsecure like the C# implementation
|
||||
val allowInsecureKeys = arrayOf("insecure", "allowInsecure", "allow_insecure")
|
||||
config.insecure = when {
|
||||
allowInsecureKeys.any { queryParam[it] == "1" } -> true
|
||||
allowInsecureKeys.any { queryParam[it] == "0" } -> false
|
||||
else -> false
|
||||
}
|
||||
config.sni = queryParam["sni"]
|
||||
config.fingerPrint = queryParam["fp"]
|
||||
config.alpn = queryParam["alpn"]
|
||||
config.echConfigList = queryParam["ech"]
|
||||
config.verifyPeerCertByName = queryParam["vcn"]
|
||||
config.pinnedCA256 = queryParam["pcs"]
|
||||
config.publicKey = queryParam["pbk"]
|
||||
config.shortId = queryParam["sid"]
|
||||
config.spiderX = queryParam["spx"]
|
||||
config.mldsa65Verify = queryParam["pqv"]
|
||||
config.flow = queryParam["flow"]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a map of query parameters from a ProfileItem object.
|
||||
*
|
||||
* @param config the ProfileItem object to create query parameters from
|
||||
* @return a map of query parameters
|
||||
*/
|
||||
fun getQueryDic(config: ProfileItem): HashMap<String, String> {
|
||||
val dicQuery = HashMap<String, String>()
|
||||
dicQuery["security"] = config.security?.ifEmpty { "none" }.orEmpty()
|
||||
config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
|
||||
config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
|
||||
config.echConfigList?.nullIfBlank()?.let { dicQuery["ech"] = it }
|
||||
config.verifyPeerCertByName?.nullIfBlank()?.let { dicQuery["vcn"] = it }
|
||||
config.pinnedCA256?.nullIfBlank()?.let { dicQuery["pcs"] = it }
|
||||
config.fingerPrint?.nullIfBlank()?.let { dicQuery["fp"] = it }
|
||||
config.publicKey?.nullIfBlank()?.let { dicQuery["pbk"] = it }
|
||||
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)
|
||||
dicQuery["type"] = networkType.type
|
||||
|
||||
when (networkType) {
|
||||
NetworkType.TCP -> {
|
||||
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
|
||||
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
|
||||
}
|
||||
|
||||
NetworkType.KCP -> {
|
||||
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
|
||||
config.seed?.nullIfBlank()?.let { dicQuery["seed"] = it }
|
||||
}
|
||||
|
||||
NetworkType.WS, NetworkType.HTTP_UPGRADE -> {
|
||||
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
|
||||
config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
|
||||
}
|
||||
|
||||
NetworkType.XHTTP -> {
|
||||
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
|
||||
config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
|
||||
config.xhttpMode?.nullIfBlank()?.let { dicQuery["mode"] = it }
|
||||
config.xhttpExtra?.nullIfBlank()?.let { dicQuery["extra"] = it }
|
||||
}
|
||||
|
||||
NetworkType.HTTP, NetworkType.H2 -> {
|
||||
dicQuery["type"] = "http"
|
||||
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
|
||||
config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
|
||||
}
|
||||
|
||||
// NetworkType.QUIC -> {
|
||||
// dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
|
||||
// config.quicSecurity?.nullIfBlank()?.let { dicQuery["quicSecurity"] = it }
|
||||
// config.quicKey?.nullIfBlank()?.let { dicQuery["key"] = it }
|
||||
// }
|
||||
|
||||
NetworkType.GRPC -> {
|
||||
config.mode?.nullIfBlank()?.let { dicQuery["mode"] = it }
|
||||
config.authority?.nullIfBlank()?.let { dicQuery["authority"] = it }
|
||||
config.serviceName?.nullIfBlank()?.let { dicQuery["serviceName"] = it }
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
return dicQuery
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
object Hysteria2Fmt : FmtBase() {
|
||||
/**
|
||||
* Parses a Hysteria2 URI string into a ProfileItem object.
|
||||
*
|
||||
* @param str the Hysteria2 URI string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem {
|
||||
val config = ProfileItem.create(EConfigType.HYSTERIA2)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
config.password = uri.userInfo
|
||||
config.security = AppConfig.TLS
|
||||
config.network = NetworkType.HYSTERIA.type
|
||||
|
||||
if (!uri.rawQuery.isNullOrEmpty()) {
|
||||
val queryParam = getQueryParam(uri)
|
||||
|
||||
getItemFormQuery(config, queryParam)
|
||||
|
||||
config.security = queryParam["security"] ?: AppConfig.TLS
|
||||
config.obfsPassword = queryParam["obfs-password"]
|
||||
config.portHopping = queryParam["mport"]
|
||||
if (config.portHopping.isNotNullEmpty()) {
|
||||
config.portHoppingInterval = queryParam["mportHopInt"]
|
||||
}
|
||||
config.pinnedCA256 = queryParam["pinSHA256"]
|
||||
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to a URI string.
|
||||
*
|
||||
* @param config the ProfileItem object to convert
|
||||
* @return the converted URI string
|
||||
*/
|
||||
fun toUri(config: ProfileItem): String {
|
||||
val dicQuery = HashMap<String, String>()
|
||||
|
||||
config.security.let { if (it != null) dicQuery["security"] = it }
|
||||
config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
|
||||
config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
|
||||
config.insecure.let { dicQuery["insecure"] = if (it == true) "1" else "0" }
|
||||
|
||||
if (config.obfsPassword.isNotNullEmpty()) {
|
||||
dicQuery["obfs"] = "salamander"
|
||||
dicQuery["obfs-password"] = config.obfsPassword.orEmpty()
|
||||
}
|
||||
if (config.portHopping.isNotNullEmpty()) {
|
||||
dicQuery["mport"] = config.portHopping.orEmpty()
|
||||
}
|
||||
if (config.portHoppingInterval.isNotNullEmpty()) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
object ShadowsocksFmt : FmtBase() {
|
||||
/**
|
||||
* Parses a Shadowsocks URI string into a ProfileItem object.
|
||||
*
|
||||
* @param str the Shadowsocks URI string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem? {
|
||||
return parseSip002(str) ?: parseLegacy(str)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a SIP002 Shadowsocks URI string into a ProfileItem object.
|
||||
*
|
||||
* @param str the SIP002 Shadowsocks URI string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parseSip002(str: String): ProfileItem? {
|
||||
val config = ProfileItem.create(EConfigType.SHADOWSOCKS)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
if (uri.idnHost.isEmpty()) return null
|
||||
if (uri.port <= 0) return null
|
||||
if (uri.userInfo.isNullOrEmpty()) return null
|
||||
|
||||
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
|
||||
val result = if (uri.userInfo.contains(":")) {
|
||||
uri.userInfo.split(":", limit = 2)
|
||||
} else {
|
||||
Utils.decode(uri.userInfo).split(":", limit = 2)
|
||||
}
|
||||
if (result.count() == 2) {
|
||||
config.method = result.first()
|
||||
config.password = result.last()
|
||||
}
|
||||
|
||||
if (!uri.rawQuery.isNullOrEmpty()) {
|
||||
val queryParam = getQueryParam(uri)
|
||||
if (queryParam["plugin"]?.contains("obfs=http") == true) {
|
||||
val queryPairs = HashMap<String, String>()
|
||||
for (pair in queryParam["plugin"]?.split(";") ?: listOf()) {
|
||||
val idx = pair.split("=")
|
||||
if (idx.count() == 2) {
|
||||
queryPairs.put(idx.first(), idx.last())
|
||||
}
|
||||
}
|
||||
config.network = NetworkType.TCP.type
|
||||
config.headerType = "http"
|
||||
config.host = queryPairs["obfs-host"]
|
||||
config.path = queryPairs["path"]
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a legacy Shadowsocks URI string into a ProfileItem object.
|
||||
*
|
||||
* @param str the legacy Shadowsocks URI string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parseLegacy(str: String): ProfileItem? {
|
||||
val config = ProfileItem.create(EConfigType.SHADOWSOCKS)
|
||||
var result = str.replace(EConfigType.SHADOWSOCKS.protocolScheme, "")
|
||||
val indexSplit = result.indexOf("#")
|
||||
if (indexSplit > 0) {
|
||||
try {
|
||||
config.remarks =
|
||||
Utils.decodeURIComponent(result.substring(indexSplit + 1, result.length))
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
|
||||
}
|
||||
|
||||
result = result.substring(0, indexSplit)
|
||||
}
|
||||
|
||||
//part decode
|
||||
val indexS = result.indexOf("@")
|
||||
result = if (indexS > 0) {
|
||||
Utils.decode(result.substring(0, indexS)) + result.substring(
|
||||
indexS,
|
||||
result.length
|
||||
)
|
||||
} else {
|
||||
Utils.decode(result)
|
||||
}
|
||||
|
||||
val legacyPattern = "^(.+?):(.*)@(.+?):(\\d+?)/?$".toRegex()
|
||||
val match = legacyPattern.matchEntire(result) ?: return null
|
||||
|
||||
config.server = match.groupValues[3].removeSurrounding("[", "]")
|
||||
config.serverPort = match.groupValues[4]
|
||||
config.password = match.groupValues[2]
|
||||
config.method = match.groupValues[1].lowercase()
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to a URI string.
|
||||
*
|
||||
* @param config the ProfileItem object to convert
|
||||
* @return the converted URI string
|
||||
*/
|
||||
fun toUri(config: ProfileItem): String {
|
||||
val pw = "${config.method}:${config.password}"
|
||||
|
||||
return toUri(config, Utils.encode(pw, true), null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
object SocksFmt : FmtBase() {
|
||||
/**
|
||||
* Parses a Socks URI string into a ProfileItem object.
|
||||
*
|
||||
* @param str the Socks URI string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem? {
|
||||
val config = ProfileItem.create(EConfigType.SOCKS)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
if (uri.idnHost.isEmpty()) return null
|
||||
if (uri.port <= 0) return null
|
||||
|
||||
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
|
||||
if (uri.userInfo?.isEmpty() == false) {
|
||||
val result = if (uri.userInfo.contains(":")) {
|
||||
uri.userInfo.split(":", limit = 2)
|
||||
} else {
|
||||
Utils.decode(uri.userInfo).split(":", limit = 2)
|
||||
}
|
||||
if (result.count() == 2) {
|
||||
config.username = result.first()
|
||||
config.password = result.last()
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to a URI string.
|
||||
*
|
||||
* @param config the ProfileItem object to convert
|
||||
* @return the converted URI string
|
||||
*/
|
||||
fun toUri(config: ProfileItem): String {
|
||||
val pw =
|
||||
if (config.username.isNotNullEmpty())
|
||||
"${config.username}:${config.password}"
|
||||
else
|
||||
":"
|
||||
|
||||
return toUri(config, Utils.encode(pw, true), null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
object TrojanFmt : FmtBase() {
|
||||
/**
|
||||
* Parses a Trojan URI string into a ProfileItem object.
|
||||
*
|
||||
* @param str the Trojan URI string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem {
|
||||
val config = ProfileItem.create(EConfigType.TROJAN)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
config.password = uri.userInfo
|
||||
|
||||
if (uri.rawQuery.isNullOrEmpty()) {
|
||||
config.network = NetworkType.TCP.type
|
||||
config.security = AppConfig.TLS
|
||||
config.insecure = false
|
||||
} else {
|
||||
val queryParam = getQueryParam(uri)
|
||||
|
||||
getItemFormQuery(config, queryParam)
|
||||
config.security = queryParam["security"] ?: AppConfig.TLS
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to a URI string.
|
||||
*
|
||||
* @param config the ProfileItem object to convert
|
||||
* @return the converted URI string
|
||||
*/
|
||||
fun toUri(config: ProfileItem): String {
|
||||
val dicQuery = getQueryDic(config)
|
||||
|
||||
return toUri(config, config.password, dicQuery)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
object VlessFmt : FmtBase() {
|
||||
|
||||
/**
|
||||
* Parses a Vless URI string into a ProfileItem object.
|
||||
*
|
||||
* @param str the Vless URI string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem? {
|
||||
val config = ProfileItem.create(EConfigType.VLESS)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
if (uri.rawQuery.isNullOrEmpty()) return null
|
||||
val queryParam = getQueryParam(uri)
|
||||
|
||||
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
config.password = uri.userInfo
|
||||
config.method = queryParam["encryption"] ?: "none"
|
||||
|
||||
getItemFormQuery(config, queryParam)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to a URI string.
|
||||
*
|
||||
* @param config the ProfileItem object to convert
|
||||
* @return the converted URI string
|
||||
*/
|
||||
fun toUri(config: ProfileItem): String {
|
||||
val dicQuery = getQueryDic(config)
|
||||
dicQuery["encryption"] = config.method ?: "none"
|
||||
|
||||
return toUri(config, config.password, dicQuery)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import android.text.TextUtils
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.VmessQRCode
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
object VmessFmt : FmtBase() {
|
||||
/**
|
||||
* Parses a Vmess string into a ProfileItem object.
|
||||
*
|
||||
* @param str the Vmess string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem? {
|
||||
if (str.indexOf('?') > 0 && str.indexOf('&') > 0) {
|
||||
return parseVmessStd(str)
|
||||
}
|
||||
|
||||
val config = ProfileItem.create(EConfigType.VMESS)
|
||||
|
||||
var result = str.replace(EConfigType.VMESS.protocolScheme, "")
|
||||
result = Utils.decode(result)
|
||||
if (TextUtils.isEmpty(result)) {
|
||||
LogUtil.w(AppConfig.TAG, "Toast decoding failed")
|
||||
return null
|
||||
}
|
||||
val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java) ?: return null
|
||||
// Although VmessQRCode fields are non null, looks like Gson may still create null fields
|
||||
if (TextUtils.isEmpty(vmessQRCode.add)
|
||||
|| TextUtils.isEmpty(vmessQRCode.port)
|
||||
|| TextUtils.isEmpty(vmessQRCode.id)
|
||||
|| TextUtils.isEmpty(vmessQRCode.net)
|
||||
) {
|
||||
LogUtil.w(AppConfig.TAG, "Toast incorrect protocol")
|
||||
return null
|
||||
}
|
||||
|
||||
config.remarks = vmessQRCode.ps
|
||||
config.server = vmessQRCode.add
|
||||
config.serverPort = vmessQRCode.port
|
||||
config.password = vmessQRCode.id
|
||||
config.method =
|
||||
if (TextUtils.isEmpty(vmessQRCode.scy)) AppConfig.DEFAULT_SECURITY else vmessQRCode.scy
|
||||
|
||||
config.network = vmessQRCode.net
|
||||
if (config.network.isNullOrEmpty()) {
|
||||
config.network = NetworkType.TCP.type
|
||||
}
|
||||
config.headerType = vmessQRCode.type
|
||||
config.host = vmessQRCode.host
|
||||
config.path = vmessQRCode.path
|
||||
|
||||
when (NetworkType.fromString(config.network)) {
|
||||
NetworkType.KCP -> {
|
||||
config.seed = vmessQRCode.path
|
||||
}
|
||||
|
||||
// NetworkType.QUIC -> {
|
||||
// config.quicSecurity = vmessQRCode.host
|
||||
// config.quicKey = vmessQRCode.path
|
||||
// }
|
||||
|
||||
NetworkType.GRPC -> {
|
||||
config.mode = vmessQRCode.type
|
||||
config.serviceName = vmessQRCode.path
|
||||
config.authority = vmessQRCode.host
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
config.security = vmessQRCode.tls
|
||||
config.sni = vmessQRCode.sni
|
||||
config.fingerPrint = vmessQRCode.fp
|
||||
config.alpn = vmessQRCode.alpn
|
||||
config.insecure = when (vmessQRCode.insecure) {
|
||||
"1" -> true
|
||||
"0" -> false
|
||||
else -> false
|
||||
}
|
||||
config.verifyPeerCertByName = vmessQRCode.vcn
|
||||
config.pinnedCA256 = vmessQRCode.pcs
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to a URI string.
|
||||
*
|
||||
* @param config the ProfileItem object to convert
|
||||
* @return the converted URI string
|
||||
*/
|
||||
fun toUri(config: ProfileItem): String {
|
||||
val vmessQRCode = VmessQRCode()
|
||||
|
||||
vmessQRCode.v = "2"
|
||||
vmessQRCode.ps = config.remarks
|
||||
vmessQRCode.add = config.server.orEmpty()
|
||||
vmessQRCode.port = config.serverPort.orEmpty()
|
||||
vmessQRCode.id = config.password.orEmpty()
|
||||
vmessQRCode.scy = config.method.orEmpty()
|
||||
vmessQRCode.aid = "0"
|
||||
|
||||
vmessQRCode.net = config.network.orEmpty()
|
||||
vmessQRCode.type = config.headerType.orEmpty()
|
||||
when (NetworkType.fromString(config.network)) {
|
||||
NetworkType.KCP -> {
|
||||
vmessQRCode.path = config.seed.orEmpty()
|
||||
}
|
||||
|
||||
// NetworkType.QUIC -> {
|
||||
// vmessQRCode.host = config.quicSecurity.orEmpty()
|
||||
// vmessQRCode.path = config.quicKey.orEmpty()
|
||||
// }
|
||||
|
||||
NetworkType.GRPC -> {
|
||||
vmessQRCode.type = config.mode.orEmpty()
|
||||
vmessQRCode.path = config.serviceName.orEmpty()
|
||||
vmessQRCode.host = config.authority.orEmpty()
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
config.host?.nullIfBlank()?.let { vmessQRCode.host = it }
|
||||
config.path?.nullIfBlank()?.let { vmessQRCode.path = it }
|
||||
|
||||
vmessQRCode.tls = config.security.orEmpty()
|
||||
vmessQRCode.sni = config.sni.orEmpty()
|
||||
vmessQRCode.fp = config.fingerPrint.orEmpty()
|
||||
vmessQRCode.alpn = config.alpn.orEmpty()
|
||||
vmessQRCode.insecure = when (config.insecure) {
|
||||
true -> "1"
|
||||
false -> "0"
|
||||
else -> ""
|
||||
}
|
||||
vmessQRCode.vcn = config.verifyPeerCertByName.orEmpty()
|
||||
vmessQRCode.pcs = config.pinnedCA256.orEmpty()
|
||||
|
||||
val json = JsonUtil.toJson(vmessQRCode)
|
||||
return Utils.encode(json)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a standard Vmess URI string into a ProfileItem object.
|
||||
*
|
||||
* @param str the standard Vmess URI string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parseVmessStd(str: String): ProfileItem? {
|
||||
val config = ProfileItem.create(EConfigType.VMESS)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
if (uri.rawQuery.isNullOrEmpty()) return null
|
||||
val queryParam = getQueryParam(uri)
|
||||
|
||||
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
config.password = uri.userInfo
|
||||
config.method = AppConfig.DEFAULT_SECURITY
|
||||
|
||||
getItemFormQuery(config, queryParam)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.v2ray.ang.fmt
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.idnHost
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.extension.removeWhiteSpace
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
object WireguardFmt : FmtBase() {
|
||||
/**
|
||||
* Parses a URI string into a ProfileItem object.
|
||||
*
|
||||
* @param str the URI string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parse(str: String): ProfileItem? {
|
||||
val config = ProfileItem.create(EConfigType.WIREGUARD)
|
||||
|
||||
val uri = URI(Utils.fixIllegalUrl(str))
|
||||
if (uri.rawQuery.isNullOrEmpty()) return null
|
||||
val queryParam = getQueryParam(uri)
|
||||
|
||||
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
|
||||
config.server = uri.idnHost
|
||||
config.serverPort = uri.port.toString()
|
||||
|
||||
config.secretKey = uri.userInfo.orEmpty()
|
||||
config.localAddress = queryParam["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||
config.publicKey = queryParam["publickey"].orEmpty()
|
||||
config.preSharedKey = queryParam["presharedkey"]?.nullIfBlank()
|
||||
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
||||
config.reserved = queryParam["reserved"] ?: "0,0,0"
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a Wireguard configuration file string into a ProfileItem object.
|
||||
*
|
||||
* @param str the Wireguard configuration file string to parse
|
||||
* @return the parsed ProfileItem object, or null if parsing fails
|
||||
*/
|
||||
fun parseWireguardConfFile(str: String): ProfileItem {
|
||||
val config = ProfileItem.create(EConfigType.WIREGUARD)
|
||||
|
||||
val interfaceParams: MutableMap<String, String> = mutableMapOf()
|
||||
val peerParams: MutableMap<String, String> = mutableMapOf()
|
||||
|
||||
var currentSection: String? = null
|
||||
|
||||
str.lines().forEach { line ->
|
||||
val trimmedLine = line.trim()
|
||||
|
||||
if (trimmedLine.isEmpty() || trimmedLine.startsWith("#")) {
|
||||
return@forEach
|
||||
}
|
||||
|
||||
when {
|
||||
trimmedLine.startsWith("[Interface]", ignoreCase = true) -> currentSection = "Interface"
|
||||
trimmedLine.startsWith("[Peer]", ignoreCase = true) -> currentSection = "Peer"
|
||||
else -> {
|
||||
if (currentSection != null) {
|
||||
val parts = trimmedLine.split("=", limit = 2).map { it.trim() }
|
||||
if (parts.size == 2) {
|
||||
val key = parts[0].lowercase()
|
||||
val value = parts[1]
|
||||
when (currentSection) {
|
||||
"Interface" -> interfaceParams[key] = value
|
||||
"Peer" -> peerParams[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config.secretKey = interfaceParams["privatekey"].orEmpty()
|
||||
config.remarks = System.currentTimeMillis().toString()
|
||||
config.localAddress = interfaceParams["address"] ?: AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
|
||||
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
|
||||
config.publicKey = peerParams["publickey"].orEmpty()
|
||||
config.preSharedKey = peerParams["presharedkey"]?.nullIfBlank()
|
||||
val endpoint = peerParams["endpoint"].orEmpty()
|
||||
val endpointParts = endpoint.split(":", limit = 2)
|
||||
if (endpointParts.size == 2) {
|
||||
config.server = endpointParts[0]
|
||||
config.serverPort = endpointParts[1]
|
||||
} else {
|
||||
config.server = endpoint
|
||||
config.serverPort = ""
|
||||
}
|
||||
config.reserved = peerParams["reserved"] ?: "0,0,0"
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Converts a ProfileItem object to a URI string.
|
||||
*
|
||||
* @param config the ProfileItem object to convert
|
||||
* @return the converted URI string
|
||||
*/
|
||||
fun toUri(config: ProfileItem): String {
|
||||
val dicQuery = HashMap<String, String>()
|
||||
|
||||
dicQuery["publickey"] = config.publicKey.orEmpty()
|
||||
if (config.reserved != null) {
|
||||
dicQuery["reserved"] = config.reserved.removeWhiteSpace().orEmpty()
|
||||
}
|
||||
dicQuery["address"] = config.localAddress.removeWhiteSpace().orEmpty()
|
||||
if (config.mtu != null) {
|
||||
dicQuery["mtu"] = config.mtu.toString()
|
||||
}
|
||||
if (config.preSharedKey != null) {
|
||||
dicQuery["presharedkey"] = config.preSharedKey.removeWhiteSpace().orEmpty()
|
||||
}
|
||||
|
||||
return toUri(config, config.secretKey, dicQuery)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.text.TextUtils
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreConfigManager
|
||||
import com.v2ray.ang.dto.SubscriptionUpdateResult
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.fmt.CustomFmt
|
||||
import com.v2ray.ang.fmt.Hysteria2Fmt
|
||||
import com.v2ray.ang.fmt.ShadowsocksFmt
|
||||
import com.v2ray.ang.fmt.SocksFmt
|
||||
import com.v2ray.ang.fmt.TrojanFmt
|
||||
import com.v2ray.ang.fmt.VlessFmt
|
||||
import com.v2ray.ang.fmt.VmessFmt
|
||||
import com.v2ray.ang.fmt.WireguardFmt
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.QRCodeDecoder
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.net.URI
|
||||
|
||||
object AngConfigManager {
|
||||
|
||||
// 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.
|
||||
*
|
||||
* @param context The context.
|
||||
* @param guid The GUID of the configuration.
|
||||
* @return The result code.
|
||||
*/
|
||||
fun share2Clipboard(context: Context, guid: String): Int {
|
||||
try {
|
||||
val conf = shareConfig(guid)
|
||||
if (TextUtils.isEmpty(conf)) {
|
||||
return -1
|
||||
}
|
||||
|
||||
Utils.setClipboard(context, conf)
|
||||
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to share config to clipboard", e)
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares non-custom configurations to the clipboard.
|
||||
*
|
||||
* @param context The context.
|
||||
* @param serverList The list of server GUIDs.
|
||||
* @return The number of configurations shared.
|
||||
*/
|
||||
fun shareNonCustomConfigsToClipboard(context: Context, serverList: List<String>): Int {
|
||||
try {
|
||||
val sb = StringBuilder()
|
||||
for (guid in serverList) {
|
||||
val url = shareConfig(guid)
|
||||
if (TextUtils.isEmpty(url)) {
|
||||
continue
|
||||
}
|
||||
sb.append(url)
|
||||
sb.appendLine()
|
||||
}
|
||||
if (sb.count() > 0) {
|
||||
Utils.setClipboard(context, sb.toString())
|
||||
}
|
||||
return sb.lines().count() - 1
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to share non-custom configs to clipboard", e)
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares the configuration as a QR code.
|
||||
*
|
||||
* @param guid The GUID of the configuration.
|
||||
* @return The QR code bitmap.
|
||||
*/
|
||||
fun share2QRCode(guid: String): Bitmap? {
|
||||
try {
|
||||
val conf = shareConfig(guid)
|
||||
if (TextUtils.isEmpty(conf)) {
|
||||
return null
|
||||
}
|
||||
return QRCodeDecoder.createQRCode(conf)
|
||||
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to share config as QR code", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares the full content of the configuration to the clipboard.
|
||||
*
|
||||
* @param context The context.
|
||||
* @param guid The GUID of the configuration.
|
||||
* @return The result code.
|
||||
*/
|
||||
fun shareFullContent2Clipboard(context: Context, guid: String?): Int {
|
||||
try {
|
||||
if (guid == null) return -1
|
||||
val result = CoreConfigManager.getV2rayConfig(context, guid)
|
||||
if (result.status) {
|
||||
Utils.setClipboard(context, result.content)
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to share full content to clipboard", e)
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares the configuration.
|
||||
*
|
||||
* @param guid The GUID of the configuration.
|
||||
* @return The configuration string.
|
||||
*/
|
||||
private fun shareConfig(guid: String): String {
|
||||
try {
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return ""
|
||||
|
||||
return config.configType.protocolScheme + when (config.configType) {
|
||||
EConfigType.VMESS -> VmessFmt.toUri(config)
|
||||
EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toUri(config)
|
||||
EConfigType.SOCKS -> SocksFmt.toUri(config)
|
||||
EConfigType.VLESS -> VlessFmt.toUri(config)
|
||||
EConfigType.TROJAN -> TrojanFmt.toUri(config)
|
||||
EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
|
||||
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
|
||||
else -> {}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a batch of configurations.
|
||||
*
|
||||
* @param server The server string.
|
||||
* @param subid The subscription ID.
|
||||
* @param append Whether to append the configurations.
|
||||
* @return A pair containing the number of configurations and subscriptions imported.
|
||||
*/
|
||||
fun importBatchConfig(server: String?, subid: String, append: Boolean): Pair<Int, Int> {
|
||||
var count = parseBatchConfig(Utils.decode(server), subid, append)
|
||||
if (count <= 0) {
|
||||
count = parseBatchConfig(server, subid, append)
|
||||
}
|
||||
if (count <= 0) {
|
||||
count = parseCustomConfigServer(server, subid, append)
|
||||
}
|
||||
|
||||
var countSub = parseBatchSubscription(server)
|
||||
if (countSub <= 0) {
|
||||
countSub = parseBatchSubscription(Utils.decode(server))
|
||||
}
|
||||
if (countSub > 0) {
|
||||
updateConfigViaSubAll()
|
||||
}
|
||||
|
||||
return count to countSub
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a batch of subscriptions.
|
||||
*
|
||||
* @param servers The servers string.
|
||||
* @return The number of subscriptions parsed.
|
||||
*/
|
||||
private fun parseBatchSubscription(servers: String?): Int {
|
||||
try {
|
||||
if (servers == null) {
|
||||
return 0
|
||||
}
|
||||
|
||||
var count = 0
|
||||
servers.lines()
|
||||
.distinct()
|
||||
.forEach { str ->
|
||||
if (Utils.isValidSubUrl(str)) {
|
||||
count += importUrlAsSubscription(str)
|
||||
}
|
||||
}
|
||||
return count
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse batch subscription", e)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a batch of configurations.
|
||||
*
|
||||
* @param servers The servers string.
|
||||
* @param subid The subscription ID.
|
||||
* @param append Whether to append the configurations.
|
||||
* @return The number of configurations parsed.
|
||||
*/
|
||||
private fun parseBatchConfig(servers: String?, subid: String, append: Boolean): Int {
|
||||
try {
|
||||
if (servers == null) {
|
||||
return 0
|
||||
}
|
||||
// Find the currently selected server that belongs to the same subscription before replacement.
|
||||
val removedSelected = getRemovedSelectedProfile(subid, append)
|
||||
|
||||
val subItem = MmkvManager.decodeSubscription(subid)
|
||||
|
||||
// Parse all configs first (no I/O during parsing)
|
||||
val configs = mutableListOf<ProfileItem>()
|
||||
servers.lines()
|
||||
.distinct()
|
||||
.reversed()
|
||||
.forEach {
|
||||
val config = parseConfig(it, subid, subItem)
|
||||
if (config != null) {
|
||||
configs.add(config)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse batch config", e)
|
||||
}
|
||||
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.
|
||||
*
|
||||
* @param server The server string.
|
||||
* @param subid The subscription ID.
|
||||
* @param append Whether to append the configurations.
|
||||
* @return The number of configurations parsed.
|
||||
*/
|
||||
private fun parseCustomConfigServer(server: String?, subid: String, append: Boolean): Int {
|
||||
if (server == null) {
|
||||
return 0
|
||||
}
|
||||
if (server.contains("inbounds")
|
||||
&& server.contains("outbounds")
|
||||
&& server.contains("routing")
|
||||
) {
|
||||
try {
|
||||
val serverList: Array<Any> =
|
||||
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
|
||||
|
||||
if (serverList.isNotEmpty()) {
|
||||
val removedSelected = getRemovedSelectedProfile(subid, append)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
var count = 0
|
||||
val keyToProfile = mutableMapOf<String, ProfileItem>()
|
||||
for (srv in serverList.reversed()) {
|
||||
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
|
||||
keyToProfile[key] = config
|
||||
count += 1
|
||||
}
|
||||
if (count > 0) {
|
||||
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||
}
|
||||
return count
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
|
||||
}
|
||||
|
||||
try {
|
||||
// For compatibility
|
||||
val config = CustomFmt.parse(server) ?: return 0
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
return 1
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
|
||||
}
|
||||
return 0
|
||||
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
||||
try {
|
||||
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
|
||||
config.description = generateDescription(config)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
return 1
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
|
||||
}
|
||||
return 0
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the configuration from a QR code or string.
|
||||
* Only parses and returns ProfileItem, does not save.
|
||||
*
|
||||
* @param str The configuration string.
|
||||
* @param subid The subscription ID.
|
||||
* @param subItem The subscription item.
|
||||
* @return The parsed ProfileItem or null if parsing fails or filtered out.
|
||||
*/
|
||||
private fun parseConfig(
|
||||
str: String?,
|
||||
subid: String,
|
||||
subItem: SubscriptionItem?
|
||||
): ProfileItem? {
|
||||
try {
|
||||
if (str == null || TextUtils.isEmpty(str)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val config = configFmtParsers.firstNotNullOfOrNull { (scheme, parser) ->
|
||||
if (str.startsWith(scheme)) parser(str) else null
|
||||
}
|
||||
|
||||
if (config == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Apply filter
|
||||
if (subItem?.filter.isNotNullEmpty() && config.remarks.isNotNullEmpty()) {
|
||||
val matched = Regex(pattern = subItem?.filter.orEmpty())
|
||||
.containsMatchIn(input = config.remarks)
|
||||
if (!matched) return null
|
||||
}
|
||||
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
|
||||
return config
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse config", e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the configuration via all subscriptions.
|
||||
*
|
||||
* @return Detailed result of the subscription update operation.
|
||||
*/
|
||||
fun updateConfigViaSubAll(): SubscriptionUpdateResult {
|
||||
return try {
|
||||
val subscriptions = MmkvManager.decodeSubscriptions()
|
||||
subscriptions.fold(SubscriptionUpdateResult()) { acc, subscription ->
|
||||
acc + updateConfigViaSub(subscription)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
|
||||
SubscriptionUpdateResult()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the configuration via a subscription.
|
||||
*
|
||||
* @param it The subscription item.
|
||||
* @return Subscription update result.
|
||||
*/
|
||||
fun updateConfigViaSub(it: SubscriptionCache): SubscriptionUpdateResult {
|
||||
try {
|
||||
// Check if disabled
|
||||
if (!it.subscription.enabled) {
|
||||
return SubscriptionUpdateResult(skipCount = 1)
|
||||
}
|
||||
|
||||
// Validate subscription info
|
||||
if (TextUtils.isEmpty(it.guid)
|
||||
|| TextUtils.isEmpty(it.subscription.remarks)
|
||||
|| TextUtils.isEmpty(it.subscription.url)
|
||||
) {
|
||||
return SubscriptionUpdateResult(skipCount = 1)
|
||||
}
|
||||
|
||||
val url = HttpUtil.toIdnUrl(it.subscription.url)
|
||||
if (!Utils.isValidUrl(url)) {
|
||||
return SubscriptionUpdateResult(failureCount = 1)
|
||||
}
|
||||
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 {
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
HttpUtil.getUrlContentWithUserAgent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
userAgent = userAgent,
|
||||
timeout = 15000,
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
proxyPassword = proxyPassword
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
|
||||
""
|
||||
}
|
||||
if (configText.isEmpty()) {
|
||||
configText = try {
|
||||
HttpUtil.getUrlContentWithUserAgent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
userAgent = userAgent
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
|
||||
""
|
||||
}
|
||||
}
|
||||
if (configText.isEmpty()) {
|
||||
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)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to update config via subscription", e)
|
||||
return SubscriptionUpdateResult(failureCount = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the configuration via a subscription.
|
||||
*
|
||||
* @param server The server string.
|
||||
* @param subid The subscription ID.
|
||||
* @param append Whether to append the configurations.
|
||||
* @return The number of configurations parsed.
|
||||
*/
|
||||
private fun parseConfigViaSub(server: String?, subid: String, append: Boolean): Int {
|
||||
var count = parseBatchConfig(Utils.decode(server), subid, append)
|
||||
if (count <= 0) {
|
||||
count = parseBatchConfig(server, subid, append)
|
||||
}
|
||||
if (count <= 0) {
|
||||
count = parseCustomConfigServer(server, subid, append)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a URL as a subscription.
|
||||
*
|
||||
* @param url The URL.
|
||||
* @return The number of subscriptions imported.
|
||||
*/
|
||||
private fun importUrlAsSubscription(url: String): Int {
|
||||
val subscriptions = MmkvManager.decodeSubscriptions()
|
||||
subscriptions.forEach {
|
||||
if (it.subscription.url == url) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
val uri = URI(Utils.fixIllegalUrl(url))
|
||||
val subItem = SubscriptionItem()
|
||||
subItem.remarks = uri.fragment ?: "import sub"
|
||||
subItem.url = url
|
||||
MmkvManager.encodeSubscription("", subItem)
|
||||
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 ?: ""}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.CertSha256Request
|
||||
import com.v2ray.ang.dto.CertSha256Result
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import libv2ray.Libv2ray
|
||||
|
||||
object CertificateFingerprintManager {
|
||||
private const val TIMEOUT_MS = 5000L
|
||||
|
||||
fun fetchForManualFill(profile: ProfileItem): String? {
|
||||
val request = buildRequest(profile) ?: return null
|
||||
val result = if (profile.configType == EConfigType.HYSTERIA2) {
|
||||
fetch("quic", request) { Libv2ray.fetchQuicCertSha256(it) }
|
||||
} else {
|
||||
fetch("tls", request) { Libv2ray.fetchTlsCertSha256(it) }
|
||||
}
|
||||
|
||||
return result
|
||||
?.takeIf { it.error.isBlank() }
|
||||
?.sha256
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun buildRequest(profile: ProfileItem): CertSha256Request? {
|
||||
if (!isFetchable(profile)) return null
|
||||
|
||||
val server = profile.server?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
val port = profile.serverPort?.toIntOrNull()?.takeIf { it > 0 } ?: AppConfig.DEFAULT_PORT
|
||||
|
||||
return CertSha256Request(
|
||||
address = resolveDialAddress(server),
|
||||
port = port,
|
||||
serverName = inferServerName(profile),
|
||||
timeoutMs = TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
private fun isFetchable(profile: ProfileItem): Boolean {
|
||||
return profile.configType == EConfigType.HYSTERIA2 || profile.security == AppConfig.TLS
|
||||
}
|
||||
|
||||
private fun fetch(
|
||||
type: String,
|
||||
request: CertSha256Request,
|
||||
fetcher: (String) -> String,
|
||||
): CertSha256Result? {
|
||||
return try {
|
||||
JsonUtil.fromJsonSafe(fetcher(JsonUtil.toJson(request)), CertSha256Result::class.java)
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
LogUtil.e(AppConfig.TAG, "Fetch $type cert SHA-256 API missing in libv2ray", e)
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Fetch $type cert SHA-256 failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveDialAddress(server: String): String {
|
||||
if (Utils.isPureIpAddress(server) || !Utils.isDomainName(server)) return server
|
||||
|
||||
val preferIpv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6, false)
|
||||
return HttpUtil.resolveHostToIP(server, preferIpv6)
|
||||
?.firstOrNull()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: server
|
||||
}
|
||||
|
||||
private fun inferServerName(profile: ProfileItem): String? {
|
||||
val sni = profile.sni?.takeIf { it.isNotBlank() }
|
||||
return sni?.takeUnless { Utils.isPureIpAddress(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,725 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
|
||||
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
|
||||
import com.v2ray.ang.dto.entities.AssetUrlCache
|
||||
import com.v2ray.ang.dto.entities.AssetUrlItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.RulesetItem
|
||||
import com.v2ray.ang.dto.entities.ServerAffiliationInfo
|
||||
import com.v2ray.ang.dto.entities.SubscriptionCache
|
||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.dto.entities.WebDavConfig
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
object MmkvManager {
|
||||
|
||||
//region private
|
||||
|
||||
private const val ID_MAIN = "MAIN"
|
||||
private const val ID_PROFILE_FULL_CONFIG = "PROFILE_FULL_CONFIG"
|
||||
private const val ID_SERVER_RAW = "SERVER_RAW"
|
||||
private const val ID_SERVER_AFF = "SERVER_AFF"
|
||||
private const val ID_SUB = "SUB"
|
||||
private const val ID_ASSET = "ASSET"
|
||||
private const val ID_SETTING = "SETTING"
|
||||
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
|
||||
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
|
||||
private const val KEY_SUB_SERVER_PREFIX = "SUB_SERVERS_"
|
||||
private const val KEY_SUB_IDS = "SUB_IDS"
|
||||
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
|
||||
|
||||
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val serverAffStorage by lazy { MMKV.mmkvWithID(ID_SERVER_AFF, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val subStorage by lazy { MMKV.mmkvWithID(ID_SUB, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val assetStorage by lazy { MMKV.mmkvWithID(ID_ASSET, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val settingsStorage by lazy { MMKV.mmkvWithID(ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
|
||||
|
||||
//endregion
|
||||
|
||||
//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.
|
||||
*
|
||||
* @return The selected server GUID.
|
||||
*/
|
||||
fun getSelectServer(): String? {
|
||||
return mainStorage.decodeString(KEY_SELECTED_SERVER)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the selected server GUID.
|
||||
*
|
||||
* @param guid The server GUID.
|
||||
*/
|
||||
fun setSelectServer(guid: String) {
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, guid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the server list for a given subscription.
|
||||
* Saves to the subscription's serverList (including default subscription for ungrouped servers).
|
||||
*
|
||||
* @param serverList The list of server GUIDs.
|
||||
* @param subscriptionId The subscription ID.
|
||||
*/
|
||||
fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
|
||||
val subId = getSubscriptionId(subscriptionId)
|
||||
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
||||
mainStorage.encode(key, JsonUtil.toJson(serverList))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Decodes the server list for a given subscription.
|
||||
* If subscriptionId is empty, returns ungrouped servers.
|
||||
* Otherwise, returns servers from the specified subscription's serverList.
|
||||
*
|
||||
* @param subscriptionId The subscription ID.
|
||||
* @return The list of server GUIDs.
|
||||
*/
|
||||
fun decodeServerList(subscriptionId: String): MutableList<String> {
|
||||
val subId = getSubscriptionId(subscriptionId)
|
||||
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
||||
val json = mainStorage.decodeString(key)
|
||||
return if (json.isNullOrBlank()) {
|
||||
mutableListOf()
|
||||
} else {
|
||||
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.
|
||||
*
|
||||
* @param guid The server GUID.
|
||||
* @return The server configuration.
|
||||
*/
|
||||
fun decodeServerConfig(guid: String): ProfileItem? {
|
||||
if (guid.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val json = profileFullStorage.decodeString(guid)
|
||||
if (json.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
return JsonUtil.fromJsonSafe(json, ProfileItem::class.java)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Encodes the server configuration.
|
||||
*
|
||||
* @param guid The server GUID.
|
||||
* @param config The server configuration.
|
||||
* @return The server GUID.
|
||||
*/
|
||||
fun encodeServerConfig(guid: String, config: ProfileItem): String {
|
||||
val key = guid.ifBlank { Utils.getUuid() }
|
||||
profileFullStorage.encode(key, JsonUtil.toJson(config))
|
||||
|
||||
// Use default subscription for servers without subscription
|
||||
val subId = getSubscriptionId(config.subscriptionId)
|
||||
val serverList = decodeServerList(subId)
|
||||
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
encodeServerList(serverList, subId)
|
||||
if (getSelectServer().isNullOrBlank()) {
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, 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.
|
||||
*
|
||||
* @param guid The server GUID.
|
||||
*/
|
||||
fun removeServer(guid: String) {
|
||||
if (guid.isBlank()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get config to determine which subscription to update
|
||||
val config = decodeServerConfig(guid)
|
||||
val subId = getSubscriptionId(config?.subscriptionId)
|
||||
|
||||
// Remove from appropriate server list
|
||||
val serverList = decodeServerList(subId)
|
||||
serverList.remove(guid)
|
||||
encodeServerList(serverList, subId)
|
||||
|
||||
// Clean up storage
|
||||
if (getSelectServer() == guid) {
|
||||
mainStorage.remove(KEY_SELECTED_SERVER)
|
||||
}
|
||||
profileFullStorage.remove(guid)
|
||||
serverAffStorage.remove(guid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the server configurations via subscription ID.
|
||||
*
|
||||
* @param subscriptionId The subscription ID.
|
||||
*/
|
||||
fun removeServerViaSubid(subscriptionId: String?) {
|
||||
val subId = getSubscriptionId(subscriptionId)
|
||||
val serverList = decodeServerList(subId)
|
||||
|
||||
// Remove all servers in the list
|
||||
serverList.forEach { guid ->
|
||||
if (getSelectServer() == guid) {
|
||||
mainStorage.remove(KEY_SELECTED_SERVER)
|
||||
}
|
||||
profileFullStorage.remove(guid)
|
||||
serverAffStorage.remove(guid)
|
||||
}
|
||||
|
||||
serverList.clear()
|
||||
encodeServerList(serverList, subId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the server affiliation information.
|
||||
*
|
||||
* @param guid The server GUID.
|
||||
* @return The server affiliation information.
|
||||
*/
|
||||
fun decodeServerAffiliationInfo(guid: String): ServerAffiliationInfo? {
|
||||
if (guid.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val json = serverAffStorage.decodeString(guid)
|
||||
if (json.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
return JsonUtil.fromJsonSafe(json, ServerAffiliationInfo::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the server test delay in milliseconds.
|
||||
*
|
||||
* @param guid The server GUID.
|
||||
* @param testResult The test delay in milliseconds.
|
||||
*/
|
||||
fun encodeServerTestDelayMillis(guid: String, testResult: Long) {
|
||||
if (guid.isBlank()) {
|
||||
return
|
||||
}
|
||||
val aff = decodeServerAffiliationInfo(guid) ?: ServerAffiliationInfo()
|
||||
aff.testDelayMillis = testResult
|
||||
serverAffStorage.encode(guid, JsonUtil.toJson(aff))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all test delay results.
|
||||
*
|
||||
* @param keys The list of server GUIDs.
|
||||
*/
|
||||
fun clearAllTestDelayResults(keys: List<String>?) {
|
||||
keys?.forEach { key ->
|
||||
decodeServerAffiliationInfo(key)?.let { aff ->
|
||||
aff.testDelayMillis = 0
|
||||
serverAffStorage.encode(key, JsonUtil.toJson(aff))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all server configurations.
|
||||
*
|
||||
* @return The number of server configurations removed.
|
||||
*/
|
||||
fun removeAllServer(): Int {
|
||||
val count = profileFullStorage.allKeys()?.count() ?: 0
|
||||
profileFullStorage.clearAll()
|
||||
serverAffStorage.clearAll()
|
||||
serverRawStorage.clearAll()
|
||||
|
||||
decodeSubscriptions().forEach { sub ->
|
||||
encodeServerList(mutableListOf(), sub.guid)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes invalid server configurations.
|
||||
*
|
||||
* @param guid The server GUID.
|
||||
* @return The number of server configurations removed.
|
||||
*/
|
||||
fun removeInvalidServer(guid: String): Int {
|
||||
var count = 0
|
||||
if (guid.isNotEmpty()) {
|
||||
decodeServerAffiliationInfo(guid)?.let { aff ->
|
||||
if (aff.testDelayMillis < 0L) {
|
||||
removeServer(guid)
|
||||
count++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
serverAffStorage.allKeys()?.forEach { key ->
|
||||
decodeServerAffiliationInfo(key)?.let { aff ->
|
||||
if (aff.testDelayMillis < 0L) {
|
||||
removeServer(key)
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the raw server configuration.
|
||||
*
|
||||
* @param guid The server GUID.
|
||||
* @param config The raw server configuration.
|
||||
*/
|
||||
fun encodeServerRaw(guid: String, config: String) {
|
||||
serverRawStorage.encode(guid, config)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the raw server configuration.
|
||||
*
|
||||
* @param guid The server GUID.
|
||||
* @return The raw server configuration.
|
||||
*/
|
||||
fun decodeServerRaw(guid: String): String? {
|
||||
return serverRawStorage.decodeString(guid)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Subscriptions
|
||||
|
||||
private fun getSubscriptionId(subscriptionId: String?): String {
|
||||
return subscriptionId?.ifEmpty { DEFAULT_SUBSCRIPTION_ID } ?: DEFAULT_SUBSCRIPTION_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the subscription list.
|
||||
*/
|
||||
private fun initSubsList() {
|
||||
val subsList = decodeSubsList()
|
||||
if (subsList.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
subStorage.allKeys()?.forEach { key ->
|
||||
subsList.add(key)
|
||||
}
|
||||
encodeSubsList(subsList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the subscriptions.
|
||||
*
|
||||
* @return The list of subscriptions.
|
||||
*/
|
||||
fun decodeSubscriptions(): List<SubscriptionCache> {
|
||||
initSubsList()
|
||||
|
||||
val subscriptions = mutableListOf<SubscriptionCache>()
|
||||
decodeSubsList().forEach { key ->
|
||||
val json = subStorage.decodeString(key)
|
||||
if (!json.isNullOrBlank()) {
|
||||
val item = JsonUtil.fromJsonSafe(json, SubscriptionItem::class.java) ?: SubscriptionItem()
|
||||
subscriptions.add(SubscriptionCache(key, item))
|
||||
}
|
||||
}
|
||||
return subscriptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the subscription.
|
||||
*
|
||||
* @param subid The subscription ID.
|
||||
*/
|
||||
fun removeSubscription(subid: String) {
|
||||
subStorage.remove(subid)
|
||||
val subsList = decodeSubsList()
|
||||
subsList.remove(subid)
|
||||
encodeSubsList(subsList)
|
||||
|
||||
removeServerViaSubid(subid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the subscription.
|
||||
*
|
||||
* @param guid The subscription GUID.
|
||||
* @param subItem The subscription item.
|
||||
*/
|
||||
fun encodeSubscription(guid: String, subItem: SubscriptionItem) {
|
||||
val key = guid.ifBlank { Utils.getUuid() }
|
||||
subStorage.encode(key, JsonUtil.toJson(subItem))
|
||||
|
||||
val subsList = decodeSubsList()
|
||||
if (!subsList.contains(key)) {
|
||||
subsList.add(key)
|
||||
encodeSubsList(subsList)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the subscription.
|
||||
*
|
||||
* @param subscriptionId The subscription ID.
|
||||
* @return The subscription item.
|
||||
*/
|
||||
fun decodeSubscription(subscriptionId: String): SubscriptionItem? {
|
||||
val json = subStorage.decodeString(subscriptionId) ?: return null
|
||||
return JsonUtil.fromJsonSafe(json, SubscriptionItem::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the subscription list.
|
||||
*
|
||||
* @param subsList The list of subscription IDs.
|
||||
*/
|
||||
fun encodeSubsList(subsList: MutableList<String>) {
|
||||
mainStorage.encode(KEY_SUB_IDS, JsonUtil.toJson(subsList))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the subscription list.
|
||||
*
|
||||
* @return The list of subscription IDs.
|
||||
*/
|
||||
fun decodeSubsList(): MutableList<String> {
|
||||
val json = mainStorage.decodeString(KEY_SUB_IDS)
|
||||
return if (json.isNullOrBlank()) {
|
||||
mutableListOf()
|
||||
} else {
|
||||
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Asset
|
||||
|
||||
/**
|
||||
* Decodes the asset URLs.
|
||||
*
|
||||
* @return The list of asset URLs.
|
||||
*/
|
||||
fun decodeAssetUrls(): List<AssetUrlCache> {
|
||||
val assetUrlItems = mutableListOf<AssetUrlCache>()
|
||||
assetStorage.allKeys()?.forEach { key ->
|
||||
val json = assetStorage.decodeString(key)
|
||||
if (!json.isNullOrBlank()) {
|
||||
val item = JsonUtil.fromJsonSafe(json, AssetUrlItem::class.java) ?: AssetUrlItem()
|
||||
assetUrlItems.add(AssetUrlCache(key, item))
|
||||
}
|
||||
}
|
||||
return assetUrlItems.sortedBy { it.assetUrl.addedTime }
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the asset URL.
|
||||
*
|
||||
* @param assetid The asset ID.
|
||||
*/
|
||||
fun removeAssetUrl(assetid: String) {
|
||||
assetStorage.remove(assetid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the asset.
|
||||
*
|
||||
* @param assetid The asset ID.
|
||||
* @param assetItem The asset item.
|
||||
*/
|
||||
fun encodeAsset(assetid: String, assetItem: AssetUrlItem) {
|
||||
val key = assetid.ifBlank { Utils.getUuid() }
|
||||
assetStorage.encode(key, JsonUtil.toJson(assetItem))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the asset.
|
||||
*
|
||||
* @param assetid The asset ID.
|
||||
* @return The asset item.
|
||||
*/
|
||||
fun decodeAsset(assetid: String): AssetUrlItem? {
|
||||
val json = assetStorage.decodeString(assetid) ?: return null
|
||||
return JsonUtil.fromJsonSafe(json, AssetUrlItem::class.java)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Routing
|
||||
|
||||
/**
|
||||
* Decodes the routing rulesets.
|
||||
*
|
||||
* @return The list of routing rulesets.
|
||||
*/
|
||||
fun decodeRoutingRulesets(): MutableList<RulesetItem>? {
|
||||
val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
|
||||
if (ruleset.isNullOrEmpty()) return null
|
||||
return JsonUtil.fromJsonSafe(ruleset, Array<RulesetItem>::class.java)?.toMutableList() ?: mutableListOf()
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the routing rulesets.
|
||||
*
|
||||
* @param rulesetList The list of routing rulesets.
|
||||
*/
|
||||
fun encodeRoutingRulesets(rulesetList: MutableList<RulesetItem>?) {
|
||||
if (rulesetList.isNullOrEmpty())
|
||||
encodeSettings(PREF_ROUTING_RULESET, "")
|
||||
else
|
||||
encodeSettings(PREF_ROUTING_RULESET, JsonUtil.toJson(rulesetList))
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region settings
|
||||
/**
|
||||
* Encodes the settings.
|
||||
*
|
||||
* @param key The settings key.
|
||||
* @param value The settings value.
|
||||
* @return Whether the encoding was successful.
|
||||
*/
|
||||
fun encodeSettings(key: String, value: String?): 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: Int): 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: 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.
|
||||
*
|
||||
* @param key The settings key.
|
||||
* @param value The settings value.
|
||||
* @return Whether the encoding was successful.
|
||||
*/
|
||||
fun encodeSettings(key: String, value: Boolean): 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: MutableSet<String>): Boolean {
|
||||
return settingsStorage.encode(key, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the settings string.
|
||||
*
|
||||
* @param key The settings key.
|
||||
* @return The settings value.
|
||||
*/
|
||||
fun decodeSettingsString(key: String): String? {
|
||||
return settingsStorage.decodeString(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the settings string.
|
||||
*
|
||||
* @param key The settings key.
|
||||
* @param defaultValue The default value.
|
||||
* @return The settings value.
|
||||
*/
|
||||
fun decodeSettingsString(key: String, defaultValue: String?): String? {
|
||||
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.
|
||||
*
|
||||
* @param key The settings key.
|
||||
* @return The settings value.
|
||||
*/
|
||||
fun decodeSettingsBool(key: String): Boolean {
|
||||
return settingsStorage.decodeBool(key, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the settings boolean.
|
||||
*
|
||||
* @param key The settings key.
|
||||
* @param defaultValue The default value.
|
||||
* @return The settings value.
|
||||
*/
|
||||
fun decodeSettingsBool(key: String, defaultValue: Boolean): Boolean {
|
||||
return settingsStorage.decodeBool(key, defaultValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the settings string set.
|
||||
*
|
||||
* @param key The settings key.
|
||||
* @return The settings value.
|
||||
*/
|
||||
fun decodeSettingsStringSet(key: String): MutableSet<String>? {
|
||||
return settingsStorage.decodeStringSet(key)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Encodes the start on boot setting.
|
||||
*
|
||||
* @param startOnBoot Whether to start on boot.
|
||||
*/
|
||||
fun encodeStartOnBoot(startOnBoot: Boolean) {
|
||||
encodeSettings(PREF_IS_BOOTED, startOnBoot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the start on boot setting.
|
||||
*
|
||||
* @return Whether to start on boot.
|
||||
*/
|
||||
fun decodeStartOnBoot(): Boolean {
|
||||
return decodeSettingsBool(PREF_IS_BOOTED, false)
|
||||
}
|
||||
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreServiceManager
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.extension.toSpeedString
|
||||
import com.v2ray.ang.ui.MainActivity
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.min
|
||||
|
||||
object NotificationManager {
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val NOTIFICATION_PENDING_INTENT_CONTENT = 0
|
||||
private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1
|
||||
private const val NOTIFICATION_PENDING_INTENT_RESTART_V2RAY = 2
|
||||
private const val NOTIFICATION_ICON_THRESHOLD = 3000
|
||||
private const val QUERY_INTERVAL_MS = 3000L
|
||||
|
||||
private var lastQueryTime = 0L
|
||||
private var mBuilder: NotificationCompat.Builder? = null
|
||||
private var speedNotificationJob: Job? = null
|
||||
private var mNotificationManager: NotificationManager? = null
|
||||
|
||||
/**
|
||||
* Starts the speed notification.
|
||||
* @param currentConfig The current profile configuration.
|
||||
*/
|
||||
fun startSpeedNotification() {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
|
||||
if (speedNotificationJob != null || CoreServiceManager.isRunning() == false) return
|
||||
|
||||
var lastZeroSpeed = false
|
||||
|
||||
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
while (isActive) {
|
||||
lastZeroSpeed = updateSpeedNotificationOnce(lastZeroSpeed)
|
||||
delay(QUERY_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the notification.
|
||||
* @param currentConfig The current profile configuration.
|
||||
*/
|
||||
fun showNotification(currentConfig: ProfileItem?) {
|
||||
val service = getService() ?: return
|
||||
|
||||
// Reset last query time to avoid querying stats too soon after showing the notification
|
||||
lastQueryTime = System.currentTimeMillis()
|
||||
|
||||
val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
|
||||
val startMainIntent = Intent(service, MainActivity::class.java)
|
||||
val contentPendingIntent = PendingIntent.getActivity(service, NOTIFICATION_PENDING_INTENT_CONTENT, startMainIntent, flags)
|
||||
|
||||
val stopV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||
stopV2RayIntent.`package` = AppConfig.ANG_PACKAGE
|
||||
stopV2RayIntent.putExtra("key", AppConfig.MSG_STATE_STOP)
|
||||
val stopV2RayPendingIntent = PendingIntent.getBroadcast(service, NOTIFICATION_PENDING_INTENT_STOP_V2RAY, stopV2RayIntent, flags)
|
||||
|
||||
val restartV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||
restartV2RayIntent.`package` = AppConfig.ANG_PACKAGE
|
||||
restartV2RayIntent.putExtra("key", AppConfig.MSG_STATE_RESTART)
|
||||
val restartV2RayPendingIntent = PendingIntent.getBroadcast(service, NOTIFICATION_PENDING_INTENT_RESTART_V2RAY, restartV2RayIntent, flags)
|
||||
|
||||
val channelId =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
createNotificationChannel()
|
||||
} else {
|
||||
// If earlier version channel ID is not used
|
||||
// https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context)
|
||||
""
|
||||
}
|
||||
|
||||
mBuilder = NotificationCompat.Builder(service, channelId)
|
||||
.setSmallIcon(R.drawable.ic_stat_name)
|
||||
.setContentTitle(currentConfig?.remarks)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setOngoing(true)
|
||||
.setShowWhen(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.addAction(
|
||||
R.drawable.ic_delete_24dp,
|
||||
service.getString(R.string.notification_action_stop_v2ray),
|
||||
stopV2RayPendingIntent
|
||||
)
|
||||
.addAction(
|
||||
R.drawable.ic_restore_24dp,
|
||||
service.getString(R.string.title_service_restart),
|
||||
restartV2RayPendingIntent
|
||||
)
|
||||
|
||||
//mBuilder?.setDefaults(NotificationCompat.FLAG_ONLY_ALERT_ONCE)
|
||||
|
||||
service.startForeground(NOTIFICATION_ID, mBuilder?.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the notification.
|
||||
*/
|
||||
fun cancelNotification() {
|
||||
val service = getService() ?: return
|
||||
service.stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||
|
||||
mBuilder = null
|
||||
speedNotificationJob?.cancel()
|
||||
speedNotificationJob = null
|
||||
mNotificationManager = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the speed notification.
|
||||
*/
|
||||
fun stopSpeedNotification() {
|
||||
speedNotificationJob?.let {
|
||||
it.cancel()
|
||||
speedNotificationJob = null
|
||||
updateNotification("", 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a notification channel for Android O and above.
|
||||
* @return The channel ID.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun createNotificationChannel(): String {
|
||||
val channelId = AppConfig.RAY_NG_CHANNEL_ID
|
||||
val channelName = AppConfig.RAY_NG_CHANNEL_NAME
|
||||
val chan = NotificationChannel(
|
||||
channelId,
|
||||
channelName, NotificationManager.IMPORTANCE_HIGH
|
||||
)
|
||||
chan.lightColor = Color.DKGRAY
|
||||
chan.importance = NotificationManager.IMPORTANCE_NONE
|
||||
chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
getNotificationManager()?.createNotificationChannel(chan)
|
||||
return channelId
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the notification with the given content text and traffic data.
|
||||
* @param contentText The content text.
|
||||
* @param proxyTraffic The proxy traffic.
|
||||
* @param directTraffic The direct traffic.
|
||||
*/
|
||||
private fun updateNotification(contentText: String?, proxyTraffic: Long, directTraffic: Long) {
|
||||
if (mBuilder != null) {
|
||||
if (proxyTraffic < NOTIFICATION_ICON_THRESHOLD && directTraffic < NOTIFICATION_ICON_THRESHOLD) {
|
||||
mBuilder?.setSmallIcon(R.drawable.ic_stat_name)
|
||||
} else if (proxyTraffic > directTraffic) {
|
||||
mBuilder?.setSmallIcon(R.drawable.ic_stat_proxy)
|
||||
} else {
|
||||
mBuilder?.setSmallIcon(R.drawable.ic_stat_direct)
|
||||
}
|
||||
mBuilder?.setStyle(NotificationCompat.BigTextStyle().bigText(contentText))
|
||||
mBuilder?.setContentText(contentText)
|
||||
getNotificationManager()?.notify(NOTIFICATION_ID, mBuilder?.build())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the notification manager.
|
||||
* @return The notification manager.
|
||||
*/
|
||||
private fun getNotificationManager(): NotificationManager? {
|
||||
if (mNotificationManager == null) {
|
||||
val service = getService() ?: return null
|
||||
mNotificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
}
|
||||
return mNotificationManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the speed string to the given text.
|
||||
* @param text The text to append to.
|
||||
* @param name The name of the tag.
|
||||
* @param up The uplink speed.
|
||||
* @param down The downlink speed.
|
||||
*/
|
||||
private fun appendSpeedString(text: StringBuilder, name: String?, up: Double, down: Double) {
|
||||
var n = name ?: "no tag"
|
||||
n = n.take(min(n.length, 6))
|
||||
text.append(n)
|
||||
for (i in n.length..6 step 2) {
|
||||
text.append("\t")
|
||||
}
|
||||
text.append("• ${up.toLong().toSpeedString()}↑ ${down.toLong().toSpeedString()}↓\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the speed notification once.
|
||||
* Queries traffic stats, separates proxy and direct, and updates the notification.
|
||||
* @param lastZeroSpeed The previous zero speed state.
|
||||
* @return The current zero speed state.
|
||||
*/
|
||||
private fun updateSpeedNotificationOnce(lastZeroSpeed: Boolean): Boolean {
|
||||
val queryTime = System.currentTimeMillis()
|
||||
val sinceLastQueryIn = (queryTime - lastQueryTime)
|
||||
|
||||
// If the query interval is too short, skip this round to avoid excessive CPU usage
|
||||
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
|
||||
LogUtil.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
|
||||
lastQueryTime = queryTime
|
||||
return lastZeroSpeed
|
||||
}
|
||||
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
|
||||
|
||||
var proxyUplink = 0L
|
||||
var proxyDownlink = 0L
|
||||
var directUplink = 0L
|
||||
var directDownlink = 0L
|
||||
|
||||
CoreServiceManager.queryAllOutboundTrafficStats().forEach { stat ->
|
||||
when {
|
||||
stat.tag == AppConfig.TAG_DIRECT -> {
|
||||
when (stat.direction) {
|
||||
AppConfig.UPLINK -> directUplink += stat.value
|
||||
AppConfig.DOWNLINK -> directDownlink += stat.value
|
||||
}
|
||||
}
|
||||
|
||||
stat.tag.startsWith(AppConfig.TAG_PROXY) -> {
|
||||
when (stat.direction) {
|
||||
AppConfig.UPLINK -> proxyUplink += stat.value
|
||||
AppConfig.DOWNLINK -> proxyDownlink += stat.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val proxyTotal = proxyUplink + proxyDownlink
|
||||
val directTotal = directUplink + directDownlink
|
||||
val zeroSpeed = proxyTotal + directTotal == 0L
|
||||
if (!zeroSpeed || !lastZeroSpeed) {
|
||||
val text = StringBuilder()
|
||||
appendSpeedString(
|
||||
text, AppConfig.TAG_PROXY,
|
||||
proxyUplink / sinceLastQueryInSeconds,
|
||||
proxyDownlink / sinceLastQueryInSeconds
|
||||
)
|
||||
|
||||
appendSpeedString(
|
||||
text, AppConfig.TAG_DIRECT,
|
||||
directUplink / sinceLastQueryInSeconds,
|
||||
directDownlink / sinceLastQueryInSeconds
|
||||
)
|
||||
updateNotification(text.toString(), proxyTotal, directTotal)
|
||||
}
|
||||
lastQueryTime = queryTime
|
||||
return zeroSpeed
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the service instance.
|
||||
* @return The service instance.
|
||||
*/
|
||||
private fun getService(): Service? {
|
||||
return 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.AssetManager
|
||||
import android.os.Build
|
||||
import android.text.TextUtils
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
|
||||
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
|
||||
import com.v2ray.ang.AppConfig.TAG_DIRECT
|
||||
import com.v2ray.ang.AppConfig.VPN
|
||||
import com.v2ray.ang.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.decodeSubsList
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeSubscription
|
||||
import com.v2ray.ang.handler.MmkvManager.encodeSubscription
|
||||
import com.v2ray.ang.handler.MmkvManager.removeSubscription
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.Collections
|
||||
import java.util.Locale
|
||||
import kotlin.random.Random
|
||||
|
||||
object SettingsManager {
|
||||
|
||||
@Volatile
|
||||
private var runtimeSocksPort: Int? = null
|
||||
|
||||
fun initApp(context: Context) {
|
||||
ensureDefaultSettings()
|
||||
//ensureDefaultSubscription()
|
||||
initRoutingRulesets(context)
|
||||
migrateServerListToSubscriptions()
|
||||
migrateHysteria2PinSHA256()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize routing rulesets.
|
||||
* @param context The application context.
|
||||
*/
|
||||
private fun initRoutingRulesets(context: Context) {
|
||||
val exist = MmkvManager.decodeRoutingRulesets()
|
||||
if (exist.isNullOrEmpty()) {
|
||||
val rulesetList = getPresetRoutingRulesets(context)
|
||||
MmkvManager.encodeRoutingRulesets(rulesetList)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preset routing rulesets.
|
||||
* @param context The application context.
|
||||
* @param index The index of the routing type.
|
||||
* @return A mutable list of RulesetItem.
|
||||
*/
|
||||
private fun getPresetRoutingRulesets(context: Context, index: Int = 0): MutableList<RulesetItem>? {
|
||||
val fileName = RoutingType.fromIndex(index).fileName
|
||||
val assets = Utils.readTextFromAssets(context, fileName)
|
||||
if (TextUtils.isEmpty(assets)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return JsonUtil.fromJsonSafe(assets, Array<RulesetItem>::class.java)?.toMutableList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset routing rulesets from presets.
|
||||
* @param context The application context.
|
||||
* @param index The index of the routing type.
|
||||
*/
|
||||
fun resetRoutingRulesetsFromPresets(context: Context, index: Int) {
|
||||
val rulesetList = getPresetRoutingRulesets(context, index) ?: return
|
||||
resetRoutingRulesetsCommon(rulesetList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset routing rulesets.
|
||||
* @param content The content of the rulesets.
|
||||
* @return True if successful, false otherwise.
|
||||
*/
|
||||
fun resetRoutingRulesets(content: String?): Boolean {
|
||||
if (content.isNullOrEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
val rulesetList = JsonUtil.fromJsonSafe(content, Array<RulesetItem>::class.java)?.toMutableList()
|
||||
if (rulesetList.isNullOrEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
resetRoutingRulesetsCommon(rulesetList)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(ANG_PACKAGE, "Failed to reset routing rulesets", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common method to reset routing rulesets.
|
||||
* @param rulesetList The list of rulesets.
|
||||
*/
|
||||
private fun resetRoutingRulesetsCommon(rulesetList: MutableList<RulesetItem>) {
|
||||
val rulesetNew: MutableList<RulesetItem> = mutableListOf()
|
||||
MmkvManager.decodeRoutingRulesets()?.forEach { key ->
|
||||
if (key.locked == true) {
|
||||
rulesetNew.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
rulesetNew.addAll(rulesetList)
|
||||
MmkvManager.encodeRoutingRulesets(rulesetNew)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a routing ruleset by index.
|
||||
* @param index The index of the ruleset.
|
||||
* @return The RulesetItem.
|
||||
*/
|
||||
fun getRoutingRuleset(index: Int): RulesetItem? {
|
||||
if (index < 0) return null
|
||||
|
||||
val rulesetList = MmkvManager.decodeRoutingRulesets()
|
||||
if (rulesetList.isNullOrEmpty()) return null
|
||||
|
||||
return rulesetList[index]
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a routing ruleset.
|
||||
* @param index The index of the ruleset.
|
||||
* @param ruleset The RulesetItem to save.
|
||||
*/
|
||||
fun saveRoutingRuleset(index: Int, ruleset: RulesetItem?) {
|
||||
if (ruleset == null) return
|
||||
|
||||
var rulesetList = MmkvManager.decodeRoutingRulesets()
|
||||
if (rulesetList.isNullOrEmpty()) {
|
||||
rulesetList = mutableListOf()
|
||||
}
|
||||
|
||||
if (index < 0 || index >= rulesetList.count()) {
|
||||
rulesetList.add(0, ruleset)
|
||||
} else {
|
||||
rulesetList[index] = ruleset
|
||||
}
|
||||
MmkvManager.encodeRoutingRulesets(rulesetList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a routing ruleset by index.
|
||||
* @param index The index of the ruleset.
|
||||
*/
|
||||
fun removeRoutingRuleset(index: Int) {
|
||||
if (index < 0) return
|
||||
|
||||
val rulesetList = MmkvManager.decodeRoutingRulesets()
|
||||
if (rulesetList.isNullOrEmpty()) return
|
||||
|
||||
rulesetList.removeAt(index)
|
||||
MmkvManager.encodeRoutingRulesets(rulesetList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if routing rulesets bypass LAN.
|
||||
* @return True if bypassing LAN, false otherwise.
|
||||
*/
|
||||
fun routingRulesetsBypassLan(): Boolean {
|
||||
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN) ?: "1"
|
||||
if (vpnBypassLan == "1") {
|
||||
return true
|
||||
} else if (vpnBypassLan == "2") {
|
||||
return false
|
||||
}
|
||||
|
||||
val guid = MmkvManager.getSelectServer() ?: return false
|
||||
val config = decodeServerConfig(guid) ?: return false
|
||||
if (config.configType == EConfigType.CUSTOM) {
|
||||
val raw = MmkvManager.decodeServerRaw(guid) ?: return false
|
||||
val v2rayConfig = JsonUtil.fromJsonSafe(raw, V2rayConfig::class.java)
|
||||
val exist = v2rayConfig?.routing?.rules?.filter { it.outboundTag == TAG_DIRECT }?.any {
|
||||
it.domain?.contains(GEOSITE_PRIVATE) == true || it.ip?.contains(GEOIP_PRIVATE) == true
|
||||
}
|
||||
return exist == true
|
||||
}
|
||||
|
||||
val rulesetItems = MmkvManager.decodeRoutingRulesets()
|
||||
val exist = rulesetItems?.filter { it.enabled && it.outboundTag == TAG_DIRECT }?.any {
|
||||
it.domain?.contains(GEOSITE_PRIVATE) == true || it.ip?.contains(GEOIP_PRIVATE) == true
|
||||
}
|
||||
return exist == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap routing rulesets.
|
||||
* @param fromPosition The position to swap from.
|
||||
* @param toPosition The position to swap to.
|
||||
*/
|
||||
fun swapRoutingRuleset(fromPosition: Int, toPosition: Int) {
|
||||
val rulesetList = MmkvManager.decodeRoutingRulesets()
|
||||
if (rulesetList.isNullOrEmpty()) return
|
||||
|
||||
Collections.swap(rulesetList, fromPosition, toPosition)
|
||||
MmkvManager.encodeRoutingRulesets(rulesetList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap subscriptions.
|
||||
* @param fromPosition The position to swap from.
|
||||
* @param toPosition The position to swap to.
|
||||
*/
|
||||
fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
|
||||
val subsList = decodeSubsList()
|
||||
if (subsList.isEmpty()) return
|
||||
|
||||
Collections.swap(subsList, fromPosition, toPosition)
|
||||
MmkvManager.encodeSubsList(subsList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server via remarks.
|
||||
* @param remarks The remarks of the server.
|
||||
* @return The ProfileItem.
|
||||
*/
|
||||
fun getServerViaRemarks(remarks: String?): ProfileItem? {
|
||||
if (remarks.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val serverList = decodeAllServerList()
|
||||
return serverList
|
||||
.mapNotNull { guid -> decodeServerConfig(guid) }
|
||||
.firstOrNull { it.remarks == remarks }
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects non-empty profile remarks while excluding specific config types.
|
||||
*/
|
||||
fun getProfileRemarks(excludeConfigTypes: Set<EConfigType> = setOf(EConfigType.CUSTOM)): List<String> {
|
||||
return decodeAllServerList()
|
||||
.asSequence()
|
||||
.mapNotNull { guid -> decodeServerConfig(guid) }
|
||||
.filter { profile -> profile.configType !in excludeConfigTypes }
|
||||
.map { it.remarks.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the subscription.
|
||||
* If there are no remaining subscriptions,
|
||||
* it creates a new default subscription to ensure that ungroup
|
||||
**/
|
||||
fun removeSubscriptionWithDefault(subid: String) {
|
||||
SubscriptionUpdater.cancelOne(subId = subid)
|
||||
// Remove the subscription
|
||||
removeSubscription(subid)
|
||||
|
||||
// After removal, check if there are any subscriptions left. If not, create a default subscription.
|
||||
val subsList2 = decodeSubsList()
|
||||
if (subsList2.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val defaultSub = SubscriptionItem(
|
||||
remarks = "Default",
|
||||
)
|
||||
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SOCKS port.
|
||||
* @return The SOCKS port.
|
||||
*/
|
||||
fun getSocksPort(): Int {
|
||||
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() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTTP port.
|
||||
* @return The HTTP port.
|
||||
*/
|
||||
fun getHttpPort(): Int {
|
||||
return getSocksPort() + if (Utils.isXray()) 0 else 1
|
||||
}
|
||||
|
||||
private fun IsDynamicSocksPort(): Boolean {
|
||||
return MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
|
||||
}
|
||||
|
||||
private fun generateRandomSocksPort(): Int {
|
||||
return Random.nextInt(10000, 65535)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize assets.
|
||||
* @param context The application context.
|
||||
* @param assets The AssetManager.
|
||||
*/
|
||||
fun initAssets(context: Context, assets: AssetManager) {
|
||||
val extFolder = Utils.userAssetPath(context)
|
||||
|
||||
try {
|
||||
val geo = arrayOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
|
||||
assets.list("")
|
||||
?.filter { geo.contains(it) }
|
||||
?.filter { !File(extFolder, it).exists() }
|
||||
?.forEach {
|
||||
val target = File(extFolder, it)
|
||||
assets.open(it).use { input ->
|
||||
FileOutputStream(target).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
LogUtil.i(AppConfig.TAG, "Copied from apk assets folder to ${target.absolutePath}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(ANG_PACKAGE, "asset copy failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get domestic DNS servers from preference.
|
||||
* @return A list of domestic DNS servers.
|
||||
*/
|
||||
fun getDomesticDnsServers(): List<String> {
|
||||
val domesticDns =
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_DOMESTIC_DNS) ?: AppConfig.DNS_DIRECT
|
||||
val ret = domesticDns.split(",").filter { Utils.isPureIpAddress(it) || Utils.isCoreDNSAddress(it) }
|
||||
if (ret.isEmpty()) {
|
||||
return listOf(AppConfig.DNS_DIRECT)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remote DNS servers from preference.
|
||||
* @return A list of remote DNS servers.
|
||||
*/
|
||||
fun getRemoteDnsServers(): List<String> {
|
||||
val remoteDns =
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_REMOTE_DNS) ?: AppConfig.DNS_PROXY
|
||||
val ret = remoteDns.split(",").filter { Utils.isPureIpAddress(it) || Utils.isCoreDNSAddress(it) }
|
||||
if (ret.isEmpty()) {
|
||||
return listOf(AppConfig.DNS_PROXY)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VPN DNS servers from preference.
|
||||
* @return A list of VPN DNS servers.
|
||||
*/
|
||||
fun getVpnDnsServers(): List<String> {
|
||||
val vpnDns = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_DNS) ?: AppConfig.DNS_VPN
|
||||
return vpnDns.split(",").filter { Utils.isPureIpAddress(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get delay test URL.
|
||||
* @param second Whether to use the second URL.
|
||||
* @return The delay test URL.
|
||||
*/
|
||||
fun getDelayTestUrl(second: Boolean = false): String {
|
||||
return if (second) {
|
||||
AppConfig.DELAY_TEST_URL2
|
||||
} else {
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL)
|
||||
?: 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.
|
||||
* @return The locale.
|
||||
*/
|
||||
fun getLocale(): Locale {
|
||||
val langCode =
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_LANGUAGE) ?: Language.AUTO.code
|
||||
val language = Language.fromCode(langCode)
|
||||
|
||||
return when (language) {
|
||||
Language.AUTO -> Utils.getSysLocale()
|
||||
Language.ENGLISH -> Locale.ENGLISH
|
||||
Language.CHINA -> Locale.CHINA
|
||||
Language.TRADITIONAL_CHINESE -> Locale.TRADITIONAL_CHINESE
|
||||
Language.VIETNAMESE -> Locale.forLanguageTag("vi")
|
||||
Language.RUSSIAN -> Locale.forLanguageTag("ru")
|
||||
Language.PERSIAN -> Locale.forLanguageTag("fa")
|
||||
Language.ARABIC -> Locale.forLanguageTag("ar")
|
||||
Language.BANGLA -> Locale.forLanguageTag("bn")
|
||||
Language.BAKHTIARI -> Locale.forLanguageTag("bqi-IR")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set night mode.
|
||||
*/
|
||||
fun setNightMode() {
|
||||
when (MmkvManager.decodeSettingsString(AppConfig.PREF_UI_MODE_NIGHT, "0")) {
|
||||
"0" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
"1" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
"2" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 a root (system-wide) run mode is selected.
|
||||
*/
|
||||
fun isRootMode(): Boolean {
|
||||
return MmkvManager.decodeSettingsBool(AppConfig.PREF_ROOT_MODE_ENABLE, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if process routing can be used.
|
||||
*/
|
||||
fun canUseProcessRouting(): Boolean {
|
||||
// Android 10+
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must xray tun
|
||||
if (isUsingHevTun()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have route only enabled
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false) == false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure default settings are present in MMKV.
|
||||
*/
|
||||
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")
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_MAXSPLIT, "10")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.IPAPIInfo
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.net.UnknownHostException
|
||||
|
||||
object SpeedtestManager {
|
||||
|
||||
/**
|
||||
* Measures the time taken to establish a TCP connection to a given URL and port.
|
||||
*
|
||||
* @param url The URL to connect to.
|
||||
* @param port The port to connect to.
|
||||
* @return The connection time in milliseconds, or -1 if the connection failed.
|
||||
*/
|
||||
fun socketConnectTime(url: String, port: Int, timeoutMs: Int = 1500): Long {
|
||||
var socket: Socket? = null
|
||||
val start = System.currentTimeMillis()
|
||||
|
||||
try {
|
||||
socket = Socket()
|
||||
socket.connect(InetSocketAddress(url, port), timeoutMs)
|
||||
|
||||
return System.currentTimeMillis() - start
|
||||
} catch (e: UnknownHostException) {
|
||||
LogUtil.e(AppConfig.TAG, "Unknown host: $url", e)
|
||||
} catch (e: IOException) {
|
||||
LogUtil.e(AppConfig.TAG, "socketConnectTime IOException: ${e.message}")
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
|
||||
} finally {
|
||||
socket?.let { s ->
|
||||
try {
|
||||
if (!s.isClosed) {
|
||||
s.close()
|
||||
}
|
||||
} catch (closeEx: IOException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
fun getRemoteIPInfo(): String? {
|
||||
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
|
||||
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
|
||||
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
if (httpPort == 0) return null
|
||||
val content = HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000,
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
proxyPassword = proxyPassword
|
||||
)
|
||||
) ?: return null
|
||||
val ipInfo = JsonUtil.fromJsonSafe(content, IPAPIInfo::class.java) ?: return null
|
||||
|
||||
val ip = listOf(
|
||||
ipInfo.ip,
|
||||
ipInfo.clientIp,
|
||||
ipInfo.ip_addr,
|
||||
ipInfo.query
|
||||
).firstOrNull { !it.isNullOrBlank() }
|
||||
|
||||
val country = listOf(
|
||||
ipInfo.country_code,
|
||||
ipInfo.country,
|
||||
ipInfo.countryCode,
|
||||
ipInfo.location?.country_code
|
||||
).firstOrNull { !it.isNullOrBlank() }
|
||||
|
||||
return "(${country ?: "unknown"}) ${ip ?: "unknown"}"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,68 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class CustomDividerItemDecoration(
|
||||
private val divider: Drawable,
|
||||
private val orientation: Int
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
|
||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
if (orientation == RecyclerView.VERTICAL) {
|
||||
drawVerticalDividers(canvas, parent)
|
||||
} else {
|
||||
drawHorizontalDividers(canvas, parent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawVerticalDividers(canvas: Canvas, parent: RecyclerView) {
|
||||
val left = parent.paddingLeft
|
||||
val right = parent.width - parent.paddingRight
|
||||
|
||||
val childCount = parent.childCount
|
||||
for (i in 0 until childCount - 1) {
|
||||
val child = parent.getChildAt(i)
|
||||
val params = child.layoutParams as RecyclerView.LayoutParams
|
||||
|
||||
val top = child.bottom + params.bottomMargin
|
||||
val bottom = top + divider.intrinsicHeight
|
||||
|
||||
divider.setBounds(left, top, right, bottom)
|
||||
divider.draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawHorizontalDividers(canvas: Canvas, parent: RecyclerView) {
|
||||
val top = parent.paddingTop
|
||||
val bottom = parent.height - parent.paddingBottom
|
||||
|
||||
val childCount = parent.childCount
|
||||
for (i in 0 until childCount - 1) {
|
||||
val child = parent.getChildAt(i)
|
||||
val params = child.layoutParams as RecyclerView.LayoutParams
|
||||
|
||||
val left = child.right + params.rightMargin
|
||||
val right = left + divider.intrinsicWidth
|
||||
|
||||
divider.setBounds(left, top, right, bottom)
|
||||
divider.draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
if (orientation == RecyclerView.VERTICAL) {
|
||||
outRect.set(0, 0, 0, divider.intrinsicHeight)
|
||||
} else {
|
||||
outRect.set(0, 0, divider.intrinsicWidth, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 455 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user