Compare commits
1034 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
+104
-38
@@ -1,13 +1,14 @@
|
||||
name: Build APK
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
XRAY_CORE_VERSION:
|
||||
description: 'Xray core version or commit hash'
|
||||
release_tag:
|
||||
required: false
|
||||
|
||||
type: string
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -15,49 +16,114 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
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@v5
|
||||
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@v5
|
||||
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@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
|
||||
- name: Setup Golang
|
||||
uses: actions/setup-go@v5
|
||||
- name: Decode Keystore
|
||||
uses: timheuer/base64-to-file@v2.0.0
|
||||
id: android_keystore
|
||||
with:
|
||||
go-version: '1.22.2'
|
||||
|
||||
- name: Install gomobile
|
||||
run: |
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
|
||||
|
||||
|
||||
- name: Setup Android environment
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
|
||||
- name: Build dependencies
|
||||
run: |
|
||||
mkdir ${{ github.workspace }}/build
|
||||
cd ${{ github.workspace }}/build
|
||||
git clone --depth=1 -b main https://github.com/2dust/AndroidLibXrayLite.git
|
||||
cd AndroidLibXrayLite
|
||||
go get github.com/xtls/xray-core@${{ github.event.inputs.XRAY_CORE_VERSION }} || true
|
||||
gomobile init
|
||||
go mod tidy -v
|
||||
gomobile bind -v -androidapi 19 -ldflags='-s -w' ./
|
||||
cp *.aar ${{ github.workspace }}/V2rayNG/app/libs/
|
||||
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 assembleDebug
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v4
|
||||
./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: apk
|
||||
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/debug/
|
||||
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
|
||||
|
||||
- name: Upload to release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
if: github.event.inputs.release_tag != ''
|
||||
with:
|
||||
file: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*.apk
|
||||
tag: ${{ github.event.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@v6
|
||||
- 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 e194d22cf1
@@ -2,17 +2,13 @@
|
||||
|
||||
A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-core) and [v2fly core](https://github.com/v2fly/v2ray-core)
|
||||
|
||||
[](https://developer.android.com/about/versions/lollipop)
|
||||
[](https://kotlinlang.org)
|
||||
[](https://developer.android.com/about/versions/lollipop)
|
||||
[](https://kotlinlang.org)
|
||||
[](https://github.com/2dust/v2rayNG/commits/master)
|
||||
[](https://www.codefactor.io/repository/github/2dust/v2rayng)
|
||||
[](https://github.com/2dust/v2rayNG/releases)
|
||||
[](https://t.me/v2rayn)
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=com.v2ray.ang">
|
||||
<img alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png" width="165" height="64" />
|
||||
</a>
|
||||
|
||||
### Telegram Channel
|
||||
[github_2dust](https://t.me/github_2dust)
|
||||
|
||||
@@ -21,7 +17,7 @@ A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-cor
|
||||
#### Geoip and Geosite
|
||||
- geoip.dat and geosite.dat files are in `Android/data/com.v2ray.ang/files/assets` (path may differ on some Android device)
|
||||
- download feature will get enhanced version in this [repo](https://github.com/Loyalsoldier/v2ray-rules-dat) (Note it need a working proxy)
|
||||
- latest official [domain list](https://github.com/v2fly/domain-list-community) and [ip list](https://github.com/v2fly/geoip) can be imported manually
|
||||
- latest official [domain list](https://github.com/Loyalsoldier/v2ray-rules-dat) and [ip list](https://github.com/Loyalsoldier/geoip) can be imported manually
|
||||
- possible to use third party dat file in the same folder, like [h2y](https://guide.v2fly.org/routing/sitedata.html#%E5%A4%96%E7%BD%AE%E7%9A%84%E5%9F%9F%E5%90%8D%E6%96%87%E4%BB%B6)
|
||||
|
||||
### More in our [wiki](https://github.com/2dust/v2rayNG/wiki)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
*.apk
|
||||
signing.properties
|
||||
*.aar
|
||||
@@ -1,2 +0,0 @@
|
||||
/build
|
||||
/google-services.json
|
||||
+144
-87
@@ -1,42 +1,63 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
id("com.jaredsburrows.license")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.v2ray.ang"
|
||||
compileSdk = 34
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionCode = 578
|
||||
versionName = "1.8.34"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 732
|
||||
versionName = "2.2.2"
|
||||
multiDexEnabled = true
|
||||
splits.abi {
|
||||
reset()
|
||||
include(
|
||||
"arm64-v8a",
|
||||
"armeabi-v7a",
|
||||
"x86_64",
|
||||
"x86"
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
reset()
|
||||
if (abiFilterList != null && abiFilterList.isNotEmpty()) {
|
||||
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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
|
||||
flavorDimensions.add("distribution")
|
||||
productFlavors {
|
||||
create("fdroid") {
|
||||
dimension = "distribution"
|
||||
applicationIdSuffix = ".fdroid"
|
||||
buildConfigField("String", "DISTRIBUTION", "\"F-Droid\"")
|
||||
}
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
|
||||
create("playstore") {
|
||||
dimension = "distribution"
|
||||
buildConfigField("String", "DISTRIBUTION", "\"Play Store\"")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,40 +67,60 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
isUniversalApk = true
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
val variant = this
|
||||
val versionCodes =
|
||||
mapOf("armeabi-v7a" to 4, "arm64-v8a" to 4, "x86" to 4, "x86_64" to 4, "universal" to 4)
|
||||
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 = if (output.getFilter("ABI") != null)
|
||||
output.getFilter("ABI")
|
||||
else
|
||||
"universal"
|
||||
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)
|
||||
|
||||
output.outputFileName = "v2rayNG_${variant.versionName}_${abi}.apk"
|
||||
if(versionCodes.containsKey(abi))
|
||||
{
|
||||
output.versionCodeOverride = (1000000 * versionCodes[abi]!!).plus(variant.versionCode)
|
||||
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
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
@@ -87,53 +128,69 @@ android {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
packaging {
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar","*.jar"))))
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
// Core Libraries
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar", "*.jar"))))
|
||||
|
||||
implementation("com.google.android.flexbox:flexbox:3.0.0")
|
||||
// Androidx
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||
implementation("androidx.legacy:legacy-support-v4:1.0.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
implementation("androidx.cardview:cardview:1.0.0")
|
||||
implementation("androidx.preference:preference-ktx:1.2.1")
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||
implementation("androidx.fragment:fragment-ktx:1.8.1")
|
||||
implementation("androidx.multidex:multidex:2.0.1")
|
||||
implementation("androidx.viewpager2:viewpager2:1.1.0")
|
||||
// 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)
|
||||
|
||||
// Androidx ktx
|
||||
implementation("androidx.activity:activity-ktx:1.9.0")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3")
|
||||
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
|
||||
// UI Libraries
|
||||
implementation(libs.material)
|
||||
implementation(libs.toasty)
|
||||
implementation(libs.editorkit)
|
||||
implementation(libs.flexbox)
|
||||
|
||||
//kotlin
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.23")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
// Data and Storage Libraries
|
||||
implementation(libs.mmkv.static)
|
||||
implementation(libs.gson)
|
||||
implementation(libs.okhttp)
|
||||
|
||||
implementation("com.tencent:mmkv-static:1.3.4")
|
||||
implementation("com.google.code.gson:gson:2.11.0")
|
||||
implementation("io.reactivex:rxjava:1.3.8")
|
||||
implementation("io.reactivex:rxandroid:1.2.1")
|
||||
implementation("com.tbruyelle.rxpermissions:rxpermissions:0.9.4@aar")
|
||||
implementation("me.drakeet.support:toastcompat:1.1.0")
|
||||
implementation("com.blacksquircle.ui:editorkit:2.9.0")
|
||||
implementation("com.blacksquircle.ui:language-base:2.9.0")
|
||||
implementation("com.blacksquircle.ui:language-json:2.9.0")
|
||||
implementation("io.github.g00fy2.quickie:quickie-bundled:1.9.0")
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
// Reactive and Utility Libraries
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
||||
implementation("androidx.work:work-multiprocess:2.8.1")
|
||||
}
|
||||
// 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
|
||||
@@ -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>
|
||||
@@ -5,140 +5,180 @@
|
||||
|
||||
<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-sdk android:minSdkVersion="21" tools:overrideLibrary="com.blacksquircle.ui.editorkit"/>
|
||||
|
||||
<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" />
|
||||
<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.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:icon="@mipmap/ic_launcher"
|
||||
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"/>
|
||||
<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:exported="false"
|
||||
android:name=".ui.UserAssetUrlActivity" />
|
||||
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"/>
|
||||
<data android:host="install-config"/>
|
||||
<data android:host="install-sub"/>
|
||||
<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:exported="false"
|
||||
android:name=".ui.AboutActivity" />
|
||||
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:foregroundServiceType="specialUse"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
android:process=":RunSoLibV2RayDaemon"
|
||||
tools:ignore="VpnServicePolicy">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
@@ -150,43 +190,57 @@
|
||||
android:value="vpn" />
|
||||
</service>
|
||||
|
||||
<service android:name=".service.V2RayProxyOnlyService"
|
||||
android:exported="false"
|
||||
android:label="@string/app_name"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:process=":RunSoLibV2RayDaemon">
|
||||
<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.V2RayTestService"
|
||||
<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:foregroundServiceType="specialUse"
|
||||
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>
|
||||
@@ -196,19 +250,19 @@
|
||||
</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>
|
||||
@@ -227,6 +281,11 @@
|
||||
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name="androidx.work.multiprocess.RemoteWorkManagerService"
|
||||
android:exported="false"
|
||||
android:process=":bg" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.cache"
|
||||
@@ -234,7 +293,7 @@
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/cache_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,2 +0,0 @@
|
||||
geosite:cn,
|
||||
geosite:geolocation-cn
|
||||
@@ -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 +0,0 @@
|
||||
geosite:geolocation-!cn
|
||||
@@ -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,290 @@
|
||||
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 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_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
|
||||
|
||||
/** 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_DNSPOD_DOMAIN = "dot.pub"
|
||||
const val DNS_ALIDNS_DOMAIN = "dns.alidns.com"
|
||||
const val DNS_CLOUDFLARE_ONE_DOMAIN = "one.one.one.one"
|
||||
const val DNS_CLOUDFLARE_DNS_COM_DOMAIN = "dns.cloudflare.com"
|
||||
const val DNS_CLOUDFLARE_DNS_DOMAIN = "cloudflare-dns.com"
|
||||
const val DNS_GOOGLE_DOMAIN = "dns.google"
|
||||
const val DNS_QUAD9_DOMAIN = "dns.quad9.net"
|
||||
const val DNS_YANDEX_DOMAIN = "common.dot.dns.yandex.net"
|
||||
|
||||
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_CLOUDFLARE_ONE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
|
||||
val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5")
|
||||
val DNS_CLOUDFLARE_DNS_ADDRESSES = arrayListOf("104.16.248.249", "104.16.249.249", "2606:4700::6810:f8f9", "2606:4700::6810:f9f9")
|
||||
val DNS_DNSPOD_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
|
||||
val DNS_GOOGLE_ADDRESSES = arrayListOf("8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844")
|
||||
val DNS_QUAD9_ADDRESSES = arrayListOf("9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9")
|
||||
val DNS_YANDEX_ADDRESSES = arrayListOf("77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff")
|
||||
|
||||
//minimum list https://serverfault.com/a/304791
|
||||
val ROUTED_IP_LIST = arrayListOf(
|
||||
"0.0.0.0/5",
|
||||
"8.0.0.0/7",
|
||||
"11.0.0.0/8",
|
||||
"12.0.0.0/6",
|
||||
"16.0.0.0/4",
|
||||
"32.0.0.0/3",
|
||||
"64.0.0.0/2",
|
||||
"128.0.0.0/3",
|
||||
"160.0.0.0/5",
|
||||
"168.0.0.0/6",
|
||||
"172.0.0.0/12",
|
||||
"172.32.0.0/11",
|
||||
"172.64.0.0/10",
|
||||
"172.128.0.0/9",
|
||||
"173.0.0.0/8",
|
||||
"174.0.0.0/7",
|
||||
"176.0.0.0/4",
|
||||
"192.0.0.0/9",
|
||||
"192.128.0.0/11",
|
||||
"192.160.0.0/13",
|
||||
"192.169.0.0/16",
|
||||
"192.170.0.0/15",
|
||||
"192.172.0.0/14",
|
||||
"192.176.0.0/12",
|
||||
"192.192.0.0/10",
|
||||
"193.0.0.0/8",
|
||||
"194.0.0.0/7",
|
||||
"196.0.0.0/6",
|
||||
"200.0.0.0/5",
|
||||
"208.0.0.0/4",
|
||||
"240.0.0.0/4"
|
||||
)
|
||||
|
||||
val PRIVATE_IP_LIST = arrayListOf(
|
||||
"0.0.0.0/8",
|
||||
"10.0.0.0/8",
|
||||
"127.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"169.254.0.0/16",
|
||||
"224.0.0.0/4"
|
||||
)
|
||||
|
||||
val GEO_FILES_SOURCES = arrayListOf(
|
||||
"Loyalsoldier/v2ray-rules-dat",
|
||||
"runetfreedom/russia-v2ray-rules-dat",
|
||||
"Chocolate4U/Iran-v2ray-rules"
|
||||
)
|
||||
|
||||
val BUILTIN_OUTBOUND_TAGS = setOf(
|
||||
TAG_PROXY,
|
||||
TAG_DIRECT,
|
||||
TAG_BLOCKED,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
/**
|
||||
* A common Adapter -> host callback interface that includes common actions: edit, remove and refresh.
|
||||
* Extend this interface or define more specific interfaces for different adapters as needed.
|
||||
*/
|
||||
interface BaseAdapterListener {
|
||||
/**
|
||||
* Request the host to edit the specified item.
|
||||
* @param guid Unique identifier (GUID) of the item
|
||||
* @param position Current position in the adapter (optional; host should validate it)
|
||||
*/
|
||||
fun onEdit(guid: String, position: Int)
|
||||
|
||||
/**
|
||||
* Request the host to remove the specified item. Position is provided for optional animation or validation.
|
||||
* @param guid Unique identifier (GUID) of the item
|
||||
* @param position Current position in the adapter (optional; host should validate it)
|
||||
*/
|
||||
fun onRemove(guid: String, position: Int)
|
||||
|
||||
/**
|
||||
* Request the host to share the specified URL.
|
||||
* @param url The URL to be shared
|
||||
*/
|
||||
fun onShare(url: String)
|
||||
|
||||
/**
|
||||
* Request the host to refresh data (for example, reload from the ViewModel or call notifyDataSetChanged).
|
||||
*/
|
||||
fun onRefreshData()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
|
||||
interface MainAdapterListener : BaseAdapterListener {
|
||||
|
||||
fun onEdit(guid: String, position: Int, profile: ProfileItem)
|
||||
|
||||
fun onSelectServer(guid: String)
|
||||
|
||||
fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean)
|
||||
|
||||
}
|
||||
@@ -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,217 @@
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.content.Context
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.CoreConfigContext
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.CoreResolvedType
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
/**
|
||||
* Build runtime context from the selected profile.
|
||||
*
|
||||
* All outbound type analysis is completed here for both the selected profile
|
||||
* and routing targets. Custom profiles are returned immediately without
|
||||
* entering the normal analysis flow.
|
||||
*/
|
||||
object CoreConfigContextBuilder {
|
||||
|
||||
/**
|
||||
* Load one profile and produce a fully analyzed context.
|
||||
*
|
||||
* Null is returned only when the selected profile cannot be loaded.
|
||||
*/
|
||||
fun build(context: Context, guid: String): CoreConfigContext? {
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return null
|
||||
|
||||
// CUSTOM: return immediately — CoreConfigManager handles this path on its own.
|
||||
if (config.configType == EConfigType.CUSTOM) {
|
||||
return CoreConfigContext(context = context, guid = guid, isCustom = true)
|
||||
}
|
||||
|
||||
// Step 1: Resolve the main outbound (always tag = TAG_PROXY).
|
||||
val primaryResolvedOutbound = resolveOutbound(AppConfig.TAG_PROXY, config) ?: run {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve main outbound for '${config.remarks}'")
|
||||
return null
|
||||
}
|
||||
|
||||
// Step 2: Resolve all non-builtin routing outbound tags.
|
||||
val routingResolvedOutbounds = resolveRoutingOutbounds()
|
||||
|
||||
return CoreConfigContext(
|
||||
context = context,
|
||||
guid = guid,
|
||||
resolvedOutbounds = listOf(primaryResolvedOutbound) + routingResolvedOutbounds,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one outbound target into a normalized outbound entry.
|
||||
*
|
||||
* Custom profiles are ignored at this stage and produce no entry.
|
||||
*/
|
||||
private fun resolveOutbound(tag: String, profile: ProfileItem): CoreConfigContext.ResolvedOutbound? {
|
||||
if (profile.configType == EConfigType.CUSTOM) {
|
||||
return null
|
||||
}
|
||||
|
||||
val (resolvedProfiles, resolvedType) = when (profile.configType) {
|
||||
EConfigType.POLICYGROUP -> Pair(
|
||||
resolvePolicyGroupProfiles(profile),
|
||||
CoreResolvedType.POLICYGROUP,
|
||||
)
|
||||
|
||||
EConfigType.PROXYCHAIN -> {
|
||||
val chainProfiles = resolveProxyChainProfiles(profile)
|
||||
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
|
||||
Pair(chainProfiles, type)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val chainProfiles = resolveProxyChainProfilesFromGroup(profile)
|
||||
val type = if (chainProfiles.size <= 1) CoreResolvedType.NORMAL else CoreResolvedType.PROXYCHAIN
|
||||
Pair(chainProfiles, type)
|
||||
}
|
||||
}
|
||||
|
||||
return CoreConfigContext.ResolvedOutbound(
|
||||
tag = tag,
|
||||
profile = profile,
|
||||
resolvedProfiles = resolvedProfiles,
|
||||
resolvedType = resolvedType,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect and resolve non-builtin routing targets from enabled rules.
|
||||
*
|
||||
* Invalid or empty targets are skipped and handled by fallback logic later.
|
||||
*/
|
||||
private fun resolveRoutingOutbounds(): List<CoreConfigContext.ResolvedOutbound> {
|
||||
val rulesetItems = MmkvManager.decodeRoutingRulesets() ?: return emptyList()
|
||||
val resolvedOutbounds = mutableListOf<CoreConfigContext.ResolvedOutbound>()
|
||||
val processedTags = mutableSetOf<String>()
|
||||
|
||||
try {
|
||||
rulesetItems
|
||||
.filter { it.enabled }
|
||||
.mapNotNull { it.outboundTag.takeIf { tag -> tag.isNotBlank() } }
|
||||
.filter { tag -> tag !in AppConfig.BUILTIN_OUTBOUND_TAGS }
|
||||
.distinct()
|
||||
.forEach { tag ->
|
||||
if (tag in processedTags) {
|
||||
return@forEach
|
||||
}
|
||||
processedTags.add(tag)
|
||||
|
||||
try {
|
||||
val profile = SettingsManager.getServerViaRemarks(tag) ?: run {
|
||||
LogUtil.w(AppConfig.TAG, "Routing tag '$tag' has no matching profile — will fall back to proxy at routing time")
|
||||
return@forEach
|
||||
}
|
||||
val resolvedOutbound = resolveOutbound(tag, profile) ?: run {
|
||||
LogUtil.w(AppConfig.TAG, "Cannot use CUSTOM profile as routing outbound for tag '$tag', skipping")
|
||||
return@forEach
|
||||
}
|
||||
if (resolvedOutbound.resolvedProfiles.isEmpty()) {
|
||||
LogUtil.w(AppConfig.TAG, "Routing outbound '$tag' resolved to empty list, skipping")
|
||||
return@forEach
|
||||
}
|
||||
resolvedOutbounds.add(resolvedOutbound)
|
||||
LogUtil.d(AppConfig.TAG, "Resolved routing outbound: tag='$tag', type='${resolvedOutbound.resolvedType}', profiles=${resolvedOutbound.resolvedProfiles.size}")
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve routing outbound for tag '$tag', skipping", e)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve routing outbounds from rulesets", e)
|
||||
}
|
||||
|
||||
return resolvedOutbounds
|
||||
}
|
||||
|
||||
private fun resolvePolicyGroupProfiles(config: ProfileItem): List<ProfileItem> {
|
||||
try {
|
||||
val serverList = MmkvManager.decodeAllServerList()
|
||||
return serverList
|
||||
.asSequence()
|
||||
.mapNotNull { id -> MmkvManager.decodeServerConfig(id) }
|
||||
.filter { profile ->
|
||||
val subscriptionId = config.policyGroupSubscriptionId
|
||||
if (subscriptionId.isNullOrBlank()) {
|
||||
true
|
||||
} else {
|
||||
profile.subscriptionId == subscriptionId
|
||||
}
|
||||
}
|
||||
.filter { profile ->
|
||||
val filter = config.policyGroupFilter
|
||||
if (filter.isNullOrBlank()) {
|
||||
true
|
||||
} else {
|
||||
try {
|
||||
Regex(filter).containsMatchIn(profile.remarks)
|
||||
} catch (_: Exception) {
|
||||
profile.remarks.contains(filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
.filter { it.server.isNotNullEmpty() }
|
||||
.filter { Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
|
||||
.filter { !it.configType.isComplexType() }
|
||||
.toList()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve policy group profiles for '${config.remarks}'", e)
|
||||
return listOf(config)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveProxyChainProfiles(config: ProfileItem): List<ProfileItem> {
|
||||
if (config.proxyChainProfiles.isNullOrBlank()) {
|
||||
return listOf(config)
|
||||
}
|
||||
|
||||
try {
|
||||
return config.proxyChainProfiles.orEmpty().split(",")
|
||||
.asSequence()
|
||||
.mapNotNull { remark -> SettingsManager.getServerViaRemarks(remark) }
|
||||
.filter { it.server.isNotNullEmpty() }
|
||||
.filter { Utils.isPureIpAddress(it.server!!) || Utils.isValidUrl(it.server!!) }
|
||||
.filter { !it.configType.isComplexType() }
|
||||
.toList()
|
||||
.reversed()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain profiles for '${config.remarks}'", e)
|
||||
return listOf(config)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve chain nodes from subscription neighbors in order: next, current, prev.
|
||||
*
|
||||
* When no chain is available, return a single-node result.
|
||||
*/
|
||||
private fun resolveProxyChainProfilesFromGroup(config: ProfileItem): List<ProfileItem> {
|
||||
if (config.subscriptionId.isEmpty()) {
|
||||
return listOf(config)
|
||||
}
|
||||
|
||||
try {
|
||||
val subItem = MmkvManager.decodeSubscription(config.subscriptionId) ?: return listOf(config)
|
||||
val resolved = mutableListOf<ProfileItem>()
|
||||
SettingsManager.getServerViaRemarks(subItem.nextProfile)?.let { resolved.add(it) }
|
||||
resolved.add(config)
|
||||
SettingsManager.getServerViaRemarks(subItem.prevProfile)?.let { resolved.add(it) }
|
||||
return resolved
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to resolve proxy chain from group for '${config.remarks}'", e)
|
||||
return listOf(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.content.Context
|
||||
import android.text.TextUtils
|
||||
import com.google.gson.JsonArray
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.ConfigResult
|
||||
import com.v2ray.ang.dto.CoreConfigContext
|
||||
import com.v2ray.ang.dto.V2rayConfig
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.RulesetItem
|
||||
import com.v2ray.ang.enums.BalancerStrategyType
|
||||
import com.v2ray.ang.enums.CoreResolvedType
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.PackageUidResolver
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
object CoreConfigManager {
|
||||
private var initConfigCache: String? = null
|
||||
private var initConfigCacheWithTun: String? = null
|
||||
|
||||
//region get config function
|
||||
|
||||
/**
|
||||
* Build the runtime configuration for normal startup.
|
||||
*/
|
||||
fun getV2rayConfig(context: Context, guid: String): ConfigResult {
|
||||
try {
|
||||
val configContext = CoreConfigContextBuilder.build(context, guid)
|
||||
?: return ConfigResult(status = false, guid = guid, errorMessage = "Failed to build config context")
|
||||
if (configContext.isCustom) {
|
||||
return buildV2rayCustomConfig(configContext)
|
||||
}
|
||||
return toConfigResult(configContext, buildUnifiedConfig(configContext))
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config", e)
|
||||
return ConfigResult(
|
||||
status = false,
|
||||
guid = guid,
|
||||
errorMessage = "Failed to get V2ray config: ${e.message ?: e.javaClass.simpleName}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a lightweight configuration for latency testing.
|
||||
*
|
||||
* The core flow is reused, then non-essential sections are removed.
|
||||
*/
|
||||
fun getV2rayConfig4Speedtest(context: Context, guid: String): ConfigResult {
|
||||
try {
|
||||
val configContext = CoreConfigContextBuilder.build(context, guid)
|
||||
?: return ConfigResult(status = false, guid = guid, errorMessage = "Failed to build config context")
|
||||
if (configContext.isCustom) {
|
||||
return buildV2rayCustomConfig(configContext)
|
||||
}
|
||||
val v2rayConfig = buildUnifiedConfig(configContext)
|
||||
postProcessForSpeedtest(v2rayConfig)
|
||||
|
||||
return toConfigResult(configContext, v2rayConfig)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to get V2ray config for speedtest", e)
|
||||
return ConfigResult(
|
||||
status = false,
|
||||
guid = guid,
|
||||
errorMessage = "Failed to get V2ray config for speedtest: ${e.message ?: e.javaClass.simpleName}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build configuration for custom profiles.
|
||||
*/
|
||||
private fun buildV2rayCustomConfig(configContext: CoreConfigContext): ConfigResult {
|
||||
val context = configContext.context
|
||||
val raw = MmkvManager.decodeServerRaw(configContext.guid)
|
||||
?: return ConfigResult(status = false, guid = configContext.guid, errorMessage = "Custom config is empty")
|
||||
val result = ConfigResult(true, configContext.guid, raw)
|
||||
if (!needTun()) {
|
||||
return result
|
||||
}
|
||||
|
||||
val json = JsonUtil.parseString(raw)?.takeIf { it.isJsonObject }?.asJsonObject ?: return result
|
||||
|
||||
// Check whether package names need to be replaced with UIDs
|
||||
if (SettingsManager.canUseProcessRouting()) {
|
||||
val rulesJson = json.get("routing")?.takeIf { it.isJsonObject }?.asJsonObject
|
||||
?.get("rules")?.takeIf { it.isJsonArray }?.asJsonArray
|
||||
?: JsonArray()
|
||||
|
||||
for (elem in rulesJson) {
|
||||
val rule = elem.takeIf { it.isJsonObject }?.asJsonObject ?: continue
|
||||
val process = rule.get("process")?.takeIf { it.isJsonArray }?.asJsonArray ?: continue
|
||||
val packages = process.mapNotNull {
|
||||
it.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }?.asString
|
||||
}.takeIf { it.isNotEmpty() } ?: continue
|
||||
val uids = PackageUidResolver.packageNamesToUids(context, packages).takeIf { it.isNotEmpty() } ?: continue
|
||||
|
||||
rule.add("process", JsonArray().apply { uids.forEach { add(it) } })
|
||||
}
|
||||
}
|
||||
|
||||
// check if tun inbound exists
|
||||
val inboundsJson = json.get("inbounds")?.takeIf { it.isJsonArray }?.asJsonArray
|
||||
?: JsonArray().also { json.add("inbounds", it) }
|
||||
val tunNotExists = inboundsJson.none { elem ->
|
||||
elem.isJsonObject && elem.asJsonObject.get("protocol")
|
||||
?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }
|
||||
?.asString == "tun"
|
||||
}
|
||||
|
||||
if (tunNotExists) {
|
||||
// add tun inbound from template
|
||||
val templateConfig = initV2rayConfig(configContext)
|
||||
templateConfig.inbounds.firstOrNull { it.tag == "tun" }?.let { inboundTun ->
|
||||
inboundTun.settings?.mtu = SettingsManager.getVpnMtu()
|
||||
inboundsJson.add(JsonUtil.parseString(JsonUtil.toJson(inboundTun)))
|
||||
}
|
||||
}
|
||||
|
||||
return JsonUtil.toJsonPretty(json)?.let { ConfigResult(true, configContext.guid, it) } ?: result
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one unified configuration for every non-custom profile type.
|
||||
*
|
||||
* The analyzed outbound plan is consumed in order and converted to concrete
|
||||
* outbounds before routing, DNS, and runtime extras are assembled.
|
||||
*/
|
||||
private fun buildUnifiedConfig(configContext: CoreConfigContext): V2rayConfig {
|
||||
require(configContext.resolvedOutbounds.isNotEmpty()) { "resolvedOutbounds must not be empty for a non-CUSTOM context" }
|
||||
val primaryResolvedOutbound = configContext.resolvedOutbounds.first()
|
||||
|
||||
val v2rayConfig = initV2rayConfig(configContext)
|
||||
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
|
||||
v2rayConfig.remarks = primaryResolvedOutbound.profile.remarks
|
||||
|
||||
configureInbounds(v2rayConfig)
|
||||
|
||||
if (v2rayConfig.outbounds.isNotEmpty()) {
|
||||
v2rayConfig.outbounds.removeAt(0)
|
||||
}
|
||||
val existingTags = v2rayConfig.outbounds.mapTo(mutableSetOf()) { it.tag }
|
||||
val policyGroupBalancerTags = mutableMapOf<String, String>()
|
||||
val balancerStrategies = mutableListOf<BalancerStrategy>()
|
||||
|
||||
// resolvedOutbounds is a single ordered plan: index 0 is primary and must be prepended,
|
||||
// the rest are routing outbounds and can be appended.
|
||||
configContext.resolvedOutbounds.forEachIndexed { index, spec ->
|
||||
buildOutbounds(
|
||||
resolvedOutbound = spec,
|
||||
prepend = index == 0,
|
||||
existingTags = existingTags,
|
||||
v2rayConfig = v2rayConfig,
|
||||
policyGroupBalancerTags = policyGroupBalancerTags,
|
||||
balancerStrategies = balancerStrategies,
|
||||
)
|
||||
}
|
||||
|
||||
// User routing rules (policyGroupBalancerTags rewrites TAG_PROXY→balancer when main is POLICYGROUP).
|
||||
configureRouting(configContext, v2rayConfig, policyGroupBalancerTags)
|
||||
configureFakeDns(v2rayConfig)
|
||||
configureDns(v2rayConfig, policyGroupBalancerTags)
|
||||
configureLocalDns(v2rayConfig)
|
||||
|
||||
// (added by getDns / getCustomLocalDns) to use the balancer, then add
|
||||
// the catch-all balancer rule.
|
||||
if (primaryResolvedOutbound.resolvedType == CoreResolvedType.POLICYGROUP) {
|
||||
if (v2rayConfig.routing.domainStrategy == "IPIfNonMatch") {
|
||||
v2rayConfig.routing.rules.add(
|
||||
V2rayConfig.RoutingBean.RulesBean(
|
||||
ip = arrayListOf("0.0.0.0/0", "::/0"),
|
||||
balancerTag = AppConfig.TAG_BALANCER,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
v2rayConfig.routing.rules.add(
|
||||
V2rayConfig.RoutingBean.RulesBean(
|
||||
network = "tcp,udp",
|
||||
balancerTag = AppConfig.TAG_BALANCER,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
applyObservability(v2rayConfig, balancerStrategies)
|
||||
applySpeedDisabled(v2rayConfig)
|
||||
resolveOutboundDomainsToHosts(v2rayConfig)
|
||||
|
||||
return v2rayConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one analyzed outbound entry into concrete outbounds and register
|
||||
* them to the runtime configuration.
|
||||
*/
|
||||
private fun buildOutbounds(
|
||||
resolvedOutbound: CoreConfigContext.ResolvedOutbound,
|
||||
prepend: Boolean,
|
||||
existingTags: MutableSet<String>,
|
||||
v2rayConfig: V2rayConfig,
|
||||
policyGroupBalancerTags: MutableMap<String, String>,
|
||||
balancerStrategies: MutableList<BalancerStrategy>,
|
||||
) {
|
||||
if (resolvedOutbound.tag in existingTags) {
|
||||
LogUtil.w(AppConfig.TAG, "Resolved outbound tag '${resolvedOutbound.tag}' already exists, skipping duplicated entry")
|
||||
return
|
||||
}
|
||||
|
||||
when (resolvedOutbound.resolvedType) {
|
||||
CoreResolvedType.NORMAL -> handleNormalResolvedOutbound(
|
||||
resolvedOutbound = resolvedOutbound,
|
||||
prepend = prepend,
|
||||
existingTags = existingTags,
|
||||
v2rayConfig = v2rayConfig,
|
||||
)
|
||||
|
||||
CoreResolvedType.PROXYCHAIN -> handleProxyChainResolvedOutbound(
|
||||
resolvedOutbound = resolvedOutbound,
|
||||
prepend = prepend,
|
||||
existingTags = existingTags,
|
||||
v2rayConfig = v2rayConfig,
|
||||
)
|
||||
|
||||
CoreResolvedType.POLICYGROUP -> handlePolicyGroupResolvedOutbound(
|
||||
resolvedOutbound = resolvedOutbound,
|
||||
prepend = prepend,
|
||||
existingTags = existingTags,
|
||||
v2rayConfig = v2rayConfig,
|
||||
policyGroupBalancerTags = policyGroupBalancerTags,
|
||||
balancerStrategies = balancerStrategies,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and insert a single-node outbound entry.
|
||||
*/
|
||||
private fun handleNormalResolvedOutbound(
|
||||
resolvedOutbound: CoreConfigContext.ResolvedOutbound,
|
||||
prepend: Boolean,
|
||||
existingTags: MutableSet<String>,
|
||||
v2rayConfig: V2rayConfig,
|
||||
) {
|
||||
val profile = resolvedOutbound.resolvedProfiles.firstOrNull() ?: run {
|
||||
LogUtil.w(AppConfig.TAG, "NORMAL resolved outbound '${resolvedOutbound.tag}' has empty resolvedProfiles, skipping")
|
||||
return
|
||||
}
|
||||
val outbound = convertProfile2Outbound(profile) ?: run {
|
||||
LogUtil.w(AppConfig.TAG, "Could not convert NORMAL resolved outbound '${resolvedOutbound.tag}' profile to outbound, skipping")
|
||||
return
|
||||
}
|
||||
outbound.tag = resolvedOutbound.tag
|
||||
if (prepend) {
|
||||
v2rayConfig.outbounds.add(0, outbound)
|
||||
} else {
|
||||
v2rayConfig.outbounds.add(outbound)
|
||||
}
|
||||
existingTags.add(resolvedOutbound.tag)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and insert a multi-hop chain entry.
|
||||
*/
|
||||
private fun handleProxyChainResolvedOutbound(
|
||||
resolvedOutbound: CoreConfigContext.ResolvedOutbound,
|
||||
prepend: Boolean,
|
||||
existingTags: MutableSet<String>,
|
||||
v2rayConfig: V2rayConfig,
|
||||
) {
|
||||
val chainOutbounds = resolvedOutbound.resolvedProfiles
|
||||
.mapNotNull { convertProfile2Outbound(it) }
|
||||
.toMutableList()
|
||||
if (chainOutbounds.isEmpty()) {
|
||||
LogUtil.w(AppConfig.TAG, "PROXYCHAIN resolved outbound '${resolvedOutbound.tag}' has no valid profiles, skipping")
|
||||
return
|
||||
}
|
||||
if (chainOutbounds.size == 1) {
|
||||
val outbound = chainOutbounds.first()
|
||||
outbound.tag = resolvedOutbound.tag
|
||||
if (prepend) {
|
||||
v2rayConfig.outbounds.add(0, outbound)
|
||||
} else {
|
||||
v2rayConfig.outbounds.add(outbound)
|
||||
}
|
||||
existingTags.add(resolvedOutbound.tag)
|
||||
return
|
||||
}
|
||||
|
||||
val chainTags = chainOutbounds.mapIndexed { index, _ ->
|
||||
if (index == 0) {
|
||||
resolvedOutbound.tag
|
||||
} else {
|
||||
"${AppConfig.TAG_PROXY}-${resolvedOutbound.tag}-$index"
|
||||
}
|
||||
}
|
||||
if (chainTags.any { it in existingTags }) {
|
||||
LogUtil.w(
|
||||
AppConfig.TAG,
|
||||
"PROXYCHAIN resolved outbound '${resolvedOutbound.tag}' has colliding hop tags, skipping"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
chainOutbounds.forEachIndexed { index, outbound ->
|
||||
outbound.tag = chainTags[index]
|
||||
}
|
||||
for (i in 0 until chainOutbounds.size - 1) {
|
||||
chainOutbounds[i].ensureSockopt().dialerProxy = chainOutbounds[i + 1].tag
|
||||
}
|
||||
|
||||
if (prepend) {
|
||||
v2rayConfig.outbounds.addAll(0, chainOutbounds)
|
||||
} else {
|
||||
v2rayConfig.outbounds.addAll(chainOutbounds)
|
||||
}
|
||||
chainOutbounds.forEach { existingTags.add(it.tag) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and insert a policy-group entry and its balancer metadata.
|
||||
*/
|
||||
private fun handlePolicyGroupResolvedOutbound(
|
||||
resolvedOutbound: CoreConfigContext.ResolvedOutbound,
|
||||
prepend: Boolean,
|
||||
existingTags: MutableSet<String>,
|
||||
v2rayConfig: V2rayConfig,
|
||||
policyGroupBalancerTags: MutableMap<String, String>,
|
||||
balancerStrategies: MutableList<BalancerStrategy>,
|
||||
) {
|
||||
val memberPairs = resolvedOutbound.resolvedProfiles.mapNotNull { profile ->
|
||||
convertProfile2Outbound(profile)?.let { ob -> ob to profile }
|
||||
}
|
||||
if (memberPairs.isEmpty()) {
|
||||
LogUtil.w(AppConfig.TAG, "POLICYGROUP resolved outbound '${resolvedOutbound.tag}' has no valid member outbounds, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
val memberTagPrefix = "${AppConfig.TAG_PROXY}-${resolvedOutbound.tag}-"
|
||||
val membersToAdd = mutableListOf<V2rayConfig.OutboundBean>()
|
||||
memberPairs.forEachIndexed { index, (outbound, profile) ->
|
||||
val memberTag = "$memberTagPrefix${index + 1}-${profile.remarks.trim()}"
|
||||
if (memberTag in existingTags) {
|
||||
return@forEachIndexed
|
||||
}
|
||||
outbound.tag = memberTag
|
||||
membersToAdd.add(outbound)
|
||||
existingTags.add(memberTag)
|
||||
}
|
||||
|
||||
if (membersToAdd.isEmpty()) {
|
||||
LogUtil.w(
|
||||
AppConfig.TAG,
|
||||
"POLICYGROUP resolved outbound '${resolvedOutbound.tag}' produced no unique member tags, skipping"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (prepend) {
|
||||
v2rayConfig.outbounds.addAll(0, membersToAdd)
|
||||
} else {
|
||||
v2rayConfig.outbounds.addAll(membersToAdd)
|
||||
}
|
||||
|
||||
val balancerTag = if (resolvedOutbound.tag == AppConfig.TAG_PROXY) {
|
||||
AppConfig.TAG_BALANCER
|
||||
} else {
|
||||
"${AppConfig.TAG_BALANCER_PRE}-${resolvedOutbound.tag}"
|
||||
}
|
||||
val strategy = buildBalancerStrategy(
|
||||
policyGroupType = resolvedOutbound.profile.policyGroupType,
|
||||
selector = listOf(memberTagPrefix),
|
||||
balancerTag = balancerTag,
|
||||
)
|
||||
val existingBalancers = v2rayConfig.routing.balancers?.toMutableList() ?: mutableListOf()
|
||||
if (existingBalancers.none { it.tag == balancerTag }) {
|
||||
existingBalancers.add(strategy.balancer)
|
||||
v2rayConfig.routing.balancers = existingBalancers
|
||||
}
|
||||
balancerStrategies.add(strategy)
|
||||
policyGroupBalancerTags[resolvedOutbound.tag] = balancerTag
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim runtime sections that are not needed for latency testing.
|
||||
*/
|
||||
private fun postProcessForSpeedtest(v2rayConfig: V2rayConfig) {
|
||||
v2rayConfig.log.loglevel = MmkvManager.decodeSettingsString(AppConfig.PREF_LOGLEVEL) ?: "warning"
|
||||
v2rayConfig.inbounds.clear()
|
||||
v2rayConfig.routing.rules.clear()
|
||||
v2rayConfig.dns = null
|
||||
v2rayConfig.fakedns = null
|
||||
v2rayConfig.stats = null
|
||||
v2rayConfig.policy = null
|
||||
v2rayConfig.outbounds.forEach { key -> key.mux = null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a runtime configuration into a standard result object.
|
||||
*/
|
||||
private fun toConfigResult(configContext: CoreConfigContext, v2rayConfig: V2rayConfig): ConfigResult {
|
||||
return ConfigResult(
|
||||
status = true,
|
||||
guid = configContext.guid,
|
||||
content = JsonUtil.toJsonPretty(v2rayConfig) ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the base template from cache or assets and parse it.
|
||||
*/
|
||||
private fun initV2rayConfig(configContext: CoreConfigContext): V2rayConfig {
|
||||
val context = configContext.context
|
||||
val assets: String
|
||||
if (needTun()) {
|
||||
assets = initConfigCacheWithTun ?: Utils.readTextFromAssets(context, "v2ray_config_with_tun.json")
|
||||
if (TextUtils.isEmpty(assets)) {
|
||||
error("Missing asset: v2ray_config_with_tun.json")
|
||||
}
|
||||
initConfigCacheWithTun = assets
|
||||
} else {
|
||||
assets = initConfigCache ?: Utils.readTextFromAssets(context, "v2ray_config.json")
|
||||
if (TextUtils.isEmpty(assets)) {
|
||||
error("Missing asset: v2ray_config.json")
|
||||
}
|
||||
initConfigCache = assets
|
||||
}
|
||||
return JsonUtil.fromJson(assets, V2rayConfig::class.java)
|
||||
?: error("Failed to parse config template")
|
||||
}
|
||||
|
||||
|
||||
//endregion
|
||||
|
||||
|
||||
//region some sub function
|
||||
|
||||
private fun needTun(): Boolean {
|
||||
return SettingsManager.isVpnMode() && !SettingsManager.isUsingHevTun()
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure inbound listeners and related runtime options.
|
||||
*/
|
||||
private fun configureInbounds(v2rayConfig: V2rayConfig) {
|
||||
val vpn = SettingsManager.isVpnMode()
|
||||
val useHev = SettingsManager.isUsingHevTun()
|
||||
val forcedByHev = vpn && useHev
|
||||
|
||||
val enableLocalProxy = forcedByHev || MmkvManager.decodeSettingsBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true)
|
||||
|
||||
val socksPort = SettingsManager.getSocksPort()
|
||||
val socksUsername = SettingsManager.getSocksUsername()
|
||||
val socksPassword = SettingsManager.getSocksPassword()
|
||||
val inbound1 = v2rayConfig.inbounds[0]
|
||||
if (inbound1.settings == null) {
|
||||
inbound1.settings = V2rayConfig.InboundBean.InSettingsBean()
|
||||
}
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING) != true) {
|
||||
inbound1.listen = AppConfig.LOOPBACK
|
||||
}
|
||||
inbound1.port = socksPort
|
||||
inbound1.settings?.udp = MmkvManager.decodeSettingsBool(AppConfig.PREF_SOCKS_ENABLE_UDP, true)
|
||||
if (socksUsername != null && socksPassword != null) {
|
||||
inbound1.settings?.auth = "password"
|
||||
inbound1.settings?.accounts = listOf(
|
||||
V2rayConfig.InboundBean.InSettingsBean.SocksAccountBean(
|
||||
user = socksUsername,
|
||||
pass = socksPassword
|
||||
)
|
||||
)
|
||||
} else {
|
||||
inbound1.settings?.auth = "noauth"
|
||||
inbound1.settings?.accounts = null
|
||||
}
|
||||
val fakedns = MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
|
||||
val sniffAllTlsAndHttp =
|
||||
MmkvManager.decodeSettingsBool(AppConfig.PREF_SNIFFING_ENABLED, true) != false
|
||||
inbound1.sniffing?.enabled = fakedns || sniffAllTlsAndHttp
|
||||
inbound1.sniffing?.routeOnly =
|
||||
MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false)
|
||||
if (!sniffAllTlsAndHttp) {
|
||||
inbound1.sniffing?.destOverride?.clear()
|
||||
}
|
||||
if (fakedns) {
|
||||
inbound1.sniffing?.destOverride?.add("fakedns")
|
||||
}
|
||||
|
||||
if (!Utils.isXray()) {
|
||||
val inbound2 = JsonUtil.fromJson(JsonUtil.toJson(inbound1), V2rayConfig.InboundBean::class.java)
|
||||
?: error("Failed to clone inbound template")
|
||||
inbound2.tag = EConfigType.HTTP.name.lowercase()
|
||||
inbound2.port = SettingsManager.getHttpPort()
|
||||
inbound2.protocol = EConfigType.HTTP.name.lowercase()
|
||||
inbound2.settings?.auth = null
|
||||
inbound2.settings?.udp = null
|
||||
v2rayConfig.inbounds.add(inbound2)
|
||||
}
|
||||
|
||||
if (!enableLocalProxy) {
|
||||
v2rayConfig.inbounds.removeIf { it.protocol == "socks" || it.protocol == "http" }
|
||||
}
|
||||
|
||||
if (needTun()) {
|
||||
val inboundTun = v2rayConfig.inbounds.firstOrNull { e -> e.tag == "tun" }
|
||||
inboundTun?.settings?.mtu = SettingsManager.getVpnMtu()
|
||||
inboundTun?.sniffing = inbound1.sniffing
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable fake DNS when local DNS and fake DNS are both enabled.
|
||||
*/
|
||||
private fun configureFakeDns(v2rayConfig: V2rayConfig) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true
|
||||
&& MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true
|
||||
) {
|
||||
v2rayConfig.fakedns = listOf(V2rayConfig.FakednsBean())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect domain rules that target one outbound tag.
|
||||
*/
|
||||
private fun collectUserRuleDomainsByTag(tag: String): ArrayList<String> {
|
||||
val domain = ArrayList<String>()
|
||||
|
||||
val rulesetItems = MmkvManager.decodeRoutingRulesets()
|
||||
rulesetItems?.forEach { key ->
|
||||
if (key.enabled && key.outboundTag == tag && !key.domain.isNullOrEmpty()) {
|
||||
key.domain?.forEach {
|
||||
domain.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return domain
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect domain rules that target non-builtin outbound tags.
|
||||
*/
|
||||
private fun collectCustomOutboundDomains(): ArrayList<String> {
|
||||
val domain = ArrayList<String>()
|
||||
|
||||
val rulesetItems = MmkvManager.decodeRoutingRulesets()
|
||||
rulesetItems?.forEach { key ->
|
||||
if (key.enabled && !AppConfig.BUILTIN_OUTBOUND_TAGS.contains(key.outboundTag)
|
||||
&& !key.domain.isNullOrEmpty()
|
||||
) {
|
||||
key.domain?.forEach {
|
||||
domain.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return domain
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure local DNS inbounds, outbounds, and routing rules.
|
||||
*/
|
||||
private fun configureLocalDns(v2rayConfig: V2rayConfig) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) != true) {
|
||||
return
|
||||
}
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FAKE_DNS_ENABLED) == true) {
|
||||
val geositeCn = arrayListOf(AppConfig.GEOSITE_CN)
|
||||
val proxyDomain = collectUserRuleDomainsByTag(AppConfig.TAG_PROXY)
|
||||
val directDomain = collectUserRuleDomainsByTag(AppConfig.TAG_DIRECT)
|
||||
val finalDomain = geositeCn.plus(proxyDomain).plus(directDomain).distinct()
|
||||
// fakedns with all domains to make it always top priority
|
||||
v2rayConfig.dns?.servers?.add(
|
||||
0,
|
||||
V2rayConfig.DnsBean.ServersBean(
|
||||
address = "fakedns",
|
||||
domains = finalDomain
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (SettingsManager.isVpnMode()) {
|
||||
if (SettingsManager.isUsingHevTun()) {
|
||||
//hev-socks5-tunnel dns routing
|
||||
v2rayConfig.routing.rules.add(
|
||||
0, V2rayConfig.RoutingBean.RulesBean(
|
||||
inboundTag = arrayListOf("socks"),
|
||||
outboundTag = "dns-out",
|
||||
port = "53",
|
||||
)
|
||||
)
|
||||
} else {
|
||||
v2rayConfig.routing.rules.add(
|
||||
0, V2rayConfig.RoutingBean.RulesBean(
|
||||
inboundTag = arrayListOf("tun"),
|
||||
outboundTag = "dns-out",
|
||||
port = "53",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DNS outbound
|
||||
if (v2rayConfig.outbounds.none { e -> e.protocol == "dns" && e.tag == "dns-out" }) {
|
||||
v2rayConfig.outbounds.add(
|
||||
V2rayConfig.OutboundBean(
|
||||
protocol = "dns",
|
||||
tag = "dns-out",
|
||||
settings = null,
|
||||
streamSettings = null,
|
||||
mux = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove speed-test runtime sections when the feature is disabled.
|
||||
*/
|
||||
private fun applySpeedDisabled(v2rayConfig: V2rayConfig) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) {
|
||||
v2rayConfig.stats = null
|
||||
v2rayConfig.policy = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure DNS servers, hosts, and DNS routing rules.
|
||||
*/
|
||||
private fun configureDns(
|
||||
v2rayConfig: V2rayConfig,
|
||||
policyGroupBalancerTags: Map<String, String>,
|
||||
) {
|
||||
val hosts = mutableMapOf<String, Any>()
|
||||
val servers = ArrayList<Any>()
|
||||
|
||||
//remote Dns
|
||||
val remoteDns = SettingsManager.getRemoteDnsServers()
|
||||
val proxyDomain = (collectUserRuleDomainsByTag(AppConfig.TAG_PROXY) + collectCustomOutboundDomains()).distinct()
|
||||
remoteDns.forEach {
|
||||
servers.add(it)
|
||||
}
|
||||
if (proxyDomain.isNotEmpty()) {
|
||||
servers.add(
|
||||
V2rayConfig.DnsBean.ServersBean(
|
||||
address = remoteDns.first(),
|
||||
domains = proxyDomain,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// domestic DNS
|
||||
val domesticDns = SettingsManager.getDomesticDnsServers()
|
||||
val directDomain = collectUserRuleDomainsByTag(AppConfig.TAG_DIRECT)
|
||||
val isCnRoutingMode = directDomain.contains(AppConfig.GEOSITE_CN)
|
||||
val cnRegionFilter = { domain: String ->
|
||||
domain.startsWith("geosite:") && (domain.endsWith("-cn") || domain.endsWith("@cn"))
|
||||
|| domain == AppConfig.GEOSITE_CN
|
||||
}
|
||||
val finalDirectDomain = if (isCnRoutingMode) directDomain.filterNot {
|
||||
cnRegionFilter(it)
|
||||
} else directDomain
|
||||
val domesticDnsTags = mutableListOf<String>()
|
||||
domesticDns.forEachIndexed { index, element ->
|
||||
val tag = AppConfig.TAG_DOMESTIC_DNS + index
|
||||
servers.add(
|
||||
V2rayConfig.DnsBean.ServersBean(
|
||||
address = element,
|
||||
domains = finalDirectDomain,
|
||||
skipFallback = true,
|
||||
tag = tag
|
||||
)
|
||||
)
|
||||
domesticDnsTags.add(tag)
|
||||
}
|
||||
if (isCnRoutingMode) {
|
||||
val geoipCn = arrayListOf(AppConfig.GEOIP_CN)
|
||||
val cnRegionDomain = directDomain.filter { cnRegionFilter(it) }
|
||||
domesticDns.forEachIndexed { index, element ->
|
||||
val geositeCnDnsTag = AppConfig.TAG_DOMESTIC_DNS + index + "_cn_expect"
|
||||
servers.add(
|
||||
V2rayConfig.DnsBean.ServersBean(
|
||||
address = element,
|
||||
domains = cnRegionDomain,
|
||||
expectIPs = geoipCn,
|
||||
skipFallback = true,
|
||||
tag = geositeCnDnsTag
|
||||
)
|
||||
)
|
||||
domesticDnsTags.add(geositeCnDnsTag)
|
||||
}
|
||||
}
|
||||
|
||||
//block dns
|
||||
val blkDomain = collectUserRuleDomainsByTag(AppConfig.TAG_BLOCKED)
|
||||
if (blkDomain.isNotEmpty()) {
|
||||
hosts.putAll(blkDomain.map { it to AppConfig.LOOPBACK })
|
||||
}
|
||||
|
||||
// hardcode googleapi rule to fix play store problems
|
||||
hosts[AppConfig.GOOGLEAPIS_CN_DOMAIN] = AppConfig.GOOGLEAPIS_COM_DOMAIN
|
||||
|
||||
// hardcode popular Android Private DNS rule to fix localhost DNS problem
|
||||
hosts[AppConfig.DNS_ALIDNS_DOMAIN] = AppConfig.DNS_ALIDNS_ADDRESSES
|
||||
hosts[AppConfig.DNS_CLOUDFLARE_ONE_DOMAIN] = AppConfig.DNS_CLOUDFLARE_ONE_ADDRESSES
|
||||
hosts[AppConfig.DNS_CLOUDFLARE_DNS_COM_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_COM_ADDRESSES
|
||||
hosts[AppConfig.DNS_CLOUDFLARE_DNS_DOMAIN] = AppConfig.DNS_CLOUDFLARE_DNS_ADDRESSES
|
||||
hosts[AppConfig.DNS_DNSPOD_DOMAIN] = AppConfig.DNS_DNSPOD_ADDRESSES
|
||||
hosts[AppConfig.DNS_GOOGLE_DOMAIN] = AppConfig.DNS_GOOGLE_ADDRESSES
|
||||
hosts[AppConfig.DNS_QUAD9_DOMAIN] = AppConfig.DNS_QUAD9_ADDRESSES
|
||||
hosts[AppConfig.DNS_YANDEX_DOMAIN] = AppConfig.DNS_YANDEX_ADDRESSES
|
||||
|
||||
//User DNS hosts
|
||||
val userHosts = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
|
||||
if (userHosts.isNotNullEmpty()) {
|
||||
val userHostsMap = userHosts?.split(",")
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.filter { it.contains(":") }
|
||||
?.associate { it.split(":").let { (k, v) -> k to v } }
|
||||
if (userHostsMap != null) {
|
||||
hosts.putAll(userHostsMap)
|
||||
}
|
||||
}
|
||||
|
||||
// DNS dns
|
||||
v2rayConfig.dns = V2rayConfig.DnsBean(
|
||||
servers = servers,
|
||||
hosts = hosts,
|
||||
tag = AppConfig.TAG_DNS,
|
||||
enableParallelQuery = if ((domesticDns.size + remoteDns.size) > 2) true else null
|
||||
)
|
||||
|
||||
// DNS routing
|
||||
v2rayConfig.routing.rules.add(
|
||||
V2rayConfig.RoutingBean.RulesBean(
|
||||
outboundTag = AppConfig.TAG_DIRECT,
|
||||
inboundTag = domesticDnsTags,
|
||||
domain = null
|
||||
)
|
||||
)
|
||||
val dnsProxyBalancerTag = policyGroupBalancerTags[AppConfig.TAG_PROXY]
|
||||
if (dnsProxyBalancerTag != null) {
|
||||
v2rayConfig.routing.rules.add(
|
||||
V2rayConfig.RoutingBean.RulesBean(
|
||||
balancerTag = dnsProxyBalancerTag,
|
||||
inboundTag = arrayListOf(AppConfig.TAG_DNS),
|
||||
domain = null
|
||||
)
|
||||
)
|
||||
} else {
|
||||
v2rayConfig.routing.rules.add(
|
||||
V2rayConfig.RoutingBean.RulesBean(
|
||||
outboundTag = AppConfig.TAG_PROXY,
|
||||
inboundTag = arrayListOf(AppConfig.TAG_DNS),
|
||||
domain = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//endregion
|
||||
|
||||
|
||||
//region outbound related functions
|
||||
|
||||
|
||||
/**
|
||||
* Resolve outbound domains to IPs and write resolved hosts to DNS map.
|
||||
*/
|
||||
private fun resolveOutboundDomainsToHosts(v2rayConfig: V2rayConfig) {
|
||||
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "1") {
|
||||
return
|
||||
}
|
||||
|
||||
val proxyOutboundList = v2rayConfig.getAllProxyOutbound()
|
||||
val dns = v2rayConfig.dns ?: return
|
||||
val newHosts = dns.hosts?.toMutableMap() ?: mutableMapOf()
|
||||
val preferIpv6 = MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true
|
||||
|
||||
for (item in proxyOutboundList) {
|
||||
val domain = item.getServerAddress()
|
||||
if (domain.isNullOrEmpty()) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (newHosts.containsKey(domain)) {
|
||||
item.ensureSockopt().domainStrategy = "UseIP"
|
||||
item.ensureSockopt().happyEyeballs = V2rayConfig.OutboundBean.StreamSettingsBean.HappyEyeballsBean(
|
||||
prioritizeIPv6 = preferIpv6,
|
||||
interleave = 2
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
val resolvedIps = HttpUtil.resolveHostToIP(domain, preferIpv6)
|
||||
if (resolvedIps.isNullOrEmpty()) {
|
||||
continue
|
||||
}
|
||||
|
||||
item.ensureSockopt().domainStrategy = "UseIP"
|
||||
item.ensureSockopt().happyEyeballs = V2rayConfig.OutboundBean.StreamSettingsBean.HappyEyeballsBean(
|
||||
prioritizeIPv6 = preferIpv6,
|
||||
interleave = 2
|
||||
)
|
||||
newHosts[domain] = if (resolvedIps.size == 1) {
|
||||
resolvedIps[0]
|
||||
} else {
|
||||
resolvedIps
|
||||
}
|
||||
}
|
||||
|
||||
dns.hosts = newHosts
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one profile object into one outbound object.
|
||||
*/
|
||||
private fun convertProfile2Outbound(profileItem: ProfileItem): V2rayConfig.OutboundBean? {
|
||||
return CoreOutboundBuilder.convert(profileItem)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
|
||||
//region routing related functions
|
||||
|
||||
|
||||
/**
|
||||
* Merge probe settings from all balancer strategies into the runtime config.
|
||||
*/
|
||||
private fun applyObservability(v2rayConfig: V2rayConfig, strategies: List<BalancerStrategy>) {
|
||||
val allObsSelectors = strategies
|
||||
.mapNotNull { it.observatory?.subjectSelector }
|
||||
.flatten()
|
||||
.distinct()
|
||||
val obsTemplate = strategies.firstNotNullOfOrNull { it.observatory }
|
||||
if (obsTemplate != null && allObsSelectors.isNotEmpty()) {
|
||||
v2rayConfig.observatory = V2rayConfig.ObservatoryObject(
|
||||
subjectSelector = allObsSelectors,
|
||||
probeUrl = obsTemplate.probeUrl,
|
||||
probeInterval = obsTemplate.probeInterval,
|
||||
enableConcurrency = obsTemplate.enableConcurrency
|
||||
)
|
||||
}
|
||||
|
||||
val allBurstSelectors = strategies
|
||||
.mapNotNull { it.burstObservatory?.subjectSelector }
|
||||
.flatten()
|
||||
.distinct()
|
||||
val burstTemplate = strategies.firstNotNullOfOrNull { it.burstObservatory }
|
||||
if (burstTemplate != null && allBurstSelectors.isNotEmpty()) {
|
||||
v2rayConfig.burstObservatory = V2rayConfig.BurstObservatoryObject(
|
||||
subjectSelector = allBurstSelectors,
|
||||
pingConfig = burstTemplate.pingConfig
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure routing domain strategy and append enabled user rules.
|
||||
*/
|
||||
private fun configureRouting(
|
||||
configContext: CoreConfigContext,
|
||||
v2rayConfig: V2rayConfig,
|
||||
policyGroupBalancerTags: Map<String, String>
|
||||
) {
|
||||
|
||||
v2rayConfig.routing.domainStrategy =
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY)
|
||||
?: "AsIs"
|
||||
|
||||
val rulesetItems = MmkvManager.decodeRoutingRulesets()
|
||||
rulesetItems?.forEach { key ->
|
||||
appendRoutingUserRule(configContext, key, v2rayConfig, policyGroupBalancerTags)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one rule item and append it to routing rules.
|
||||
*/
|
||||
private fun appendRoutingUserRule(
|
||||
configContext: CoreConfigContext,
|
||||
item: RulesetItem?,
|
||||
v2rayConfig: V2rayConfig,
|
||||
policyGroupBalancerTags: Map<String, String>
|
||||
) {
|
||||
val context = configContext.context
|
||||
if (item == null || !item.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
val rule = JsonUtil.fromJson(JsonUtil.toJson(item), V2rayConfig.RoutingBean.RulesBean::class.java) ?: return
|
||||
|
||||
// Replace specific geoip rules with ext versions
|
||||
rule.ip?.let { ipList ->
|
||||
val updatedIpList = ArrayList<String>()
|
||||
ipList.forEach { ip ->
|
||||
when (ip) {
|
||||
AppConfig.GEOIP_CN -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:cn")
|
||||
AppConfig.GEOIP_PRIVATE -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:private")
|
||||
else -> updatedIpList.add(ip)
|
||||
}
|
||||
}
|
||||
rule.ip = updatedIpList
|
||||
}
|
||||
|
||||
if (SettingsManager.canUseProcessRouting()) {
|
||||
// Convert process package names to UIDs
|
||||
rule.process?.let { processList ->
|
||||
if (processList.isNotEmpty()) {
|
||||
val uids = PackageUidResolver.packageNamesToUids(context, processList)
|
||||
rule.process = uids.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rule.process = null
|
||||
}
|
||||
|
||||
val outboundTag = rule.outboundTag
|
||||
|
||||
// Route rules targeting a custom policy-group tag should hit its balancer.
|
||||
policyGroupBalancerTags[outboundTag]?.let { balancerTag ->
|
||||
rule.outboundTag = null
|
||||
rule.balancerTag = balancerTag
|
||||
}
|
||||
|
||||
// If the outbound tag is a custom one that failed to inject, fall back to proxy
|
||||
if (!outboundTag.isNullOrBlank()
|
||||
&& outboundTag !in policyGroupBalancerTags
|
||||
&& outboundTag !in AppConfig.BUILTIN_OUTBOUND_TAGS
|
||||
&& v2rayConfig.outbounds.none { it.tag == outboundTag }
|
||||
) {
|
||||
LogUtil.w(AppConfig.TAG, "Outbound tag '$outboundTag' not found, falling back to '${AppConfig.TAG_PROXY}'")
|
||||
rule.outboundTag = AppConfig.TAG_PROXY
|
||||
}
|
||||
|
||||
v2rayConfig.routing.rules.add(rule)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build balancer and probe settings from one policy-group strategy value.
|
||||
*/
|
||||
private fun buildBalancerStrategy(
|
||||
policyGroupType: String?,
|
||||
selector: List<String>,
|
||||
balancerTag: String = AppConfig.TAG_BALANCER,
|
||||
): BalancerStrategy {
|
||||
val probeUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL) ?: AppConfig.DELAY_TEST_URL
|
||||
val strategyType = BalancerStrategyType.from(policyGroupType)
|
||||
val balancer = V2rayConfig.RoutingBean.BalancerBean(
|
||||
tag = balancerTag,
|
||||
selector = selector,
|
||||
strategy = V2rayConfig.RoutingBean.StrategyObject(type = strategyType.policyGroupType)
|
||||
)
|
||||
val observatory = if (strategyType.requiresObservatory) {
|
||||
V2rayConfig.ObservatoryObject(
|
||||
subjectSelector = selector,
|
||||
probeUrl = probeUrl,
|
||||
probeInterval = "3m",
|
||||
enableConcurrency = true
|
||||
)
|
||||
} else null
|
||||
val burstObservatory = if (strategyType.requiresBurstObservatory) {
|
||||
V2rayConfig.BurstObservatoryObject(
|
||||
subjectSelector = selector,
|
||||
pingConfig = V2rayConfig.BurstObservatoryObject.PingConfigObject(
|
||||
destination = probeUrl,
|
||||
interval = "5m",
|
||||
sampling = 2,
|
||||
timeout = "30s"
|
||||
)
|
||||
)
|
||||
} else null
|
||||
return BalancerStrategy(balancer, observatory, burstObservatory)
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry balancer data plus optional probe settings for later merge.
|
||||
*/
|
||||
private data class BalancerStrategy(
|
||||
val balancer: V2rayConfig.RoutingBean.BalancerBean,
|
||||
val observatory: V2rayConfig.ObservatoryObject? = null,
|
||||
val burstObservatory: V2rayConfig.BurstObservatoryObject? = null,
|
||||
)
|
||||
|
||||
//endregion
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.content.Context
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import go.Seq
|
||||
import libv2ray.CoreCallbackHandler
|
||||
import libv2ray.CoreController
|
||||
import libv2ray.Libv2ray
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* V2Ray Native Library Manager
|
||||
*
|
||||
* Thread-safe singleton wrapper for Libv2ray native methods.
|
||||
* Provides initialization protection and unified API for V2Ray core operations.
|
||||
*/
|
||||
object CoreNativeManager {
|
||||
private val initialized = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* Initialize V2Ray core environment.
|
||||
* This method is thread-safe and ensures initialization happens only once.
|
||||
* Subsequent calls will be ignored silently.
|
||||
*
|
||||
*/
|
||||
fun initCoreEnv(context: Context?) {
|
||||
if (initialized.compareAndSet(false, true)) {
|
||||
try {
|
||||
Seq.setContext(context?.applicationContext)
|
||||
val assetPath = Utils.userAssetPath(context)
|
||||
val deviceId = Utils.getDeviceIdForXUDPBaseKey()
|
||||
Libv2ray.initCoreEnv(assetPath, deviceId)
|
||||
LogUtil.i(AppConfig.TAG, "V2Ray core environment initialized successfully")
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to initialize V2Ray core environment", e)
|
||||
initialized.set(false)
|
||||
throw e
|
||||
}
|
||||
} else {
|
||||
LogUtil.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping")
|
||||
}
|
||||
}
|
||||
|
||||
fun reconcileBrowserDialer(dialerAddr: String) {
|
||||
try {
|
||||
Libv2ray.reconcileBrowserDialer(dialerAddr)
|
||||
LogUtil.i(AppConfig.TAG, "Browser dialer reconciled successfully with address: $dialerAddr")
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to reconcile browser dialer with address: $dialerAddr", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get V2Ray core version.
|
||||
*
|
||||
* @return Version string of the V2Ray core
|
||||
*/
|
||||
fun getLibVersion(): String {
|
||||
return try {
|
||||
Libv2ray.checkVersionX()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to check V2Ray version", e)
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure outbound connection delay.
|
||||
*
|
||||
* @param config The configuration JSON string
|
||||
* @param testUrl The URL to test against
|
||||
* @return Delay in milliseconds, or -1 if test failed
|
||||
*/
|
||||
fun measureOutboundDelay(config: String, testUrl: String): Long {
|
||||
return try {
|
||||
Libv2ray.measureOutboundDelay(config, testUrl)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to measure outbound delay", e)
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new core controller instance.
|
||||
*
|
||||
* @param handler The callback handler for core events
|
||||
* @return A new CoreController instance
|
||||
*/
|
||||
fun newCoreController(handler: CoreCallbackHandler): CoreController {
|
||||
return try {
|
||||
Libv2ray.newCoreController(handler)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to create core controller", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.enums.NetworkType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
/**
|
||||
* Centralizes ProfileItem -> OutboundBean conversion.
|
||||
* Most protocol builders mirror the previous *Fmt.toOutbound behavior.
|
||||
*/
|
||||
object CoreOutboundBuilder {
|
||||
|
||||
/** Dispatches a profile to protocol-specific outbound builder. */
|
||||
fun convert(profileItem: ProfileItem): OutboundBean? {
|
||||
val outbound = when (profileItem.configType) {
|
||||
EConfigType.VMESS -> toOutboundVmess(profileItem)
|
||||
EConfigType.SHADOWSOCKS -> toOutboundShadowsocks(profileItem)
|
||||
EConfigType.SOCKS -> toOutboundSocks(profileItem)
|
||||
EConfigType.VLESS -> toOutboundVless(profileItem)
|
||||
EConfigType.TROJAN -> toOutboundTrojan(profileItem)
|
||||
EConfigType.WIREGUARD -> toOutboundWireguard(profileItem)
|
||||
EConfigType.HYSTERIA2 -> toOutboundHysteria2(profileItem)
|
||||
EConfigType.HTTP -> toOutboundHttp(profileItem)
|
||||
else -> null
|
||||
}
|
||||
|
||||
outbound ?: return null
|
||||
val ret = updateOutboundWithGlobalSettings(outbound)
|
||||
if (!ret) return null
|
||||
return outbound
|
||||
}
|
||||
|
||||
/** Applies global outbound options (mux, protocol-specific tweaks, etc.). */
|
||||
private fun updateOutboundWithGlobalSettings(outbound: OutboundBean): Boolean {
|
||||
try {
|
||||
var muxEnabled = MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false)
|
||||
val protocol = outbound.protocol
|
||||
if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.SOCKS.name, true)
|
||||
|| protocol.equals(EConfigType.HTTP.name, true)
|
||||
|| protocol.equals(EConfigType.TROJAN.name, true)
|
||||
|| protocol.equals(EConfigType.WIREGUARD.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
|
||||
|| protocol.equals(EConfigType.HYSTERIA.name, true)
|
||||
) {
|
||||
muxEnabled = false
|
||||
} else if (outbound.streamSettings?.network == NetworkType.XHTTP.type) {
|
||||
muxEnabled = false
|
||||
}
|
||||
|
||||
if (muxEnabled) {
|
||||
outbound.mux?.enabled = true
|
||||
outbound.mux?.concurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8").orEmpty().toInt()
|
||||
outbound.mux?.xudpConcurrency = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "16").orEmpty().toInt()
|
||||
outbound.mux?.xudpProxyUDP443 = MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
|
||||
if (protocol.equals(EConfigType.VLESS.name, true) && outbound.settings?.vnext?.first()?.users?.first()?.flow?.isNotEmpty() == true) {
|
||||
outbound.mux?.concurrency = -1
|
||||
}
|
||||
} else {
|
||||
outbound.mux?.enabled = false
|
||||
outbound.mux?.concurrency = -1
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to update outbound with global settings", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Creates an initial outbound template for a protocol type. */
|
||||
fun createInitOutbound(configType: EConfigType): OutboundBean? {
|
||||
return when (configType) {
|
||||
EConfigType.VMESS,
|
||||
EConfigType.VLESS ->
|
||||
return OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(
|
||||
vnext = listOf(
|
||||
OutboundBean.OutSettingsBean.VnextBean(
|
||||
users = listOf(OutboundBean.OutSettingsBean.VnextBean.UsersBean())
|
||||
)
|
||||
)
|
||||
),
|
||||
streamSettings = OutboundBean.StreamSettingsBean()
|
||||
)
|
||||
|
||||
EConfigType.SHADOWSOCKS,
|
||||
EConfigType.SOCKS,
|
||||
EConfigType.HTTP,
|
||||
EConfigType.TROJAN ->
|
||||
return OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(
|
||||
servers = listOf(OutboundBean.OutSettingsBean.ServersBean())
|
||||
),
|
||||
streamSettings = OutboundBean.StreamSettingsBean()
|
||||
)
|
||||
|
||||
EConfigType.WIREGUARD ->
|
||||
return OutboundBean(
|
||||
protocol = configType.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(
|
||||
secretKey = "",
|
||||
peers = listOf(OutboundBean.OutSettingsBean.WireGuardBean())
|
||||
)
|
||||
)
|
||||
|
||||
EConfigType.HYSTERIA,
|
||||
EConfigType.HYSTERIA2 ->
|
||||
return OutboundBean(
|
||||
protocol = EConfigType.HYSTERIA.name.lowercase(),
|
||||
settings = OutboundBean.OutSettingsBean(
|
||||
servers = null
|
||||
),
|
||||
streamSettings = OutboundBean.StreamSettingsBean()
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-protocol builders — implementations are identical to each *Fmt.toOutbound ──
|
||||
|
||||
private fun toOutboundVmess(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.VMESS)
|
||||
|
||||
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||
vnext.address = getServerAddress(profileItem)
|
||||
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
||||
vnext.users[0].id = profileItem.password.orEmpty()
|
||||
vnext.users[0].security = profileItem.method
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundVless(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.VLESS)
|
||||
|
||||
outboundBean?.settings?.vnext?.first()?.let { vnext ->
|
||||
vnext.address = getServerAddress(profileItem)
|
||||
vnext.port = profileItem.serverPort.orEmpty().toInt()
|
||||
vnext.users[0].id = profileItem.password.orEmpty()
|
||||
vnext.users[0].encryption = profileItem.method
|
||||
vnext.users[0].flow = profileItem.flow
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundShadowsocks(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.SHADOWSOCKS)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.password = profileItem.password
|
||||
server.method = profileItem.method
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundTrojan(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.TROJAN)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.password = profileItem.password
|
||||
server.flow = profileItem.flow
|
||||
}
|
||||
|
||||
val sni = outboundBean?.streamSettings?.let {
|
||||
populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean?.streamSettings?.let {
|
||||
populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundSocks(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.SOCKS)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
if (profileItem.username.isNotNullEmpty()) {
|
||||
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
|
||||
socksUsersBean.user = profileItem.username.orEmpty()
|
||||
socksUsersBean.pass = profileItem.password.orEmpty()
|
||||
server.users = listOf(socksUsersBean)
|
||||
}
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundHttp(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.HTTP)
|
||||
|
||||
outboundBean?.settings?.servers?.first()?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
if (profileItem.username.isNotNullEmpty()) {
|
||||
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
|
||||
socksUsersBean.user = profileItem.username.orEmpty()
|
||||
socksUsersBean.pass = profileItem.password.orEmpty()
|
||||
server.users = listOf(socksUsersBean)
|
||||
}
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundWireguard(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.WIREGUARD)
|
||||
|
||||
val rawAddresses = profileItem.localAddress
|
||||
?.split(",")
|
||||
?.map { it.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.ifEmpty { null }
|
||||
?: listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4)
|
||||
|
||||
val addresses = if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
|
||||
rawAddresses
|
||||
} else {
|
||||
val ipv4Addresses = rawAddresses.filter { !it.contains(":") }
|
||||
ipv4Addresses.ifEmpty { listOf(AppConfig.WIREGUARD_LOCAL_ADDRESS_V4) }
|
||||
}
|
||||
|
||||
outboundBean?.settings?.let { wireguard ->
|
||||
wireguard.secretKey = profileItem.secretKey
|
||||
wireguard.address = addresses
|
||||
wireguard.peers?.firstOrNull()?.let { peer ->
|
||||
peer.publicKey = profileItem.publicKey.orEmpty()
|
||||
peer.preSharedKey = profileItem.preSharedKey?.nullIfBlank()
|
||||
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
|
||||
}
|
||||
wireguard.mtu = profileItem.mtu
|
||||
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun toOutboundHysteria2(profileItem: ProfileItem): OutboundBean? {
|
||||
val outboundBean = createInitOutbound(EConfigType.HYSTERIA2) ?: return null
|
||||
profileItem.network = NetworkType.HYSTERIA.type
|
||||
profileItem.alpn = "h3"
|
||||
|
||||
outboundBean.settings?.let { server ->
|
||||
server.address = getServerAddress(profileItem)
|
||||
server.port = profileItem.serverPort.orEmpty().toInt()
|
||||
server.version = 2
|
||||
}
|
||||
|
||||
val sni = outboundBean.streamSettings?.let {
|
||||
populateTransportSettings(it, profileItem)
|
||||
}
|
||||
|
||||
outboundBean.streamSettings?.let {
|
||||
populateTlsSettings(it, profileItem, sni)
|
||||
}
|
||||
|
||||
return outboundBean
|
||||
}
|
||||
|
||||
private fun createTcpHttpRequest(
|
||||
host: String?,
|
||||
path: String?
|
||||
): OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean {
|
||||
val requestString =
|
||||
"""{"version":"1.1","method":"GET","headers":{"User-Agent":["Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.122 Mobile Safari/537.36"],"Accept-Encoding":["gzip, deflate"],"Connection":["keep-alive"],"Pragma":"no-cache"}}"""
|
||||
val request = JsonUtil.fromJson(
|
||||
requestString,
|
||||
OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean::class.java
|
||||
) ?: OutboundBean.StreamSettingsBean.TcpSettingsBean.HeaderBean.RequestBean()
|
||||
|
||||
val parsedHost = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
request.headers.Host = parsedHost.ifEmpty { null }
|
||||
request.path = path.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }.ifEmpty { listOf("/") }
|
||||
return request
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures transport settings for an outbound connection.
|
||||
*
|
||||
* Sets up protocol-specific transport options based on the profile settings.
|
||||
*
|
||||
* @param streamSettings The stream settings to configure
|
||||
* @param profileItem The profile containing transport configuration
|
||||
* @return The Server Name Indication (SNI) value to use, or null if not applicable
|
||||
*/
|
||||
fun populateTransportSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem): String? {
|
||||
val transport = profileItem.network.orEmpty()
|
||||
val headerType = profileItem.headerType
|
||||
val host = profileItem.host
|
||||
val path = profileItem.path
|
||||
val seed = profileItem.seed
|
||||
// val quicSecurity = profileItem.quicSecurity
|
||||
// val key = profileItem.quicKey
|
||||
val mode = profileItem.mode
|
||||
val serviceName = profileItem.serviceName
|
||||
val authority = profileItem.authority
|
||||
val xhttpMode = profileItem.xhttpMode
|
||||
val xhttpExtra = profileItem.xhttpExtra
|
||||
val finalMask = profileItem.finalMask
|
||||
var sni: String? = null
|
||||
streamSettings.network = transport.ifEmpty { NetworkType.TCP.type }
|
||||
when (streamSettings.network) {
|
||||
NetworkType.TCP.type -> {
|
||||
val tcpSetting = OutboundBean.StreamSettingsBean.TcpSettingsBean()
|
||||
if (headerType == AppConfig.HEADER_TYPE_HTTP) {
|
||||
tcpSetting.header.type = AppConfig.HEADER_TYPE_HTTP
|
||||
val requestObj = createTcpHttpRequest(host, path)
|
||||
tcpSetting.header.request = requestObj
|
||||
sni = requestObj.headers.Host?.getOrNull(0)
|
||||
} else {
|
||||
tcpSetting.header.type = "none"
|
||||
sni = host
|
||||
}
|
||||
streamSettings.tcpSettings = tcpSetting
|
||||
}
|
||||
|
||||
NetworkType.KCP.type -> {
|
||||
val kcpSetting = OutboundBean.StreamSettingsBean.KcpSettingsBean()
|
||||
profileItem.kcpMtu?.let { kcpSetting.mtu = it }
|
||||
profileItem.kcpTti?.let { kcpSetting.tti = it }
|
||||
streamSettings.kcpSettings = kcpSetting
|
||||
val udpMaskList = mutableListOf<OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean>()
|
||||
if (!headerType.isNullOrEmpty() && headerType != "none") {
|
||||
val kcpHeaderType = when {
|
||||
headerType == "wechat-video" -> "header-wechat"
|
||||
else -> "header-$headerType"
|
||||
}
|
||||
udpMaskList.add(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = kcpHeaderType,
|
||||
settings = if (headerType == "dns" && !host.isNullOrEmpty()) {
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
domain = host
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
if (seed.isNullOrEmpty()) {
|
||||
udpMaskList.add(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "mkcp-original"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
udpMaskList.add(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "mkcp-aes128gcm",
|
||||
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
password = seed
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
streamSettings.finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
|
||||
udp = udpMaskList.toList()
|
||||
)
|
||||
}
|
||||
|
||||
NetworkType.WS.type -> {
|
||||
val wssetting = OutboundBean.StreamSettingsBean.WsSettingsBean()
|
||||
wssetting.host = host.orEmpty()
|
||||
sni = host
|
||||
wssetting.path = path ?: "/"
|
||||
streamSettings.wsSettings = wssetting
|
||||
}
|
||||
|
||||
NetworkType.HTTP_UPGRADE.type -> {
|
||||
val httpupgradeSetting = OutboundBean.StreamSettingsBean.HttpupgradeSettingsBean()
|
||||
httpupgradeSetting.host = host.orEmpty()
|
||||
sni = host
|
||||
httpupgradeSetting.path = path ?: "/"
|
||||
streamSettings.httpupgradeSettings = httpupgradeSetting
|
||||
}
|
||||
|
||||
NetworkType.XHTTP.type -> {
|
||||
val xhttpSetting = OutboundBean.StreamSettingsBean.XhttpSettingsBean()
|
||||
xhttpSetting.host = host.orEmpty()
|
||||
sni = host
|
||||
xhttpSetting.path = path ?: "/"
|
||||
xhttpSetting.mode = xhttpMode
|
||||
xhttpSetting.extra = JsonUtil.parseString(xhttpExtra)
|
||||
streamSettings.xhttpSettings = xhttpSetting
|
||||
}
|
||||
|
||||
NetworkType.H2.type, NetworkType.HTTP.type -> {
|
||||
streamSettings.network = NetworkType.H2.type
|
||||
val h2Setting = OutboundBean.StreamSettingsBean.HttpSettingsBean()
|
||||
h2Setting.host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
sni = h2Setting.host.getOrNull(0)
|
||||
h2Setting.path = path ?: "/"
|
||||
streamSettings.httpSettings = h2Setting
|
||||
}
|
||||
|
||||
// "quic" -> {
|
||||
// val quicsetting = QuicSettingBean()
|
||||
// quicsetting.security = quicSecurity ?: "none"
|
||||
// quicsetting.key = key.orEmpty()
|
||||
// quicsetting.header.type = headerType ?: "none"
|
||||
// quicSettings = quicsetting
|
||||
// }
|
||||
|
||||
NetworkType.GRPC.type -> {
|
||||
val grpcSetting = OutboundBean.StreamSettingsBean.GrpcSettingsBean()
|
||||
grpcSetting.multiMode = mode == "multi"
|
||||
grpcSetting.serviceName = serviceName.orEmpty()
|
||||
grpcSetting.authority = authority.orEmpty()
|
||||
grpcSetting.idle_timeout = 60
|
||||
grpcSetting.health_check_timeout = 20
|
||||
sni = authority
|
||||
streamSettings.grpcSettings = grpcSetting
|
||||
}
|
||||
|
||||
NetworkType.HYSTERIA.type -> {
|
||||
val hysteriaSetting = OutboundBean.StreamSettingsBean.HysteriaSettingsBean(
|
||||
version = 2,
|
||||
auth = profileItem.password.orEmpty(),
|
||||
)
|
||||
val quicParams = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean(
|
||||
brutalUp = profileItem.bandwidthUp?.nullIfBlank(),
|
||||
brutalDown = profileItem.bandwidthDown?.nullIfBlank(),
|
||||
)
|
||||
quicParams.congestion = if (quicParams.brutalUp != null || quicParams.brutalDown != null) "brutal" else null
|
||||
if (profileItem.portHopping.isNotNullEmpty()) {
|
||||
val rawInterval = profileItem.portHoppingInterval?.trim().nullIfBlank()
|
||||
val interval = if (rawInterval == null) {
|
||||
"30"
|
||||
} else {
|
||||
val singleValue = rawInterval.toIntOrNull()
|
||||
if (singleValue != null) {
|
||||
if (singleValue < 5) {
|
||||
"30"
|
||||
} else {
|
||||
rawInterval
|
||||
}
|
||||
} else {
|
||||
val parts = rawInterval.split('-')
|
||||
if (parts.size == 2) {
|
||||
val start = parts[0].trim().toIntOrNull()
|
||||
val end = parts[1].trim().toIntOrNull()
|
||||
if (start != null && end != null) {
|
||||
val minStart = maxOf(5, start)
|
||||
val minEnd = maxOf(minStart, end)
|
||||
"$minStart-$minEnd"
|
||||
} else {
|
||||
"30"
|
||||
}
|
||||
} else {
|
||||
"30"
|
||||
}
|
||||
}
|
||||
}
|
||||
quicParams.udpHop = OutboundBean.StreamSettingsBean.FinalMaskBean.QuicParamsBean.UdpHopBean(
|
||||
ports = profileItem.portHopping,
|
||||
interval = interval
|
||||
)
|
||||
}
|
||||
val finalmask = OutboundBean.StreamSettingsBean.FinalMaskBean(
|
||||
quicParams = quicParams
|
||||
)
|
||||
if (profileItem.obfsPassword.isNotNullEmpty()) {
|
||||
finalmask.udp = listOf(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "salamander",
|
||||
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
password = profileItem.obfsPassword.orEmpty()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
streamSettings.hysteriaSettings = hysteriaSetting
|
||||
streamSettings.finalmask = finalmask
|
||||
}
|
||||
}
|
||||
finalMask?.let {
|
||||
val parsedFinalMask = JsonUtil.parseString(finalMask)
|
||||
if (parsedFinalMask != null) {
|
||||
streamSettings.finalmask = parsedFinalMask
|
||||
} else {
|
||||
LogUtil.w("V2rayConfigManager", "Invalid finalMask JSON, keeping previously generated finalmask")
|
||||
}
|
||||
}
|
||||
return sni
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures TLS or REALITY security settings for an outbound connection.
|
||||
*
|
||||
* Sets up security-related parameters like certificates, fingerprints, and SNI.
|
||||
*
|
||||
* @param streamSettings The stream settings to configure
|
||||
* @param profileItem The profile containing security configuration
|
||||
* @param sniExt An external SNI value to use if the profile doesn't specify one
|
||||
*/
|
||||
fun populateTlsSettings(streamSettings: OutboundBean.StreamSettingsBean, profileItem: ProfileItem, sniExt: String?) {
|
||||
val streamSecurity = profileItem.security.orEmpty()
|
||||
val allowInsecure = profileItem.insecure == true && profileItem.pinnedCA256.isNullOrEmpty()
|
||||
val sni = if (profileItem.sni.isNullOrEmpty()) {
|
||||
when {
|
||||
sniExt.isNotNullEmpty() && Utils.isDomainName(sniExt) -> sniExt
|
||||
profileItem.server.isNotNullEmpty() && Utils.isDomainName(profileItem.server) -> profileItem.server
|
||||
else -> sniExt
|
||||
}
|
||||
} else {
|
||||
profileItem.sni
|
||||
}
|
||||
|
||||
streamSettings.security = streamSecurity.nullIfBlank()
|
||||
if (streamSettings.security == null) return
|
||||
val tlsSetting = OutboundBean.StreamSettingsBean.TlsSettingsBean(
|
||||
allowInsecure = allowInsecure,
|
||||
serverName = sni.nullIfBlank(),
|
||||
fingerprint = profileItem.fingerPrint.nullIfBlank(),
|
||||
alpn = profileItem.alpn?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }.takeIf { !it.isNullOrEmpty() },
|
||||
echConfigList = profileItem.echConfigList.nullIfBlank(),
|
||||
pinnedPeerCertSha256 = profileItem.pinnedCA256.nullIfBlank(),
|
||||
publicKey = profileItem.publicKey.nullIfBlank(),
|
||||
shortId = profileItem.shortId.nullIfBlank(),
|
||||
spiderX = profileItem.spiderX.nullIfBlank(),
|
||||
mldsa65Verify = profileItem.mldsa65Verify.nullIfBlank(),
|
||||
)
|
||||
if (streamSettings.security == AppConfig.TLS) {
|
||||
streamSettings.tlsSettings = tlsSetting
|
||||
streamSettings.realitySettings = null
|
||||
} else if (streamSettings.security == AppConfig.REALITY) {
|
||||
streamSettings.tlsSettings = null
|
||||
streamSettings.realitySettings = tlsSetting
|
||||
}
|
||||
|
||||
if (profileItem.finalMask.isNullOrEmpty()) {
|
||||
updateOutboundFragment(streamSettings)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the outbound with fragment settings for traffic optimization.
|
||||
*
|
||||
* Configures packet fragmentation for TLS and REALITY protocols if enabled.
|
||||
*
|
||||
* @param streamSettings The streamSettings object to be modified
|
||||
* @return true if fragment configuration was successful, false otherwise
|
||||
*/
|
||||
private fun updateOutboundFragment(streamSettings: OutboundBean.StreamSettingsBean): Boolean {
|
||||
try {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false) == false) {
|
||||
return true
|
||||
}
|
||||
if (streamSettings.security != AppConfig.TLS
|
||||
&& streamSettings.security != AppConfig.REALITY
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (streamSettings.sockopt?.dialerProxy.isNotNullEmpty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
var packets =
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_PACKETS) ?: "tlshello"
|
||||
if (streamSettings.security == AppConfig.REALITY
|
||||
&& packets == "tlshello"
|
||||
) {
|
||||
packets = "1-3"
|
||||
} else if (streamSettings.security == AppConfig.TLS
|
||||
&& packets != "tlshello"
|
||||
) {
|
||||
packets = "tlshello"
|
||||
}
|
||||
|
||||
val fragmentMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "fragment",
|
||||
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
packets = packets,
|
||||
length = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_LENGTH)
|
||||
?: "50-100",
|
||||
delay = MmkvManager.decodeSettingsString(AppConfig.PREF_FRAGMENT_INTERVAL)
|
||||
?: "10-20"
|
||||
)
|
||||
)
|
||||
val noiseMask = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean(
|
||||
type = "noise",
|
||||
settings = OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean(
|
||||
noise = listOf(
|
||||
OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean.MaskSettingsBean.NoiseMaskBean(
|
||||
rand = "10-20",
|
||||
delay = "10-16",
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val finalMaskObj = streamSettings.finalmask?.let { existingFinalMask ->
|
||||
JsonUtil.parseString(JsonUtil.toJson(existingFinalMask))
|
||||
} ?: JsonObject()
|
||||
|
||||
// finalmask.tcp / finalmask.udp are arrays; prepend mask at index 0.
|
||||
fun prependMask(scope: String, mask: OutboundBean.StreamSettingsBean.FinalMaskBean.MaskBean) {
|
||||
val current = finalMaskObj.get(scope)
|
||||
if (current != null && current.isJsonArray && current.asJsonArray.size() > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
val newArray = JsonArray()
|
||||
newArray.add(JsonUtil.parseString(JsonUtil.toJson(mask)))
|
||||
|
||||
if (current != null && current.isJsonArray) {
|
||||
current.asJsonArray.forEach { newArray.add(it) }
|
||||
}
|
||||
finalMaskObj.add(scope, newArray)
|
||||
}
|
||||
|
||||
prependMask("tcp", fragmentMask)
|
||||
prependMask("udp", noiseMask)
|
||||
streamSettings.finalmask = finalMaskObj
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to update outbound fragment", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun getServerAddress(profileItem: ProfileItem): String {
|
||||
if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
|
||||
return profileItem.server.orEmpty()
|
||||
}
|
||||
|
||||
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
|
||||
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
|
||||
return domain
|
||||
}
|
||||
//Resolve and replace domain
|
||||
val resolvedIps = HttpUtil.resolveHostToIP(domain, MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
|
||||
if (resolvedIps.isNullOrEmpty()) {
|
||||
return domain
|
||||
}
|
||||
return resolvedIps.first()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.system.OsConstants
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.ServiceControl
|
||||
import com.v2ray.ang.dto.OutboundTrafficStat
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.NotificationManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.SpeedtestManager
|
||||
import com.v2ray.ang.service.CoreProxyOnlyService
|
||||
import com.v2ray.ang.service.CoreVpnService
|
||||
import com.v2ray.ang.service.DialerNativeService
|
||||
import com.v2ray.ang.service.DialerWebviewService
|
||||
import com.v2ray.ang.service.IDialerService
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import libv2ray.CoreCallbackHandler
|
||||
import libv2ray.CoreController
|
||||
import libv2ray.ProcessFinder
|
||||
import java.lang.ref.SoftReference
|
||||
import java.net.InetSocketAddress
|
||||
|
||||
object CoreServiceManager {
|
||||
|
||||
private val coreController: CoreController = CoreNativeManager.newCoreController(CoreCallback())
|
||||
private val mMsgReceive = ReceiveMessageHandler()
|
||||
private var currentConfig: ProfileItem? = null
|
||||
private var processFinder: XrayProcessFinder? = null
|
||||
private var browserDialer: IDialerService? = null
|
||||
|
||||
var serviceControl: SoftReference<ServiceControl>? = null
|
||||
set(value) {
|
||||
field = value
|
||||
val service = value?.get()?.getService()
|
||||
CoreNativeManager.initCoreEnv(service)
|
||||
if (service != null && processFinder == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
processFinder = XrayProcessFinder(service)
|
||||
coreController.registerProcessFinder(processFinder)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the V2Ray service from a toggle action.
|
||||
* @param context The context from which the service is started.
|
||||
* @return True if the service was started successfully, false otherwise.
|
||||
*/
|
||||
fun startVServiceFromToggle(context: Context): Boolean {
|
||||
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
|
||||
context.toast(R.string.app_tile_first_use)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
startContextService(context)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: ${e.message}", e)
|
||||
context.toast(e.message ?: e.javaClass.simpleName)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the V2Ray service.
|
||||
* @param context The context from which the service is started.
|
||||
* @param guid The GUID of the server configuration to use (optional).
|
||||
*/
|
||||
fun startVService(context: Context, guid: String? = null) {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
|
||||
|
||||
if (guid != null) {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
}
|
||||
|
||||
try {
|
||||
startContextService(context)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: ${e.message}", e)
|
||||
context.toast(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the V2Ray service.
|
||||
* @param context The context from which the service is stopped.
|
||||
*/
|
||||
fun stopVService(context: Context) {
|
||||
//context.toast(R.string.toast_services_stop)
|
||||
MessageUtil.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the V2Ray service is running.
|
||||
* @return True if the service is running, false otherwise.
|
||||
*/
|
||||
fun isRunning() = coreController.isRunning
|
||||
|
||||
/**
|
||||
* Gets the name of the currently running server.
|
||||
* @return The name of the running server.
|
||||
*/
|
||||
fun getRunningServerName() = currentConfig?.remarks.orEmpty()
|
||||
|
||||
/**
|
||||
* Starts the context service for V2Ray.
|
||||
* Chooses between VPN service or Proxy-only service based on user settings.
|
||||
* @param context The context from which the service is started.
|
||||
* @throws IllegalStateException if the core is already running, no server is selected,
|
||||
* server config cannot be decoded, or server configuration is invalid.
|
||||
* @throws Exception if the foreground service fails to start.
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
private fun startContextService(context: Context) {
|
||||
if (coreController.isRunning) {
|
||||
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||
return
|
||||
}
|
||||
|
||||
val guid = MmkvManager.getSelectServer()
|
||||
?: run {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
|
||||
error(context.getString(R.string.app_tile_first_use))
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(guid)
|
||||
?: run {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
|
||||
error(context.getString(R.string.toast_config_file_invalid))
|
||||
}
|
||||
|
||||
if (!config.configType.isComplexType()
|
||||
&& !Utils.isValidUrl(config.server)
|
||||
&& !Utils.isPureIpAddress(config.server.orEmpty())
|
||||
) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
|
||||
error(context.getString(R.string.toast_config_file_invalid))
|
||||
}
|
||||
|
||||
// refresh socks port when enabled dynamic socks port
|
||||
SettingsManager.refreshRuntimeSocksPort()
|
||||
|
||||
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
|
||||
// if (!result.status) error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) {
|
||||
context.toast(R.string.toast_warning_pref_proxysharing_short)
|
||||
} else {
|
||||
context.toast(R.string.toast_services_start)
|
||||
}
|
||||
|
||||
val isVpnMode = SettingsManager.isVpnMode()
|
||||
val intent = if (isVpnMode) {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
|
||||
Intent(context.applicationContext, CoreVpnService::class.java)
|
||||
} else {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
|
||||
Intent(context.applicationContext, CoreProxyOnlyService::class.java)
|
||||
}
|
||||
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} catch (e: SecurityException) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Missing permission to start foreground service", e)
|
||||
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
|
||||
} catch (e: RuntimeException) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
e.javaClass.name == "android.app.ForegroundServiceStartNotAllowedException"
|
||||
) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Foreground service start not allowed", e)
|
||||
throw IllegalStateException(e.message ?: e.javaClass.simpleName, e)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refer to the official documentation for [registerReceiver](https://developer.android.com/reference/androidx/core/content/ContextCompat#registerReceiver(android.content.Context,android.content.BroadcastReceiver,android.content.IntentFilter,int):
|
||||
* `registerReceiver(Context, BroadcastReceiver, IntentFilter, int)`.
|
||||
* Starts the V2Ray core service.
|
||||
*/
|
||||
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
|
||||
if (coreController.isRunning) {
|
||||
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||
return false
|
||||
}
|
||||
|
||||
val service = getService()
|
||||
if (service == null) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Service is null")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
doStartCoreLoop(service, vpnInterface)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
val message = e.message?.takeUnless { it.isBlank() } ?: e.javaClass.simpleName
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: $message", e)
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, message)
|
||||
NotificationManager.cancelNotification()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun doStartCoreLoop(service: Service, vpnInterface: ParcelFileDescriptor?) {
|
||||
val guid = MmkvManager.getSelectServer() ?: error("No server selected")
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: error("Failed to decode server config")
|
||||
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting core loop for ${config.remarks}")
|
||||
val result = CoreConfigManager.getV2rayConfig(service, guid)
|
||||
LogUtil.d(AppConfig.TAG, result.content)
|
||||
if (!result.status) {
|
||||
error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
|
||||
}
|
||||
|
||||
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||
mFilter.addAction(Intent.ACTION_SCREEN_ON)
|
||||
mFilter.addAction(Intent.ACTION_SCREEN_OFF)
|
||||
mFilter.addAction(Intent.ACTION_USER_PRESENT)
|
||||
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
|
||||
|
||||
currentConfig = config
|
||||
var tunFd = vpnInterface?.fd ?: 0
|
||||
val dialerAddr = if (currentConfig?.browserDialerMode.isNullOrEmpty()) {
|
||||
""
|
||||
} else {
|
||||
"127.0.0.1:${Utils.findRandomFreePort()}"
|
||||
}
|
||||
if (SettingsManager.isUsingHevTun()) {
|
||||
tunFd = 0
|
||||
}
|
||||
|
||||
NotificationManager.showNotification(currentConfig)
|
||||
CoreNativeManager.reconcileBrowserDialer(dialerAddr)
|
||||
coreController.startLoop(result.content, tunFd)
|
||||
|
||||
if (!coreController.isRunning) {
|
||||
error("Core failed to start")
|
||||
}
|
||||
|
||||
if (browserDialer != null) {
|
||||
browserDialer!!.stop()
|
||||
browserDialer = null
|
||||
}
|
||||
if (config.browserDialerMode == "OkHttp") {
|
||||
browserDialer = DialerNativeService()
|
||||
browserDialer!!.start(service, dialerAddr)
|
||||
} else if (config.browserDialerMode == "WebView") {
|
||||
browserDialer = DialerWebviewService()
|
||||
browserDialer!!.start(service, dialerAddr)
|
||||
}
|
||||
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
|
||||
NotificationManager.startSpeedNotification()
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the V2Ray core service.
|
||||
* Unregisters broadcast receivers, stops notifications, and shuts down plugins.
|
||||
* @return True if the core was stopped successfully, false otherwise.
|
||||
*/
|
||||
fun stopCoreLoop(): Boolean {
|
||||
val service = getService() ?: return false
|
||||
|
||||
if (coreController.isRunning) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
coreController.stopLoop()
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop V2Ray loop", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close existing browser dialer
|
||||
CoreNativeManager.reconcileBrowserDialer("")
|
||||
if (browserDialer != null) {
|
||||
browserDialer!!.stop()
|
||||
browserDialer = null
|
||||
}
|
||||
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
|
||||
NotificationManager.cancelNotification()
|
||||
|
||||
try {
|
||||
service.unregisterReceiver(mMsgReceive)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to unregister receiver", e)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries and resets all outbound traffic counters in one core call.
|
||||
* Go side format: tag,direction,value;tag,direction,value;
|
||||
*/
|
||||
fun queryAllOutboundTrafficStats(): List<OutboundTrafficStat> {
|
||||
val payload = coreController.queryAllOutboundTrafficStats()
|
||||
|
||||
val result = ArrayList<OutboundTrafficStat>()
|
||||
|
||||
payload.split(';').forEach { entry ->
|
||||
if (entry.isBlank()) return@forEach
|
||||
|
||||
val parts = entry.split(',', limit = 3)
|
||||
if (parts.size != 3) return@forEach
|
||||
|
||||
val value = parts[2].toLongOrNull() ?: return@forEach
|
||||
|
||||
result.add(
|
||||
OutboundTrafficStat(
|
||||
tag = parts[0],
|
||||
direction = parts[1],
|
||||
value = value,
|
||||
)
|
||||
)
|
||||
}
|
||||
// LogUtil.d(AppConfig.TAG, "Queried outbound traffic stats: $result")
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the connection delay for the current V2Ray configuration.
|
||||
* Tests with primary URL first, then falls back to alternative URL if needed.
|
||||
* Also fetches remote IP information if the delay test was successful.
|
||||
*/
|
||||
private fun measureV2rayDelay() {
|
||||
if (coreController.isRunning == false) {
|
||||
return
|
||||
}
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val service = getService() ?: return@launch
|
||||
var time = -1L
|
||||
var errorStr = ""
|
||||
|
||||
try {
|
||||
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
||||
}
|
||||
if (time == -1L) {
|
||||
try {
|
||||
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
||||
}
|
||||
}
|
||||
|
||||
val result = if (time >= 0) {
|
||||
service.getString(R.string.connection_test_available, time)
|
||||
} else {
|
||||
service.getString(R.string.connection_test_error, errorStr)
|
||||
}
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, result)
|
||||
|
||||
// Only fetch IP info if the delay test was successful
|
||||
if (time >= 0) {
|
||||
SpeedtestManager.getRemoteIPInfo()?.let { ip ->
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result\n$ip")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current service instance.
|
||||
* @return The current service instance, or null if not available.
|
||||
*/
|
||||
private fun getService(): Service? {
|
||||
return serviceControl?.get()?.getService()
|
||||
}
|
||||
|
||||
/**
|
||||
* Core callback handler implementation for handling V2Ray core events.
|
||||
* Handles startup, shutdown, socket protection, and status emission.
|
||||
*/
|
||||
private class CoreCallback : CoreCallbackHandler {
|
||||
/**
|
||||
* Called when V2Ray core starts up.
|
||||
* @return 0 for success, any other value for failure.
|
||||
*/
|
||||
override fun startup(): Long {
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when V2Ray core shuts down.
|
||||
* @return 0 for success, any other value for failure.
|
||||
*/
|
||||
override fun shutdown(): Long {
|
||||
val serviceControl = serviceControl?.get() ?: return -1
|
||||
return try {
|
||||
serviceControl.stopService()
|
||||
0
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when V2Ray core emits status information.
|
||||
* @param l Status code.
|
||||
* @param s Status message.
|
||||
* @return Always returns 0.
|
||||
*/
|
||||
override fun onEmitStatus(l: Long, s: String?): Long {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process finder implementation for Xray core.
|
||||
* Uses ConnectivityManager to find the owning UID of a connection based on network parameters.
|
||||
*/
|
||||
private class XrayProcessFinder(context: Context) : ProcessFinder {
|
||||
private val cm: ConnectivityManager? = context.getSystemService(ConnectivityManager::class.java)
|
||||
|
||||
override fun findProcessByConnection(network: String, srcIP: String, srcPort: Long, destIP: String, destPort: Long): Long {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return -1L
|
||||
if (cm == null) return -1L
|
||||
val proto = when (network) {
|
||||
"tcp" -> OsConstants.IPPROTO_TCP
|
||||
"udp" -> OsConstants.IPPROTO_UDP
|
||||
else -> return -1L
|
||||
}
|
||||
|
||||
if (destIP.isBlank() || destPort == 0L) {
|
||||
LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to :$destPort, (no dest)")
|
||||
return -1L
|
||||
}
|
||||
|
||||
return try {
|
||||
val uid = cm.getConnectionOwnerUid(
|
||||
proto,
|
||||
InetSocketAddress(srcIP, srcPort.toInt()),
|
||||
InetSocketAddress(destIP, destPort.toInt())
|
||||
).toLong()
|
||||
LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to $destIP:$destPort, uid=$uid")
|
||||
//LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to $destIP:$destPort, uid=$uid,${PackageUidResolver.uidToPackageName(uid.toString())}")
|
||||
|
||||
uid
|
||||
} catch (_: Exception) {
|
||||
-1L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast receiver for handling messages sent to the service.
|
||||
* Handles registration, service control, and screen events.
|
||||
*/
|
||||
private class ReceiveMessageHandler : BroadcastReceiver() {
|
||||
/**
|
||||
* Handles received broadcast messages.
|
||||
* Processes service control messages and screen state changes.
|
||||
* @param ctx The context in which the receiver is running.
|
||||
* @param intent The intent being received.
|
||||
*/
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
val serviceControl = serviceControl?.get() ?: return
|
||||
when (intent?.getIntExtra("key", 0)) {
|
||||
AppConfig.MSG_REGISTER_CLIENT -> {
|
||||
if (coreController.isRunning) {
|
||||
MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_RUNNING, "")
|
||||
} else {
|
||||
MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
|
||||
}
|
||||
}
|
||||
|
||||
AppConfig.MSG_UNREGISTER_CLIENT -> {
|
||||
// nothing to do
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_START -> {
|
||||
// nothing to do
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_STOP -> {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Stop service")
|
||||
serviceControl.stopService()
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_RESTART -> {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Restart service")
|
||||
serviceControl.stopService()
|
||||
Thread.sleep(500L)
|
||||
startVService(serviceControl.getService())
|
||||
}
|
||||
|
||||
AppConfig.MSG_MEASURE_DELAY -> {
|
||||
measureV2rayDelay()
|
||||
}
|
||||
}
|
||||
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SCREEN_OFF -> {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen off")
|
||||
NotificationManager.stopSpeedNotification()
|
||||
}
|
||||
|
||||
Intent.ACTION_SCREEN_ON -> {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen on")
|
||||
NotificationManager.startSpeedNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,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,19 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import android.content.Context
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.CoreResolvedType
|
||||
|
||||
data class CoreConfigContext(
|
||||
val context: Context,
|
||||
val guid: String,
|
||||
val isCustom: Boolean = false,
|
||||
val resolvedOutbounds: List<ResolvedOutbound> = emptyList(),
|
||||
) {
|
||||
data class ResolvedOutbound(
|
||||
val tag: String,
|
||||
val profile: ProfileItem,
|
||||
val resolvedProfiles: List<ProfileItem>,
|
||||
val resolvedType: CoreResolvedType,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class GitHubRelease(
|
||||
@SerializedName("tag_name")
|
||||
val tagName: String,
|
||||
@SerializedName("body")
|
||||
val body: String,
|
||||
@SerializedName("assets")
|
||||
val assets: List<Asset>,
|
||||
@SerializedName("prerelease")
|
||||
val prerelease: Boolean = false,
|
||||
@SerializedName("published_at")
|
||||
val publishedAt: String = ""
|
||||
) {
|
||||
data class Asset(
|
||||
@SerializedName("name")
|
||||
val name: String,
|
||||
@SerializedName("browser_download_url")
|
||||
val browserDownloadUrl: String
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class GroupMapItem(
|
||||
var id: String,
|
||||
var remarks: String,
|
||||
)
|
||||
@@ -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,523 @@
|
||||
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 pinnedPeerCertSha256: String? = null,
|
||||
// REALITY settings
|
||||
val show: Boolean = false,
|
||||
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 domain: String? = null,
|
||||
// fragment
|
||||
val packets: String? = null,
|
||||
val length: String? = null,
|
||||
val delay: String? = null,
|
||||
// val maxSplit: String? = null,
|
||||
// noise
|
||||
val reset: Int? = null,
|
||||
val noise: List<NoiseMaskBean>? = null
|
||||
) {
|
||||
data class NoiseMaskBean(
|
||||
val rand: String? = null,
|
||||
// val randRange: String? = null,
|
||||
// val type: String? = null,
|
||||
// val packet: String? = null,
|
||||
val delay: String? = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class QuicParamsBean(
|
||||
var congestion: String? = null,
|
||||
var brutalUp: String? = null,
|
||||
var brutalDown: String? = null,
|
||||
var udpHop: UdpHopBean? = null,
|
||||
) {
|
||||
// Nested data class for the udpHop JSON object
|
||||
data class UdpHopBean(
|
||||
var ports: String? = null,
|
||||
var interval: String? = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,20 @@
|
||||
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 = ""
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class AssetUrlCache(
|
||||
val guid: String,
|
||||
val assetUrl: AssetUrlItem
|
||||
)
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
package com.v2ray.ang.dto
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class AssetUrlItem(
|
||||
var remarks: String = "",
|
||||
var url: String = "",
|
||||
val addedTime: Long = System.currentTimeMillis(),
|
||||
var lastUpdated: Long = -1
|
||||
var lastUpdated: Long = -1,
|
||||
var locked: Boolean? = false,
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
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 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,32 @@
|
||||
package com.v2ray.ang.enums
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
|
||||
/**
|
||||
* Permission types used in the app, handling API level differences.
|
||||
*/
|
||||
enum class PermissionType {
|
||||
/** Camera permission (used for scanning QR codes) */
|
||||
CAMERA {
|
||||
override fun getPermission(): String = Manifest.permission.CAMERA
|
||||
},
|
||||
|
||||
/** Notification permission (Android 13+) */
|
||||
POST_NOTIFICATIONS {
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
override fun getPermission(): String = Manifest.permission.POST_NOTIFICATIONS
|
||||
};
|
||||
|
||||
/** Return the actual Android permission string */
|
||||
abstract fun getPermission(): String
|
||||
|
||||
/** Return a human-readable label for the permission */
|
||||
fun getLabel(): String {
|
||||
return when (this) {
|
||||
CAMERA -> "Camera"
|
||||
POST_NOTIFICATIONS -> "Notification"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,173 @@
|
||||
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.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.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,110 @@
|
||||
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.handler.MmkvManager
|
||||
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,53 @@
|
||||
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.handler.MmkvManager
|
||||
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,50 @@
|
||||
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.handler.MmkvManager
|
||||
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,174 @@
|
||||
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.handler.MmkvManager
|
||||
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
|
||||
}
|
||||
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 -> ""
|
||||
}
|
||||
|
||||
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,721 @@
|
||||
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
|
||||
mainStorage.clearAll()
|
||||
profileFullStorage.clearAll()
|
||||
serverAffStorage.clearAll()
|
||||
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_delete_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,633 @@
|
||||
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 process routing can be used.
|
||||
*/
|
||||
fun canUseProcessRouting(): Boolean {
|
||||
// Android 10+
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must xray tun
|
||||
if (isUsingHevTun()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have route only enabled
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false) == false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure default settings are present in MMKV.
|
||||
*/
|
||||
private fun ensureDefaultSettings() {
|
||||
// Write defaults in the exact order requested by the user
|
||||
ensureDefaultValue(AppConfig.PREF_MODE, VPN)
|
||||
ensureDefaultValue(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
|
||||
ensureDefaultValue(AppConfig.PREF_VPN_MTU, AppConfig.VPN_MTU.toString())
|
||||
ensureDefaultValue(AppConfig.PREF_SOCKS_PORT, AppConfig.PORT_SOCKS)
|
||||
ensureDefaultValue(AppConfig.PREF_REMOTE_DNS, AppConfig.DNS_PROXY)
|
||||
ensureDefaultValue(AppConfig.PREF_DOMESTIC_DNS, AppConfig.DNS_DIRECT)
|
||||
ensureDefaultValue(AppConfig.PREF_DELAY_TEST_URL, AppConfig.DELAY_TEST_URL)
|
||||
ensureDefaultValue(AppConfig.PREF_IP_API_URL, AppConfig.IP_API_URL)
|
||||
ensureDefaultValue(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, AppConfig.HEVTUN_RW_TIMEOUT)
|
||||
ensureDefaultValue(AppConfig.PREF_MUX_CONCURRENCY, "8")
|
||||
ensureDefaultValue(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
|
||||
ensureDefaultValue(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
|
||||
}
|
||||
|
||||
private fun ensureDefaultValue(key: String, default: String) {
|
||||
if (MmkvManager.decodeSettingsString(key).isNullOrEmpty()) {
|
||||
MmkvManager.encodeSettings(key, default)
|
||||
}
|
||||
}
|
||||
|
||||
private fun migrateHysteria2PinSHA256() {
|
||||
// Check if migration has already been done
|
||||
val migrationKey = "hysteria2_pin_sha256_migrated"
|
||||
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
val serverList = decodeAllServerList()
|
||||
|
||||
for (guid in serverList) {
|
||||
val profile = decodeServerConfig(guid) ?: continue
|
||||
if (profile.configType != EConfigType.HYSTERIA2) {
|
||||
continue
|
||||
}
|
||||
if (profile.pinSHA256.isNullOrEmpty() || !profile.pinnedCA256.isNullOrEmpty()) {
|
||||
continue
|
||||
}
|
||||
profile.pinnedCA256 = profile.pinSHA256
|
||||
profile.pinSHA256 = null
|
||||
MmkvManager.encodeServerConfig(guid, profile)
|
||||
}
|
||||
|
||||
MmkvManager.encodeSettings(migrationKey, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates server list from legacy KEY_ANG_CONFIGS to subscription-based storage.
|
||||
* This method should be called once during app initialization after the storage structure change.
|
||||
* Servers are grouped by their subscriptionId into respective subscription's serverList.
|
||||
* Servers without subscription are moved to the default subscription.
|
||||
* After migration, KEY_ANG_CONFIGS is removed.
|
||||
*/
|
||||
private fun migrateServerListToSubscriptions() {
|
||||
// Check if migration has already been done
|
||||
val migrationKey = "server_list_to_subscriptions_migrated"
|
||||
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure default subscription exists before migration
|
||||
ensureDefaultSubscription()
|
||||
|
||||
// Read existing server list from legacy KEY_ANG_CONFIGS
|
||||
val oldJson = MmkvManager.readLegacyServerList()
|
||||
if (oldJson.isNullOrBlank()) {
|
||||
// No data to migrate, mark as done
|
||||
MmkvManager.encodeSettings(migrationKey, true)
|
||||
return
|
||||
}
|
||||
|
||||
val guids = JsonUtil.fromJsonSafe(oldJson, Array<String>::class.java) ?: run {
|
||||
MmkvManager.encodeSettings(migrationKey, true)
|
||||
return
|
||||
}
|
||||
|
||||
val subscriptionServerMap = mutableMapOf<String, MutableList<String>>()
|
||||
|
||||
// Group servers by subscription (use default subscription for empty subscriptionId)
|
||||
guids.forEach { guid ->
|
||||
val config = decodeServerConfig(guid) ?: return@forEach
|
||||
val subId = config.subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
|
||||
|
||||
subscriptionServerMap.getOrPut(subId) { mutableListOf() }.add(guid)
|
||||
}
|
||||
|
||||
// Update each subscription's serverList (including default subscription)
|
||||
subscriptionServerMap.forEach { (subId, serverGuids) ->
|
||||
MmkvManager.encodeServerList(serverGuids, subId)
|
||||
}
|
||||
|
||||
|
||||
// Mark migration as complete
|
||||
MmkvManager.encodeSettings(migrationKey, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the default subscription exists for ungrouped servers.
|
||||
* This subscription is used internally to store servers without a subscription.
|
||||
* Made public for migration in SettingsManager.
|
||||
*/
|
||||
private fun ensureDefaultSubscription() {
|
||||
if (decodeSubscription(DEFAULT_SUBSCRIPTION_ID) == null) {
|
||||
val defaultSub = SubscriptionItem(
|
||||
remarks = "Default",
|
||||
)
|
||||
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
|
||||
|
||||
// Move top
|
||||
val subsList = decodeSubsList()
|
||||
if (subsList.count() > 1) {
|
||||
swapSubscriptions(0, subsList.count() - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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 kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.isActive
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.net.UnknownHostException
|
||||
|
||||
object SpeedtestManager {
|
||||
|
||||
private val tcpTestingSockets = ArrayList<Socket?>()
|
||||
|
||||
/**
|
||||
* Measures the TCP connection time to a given URL and port.
|
||||
*
|
||||
* @param url The URL to connect to.
|
||||
* @param port The port to connect to.
|
||||
* @return The connection time in milliseconds, or -1 if the connection failed.
|
||||
*/
|
||||
suspend fun tcping(url: String, port: Int): Long {
|
||||
var time = -1L
|
||||
for (k in 0 until 2) {
|
||||
val one = socketConnectTime(url, port)
|
||||
if (!currentCoroutineContext().isActive) {
|
||||
break
|
||||
}
|
||||
if (one != -1L && (time == -1L || one < time)) {
|
||||
time = one
|
||||
}
|
||||
}
|
||||
return time
|
||||
}
|
||||
|
||||
/**
|
||||
* 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): Long {
|
||||
try {
|
||||
val socket = Socket()
|
||||
synchronized(this) {
|
||||
tcpTestingSockets.add(socket)
|
||||
}
|
||||
val start = System.currentTimeMillis()
|
||||
socket.connect(InetSocketAddress(url, port), 3000)
|
||||
val time = System.currentTimeMillis() - start
|
||||
synchronized(this) {
|
||||
tcpTestingSockets.remove(socket)
|
||||
}
|
||||
socket.close()
|
||||
return time
|
||||
} catch (e: UnknownHostException) {
|
||||
LogUtil.e(AppConfig.TAG, "Unknown host: $url", e)
|
||||
} catch (e: IOException) {
|
||||
LogUtil.e(AppConfig.TAG, "socketConnectTime IOException: $e")
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes all TCP sockets that are currently being tested.
|
||||
*/
|
||||
fun closeAllTcpSockets() {
|
||||
synchronized(this) {
|
||||
tcpTestingSockets.forEach {
|
||||
it?.close()
|
||||
}
|
||||
tcpTestingSockets.clear()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
/**
|
||||
* Helper for choosing and creating files using Android Storage Access Framework.
|
||||
* Supports both file selection (ACTION_GET_CONTENT) and file creation (CreateDocument).
|
||||
*/
|
||||
class FileChooserHelper(private val activity: AppCompatActivity) {
|
||||
private var fileChooserCallback: ((Uri?) -> Unit)? = null
|
||||
private var documentCreateCallback: ((Uri?) -> Unit)? = null
|
||||
|
||||
private val fileChooserLauncher: ActivityResultLauncher<Intent> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val uri = result.data?.data
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK && uri != null) {
|
||||
fileChooserCallback?.invoke(uri)
|
||||
} else {
|
||||
fileChooserCallback?.invoke(null)
|
||||
}
|
||||
fileChooserCallback = null
|
||||
}
|
||||
|
||||
private val documentCreateLauncher: ActivityResultLauncher<String> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
|
||||
documentCreateCallback?.invoke(uri)
|
||||
documentCreateCallback = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch file chooser with ACTION_GET_CONTENT intent to select an existing file.
|
||||
*
|
||||
* @param mimeType MIME type filter for files
|
||||
* @param onResult Callback invoked with the selected file URI (null if cancelled)
|
||||
*/
|
||||
fun launch(
|
||||
mimeType: String = "*/*",
|
||||
onResult: (Uri?) -> Unit
|
||||
) {
|
||||
fileChooserCallback = onResult
|
||||
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||
type = mimeType
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
|
||||
try {
|
||||
fileChooserLauncher.launch(
|
||||
Intent.createChooser(intent, activity.getString(R.string.title_file_chooser))
|
||||
)
|
||||
} catch (ex: ActivityNotFoundException) {
|
||||
LogUtil.e(AppConfig.TAG, "File chooser activity not found", ex)
|
||||
activity.toast(R.string.toast_require_file_manager)
|
||||
fileChooserCallback?.invoke(null)
|
||||
fileChooserCallback = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch document creator to create a new file at user-selected location.
|
||||
*
|
||||
* @param fileName Default file name for the new document
|
||||
* @param onResult Callback invoked with the created file URI (null if cancelled)
|
||||
*/
|
||||
fun createDocument(
|
||||
fileName: String,
|
||||
onResult: (Uri?) -> Unit
|
||||
) {
|
||||
documentCreateCallback = onResult
|
||||
try {
|
||||
documentCreateLauncher.launch(fileName)
|
||||
} catch (ex: ActivityNotFoundException) {
|
||||
LogUtil.e(AppConfig.TAG, "Document creator activity not found", ex)
|
||||
activity.toast(R.string.toast_require_file_manager)
|
||||
documentCreateCallback?.invoke(null)
|
||||
documentCreateCallback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-23
@@ -13,48 +13,41 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.v2ray.ang.helper;
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
/**
|
||||
* Interface to listen for a move or dismissal event from a {@link ItemTouchHelper.Callback}.
|
||||
* Interface to listen for a move or dismissal event from a [ItemTouchHelper.Callback].
|
||||
*
|
||||
* @author Paul Burke (ipaulpro)
|
||||
*/
|
||||
public interface ItemTouchHelperAdapter {
|
||||
|
||||
interface ItemTouchHelperAdapter {
|
||||
/**
|
||||
* Called when an item has been dragged far enough to trigger a move. This is called every time
|
||||
* an item is shifted, and <strong>not</strong> at the end of a "drop" event.<br/>
|
||||
* <br/>
|
||||
* Implementations should call {@link RecyclerView.Adapter#notifyItemMoved(int, int)} after
|
||||
* an item is shifted, and **not** at the end of a "drop" event.<br></br>
|
||||
* <br></br>
|
||||
* Implementations should call [RecyclerView.Adapter.notifyItemMoved] after
|
||||
* adjusting the underlying data to reflect this move.
|
||||
*
|
||||
* @param fromPosition The start position of the moved item.
|
||||
* @param toPosition Then resolved position of the moved item.
|
||||
* @return True if the item was moved to the new adapter position.
|
||||
*
|
||||
* @see RecyclerView#getAdapterPositionFor(RecyclerView.ViewHolder)
|
||||
* @see RecyclerView.ViewHolder#getAdapterPosition()
|
||||
* @see RecyclerView.getAdapterPositionFor
|
||||
* @see RecyclerView.ViewHolder.getAdapterPosition
|
||||
*/
|
||||
boolean onItemMove(int fromPosition, int toPosition);
|
||||
fun onItemMove(fromPosition: Int, toPosition: Int): Boolean
|
||||
|
||||
|
||||
void onItemMoveCompleted();
|
||||
fun onItemMoveCompleted()
|
||||
|
||||
/**
|
||||
* Called when an item has been dismissed by a swipe.<br/>
|
||||
* <br/>
|
||||
* Implementations should call {@link RecyclerView.Adapter#notifyItemRemoved(int)} after
|
||||
* Called when an item has been dismissed by a swipe.<br></br>
|
||||
* <br></br>
|
||||
* Implementations should call [RecyclerView.Adapter.notifyItemRemoved] after
|
||||
* adjusting the underlying data to reflect this removal.
|
||||
*
|
||||
* @param position The position of the item dismissed.
|
||||
*
|
||||
* @see RecyclerView#getAdapterPositionFor(RecyclerView.ViewHolder)
|
||||
* @see RecyclerView.ViewHolder#getAdapterPosition()
|
||||
* @see RecyclerView.getAdapterPositionFor
|
||||
* @see RecyclerView.ViewHolder.getAdapterPosition
|
||||
*/
|
||||
void onItemDismiss(int position);
|
||||
fun onItemDismiss(position: Int)
|
||||
}
|
||||
+8
-11
@@ -13,29 +13,26 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
package com.v2ray.ang.helper;
|
||||
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
|
||||
/**
|
||||
* Interface to notify an item ViewHolder of relevant callbacks from {@link
|
||||
* ItemTouchHelper.Callback}.
|
||||
* Interface to notify an item ViewHolder of relevant callbacks from [ ].
|
||||
*
|
||||
* @author Paul Burke (ipaulpro)
|
||||
*/
|
||||
public interface ItemTouchHelperViewHolder {
|
||||
|
||||
interface ItemTouchHelperViewHolder {
|
||||
/**
|
||||
* Called when the {@link ItemTouchHelper} first registers an item as being moved or swiped.
|
||||
* Called when the [ItemTouchHelper] first registers an item as being moved or swiped.
|
||||
* Implementations should update the item view to indicate it's active state.
|
||||
*/
|
||||
void onItemSelected();
|
||||
fun onItemSelected()
|
||||
|
||||
|
||||
/**
|
||||
* Called when the {@link ItemTouchHelper} has completed the move or swipe, and the active item
|
||||
* Called when the [ItemTouchHelper] has completed the move or swipe, and the active item
|
||||
* state should be cleared.
|
||||
*/
|
||||
void onItemClear();
|
||||
fun onItemClear()
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import androidx.preference.PreferenceDataStore
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
/**
|
||||
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
|
||||
* This ensures that all Preference UI operations read/write directly from/to MMKV,
|
||||
* avoiding inconsistencies between SharedPreferences and MMKV.
|
||||
*/
|
||||
class MmkvPreferenceDataStore : PreferenceDataStore() {
|
||||
|
||||
override fun putString(key: String, value: String?) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getString(key: String, defaultValue: String?): String? {
|
||||
return MmkvManager.decodeSettingsString(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putInt(key: String, value: Int) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getInt(key: String, defaultValue: Int): Int {
|
||||
return MmkvManager.decodeSettingsInt(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putLong(key: String, value: Long) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getLong(key: String, defaultValue: Long): Long {
|
||||
return MmkvManager.decodeSettingsLong(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putFloat(key: String, value: Float) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getFloat(key: String, defaultValue: Float): Float {
|
||||
return MmkvManager.decodeSettingsFloat(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putBoolean(key: String, value: Boolean) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String, defaultValue: Boolean): Boolean {
|
||||
return MmkvManager.decodeSettingsBool(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putStringSet(key: String, values: MutableSet<String>?) {
|
||||
if (values == null) {
|
||||
MmkvManager.encodeSettings(key, null as String?)
|
||||
} else {
|
||||
MmkvManager.encodeSettings(key, values)
|
||||
}
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getStringSet(key: String, defaultValues: MutableSet<String>?): MutableSet<String>? {
|
||||
return MmkvManager.decodeSettingsStringSet(key) ?: defaultValues
|
||||
}
|
||||
|
||||
// Internal helper: notify other modules about setting changes
|
||||
private fun notifySettingChanged(key: String) {
|
||||
if (key == AppConfig.PREF_LOGLEVEL) {
|
||||
LogUtil.refreshLogLevel()
|
||||
}
|
||||
|
||||
// Call SettingsManager.setNightMode if UI mode changed
|
||||
if (key == AppConfig.PREF_UI_MODE_NIGHT) {
|
||||
SettingsManager.setNightMode()
|
||||
}
|
||||
// Notify listeners that require service restart or reinit
|
||||
SettingsChangeManager.makeRestartService()
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2015 Paul Burke
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.v2ray.ang.helper;
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
/**
|
||||
* Listener for manual initiation of a drag.
|
||||
*/
|
||||
public interface OnStartDragListener {
|
||||
|
||||
/**
|
||||
* Called when a view is requesting a start of a drag.
|
||||
*
|
||||
* @param viewHolder The holder of the view to drag.
|
||||
*/
|
||||
void onStartDrag(RecyclerView.ViewHolder viewHolder);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.enums.PermissionType
|
||||
import com.v2ray.ang.extension.toast
|
||||
|
||||
/**
|
||||
* Helper for requesting permissions.
|
||||
*/
|
||||
class PermissionHelper(private val activity: AppCompatActivity) {
|
||||
private var permissionCallback: ((Boolean) -> Unit)? = null
|
||||
|
||||
private val permissionLauncher: ActivityResultLauncher<String> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
|
||||
permissionCallback?.invoke(isGranted)
|
||||
permissionCallback = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the permission and request it if not granted.
|
||||
*
|
||||
* @param permissionType the type of permission
|
||||
* @param onGranted called when permission is granted (called immediately if already granted)
|
||||
*/
|
||||
fun request(permissionType: PermissionType, onGranted: () -> Unit) {
|
||||
val permission = permissionType.getPermission()
|
||||
if (ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED) {
|
||||
onGranted()
|
||||
} else {
|
||||
permissionCallback = { isGranted ->
|
||||
if (isGranted) {
|
||||
onGranted()
|
||||
} else {
|
||||
val message = "${activity.getString(R.string.toast_permission_denied)} ${permissionType.getLabel()}"
|
||||
activity.toast(message)
|
||||
}
|
||||
}
|
||||
permissionLauncher.launch(permission)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user