Compare commits

...

258 commits

Author SHA1 Message Date
2dust
3bf911da9c up 1.10.8
Some checks are pending
Build APK / build (push) Waiting to run
Validate Fastlane metadata / go (push) Waiting to run
2025-06-29 10:27:57 +08:00
2dust
3f778a1ea2 Optimize the source of tls sni
Some checks are pending
Build APK / build (push) Waiting to run
Validate Fastlane metadata / go (push) Waiting to run
2025-06-28 10:02:25 +08:00
Hossein Abaspanah
8e03de8055
Update strings.xml (#4698)
Add "title_core_settings" string
2025-06-28 08:45:00 +08:00
Hossein Abaspanah
1f42d7fc07
Update strings.xml (#4696)
Some checks are pending
Build APK / build (push) Waiting to run
Validate Fastlane metadata / go (push) Waiting to run
2025-06-27 20:39:05 +08:00
Hossein Abaspanah
0700e834f1
Update Luri Bakhtiari translation (#4695) 2025-06-27 20:38:31 +08:00
2dust
777190e861 Added setting option for Outbound domain pre-resolve method
Some checks are pending
Build APK / build (push) Waiting to run
Validate Fastlane metadata / go (push) Waiting to run
https://github.com/2dust/v2rayNG/issues/4679
2025-06-27 17:48:31 +08:00
2dust
33572477fc Adjustment setting items 2025-06-27 16:39:06 +08:00
2dust
2fb6e62e13 Added setting option for VPN interface address
https://github.com/2dust/v2rayNG/issues/4641
2025-06-27 16:09:03 +08:00
2dust
94cc72d2b9 up 1.10.7 2025-06-19 14:40:47 +08:00
2dust
f68c353715 Update AndroidLibXrayLite 2025-06-19 14:40:11 +08:00
2dust
e077c18108 Improved update checking and prompts in case of abnormality 2025-06-19 14:40:07 +08:00
Ural Khamitov
1a5e105212
Fix blinking QSTile when QS panel is opening (#4676) 2025-06-18 16:17:28 +08:00
DHR60
e0881caab4
Fix missing sockopt.domainStrategy (#4673)
* Fix missing sockopt.domainStrategy

* Fix
2025-06-17 13:43:03 +08:00
DHR60
7219425258
Cloudflare DNS Hosts (#4661) 2025-06-15 09:46:30 +08:00
2dust
51eabe5440 up 1.10.6 2025-06-14 14:27:09 +08:00
2dust
6f0b3ce990 Update AndroidLibXrayLite 2025-06-14 14:26:37 +08:00
2dust
69e27ed3bb Fix log for plugin 2025-06-14 14:26:33 +08:00
patterniha
fff6ab30e6
Xray-core default FakeIPv6 Pool should not bypass and should route (#4649)
* Update V2RayVpnService.kt

* Update V2RayVpnService.kt

* Update AppConfig.kt
2025-06-14 13:59:59 +08:00
2dust
fdb67a86f4 up 1.10.5 2025-06-08 09:26:36 +08:00
2dust
ea088376ac Update AndroidLibXrayLite 2025-06-08 09:25:46 +08:00
2dust
52332d960e Update libs.versions.toml 2025-06-07 11:20:41 +08:00
2dust
3ead542e2b VPN bypass LAN By default 2025-06-07 11:20:37 +08:00
2dust
9d1f98ff34 Fix non-English domain
https://github.com/2dust/v2rayNG/issues/4626
f305e26a39
2025-05-31 14:03:52 +08:00
2dust
f305e26a39 Fix the parsing problem of non-English domain
https://github.com/2dust/v2rayNG/issues/4626
2025-05-31 11:12:57 +08:00
2dust
aa47fba20d up 1.10.4 2025-05-25 11:06:15 +08:00
Hossein Abaspanah
69c5bbfd3d
Improved Luri Bakhtiari Translation (#4600) 2025-05-25 10:12:52 +08:00
Pk-web6936
90ed02804c
Update Persian translate (#4607) 2025-05-25 10:12:45 +08:00
Hossein Abaspanah
822c1de79c
Update Luri Bakhtiari translation (#4610) 2025-05-25 10:12:39 +08:00
solokot
d910b93525
Update Russian translation (#4611) 2025-05-25 10:12:29 +08:00
Pk-web6936
7e6b1c247b
Update kotlin version to 2.1.21 (#4583)
* Update kotlin version to 2.1.21

* Update kotlin version to 2.1.21
2025-05-23 16:58:03 +08:00
2dust
f3f2b7fab5 Added delete function to subscription group list, secondary confirmation with settings 2025-05-23 16:17:38 +08:00
2dust
e6f260da76 Added the check update entry to the main interface drawer menu
https://github.com/2dust/v2rayNG/issues/4599
2025-05-23 14:34:55 +08:00
2dust
55bc2bf934 up 1.10.3 2025-05-17 12:01:34 +08:00
2dust
f22454da5d Update AndroidLibXrayLite 2025-05-17 11:48:15 +08:00
2dust
4a87549fa7
Update README.md 2025-05-15 10:58:52 +08:00
2dust
d447adc97f Fix
https://github.com/2dust/v2rayN/discussions/7268
2025-05-11 18:07:26 +08:00
2dust
3773962b64 up 1.10.2 2025-05-07 10:47:50 +08:00
2dust
be0a2506ce Update AndroidLibXrayLite 2025-05-07 10:44:19 +08:00
2dust
7f9cb8dfdd Check upgrade function is visible 2025-05-07 10:14:14 +08:00
2dust
71a5b6e480 Update AndroidLibXrayLite 2025-05-04 17:49:02 +08:00
2dust
02e53ced50 Update AndroidLibXrayLite 2025-04-30 14:49:02 +08:00
2dust
42c27a5e7e Update hysteria 2025-04-30 14:48:59 +08:00
2dust
af04bbcf87 up 1.10.1 2025-04-30 14:35:48 +08:00
2dust
9bedfe8a7b Bug fix
https://github.com/2dust/v2rayNG/issues/4555
2025-04-30 08:31:51 +08:00
2dust
2fdf684ee7 Fix
https://github.com/2dust/v2rayNG/issues/4548
2025-04-28 15:30:16 +08:00
2dust
5b79951da7 up 1.10.0 2025-04-24 10:33:23 +08:00
2dust
06aa680d45 Update libs.versions.toml 2025-04-24 10:26:03 +08:00
Hossein Abaspanah
cdb9b1811c
Update Luri Bakhtiari translation (#4535) 2025-04-24 10:11:20 +08:00
solokot
0fc1f2f5d3
Update Russian translation (#4534) 2025-04-24 10:11:10 +08:00
Pk-web6936
ef1bb3dd34
Update Persian translation (#4533) 2025-04-24 10:11:02 +08:00
2dust
1bca321d3f Temporarily add option to allow insecure HTTP subscription address
https://github.com/2dust/v2rayNG/issues/4526
2025-04-23 10:05:49 +08:00
solokot
247e2b3ba3
Update Russian translation (#4532) 2025-04-22 10:36:23 +08:00
2dust
41fd2b0cfb Fix 2025-04-22 10:36:10 +08:00
Hossein Abaspanah
72da42ee40
Update Luri Bakhtiari translation (#4524) 2025-04-20 09:33:01 +08:00
AmirHossein Abdolmotallebi
c130d55e8f
Update V2rayConfig.kt (#4522) 2025-04-20 09:32:52 +08:00
Pk-web6936
5ae84f7eac
Update Persian translate (#4518)
https://github.com/2dust/v2rayNG/pull/4507
2025-04-20 09:31:50 +08:00
patterniha
df5ea251e1
move Prefer_IPv6 settings (#4507) 2025-04-19 15:35:20 +08:00
2dust
8890d9f004 Organize and optimize the code of V2rayConfigManager 2025-04-19 11:38:02 +08:00
2dust
4fcb3f9d06 Refactor ConfigResult 2025-04-19 10:24:15 +08:00
2dust
5bf7c98cd3 Refactor outbound related code 2025-04-18 20:02:55 +08:00
2dust
46bc1a49df Refactor reference code with libv2ray remove protect 2025-04-18 17:20:21 +08:00
2dust
21175f41ec Update AndroidLibXrayLite 2025-04-18 17:19:40 +08:00
DHR60
864c63987e
Adds domain strategy option to sockopt (#4511)
* Adds domain strategy option to sockopt

* Simplifies sockopt handling in V2Ray config
2025-04-18 16:49:51 +08:00
DHR60
4ac0547e22
Resolves hostnames in config (#4508)
* Removes IP resolution and resolves in config

* Resolves hostnames to multiple IPs for DNS

* Improves custom config handling
2025-04-18 14:12:14 +08:00
2dust
12a9ee262c Revert "Update gradle.properties"
This reverts commit 56e33e6cdd.
2025-04-18 10:24:54 +08:00
2dust
cfa9c19c94 Clean code 2025-04-18 10:22:44 +08:00
2dust
56e33e6cdd Update gradle.properties 2025-04-18 10:22:17 +08:00
2dust
02421072c1 Merge branch 'master' of https://github.com/2dust/v2rayNG 2025-04-17 16:41:04 +08:00
2dust
b862a0dc65 Update AndroidLibXrayLite 2025-04-17 16:40:53 +08:00
Pk-web6936
1f25d6a000
Update dependencies (#4504)
* Update libs.versions.toml

* Update libs.versions.toml
2025-04-17 16:29:56 +08:00
2dust
e1def0616a Refactor the Outbound transport and tls in the configuration file 2025-04-17 15:23:57 +08:00
2dust
83fd6efc17 Resolve remote host names in the configuration file to IP addresses 2025-04-17 14:08:45 +08:00
2dust
f0c0e2e83a Refactor reference code with libv2ray 2025-04-17 10:41:30 +08:00
Pk-web6936
6ca3eb769e
Update dependencies (#4485)
* Update libs.versions.toml

* Update libs.versions.toml

* Update libs.versions.toml

* Update libs.versions.toml

* Update libs.versions.toml

* Update libs.versions.toml
2025-04-16 20:06:44 +08:00
2dust
963d24ab66 Optimize and improve
38193b5621
2025-04-16 15:09:49 +08:00
2dust
cfd81441fa Update libs.versions.toml 2025-04-15 20:44:03 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
4084ae2938
Updating PRIVATE_IP_LIST (#4498) 2025-04-15 19:21:25 +08:00
kore kas nadar
3f9bc098ec
Update Luri Bakhtiari translation (#4497) 2025-04-15 19:21:17 +08:00
Pk-web6936
9cb28ed969
Update Persian translate (#4495)
38193b5621
2025-04-15 14:14:13 +08:00
DHR60
773ddc5373
Fix wg chain proxy (#4496)
* Fix handle null streamSettings in WireGuard chained proxy

* Allows null preshared key for WireGuard

* remove WIREGUARD_LOCAL_ADDRESS_V6

* Considers WireGuard outbound for domain port
2025-04-14 20:42:02 +08:00
2dust
38193b5621 Added IP display in connection test
https://github.com/2dust/v2rayNG/issues/4489
2025-04-14 14:15:25 +08:00
solokot
358713a2a3
Update Russian translation (#4484) 2025-04-11 09:19:02 +08:00
2dust
5b9f24c1f0 up 1.9.46 2025-04-09 14:41:11 +08:00
2dust
c47c2c3666 Code clean 2025-04-09 14:35:05 +08:00
2dust
49f7c3e7d7 Added tips for per app proxy 2025-04-09 14:34:27 +08:00
2dust
423e5de2c6 Fix
https://github.com/2dust/v2rayNG/issues/4473
2025-04-09 11:55:43 +08:00
kore kas nadar
3e3387e63e
Update Luri Bakhtiari translation (#4477) 2025-04-09 10:50:47 +08:00
solokot
debddace8b
Update Russian translation (#4476) 2025-04-09 10:50:33 +08:00
Pk-web6936
160b412e0a
Update Persian translate (#4475) 2025-04-09 10:50:23 +08:00
2dust
0f3e0a0ea2 Optimize and improve RoutingSettingActivity 2025-04-09 10:47:35 +08:00
2dust
c4cf90e807 Optimize and improve UserAssetActivity 2025-04-09 10:46:21 +08:00
2dust
5db46e81b7 Bug fix
https://github.com/2dust/v2rayNG/issues/4474
2025-04-09 10:08:56 +08:00
2dust
1ef80a3a96 Refactor AppConfig const 2025-04-08 21:05:34 +08:00
2dust
a46d9d0d2a Fix tools:context 2025-04-08 21:03:29 +08:00
2dust
7b80536e1e Added GEO files sources settings in asset settings
https://github.com/2dust/v2rayNG/issues/4440
2025-04-08 19:31:26 +08:00
2dust
5733ecf20e Add some unit test 2025-04-07 17:06:29 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
eae33b61cf
Restoring some IP ranges removed in b4c833b (#4469) 2025-04-07 16:55:59 +08:00
2dust
9e55b525f1 Bug fix
https://github.com/2dust/v2rayNG/issues/4468
2025-04-07 16:15:06 +08:00
2dust
678b3cb505 Allow private IP to use HTTP protocol
https://github.com/2dust/v2rayNG/issues/4460
2025-04-07 16:00:56 +08:00
2dust
b4c833b039 Refactoring of private IP lists 2025-04-06 19:42:01 +08:00
2dust
597bd021b8 Bug fix 2025-04-06 14:22:47 +08:00
2dust
ba03118a43 Code clean 2025-04-06 14:22:30 +08:00
kore kas nadar
82148408b0
Improved Luri Bakhtiari Translation (#4465)
* Improved Luri Bakhtiari Translation

* Improved Luri Bakhtiari Translation
2025-04-06 14:09:49 +08:00
kore kas nadar
042900e065
Update Luri Bakhtiari translation (#4463) 2025-04-06 10:19:20 +08:00
kore kas nadar
874fccc351
Update Luri Bakhtiari translation (#4455) 2025-04-04 10:35:39 +08:00
2dust
14f36872e7 If it is the Google Play version, the update check will not be displayed within 2 days after update. 2025-04-03 17:30:29 +08:00
solokot
3b6ad3052a
Update Russian translation (#4451) 2025-04-03 15:39:55 +08:00
Pk-web6936
194fc6b6ed
Update Persian Translation (#4449)
* Update Persian Translation

* Update strings.xml

* Update strings.xml
2025-04-03 15:39:47 +08:00
Pk-web6936
0275ad54ac
Delete Unnecessary () around expression (#4447)
* Delete Unnecessary () around expression

Delete Unnecessary () around expression

* Delete Unnecessary () around expression

Delete Unnecessary () around expression

* Delete Unnecessary () around expression

Delete Unnecessary () around expression

* Delete Unnecessary () around expression

Delete Unnecessary () around expression

* Delete Unnecessary () around expression

Delete Unnecessary () around expression

* Delete Unnecessary () around expression

Delete Unnecessary () around expression

* Delete Unnecessary () around expression

Delete Unnecessary () around expression
2025-04-03 10:20:59 +08:00
2dust
7ca4044467 Added check for updates 2025-04-01 17:24:59 +08:00
2dust
1672494ee9 1.9.45 2025-04-01 10:26:59 +08:00
2dust
bbbbc72d22 Update AndroidLibXrayLite 2025-04-01 10:26:45 +08:00
2dust
1e7f49b756 up 1.9.44 2025-03-30 19:14:55 +08:00
2dust
ac4c0f7ee1 Optimize and improve Log 2025-03-30 19:05:35 +08:00
2dust
6cc91b1a89 Optimize and improve log
Use Log.e() instead of e.printStackTrace()
2025-03-30 17:40:36 +08:00
2dust
45facff41d Optimize and improve Utils 2025-03-30 16:28:14 +08:00
2dust
ee703e6c95 Remove ads rules from default routing rules 2025-03-30 11:18:39 +08:00
hhhkkmk
87213c34a6
Revert "Optimization (#4426)" (#4437)
This reverts commit d111328541.
2025-03-29 18:06:09 +08:00
solokot
73a7c76183
Update Russian translation (#4435) 2025-03-29 18:05:43 +08:00
Pk-web6936
ed5282f2b3
Update dependencies (#4432)
* Update libs.versions.toml

Update agp

* Update validate-fastlane-supply-metadata

Update validate-fastlane-supply-metadata
2025-03-29 10:47:09 +08:00
Pk-web6936
390c657047
Switch to Loyalsoldier's v2ray-rules-dat (#4431)
https://github.com/2dust/AndroidLibXrayLite/pull/132/files
2025-03-29 10:46:49 +08:00
Pk-web6936
7071072862
Fix Arabic Language (#4430)
* Fix Arabic Language

* Fix Arabic Language

Fix Arabic Language
2025-03-29 10:46:29 +08:00
Pk-web6936
d111328541
Optimization (#4426)
* Optimization

Add security flags to CFLAGS and LDFLAGS. Use local variables instead of global variables. Clean up and simplify the script.

* Optimizition

Add security flags to CFLAGS and LDFLAGS. Simplify and organize the file. Makefile

* Optimization

Add security flags to CFLAGS and LDFLAGS. Use local variables instead of global variables. Clean up and simplify the script.

* Optimization
2025-03-29 10:44:21 +08:00
Pk-web6936
76cb2aaf46
Update Persian translate (#4424) 2025-03-29 10:44:03 +08:00
2dust
7ff1397163 Optimize the test true delay function
When testing, remove unnecessary configurations such as dns and routing to reduce resource usage, except for custom configurations.
2025-03-29 10:43:25 +08:00
2dust
bcf5d49a3d up 1.9.43 2025-03-28 16:20:12 +08:00
2dust
4fffb17283 Added user asset file settings in the drawer menu 2025-03-28 15:08:52 +08:00
2dust
83b8bdfdf4 Optimize UI 2025-03-28 15:07:24 +08:00
2dust
cc1538a24d Code clean 2025-03-28 11:02:45 +08:00
2dust
eb19199d18 Update .gitignore 2025-03-28 10:56:59 +08:00
Pk-web6936
441e5ef8d5
Consolidate and Optimize .gitignore Files (#4421)
* Delete .gitignore

* Delete V2rayNG/.gitignore

* Delete V2rayNG/app/.gitignore

* Create .gitignore

* Add New .gitignore
2025-03-28 09:24:22 +08:00
2dust
d768774aad Optimize and improve toast
Migrate from https://github.com/PureWriter/ToastCompat to https://github.com/GrenderG/Toasty
2025-03-27 17:43:58 +08:00
2dust
c3d83907a5 Optimize and improve 2025-03-27 13:46:35 +08:00
solokot
b52a98ae5e
Update Russian translation (#4416) 2025-03-27 09:12:52 +08:00
Pk-web6936
a70e4089e3
Update Persian translate (#4415) 2025-03-27 09:12:41 +08:00
2dust
397989769c up 1.9.42 2025-03-26 18:54:42 +08:00
2dust
796676abdc Enable double column display
The profile list is displayed in double columns, allowing more content to be displayed on the screen. You need to restart the application to take effect.
2025-03-26 18:48:14 +08:00
2dust
3c93ccb86c Optimize and improve 2025-03-25 20:26:28 +08:00
2dust
fc8c74184a Optimize and improve for toast 2025-03-25 17:44:30 +08:00
solokot
1939e6b5cf
Optimize Russian translation (#4411) 2025-03-25 17:05:12 +08:00
2dust
1c2ac9385d Fix
https://github.com/2dust/v2rayNG/issues/4410
2025-03-25 10:49:07 +08:00
2dust
7513f1fe07 Optimize and improve 2025-03-25 10:44:06 +08:00
2dust
63e2c02daa Fix
https://github.com/2dust/v2rayNG/issues/4408
2025-03-24 20:17:49 +08:00
2dust
fec76385e1 Bug fix
https://github.com/2dust/v2rayNG/issues/4409
2025-03-24 20:17:05 +08:00
2dust
bf21428746 up 1.9.41 2025-03-24 15:45:56 +08:00
2dust
01ab8dad17 Optimize and improve removeWhiteSpace 2025-03-24 15:44:08 +08:00
2dust
8974f73841 Fix
https://github.com/2dust/v2rayNG/issues/4407
2025-03-24 14:43:07 +08:00
Pk-web6936
d3a2a2413c
Update Persian translate (#4404)
* Update Persian translate

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml
2025-03-24 10:50:13 +08:00
2dust
9b52863270 Bug fix
https://github.com/2dust/v2rayNG/issues/4405
2025-03-24 10:34:59 +08:00
2dust
733914a7a7 Add colorPrimaryDark to theme 2025-03-23 19:25:20 +08:00
2dust
7b0ab1ea4d up 1.9.40 2025-03-23 16:37:22 +08:00
2dust
51ff95f071 Update AndroidLibXrayLite 2025-03-23 16:37:01 +08:00
2dust
fc7aca46ce Update hysteria 2025-03-23 16:36:50 +08:00
2dust
78e34caff3 Optimize and improve style 2025-03-23 15:12:28 +08:00
2dust
d2aecd4dee Optimize and improve string 2025-03-23 15:12:14 +08:00
Pk-web6936
9be1083495
Fix (#4401)
* Update fastlane.yml

* Update libs.versions.toml

* Update libs.versions.toml

* Update fastlane.yml

* Update libs.versions.toml

* Update libs.versions.toml

* Update libs.versions.toml
2025-03-23 15:11:38 +08:00
2dust
38eb9ee13f Update libs.versions.toml 2025-03-23 10:44:59 +08:00
Pk-web6936
15b36dfc57
Update dependencies (#4400)
* Update libs.versions.toml

* Update build.yml
2025-03-23 10:29:59 +08:00
2dust
40a83f7cac Optimize and improve 2025-03-22 18:02:51 +08:00
2dust
fd9f912c18 Optimize and improve theme 2025-03-22 17:50:55 +08:00
hhhkkmk
eef6e60dbb
optimization (#4393)
* optimization

* fix

* fix2

* fix3
2025-03-21 18:13:04 +08:00
Pk-web6936
f0de5275b9
Update kotlin version to 2.1.20 (#4397)
* Update kotlin version to 2.1.20

Update kotlin version to 2.1.20

* Update kotlin version to 2.1.20

Update kotlin version to 2.1.20

* Update gradle-wrapper.properties
2025-03-21 18:10:10 +08:00
2dust
4eb5c0263c Optimize and improve 2025-03-21 16:53:30 +08:00
solokot
7a0d997a81
Update Russian translation (#4396) 2025-03-21 13:57:38 +08:00
Pk-web6936
17b7c6d357
Update Persian translate (#4395)
* Update Persian translate 

Update Persian translate

* Update strings.xml
2025-03-21 13:56:35 +08:00
2dust
206f2cb306 Optimize and improve string 2025-03-20 20:16:54 +08:00
2dust
1bc433097b Add per_app_proxy_settings to the drawer menu 2025-03-20 16:14:41 +08:00
2dust
9bee5fbe99 Refactoring to import custom configuration function
Most of the functions have been merged into the existing menu, only the local import function needs to be added.
The code is temporarily commented and will be deleted after user testing.
2025-03-20 15:36:06 +08:00
2dust
e819798d80 Optimize and improve 2025-03-20 14:37:53 +08:00
2dust
14ff9eb527 Optimize and improve PerAppProxyActivity 2025-03-20 14:37:38 +08:00
2dust
a60f45ce31 The timeout was changed from 30s to 15s 2025-03-20 12:06:13 +08:00
2dust
0b9a96209f Improved progress bar 2025-03-20 12:00:33 +08:00
2dust
3747e58e4e Adjust dimen resources 2025-03-20 10:24:38 +08:00
2dust
28b1788dc1 Remove the use of androidx.cardview.widget.CardView in the list 2025-03-19 20:21:12 +08:00
2dust
12ab2954b0 Using AI to improve function documentation 2025-03-19 14:12:30 +08:00
2dust
d0e8937f03 Using AI to improve function documentation 2025-03-19 12:15:43 +08:00
2dust
1a7ab97a3a Using AI to improve function documentation 2025-03-19 12:08:19 +08:00
2dust
ef4145787b Using AI to improve function documentation 2025-03-19 11:57:56 +08:00
2dust
899e4c1b14 Using AI to improve function documentation 2025-03-19 11:41:12 +08:00
2dust
faa4385087 Using AI to improve function documentation 2025-03-19 11:01:07 +08:00
2dust
172d9fd093 Migrate SpeedtestUtil to SpeedtestManager 2025-03-19 10:43:00 +08:00
2dust
093716baaa Optimize and improve SettingsManager 2025-03-19 10:38:58 +08:00
2dust
0165ad54b3 Update build.gradle.kts 2025-03-18 21:04:51 +08:00
2dust
b52dd33102 Optimize and improve V2RayServiceManager 2025-03-18 21:00:22 +08:00
2dust
00aed90f2f Add NotificationService, refactor V2RayServiceManager and V2RayVpnService 2025-03-18 17:21:42 +08:00
2dust
5f3d7c0213 Refactor the start and stop service 2025-03-18 15:05:42 +08:00
2dust
401f051774 Update AndroidLibXrayLite 2025-03-18 15:04:33 +08:00
2dust
e14b48f3eb Code clean 2025-03-18 15:04:25 +08:00
2dust
1972f83b86 Add HttpUtil, refactor the network connection function 2025-03-18 09:55:40 +08:00
2dust
55a11bbeee Update libs.versions.toml 2025-03-18 09:31:00 +08:00
2dust
ca32ee3a0e Update proxy_packagename.txt 2025-03-17 20:55:08 +08:00
2dust
159a370286 Changed tun2socks config private vlan
https://github.com/2dust/v2rayNG/issues/4373
2025-03-16 14:44:25 +08:00
2dust
d1904d52d9 Bug fix
Exception java.lang.StackOverflowError: stack size 8188KB
2025-03-16 10:24:32 +08:00
Noo6
5e993fd91b
Fix subscription link redirect handling (#4378)
* Fix subscription link redirect handling

* optimize code format
2025-03-12 08:02:35 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
bada7c93d7
Allow user certificate (#4375)
* Add network security config for user CA

* Use network security config

* mv

* Suppress Inspection for user CA
2025-03-12 08:02:02 +08:00
DHR60
38850597f3
Add kcp DNS masquerade support (#4368) 2025-03-07 17:45:21 +08:00
2dust
da347492d3 up 1.9.39 2025-03-07 14:25:30 +08:00
2dust
2ec5d8db3c Update AndroidLibXrayLite 2025-03-07 14:24:07 +08:00
2dust
fd9c5040bf up 1.9.38 2025-03-04 10:19:39 +08:00
2dust
aa328f0add Update AndroidLibXrayLite 2025-03-04 10:12:46 +08:00
hhhkkmk
9743d7b87b
update (#4365) 2025-03-04 10:05:52 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
98fb0c433e
fixup! Fix badvpn (#4302) (#4352)
thx
2025-02-25 09:31:11 +08:00
2dust
7c9fcd9f43 Update build.gradle.kts 2025-02-22 17:08:23 +08:00
2dust
54c76d9968 git submodule update --remote 2025-02-22 16:51:24 +08:00
2dust
40b3f0fedc up 1.9.37 2025-02-22 15:17:29 +08:00
2dust
dcfcf83430 Update AndroidLibXrayLite 2025-02-22 14:52:05 +08:00
2dust
e46b354643 up 1.9.36 2025-02-19 18:18:43 +08:00
2dust
f497e4e301 Update AndroidLibXrayLite 2025-02-19 18:13:34 +08:00
2dust
b65e4b3819 Bug fix
https://github.com/2dust/v2rayNG/issues/4329
2025-02-11 10:46:01 +08:00
Hossin Asaadi
d166b036fc
Update ServerActivity.kt (#4326) 2025-02-11 10:31:22 +08:00
2dust
ddf5f22037 up 1.9.35 2025-02-09 10:41:09 +08:00
2dust
7d8a9f2b6d Update AndroidLibXrayLite 2025-02-09 10:33:41 +08:00
alphax-hue3682
0a1695e3d7
Update kotlin version to 2.1.10 (#4305)
* Update libs.versions.toml

* Update README.md
2025-02-07 14:31:45 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
4a653d4935
Fix badvpn (#4302)
* copying from df181a3065

* add missing includes of dc99ade18d

* update workflow

* fixup! update workflow
2025-01-31 13:58:59 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
2bc31a10c5
rm AndroidLibV2rayLite (#4303) 2025-01-31 13:54:08 +08:00
alphax-hue3682
e8d2c6214b
Update dependencies (#4301)
* Update dependencies

* Update dependencies
2025-01-30 20:46:01 +08:00
alphax-hue3682
3a0f2687e9
Update_Submodules (#4292)
* UpdateSubmodules

* Update _Submodules
2025-01-30 20:20:26 +08:00
2dust
04c98326b2 up 1.9.34 2025-01-30 19:55:47 +08:00
2dust
eb22c7f303 up 1.9.33 2025-01-25 13:40:08 +08:00
alphax-hue3682
d51a4d7a7e
Update libs.versions.toml (#4291)
* Update libs.versions.toml

* Update gradle-wrapper.properties

* Update libs.versions.toml

* Update libs.versions.toml
2025-01-25 13:37:47 +08:00
2dust
0fb705e1e2 Update libs.versions.toml 2025-01-25 10:28:23 +08:00
kore kas nadar
10b849ef09
Update Luri Bakhtiari translation (#4286) 2025-01-25 10:03:01 +08:00
solokot
d7d3b23cea
Update Russian translation (#4281) 2025-01-25 10:02:51 +08:00
alphax-hue3682
c3786d434e
remove patch (#4279)
remove patch
2025-01-19 18:36:46 +08:00
2dust
9e3b92014a logcat content reversed 2025-01-17 13:53:35 +08:00
alphax-hue3682
f4e088131b
Update Persian translate (#4269) 2025-01-16 15:56:23 +08:00
2dust
e55e069fe3 Add bandwidth to hysteria2 settings
https://github.com/2dust/v2rayNG/issues/4261
2025-01-16 14:44:19 +08:00
alphax-hue3682
d8d3767798
Update Persian translate (#4264) 2025-01-14 09:53:57 +08:00
2dust
7e99b1ac78 up 1.9.32 2025-01-13 15:04:01 +08:00
2dust
6ff3a73bf2 Adjust UI for subscription 2025-01-13 14:56:48 +08:00
2dust
2a43b52344 Logcat add pull-down refresh 2025-01-13 14:31:36 +08:00
2dust
abff80ec23 Adjust UI 2025-01-13 12:51:26 +08:00
2dust
a4edf86195 Improved logcat 2025-01-13 12:50:35 +08:00
alphax-hue3682
0d0da6bfec
Update Persian translate (#4256)
* Update Persian translate

* Update strings.xml
2025-01-12 13:47:18 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
e0c8ece9b5
Reproducible Builds for libhysteria2.so (#4249)
* Patch Go use 600296

* -buildvcs=false for libhysteria2

* fix if

* fixup! Build and cache libhysteria2.so (#4226)
2025-01-11 10:38:47 +08:00
2dust
4d875bc3d4 Add theme to SwitchCompat for tasker 2025-01-09 09:48:37 +08:00
2dust
3a6e23bcef Fix the bug of mux parameter taking 2025-01-08 11:23:21 +08:00
2dust
efd0716707 Custom configuration can use any outbound
https://github.com/2dust/v2rayNG/issues/4243
2025-01-07 17:14:23 +08:00
2dust
c94a5fb743 Update Luri Bakhtiari translation 2025-01-07 14:47:18 +08:00
2dust
047011f60b up 1.9.31 2025-01-07 14:21:08 +08:00
2dust
a54ed3a51a Update libs.versions.toml 2025-01-07 14:19:31 +08:00
2dust
c37f09bfcd Fix logcat 2025-01-07 14:03:26 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
1c7042463d
Fixup! 7dbda3c (#4237) 2025-01-05 19:04:50 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
dcb003f9ab
Submodules (#4234)
* add submodule AndroidLibXrayLite

* rm -r AndroidLibV2rayLite

* add submodule AndroidLibV2rayLite

* update cache key for libtun2socks due to submodules

* fetch-depth: '0'

* fail safe

* Revert "add submodule AndroidLibV2rayLite"

This reverts commit 816f75e0f9.

* sync with 2dust/AndroidLibXrayLite#90

* checkout to 664c389 of AndroidLibXrayLite

* refine cache key of libtun2socks
2025-01-05 11:52:31 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
7dbda3cee7
Build and cache libhysteria2.so (#4226)
* add submodule apernet/hysteria

* remove libhysteria2 binary from git repo

* libhysteria2.sh

* ignore *.so
2025-01-04 10:49:04 +08:00
solokot
26bee229a1
Update Russian translation (#4221) 2025-01-03 09:47:35 +08:00
alphax-hue3682
5bf2beb179
Update Persian translate (#4219)
* Update Persian translate

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml
2025-01-03 09:47:21 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
4a5c551678
ndk for gradlew (#4220) 2025-01-02 14:57:44 +08:00
2dust
277894215d up 1.9.30 2025-01-02 10:11:57 +08:00
alphax-hue3682
684e08a3a1
Update Persian translate (#4214)
* Update Persian translate

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml

* Update strings.xml
2025-01-02 09:37:24 +08:00
2dust
19dbc2f9b9 up 1.9.29 2025-01-01 16:45:08 +08:00
2dust
833a1e06f0 Add VPN bypass LAN option
https://github.com/2dust/v2rayNG/pull/4208
2025-01-01 16:43:48 +08:00
2dust
daca0831a4 Remove the last rule from the Whitelist 2024-12-31 21:19:15 +08:00
2dust
337889c5f1 up 1.9.28 2024-12-29 14:29:22 +08:00
2dust
244d2d3866 Fix bugs related to routing rules
https://github.com/2dust/v2rayNG/issues/4196
https://github.com/2dust/v2rayNG/issues/4199
2024-12-29 11:06:08 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
c0fed0ba4f
Download libv2ray from 2dust/AndroidLibXrayLite (#4200) 2024-12-28 19:46:51 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
affb107b9d
Sagernet (#4194)
* switch to sagernet/gomobile

* arguments for rb

* match ndk version

* with more options

* nttld/setup-ndk#518
2024-12-28 19:29:00 +08:00
2dust
f96073af99 Update build.yml 2024-12-25 10:23:30 +08:00
198 changed files with 8067 additions and 4322 deletions

View file

@ -16,117 +16,105 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v4.2.2
with:
submodules: 'recursive'
fetch-depth: '0'
- name: Prepare build dir
run: |
mkdir ${{ github.workspace }}/build
- name: Setup Android SDK
uses: android-actions/setup-android@v3.2.0
with:
log-accepted-android-sdk-licenses: false
cmdline-tools-version: '12266719'
packages: 'platforms;android-35 build-tools;35.0.0 platform-tools'
- name: Fetch AndroidLibV2rayLite
- name: Install NDK
run: |
cd ${{ github.workspace }}/build
git clone --depth=1 -b master https://github.com/2dust/AndroidLibV2rayLite.git
cd AndroidLibV2rayLite
git submodule update --init
echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
--channel=3 \
--install "ndk;29.0.13113456"
echo "NDK_HOME=$ANDROID_HOME/ndk/29.0.13113456" >> $GITHUB_ENV
sed -i '10i\
\
ndkVersion = "29.0.13113456"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
- name: Restore cached libtun2socks
id: cache-libtun2socks-restore
uses: actions/cache/restore@v4
with:
path: ${{ github.workspace }}/build/AndroidLibV2rayLite/libs
key: libtun2socks-${{ runner.os }}-${{ hashFiles('build/AndroidLibV2rayLite/.git/refs/heads/master') }}-${{ hashFiles('build/AndroidLibV2rayLite/.git/modules/badvpn/HEAD') }}-${{ hashFiles('build/AndroidLibV2rayLite/.git/modules/libancillary/HEAD') }}
- name: Setup Android NDK
if: steps.cache-libtun2socks-restore.outputs.cache-hit != 'true'
uses: nttld/setup-ndk@v1
id: setup-ndk
# Same version as https://gitlab.com/fdroid/fdroiddata/metadata/com.v2ray.ang.yml
with:
ndk-version: r27
path: ${{ github.workspace }}/libs
key: libtun2socks-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/badvpn/HEAD') }}-${{ hashFiles('.git/modules/libancillary/HEAD') }}
- name: Build libtun2socks
if: steps.cache-libtun2socks-restore.outputs.cache-hit != 'true'
run: |
cd ${{ github.workspace }}/build/AndroidLibV2rayLite
bash compile-tun2socks.sh
tar -xvzf libtun2socks.so.tgz
cp -r libs/* ${{ github.workspace }}/V2rayNG/app/libs/
env:
NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
- name: Save libtun2socks
if: steps.cache-libtun2socks-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: ${{ github.workspace }}/build/AndroidLibV2rayLite/libs
key: libtun2socks-${{ runner.os }}-${{ hashFiles('build/AndroidLibV2rayLite/.git/refs/heads/master') }}-${{ hashFiles('build/AndroidLibV2rayLite/.git/modules/badvpn/HEAD') }}-${{ hashFiles('build/AndroidLibV2rayLite/.git/modules/libancillary/HEAD') }}
path: ${{ github.workspace }}/libs
key: libtun2socks-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/badvpn/HEAD') }}-${{ hashFiles('.git/modules/libancillary/HEAD') }}
- name: Copy libtun2socks
run: |
cp -r ${{ github.workspace }}/build/AndroidLibV2rayLite/libs/* ${{ github.workspace }}/V2rayNG/app/libs/
cp -r ${{ github.workspace }}/libs ${{ github.workspace }}/V2rayNG/app
- name: Fetch AndroidLibXrayLite
- name: Fetch AndroidLibXrayLite tag
run: |
cd ${{ github.workspace }}/build
git clone --depth=1 -b main https://github.com/2dust/AndroidLibXrayLite.git
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: Restore cached libv2ray
id: cache-libv2ray-restore
- name: Download libv2ray
uses: robinraju/release-downloader@v1.12
with:
repository: '2dust/AndroidLibXrayLite'
tag: ${{ env.CURRENT_TAG }}
fileName: 'libv2ray.aar'
out-file-path: V2rayNG/app/libs/
- name: Restore cached libhysteria2
id: cache-libhysteria2-restore
uses: actions/cache/restore@v4
with:
path: ${{ github.workspace }}/build/AndroidLibXrayLite/
key: libv2ray-${{ runner.os }}-${{ hashFiles('build/AndroidLibXrayLite/.git/refs/heads/main') }}
path: ${{ github.workspace }}/hysteria/libs
key: libhysteria2-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hysteria/HEAD') }}-${{ hashFiles('libhysteria2.sh') }}
- name: Setup Golang
if: steps.cache-libv2ray-restore.outputs.cache-hit != 'true'
uses: actions/setup-go@v5
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
uses: actions/setup-go@v5.4.0
with:
go-version: '1.23.4'
go-version-file: 'AndroidLibXrayLite/go.mod'
cache: false
- name: Patch Go use 600296
if: steps.cache-libv2ray-restore.outputs.cache-hit != 'true'
#https://go-review.googlesource.com/c/go/+/600296
- name: Build libhysteria2
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
run: |
cd "$(go env GOROOT)"
curl "https://go-review.googlesource.com/changes/go~600296/revisions/5/patch" | base64 -d | patch --verbose -p 1
bash libhysteria2.sh
- name: Install gomobile
if: steps.cache-libv2ray-restore.outputs.cache-hit != 'true'
run: |
go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20240806205939-81131f6468ab
echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
- name: Build libv2ray
if: steps.cache-libv2ray-restore.outputs.cache-hit != 'true'
run: |
cd ${{ github.workspace }}/build/AndroidLibXrayLite
gomobile init
go mod tidy -v
gomobile bind -v -androidapi 21 -ldflags='-s -w' ./
- name: Save libv2ray
if: steps.cache-libv2ray-restore.outputs.cache-hit != 'true'
- name: Save libhysteria2
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: ${{ github.workspace }}/build/AndroidLibXrayLite/
key: libv2ray-${{ runner.os }}-${{ hashFiles('build/AndroidLibXrayLite/.git/refs/heads/main') }}
path: ${{ github.workspace }}/hysteria/libs
key: libhysteria2-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hysteria/HEAD') }}-${{ hashFiles('libhysteria2.sh') }}
- name: Copy libv2ray
- name: Copy libhysteria2
run: |
cp -r ${{ github.workspace }}/build/AndroidLibXrayLite/*.aar ${{ github.workspace }}/V2rayNG/app/libs/
cp -r ${{ github.workspace }}/hysteria/libs ${{ github.workspace }}/V2rayNG/app
- name: Setup Java
uses: actions/setup-java@v4
uses: actions/setup-java@v4.7.0
with:
distribution: 'temurin'
java-version: '21'
- name: Setup Android environment
uses: android-actions/setup-android@v3
- name: Decode Keystore
uses: timheuer/base64-to-file@v1
uses: timheuer/base64-to-file@v1.2.4
id: android_keystore
with:
fileName: "android_keystore.jks"
@ -135,35 +123,37 @@ jobs:
- name: Build APK
run: |
cd ${{ github.workspace }}/V2rayNG
echo "sdk.dir=${ANDROID_HOME}" > local.properties
chmod 755 gradlew
./gradlew licenseFdroidReleaseReport
./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }}
- name: Upload arm64-v8a APK
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v4.6.2
if: ${{ success() }}
with:
name: arm64-v8a
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk
- name: Upload armeabi-v7a APK
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v4.6.2
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@v4
uses: actions/upload-artifact@v4.6.2
if: ${{ success() }}
with:
name: x86-apk
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*x86*.apk
- name: Upload AndroidLibXrayLite to release
- 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/*playstore*/release/*.apk
tag: ${{ github.event.inputs.release_tag }}
file_glob: true
prerelease: true

View file

@ -13,4 +13,4 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Validate Fastlane Supply Metadata
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.0.0
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0

63
.gitignore vendored
View file

@ -1,7 +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/
libtun2socks.so
libhysteria2.so
*.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

12
.gitmodules vendored Normal file
View file

@ -0,0 +1,12 @@
[submodule "hysteria"]
path = hysteria
url = https://github.com/apernet/hysteria
[submodule "AndroidLibXrayLite"]
path = AndroidLibXrayLite
url = https://github.com/2dust/AndroidLibXrayLite
[submodule "badvpn"]
path = badvpn
url = https://github.com/XTLS/badvpn
[submodule "libancillary"]
path = libancillary
url = https://github.com/shadowsocks/libancillary

View file

@ -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)

1
AndroidLibXrayLite Submodule

@ -0,0 +1 @@
Subproject commit 8ad3e1ddf165d8d67e488346b2faa9153d3e33a4

View file

@ -3,16 +3,12 @@
A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-core) and [v2fly core](https://github.com/v2fly/v2ray-core)
[![API](https://img.shields.io/badge/API-21%2B-yellow.svg?style=flat)](https://developer.android.com/about/versions/lollipop)
[![Kotlin Version](https://img.shields.io/badge/Kotlin-2.1.0-blue.svg)](https://kotlinlang.org)
[![Kotlin Version](https://img.shields.io/badge/Kotlin-2.1.21-blue.svg)](https://kotlinlang.org)
[![GitHub commit activity](https://img.shields.io/github/commit-activity/m/2dust/v2rayNG)](https://github.com/2dust/v2rayNG/commits/master)
[![CodeFactor](https://www.codefactor.io/repository/github/2dust/v2rayng/badge)](https://www.codefactor.io/repository/github/2dust/v2rayng)
[![GitHub Releases](https://img.shields.io/github/downloads/2dust/v2rayNG/latest/total?logo=github)](https://github.com/2dust/v2rayNG/releases)
[![Chat on Telegram](https://img.shields.io/badge/Chat%20on-Telegram-brightgreen.svg)](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)

10
V2rayNG/.gitignore vendored
View file

@ -1,10 +0,0 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
*.apk
signing.properties
*.aar

View file

@ -1,2 +0,0 @@
/build
/google-services.json

View file

@ -12,8 +12,8 @@ android {
applicationId = "com.v2ray.ang"
minSdk = 21
targetSdk = 35
versionCode = 623
versionName = "1.9.27"
versionCode = 658
versionName = "1.10.8"
multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
@ -82,8 +82,9 @@ android {
val isFdroid = variant.productFlavors.any { it.name == "fdroid" }
if (isFdroid) {
val versionCodes =
mapOf("armeabi-v7a" to 2, "arm64-v8a" to 1, "x86" to 4, "x86_64" to 3, "universal" to 0
)
mapOf(
"armeabi-v7a" to 2, "arm64-v8a" to 1, "x86" to 4, "x86_64" to 3, "universal" to 0
)
variant.outputs
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
@ -144,10 +145,11 @@ dependencies {
implementation(libs.androidx.constraintlayout)
implementation(libs.preference.ktx)
implementation(libs.recyclerview)
implementation(libs.androidx.swiperefreshlayout)
// UI Libraries
implementation(libs.material)
implementation(libs.toastcompat)
implementation(libs.toasty)
implementation(libs.editorkit)
implementation(libs.flexbox)
@ -156,9 +158,8 @@ dependencies {
implementation(libs.gson)
// Reactive and Utility Libraries
implementation(libs.rxjava)
implementation(libs.rxandroid)
implementation(libs.rxpermissions)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
// Language and Processing Libraries
implementation(libs.language.base)

Binary file not shown.

View file

@ -35,7 +35,6 @@
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- <useapplications-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission
@ -52,6 +51,7 @@
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"
@ -144,6 +144,9 @@
<data android:host="install-sub" />
</intent-filter>
</activity>
<activity
android:name=".ui.CheckUpdateActivity"
android:exported="false" />
<activity
android:name=".ui.AboutActivity"
android:exported="false" />
@ -212,7 +215,8 @@
android:icon="@drawable/ic_stat_name"
android:label="@string/app_tile_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
android:process=":RunSoLibV2RayDaemon">
android:process=":RunSoLibV2RayDaemon"
tools:targetApi="24">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>

View file

@ -20,13 +20,6 @@
"port": "443",
"network": "udp"
},
{
"remarks": "阻断广告",
"outboundTag": "block",
"domain": [
"geosite:category-ads-all"
]
},
{
"remarks": "绕过局域网IP",
"outboundTag": "direct",

View file

@ -5,13 +5,6 @@
"port": "443",
"network": "udp"
},
{
"remarks": "阻断广告",
"outboundTag": "block",
"domain": [
"geosite:category-ads-all"
]
},
{
"remarks": "绕过局域网IP",
"outboundTag": "direct",

View file

@ -13,13 +13,6 @@
"port": "443",
"network": "udp"
},
{
"remarks": "阻断广告",
"outboundTag": "block",
"domain": [
"geosite:category-ads-all"
]
},
{
"remarks": "绕过局域网IP",
"outboundTag": "direct",
@ -99,10 +92,5 @@
"domain": [
"geosite:cn"
]
},
{
"remarks": "最终代理",
"port": "0-65535",
"outboundTag": "proxy"
}
]

View file

@ -5,13 +5,6 @@
"port": "443",
"network": "udp"
},
{
"remarks": "Block ads and trackers",
"outboundTag": "block",
"domain": [
"geosite:category-ads-all"
]
},
{
"remarks": "Direct LAN IP",
"outboundTag": "direct",
@ -40,10 +33,5 @@
"ip": [
"geoip:ir"
]
},
{
"remarks": "Final Agent",
"port": "0-65535",
"outboundTag": "proxy"
}
]

View file

@ -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

View file

@ -97,7 +97,7 @@
}
],
"routing": {
"domainStrategy": "IPIfNonMatch",
"domainStrategy": "AsIs",
"rules": []
},
"dns": {

View file

@ -1,22 +1,22 @@
package com.v2ray.ang
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
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
import com.v2ray.ang.util.Utils
class AngApplication : MultiDexApplication() {
companion object {
//const val PREF_LAST_VERSION = "pref_last_version"
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
@ -26,23 +26,22 @@ class AngApplication : MultiDexApplication() {
.setDefaultProcessName("${ANG_PACKAGE}:bg")
.build()
/**
* Initializes the application.
*/
override fun onCreate() {
super.onCreate()
// LeakCanary.install(this)
// val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
// firstRun = defaultSharedPreferences.getInt(PREF_LAST_VERSION, 0) != BuildConfig.VERSION_CODE
// if (firstRun)
// defaultSharedPreferences.edit().putInt(PREF_LAST_VERSION, BuildConfig.VERSION_CODE).apply()
MMKV.initialize(this)
Utils.setNightMode()
SettingsManager.setNightMode()
// Initialize WorkManager with the custom configuration
WorkManager.initialize(this, workManagerConfiguration)
SettingsManager.initRoutingRulesets(this)
}
es.dmoral.toasty.Toasty.Config.getInstance()
.setGravity(android.view.Gravity.BOTTOM, 0, 200)
.apply()
}
}

View file

@ -5,6 +5,7 @@ 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"
@ -12,7 +13,6 @@ object AppConfig {
/** Legacy configuration keys. */
const val ANG_CONFIG = "ang_config"
const val PREF_INAPP_BUY_IS_PREMIUM = "pref_inapp_buy_is_premium"
/** Preferences mapped to MMKV storage. */
const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
@ -25,6 +25,8 @@ object AppConfig {
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_ROUTING_DOMAIN_STRATEGY = "pref_routing_domain_strategy"
const val PREF_ROUTING_RULESET = "pref_routing_ruleset"
const val PREF_MUX_ENABLED = "pref_mux_enabled"
@ -42,6 +44,7 @@ object AppConfig {
const val PREF_SPEED_ENABLED = "pref_speed_enabled"
const val PREF_CONFIRM_REMOVE = "pref_confirm_remove"
const val PREF_START_SCAN_IMMEDIATE = "pref_start_scan_immediate"
const val PREF_DOUBLE_COLUMN_DISPLAY = "pref_double_column_display"
const val PREF_LANGUAGE = "pref_language"
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
@ -53,15 +56,18 @@ object AppConfig {
const val PREF_DNS_HOSTS = "pref_dns_hosts"
const val PREF_DELAY_TEST_URL = "pref_delay_test_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"
/** Cache keys. */
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
const val CACHE_KEYWORD_FILTER = "cache_keyword_filter"
/** Protocol identifiers. */
const val PROTOCOL_FREEDOM: String = "freedom"
const val PROTOCOL_FREEDOM = "freedom"
/** Broadcast actions. */
const val BROADCAST_ACTION_SERVICE = "com.v2ray.ang.action.service"
@ -86,19 +92,20 @@ object AppConfig {
const val DOWNLINK = "downlink"
/** URLs for various resources. */
const val androidpackagenamelistUrl =
"https://raw.githubusercontent.com/2dust/androidpackagenamelist/master/proxy.txt"
const val v2rayCustomRoutingListUrl =
"https://raw.githubusercontent.com/2dust/v2rayCustomRoutingList/master/"
const val v2rayNGUrl = "https://github.com/2dust/v2rayNG"
const val v2rayNGIssues = "$v2rayNGUrl/issues"
const val v2rayNGWikiMode = "$v2rayNGUrl/wiki/Mode"
const val v2rayNGPrivacyPolicy = "https://raw.githubusercontent.com/2dust/v2rayNG/master/CR.md"
const val PromotionUrl = "aHR0cHM6Ly85LjIzNDQ1Ni54eXovYWJjLmh0bWw="
const val GeoUrl = "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/"
const val TgChannelUrl = "https://t.me/github_2dust"
const val DelayTestUrl = "https://www.gstatic.com/generate_204"
const val DelayTestUrl2 = "https://www.google.com/generate_204"
const val GITHUB_URL = "https://github.com"
const val GITHUB_RAW_URL = "https://raw.githubusercontent.com"
const val GITHUB_DOWNLOAD_URL = "$GITHUB_URL/%s/releases/latest/download"
const val ANDROID_PACKAGE_NAME_LIST_URL = "$GITHUB_RAW_URL/2dust/androidpackagenamelist/master/proxy.txt"
const val APP_URL = "$GITHUB_URL/2dust/v2rayNG"
const val APP_API_URL = "https://api.github.com/repos/2dust/v2rayNG/releases"
const val APP_ISSUES_URL = "$APP_URL/issues"
const val APP_WIKI_MODE = "$APP_URL/wiki/Mode"
const val APP_PRIVACY_POLICY = "$GITHUB_RAW_URL/2dust/v2rayNG/master/CR.md"
const val APP_PROMOTION_URL = "aHR0cHM6Ly85LjIzNDQ1Ni54eXovYWJjLmh0bWw="
const val TG_CHANNEL_URL = "https://t.me/github_2dust"
const val DELAY_TEST_URL = "https://www.gstatic.com/generate_204"
const val DELAY_TEST_URL2 = "https://www.google.com/generate_204"
const val IP_API_URL = "https://speed.cloudflare.com/meta"
/** DNS server addresses. */
const val DNS_PROXY = "1.1.1.1"
@ -163,19 +170,13 @@ object AppConfig {
// Android Private DNS constants
const val DNS_DNSPOD_DOMAIN = "dot.pub"
const val DNS_ALIDNS_DOMAIN = "dns.alidns.com"
const val DNS_CLOUDFLARE_DOMAIN = "one.one.one.one"
const val DNS_CLOUDFLARE_ONE_DOMAIN = "one.one.one.one"
const val DNS_CLOUDFLARE_DNS_COM_DOMAIN = "dns.cloudflare.com"
const val DNS_CLOUDFLARE_DNS_DOMAIN = "cloudflare-dns.com"
const val DNS_GOOGLE_DOMAIN = "dns.google"
const val DNS_QUAD9_DOMAIN = "dns.quad9.net"
const val DNS_YANDEX_DOMAIN = "common.dot.dns.yandex.net"
val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
val DNS_CLOUDFLARE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
val DNS_DNSPOD_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
val DNS_GOOGLE_ADDRESSES = arrayListOf("8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844")
val DNS_QUAD9_ADDRESSES = arrayListOf("9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9")
val DNS_YANDEX_ADDRESSES = arrayListOf("77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff")
const val DEFAULT_PORT = 443
const val DEFAULT_SECURITY = "auto"
const val DEFAULT_LEVEL = 8
@ -184,4 +185,64 @@ object AppConfig {
const val REALITY = "reality"
const val HEADER_TYPE_HTTP = "http"
val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
val DNS_CLOUDFLARE_ONE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5")
val DNS_CLOUDFLARE_DNS_ADDRESSES = arrayListOf("104.16.248.249", "104.16.249.249", "2606:4700::6810:f8f9", "2606:4700::6810:f9f9")
val DNS_DNSPOD_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
val DNS_GOOGLE_ADDRESSES = arrayListOf("8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844")
val DNS_QUAD9_ADDRESSES = arrayListOf("9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9")
val DNS_YANDEX_ADDRESSES = arrayListOf("77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff")
//minimum list https://serverfault.com/a/304791
val ROUTED_IP_LIST = arrayListOf(
"0.0.0.0/5",
"8.0.0.0/7",
"11.0.0.0/8",
"12.0.0.0/6",
"16.0.0.0/4",
"32.0.0.0/3",
"64.0.0.0/2",
"128.0.0.0/3",
"160.0.0.0/5",
"168.0.0.0/6",
"172.0.0.0/12",
"172.32.0.0/11",
"172.64.0.0/10",
"172.128.0.0/9",
"173.0.0.0/8",
"174.0.0.0/7",
"176.0.0.0/4",
"192.0.0.0/9",
"192.128.0.0/11",
"192.160.0.0/13",
"192.169.0.0/16",
"192.170.0.0/15",
"192.172.0.0/14",
"192.176.0.0/12",
"192.192.0.0/10",
"193.0.0.0/8",
"194.0.0.0/7",
"196.0.0.0/6",
"200.0.0.0/5",
"208.0.0.0/4",
"240.0.0.0/4"
)
val PRIVATE_IP_LIST = arrayListOf(
"0.0.0.0/8",
"10.0.0.0/8",
"127.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16",
"224.0.0.0/4"
)
val GEO_FILES_SOURCES = arrayListOf(
"Loyalsoldier/v2ray-rules-dat",
"runetfreedom/russia-v2ray-rules-dat",
"Chocolate4U/Iran-v2ray-rules"
)
}

View file

@ -4,5 +4,6 @@ data class AssetUrlItem(
var remarks: String = "",
var url: String = "",
val addedTime: Long = System.currentTimeMillis(),
var lastUpdated: Long = -1
var lastUpdated: Long = -1,
var locked: Boolean? = false,
)

View file

@ -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
)

View file

@ -4,6 +4,6 @@ data class ConfigResult(
var status: Boolean,
var guid: String? = null,
var content: String = "",
var domainPort: String? = null,
var socksPort: Int? = null,
)

View file

@ -11,7 +11,8 @@ enum class EConfigType(val value: Int, val protocolScheme: String) {
VLESS(5, AppConfig.VLESS),
TROJAN(6, AppConfig.TROJAN),
WIREGUARD(7, AppConfig.WIREGUARD),
// TUIC(8, AppConfig.TUIC),
// TUIC(8, AppConfig.TUIC),
HYSTERIA2(9, AppConfig.HYSTERIA2),
HTTP(10, AppConfig.HTTP);

View file

@ -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
)
}

View file

@ -9,6 +9,7 @@ data class Hysteria2Bean(
val http: Socks5Bean? = null,
val tls: TlsBean? = null,
val transport: TransportBean? = null,
val bandwidth: BandwidthBean? = null,
) {
data class ObfsBean(
val type: String?,
@ -37,4 +38,9 @@ data class Hysteria2Bean(
val hopInterval: String?,
)
}
data class BandwidthBean(
val down: String?,
val up: String?,
)
}

View file

@ -0,0 +1,12 @@
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
)

View file

@ -8,6 +8,7 @@ enum class Language(val code: String) {
VIETNAMESE("vi"),
RUSSIAN("ru"),
PERSIAN("fa"),
ARABIC("ar"),
BANGLA("bn"),
BAKHTIARI("bqi-rIR");

View file

@ -8,6 +8,7 @@ enum class NetworkType(val type: String) {
XHTTP("xhttp"),
HTTP("http"),
H2("h2"),
//QUIC("quic"),
GRPC("grpc");

View file

@ -1,5 +1,7 @@
package com.v2ray.ang.dto
import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.AppConfig.PORT_SOCKS
import com.v2ray.ang.AppConfig.TAG_BLOCKED
import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.TAG_PROXY
@ -53,6 +55,8 @@ data class ProfileItem(
var portHopping: String? = null,
var portHoppingInterval: String? = null,
var pinSHA256: String? = null,
var bandwidthDown: String? = null,
var bandwidthUp: String? = null,
) {
companion object {
@ -66,6 +70,9 @@ data class ProfileItem(
}
fun getServerAddressAndPort(): String {
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
return "$LOOPBACK:$PORT_SOCKS"
}
return Utils.getIpv6Address(server) + ":" + serverPort
}

View file

@ -1,9 +0,0 @@
package com.v2ray.ang.dto
data class ProfileLiteItem(
val configType: EConfigType,
var subscriptionId: String = "",
var remarks: String = "",
var server: String?,
var serverPort: Int?,
)

View file

@ -11,5 +11,6 @@ data class SubscriptionItem(
var prevProfile: String? = null,
var nextProfile: String? = null,
var filter: String? = null,
var allowInsecureUrl: Boolean = false,
)

View file

@ -1,26 +1,17 @@
package com.v2ray.ang.dto
import android.text.TextUtils
import com.google.gson.GsonBuilder
import com.google.gson.JsonPrimitive
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import com.google.gson.annotations.SerializedName
import com.google.gson.reflect.TypeToken
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean.*
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean.VnextBean.*
import com.v2ray.ang.util.Utils
import java.lang.reflect.Type
data class V2rayConfig(
var remarks: String? = null,
var stats: Any? = null,
val log: LogBean,
var policy: PolicyBean?,
var policy: PolicyBean? = null,
val inbounds: ArrayList<InboundBean>,
var outbounds: ArrayList<OutboundBean>,
var dns: DnsBean,
var dns: DnsBean? = null,
val routing: RoutingBean,
val api: Any? = null,
val transport: Any? = null,
@ -32,9 +23,9 @@ data class V2rayConfig(
) {
data class LogBean(
val access: String,
val error: String,
var loglevel: String?,
val access: String? = null,
val error: String? = null,
var loglevel: String? = null,
val dnsLog: Boolean? = null
)
@ -44,7 +35,7 @@ data class V2rayConfig(
var protocol: String,
var listen: String? = null,
val settings: Any? = null,
val sniffing: SniffingBean?,
val sniffing: SniffingBean? = null,
val streamSettings: Any? = null,
val allocate: Any? = null
) {
@ -75,50 +66,6 @@ data class V2rayConfig(
val sendThrough: String? = null,
var mux: MuxBean? = MuxBean(false)
) {
companion object {
fun create(configType: EConfigType): OutboundBean? {
return when (configType) {
EConfigType.VMESS,
EConfigType.VLESS ->
return OutboundBean(
protocol = configType.name.lowercase(),
settings = OutSettingsBean(
vnext = listOf(
VnextBean(
users = listOf(UsersBean())
)
)
),
streamSettings = StreamSettingsBean()
)
EConfigType.SHADOWSOCKS,
EConfigType.SOCKS,
EConfigType.HTTP,
EConfigType.TROJAN,
EConfigType.HYSTERIA2 ->
return OutboundBean(
protocol = configType.name.lowercase(),
settings = OutSettingsBean(
servers = listOf(ServersBean())
),
streamSettings = StreamSettingsBean()
)
EConfigType.WIREGUARD ->
return OutboundBean(
protocol = configType.name.lowercase(),
settings = OutSettingsBean(
secretKey = "",
peers = listOf(WireGuardBean())
)
)
EConfigType.CUSTOM -> null
}
}
}
data class OutSettingsBean(
var vnext: List<VnextBean>? = null,
var fragment: FragmentBean? = null,
@ -195,7 +142,7 @@ data class V2rayConfig(
data class WireGuardBean(
var publicKey: String = "",
var preSharedKey: String = "",
var preSharedKey: String? = null,
var endpoint: String = ""
)
}
@ -257,7 +204,10 @@ data class V2rayConfig(
var header: HeaderBean = HeaderBean(),
var seed: String? = null
) {
data class HeaderBean(var type: String = "none")
data class HeaderBean(
var type: String = "none",
var domain: String? = null
)
}
data class WsSettingsBean(
@ -294,7 +244,8 @@ data class V2rayConfig(
var tcpFastOpen: Boolean? = null,
var tproxy: String? = null,
var mark: Int? = null,
var dialerProxy: String? = null
var dialerProxy: String? = null,
var domainStrategy: String? = null
)
data class TlsSettingsBean(
@ -344,141 +295,13 @@ data class V2rayConfig(
)
}
fun populateTransportSettings(
transport: String,
headerType: String?,
host: String?,
path: String?,
seed: String?,
quicSecurity: String?,
key: String?,
mode: String?,
serviceName: String?,
authority: String?
): String? {
var sni: String? = null
network = if (transport.isEmpty()) NetworkType.TCP.type else transport
when (network) {
NetworkType.TCP.type -> {
val tcpSetting = TcpSettingsBean()
if (headerType == AppConfig.HEADER_TYPE_HTTP) {
tcpSetting.header.type = AppConfig.HEADER_TYPE_HTTP
if (!TextUtils.isEmpty(host) || !TextUtils.isEmpty(path)) {
val requestObj = TcpSettingsBean.HeaderBean.RequestBean()
requestObj.headers.Host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
requestObj.path = path.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
tcpSetting.header.request = requestObj
sni = requestObj.headers.Host?.getOrNull(0)
}
} else {
tcpSetting.header.type = "none"
sni = host
}
tcpSettings = tcpSetting
}
NetworkType.KCP.type -> {
val kcpsetting = KcpSettingsBean()
kcpsetting.header.type = headerType ?: "none"
if (seed.isNullOrEmpty()) {
kcpsetting.seed = null
} else {
kcpsetting.seed = seed
}
kcpSettings = kcpsetting
}
NetworkType.WS.type -> {
val wssetting = WsSettingsBean()
wssetting.headers.Host = host.orEmpty()
sni = host
wssetting.path = path ?: "/"
wsSettings = wssetting
}
NetworkType.HTTP_UPGRADE.type -> {
val httpupgradeSetting = HttpupgradeSettingsBean()
httpupgradeSetting.host = host.orEmpty()
sni = host
httpupgradeSetting.path = path ?: "/"
httpupgradeSettings = httpupgradeSetting
}
NetworkType.XHTTP.type -> {
val xhttpSetting = XhttpSettingsBean()
xhttpSetting.host = host.orEmpty()
sni = host
xhttpSetting.path = path ?: "/"
xhttpSettings = xhttpSetting
}
NetworkType.H2.type, NetworkType.HTTP.type -> {
network = NetworkType.H2.type
val h2Setting = HttpSettingsBean()
h2Setting.host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
sni = h2Setting.host.getOrNull(0)
h2Setting.path = path ?: "/"
httpSettings = h2Setting
}
// "quic" -> {
// val quicsetting = QuicSettingBean()
// quicsetting.security = quicSecurity ?: "none"
// quicsetting.key = key.orEmpty()
// quicsetting.header.type = headerType ?: "none"
// quicSettings = quicsetting
// }
NetworkType.GRPC.type -> {
val grpcSetting = GrpcSettingsBean()
grpcSetting.multiMode = mode == "multi"
grpcSetting.serviceName = serviceName.orEmpty()
grpcSetting.authority = authority.orEmpty()
grpcSetting.idle_timeout = 60
grpcSetting.health_check_timeout = 20
sni = authority
grpcSettings = grpcSetting
}
}
return sni
}
fun populateTlsSettings(
streamSecurity: String,
allowInsecure: Boolean,
sni: String?,
fingerprint: String?,
alpns: String?,
publicKey: String?,
shortId: String?,
spiderX: String?
) {
security = if (streamSecurity.isEmpty()) null else streamSecurity
if (security == null) return
val tlsSetting = TlsSettingsBean(
allowInsecure = allowInsecure,
serverName = if (sni.isNullOrEmpty()) null else sni,
fingerprint = if (fingerprint.isNullOrEmpty()) null else fingerprint,
alpn = if (alpns.isNullOrEmpty()) null else alpns.split(",").map { it.trim() }.filter { it.isNotEmpty() },
publicKey = if (publicKey.isNullOrEmpty()) null else publicKey,
shortId = if (shortId.isNullOrEmpty()) null else shortId,
spiderX = if (spiderX.isNullOrEmpty()) null else spiderX,
)
if (security == AppConfig.TLS) {
tlsSettings = tlsSetting
realitySettings = null
} else if (security == AppConfig.REALITY) {
tlsSettings = null
realitySettings = tlsSetting
}
}
}
data class MuxBean(
var enabled: Boolean,
var concurrency: Int = 8,
var xudpConcurrency: Int = 8,
var xudpProxyUDP443: String = "",
var concurrency: Int? = null,
var xudpConcurrency: Int? = null,
var xudpProxyUDP443: String? = null,
)
fun getServerAddress(): String? {
@ -637,6 +460,18 @@ data class V2rayConfig(
}
return null
}
fun ensureSockopt(): V2rayConfig.OutboundBean.StreamSettingsBean.SockoptBean {
val stream = streamSettings ?: V2rayConfig.OutboundBean.StreamSettingsBean().also {
streamSettings = it
}
val sockopt = stream.sockopt ?: V2rayConfig.OutboundBean.StreamSettingsBean.SockoptBean().also {
stream.sockopt = it
}
return sockopt
}
}
data class DnsBean(
@ -713,15 +548,9 @@ data class V2rayConfig(
return null
}
fun toPrettyPrinting(): String {
return GsonBuilder()
.setPrettyPrinting()
.disableHtmlEscaping()
.registerTypeAdapter( // custom serialiser is needed here since JSON by default parse number as Double, core will fail to start
object : TypeToken<Double>() {}.type,
JsonSerializer { src: Double?, _: Type?, _: JsonSerializationContext? -> JsonPrimitive(src?.toInt()) }
)
.create()
.toJson(this)
fun getAllProxyOutbound(): List<OutboundBean> {
return outbounds.filter { outbound ->
EConfigType.entries.any { it.name.equals(outbound.protocol, ignoreCase = true) }
}
}
}

View file

@ -0,0 +1,39 @@
package com.v2ray.ang.dto
/**
* VPN interface address configuration enum class
* Defines predefined IPv4 and IPv6 address pairs for VPN TUN interface configuration.
* Each option provides client and router addresses to establish point-to-point VPN tunnels.
*/
enum class VpnInterfaceAddressConfig(
val displayName: String,
val ipv4Client: String,
val ipv4Router: String,
val ipv6Client: String,
val ipv6Router: String
) {
OPTION_1("10.10.14.x", "10.10.14.1", "10.10.14.2", "fc00::10:10:14:1", "fc00::10:10:14:2"),
OPTION_2("10.1.0.x", "10.1.0.1", "10.1.0.2", "fc00::10:1:0:1", "fc00::10:1:0:2"),
OPTION_3("10.0.0.x", "10.0.0.1", "10.0.0.2", "fc00::10:0:0:1", "fc00::10:0:0:2"),
OPTION_4("172.31.0.x", "172.31.0.1", "172.31.0.2", "fc00::172:31:0:1", "fc00::172:31:0:2"),
OPTION_5("172.20.0.x", "172.20.0.1", "172.20.0.2", "fc00::172:20:0:1", "fc00::172:20:0:2"),
OPTION_6("172.16.0.x", "172.16.0.1", "172.16.0.2", "fc00::172:16:0:1", "fc00::172:16:0:2"),
OPTION_7("192.168.100.x", "192.168.100.1", "192.168.100.2", "fc00::192:168:100:1", "fc00::192:168:100:2");
companion object {
/**
* Retrieves the VPN interface address configuration based on the specified index.
*
* @param index The configuration index (0-based) corresponding to user selection
* @return The VpnInterfaceAddressConfig instance at the specified index,
* or OPTION_1 (default) if the index is out of bounds
*/
fun getConfigByIndex(index: Int): VpnInterfaceAddressConfig {
return if (index in values().indices) {
values()[index]
} else {
OPTION_1 // Default to the first configuration
}
}
}
}

View file

@ -8,7 +8,7 @@ import android.os.Build
import android.os.Bundle
import android.widget.Toast
import com.v2ray.ang.AngApplication
import me.drakeet.support.toast.ToastCompat
import es.dmoral.toasty.Toasty
import org.json.JSONObject
import java.io.Serializable
import java.net.URI
@ -17,18 +17,75 @@ import java.net.URLConnection
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) {
ToastCompat.makeText(this, message, Toast.LENGTH_SHORT).show()
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) {
ToastCompat.makeText(this, message, Toast.LENGTH_SHORT).show()
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()
}
/**
* Puts a key-value pair into the JSONObject.
*
* @param pair The key-value pair to put.
*/
fun JSONObject.putOpt(pair: Pair<String, Any?>) {
put(pair.first, pair.second)
}
/**
* Puts multiple key-value pairs into the JSONObject.
*
* @param pairs The map of key-value pairs to put.
*/
fun JSONObject.putOpt(pairs: Map<String, Any?>) {
pairs.forEach { put(it.key, it.value) }
}
@ -36,8 +93,18 @@ fun JSONObject.putOpt(pairs: Map<String, Any?>) {
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()
@ -59,10 +126,27 @@ val URLConnection.responseLength: Long
val URI.idnHost: String
get() = host?.replace("[", "")?.replace("]", "").orEmpty()
fun String.removeWhiteSpace(): String = replace("\\s+".toRegex(), "")
/**
* Removes all whitespace from the string.
*
* @return The string without whitespace.
*/
fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
/**
* 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) {
@ -85,14 +169,44 @@ fun Context.listenForPackageChanges(onetime: Boolean = true, callback: () -> Uni
}
}
/**
* 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
}
fun CharSequence?.isNotNullEmpty(): Boolean = (this != null && this.isNotEmpty())
/**
* 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 != null && this.isNotEmpty()
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()
}

View file

@ -6,6 +6,12 @@ import com.v2ray.ang.dto.V2rayConfig
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)

View file

@ -4,32 +4,55 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.NetworkType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.Utils
import java.net.URI
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(
"?" + dicQuery.toList().joinToString(
separator = "&",
transform = { it.first + "=" + Utils.urlEncode(it.second) }))
transform = { it.first + "=" + Utils.urlEncode(it.second) })
else ""
val url = String.format(
"%s@%s:%s",
Utils.urlEncode(userInfo ?: ""),
Utils.getIpv6Address(config.server),
Utils.getIpv6Address(HttpUtil.toIdnDomain(config.server.orEmpty())),
config.serverPort
)
return "${url}${query}#${Utils.urlEncode(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.urlDecode(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
* @param allowInsecure whether to allow insecure connections
*/
fun getItemFormQuery(config: ProfileItem, queryParam: Map<String, String>, allowInsecure: Boolean) {
config.network = queryParam["type"] ?: NetworkType.TCP.type
config.headerType = queryParam["headerType"]
@ -63,6 +86,12 @@ open class FmtBase {
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()
@ -93,7 +122,7 @@ open class FmtBase {
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
}
NetworkType.XHTTP -> {
NetworkType.XHTTP -> {
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
config.xhttpMode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() }
@ -122,4 +151,20 @@ open class FmtBase {
return dicQuery
}
fun getServerAddress(profileItem: ProfileItem): String {
if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
return profileItem.server.orEmpty()
}
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
return domain
}
//Resolve and replace domain
val resolvedIps = HttpUtil.resolveHostToIP(domain, MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
if (resolvedIps.isNullOrEmpty()) {
return domain
}
return resolvedIps.first()
}
}

View file

@ -4,14 +4,20 @@ import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.isNotNullEmpty
import kotlin.text.orEmpty
import com.v2ray.ang.handler.V2rayConfigManager
object HttpFmt : FmtBase() {
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = OutboundBean.create(EConfigType.HTTP)
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HTTP)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = profileItem.server.orEmpty()
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
if (profileItem.username.isNotNullEmpty()) {
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
@ -23,6 +29,4 @@ object HttpFmt : FmtBase() {
return outboundBean
}
}

View file

@ -9,16 +9,23 @@ import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
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? {
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.HYSTERIA2)
val uri = URI(Utils.fixIllegalUrl(str))
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
config.server = uri.idnHost
config.serverPort = uri.port.toString()
config.password = uri.userInfo
@ -45,6 +52,12 @@ object Hysteria2Fmt : FmtBase() {
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>()
@ -67,6 +80,13 @@ object Hysteria2Fmt : FmtBase() {
return toUri(config, config.password, dicQuery)
}
/**
* Converts a ProfileItem object to a Hysteria2Bean object.
*
* @param config the ProfileItem object to convert
* @param socksPort the port number for the socks5 proxy
* @return the converted Hysteria2Bean object, or null if conversion fails
*/
fun toNativeConfig(config: ProfileItem, socksPort: Int): Hysteria2Bean? {
val obfs = if (config.obfsPassword.isNullOrEmpty()) null else
@ -85,6 +105,12 @@ object Hysteria2Fmt : FmtBase() {
)
)
val bandwidth = if (config.bandwidthDown.isNullOrEmpty() || config.bandwidthUp.isNullOrEmpty()) null else
Hysteria2Bean.BandwidthBean(
down = config.bandwidthDown,
up = config.bandwidthUp,
)
val server =
if (config.portHopping.isNullOrEmpty())
config.getServerAddressAndPort()
@ -96,6 +122,7 @@ object Hysteria2Fmt : FmtBase() {
auth = config.password,
obfs = obfs,
transport = transport,
bandwidth = bandwidth,
socks5 = Hysteria2Bean.Socks5Bean(
listen = "$LOOPBACK:${socksPort}",
),
@ -111,10 +138,14 @@ object Hysteria2Fmt : FmtBase() {
return bean
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = OutboundBean.create(EConfigType.HYSTERIA2)
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.HYSTERIA2)
return outboundBean
}
}

View file

@ -1,18 +1,33 @@
package com.v2ray.ang.fmt
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.NetworkType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
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)
@ -21,7 +36,7 @@ object ShadowsocksFmt : FmtBase() {
if (uri.port <= 0) return null
if (uri.userInfo.isNullOrEmpty()) return null
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
config.server = uri.idnHost
config.serverPort = uri.port.toString()
@ -55,6 +70,12 @@ object ShadowsocksFmt : FmtBase() {
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, "")
@ -64,7 +85,7 @@ object ShadowsocksFmt : FmtBase() {
config.remarks =
Utils.urlDecode(result.substring(indexSplit + 1, result.length))
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
}
result = result.substring(0, indexSplit)
@ -92,48 +113,42 @@ object ShadowsocksFmt : FmtBase() {
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), null)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = OutboundBean.create(EConfigType.SHADOWSOCKS)
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SHADOWSOCKS)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = profileItem.server.orEmpty()
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.password = profileItem.password
server.method = profileItem.method
}
val sni = outboundBean?.streamSettings?.populateTransportSettings(
profileItem.network.orEmpty(),
profileItem.headerType,
profileItem.host,
profileItem.path,
profileItem.seed,
profileItem.quicSecurity,
profileItem.quicKey,
profileItem.mode,
profileItem.serviceName,
profileItem.authority,
)
val sni = outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.populateTlsSettings(
profileItem.security.orEmpty(),
profileItem.insecure == true,
if (profileItem.sni.isNullOrEmpty()) sni else profileItem.sni,
profileItem.fingerPrint,
profileItem.alpn,
profileItem.publicKey,
profileItem.shortId,
profileItem.spiderX,
)
outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}

View file

@ -5,11 +5,17 @@ import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
import kotlin.text.orEmpty
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)
@ -17,7 +23,7 @@ object SocksFmt : FmtBase() {
if (uri.idnHost.isEmpty()) return null
if (uri.port <= 0) return null
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
config.server = uri.idnHost
config.serverPort = uri.port.toString()
@ -32,6 +38,12 @@ object SocksFmt : FmtBase() {
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())
@ -42,11 +54,17 @@ object SocksFmt : FmtBase() {
return toUri(config, Utils.encode(pw), null)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = OutboundBean.create(EConfigType.SOCKS)
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.SOCKS)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = profileItem.server.orEmpty()
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
if (profileItem.username.isNotNullEmpty()) {
val socksUsersBean = OutboundBean.OutSettingsBean.ServersBean.SocksUsersBean()
@ -58,5 +76,4 @@ object SocksFmt : FmtBase() {
return outboundBean
}
}

View file

@ -7,17 +7,23 @@ import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
import kotlin.text.orEmpty
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? {
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.TROJAN)
val uri = URI(Utils.fixIllegalUrl(str))
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
config.server = uri.idnHost
config.serverPort = uri.port.toString()
config.password = uri.userInfo
@ -36,45 +42,41 @@ object TrojanFmt : FmtBase() {
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)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = OutboundBean.create(EConfigType.TROJAN)
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.TROJAN)
outboundBean?.settings?.servers?.first()?.let { server ->
server.address = profileItem.server.orEmpty()
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.password = profileItem.password
server.flow = profileItem.flow
}
val sni = outboundBean?.streamSettings?.populateTransportSettings(
profileItem.network.orEmpty(),
profileItem.headerType,
profileItem.host,
profileItem.path,
profileItem.seed,
profileItem.quicSecurity,
profileItem.quicKey,
profileItem.mode,
profileItem.serviceName,
profileItem.authority,
)
val sni = outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.populateTlsSettings(
profileItem.security.orEmpty(),
profileItem.insecure == true,
if (profileItem.sni.isNullOrEmpty()) sni else profileItem.sni,
profileItem.fingerPrint,
profileItem.alpn,
profileItem.publicKey,
profileItem.shortId,
profileItem.spiderX,
)
outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}

View file

@ -6,12 +6,18 @@ import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
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? {
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.VLESS)
@ -20,7 +26,7 @@ object VlessFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
config.server = uri.idnHost
config.serverPort = uri.port.toString()
config.password = uri.userInfo
@ -31,6 +37,12 @@ object VlessFmt : FmtBase() {
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"
@ -38,46 +50,31 @@ object VlessFmt : FmtBase() {
return toUri(config, config.password, dicQuery)
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = OutboundBean.create(EConfigType.VLESS)
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VLESS)
outboundBean?.settings?.vnext?.first()?.let { vnext ->
vnext.address = profileItem.server.orEmpty()
vnext.address = getServerAddress(profileItem)
vnext.port = profileItem.serverPort.orEmpty().toInt()
vnext.users[0].id = profileItem.password.orEmpty()
vnext.users[0].encryption = profileItem.method
vnext.users[0].flow = profileItem.flow
}
val sni = outboundBean?.streamSettings?.populateTransportSettings(
profileItem.network.orEmpty(),
profileItem.headerType,
profileItem.host,
profileItem.path,
profileItem.seed,
profileItem.quicSecurity,
profileItem.quicKey,
profileItem.mode,
profileItem.serviceName,
profileItem.authority,
)
outboundBean?.streamSettings?.xhttpSettings?.mode = profileItem.xhttpMode
outboundBean?.streamSettings?.xhttpSettings?.extra = JsonUtil.parseString(profileItem.xhttpExtra)
val sni = outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.populateTlsSettings(
profileItem.security.orEmpty(),
profileItem.insecure == true,
if (profileItem.sni.isNullOrEmpty()) sni else profileItem.sni,
profileItem.fingerPrint,
profileItem.alpn,
profileItem.publicKey,
profileItem.shortId,
profileItem.spiderX,
)
outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}

View file

@ -11,12 +11,18 @@ import com.v2ray.ang.dto.VmessQRCode
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils
import java.net.URI
import kotlin.text.orEmpty
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)
@ -28,7 +34,7 @@ object VmessFmt : FmtBase() {
var result = str.replace(EConfigType.VMESS.protocolScheme, "")
result = Utils.decode(result)
if (TextUtils.isEmpty(result)) {
Log.d(AppConfig.ANG_PACKAGE, "R.string.toast_decoding_failed")
Log.w(AppConfig.TAG, "Toast decoding failed")
return null
}
val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java)
@ -38,7 +44,7 @@ object VmessFmt : FmtBase() {
|| TextUtils.isEmpty(vmessQRCode.id)
|| TextUtils.isEmpty(vmessQRCode.net)
) {
Log.d(AppConfig.ANG_PACKAGE, "R.string.toast_incorrect_protocol")
Log.w(AppConfig.TAG, "Toast incorrect protocol")
return null
}
@ -68,6 +74,7 @@ object VmessFmt : FmtBase() {
config.serviceName = vmessQRCode.path
config.authority = vmessQRCode.host
}
else -> {}
}
@ -80,6 +87,12 @@ object VmessFmt : FmtBase() {
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()
@ -108,6 +121,7 @@ object VmessFmt : FmtBase() {
vmessQRCode.path = config.serviceName.orEmpty()
vmessQRCode.host = config.authority.orEmpty()
}
else -> {}
}
@ -123,6 +137,12 @@ object VmessFmt : FmtBase() {
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 allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.VMESS)
@ -131,7 +151,7 @@ object VmessFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
config.server = uri.idnHost
config.serverPort = uri.port.toString()
config.password = uri.userInfo
@ -142,40 +162,29 @@ object VmessFmt : FmtBase() {
return config
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = OutboundBean.create(EConfigType.VMESS)
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.VMESS)
outboundBean?.settings?.vnext?.first()?.let { vnext ->
vnext.address = profileItem.server.orEmpty()
vnext.address = getServerAddress(profileItem)
vnext.port = profileItem.serverPort.orEmpty().toInt()
vnext.users[0].id = profileItem.password.orEmpty()
vnext.users[0].security = profileItem.method
}
val sni = outboundBean?.streamSettings?.populateTransportSettings(
profileItem.network.orEmpty(),
profileItem.headerType,
profileItem.host,
profileItem.path,
profileItem.seed,
profileItem.quicSecurity,
profileItem.quicKey,
profileItem.mode,
profileItem.serviceName,
profileItem.authority,
)
val sni = outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean?.streamSettings?.populateTlsSettings(
profileItem.security.orEmpty(),
profileItem.insecure == true,
if (profileItem.sni.isNullOrEmpty()) sni else profileItem.sni,
profileItem.fingerPrint,
profileItem.alpn,
null,
null,
null
)
outboundBean?.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}

View file

@ -6,11 +6,18 @@ import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.removeWhiteSpace
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
import kotlin.text.orEmpty
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)
@ -18,20 +25,26 @@ object WireguardFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it }
config.server = uri.idnHost
config.serverPort = uri.port.toString()
config.secretKey = uri.userInfo.orEmpty()
config.localAddress = (queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4)
config.localAddress = queryParam["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
config.publicKey = queryParam["publickey"].orEmpty()
config.preSharedKey = queryParam["presharedkey"].orEmpty()
config.preSharedKey = queryParam["presharedkey"]?.takeIf { it.isNotEmpty() }
config.mtu = Utils.parseInt(queryParam["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.reserved = (queryParam["reserved"] ?: "0,0,0")
config.reserved = queryParam["reserved"] ?: "0,0,0"
return config
}
/**
* 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)
@ -71,7 +84,7 @@ object WireguardFmt : FmtBase() {
config.localAddress = interfaceParams["address"] ?: WIREGUARD_LOCAL_ADDRESS_V4
config.mtu = Utils.parseInt(interfaceParams["mtu"] ?: AppConfig.WIREGUARD_LOCAL_MTU)
config.publicKey = peerParams["publickey"].orEmpty()
config.preSharedKey = peerParams["presharedkey"].orEmpty()
config.preSharedKey = peerParams["presharedkey"]?.takeIf { it.isNotEmpty() }
val endpoint = peerParams["endpoint"].orEmpty()
val endpointParts = endpoint.split(":", limit = 2)
if (endpointParts.size == 2) {
@ -86,37 +99,49 @@ object WireguardFmt : FmtBase() {
return config
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
* @param profileItem the ProfileItem object to convert
* @return the converted OutboundBean object, or null if conversion fails
*/
fun toOutbound(profileItem: ProfileItem): OutboundBean? {
val outboundBean = OutboundBean.create(EConfigType.WIREGUARD)
val outboundBean = V2rayConfigManager.createInitOutbound(EConfigType.WIREGUARD)
outboundBean?.settings?.let { wireguard ->
wireguard.secretKey = profileItem.secretKey
wireguard.address = (profileItem.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4).split(",")
wireguard.peers?.firstOrNull()?.let { peer ->
peer.publicKey = profileItem.publicKey.orEmpty()
peer.preSharedKey = profileItem.preSharedKey.orEmpty()
peer.preSharedKey = profileItem.preSharedKey?.takeIf { it.isNotEmpty() }
peer.endpoint = Utils.getIpv6Address(profileItem.server) + ":${profileItem.serverPort}"
}
wireguard.mtu = profileItem.mtu
wireguard.reserved = profileItem.reserved?.split(",")?.map { it.toInt() }
wireguard.reserved = profileItem.reserved?.takeIf { it.isNotBlank() }?.split(",")?.filter { it.isNotBlank() }?.map { it.trim().toInt() }
}
return outboundBean
}
/**
* 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"] = Utils.removeWhiteSpace(config.reserved).orEmpty()
dicQuery["reserved"] = config.reserved.removeWhiteSpace().orEmpty()
}
dicQuery["address"] = Utils.removeWhiteSpace(config.localAddress).orEmpty()
dicQuery["address"] = config.localAddress.removeWhiteSpace().orEmpty()
if (config.mtu != null) {
dicQuery["mtu"] = config.mtu.toString()
}
if (config.preSharedKey != null) {
dicQuery["presharedkey"] = Utils.removeWhiteSpace(config.preSharedKey).orEmpty()
dicQuery["presharedkey"] = config.preSharedKey.removeWhiteSpace().orEmpty()
}
return toUri(config, config.secretKey, dicQuery)

View file

@ -7,7 +7,9 @@ import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.HY2
import com.v2ray.ang.R
import com.v2ray.ang.dto.*
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.fmt.CustomFmt
import com.v2ray.ang.fmt.Hysteria2Fmt
import com.v2ray.ang.fmt.ShadowsocksFmt
@ -16,14 +18,314 @@ 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.QRCodeDecoder
import com.v2ray.ang.util.Utils
import java.net.URI
object AngConfigManager {
/**
* parse config form qrcode or...
* 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) {
Log.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()
} catch (e: Exception) {
Log.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) {
Log.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 = V2rayConfigManager.getV2rayConfig(context, guid)
if (result.status) {
val config = MmkvManager.decodeServerConfig(guid)
if (config?.configType == EConfigType.HYSTERIA2) {
val socksPort = Utils.findFreePort(listOf(100 + SettingsManager.getSocksPort(), 0))
val hy2Config = Hysteria2Fmt.toNativeConfig(config, socksPort)
Utils.setClipboard(context, JsonUtil.toJsonPretty(hy2Config) + "\n" + result.content)
return 0
}
Utils.setClipboard(context, result.content)
} else {
return -1
}
} catch (e: Exception) {
Log.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.CUSTOM -> ""
EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toUri(config)
EConfigType.SOCKS -> SocksFmt.toUri(config)
EConfigType.HTTP -> ""
EConfigType.VLESS -> VlessFmt.toUri(config)
EConfigType.TROJAN -> TrojanFmt.toUri(config)
EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
}
} catch (e: Exception) {
Log.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)
}
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) {
Log.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
}
val removedSelectedServer =
if (!TextUtils.isEmpty(subid) && !append) {
MmkvManager.decodeServerConfig(
MmkvManager.getSelectServer().orEmpty()
)?.let {
if (it.subscriptionId == subid) {
return@let it
}
return@let null
}
} else {
null
}
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val subItem = MmkvManager.decodeSubscription(subid)
var count = 0
servers.lines()
.distinct()
.reversed()
.forEach {
val resId = parseConfig(it, subid, subItem, removedSelectedServer)
if (resId == 0) {
count++
}
}
return count
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse batch config", e)
}
return 0
}
/**
* Parses a custom configuration server.
*
* @param server The server string.
* @param subid The subscription ID.
* @return The number of configurations parsed.
*/
private fun parseCustomConfigServer(server: String?, subid: String): 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)
if (serverList.isNotEmpty()) {
var count = 0
for (srv in serverList.reversed()) {
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
config.subscriptionId = subid
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
count += 1
}
return count
}
} catch (e: Exception) {
Log.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
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
return 1
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
}
return 0
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
try {
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
return 1
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
}
return 0
} else {
return 0
}
}
/**
* Parses the configuration from a QR code or string.
*
* @param str The configuration string.
* @param subid The subscription ID.
* @param subItem The subscription item.
* @param removedSelectedServer The removed selected server.
* @return The result code.
*/
private fun parseConfig(
str: String?,
@ -72,256 +374,17 @@ object AngConfigManager {
MmkvManager.setSelectServer(guid)
}
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Failed to parse config", e)
return -1
}
return 0
}
/**
* share config
* Updates the configuration via all subscriptions.
*
* @return The number of configurations updated.
*/
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.CUSTOM -> ""
EConfigType.SHADOWSOCKS -> ShadowsocksFmt.toUri(config)
EConfigType.SOCKS -> SocksFmt.toUri(config)
EConfigType.HTTP -> ""
EConfigType.VLESS -> VlessFmt.toUri(config)
EConfigType.TROJAN -> TrojanFmt.toUri(config)
EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
}
} catch (e: Exception) {
e.printStackTrace()
return ""
}
}
/**
* share2Clipboard
*/
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) {
e.printStackTrace()
return -1
}
return 0
}
/**
* share2Clipboard
*/
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()
} catch (e: Exception) {
e.printStackTrace()
return -1
}
}
/**
* share2QRCode
*/
fun share2QRCode(guid: String): Bitmap? {
try {
val conf = shareConfig(guid)
if (TextUtils.isEmpty(conf)) {
return null
}
return QRCodeDecoder.createQRCode(conf)
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
/**
* shareFullContent2Clipboard
*/
fun shareFullContent2Clipboard(context: Context, guid: String?): Int {
try {
if (guid == null) return -1
val result = V2rayConfigManager.getV2rayConfig(context, guid)
if (result.status) {
val config = MmkvManager.decodeServerConfig(guid)
if (config?.configType == EConfigType.HYSTERIA2) {
val socksPort = Utils.findFreePort(listOf(100 + SettingsManager.getSocksPort(), 0))
val hy2Config = Hysteria2Fmt.toNativeConfig(config, socksPort)
Utils.setClipboard(context, JsonUtil.toJsonPretty(hy2Config) + "\n" + result.content)
return 0
}
Utils.setClipboard(context, result.content)
} else {
return -1
}
} catch (e: Exception) {
e.printStackTrace()
return -1
}
return 0
}
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)
}
var countSub = parseBatchSubscription(server)
if (countSub <= 0) {
countSub = parseBatchSubscription(Utils.decode(server))
}
if (countSub > 0) {
updateConfigViaSubAll()
}
return count to countSub
}
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) {
e.printStackTrace()
}
return 0
}
fun parseBatchConfig(servers: String?, subid: String, append: Boolean): Int {
try {
if (servers == null) {
return 0
}
val removedSelectedServer =
if (!TextUtils.isEmpty(subid) && !append) {
MmkvManager.decodeServerConfig(
MmkvManager.getSelectServer().orEmpty()
)?.let {
if (it.subscriptionId == subid) {
return@let it
}
return@let null
}
} else {
null
}
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val subItem = MmkvManager.decodeSubscription(subid)
var count = 0
servers.lines()
.distinct()
.reversed()
.forEach {
val resId = parseConfig(it, subid, subItem, removedSelectedServer)
if (resId == 0) {
count++
}
}
return count
} catch (e: Exception) {
e.printStackTrace()
}
return 0
}
fun parseCustomConfigServer(server: String?, subid: String): 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)
if (serverList.isNotEmpty()) {
var count = 0
for (srv in serverList.reversed()) {
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
config.subscriptionId = subid
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
count += 1
}
return count
}
} catch (e: Exception) {
e.printStackTrace()
}
try {
// For compatibility
val config = CustomFmt.parse(server) ?: return 0
config.subscriptionId = subid
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
return 1
} catch (e: Exception) {
e.printStackTrace()
}
return 0
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
try {
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
return 1
} catch (e: Exception) {
e.printStackTrace()
}
return 0
} else {
return 0
}
}
fun updateConfigViaSubAll(): Int {
var count = 0
try {
@ -329,12 +392,18 @@ object AngConfigManager {
count += updateConfigViaSub(it)
}
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
return 0
}
return count
}
/**
* Updates the configuration via a subscription.
*
* @param it The subscription item.
* @return The number of configurations updated.
*/
fun updateConfigViaSub(it: Pair<String, SubscriptionItem>): Int {
try {
if (TextUtils.isEmpty(it.first)
@ -346,25 +415,29 @@ object AngConfigManager {
if (!it.second.enabled) {
return 0
}
val url = Utils.idnToASCII(it.second.url)
val url = HttpUtil.toIdnUrl(it.second.url)
if (!Utils.isValidUrl(url)) {
return 0
}
Log.d(AppConfig.ANG_PACKAGE, url)
if (!it.second.allowInsecureUrl) {
if (!Utils.isValidSubUrl(url)) {
return 0
}
}
Log.i(AppConfig.TAG, url)
var configText = try {
val httpPort = SettingsManager.getHttpPort()
Utils.getUrlContentWithCustomUserAgent(url, 30000, httpPort)
HttpUtil.getUrlContentWithUserAgent(url, 15000, httpPort)
} catch (e: Exception) {
Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error, try……")
//e.printStackTrace()
Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
""
}
if (configText.isEmpty()) {
configText = try {
Utils.getUrlContentWithCustomUserAgent(url)
HttpUtil.getUrlContentWithUserAgent(url)
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
""
}
}
@ -373,11 +446,19 @@ object AngConfigManager {
}
return parseConfigViaSub(configText, it.first, false)
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Failed to update config via subscription", e)
return 0
}
}
/**
* 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) {
@ -389,6 +470,12 @@ object AngConfigManager {
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 {

View file

@ -3,25 +3,29 @@ package com.v2ray.ang.handler
import android.util.Log
import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.NetworkType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.ServerConfig
import com.v2ray.ang.extension.removeWhiteSpace
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils
object MigrateManager {
private const val ID_SERVER_CONFIG = "SERVER_CONFIG"
private val serverStorage by lazy { MMKV.mmkvWithID(ID_SERVER_CONFIG, MMKV.MULTI_PROCESS_MODE) }
/**
* Migrates server configurations to profile items.
*
* @return True if migration was successful, false otherwise.
*/
fun migrateServerConfig2Profile(): Boolean {
if (serverStorage.count().toInt() == 0) {
return false
}
val serverList = serverStorage.allKeys() ?: return false
Log.d(ANG_PACKAGE, "migrateServerConfig2Profile-" + serverList.count())
Log.i(AppConfig.TAG, "migrateServerConfig2Profile-" + serverList.count())
for (guid in serverList) {
var configOld = decodeServerConfigOld(guid) ?: continue
@ -38,12 +42,18 @@ object MigrateManager {
//check and remove old
decodeServerConfig(guid) ?: continue
serverStorage.remove(guid)
Log.d(ANG_PACKAGE, "migrateServerConfig2Profile-" + config.remarks)
Log.i(AppConfig.TAG, "migrateServerConfig2Profile-" + config.remarks)
}
Log.d(ANG_PACKAGE, "migrateServerConfig2Profile-end")
Log.i(AppConfig.TAG, "migrateServerConfig2Profile-end")
return true
}
/**
* Migrates a server configuration to a profile item.
*
* @param configOld The old server configuration.
* @return The profile item.
*/
private fun migrateServerConfig2ProfileSub(configOld: ServerConfig): ProfileItem? {
return when (configOld.getProxyOutbound()?.protocol) {
EConfigType.VMESS.name.lowercase() -> migrate2ProfileCommon(configOld)
@ -62,6 +72,12 @@ object MigrateManager {
}
}
/**
* Migrates a common server configuration to a profile item.
*
* @param configOld The old server configuration.
* @return The profile item.
*/
private fun migrate2ProfileCommon(configOld: ServerConfig): ProfileItem? {
val config = ProfileItem.create(configOld.configType)
@ -92,7 +108,7 @@ object MigrateManager {
config.insecure = tlsSettings?.allowInsecure
config.sni = tlsSettings?.serverName
config.fingerPrint = tlsSettings?.fingerprint
config.alpn = Utils.removeWhiteSpace(tlsSettings?.alpn?.joinToString(",")).toString()
config.alpn = tlsSettings?.alpn?.joinToString(",").removeWhiteSpace().toString()
config.publicKey = tlsSettings?.publicKey
config.shortId = tlsSettings?.shortId
@ -101,6 +117,12 @@ object MigrateManager {
return config
}
/**
* Migrates a SOCKS server configuration to a profile item.
*
* @param configOld The old server configuration.
* @return The profile item.
*/
private fun migrate2ProfileSocks(configOld: ServerConfig): ProfileItem? {
val config = ProfileItem.create(EConfigType.SOCKS)
@ -114,6 +136,12 @@ object MigrateManager {
return config
}
/**
* Migrates an HTTP server configuration to a profile item.
*
* @param configOld The old server configuration.
* @return The profile item.
*/
private fun migrate2ProfileHttp(configOld: ServerConfig): ProfileItem? {
val config = ProfileItem.create(EConfigType.HTTP)
@ -127,6 +155,12 @@ object MigrateManager {
return config
}
/**
* Migrates a WireGuard server configuration to a profile item.
*
* @param configOld The old server configuration.
* @return The profile item.
*/
private fun migrate2ProfileWireguard(configOld: ServerConfig): ProfileItem? {
val config = ProfileItem.create(EConfigType.WIREGUARD)
@ -137,14 +171,20 @@ object MigrateManager {
outbound.settings?.let { wireguard ->
config.secretKey = wireguard.secretKey
config.localAddress = Utils.removeWhiteSpace((wireguard.address as List<*>).joinToString(",")).toString()
config.localAddress = (wireguard.address as List<*>).joinToString(",").removeWhiteSpace().toString()
config.publicKey = wireguard.peers?.getOrNull(0)?.publicKey
config.mtu = wireguard.mtu
config.reserved = Utils.removeWhiteSpace(wireguard.reserved?.joinToString(",")).toString()
config.reserved = wireguard.reserved?.joinToString(",").removeWhiteSpace().toString()
}
return config
}
/**
* Migrates a Hysteria2 server configuration to a profile item.
*
* @param configOld The old server configuration.
* @return The profile item.
*/
private fun migrate2ProfileHysteria2(configOld: ServerConfig): ProfileItem? {
val config = ProfileItem.create(EConfigType.HYSTERIA2)
@ -158,7 +198,7 @@ object MigrateManager {
outbound.streamSettings?.tlsSettings?.let { tlsSetting ->
config.insecure = tlsSetting.allowInsecure
config.sni = tlsSetting.serverName
config.alpn = Utils.removeWhiteSpace(tlsSetting.alpn?.joinToString(",")).orEmpty()
config.alpn = tlsSetting.alpn?.joinToString(",").removeWhiteSpace().orEmpty()
}
config.obfsPassword = outbound.settings?.obfsPassword
@ -166,6 +206,12 @@ object MigrateManager {
return config
}
/**
* Migrates a custom server configuration to a profile item.
*
* @param configOld The old server configuration.
* @return The profile item.
*/
private fun migrate2ProfileCustom(configOld: ServerConfig): ProfileItem? {
val config = ProfileItem.create(EConfigType.CUSTOM)
@ -177,7 +223,12 @@ object MigrateManager {
return config
}
/**
* Decodes the old server configuration.
*
* @param guid The server GUID.
* @return The old server configuration.
*/
private fun decodeServerConfigOld(guid: String): ServerConfig? {
if (guid.isBlank()) {
return null

View file

@ -1,6 +1,5 @@
package com.v2ray.ang.handler
import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
@ -41,18 +40,38 @@ object MmkvManager {
//region Server
/**
* 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.
*
* @param serverList The list of server GUIDs.
*/
fun encodeServerList(serverList: MutableList<String>) {
mainStorage.encode(KEY_ANG_CONFIGS, JsonUtil.toJson(serverList))
}
/**
* Decodes the server list.
*
* @return The list of server GUIDs.
*/
fun decodeServerList(): MutableList<String> {
val json = mainStorage.decodeString(KEY_ANG_CONFIGS)
return if (json.isNullOrBlank()) {
@ -62,7 +81,12 @@ object MmkvManager {
}
}
/**
* Decodes the server configuration.
*
* @param guid The server GUID.
* @return The server configuration.
*/
fun decodeServerConfig(guid: String): ProfileItem? {
if (guid.isBlank()) {
return null
@ -85,6 +109,13 @@ object MmkvManager {
// return JsonUtil.fromJson(json, ProfileLiteItem::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))
@ -107,6 +138,11 @@ object MmkvManager {
return key
}
/**
* Removes the server configuration.
*
* @param guid The server GUID.
*/
fun removeServer(guid: String) {
if (guid.isBlank()) {
return
@ -122,6 +158,11 @@ object MmkvManager {
serverAffStorage.remove(guid)
}
/**
* Removes the server configurations via subscription ID.
*
* @param subid The subscription ID.
*/
fun removeServerViaSubid(subid: String) {
if (subid.isBlank()) {
return
@ -135,6 +176,12 @@ object MmkvManager {
}
}
/**
* 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
@ -146,6 +193,12 @@ object MmkvManager {
return JsonUtil.fromJson(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
@ -155,6 +208,11 @@ object MmkvManager {
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 ->
@ -164,6 +222,11 @@ object MmkvManager {
}
}
/**
* Removes all server configurations.
*
* @return The number of server configurations removed.
*/
fun removeAllServer(): Int {
val count = profileFullStorage.allKeys()?.count() ?: 0
mainStorage.clearAll()
@ -173,6 +236,12 @@ object MmkvManager {
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()) {
@ -195,10 +264,22 @@ object MmkvManager {
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)
}
@ -207,6 +288,9 @@ object MmkvManager {
//region Subscriptions
/**
* Initializes the subscription list.
*/
private fun initSubsList() {
val subsList = decodeSubsList()
if (subsList.isNotEmpty()) {
@ -218,6 +302,11 @@ object MmkvManager {
encodeSubsList(subsList)
}
/**
* Decodes the subscriptions.
*
* @return The list of subscriptions.
*/
fun decodeSubscriptions(): List<Pair<String, SubscriptionItem>> {
initSubsList()
@ -231,6 +320,11 @@ object MmkvManager {
return subscriptions
}
/**
* Removes the subscription.
*
* @param subid The subscription ID.
*/
fun removeSubscription(subid: String) {
subStorage.remove(subid)
val subsList = decodeSubsList()
@ -240,6 +334,12 @@ object MmkvManager {
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))
@ -251,15 +351,31 @@ object MmkvManager {
}
}
/**
* 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.fromJson(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()) {
@ -273,6 +389,11 @@ object MmkvManager {
//region Asset
/**
* Decodes the asset URLs.
*
* @return The list of asset URLs.
*/
fun decodeAssetUrls(): List<Pair<String, AssetUrlItem>> {
val assetUrlItems = mutableListOf<Pair<String, AssetUrlItem>>()
assetStorage.allKeys()?.forEach { key ->
@ -284,15 +405,32 @@ object MmkvManager {
return assetUrlItems.sortedBy { (_, value) -> value.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.fromJson(json, AssetUrlItem::class.java)
@ -302,12 +440,22 @@ object MmkvManager {
//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.fromJson(ruleset, Array<RulesetItem>::class.java).toMutableList()
}
/**
* Encodes the routing rulesets.
*
* @param rulesetList The list of routing rulesets.
*/
fun encodeRoutingRulesets(rulesetList: MutableList<RulesetItem>?) {
if (rulesetList.isNullOrEmpty())
encodeSettings(PREF_ROUTING_RULESET, "")
@ -316,43 +464,99 @@ object MmkvManager {
}
//endregion
/**
* 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: 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 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)
}
fun decodeSettingsInt(key: String, defaultValue: Int): Int {
return settingsStorage.decodeInt(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)
}
@ -361,10 +565,20 @@ object MmkvManager {
//region Others
/**
* Encodes the start on boot setting.
*
* @param startOnBoot Whether to start on boot.
*/
fun encodeStartOnBoot(startOnBoot: Boolean) {
MmkvManager.encodeSettings(PREF_IS_BOOTED, startOnBoot)
encodeSettings(PREF_IS_BOOTED, startOnBoot)
}
/**
* Decodes the start on boot setting.
*
* @return Whether to start on boot.
*/
fun decodeStartOnBoot(): Boolean {
return decodeSettingsBool(PREF_IS_BOOTED, false)
}

View file

@ -4,26 +4,34 @@ import android.content.Context
import android.content.res.AssetManager
import android.text.TextUtils
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.Language
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RoutingType
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.dto.VpnInterfaceAddressConfig
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
import com.v2ray.ang.handler.MmkvManager.decodeServerList
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils
import com.v2ray.ang.util.Utils.parseInt
import java.io.File
import java.io.FileOutputStream
import java.util.Collections
import kotlin.Int
import java.util.Locale
object SettingsManager {
/**
* Initialize routing rulesets.
* @param context The application context.
*/
fun initRoutingRulesets(context: Context) {
val exist = MmkvManager.decodeRoutingRulesets()
if (exist.isNullOrEmpty()) {
@ -32,6 +40,12 @@ object SettingsManager {
}
}
/**
* 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)
@ -42,12 +56,21 @@ object SettingsManager {
return JsonUtil.fromJson(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
@ -62,11 +85,15 @@ object SettingsManager {
resetRoutingRulesetsCommon(rulesetList)
return true
} catch (e: Exception) {
e.printStackTrace()
Log.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 ->
@ -79,6 +106,11 @@ object SettingsManager {
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
@ -88,11 +120,18 @@ object SettingsManager {
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
val rulesetList = MmkvManager.decodeRoutingRulesets()
if (rulesetList.isNullOrEmpty()) return
var rulesetList = MmkvManager.decodeRoutingRulesets()
if (rulesetList.isNullOrEmpty()) {
rulesetList = mutableListOf()
}
if (index < 0 || index >= rulesetList.count()) {
rulesetList.add(0, ruleset)
@ -102,6 +141,10 @@ object SettingsManager {
MmkvManager.encodeRoutingRulesets(rulesetList)
}
/**
* Remove a routing ruleset by index.
* @param index The index of the ruleset.
*/
fun removeRoutingRuleset(index: Int) {
if (index < 0) return
@ -112,7 +155,29 @@ object SettingsManager {
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.fromJson(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
@ -120,6 +185,11 @@ object SettingsManager {
return exist == true
}
/**
* Swap routing rulesets.
* @param fromPosition The position to swap from.
* @param toPosition The position to swap to.
*/
fun swapRoutingRuleset(fromPosition: Int, toPosition: Int) {
val rulesetList = MmkvManager.decodeRoutingRulesets()
if (rulesetList.isNullOrEmpty()) return
@ -128,6 +198,11 @@ object SettingsManager {
MmkvManager.encodeRoutingRulesets(rulesetList)
}
/**
* Swap subscriptions.
* @param fromPosition The position to swap from.
* @param toPosition The position to swap to.
*/
fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
val subsList = MmkvManager.decodeSubsList()
if (subsList.isNullOrEmpty()) return
@ -136,8 +211,13 @@ object SettingsManager {
MmkvManager.encodeSubsList(subsList)
}
/**
* Get server via remarks.
* @param remarks The remarks of the server.
* @return The ProfileItem.
*/
fun getServerViaRemarks(remarks: String?): ProfileItem? {
if (remarks == null) {
if (remarks.isNullOrEmpty()) {
return null
}
val serverList = decodeServerList()
@ -150,14 +230,27 @@ object SettingsManager {
return null
}
/**
* Get the SOCKS port.
* @return The SOCKS port.
*/
fun getSocksPort(): Int {
return parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
return Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
}
/**
* Get the HTTP port.
* @return The HTTP port.
*/
fun getHttpPort(): Int {
return getSocksPort() + (if (Utils.isXray()) 0 else 1)
return getSocksPort() + if (Utils.isXray()) 0 else 1
}
/**
* Initialize assets.
* @param context The application context.
* @param assets The AssetManager.
*/
fun initAssets(context: Context, assets: AssetManager) {
val extFolder = Utils.userAssetPath(context)
@ -173,14 +266,108 @@ object SettingsManager {
input.copyTo(output)
}
}
Log.i(
ANG_PACKAGE,
"Copied from apk assets folder to ${target.absolutePath}"
)
Log.i(AppConfig.TAG, "Copied from apk assets folder to ${target.absolutePath}")
}
} catch (e: Exception) {
Log.e(ANG_PACKAGE, "asset copy failed", e)
}
}
/**
* 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 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("vi")
Language.RUSSIAN -> Locale("ru")
Language.PERSIAN -> Locale("fa")
Language.ARABIC -> Locale("ar")
Language.BANGLA -> Locale("bn")
Language.BAKHTIARI -> Locale("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)
}
}

View file

@ -1,28 +1,34 @@
package com.v2ray.ang.util
package com.v2ray.ang.handler
import android.content.Context
import android.os.SystemClock
import android.text.TextUtils
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.R
import com.v2ray.ang.dto.IPAPIInfo
import com.v2ray.ang.extension.responseLength
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import kotlinx.coroutines.isActive
import libv2ray.Libv2ray
import java.io.IOException
import java.net.HttpURLConnection
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.Socket
import java.net.URL
import java.net.UnknownHostException
import kotlin.coroutines.coroutineContext
object SpeedtestUtil {
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) {
@ -37,15 +43,27 @@ object SpeedtestUtil {
return time
}
/**
* Measures the real ping time using the V2Ray library.
*
* @param config The configuration string for the V2Ray library.
* @return The ping time in milliseconds, or -1 if the ping failed.
*/
fun realPing(config: String): Long {
return try {
Libv2ray.measureOutboundDelay(config, Utils.getDelayTestUrl())
Libv2ray.measureOutboundDelay(config, SettingsManager.getDelayTestUrl())
} catch (e: Exception) {
Log.d(AppConfig.ANG_PACKAGE, "realPing: $e")
Log.e(AppConfig.TAG, "Failed to measure outbound delay", e)
-1L
}
}
/**
* Measures the ping time to a given URL using the system ping command.
*
* @param url The URL to ping.
* @return The ping time in milliseconds as a string, or "-1ms" if the ping failed.
*/
fun ping(url: String): String {
try {
val command = "/system/bin/ping -c 3 $url"
@ -60,11 +78,18 @@ object SpeedtestUtil {
}
}
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Failed to ping URL: $url", e)
}
return "-1ms"
}
/**
* 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()
@ -80,15 +105,18 @@ object SpeedtestUtil {
socket.close()
return time
} catch (e: UnknownHostException) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Unknown host: $url", e)
} catch (e: IOException) {
Log.d(AppConfig.ANG_PACKAGE, "socketConnectTime IOException: $e")
Log.e(AppConfig.TAG, "socketConnectTime IOException: $e")
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
}
return -1
}
/**
* Closes all TCP sockets that are currently being tested.
*/
fun closeAllTcpSockets() {
synchronized(this) {
tcpTestingSockets.forEach {
@ -98,26 +126,19 @@ object SpeedtestUtil {
}
}
/**
* Tests the connection to a given URL and port.
*
* @param context The Context in which the test is running.
* @param port The port to connect to.
* @return A pair containing the elapsed time in milliseconds and the result message.
*/
fun testConnection(context: Context, port: Int): Pair<Long, String> {
var result: String
var elapsed = -1L
var conn: HttpURLConnection? = null
val conn = HttpUtil.createProxyConnection(SettingsManager.getDelayTestUrl(), port, 15000, 15000) ?: return Pair(elapsed, "")
try {
val url = URL(Utils.getDelayTestUrl())
conn = url.openConnection(
Proxy(
Proxy.Type.HTTP,
InetSocketAddress(LOOPBACK, port)
)
) as HttpURLConnection
conn.connectTimeout = 30000
conn.readTimeout = 30000
conn.setRequestProperty("Connection", "close")
conn.instanceFollowRedirects = false
conn.useCaches = false
val start = SystemClock.elapsedRealtime()
val code = conn.responseCode
elapsed = SystemClock.elapsedRealtime() - start
@ -133,20 +154,34 @@ object SpeedtestUtil {
)
}
} catch (e: IOException) {
// network exception
Log.d(AppConfig.ANG_PACKAGE, "testConnection IOException: " + Log.getStackTraceString(e))
Log.e(AppConfig.TAG, "Connection test IOException", e)
result = context.getString(R.string.connection_test_error, e.message)
} catch (e: Exception) {
// library exception, eg sumsung
Log.d(AppConfig.ANG_PACKAGE, "testConnection Exception: " + Log.getStackTraceString(e))
Log.e(AppConfig.TAG, "Connection test Exception", e)
result = context.getString(R.string.connection_test_error, e.message)
} finally {
conn?.disconnect()
conn.disconnect()
}
return Pair(elapsed, result)
}
fun getRemoteIPInfo(): String? {
val httpPort = SettingsManager.getHttpPort()
var content = HttpUtil.getUrlContent(AppConfig.IP_API_URL, 5000, httpPort) ?: return null
var ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
var ip = ipInfo.ip ?: ipInfo.clientIp ?: ipInfo.ip_addr ?: ipInfo.query
var country = ipInfo.country_code ?: ipInfo.country ?: ipInfo.countryCode
return "(${country ?: "unknown"}) $ip"
}
/**
* Gets the version of the V2Ray library.
*
* @return The version of the V2Ray library.
*/
fun getLibVersion(): String {
return Libv2ray.checkVersionX()
}

View file

@ -0,0 +1,107 @@
package com.v2ray.ang.handler
import android.content.Context
import android.os.Build
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.dto.CheckUpdateResult
import com.v2ray.ang.dto.GitHubRelease
import com.v2ray.ang.extension.concatUrl
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
object UpdateCheckerManager {
suspend fun checkForUpdate(includePreRelease: Boolean = false): CheckUpdateResult = withContext(Dispatchers.IO) {
val url = if (includePreRelease) {
AppConfig.APP_API_URL
} else {
AppConfig.APP_API_URL.concatUrl("latest")
}
var response = HttpUtil.getUrlContent(url, 5000)
if (response.isNullOrEmpty()) {
val httpPort = SettingsManager.getHttpPort()
response = HttpUtil.getUrlContent(url, 5000, httpPort) ?: throw IllegalStateException("Failed to get response")
}
val latestRelease = if (includePreRelease) {
JsonUtil.fromJson(response, Array<GitHubRelease>::class.java)
.firstOrNull()
?: throw IllegalStateException("No pre-release found")
} else {
JsonUtil.fromJson(response, GitHubRelease::class.java)
}
val latestVersion = latestRelease.tagName.removePrefix("v")
Log.i(AppConfig.TAG, "Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})")
return@withContext if (compareVersions(latestVersion, BuildConfig.VERSION_NAME) > 0) {
val downloadUrl = getDownloadUrl(latestRelease, Build.SUPPORTED_ABIS[0])
CheckUpdateResult(
hasUpdate = true,
latestVersion = latestVersion,
releaseNotes = latestRelease.body,
downloadUrl = downloadUrl,
isPreRelease = latestRelease.prerelease
)
} else {
CheckUpdateResult(hasUpdate = false)
}
}
suspend fun downloadApk(context: Context, downloadUrl: String): File? = withContext(Dispatchers.IO) {
try {
val httpPort = SettingsManager.getHttpPort()
val connection = HttpUtil.createProxyConnection(downloadUrl, httpPort, 10000, 10000, true)
?: throw IllegalStateException("Failed to create connection")
try {
val apkFile = File(context.cacheDir, "update.apk")
Log.i(AppConfig.TAG, "Downloading APK to: ${apkFile.absolutePath}")
FileOutputStream(apkFile).use { outputStream ->
connection.inputStream.use { inputStream ->
inputStream.copyTo(outputStream)
}
}
Log.i(AppConfig.TAG, "APK download completed")
return@withContext apkFile
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download APK: ${e.message}")
return@withContext null
} finally {
try {
connection.disconnect()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error closing connection: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to initiate download: ${e.message}")
return@withContext null
}
}
private fun compareVersions(version1: String, version2: String): Int {
val v1 = version1.split(".")
val v2 = version2.split(".")
for (i in 0 until maxOf(v1.size, v2.size)) {
val num1 = if (i < v1.size) v1[i].toInt() else 0
val num2 = if (i < v2.size) v2[i].toInt() else 0
if (num1 != num2) return num1 - num2
}
return 0
}
private fun getDownloadUrl(release: GitHubRelease, abi: String): String {
return release.assets.find { it.name.contains(abi) }?.browserDownloadUrl
?: release.assets.firstOrNull()?.browserDownloadUrl
?: throw IllegalStateException("No compatible APK found")
}
}

View file

@ -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)
}
}
}

View file

@ -16,7 +16,6 @@
package com.v2ray.ang.helper
import android.animation.ValueAnimator
import android.animation.ValueAnimator.AnimatorUpdateListener
import android.graphics.Canvas
import android.view.animation.DecelerateInterpolator
import androidx.recyclerview.widget.GridLayoutManager
@ -108,7 +107,7 @@ class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter
addUpdateListener { animation ->
val value = animation.animatedValue as Float
viewHolder.itemView.translationX = value
viewHolder.itemView.alpha = (1f - abs(value) / (viewHolder.itemView.width * SWIPE_THRESHOLD))
viewHolder.itemView.alpha = 1f - abs(value) / (viewHolder.itemView.width * SWIPE_THRESHOLD)
}
interpolator = DecelerateInterpolator()
duration = ANIMATION_DURATION

View file

@ -32,10 +32,10 @@ import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.system.Os
import android.widget.Toast
import androidx.core.os.bundleOf
import com.v2ray.ang.AngApplication
import com.v2ray.ang.extension.listenForPackageChanges
import com.v2ray.ang.extension.toast
import com.v2ray.ang.plugin.PluginContract.METADATA_KEY_ID
import java.io.File
import java.io.FileNotFoundException
@ -126,7 +126,7 @@ object PluginManager {
if (providers.size > 1) {
val message =
"Conflicting plugins found from: ${providers.joinToString { it.providerInfo.packageName }}"
Toast.makeText(AngApplication.application, message, Toast.LENGTH_LONG).show()
AngApplication.application.toast(message)
throw IllegalStateException(message)
}
val provider = providers.single().providerInfo
@ -224,8 +224,8 @@ object PluginManager {
fun ComponentInfo.loadString(key: String) = when (val value = metaData.getString(key)) {
is String -> value
is Int -> AngApplication.application.packageManager.getResourcesForApplication(applicationInfo)
.getString(value)
// is Int -> AngApplication.application.packageManager.getResourcesForApplication(applicationInfo)
// .getString(value)
null -> null
else -> error("meta-data $key has invalid type ${value.javaClass}")

View file

@ -7,12 +7,17 @@ import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.service.V2RayServiceManager
class BootReceiver : BroadcastReceiver() {
/**
* This method is called when the BroadcastReceiver is receiving an Intent broadcast.
* It checks if the context is not null and the action is ACTION_BOOT_COMPLETED.
* If the conditions are met, it starts the V2Ray service.
*
* @param context The Context in which the receiver is running.
* @param intent The Intent being received.
*/
override fun onReceive(context: Context?, intent: Intent?) {
//Check if context is not null and action is the one we want
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) return
//Check if flag is true and a server is selected
if (!MmkvManager.decodeStartOnBoot() || MmkvManager.getSelectServer().isNullOrEmpty()) return
//Start v2ray
V2RayServiceManager.startV2Ray(context)
V2RayServiceManager.startVService(context)
}
}

View file

@ -5,14 +5,19 @@ import android.content.Context
import android.content.Intent
import android.text.TextUtils
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.service.V2RayServiceManager
import com.v2ray.ang.util.Utils
class TaskerReceiver : BroadcastReceiver() {
/**
* This method is called when the BroadcastReceiver is receiving an Intent broadcast.
* It retrieves the bundle from the intent and checks the switch and guid values.
* Depending on the switch value, it starts or stops the V2Ray service.
*
* @param context The Context in which the receiver is running.
* @param intent The Intent being received.
*/
override fun onReceive(context: Context, intent: Intent?) {
try {
val bundle = intent?.getBundleExtra(AppConfig.TASKER_EXTRA_BUNDLE)
val switch = bundle?.getBoolean(AppConfig.TASKER_EXTRA_BUNDLE_SWITCH, false)
@ -22,16 +27,15 @@ class TaskerReceiver : BroadcastReceiver() {
return
} else if (switch) {
if (guid == AppConfig.TASKER_DEFAULT_GUID) {
Utils.startVServiceFromToggle(context)
V2RayServiceManager.startVServiceFromToggle(context)
} else {
MmkvManager.setSelectServer(guid)
V2RayServiceManager.startV2Ray(context)
V2RayServiceManager.startVService(context, guid)
}
} else {
Utils.stopVService(context)
V2RayServiceManager.stopVService(context)
}
} catch (e: Exception) {
e.printStackTrace()
android.util.Log.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
}
}
}

View file

@ -11,18 +11,29 @@ import android.widget.RemoteViews
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.service.V2RayServiceManager
import com.v2ray.ang.util.Utils
class WidgetProvider : AppWidgetProvider() {
/**
* 每次窗口小部件被更新都调用一次该方法
* This method is called every time the widget is updated.
* It updates the widget background based on the V2Ray service running state.
*
* @param context The Context in which the receiver is running.
* @param appWidgetManager The AppWidgetManager instance.
* @param appWidgetIds The appWidgetIds for which an update is needed.
*/
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
updateWidgetBackground(context, appWidgetManager, appWidgetIds, V2RayServiceManager.v2rayPoint.isRunning)
updateWidgetBackground(context, appWidgetManager, appWidgetIds, V2RayServiceManager.isRunning())
}
/**
* Updates the widget background based on whether the V2Ray service is running.
*
* @param context The Context in which the receiver is running.
* @param appWidgetManager The AppWidgetManager instance.
* @param appWidgetIds The appWidgetIds for which an update is needed.
* @param isRunning Boolean indicating if the V2Ray service is running.
*/
private fun updateWidgetBackground(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, isRunning: Boolean) {
val remoteViews = RemoteViews(context.packageName, R.layout.widget_switch)
val intent = Intent(context, WidgetProvider::class.java)
@ -52,15 +63,19 @@ class WidgetProvider : AppWidgetProvider() {
}
/**
* 接收窗口小部件发送的广播
* This method is called when the BroadcastReceiver is receiving an Intent broadcast.
* It handles widget click actions and updates the widget background based on the V2Ray service state.
*
* @param context The Context in which the receiver is running.
* @param intent The Intent being received.
*/
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (AppConfig.BROADCAST_ACTION_WIDGET_CLICK == intent.action) {
if (V2RayServiceManager.v2rayPoint.isRunning) {
Utils.stopVService(context)
if (V2RayServiceManager.isRunning()) {
V2RayServiceManager.stopVService(context)
} else {
Utils.startVServiceFromToggle(context)
V2RayServiceManager.startVServiceFromToggle(context)
}
} else if (AppConfig.BROADCAST_ACTION_ACTIVITY == intent.action) {
AppWidgetManager.getInstance(context)?.let { manager ->

View file

@ -0,0 +1,252 @@
package com.v2ray.ang.service
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.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.R
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toSpeedString
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.ui.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.math.min
object NotificationService {
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 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(currentConfig: ProfileItem?) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) != true) return
if (speedNotificationJob != null || V2RayServiceManager.isRunning() == false) return
lastQueryTime = System.currentTimeMillis()
var lastZeroSpeed = false
val outboundTags = currentConfig?.getAllOutboundTags()
outboundTags?.remove(TAG_DIRECT)
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
while (isActive) {
val queryTime = System.currentTimeMillis()
val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0
var proxyTotal = 0L
val text = StringBuilder()
outboundTags?.forEach {
val up = V2RayServiceManager.queryStats(it, AppConfig.UPLINK)
val down = V2RayServiceManager.queryStats(it, AppConfig.DOWNLINK)
if (up + down > 0) {
appendSpeedString(text, it, up / sinceLastQueryInSeconds, down / sinceLastQueryInSeconds)
proxyTotal += up + down
}
}
val directUplink = V2RayServiceManager.queryStats(TAG_DIRECT, AppConfig.UPLINK)
val directDownlink = V2RayServiceManager.queryStats(TAG_DIRECT, AppConfig.DOWNLINK)
val zeroSpeed = proxyTotal == 0L && directUplink == 0L && directDownlink == 0L
if (!zeroSpeed || !lastZeroSpeed) {
if (proxyTotal == 0L) {
appendSpeedString(text, outboundTags?.firstOrNull(), 0.0, 0.0)
}
appendSpeedString(
text, TAG_DIRECT, directUplink / sinceLastQueryInSeconds,
directDownlink / sinceLastQueryInSeconds
)
updateNotification(text.toString(), proxyTotal, directDownlink + directUplink)
}
lastZeroSpeed = zeroSpeed
lastQueryTime = queryTime
delay(3000)
}
}
}
/**
* Shows the notification.
* @param currentConfig The current profile configuration.
*/
fun showNotification(currentConfig: ProfileItem?) {
val service = getService() ?: return
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val startMainIntent = Intent(service, MainActivity::class.java)
val contentPendingIntent = PendingIntent.getActivity(service, NOTIFICATION_PENDING_INTENT_CONTENT, startMainIntent, flags)
val stopV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
stopV2RayIntent.`package` = ANG_PACKAGE
stopV2RayIntent.putExtra("key", AppConfig.MSG_STATE_STOP)
val stopV2RayPendingIntent = PendingIntent.getBroadcast(service, NOTIFICATION_PENDING_INTENT_STOP_V2RAY, stopV2RayIntent, flags)
val restartV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
restartV2RayIntent.`package` = ANG_PACKAGE
restartV2RayIntent.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
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
service.stopForeground(Service.STOP_FOREGROUND_REMOVE)
} else {
service.stopForeground(true)
}
mBuilder = null
speedNotificationJob?.cancel()
speedNotificationJob = null
mNotificationManager = null
}
/**
* Stops the speed notification.
* @param currentConfig The current profile configuration.
*/
fun stopSpeedNotification(currentConfig: ProfileItem?) {
speedNotificationJob?.let {
it.cancel()
speedNotificationJob = null
updateNotification(currentConfig?.remarks, 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.substring(0, 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")
}
/**
* Gets the service instance.
* @return The service instance.
*/
private fun getService(): Service? {
return V2RayServiceManager.serviceControl?.get()?.getService()
}
}

View file

@ -2,7 +2,7 @@ package com.v2ray.ang.service
import android.content.Context
import android.util.Log
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -10,8 +10,13 @@ import kotlinx.coroutines.launch
class ProcessService {
private var process: Process? = null
/**
* Runs a process with the given command.
* @param context The context.
* @param cmd The command to run.
*/
fun runProcess(context: Context, cmd: MutableList<String>) {
Log.d(ANG_PACKAGE, cmd.toString())
Log.i(AppConfig.TAG, cmd.toString())
try {
val proBuilder = ProcessBuilder(cmd)
@ -22,23 +27,26 @@ class ProcessService {
CoroutineScope(Dispatchers.IO).launch {
Thread.sleep(50L)
Log.d(ANG_PACKAGE, "runProcess check")
Log.i(AppConfig.TAG, "runProcess check")
process?.waitFor()
Log.d(ANG_PACKAGE, "runProcess exited")
Log.i(AppConfig.TAG, "runProcess exited")
}
Log.d(ANG_PACKAGE, process.toString())
Log.i(AppConfig.TAG, process.toString())
} catch (e: Exception) {
Log.d(ANG_PACKAGE, e.toString())
Log.e(AppConfig.TAG, e.toString(), e)
}
}
/**
* Stops the running process.
*/
fun stopProcess() {
try {
Log.d(ANG_PACKAGE, "runProcess destroy")
Log.i(AppConfig.TAG, "runProcess destroy")
process?.destroy()
} catch (e: Exception) {
Log.d(ANG_PACKAGE, e.toString())
Log.e(AppConfig.TAG, "Failed to destroy process", e)
}
}
}

View file

@ -1,6 +1,5 @@
package com.v2ray.ang.service
import android.annotation.TargetApi
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@ -9,6 +8,8 @@ import android.graphics.drawable.Icon
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
@ -16,18 +17,21 @@ import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.Utils
import java.lang.ref.SoftReference
@TargetApi(Build.VERSION_CODES.N)
@RequiresApi(Build.VERSION_CODES.N)
class QSTileService : TileService() {
/**
* Sets the state of the tile.
* @param state The state to set.
*/
fun setState(state: Int) {
qsTile?.icon = Icon.createWithResource(applicationContext, R.drawable.ic_stat_name)
if (state == Tile.STATE_INACTIVE) {
qsTile?.state = Tile.STATE_INACTIVE
qsTile?.label = getString(R.string.app_name)
qsTile?.icon = Icon.createWithResource(applicationContext, R.drawable.ic_stat_name)
} else if (state == Tile.STATE_ACTIVE) {
qsTile?.state = Tile.STATE_ACTIVE
qsTile?.label = V2RayServiceManager.currentConfig?.remarks
qsTile?.icon = Icon.createWithResource(applicationContext, R.drawable.ic_stat_name)
qsTile?.label = V2RayServiceManager.getRunningServerName()
}
qsTile?.updateTile()
@ -37,17 +41,23 @@ class QSTileService : TileService() {
* 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)`.
*/
override fun onStartListening() {
super.onStartListening()
setState(Tile.STATE_INACTIVE)
if (V2RayServiceManager.isRunning()) {
setState(Tile.STATE_ACTIVE)
} else {
setState(Tile.STATE_INACTIVE)
}
mMsgReceive = ReceiveMessageHandler(this)
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_ACTIVITY)
ContextCompat.registerReceiver(applicationContext, mMsgReceive, mFilter, Utils.receiverFlags())
MessageUtil.sendMsg2Service(this, AppConfig.MSG_REGISTER_CLIENT, "")
}
/**
* Called when the tile stops listening.
*/
override fun onStopListening() {
super.onStopListening()
@ -55,20 +65,23 @@ class QSTileService : TileService() {
applicationContext.unregisterReceiver(mMsgReceive)
mMsgReceive = null
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Failed to unregister receiver", e)
}
}
/**
* Called when the tile is clicked.
*/
override fun onClick() {
super.onClick()
when (qsTile.state) {
Tile.STATE_INACTIVE -> {
Utils.startVServiceFromToggle(this)
V2RayServiceManager.startVServiceFromToggle(this)
}
Tile.STATE_ACTIVE -> {
Utils.stopVService(this)
V2RayServiceManager.stopVService(this)
}
}
}

View file

@ -3,11 +3,26 @@ package com.v2ray.ang.service
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
}

View file

@ -19,7 +19,6 @@ import com.v2ray.ang.handler.MmkvManager
object SubscriptionUpdater {
class UpdateTask(context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
@ -33,9 +32,13 @@ object SubscriptionUpdater {
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
/**
* Performs the subscription update work.
* @return The result of the work.
*/
@SuppressLint("MissingPermission")
override suspend fun doWork(): Result {
Log.d(AppConfig.ANG_PACKAGE, "subscription automatic update starting")
Log.i(AppConfig.TAG, "subscription automatic update starting")
val subs = MmkvManager.decodeSubscriptions().filter { it.second.autoUpdate }
@ -53,10 +56,7 @@ object SubscriptionUpdater {
notificationManager.createNotificationChannel(channel)
}
notificationManager.notify(3, notification.build())
Log.d(
AppConfig.ANG_PACKAGE,
"subscription automatic update: ---${subItem.remarks}"
)
Log.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
updateConfigViaSub(Pair(sub.first, subItem))
notification.setContentText("Updating ${subItem.remarks}")
}

View file

@ -6,50 +6,87 @@ import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.annotation.RequiresApi
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.MyContextWrapper
import com.v2ray.ang.util.Utils
import java.lang.ref.SoftReference
class V2RayProxyOnlyService : Service(), ServiceControl {
/**
* Initializes the service.
*/
override fun onCreate() {
super.onCreate()
V2RayServiceManager.serviceControl = SoftReference(this)
}
/**
* Handles the start command for the service.
* @param intent The intent.
* @param flags The flags.
* @param startId The start ID.
* @return The start mode.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
V2RayServiceManager.startV2rayPoint()
V2RayServiceManager.startCoreLoop()
return START_STICKY
}
/**
* Destroys the service.
*/
override fun onDestroy() {
super.onDestroy()
V2RayServiceManager.stopV2rayPoint()
V2RayServiceManager.stopCoreLoop()
}
/**
* Gets the service instance.
* @return The service instance.
*/
override fun getService(): Service {
return this
}
/**
* Starts the service.
*/
override fun startService() {
// do nothing
}
/**
* Stops the service.
*/
override fun stopService() {
stopSelf()
}
/**
* Protects the VPN socket.
* @param socket The socket to protect.
* @return True if the socket is protected, false otherwise.
*/
override fun vpnProtect(socket: Int): Boolean {
return true
}
/**
* Binds the service.
* @param intent The intent.
* @return The binder.
*/
override fun onBind(intent: Intent?): IBinder? {
return null
}
/**
* Attaches the base context to the service.
* @param newBase The new base context.
*/
@RequiresApi(Build.VERSION_CODES.N)
override fun attachBaseContext(newBase: Context?) {
val context = newBase?.let {
MyContextWrapper.wrap(newBase, Utils.getLocale())
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
}
super.attachBaseContext(context)
}

View file

@ -1,74 +1,109 @@
package com.v2ray.ang.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Color
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toSpeedString
import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.SpeedtestManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.ui.MainActivity
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.PluginUtil
import com.v2ray.ang.util.Utils
import go.Seq
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import libv2ray.CoreCallbackHandler
import libv2ray.CoreController
import libv2ray.Libv2ray
import libv2ray.V2RayPoint
import libv2ray.V2RayVPNServiceSupportsSet
import java.lang.ref.SoftReference
import kotlin.math.min
object V2RayServiceManager {
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
val v2rayPoint: V2RayPoint = Libv2ray.newV2RayPoint(V2RayCallback(), Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1)
private val coreController: CoreController = Libv2ray.newCoreController(CoreCallback())
private val mMsgReceive = ReceiveMessageHandler()
private var currentConfig: ProfileItem? = null
var serviceControl: SoftReference<ServiceControl>? = null
set(value) {
field = value
Seq.setContext(value?.get()?.getService()?.applicationContext)
Libv2ray.initV2Env(Utils.userAssetPath(value?.get()?.getService()), Utils.getDeviceIdForXUDPBaseKey())
Libv2ray.initCoreEnv(Utils.userAssetPath(value?.get()?.getService()), Utils.getDeviceIdForXUDPBaseKey())
}
var currentConfig: ProfileItem? = null
private var lastQueryTime = 0L
private var mBuilder: NotificationCompat.Builder? = null
private var mDisposable: Disposable? = null
private var mNotificationManager: NotificationManager? = null
/**
* Starts the V2Ray service from a toggle action.
* @param context The context from which the service is started.
* @return True if the service was started successfully, false otherwise.
*/
fun startVServiceFromToggle(context: Context): Boolean {
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
context.toast(R.string.app_tile_first_use)
return false
}
startContextService(context)
return true
}
fun startV2Ray(context: Context) {
if (v2rayPoint.isRunning) return
/**
* Starts the V2Ray service.
* @param context The context from which the service is started.
* @param guid The GUID of the server configuration to use (optional).
*/
fun startVService(context: Context, guid: String? = null) {
if (guid != null) {
MmkvManager.setSelectServer(guid)
}
startContextService(context)
}
/**
* Stops the V2Ray service.
* @param context The context from which the service is stopped.
*/
fun stopVService(context: Context) {
context.toast(R.string.toast_services_stop)
MessageUtil.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
}
/**
* Checks if the V2Ray service is running.
* @return True if the service is running, false otherwise.
*/
fun isRunning() = coreController.isRunning
/**
* Gets the name of the currently running server.
* @return The name of the running server.
*/
fun getRunningServerName() = currentConfig?.remarks.orEmpty()
/**
* Starts the context service for V2Ray.
* Chooses between VPN service or Proxy-only service based on user settings.
* @param context The context from which the service is started.
*/
private fun startContextService(context: Context) {
if (coreController.isRunning) {
return
}
val guid = MmkvManager.getSelectServer() ?: return
val config = MmkvManager.decodeServerConfig(guid) ?: return
if (!Utils.isValidUrl(config.server) && !Utils.isIpAddress(config.server)) return
if (config.configType != EConfigType.CUSTOM
&& !Utils.isValidUrl(config.server)
&& !Utils.isPureIpAddress(config.server.orEmpty())
) return
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
// if (!result.status) return
@ -77,7 +112,7 @@ object V2RayServiceManager {
} else {
context.toast(R.string.toast_services_start)
}
val intent = if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: VPN) == VPN) {
val intent = if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: AppConfig.VPN) == AppConfig.VPN) {
Intent(context.applicationContext, V2RayVpnService::class.java)
} else {
Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
@ -89,61 +124,22 @@ object V2RayServiceManager {
}
}
private class V2RayCallback : V2RayVPNServiceSupportsSet {
override fun shutdown(): Long {
val serviceControl = serviceControl?.get() ?: return -1
// called by go
return try {
serviceControl.stopService()
0
} catch (e: Exception) {
Log.d(ANG_PACKAGE, e.toString())
-1
}
}
override fun prepare(): Long {
return 0
}
override fun protect(l: Long): Boolean {
val serviceControl = serviceControl?.get() ?: return true
return serviceControl.vpnProtect(l.toInt())
}
override fun onEmitStatus(l: Long, s: String?): Long {
return 0
}
override fun setup(s: String): Long {
val serviceControl = serviceControl?.get() ?: return -1
return try {
serviceControl.startService()
lastQueryTime = System.currentTimeMillis()
startSpeedNotification()
0
} catch (e: Exception) {
Log.d(ANG_PACKAGE, e.toString())
-1
}
}
}
/**
* 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 startV2rayPoint() {
val service = serviceControl?.get()?.getService() ?: return
val guid = MmkvManager.getSelectServer() ?: return
val config = MmkvManager.decodeServerConfig(guid) ?: return
if (v2rayPoint.isRunning) {
return
fun startCoreLoop(): Boolean {
if (coreController.isRunning) {
return false
}
val service = getService() ?: return false
val guid = MmkvManager.getSelectServer() ?: return false
val config = MmkvManager.decodeServerConfig(guid) ?: return false
val result = V2rayConfigManager.getV2rayConfig(service, guid)
if (!result.status)
return
return false
try {
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
@ -152,60 +148,188 @@ object V2RayServiceManager {
mFilter.addAction(Intent.ACTION_USER_PRESENT)
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
} catch (e: Exception) {
Log.d(ANG_PACKAGE, e.toString())
Log.e(AppConfig.TAG, "Failed to register broadcast receiver", e)
return false
}
v2rayPoint.configureFileContent = result.content
v2rayPoint.domainName = result.domainPort
currentConfig = config
try {
v2rayPoint.runLoop(MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
coreController.startLoop(result.content)
} catch (e: Exception) {
Log.d(ANG_PACKAGE, e.toString())
Log.e(AppConfig.TAG, "Failed to start Core loop", e)
return false
}
if (v2rayPoint.isRunning) {
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
showNotification()
PluginUtil.runPlugin(service, config, result.domainPort)
} else {
if (coreController.isRunning == false) {
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
cancelNotification()
NotificationService.cancelNotification()
return false
}
try {
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
NotificationService.showNotification(currentConfig)
NotificationService.startSpeedNotification(currentConfig)
PluginUtil.runPlugin(service, config, result.socksPort)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to startup service", e)
return false
}
return true
}
fun stopV2rayPoint() {
val service = serviceControl?.get()?.getService() ?: return
/**
* 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 (v2rayPoint.isRunning) {
if (coreController.isRunning) {
CoroutineScope(Dispatchers.IO).launch {
try {
v2rayPoint.stopLoop()
coreController.stopLoop()
} catch (e: Exception) {
Log.d(ANG_PACKAGE, e.toString())
Log.e(AppConfig.TAG, "Failed to stop V2Ray loop", e)
}
}
}
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
cancelNotification()
NotificationService.cancelNotification()
try {
service.unregisterReceiver(mMsgReceive)
} catch (e: Exception) {
Log.d(ANG_PACKAGE, e.toString())
Log.e(AppConfig.TAG, "Failed to unregister broadcast receiver", e)
}
PluginUtil.stopPlugin()
return true
}
/**
* Queries the statistics for a given tag and link.
* @param tag The tag to query.
* @param link The link to query.
* @return The statistics value.
*/
fun queryStats(tag: String, link: String): Long {
return coreController.queryStats(tag, link)
}
/**
* Measures the connection delay for the current V2Ray configuration.
* Tests with primary URL first, then falls back to alternative URL if needed.
* Also fetches remote IP information if the delay test was successful.
*/
private fun measureV2rayDelay() {
if (coreController.isRunning == false) {
return
}
CoroutineScope(Dispatchers.IO).launch {
val service = getService() ?: return@launch
var time = -1L
var errorStr = ""
try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to measure delay with primary URL", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message"
}
if (time == -1L) {
try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to measure delay with alternative URL", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message"
}
}
val result = if (time >= 0) {
service.getString(R.string.connection_test_available, time)
} else {
service.getString(R.string.connection_test_error, errorStr)
}
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, result)
// Only fetch IP info if the delay test was successful
if (time >= 0) {
SpeedtestManager.getRemoteIPInfo()?.let { ip ->
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result\n$ip")
}
}
}
}
/**
* Gets the current service instance.
* @return The current service instance, or null if not available.
*/
private fun getService(): Service? {
return serviceControl?.get()?.getService()
}
/**
* Core callback handler implementation for handling V2Ray core events.
* Handles startup, shutdown, socket protection, and status emission.
*/
private class CoreCallback : CoreCallbackHandler {
/**
* Called when V2Ray core starts up.
* @return 0 for success, any other value for failure.
*/
override fun startup(): Long {
return 0
}
/**
* Called when V2Ray core shuts down.
* @return 0 for success, any other value for failure.
*/
override fun shutdown(): Long {
val serviceControl = serviceControl?.get() ?: return -1
return try {
serviceControl.stopService()
0
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to stop service in callback", e)
-1
}
}
/**
* Called when V2Ray core emits status information.
* @param l Status code.
* @param s Status message.
* @return Always returns 0.
*/
override fun onEmitStatus(l: Long, s: String?): Long {
return 0
}
}
/**
* Broadcast receiver for handling messages sent to the service.
* Handles registration, service control, and screen events.
*/
private class ReceiveMessageHandler : BroadcastReceiver() {
/**
* Handles received broadcast messages.
* Processes service control messages and screen state changes.
* @param ctx The context in which the receiver is running.
* @param intent The intent being received.
*/
override fun onReceive(ctx: Context?, intent: Intent?) {
val serviceControl = serviceControl?.get() ?: return
when (intent?.getIntExtra("key", 0)) {
AppConfig.MSG_REGISTER_CLIENT -> {
if (v2rayPoint.isRunning) {
if (coreController.isRunning) {
MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_RUNNING, "")
} else {
MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
@ -221,15 +345,15 @@ object V2RayServiceManager {
}
AppConfig.MSG_STATE_STOP -> {
Log.d(ANG_PACKAGE, "Stop Service")
Log.i(AppConfig.TAG, "Stop Service")
serviceControl.stopService()
}
AppConfig.MSG_STATE_RESTART -> {
Log.d(ANG_PACKAGE, "Restart Service")
Log.i(AppConfig.TAG, "Restart Service")
serviceControl.stopService()
Thread.sleep(500L)
startV2Ray(serviceControl.getService())
startVService(serviceControl.getService())
}
AppConfig.MSG_MEASURE_DELAY -> {
@ -239,213 +363,15 @@ object V2RayServiceManager {
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> {
Log.d(ANG_PACKAGE, "SCREEN_OFF, stop querying stats")
stopSpeedNotification()
Log.i(AppConfig.TAG, "SCREEN_OFF, stop querying stats")
NotificationService.stopSpeedNotification(currentConfig)
}
Intent.ACTION_SCREEN_ON -> {
Log.d(ANG_PACKAGE, "SCREEN_ON, start querying stats")
startSpeedNotification()
Log.i(AppConfig.TAG, "SCREEN_ON, start querying stats")
NotificationService.startSpeedNotification(currentConfig)
}
}
}
}
private fun measureV2rayDelay() {
CoroutineScope(Dispatchers.IO).launch {
val service = serviceControl?.get()?.getService() ?: return@launch
var time = -1L
var errstr = ""
if (v2rayPoint.isRunning) {
try {
time = v2rayPoint.measureDelay(Utils.getDelayTestUrl())
} catch (e: Exception) {
Log.d(ANG_PACKAGE, "measureV2rayDelay: $e")
errstr = e.message?.substringAfter("\":") ?: "empty message"
}
if (time == -1L) {
try {
time = v2rayPoint.measureDelay(Utils.getDelayTestUrl(true))
} catch (e: Exception) {
Log.d(ANG_PACKAGE, "measureV2rayDelay: $e")
errstr = e.message?.substringAfter("\":") ?: "empty message"
}
}
}
val result = if (time == -1L) {
service.getString(R.string.connection_test_error, errstr)
} else {
service.getString(R.string.connection_test_available, time)
}
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, result)
}
}
private fun showNotification() {
val service = serviceControl?.get()?.getService() ?: return
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val startMainIntent = Intent(service, MainActivity::class.java)
val contentPendingIntent = PendingIntent.getActivity(service, NOTIFICATION_PENDING_INTENT_CONTENT, startMainIntent, flags)
val stopV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
stopV2RayIntent.`package` = ANG_PACKAGE
stopV2RayIntent.putExtra("key", AppConfig.MSG_STATE_STOP)
val stopV2RayPendingIntent = PendingIntent.getBroadcast(service, NOTIFICATION_PENDING_INTENT_STOP_V2RAY, stopV2RayIntent, flags)
val restartV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
restartV2RayIntent.`package` = ANG_PACKAGE
restartV2RayIntent.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
)
//.build()
//mBuilder?.setDefaults(NotificationCompat.FLAG_ONLY_ALERT_ONCE) //取消震动,铃声其他都不好使
service.startForeground(NOTIFICATION_ID, mBuilder?.build())
}
@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
}
fun cancelNotification() {
val service = serviceControl?.get()?.getService() ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
service.stopForeground(Service.STOP_FOREGROUND_REMOVE)
} else {
service.stopForeground(true)
}
mBuilder = null
mDisposable?.dispose()
mDisposable = null
}
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) // Emui4.1 need content text even if style is set as BigTextStyle
getNotificationManager()?.notify(NOTIFICATION_ID, mBuilder?.build())
}
}
private fun getNotificationManager(): NotificationManager? {
if (mNotificationManager == null) {
val service = serviceControl?.get()?.getService() ?: return null
mNotificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
return mNotificationManager
}
private fun startSpeedNotification() {
if (mDisposable == null &&
v2rayPoint.isRunning &&
MmkvManager.decodeSettingsBool(AppConfig.PREF_SPEED_ENABLED) == true
) {
var lastZeroSpeed = false
val outboundTags = currentConfig?.getAllOutboundTags()
outboundTags?.remove(TAG_DIRECT)
mDisposable = Observable.interval(3, java.util.concurrent.TimeUnit.SECONDS)
.subscribe {
val queryTime = System.currentTimeMillis()
val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0
var proxyTotal = 0L
val text = StringBuilder()
outboundTags?.forEach {
val up = v2rayPoint.queryStats(it, AppConfig.UPLINK)
val down = v2rayPoint.queryStats(it, AppConfig.DOWNLINK)
if (up + down > 0) {
appendSpeedString(text, it, up / sinceLastQueryInSeconds, down / sinceLastQueryInSeconds)
proxyTotal += up + down
}
}
val directUplink = v2rayPoint.queryStats(TAG_DIRECT, AppConfig.UPLINK)
val directDownlink = v2rayPoint.queryStats(TAG_DIRECT, AppConfig.DOWNLINK)
val zeroSpeed = proxyTotal == 0L && directUplink == 0L && directDownlink == 0L
if (!zeroSpeed || !lastZeroSpeed) {
if (proxyTotal == 0L) {
appendSpeedString(text, outboundTags?.firstOrNull(), 0.0, 0.0)
}
appendSpeedString(
text, TAG_DIRECT, directUplink / sinceLastQueryInSeconds,
directDownlink / sinceLastQueryInSeconds
)
updateNotification(text.toString(), proxyTotal, directDownlink + directUplink)
}
lastZeroSpeed = zeroSpeed
lastQueryTime = queryTime
}
}
}
private fun appendSpeedString(text: StringBuilder, name: String?, up: Double, down: Double) {
var n = name ?: "no tag"
n = n.substring(0, 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")
}
private fun stopSpeedNotification() {
mDisposable?.let {
it.dispose() //stop queryStats
mDisposable = null
updateNotification(currentConfig?.remarks, 0, 0)
}
}
}

View file

@ -9,10 +9,10 @@ import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_SUCCESS
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SpeedtestManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.PluginUtil
import com.v2ray.ang.util.SpeedtestUtil
import com.v2ray.ang.util.Utils
import go.Seq
import kotlinx.coroutines.CoroutineScope
@ -26,12 +26,22 @@ import java.util.concurrent.Executors
class V2RayTestService : Service() {
private val realTestScope by lazy { CoroutineScope(Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()).asCoroutineDispatcher()) }
/**
* Initializes the V2Ray environment.
*/
override fun onCreate() {
super.onCreate()
Seq.setContext(this)
Libv2ray.initV2Env(Utils.userAssetPath(this), Utils.getDeviceIdForXUDPBaseKey())
Libv2ray.initCoreEnv(Utils.userAssetPath(this), Utils.getDeviceIdForXUDPBaseKey())
}
/**
* Handles the start command for the service.
* @param intent The intent.
* @param flags The flags.
* @param startId The start ID.
* @return The start mode.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.getIntExtra("key", 0)) {
MSG_MEASURE_CONFIG -> {
@ -49,10 +59,20 @@ class V2RayTestService : Service() {
return super.onStartCommand(intent, flags, startId)
}
/**
* Binds the service.
* @param intent The intent.
* @return The binder.
*/
override fun onBind(intent: Intent?): IBinder? {
return null
}
/**
* Starts the real ping test.
* @param guid The GUID of the configuration.
* @return The ping result.
*/
private fun startRealPing(guid: String): Long {
val retFailure = -1L
@ -61,11 +81,11 @@ class V2RayTestService : Service() {
val delay = PluginUtil.realPingHy2(this, config)
return delay
} else {
val config = V2rayConfigManager.getV2rayConfig(this, guid)
if (!config.status) {
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(this, guid)
if (!configResult.status) {
return retFailure
}
return SpeedtestUtil.realPing(config.content)
return SpeedtestManager.realPing(configResult.content)
}
}
}

View file

@ -18,10 +18,8 @@ import android.os.StrictMode
import android.util.Log
import androidx.annotation.RequiresApi
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.MyContextWrapper
@ -35,18 +33,11 @@ import java.lang.ref.SoftReference
class V2RayVpnService : VpnService(), ServiceControl {
companion object {
private const val VPN_MTU = 1500
private const val PRIVATE_VLAN4_CLIENT = "10.10.10.1"
private const val PRIVATE_VLAN4_ROUTER = "10.10.10.2"
private const val PRIVATE_VLAN6_CLIENT = "fc00::10:10:10:1"
private const val PRIVATE_VLAN6_ROUTER = "fc00::10:10:10:2"
private const val TUN2SOCKS = "libtun2socks.so"
}
private lateinit var mInterface: ParcelFileDescriptor
private var isRunning = false
//val fd: Int get() = mInterface.fd
private lateinit var process: Process
/**destroy
@ -88,7 +79,6 @@ class V2RayVpnService : VpnService(), ServiceControl {
override fun onCreate() {
super.onCreate()
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
StrictMode.setThreadPolicy(policy)
V2RayServiceManager.serviceControl = SoftReference(this)
@ -105,225 +95,17 @@ class V2RayVpnService : VpnService(), ServiceControl {
override fun onDestroy() {
super.onDestroy()
V2RayServiceManager.cancelNotification()
}
private fun setup() {
val prepare = prepare(this)
if (prepare != null) {
return
}
// If the old interface has exactly the same parameters, use it!
// Configure a builder while parsing the parameters.
val builder = Builder()
//val enableLocalDns = defaultDPreference.getPrefBoolean(AppConfig.PREF_LOCAL_DNS_ENABLED, false)
builder.setMtu(VPN_MTU)
builder.addAddress(PRIVATE_VLAN4_CLIENT, 30)
//builder.addDnsServer(PRIVATE_VLAN4_ROUTER)
val bypassLan = SettingsManager.routingRulesetsBypassLan()
if (bypassLan) {
resources.getStringArray(R.array.bypass_private_ip_address).forEach {
val addr = it.split('/')
builder.addRoute(addr[0], addr[1].toInt())
}
} else {
builder.addRoute("0.0.0.0", 0)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true) {
builder.addAddress(PRIVATE_VLAN6_CLIENT, 126)
if (bypassLan) {
builder.addRoute("2000::", 3) //currently only 1/8 of total ipV6 is in use
} else {
builder.addRoute("::", 0)
}
}
// if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
// builder.addDnsServer(PRIVATE_VLAN4_ROUTER)
// } else {
Utils.getVpnDnsServers()
.forEach {
if (Utils.isPureIpAddress(it)) {
builder.addDnsServer(it)
}
}
// }
builder.setSession(V2RayServiceManager.currentConfig?.remarks.orEmpty())
val selfPackageName = BuildConfig.APPLICATION_ID
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY)) {
val apps = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)
val bypassApps = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS)
//process self package
if (bypassApps) apps?.add(selfPackageName) else apps?.remove(selfPackageName)
apps?.forEach {
try {
if (bypassApps)
builder.addDisallowedApplication(it)
else
builder.addAllowedApplication(it)
} catch (e: PackageManager.NameNotFoundException) {
Log.d(ANG_PACKAGE, "setup error : --${e.localizedMessage}")
}
}
} else {
builder.addDisallowedApplication(selfPackageName)
}
// Close the old interface since the parameters have been changed.
try {
mInterface.close()
} catch (ignored: Exception) {
// ignored
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
} catch (e: Exception) {
e.printStackTrace()
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
builder.setMetered(false)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_APPEND_HTTP_PROXY)) {
builder.setHttpProxy(ProxyInfo.buildDirectProxy(LOOPBACK, SettingsManager.getHttpPort()))
}
}
// Create a new interface using the builder and save the parameters.
try {
mInterface = builder.establish()!!
isRunning = true
runTun2socks()
} catch (e: Exception) {
// non-nullable lateinit var
e.printStackTrace()
stopV2Ray()
}
}
private fun runTun2socks() {
val socksPort = SettingsManager.getSocksPort()
val cmd = arrayListOf(
File(applicationContext.applicationInfo.nativeLibraryDir, TUN2SOCKS).absolutePath,
"--netif-ipaddr", PRIVATE_VLAN4_ROUTER,
"--netif-netmask", "255.255.255.252",
"--socks-server-addr", "$LOOPBACK:${socksPort}",
"--tunmtu", VPN_MTU.toString(),
"--sock-path", "sock_path",//File(applicationContext.filesDir, "sock_path").absolutePath,
"--enable-udprelay",
"--loglevel", "notice"
)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6)) {
cmd.add("--netif-ip6addr")
cmd.add(PRIVATE_VLAN6_ROUTER)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED)) {
val localDnsPort = Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_LOCAL_DNS_PORT), AppConfig.PORT_LOCAL_DNS.toInt())
cmd.add("--dnsgw")
cmd.add("$LOOPBACK:${localDnsPort}")
}
Log.d(packageName, cmd.toString())
try {
val proBuilder = ProcessBuilder(cmd)
proBuilder.redirectErrorStream(true)
process = proBuilder
.directory(applicationContext.filesDir)
.start()
Thread {
Log.d(packageName, "$TUN2SOCKS check")
process.waitFor()
Log.d(packageName, "$TUN2SOCKS exited")
if (isRunning) {
Log.d(packageName, "$TUN2SOCKS restart")
runTun2socks()
}
}.start()
Log.d(packageName, process.toString())
sendFd()
} catch (e: Exception) {
Log.d(packageName, e.toString())
}
}
private fun sendFd() {
val fd = mInterface.fileDescriptor
val path = File(applicationContext.filesDir, "sock_path").absolutePath
Log.d(packageName, path)
CoroutineScope(Dispatchers.IO).launch {
var tries = 0
while (true) try {
Thread.sleep(50L shl tries)
Log.d(packageName, "sendFd tries: $tries")
LocalSocket().use { localSocket ->
localSocket.connect(LocalSocketAddress(path, LocalSocketAddress.Namespace.FILESYSTEM))
localSocket.setFileDescriptorsForSend(arrayOf(fd))
localSocket.outputStream.write(42)
}
break
} catch (e: Exception) {
Log.d(packageName, e.toString())
if (tries > 5) break
tries += 1
}
}
NotificationService.cancelNotification()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
V2RayServiceManager.startV2rayPoint()
if (V2RayServiceManager.startCoreLoop()) {
startService()
}
return START_STICKY
//return super.onStartCommand(intent, flags, startId)
}
private fun stopV2Ray(isForced: Boolean = true) {
// val configName = defaultDPreference.getPrefString(PREF_CURR_CONFIG_GUID, "")
// val emptyInfo = VpnNetworkInfo()
// val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo)
// saveVpnNetworkInfo(configName, info)
isRunning = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
connectivity.unregisterNetworkCallback(defaultNetworkCallback)
} catch (ignored: Exception) {
// ignored
}
}
try {
Log.d(packageName, "tun2socks destroy")
process.destroy()
} catch (e: Exception) {
Log.d(packageName, e.toString())
}
V2RayServiceManager.stopV2rayPoint()
if (isForced) {
//stopSelf has to be called ahead of mInterface.close(). otherwise v2ray core cannot be stooped
//It's strage but true.
//This can be verified by putting stopself() behind and call stopLoop and startLoop
//in a row for several times. You will find that later created v2ray core report port in use
//which means the first v2ray core somehow failed to stop and release the port.
stopSelf()
try {
mInterface.close()
} catch (ignored: Exception) {
// ignored
}
}
}
override fun getService(): Service {
return this
}
@ -343,8 +125,251 @@ class V2RayVpnService : VpnService(), ServiceControl {
@RequiresApi(Build.VERSION_CODES.N)
override fun attachBaseContext(newBase: Context?) {
val context = newBase?.let {
MyContextWrapper.wrap(newBase, Utils.getLocale())
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
}
super.attachBaseContext(context)
}
/**
* Sets up the VPN service.
* Prepares the VPN and configures it if preparation is successful.
*/
private fun setup() {
val prepare = prepare(this)
if (prepare != null) {
return
}
if (setupVpnService() != true) {
return
}
runTun2socks()
}
/**
* Configures the VPN service.
* @return True if the VPN service was configured successfully, false otherwise.
*/
private fun setupVpnService(): Boolean {
// If the old interface has exactly the same parameters, use it!
// Configure a builder while parsing the parameters.
val builder = Builder()
val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
//val enableLocalDns = defaultDPreference.getPrefBoolean(AppConfig.PREF_LOCAL_DNS_ENABLED, false)
builder.setMtu(VPN_MTU)
builder.addAddress(vpnConfig.ipv4Client, 30)
//builder.addDnsServer(PRIVATE_VLAN4_ROUTER)
val bypassLan = SettingsManager.routingRulesetsBypassLan()
if (bypassLan) {
AppConfig.ROUTED_IP_LIST.forEach {
val addr = it.split('/')
builder.addRoute(addr[0], addr[1].toInt())
}
} else {
builder.addRoute("0.0.0.0", 0)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true) {
builder.addAddress(vpnConfig.ipv6Client, 126)
if (bypassLan) {
builder.addRoute("2000::", 3) //currently only 1/8 of total ipV6 is in use
builder.addRoute("fc00::", 18) //Xray-core default FakeIPv6 Pool
} else {
builder.addRoute("::", 0)
}
}
// if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
// builder.addDnsServer(PRIVATE_VLAN4_ROUTER)
// } else {
SettingsManager.getVpnDnsServers()
.forEach {
if (Utils.isPureIpAddress(it)) {
builder.addDnsServer(it)
}
}
// }
builder.setSession(V2RayServiceManager.getRunningServerName())
val selfPackageName = BuildConfig.APPLICATION_ID
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY)) {
val apps = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)
val bypassApps = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS)
//process self package
if (bypassApps) apps?.add(selfPackageName) else apps?.remove(selfPackageName)
apps?.forEach {
try {
if (bypassApps)
builder.addDisallowedApplication(it)
else
builder.addAllowedApplication(it)
} catch (e: PackageManager.NameNotFoundException) {
Log.e(AppConfig.TAG, "Failed to configure app in VPN: ${e.localizedMessage}", e)
}
}
} else {
builder.addDisallowedApplication(selfPackageName)
}
// Close the old interface since the parameters have been changed.
try {
mInterface.close()
} catch (ignored: Exception) {
// ignored
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to request default network", e)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
builder.setMetered(false)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_APPEND_HTTP_PROXY)) {
builder.setHttpProxy(ProxyInfo.buildDirectProxy(LOOPBACK, SettingsManager.getHttpPort()))
}
}
// Create a new interface using the builder and save the parameters.
try {
mInterface = builder.establish()!!
isRunning = true
return true
} catch (e: Exception) {
// non-nullable lateinit var
Log.e(AppConfig.TAG, "Failed to establish VPN interface", e)
stopV2Ray()
}
return false
}
/**
* Runs the tun2socks process.
* Starts the tun2socks process with the appropriate parameters.
*/
private fun runTun2socks() {
Log.i(AppConfig.TAG, "Start run $TUN2SOCKS")
val socksPort = SettingsManager.getSocksPort()
val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
val cmd = arrayListOf(
File(applicationContext.applicationInfo.nativeLibraryDir, TUN2SOCKS).absolutePath,
"--netif-ipaddr", vpnConfig.ipv4Router,
"--netif-netmask", "255.255.255.252",
"--socks-server-addr", "$LOOPBACK:${socksPort}",
"--tunmtu", VPN_MTU.toString(),
"--sock-path", "sock_path",//File(applicationContext.filesDir, "sock_path").absolutePath,
"--enable-udprelay",
"--loglevel", "notice"
)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6)) {
cmd.add("--netif-ip6addr")
cmd.add(vpnConfig.ipv6Router)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED)) {
val localDnsPort = Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_LOCAL_DNS_PORT), AppConfig.PORT_LOCAL_DNS.toInt())
cmd.add("--dnsgw")
cmd.add("$LOOPBACK:${localDnsPort}")
}
Log.i(AppConfig.TAG, cmd.toString())
try {
val proBuilder = ProcessBuilder(cmd)
proBuilder.redirectErrorStream(true)
process = proBuilder
.directory(applicationContext.filesDir)
.start()
Thread {
Log.i(AppConfig.TAG, "$TUN2SOCKS check")
process.waitFor()
Log.i(AppConfig.TAG, "$TUN2SOCKS exited")
if (isRunning) {
Log.i(AppConfig.TAG, "$TUN2SOCKS restart")
runTun2socks()
}
}.start()
Log.i(AppConfig.TAG, "$TUN2SOCKS process info : ${process.toString()}")
sendFd()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to start $TUN2SOCKS process", e)
}
}
/**
* Sends the file descriptor to the tun2socks process.
* Attempts to send the file descriptor multiple times if necessary.
*/
private fun sendFd() {
val fd = mInterface.fileDescriptor
val path = File(applicationContext.filesDir, "sock_path").absolutePath
Log.i(AppConfig.TAG, "LocalSocket path : $path")
CoroutineScope(Dispatchers.IO).launch {
var tries = 0
while (true) try {
Thread.sleep(50L shl tries)
Log.i(AppConfig.TAG, "LocalSocket sendFd tries: $tries")
LocalSocket().use { localSocket ->
localSocket.connect(LocalSocketAddress(path, LocalSocketAddress.Namespace.FILESYSTEM))
localSocket.setFileDescriptorsForSend(arrayOf(fd))
localSocket.outputStream.write(42)
}
break
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to send file descriptor, try: $tries", e)
if (tries > 5) break
tries += 1
}
}
}
/**
* Stops the V2Ray service.
* @param isForced Whether to force stop the service.
*/
private fun stopV2Ray(isForced: Boolean = true) {
// val configName = defaultDPreference.getPrefString(PREF_CURR_CONFIG_GUID, "")
// val emptyInfo = VpnNetworkInfo()
// val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo)
// saveVpnNetworkInfo(configName, info)
isRunning = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
connectivity.unregisterNetworkCallback(defaultNetworkCallback)
} catch (ignored: Exception) {
// ignored
}
}
try {
Log.i(AppConfig.TAG, "$TUN2SOCKS destroy")
process.destroy()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to destroy $TUN2SOCKS process", e)
}
V2RayServiceManager.stopCoreLoop()
if (isForced) {
//stopSelf has to be called ahead of mInterface.close(). otherwise v2ray core cannot be stooped
//It's strage but true.
//This can be verified by putting stopself() behind and call stopLoop and startLoop
//in a row for several times. You will find that later created v2ray core report port in use
//which means the first v2ray core somehow failed to stop and release the port.
stopSelf()
try {
mInterface.close()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to close VPN interface", e)
}
}
}
}

View file

@ -6,26 +6,42 @@ import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.tbruyelle.rxpermissions3.RxPermissions
import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityAboutBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.util.SpeedtestUtil
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SpeedtestManager
import com.v2ray.ang.util.Utils
import com.v2ray.ang.util.ZipUtil
import java.io.File
import java.text.SimpleDateFormat
import java.util.Locale
class AboutActivity : BaseActivity() {
private val binding by lazy { ActivityAboutBinding.inflate(layoutInflater) }
private val extDir by lazy { File(Utils.backupPath(this)) }
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
try {
showFileChooser()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to show file chooser", e)
}
} else {
toast(R.string.toast_permission_denied)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
@ -33,12 +49,13 @@ class AboutActivity : BaseActivity() {
title = getString(R.string.title_about)
binding.tvBackupSummary.text = this.getString(R.string.summary_configuration_backup, extDir)
binding.layoutBackup.setOnClickListener {
val ret = backupConfiguration(extDir.absolutePath)
if (ret.first) {
toast(R.string.toast_success)
toastSuccess(R.string.toast_success)
} else {
toast(R.string.toast_failure)
toastError(R.string.toast_failure)
}
}
@ -50,67 +67,69 @@ class AboutActivity : BaseActivity() {
Intent(Intent.ACTION_SEND).setType("application/zip")
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putExtra(
Intent.EXTRA_STREAM, FileProvider.getUriForFile(
Intent.EXTRA_STREAM,
FileProvider.getUriForFile(
this, BuildConfig.APPLICATION_ID + ".cache", File(ret.second)
)
), getString(R.string.title_configuration_share)
)
)
} else {
toast(R.string.toast_failure)
toastError(R.string.toast_failure)
}
}
binding.layoutRestore.setOnClickListener {
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
RxPermissions(this)
.request(permission)
.subscribe {
if (it) {
try {
showFileChooser()
} catch (e: Exception) {
e.printStackTrace()
}
} else
toast(R.string.toast_permission_denied)
val permission =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
if (ContextCompat.checkSelfPermission(this, permission) == android.content.pm.PackageManager.PERMISSION_GRANTED) {
try {
showFileChooser()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to show file chooser", e)
}
} else {
requestPermissionLauncher.launch(permission)
}
}
binding.layoutSoureCcode.setOnClickListener {
Utils.openUri(this, AppConfig.v2rayNGUrl)
Utils.openUri(this, AppConfig.APP_URL)
}
binding.layoutFeedback.setOnClickListener {
Utils.openUri(this, AppConfig.v2rayNGIssues)
Utils.openUri(this, AppConfig.APP_ISSUES_URL)
}
binding.layoutOssLicenses.setOnClickListener{
val webView = android.webkit.WebView(this);
webView.loadUrl("file:///android_asset/open_source_licenses.html");
binding.layoutOssLicenses.setOnClickListener {
val webView = android.webkit.WebView(this)
webView.loadUrl("file:///android_asset/open_source_licenses.html")
android.app.AlertDialog.Builder(this)
.setTitle("Open source licenses")
.setView(webView)
.setPositiveButton("OK", android.content.DialogInterface.OnClickListener { dialog, whichButton -> dialog.dismiss() }).show()
.setPositiveButton("OK") { dialog, _ -> dialog.dismiss() }
.show()
}
binding.layoutTgChannel.setOnClickListener {
Utils.openUri(this, AppConfig.TgChannelUrl)
Utils.openUri(this, AppConfig.TG_CHANNEL_URL)
}
binding.layoutPrivacyPolicy.setOnClickListener {
Utils.openUri(this, AppConfig.v2rayNGPrivacyPolicy)
Utils.openUri(this, AppConfig.APP_PRIVACY_POLICY)
}
"v${BuildConfig.VERSION_NAME} (${SpeedtestUtil.getLibVersion()})".also {
"v${BuildConfig.VERSION_NAME} (${SpeedtestManager.getLibVersion()})".also {
binding.tvVersion.text = it
}
}
fun backupConfiguration(outputZipFilePos: String): Pair<Boolean, String> {
private fun backupConfiguration(outputZipFilePos: String): Pair<Boolean, String> {
val dateFormated = SimpleDateFormat(
"yyyy-MM-dd-HH-mm-ss",
Locale.getDefault()
@ -131,7 +150,7 @@ class AboutActivity : BaseActivity() {
}
}
fun restoreConfiguration(zipFile: File): Boolean {
private fun restoreConfiguration(zipFile: File): Boolean {
val backupDir = this.cacheDir.absolutePath + "/${System.currentTimeMillis()}"
if (!ZipUtil.unzipToFolder(zipFile, backupDir)) {
@ -151,15 +170,15 @@ class AboutActivity : BaseActivity() {
try {
chooseFile.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
} catch (ex: android.content.ActivityNotFoundException) {
Log.e(AppConfig.ANG_PACKAGE, "File chooser activity not found: ${ex.message}", ex)
Log.e(AppConfig.TAG, "File chooser activity not found", ex)
toast(R.string.toast_require_file_manager)
}
}
private val chooseFile =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val uri = it.data?.data
if (it.resultCode == RESULT_OK && uri != null) {
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == RESULT_OK && uri != null) {
try {
val targetFile =
File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
@ -169,15 +188,14 @@ class AboutActivity : BaseActivity() {
}
}
if (restoreConfiguration(targetFile)) {
toast(R.string.toast_success)
toastSuccess(R.string.toast_success)
} else {
toast(R.string.toast_failure)
toastError(R.string.toast_failure)
}
} catch (e: Exception) {
Log.e(AppConfig.ANG_PACKAGE, "Error during file restore: ${e.message}", e)
toast(R.string.toast_failure)
Log.e(AppConfig.TAG, "Error during file restore", e)
toastError(R.string.toast_failure)
}
}
}
}

View file

@ -6,10 +6,16 @@ import android.os.Bundle
import android.view.MenuItem
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.CustomDividerItemDecoration
import com.v2ray.ang.util.MyContextWrapper
import com.v2ray.ang.util.Utils
abstract class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -34,6 +40,26 @@ abstract class BaseActivity : AppCompatActivity() {
@RequiresApi(Build.VERSION_CODES.N)
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(MyContextWrapper.wrap(newBase ?: return, Utils.getLocale()))
super.attachBaseContext(MyContextWrapper.wrap(newBase ?: return, SettingsManager.getLocale()))
}
/**
* Adds a custom divider to a RecyclerView.
*
* @param recyclerView The target RecyclerView to which the divider will be added.
* @param context The context used to access resources.
* @param drawableResId The resource ID of the drawable to be used as the divider.
* @param orientation The orientation of the divider (DividerItemDecoration.VERTICAL or DividerItemDecoration.HORIZONTAL).
*/
fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, context: Context?, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
// Get the drawable from resources
val drawable = ContextCompat.getDrawable(context!!, drawableResId)
requireNotNull(drawable) { "Drawable resource not found" }
// Create a DividerItemDecoration with the specified orientation
val dividerItemDecoration = CustomDividerItemDecoration(drawable, orientation)
// Add the divider to the RecyclerView
recyclerView.addItemDecoration(dividerItemDecoration)
}
}

View file

@ -0,0 +1,77 @@
package com.v2ray.ang.ui
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityCheckUpdateBinding
import com.v2ray.ang.dto.CheckUpdateResult
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SpeedtestManager
import com.v2ray.ang.handler.UpdateCheckerManager
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.launch
class CheckUpdateActivity : BaseActivity() {
private val binding by lazy { ActivityCheckUpdateBinding.inflate(layoutInflater) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
title = getString(R.string.update_check_for_update)
binding.layoutCheckUpdate.setOnClickListener {
checkForUpdates(binding.checkPreRelease.isChecked)
}
binding.checkPreRelease.setOnCheckedChangeListener { _, isChecked ->
MmkvManager.encodeSettings(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, isChecked)
}
binding.checkPreRelease.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, false)
"v${BuildConfig.VERSION_NAME} (${SpeedtestManager.getLibVersion()})".also {
binding.tvVersion.text = it
}
checkForUpdates(binding.checkPreRelease.isChecked)
}
private fun checkForUpdates(includePreRelease: Boolean) {
toast(R.string.update_checking_for_update)
lifecycleScope.launch {
try {
val result = UpdateCheckerManager.checkForUpdate(includePreRelease)
if (result.hasUpdate) {
showUpdateDialog(result)
} else {
toastSuccess(R.string.update_already_latest_version)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to check for updates: ${e.message}")
toastError(e.message ?: getString(R.string.toast_failure))
}
}
}
private fun showUpdateDialog(result: CheckUpdateResult) {
AlertDialog.Builder(this)
.setTitle(getString(R.string.update_new_version_found, result.latestVersion))
.setMessage(result.releaseNotes)
.setPositiveButton(R.string.update_now) { _, _ ->
result.downloadUrl?.let {
Utils.openUri(this, it)
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}

View file

@ -1,146 +1,156 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.method.ScrollingMovementMethod
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityLogcatBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.IOException
class LogcatActivity : BaseActivity() {
class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
private val throttleManager = ThrottleManager()
private var logsetsAll: MutableList<String> = mutableListOf()
var logsets: MutableList<String> = mutableListOf()
private val adapter by lazy { LogcatRecyclerAdapter(this) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
title = getString(R.string.title_logcat)
logcat(false)
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
binding.recyclerView.adapter = adapter
binding.refreshLayout.setOnRefreshListener(this)
logsets.add(getString(R.string.pull_down_to_refresh))
}
class ThrottleManager {
private val throttleMap = mutableMapOf<String, Long>()
private fun getLogcat() {
companion object {
private const val THROTTLE_DURATION = 1000L
}
@Synchronized
fun shouldProcess(key: String): Boolean {
val currentTime = System.currentTimeMillis()
val lastProcessTime = throttleMap[key] ?: 0L
return if (currentTime - lastProcessTime > THROTTLE_DURATION) {
throttleMap[key] = currentTime
true
} else {
false
}
}
@Synchronized
fun reset(key: String) {
throttleMap.remove(key)
}
}
private fun logcat(shouldFlushLog: Boolean) {
binding.pbWaiting.visibility = View.VISIBLE
lifecycleScope.launch(Dispatchers.Default) {
try {
if (shouldFlushLog) {
val lst = linkedSetOf("logcat", "-c")
withContext(Dispatchers.IO) {
val process = Runtime.getRuntime().exec(lst.toTypedArray())
process.waitFor()
}
}
val lst = linkedSetOf(
"logcat", "-d", "-v", "time", "-s",
"GoLog,tun2socks,$ANG_PACKAGE,AndroidRuntime,System.err"
)
try {
binding.refreshLayout.isRefreshing = true
lifecycleScope.launch(Dispatchers.Default) {
val lst = LinkedHashSet<String>()
lst.add("logcat")
lst.add("-d")
lst.add("-v")
lst.add("time")
lst.add("-s")
lst.add("GoLog,tun2socks,${ANG_PACKAGE},AndroidRuntime,System.err")
val process = withContext(Dispatchers.IO) {
Runtime.getRuntime().exec(lst.toTypedArray())
}
val allLogs = process.inputStream.bufferedReader().use { it.readLines() }
val filteredLogs = processLogs(allLogs)
withContext(Dispatchers.Main) {
updateLogDisplay(filteredLogs)
val allText = process.inputStream.bufferedReader().use { it.readLines() }.reversed()
launch(Dispatchers.Main) {
logsetsAll = allText.toMutableList()
logsets = allText.toMutableList()
refreshData()
binding.refreshLayout.isRefreshing = false
}
} catch (e: IOException) {
withContext(Dispatchers.Main) {
binding.pbWaiting.visibility = View.GONE
toast(R.string.toast_failure)
}
e.printStackTrace()
}
} catch (e: IOException) {
Log.e(AppConfig.TAG, "Failed to get logcat", e)
}
}
private fun processLogs(logs: List<String>): List<String> {
val processedLogs = mutableListOf<String>()
var isNotMatch = false
for (line in logs) {
when {
line.contains("zxing.NotFoundException", ignoreCase = true) -> {
if (!isNotMatch) {
if (throttleManager.shouldProcess("NotFoundException")) {
processedLogs.add(line)
isNotMatch = true
}
}
private fun clearLogcat() {
try {
lifecycleScope.launch(Dispatchers.Default) {
val lst = LinkedHashSet<String>()
lst.add("logcat")
lst.add("-c")
withContext(Dispatchers.IO) {
val process = Runtime.getRuntime().exec(lst.toTypedArray())
process.waitFor()
}
launch(Dispatchers.Main) {
logsetsAll.clear()
logsets.clear()
refreshData()
}
else -> processedLogs.add(line)
}
}
return processedLogs.take(500)
}
private fun updateLogDisplay(logs: List<String>) {
binding.tvLogcat.text = logs.joinToString("\n")
binding.tvLogcat.movementMethod = ScrollingMovementMethod()
binding.pbWaiting.visibility = View.GONE
Handler(Looper.getMainLooper()).post {
binding.svLogcat.fullScroll(View.FOCUS_DOWN)
} catch (e: IOException) {
Log.e(AppConfig.TAG, "Failed to clear logcat", e)
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_logcat, menu)
val searchItem = menu.findItem(R.id.search_view)
if (searchItem != null) {
val searchView = searchItem.actionView as SearchView
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
filterLogs(newText)
return false
}
})
searchView.setOnCloseListener {
filterLogs("")
false
}
}
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.copy_all -> {
Utils.setClipboard(this, binding.tvLogcat.text.toString())
toast(R.string.toast_success)
Utils.setClipboard(this, logsets.joinToString("\n"))
toastSuccess(R.string.toast_success)
true
}
R.id.clear_all -> {
throttleManager.reset("zxing.NotFoundException")
logcat(true)
clearLogcat()
true
}
else -> super.onOptionsItemSelected(item)
}
private fun filterLogs(content: String?): Boolean {
val key = content?.trim()
logsets = if (key.isNullOrEmpty()) {
logsetsAll.toMutableList()
} else {
logsetsAll.filter { it.contains(key) }.toMutableList()
}
refreshData()
return true
}
override fun onRefresh() {
getLogcat()
}
@SuppressLint("NotifyDataSetChanged")
fun refreshData() {
adapter.notifyDataSetChanged()
}
}

View file

@ -0,0 +1,44 @@
package com.v2ray.ang.ui
import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
class LogcatRecyclerAdapter(val activity: LogcatActivity) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() {
private var mActivity: LogcatActivity = activity
override fun getItemCount() = mActivity.logsets.size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
try {
val log = mActivity.logsets[position]
if (log.isEmpty()) {
holder.itemSubSettingBinding.logTag.text = ""
holder.itemSubSettingBinding.logContent.text = ""
} else {
val content = log.split("):", limit = 2)
holder.itemSubSettingBinding.logTag.text = content.first().split("(", limit = 2).first().trim()
holder.itemSubSettingBinding.logContent.text = if (content.count() > 1) content.last().trim() else ""
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error binding log view data", e)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
return MainViewHolder(
ItemRecyclerLogcatBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
class MainViewHolder(val itemSubSettingBinding: ItemRecyclerLogcatBinding) : RecyclerView.ViewHolder(itemSubSettingBinding.root)
}

View file

@ -1,18 +1,18 @@
package com.v2ray.ang.ui
import android.Manifest
import android.content.ActivityNotFoundException
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.ColorStateList
import android.net.Uri
import android.net.VpnService
import android.os.Build
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
@ -23,17 +23,17 @@ import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.navigation.NavigationView
import com.google.android.material.tabs.TabLayout
import com.tbruyelle.rxpermissions3.RxPermissions
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityMainBinding
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MigrateManager
import com.v2ray.ang.handler.MmkvManager
@ -41,14 +41,10 @@ import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.service.V2RayServiceManager
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.MainViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.drakeet.support.toast.ToastCompat
import java.util.concurrent.TimeUnit
class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedListener {
private val binding by lazy {
@ -81,6 +77,53 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
private var mItemTouchHelper: ItemTouchHelper? = null
val mainViewModel: MainViewModel by viewModels()
// register activity result for requesting permission
private val requestPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
when (pendingAction) {
Action.IMPORT_QR_CODE_CONFIG ->
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java))
Action.READ_CONTENT_FROM_URI ->
chooseFileForCustomConfig.launch(Intent.createChooser(Intent(Intent.ACTION_GET_CONTENT).apply {
type = "*/*"
addCategory(Intent.CATEGORY_OPENABLE)
}, getString(R.string.title_file_chooser)))
Action.POST_NOTIFICATIONS -> {}
else -> {}
}
} else {
toast(R.string.toast_permission_denied)
}
pendingAction = Action.NONE
}
private var pendingAction: Action = Action.NONE
enum class Action {
NONE,
IMPORT_QR_CODE_CONFIG,
READ_CONTENT_FROM_URI,
POST_NOTIFICATIONS
}
private val chooseFileForCustomConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val uri = it.data?.data
if (it.resultCode == RESULT_OK && uri != null) {
readContentFromUri(uri)
}
}
private val scanQRCodeForConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
importBatchConfig(it.data?.getStringExtra("SCAN_RESULT"))
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
@ -89,7 +132,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
binding.fab.setOnClickListener {
if (mainViewModel.isRunning.value == true) {
Utils.stopVService(this)
V2RayServiceManager.stopVService(this)
} else if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: VPN) == VPN) {
val intent = VpnService.prepare(this)
if (intent == null) {
@ -111,7 +154,12 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
}
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
binding.recyclerView.layoutManager = GridLayoutManager(this, 2)
} else {
binding.recyclerView.layoutManager = GridLayoutManager(this, 1)
}
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
binding.recyclerView.adapter = adapter
mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
@ -129,12 +177,10 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
migrateLegacy()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
RxPermissions(this)
.request(Manifest.permission.POST_NOTIFICATIONS)
.subscribe {
if (!it)
toast(R.string.toast_permission_denied_notification)
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
pendingAction = Action.POST_NOTIFICATIONS
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
@ -142,13 +188,15 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
binding.drawerLayout.closeDrawer(GravityCompat.START)
} else {
//super.onBackPressed()
isEnabled = false
onBackPressedDispatcher.onBackPressed()
isEnabled = true
}
}
})
}
@SuppressLint("NotifyDataSetChanged")
private fun setupViewModel() {
mainViewModel.updateListAction.observe(this) { index ->
if (index >= 0) {
@ -214,23 +262,22 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
binding.tabGroup.isVisible = true
}
fun startV2Ray() {
private fun startV2Ray() {
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
toast(R.string.title_file_chooser)
return
}
V2RayServiceManager.startV2Ray(this)
V2RayServiceManager.startVService(this)
}
fun restartV2Ray() {
private fun restartV2Ray() {
if (mainViewModel.isRunning.value == true) {
Utils.stopVService(this)
V2RayServiceManager.stopVService(this)
}
lifecycleScope.launch {
delay(500)
startV2Ray()
}
Observable.timer(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
startV2Ray()
}
}
public override fun onResume() {
@ -267,7 +314,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.import_qrcode -> {
importQRcode(true)
importQRcode()
true
}
@ -276,6 +323,11 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
true
}
R.id.import_local -> {
importConfigLocal()
true
}
R.id.import_manually_vmess -> {
importManually(EConfigType.VMESS.value)
true
@ -316,44 +368,8 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
true
}
R.id.import_config_custom_clipboard -> {
importConfigCustomClipboard()
true
}
R.id.import_config_custom_local -> {
importConfigCustomLocal()
true
}
R.id.import_config_custom_url -> {
importConfigCustomUrlClipboard()
true
}
R.id.import_config_custom_url_scan -> {
importQRcode(false)
true
}
R.id.sub_update -> {
importConfigViaSub()
true
}
R.id.export_all -> {
binding.pbWaiting.show()
lifecycleScope.launch(Dispatchers.IO) {
val ret = mainViewModel.exportAllServer()
launch(Dispatchers.Main) {
if (ret > 0)
toast(getString(R.string.title_export_config_count, ret))
else
toast(R.string.toast_failure)
binding.pbWaiting.hide()
}
}
exportAll()
true
}
@ -375,77 +391,31 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
}
R.id.del_all_config -> {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
binding.pbWaiting.show()
lifecycleScope.launch(Dispatchers.IO) {
val ret = mainViewModel.removeAllServer()
launch(Dispatchers.Main) {
mainViewModel.reloadServerList()
toast(getString(R.string.title_del_config_count, ret))
binding.pbWaiting.hide()
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
delAllConfig()
true
}
R.id.del_duplicate_config -> {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
binding.pbWaiting.show()
lifecycleScope.launch(Dispatchers.IO) {
val ret = mainViewModel.removeDuplicateServer()
launch(Dispatchers.Main) {
mainViewModel.reloadServerList()
toast(getString(R.string.title_del_duplicate_config_count, ret))
binding.pbWaiting.hide()
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
delDuplicateConfig()
true
}
R.id.del_invalid_config -> {
AlertDialog.Builder(this).setMessage(R.string.del_invalid_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
binding.pbWaiting.show()
lifecycleScope.launch(Dispatchers.IO) {
val ret = mainViewModel.removeInvalidServer()
launch(Dispatchers.Main) {
mainViewModel.reloadServerList()
toast(getString(R.string.title_del_config_count, ret))
binding.pbWaiting.hide()
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
delInvalidConfig()
true
}
R.id.sort_by_test_results -> {
binding.pbWaiting.show()
lifecycleScope.launch(Dispatchers.IO) {
mainViewModel.sortByTestResults()
launch(Dispatchers.Main) {
mainViewModel.reloadServerList()
binding.pbWaiting.hide()
}
}
sortByTestResults()
true
}
R.id.sub_update -> {
importConfigViaSub()
true
}
else -> super.onOptionsItemSelected(item)
}
@ -461,39 +431,17 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
/**
* import config from qrcode
*/
private fun importQRcode(forConfig: Boolean): Boolean {
// try {
// startActivityForResult(Intent("com.google.zxing.client.android.SCAN")
// .addCategory(Intent.CATEGORY_DEFAULT)
// .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP), requestCode)
// } catch (e: Exception) {
RxPermissions(this)
.request(Manifest.permission.CAMERA)
.subscribe {
if (it)
if (forConfig)
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java))
else
scanQRCodeForUrlToCustomConfig.launch(Intent(this, ScannerActivity::class.java))
else
toast(R.string.toast_permission_denied)
}
// }
private fun importQRcode(): Boolean {
val permission = Manifest.permission.CAMERA
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
scanQRCodeForConfig.launch(Intent(this, ScannerActivity::class.java))
} else {
pendingAction = Action.IMPORT_QR_CODE_CONFIG
requestPermissionLauncher.launch(permission)
}
return true
}
private val scanQRCodeForConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
importBatchConfig(it.data?.getStringExtra("SCAN_RESULT"))
}
}
private val scanQRCodeForUrlToCustomConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
importConfigCustomUrl(it.data?.getStringExtra("SCAN_RESULT"))
}
}
/**
* import config from clipboard
*/
@ -503,7 +451,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
val clipboard = Utils.getClipboard(this)
importBatchConfig(clipboard)
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Failed to import config from clipboard", e)
return false
}
return true
@ -524,100 +472,38 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
}
countSub > 0 -> initGroupTab()
else -> toast(R.string.toast_failure)
else -> toastError(R.string.toast_failure)
}
binding.pbWaiting.hide()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
toast(R.string.toast_failure)
toastError(R.string.toast_failure)
binding.pbWaiting.hide()
}
e.printStackTrace()
Log.e(AppConfig.TAG, "Failed to import batch config", e)
}
}
}
private fun importConfigCustomClipboard()
: Boolean {
try {
val configText = Utils.getClipboard(this)
if (TextUtils.isEmpty(configText)) {
toast(R.string.toast_none_data_clipboard)
return false
}
importCustomizeConfig(configText)
return true
} catch (e: Exception) {
e.printStackTrace()
return false
}
}
/**
* import config from local config file
*/
private fun importConfigCustomLocal(): Boolean {
private fun importConfigLocal(): Boolean {
try {
showFileChooser()
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Failed to import config from local file", e)
return false
}
return true
}
private fun importConfigCustomUrlClipboard()
: Boolean {
try {
val url = Utils.getClipboard(this)
if (TextUtils.isEmpty(url)) {
toast(R.string.toast_none_data_clipboard)
return false
}
return importConfigCustomUrl(url)
} catch (e: Exception) {
e.printStackTrace()
return false
}
}
/**
* import config from url
*/
private fun importConfigCustomUrl(url: String?): Boolean {
try {
if (!Utils.isValidUrl(url)) {
toast(R.string.toast_invalid_url)
return false
}
lifecycleScope.launch(Dispatchers.IO) {
val configText = try {
Utils.getUrlContentWithCustomUserAgent(url)
} catch (e: Exception) {
e.printStackTrace()
""
}
launch(Dispatchers.Main) {
importCustomizeConfig(configText)
}
}
} catch (e: Exception) {
e.printStackTrace()
return false
}
return true
}
/**
* import config from sub
*/
private fun importConfigViaSub(): Boolean {
// val dialog = AlertDialog.Builder(this)
// .setView(LayoutProgressBinding.inflate(layoutInflater).root)
// .setCancelable(false)
// .show()
binding.pbWaiting.show()
lifecycleScope.launch(Dispatchers.IO) {
@ -628,15 +514,96 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
toast(getString(R.string.title_update_config_count, count))
mainViewModel.reloadServerList()
} else {
toast(R.string.toast_failure)
toastError(R.string.toast_failure)
}
//dialog.dismiss()
binding.pbWaiting.hide()
}
}
return true
}
private fun exportAll() {
binding.pbWaiting.show()
lifecycleScope.launch(Dispatchers.IO) {
val ret = mainViewModel.exportAllServer()
launch(Dispatchers.Main) {
if (ret > 0)
toast(getString(R.string.title_export_config_count, ret))
else
toastError(R.string.toast_failure)
binding.pbWaiting.hide()
}
}
}
private fun delAllConfig() {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
binding.pbWaiting.show()
lifecycleScope.launch(Dispatchers.IO) {
val ret = mainViewModel.removeAllServer()
launch(Dispatchers.Main) {
mainViewModel.reloadServerList()
toast(getString(R.string.title_del_config_count, ret))
binding.pbWaiting.hide()
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
}
private fun delDuplicateConfig() {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
binding.pbWaiting.show()
lifecycleScope.launch(Dispatchers.IO) {
val ret = mainViewModel.removeDuplicateServer()
launch(Dispatchers.Main) {
mainViewModel.reloadServerList()
toast(getString(R.string.title_del_duplicate_config_count, ret))
binding.pbWaiting.hide()
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
}
private fun delInvalidConfig() {
AlertDialog.Builder(this).setMessage(R.string.del_invalid_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
binding.pbWaiting.show()
lifecycleScope.launch(Dispatchers.IO) {
val ret = mainViewModel.removeInvalidServer()
launch(Dispatchers.Main) {
mainViewModel.reloadServerList()
toast(getString(R.string.title_del_config_count, ret))
binding.pbWaiting.hide()
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
}
private fun sortByTestResults() {
binding.pbWaiting.show()
lifecycleScope.launch(Dispatchers.IO) {
mainViewModel.sortByTestResults()
launch(Dispatchers.Main) {
mainViewModel.reloadServerList()
binding.pbWaiting.hide()
}
}
}
/**
* show file chooser
*/
@ -645,17 +612,17 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
intent.type = "*/*"
intent.addCategory(Intent.CATEGORY_OPENABLE)
try {
chooseFileForCustomConfig.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
} catch (ex: ActivityNotFoundException) {
toast(R.string.toast_require_file_manager)
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
}
private val chooseFileForCustomConfig = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val uri = it.data?.data
if (it.resultCode == RESULT_OK && uri != null) {
readContentFromUri(uri)
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
pendingAction = Action.READ_CONTENT_FROM_URI
chooseFileForCustomConfig.launch(Intent.createChooser(intent, getString(R.string.title_file_chooser)))
} else {
requestPermissionLauncher.launch(permission)
}
}
@ -668,42 +635,17 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
RxPermissions(this)
.request(permission)
.subscribe {
if (it) {
try {
contentResolver.openInputStream(uri).use { input ->
importCustomizeConfig(input?.bufferedReader()?.readText())
}
} catch (e: Exception) {
e.printStackTrace()
}
} else
toast(R.string.toast_permission_denied)
}
}
/**
* import customize config
*/
private fun importCustomizeConfig(server: String?) {
try {
if (server == null || TextUtils.isEmpty(server)) {
toast(R.string.toast_none_data)
return
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
try {
contentResolver.openInputStream(uri).use { input ->
importBatchConfig(input?.bufferedReader()?.readText())
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to read content from URI", e)
}
if (mainViewModel.appendCustomConfigServer(server)) {
mainViewModel.reloadServerList()
toast(R.string.toast_success)
} else {
toast(R.string.toast_failure)
}
//adapter.notifyItemInserted(mainViewModel.serverList.lastIndex)
} catch (e: Exception) {
ToastCompat.makeText(this, "${getString(R.string.toast_malformed_josn)} ${e.cause?.message}", Toast.LENGTH_LONG).show()
e.printStackTrace()
return
} else {
requestPermissionLauncher.launch(permission)
}
}
@ -732,34 +674,21 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
override fun onNavigationItemSelected(item: MenuItem): Boolean {
// Handle navigation view item clicks here.
when (item.itemId) {
R.id.sub_setting -> {
requestSubSettingActivity.launch(Intent(this, SubSettingActivity::class.java))
}
R.id.sub_setting -> requestSubSettingActivity.launch(Intent(this, SubSettingActivity::class.java))
R.id.per_app_proxy_settings -> startActivity(Intent(this, PerAppProxyActivity::class.java))
R.id.routing_setting -> requestSubSettingActivity.launch(Intent(this, RoutingSettingActivity::class.java))
R.id.user_asset_setting -> startActivity(Intent(this, UserAssetActivity::class.java))
R.id.settings -> startActivity(
Intent(this, SettingsActivity::class.java)
.putExtra("isRunning", mainViewModel.isRunning.value == true)
)
R.id.settings -> {
startActivity(
Intent(this, SettingsActivity::class.java)
.putExtra("isRunning", mainViewModel.isRunning.value == true)
)
}
R.id.routing_setting -> {
requestSubSettingActivity.launch(Intent(this, RoutingSettingActivity::class.java))
}
R.id.promotion -> {
Utils.openUri(this, "${Utils.decode(AppConfig.PromotionUrl)}?t=${System.currentTimeMillis()}")
}
R.id.logcat -> {
startActivity(Intent(this, LogcatActivity::class.java))
}
R.id.about -> {
startActivity(Intent(this, AboutActivity::class.java))
}
R.id.promotion -> Utils.openUri(this, "${Utils.decode(AppConfig.APP_PROMOTION_URL)}?t=${System.currentTimeMillis()}")
R.id.logcat -> startActivity(Intent(this, LogcatActivity::class.java))
R.id.check_for_update -> startActivity(Intent(this, CheckUpdateActivity::class.java))
R.id.about -> startActivity(Intent(this, AboutActivity::class.java))
}
binding.drawerLayout.closeDrawer(GravityCompat.START)
return true
}

View file

@ -3,11 +3,13 @@ package com.v2ray.ang.ui
import android.content.Intent
import android.graphics.Color
import android.text.TextUtils
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AngApplication.Companion.application
import com.v2ray.ang.AppConfig
@ -16,16 +18,18 @@ import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
import com.v2ray.ang.databinding.ItemRecyclerMainBinding
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.service.V2RayServiceManager
import com.v2ray.ang.util.Utils
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter {
companion object {
@ -37,169 +41,278 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
private val share_method: Array<out String> by lazy {
mActivity.resources.getStringArray(R.array.share_method)
}
private val share_method_more: Array<out String> by lazy {
mActivity.resources.getStringArray(R.array.share_method_more)
}
var isRunning = false
private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
/**
* Gets the total number of items in the adapter (servers count + footer view)
* @return The total item count
*/
override fun getItemCount() = mActivity.mainViewModel.serversCache.size + 1
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
if (holder is MainViewHolder) {
val guid = mActivity.mainViewModel.serversCache[position].guid
val profile = mActivity.mainViewModel.serversCache[position].profile
// //filter
// if (mActivity.mainViewModel.subscriptionId.isNotEmpty()
// && mActivity.mainViewModel.subscriptionId != config.subscriptionId
// ) {
// holder.itemMainBinding.cardView.visibility = View.GONE
// } else {
// holder.itemMainBinding.cardView.visibility = View.VISIBLE
// }
val isCustom = profile.configType == EConfigType.CUSTOM
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
holder.itemMainBinding.tvName.text = profile.remarks
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
//Name address
holder.itemMainBinding.tvName.text = profile.remarks
holder.itemMainBinding.tvStatistics.text = getAddress(profile)
holder.itemMainBinding.tvType.text = profile.configType.name
//TestResult
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty()
if ((aff?.testDelayMillis ?: 0L) < 0L) {
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPingRed))
} else {
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(mActivity, R.color.colorPing))
}
//layoutIndicator
if (guid == MmkvManager.getSelectServer()) {
holder.itemMainBinding.layoutIndicator.setBackgroundResource(R.color.colorAccent)
} else {
holder.itemMainBinding.layoutIndicator.setBackgroundResource(0)
}
holder.itemMainBinding.tvSubscription.text = MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks ?: ""
var shareOptions = share_method.asList()
when (profile.configType) {
EConfigType.CUSTOM -> {
holder.itemMainBinding.tvType.text = mActivity.getString(R.string.server_customize_config)
shareOptions = shareOptions.takeLast(1)
//subscription remarks
val subRemarks = getSubscriptionRemarks(profile)
holder.itemMainBinding.tvSubscription.text = subRemarks
holder.itemMainBinding.layoutSubscription.visibility = if (subRemarks.isEmpty()) View.GONE else View.VISIBLE
//layout
if (doubleColumnDisplay) {
holder.itemMainBinding.layoutShare.visibility = View.GONE
holder.itemMainBinding.layoutEdit.visibility = View.GONE
holder.itemMainBinding.layoutRemove.visibility = View.GONE
holder.itemMainBinding.layoutMore.visibility = View.VISIBLE
//share method
val shareOptions = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
holder.itemMainBinding.layoutMore.setOnClickListener {
shareServer(guid, profile, position, shareOptions, if (isCustom) 2 else 0)
}
} else {
holder.itemMainBinding.layoutShare.visibility = View.VISIBLE
holder.itemMainBinding.layoutEdit.visibility = View.VISIBLE
holder.itemMainBinding.layoutRemove.visibility = View.VISIBLE
holder.itemMainBinding.layoutMore.visibility = View.GONE
//share method
val shareOptions = if (isCustom) share_method.asList().takeLast(1) else share_method.asList()
holder.itemMainBinding.layoutShare.setOnClickListener {
shareServer(guid, profile, position, shareOptions, if (isCustom) 2 else 0)
}
else -> {
holder.itemMainBinding.tvType.text = profile.configType.name
holder.itemMainBinding.layoutEdit.setOnClickListener {
editServer(guid, profile)
}
}
// 隐藏主页服务器地址为xxx:xxx:***/xxx.xxx.xxx.***
val strState = "${
profile.server?.let {
if (it.contains(":"))
it.split(":").take(2).joinToString(":", postfix = ":***")
else
it.split('.').dropLast(1).joinToString(".", postfix = ".***")
}
} : ${profile.serverPort}"
holder.itemMainBinding.tvStatistics.text = strState
holder.itemMainBinding.layoutShare.setOnClickListener {
AlertDialog.Builder(mActivity).setItems(shareOptions.toTypedArray()) { _, i ->
try {
when (i) {
0 -> {
if (profile.configType == EConfigType.CUSTOM) {
shareFullContent(guid)
} else {
val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(mActivity))
ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
AlertDialog.Builder(mActivity).setView(ivBinding.root).show()
}
}
1 -> {
if (AngConfigManager.share2Clipboard(mActivity, guid) == 0) {
mActivity.toast(R.string.toast_success)
} else {
mActivity.toast(R.string.toast_failure)
}
}
2 -> shareFullContent(guid)
else -> mActivity.toast("else")
}
} catch (e: Exception) {
e.printStackTrace()
}
}.show()
}
holder.itemMainBinding.layoutEdit.setOnClickListener {
val intent = Intent().putExtra("guid", guid)
.putExtra("isRunning", isRunning)
.putExtra("createConfigType", profile.configType.value)
if (profile.configType == EConfigType.CUSTOM) {
mActivity.startActivity(intent.setClass(mActivity, ServerCustomConfigActivity::class.java))
} else {
mActivity.startActivity(intent.setClass(mActivity, ServerActivity::class.java))
}
}
holder.itemMainBinding.layoutRemove.setOnClickListener {
if (guid != MmkvManager.getSelectServer()) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE) == true) {
AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
removeServer(guid, position)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
} else {
removeServer(guid, position)
}
} else {
application.toast(R.string.toast_action_not_allowed)
holder.itemMainBinding.layoutRemove.setOnClickListener {
removeServer(guid, position)
}
}
holder.itemMainBinding.infoContainer.setOnClickListener {
val selected = MmkvManager.getSelectServer()
if (guid != selected) {
MmkvManager.setSelectServer(guid)
if (!TextUtils.isEmpty(selected)) {
notifyItemChanged(mActivity.mainViewModel.getPosition(selected.orEmpty()))
}
notifyItemChanged(mActivity.mainViewModel.getPosition(guid))
if (isRunning) {
Utils.stopVService(mActivity)
Observable.timer(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
V2RayServiceManager.startV2Ray(mActivity)
}
}
}
}
}
if (holder is FooterViewHolder) {
//if (activity?.defaultDPreference?.getPrefBoolean(AppConfig.PREF_INAPP_BUY_IS_PREMIUM, false)) {
if (true) {
holder.itemFooterBinding.layoutEdit.visibility = View.INVISIBLE
} else {
holder.itemFooterBinding.layoutEdit.setOnClickListener {
Utils.openUri(mActivity, "${Utils.decode(AppConfig.PromotionUrl)}?t=${System.currentTimeMillis()}")
}
setSelectServer(guid)
}
}
// if (holder is FooterViewHolder) {
// if (true) {
// holder.itemFooterBinding.layoutEdit.visibility = View.INVISIBLE
// } else {
// holder.itemFooterBinding.layoutEdit.setOnClickListener {
// Utils.openUri(mActivity, "${Utils.decode(AppConfig.PromotionUrl)}?t=${System.currentTimeMillis()}")
// }
// }
// }
}
private fun shareFullContent(guid: String) {
if (AngConfigManager.shareFullContent2Clipboard(mActivity, guid) == 0) {
mActivity.toast(R.string.toast_success)
/**
* Gets the server address information
* Hides part of IP or domain information for privacy protection
* @param profile The server configuration
* @return Formatted address string
*/
private fun getAddress(profile: ProfileItem): String {
// Hide xxx:xxx:***/xxx.xxx.xxx.***
return "${
profile.server?.let {
if (it.contains(":"))
it.split(":").take(2).joinToString(":", postfix = ":***")
else
it.split('.').dropLast(1).joinToString(".", postfix = ".***")
}
} : ${profile.serverPort}"
}
/**
* Gets the subscription remarks information
* @param profile The server configuration
* @return Subscription remarks string, or empty string if none
*/
private fun getSubscriptionRemarks(profile: ProfileItem): String {
val subRemarks =
if (mActivity.mainViewModel.subscriptionId.isEmpty())
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
else
null
return subRemarks?.toString() ?: ""
}
/**
* Shares server configuration
* Displays a dialog with sharing options and executes the selected action
* @param guid The server unique identifier
* @param profile The server configuration
* @param position The position in the list
* @param shareOptions The list of share options
* @param skip The number of options to skip
*/
private fun shareServer(guid: String, profile: ProfileItem, position: Int, shareOptions: List<String>, skip: Int) {
AlertDialog.Builder(mActivity).setItems(shareOptions.toTypedArray()) { _, i ->
try {
when (i + skip) {
0 -> showQRCode(guid)
1 -> share2Clipboard(guid)
2 -> shareFullContent(guid)
3 -> editServer(guid, profile)
4 -> removeServer(guid, position)
else -> mActivity.toast("else")
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error when sharing server", e)
}
}.show()
}
/**
* Displays QR code for the server configuration
* @param guid The server unique identifier
*/
private fun showQRCode(guid: String) {
val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(mActivity))
ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
AlertDialog.Builder(mActivity).setView(ivBinding.root).show()
}
/**
* Shares server configuration to clipboard
* @param guid The server unique identifier
*/
private fun share2Clipboard(guid: String) {
if (AngConfigManager.share2Clipboard(mActivity, guid) == 0) {
mActivity.toastSuccess(R.string.toast_success)
} else {
mActivity.toast(R.string.toast_failure)
mActivity.toastError(R.string.toast_failure)
}
}
/**
* Shares full server configuration content to clipboard
* @param guid The server unique identifier
*/
private fun shareFullContent(guid: String) {
mActivity.lifecycleScope.launch(Dispatchers.IO) {
val result = AngConfigManager.shareFullContent2Clipboard(mActivity, guid)
launch(Dispatchers.Main) {
if (result == 0) {
mActivity.toastSuccess(R.string.toast_success)
} else {
mActivity.toastError(R.string.toast_failure)
}
}
}
}
/**
* Edits server configuration
* Opens appropriate editing interface based on configuration type
* @param guid The server unique identifier
* @param profile The server configuration
*/
private fun editServer(guid: String, profile: ProfileItem) {
val intent = Intent().putExtra("guid", guid)
.putExtra("isRunning", isRunning)
.putExtra("createConfigType", profile.configType.value)
if (profile.configType == EConfigType.CUSTOM) {
mActivity.startActivity(intent.setClass(mActivity, ServerCustomConfigActivity::class.java))
} else {
mActivity.startActivity(intent.setClass(mActivity, ServerActivity::class.java))
}
}
/**
* Removes server configuration
* Handles confirmation dialog and related checks
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServer(guid: String, position: Int) {
if (guid != MmkvManager.getSelectServer()) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE) == true) {
AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
removeServerSub(guid, position)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
} else {
removeServerSub(guid, position)
}
} else {
application.toast(R.string.toast_action_not_allowed)
}
}
/**
* Executes the actual server removal process
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServerSub(guid: String, position: Int) {
mActivity.mainViewModel.removeServer(guid)
notifyItemRemoved(position)
notifyItemRangeChanged(position, mActivity.mainViewModel.serversCache.size)
}
/**
* Sets the selected server
* Updates UI and restarts service if needed
* @param guid The server unique identifier to select
*/
private fun setSelectServer(guid: String) {
val selected = MmkvManager.getSelectServer()
if (guid != selected) {
MmkvManager.setSelectServer(guid)
if (!TextUtils.isEmpty(selected)) {
notifyItemChanged(mActivity.mainViewModel.getPosition(selected.orEmpty()))
}
notifyItemChanged(mActivity.mainViewModel.getPosition(guid))
if (isRunning) {
V2RayServiceManager.stopVService(mActivity)
mActivity.lifecycleScope.launch {
try {
delay(500)
V2RayServiceManager.startVService(mActivity)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to restart V2Ray service", e)
}
}
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
return when (viewType) {
VIEW_TYPE_ITEM ->

View file

@ -1,35 +1,35 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.Toast
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityBypassListBinding
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.extension.v2RayApplication
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.AppManagerUtil
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.Utils
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import es.dmoral.toasty.Toasty
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.Collator
class PerAppProxyActivity : BaseActivity() {
private val binding by lazy {
ActivityBypassListBinding.inflate(layoutInflater)
}
private val binding by lazy { ActivityBypassListBinding.inflate(layoutInflater) }
private var adapter: PerAppProxyAdapter? = null
private var appsAll: List<AppInfo>? = null
@ -38,98 +38,43 @@ class PerAppProxyActivity : BaseActivity() {
super.onCreate(savedInstanceState)
setContentView(binding.root)
val dividerItemDecoration = DividerItemDecoration(this, LinearLayoutManager.VERTICAL)
binding.recyclerView.addItemDecoration(dividerItemDecoration)
title = getString(R.string.per_app_proxy_settings)
val blacklist = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
AppManagerUtil.rxLoadNetworkAppList(this)
.subscribeOn(Schedulers.io())
.map {
if (blacklist != null) {
it.forEach { one ->
if (blacklist.contains(one.packageName)) {
one.isSelected = 1
} else {
one.isSelected = 0
lifecycleScope.launch {
try {
binding.pbWaiting.show()
val blacklist = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)
val apps = withContext(Dispatchers.IO) {
val appsList = AppManagerUtil.loadNetworkAppList(this@PerAppProxyActivity)
if (blacklist != null) {
appsList.forEach { app ->
app.isSelected = if (blacklist.contains(app.packageName)) 1 else 0
}
}
val comparator = Comparator<AppInfo> { p1, p2 ->
when {
p1.isSelected > p2.isSelected -> -1
p1.isSelected == p2.isSelected -> 0
else -> 1
appsList.sortedWith { p1, p2 ->
when {
p1.isSelected > p2.isSelected -> -1
p1.isSelected == p2.isSelected -> 0
else -> 1
}
}
}
it.sortedWith(comparator)
} else {
val comparator = object : Comparator<AppInfo> {
} else {
val collator = Collator.getInstance()
override fun compare(o1: AppInfo, o2: AppInfo) = collator.compare(o1.appName, o2.appName)
appsList.sortedWith(compareBy(collator) { it.appName })
}
it.sortedWith(comparator)
}
}
// .map {
// val comparator = object : Comparator<AppInfo> {
// val collator = Collator.getInstance()
// override fun compare(o1: AppInfo, o2: AppInfo) = collator.compare(o1.appName, o2.appName)
// }
// it.sortedWith(comparator)
// }
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
appsAll = it
adapter = PerAppProxyAdapter(this, it, blacklist)
appsAll = apps
adapter = PerAppProxyAdapter(this@PerAppProxyActivity, apps, blacklist)
binding.recyclerView.adapter = adapter
binding.pbWaiting.visibility = View.GONE
binding.pbWaiting.hide()
} catch (e: Exception) {
binding.pbWaiting.hide()
Log.e(ANG_PACKAGE, "Error loading apps", e)
}
/***
recycler_view.addOnScrollListener(object : RecyclerView.OnScrollListener() {
var dst = 0
val threshold = resources.getDimensionPixelSize(R.dimen.bypass_list_header_height) * 2
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
dst += dy
if (dst > threshold) {
header_view.hide()
dst = 0
} else if (dst < -20) {
header_view.show()
dst = 0
}
}
var hiding = false
fun View.hide() {
val target = -height.toFloat()
if (hiding || translationY == target) return
animate()
.translationY(target)
.setInterpolator(AccelerateInterpolator(2F))
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
hiding = false
}
})
hiding = true
}
var showing = false
fun View.show() {
val target = 0f
if (showing || translationY == target) return
animate()
.translationY(target)
.setInterpolator(DecelerateInterpolator(2F))
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
showing = false
}
})
showing = true
}
})
***/
binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked ->
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY, isChecked)
@ -141,35 +86,9 @@ class PerAppProxyActivity : BaseActivity() {
}
binding.switchBypassApps.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS, false)
/***
et_search.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
//hide
var imm: InputMethodManager = v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.toggleSoftInput(0, InputMethodManager.HIDE_NOT_ALWAYS)
val key = v.text.toString().toUpperCase()
val apps = ArrayList<AppInfo>()
if (TextUtils.isEmpty(key)) {
appsAll?.forEach {
apps.add(it)
binding.layoutSwitchBypassAppsTips.setOnClickListener {
Toasty.info(this, R.string.summary_pref_per_app_proxy, Toast.LENGTH_LONG, true).show()
}
} else {
appsAll?.forEach {
if (it.appName.toUpperCase().indexOf(key) >= 0) {
apps.add(it)
}
}
}
adapter = PerAppProxyAdapter(this, apps, adapter?.blacklist)
recycler_view.adapter = adapter
adapter?.notifyDataSetChanged()
true
} else {
false
}
}
***/
}
override fun onPause() {
@ -199,8 +118,10 @@ class PerAppProxyActivity : BaseActivity() {
return super.onCreateOptionsMenu(menu)
}
@SuppressLint("NotifyDataSetChanged")
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.select_all -> adapter?.let {
R.id.select_all -> adapter?.let { it ->
val pkgNames = it.apps.map { it.packageName }
if (it.blacklist.containsAll(pkgNames)) {
it.apps.forEach {
@ -237,13 +158,20 @@ class PerAppProxyActivity : BaseActivity() {
private fun selectProxyApp() {
toast(R.string.msg_downloading_content)
val url = AppConfig.androidpackagenamelistUrl
binding.pbWaiting.show()
val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL
lifecycleScope.launch(Dispatchers.IO) {
val content = Utils.getUrlContext(url, 5000)
var content = HttpUtil.getUrlContent(url, 5000)
if (content.isNullOrEmpty()) {
val httpPort = SettingsManager.getHttpPort()
content = HttpUtil.getUrlContent(url, 5000, httpPort) ?: ""
}
launch(Dispatchers.Main) {
Log.d(ANG_PACKAGE, content)
Log.i(AppConfig.TAG, content)
selectProxyApp(content, true)
toast(R.string.toast_success)
toastSuccess(R.string.toast_success)
binding.pbWaiting.hide()
}
}
}
@ -252,7 +180,7 @@ class PerAppProxyActivity : BaseActivity() {
val content = Utils.getClipboard(applicationContext)
if (TextUtils.isEmpty(content)) return
selectProxyApp(content, false)
toast(R.string.toast_success)
toastSuccess(R.string.toast_success)
}
private fun exportProxyApp() {
@ -262,9 +190,10 @@ class PerAppProxyActivity : BaseActivity() {
lst = lst + System.getProperty("line.separator") + it
}
Utils.setClipboard(applicationContext, lst)
toast(R.string.toast_success)
toastSuccess(R.string.toast_success)
}
@SuppressLint("NotifyDataSetChanged")
private fun selectProxyApp(content: String, force: Boolean): Boolean {
try {
val proxyApps = if (TextUtils.isEmpty(content)) {
@ -277,10 +206,10 @@ class PerAppProxyActivity : BaseActivity() {
adapter?.blacklist?.clear()
if (binding.switchBypassApps.isChecked) {
adapter?.let {
adapter?.let { it ->
it.apps.forEach block@{
val packageName = it.packageName
Log.d(ANG_PACKAGE, packageName)
Log.i(AppConfig.TAG, packageName)
if (!inProxyApps(proxyApps, packageName, force)) {
adapter?.blacklist?.add(packageName)
println(packageName)
@ -290,10 +219,10 @@ class PerAppProxyActivity : BaseActivity() {
it.notifyDataSetChanged()
}
} else {
adapter?.let {
adapter?.let { it ->
it.apps.forEach block@{
val packageName = it.packageName
Log.d(ANG_PACKAGE, packageName)
Log.i(AppConfig.TAG, packageName)
if (inProxyApps(proxyApps, packageName, force)) {
adapter?.blacklist?.add(packageName)
println(packageName)
@ -304,7 +233,7 @@ class PerAppProxyActivity : BaseActivity() {
}
}
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Error selecting proxy app", e)
return false
}
return true
@ -339,7 +268,12 @@ class PerAppProxyActivity : BaseActivity() {
adapter = PerAppProxyAdapter(this, apps, adapter?.blacklist)
binding.recyclerView.adapter = adapter
adapter?.notifyDataSetChanged()
refreshData()
return true
}
@SuppressLint("NotifyDataSetChanged")
fun refreshData() {
adapter?.notifyDataSetChanged()
}
}

View file

@ -4,7 +4,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
import com.v2ray.ang.dto.AppInfo
@ -35,7 +34,7 @@ class PerAppProxyAdapter(val activity: BaseActivity, val apps: List<AppInfo>, bl
val view = View(ctx)
view.layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ctx.resources.getDimensionPixelSize(R.dimen.bypass_list_header_height) * 0
0
)
BaseViewHolder(view)
}

View file

@ -9,6 +9,7 @@ import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityRoutingEditBinding
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
@ -78,7 +79,7 @@ class RoutingEditActivity : BaseActivity() {
}
SettingsManager.saveRoutingRuleset(position, rulesetItem)
toast(R.string.toast_success)
toastSuccess(R.string.toast_success)
finish()
return true
}

View file

@ -1,23 +1,24 @@
package com.v2ray.ang.ui
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.AdapterView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import com.tbruyelle.rxpermissions3.RxPermissions
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityRoutingSettingBinding
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
@ -40,6 +41,16 @@ class RoutingSettingActivity : BaseActivity() {
resources.getStringArray(R.array.preset_rulesets)
}
private val requestCameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
scanQRcodeForRulesets.launch(Intent(this, ScannerActivity::class.java))
} else {
toast(R.string.toast_permission_denied)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
@ -48,20 +59,15 @@ class RoutingSettingActivity : BaseActivity() {
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
binding.recyclerView.adapter = adapter
mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
val found = Utils.arrayFind(routing_domain_strategy, MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY) ?: "")
found.let { binding.spDomainStrategy.setSelection(if (it >= 0) it else 0) }
binding.spDomainStrategy.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: AdapterView<*>?) {
}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, routing_domain_strategy[position])
}
binding.tvDomainStrategySummary.text = getDomainStrategy()
binding.layoutDomainStrategy.setOnClickListener {
setDomainStrategy()
}
}
@ -75,97 +81,90 @@ class RoutingSettingActivity : BaseActivity() {
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.add_rule -> {
startActivity(Intent(this, RoutingEditActivity::class.java))
true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.add_rule -> startActivity(Intent(this, RoutingEditActivity::class.java)).let { true }
R.id.import_predefined_rulesets -> importPredefined().let { true }
R.id.import_rulesets_from_clipboard -> importFromClipboard().let { true }
R.id.import_rulesets_from_qrcode -> requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA).let { true }
R.id.export_rulesets_to_clipboard -> export2Clipboard().let { true }
else -> super.onOptionsItemSelected(item)
}
R.id.user_asset_setting -> {
startActivity(Intent(this, UserAssetActivity::class.java))
true
}
private fun getDomainStrategy(): String {
return MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY) ?: routing_domain_strategy.first()
}
R.id.import_predefined_rulesets -> {
private fun setDomainStrategy() {
android.app.AlertDialog.Builder(this).setItems(routing_domain_strategy.asList().toTypedArray()) { _, i ->
try {
val value = routing_domain_strategy[i]
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
binding.tvDomainStrategySummary.text = value
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to set domain strategy", e)
}
}.show()
}
private fun importPredefined() {
AlertDialog.Builder(this).setItems(preset_rulesets.asList().toTypedArray()) { _, i ->
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
.setPositiveButton(android.R.string.ok) { _, _ ->
AlertDialog.Builder(this).setItems(preset_rulesets.asList().toTypedArray()) { _, i ->
try {
lifecycleScope.launch(Dispatchers.IO) {
SettingsManager.resetRoutingRulesetsFromPresets(this@RoutingSettingActivity, i)
launch(Dispatchers.Main) {
refreshData()
toast(R.string.toast_success)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}.show()
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
true
}
R.id.import_rulesets_from_clipboard -> {
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
.setPositiveButton(android.R.string.ok) { _, _ ->
val clipboard = try {
Utils.getClipboard(this)
} catch (e: Exception) {
e.printStackTrace()
toast(R.string.toast_failure)
return@setPositiveButton
}
lifecycleScope.launch(Dispatchers.IO) {
val result = SettingsManager.resetRoutingRulesets(clipboard)
withContext(Dispatchers.Main) {
if (result) {
try {
lifecycleScope.launch(Dispatchers.IO) {
SettingsManager.resetRoutingRulesetsFromPresets(this@RoutingSettingActivity, i)
launch(Dispatchers.Main) {
refreshData()
toast(R.string.toast_success)
} else {
toast(R.string.toast_failure)
toastSuccess(R.string.toast_success)
}
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to import predefined ruleset", e)
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do nothing
}
.show()
true
}
}.show()
}
R.id.import_rulesets_from_qrcode -> {
RxPermissions(this)
.request(Manifest.permission.CAMERA)
.subscribe {
if (it)
scanQRcodeForRulesets.launch(Intent(this, ScannerActivity::class.java))
else
toast(R.string.toast_permission_denied)
private fun importFromClipboard() {
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
.setPositiveButton(android.R.string.ok) { _, _ ->
val clipboard = try {
Utils.getClipboard(this)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to get clipboard content", e)
toastError(R.string.toast_failure)
return@setPositiveButton
}
lifecycleScope.launch(Dispatchers.IO) {
val result = SettingsManager.resetRoutingRulesets(clipboard)
withContext(Dispatchers.Main) {
if (result) {
refreshData()
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
}
}
}
true
}
R.id.export_rulesets_to_clipboard -> {
val rulesetList = MmkvManager.decodeRoutingRulesets()
if (rulesetList.isNullOrEmpty()) {
toast(R.string.toast_failure)
} else {
Utils.setClipboard(this, JsonUtil.toJson(rulesetList))
toast(R.string.toast_success)
}
true
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do nothing
}
.show()
}
else -> super.onOptionsItemSelected(item)
private fun export2Clipboard() {
val rulesetList = MmkvManager.decodeRoutingRulesets()
if (rulesetList.isNullOrEmpty()) {
toastError(R.string.toast_failure)
} else {
Utils.setClipboard(this, JsonUtil.toJson(rulesetList))
toastSuccess(R.string.toast_success)
}
}
private val scanQRcodeForRulesets = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
@ -182,9 +181,9 @@ class RoutingSettingActivity : BaseActivity() {
withContext(Dispatchers.Main) {
if (result) {
refreshData()
toast(R.string.toast_success)
toastSuccess(R.string.toast_success)
} else {
toast(R.string.toast_failure)
toastError(R.string.toast_failure)
}
}
}
@ -196,10 +195,10 @@ class RoutingSettingActivity : BaseActivity() {
return true
}
@SuppressLint("NotifyDataSetChanged")
fun refreshData() {
rulesets.clear()
rulesets.addAll(MmkvManager.decodeRoutingRulesets() ?: mutableListOf())
adapter.notifyDataSetChanged()
}
}

View file

@ -4,47 +4,49 @@ import android.Manifest
import android.content.Intent
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import com.tbruyelle.rxpermissions3.RxPermissions
import com.v2ray.ang.R
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
class ScScannerActivity : BaseActivity() {
private val requestCameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
scanQRCode.launch(Intent(this, ScannerActivity::class.java))
} else {
toast(R.string.toast_permission_denied)
finish()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_none)
importQRcode()
}
fun importQRcode(): Boolean {
RxPermissions(this)
.request(Manifest.permission.CAMERA)
.subscribe { granted ->
if (granted) {
scanQRCode.launch(Intent(this, ScannerActivity::class.java))
} else {
toast(R.string.toast_permission_denied)
}
}
private fun importQRcode(): Boolean {
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
return true
}
private val scanQRCode = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
val scanResult = it.data?.getStringExtra("SCAN_RESULT").orEmpty()
val (count, countSub) = AngConfigManager.importBatchConfig(scanResult, "", false)
if (count + countSub > 0) {
toast(R.string.toast_success)
toastSuccess(R.string.toast_success)
} else {
toast(R.string.toast_failure)
toastError(R.string.toast_failure)
}
startActivity(Intent(this, MainActivity::class.java))
}
finish()
}
}

View file

@ -3,7 +3,6 @@ package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import com.v2ray.ang.service.V2RayServiceManager
import com.v2ray.ang.util.Utils
class ScSwitchActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -12,10 +11,10 @@ class ScSwitchActivity : BaseActivity() {
setContentView(R.layout.activity_none)
if (V2RayServiceManager.v2rayPoint.isRunning) {
Utils.stopVService(this)
if (V2RayServiceManager.isRunning()) {
V2RayServiceManager.stopVService(this)
} else {
Utils.startVServiceFromToggle(this)
V2RayServiceManager.startVServiceFromToggle(this)
}
finish()
}

View file

@ -2,13 +2,15 @@ package com.v2ray.ang.ui
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContracts
import com.tbruyelle.rxpermissions3.RxPermissions
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.extension.toast
@ -20,7 +22,39 @@ import io.github.g00fy2.quickie.config.ScannerConfig
class ScannerActivity : BaseActivity() {
private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult)
private val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val uri = it.data?.data
if (it.resultCode == RESULT_OK && uri != null) {
try {
val inputStream = contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
val text = QRCodeDecoder.syncDecodeQRCode(bitmap)
if (text.isNullOrEmpty()) {
toast(R.string.toast_decoding_failed)
} else {
finished(text)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to decode QR code from file", e)
toast(R.string.toast_decoding_failed)
}
}
}
private val requestPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
showFileChooser()
} else {
toast(R.string.toast_permission_denied)
}
}
public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -72,15 +106,12 @@ class ScannerActivity : BaseActivity() {
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
RxPermissions(this)
.request(permission)
.subscribe { granted ->
if (granted) {
showFileChooser()
} else {
toast(R.string.toast_permission_denied)
}
}
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
showFileChooser()
} else {
requestPermissionLauncher.launch(permission)
}
true
}
@ -100,26 +131,4 @@ class ScannerActivity : BaseActivity() {
toast(R.string.toast_require_file_manager)
}
}
private val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val uri = it.data?.data
if (it.resultCode == RESULT_OK && uri != null) {
try {
val inputStream = contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
val text = QRCodeDecoder.syncDecodeQRCode(bitmap)
if (text.isNullOrEmpty()) {
toast(R.string.toast_decoding_failed)
} else {
finished(text)
}
} catch (e: Exception) {
e.printStackTrace()
toast(R.string.toast_decoding_failed)
}
}
}
}

View file

@ -2,7 +2,6 @@ package com.v2ray.ang.ui
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
@ -14,13 +13,11 @@ import android.widget.Spinner
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig.DEFAULT_PORT
import com.v2ray.ang.AppConfig.PREF_ALLOW_INSECURE
import com.v2ray.ang.AppConfig.REALITY
import com.v2ray.ang.AppConfig.TLS
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V6
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU
import com.v2ray.ang.R
import com.v2ray.ang.dto.EConfigType
@ -28,6 +25,7 @@ import com.v2ray.ang.dto.NetworkType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils
@ -126,6 +124,8 @@ class ServerActivity : BaseActivity() {
private val et_port_hop: EditText? by lazy { findViewById(R.id.et_port_hop) }
private val et_port_hop_interval: EditText? by lazy { findViewById(R.id.et_port_hop_interval) }
private val et_pinsha256: EditText? by lazy { findViewById(R.id.et_pinsha256) }
private val et_bandwidth_down: EditText? by lazy { findViewById(R.id.et_bandwidth_down) }
private val et_bandwidth_up: EditText? by lazy { findViewById(R.id.et_bandwidth_up) }
private val et_extra: EditText? by lazy { findViewById(R.id.et_extra) }
private val layout_extra: LinearLayout? by lazy { findViewById(R.id.layout_extra) }
@ -326,7 +326,7 @@ class ServerActivity : BaseActivity() {
et_preshared_key?.text = Utils.getEditable(config.preSharedKey.orEmpty())
et_reserved1?.text = Utils.getEditable(config.reserved ?: "0,0,0")
et_local_address?.text = Utils.getEditable(
config.localAddress ?: "$WIREGUARD_LOCAL_ADDRESS_V4,$WIREGUARD_LOCAL_ADDRESS_V6"
config.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4
)
et_local_mtu?.text = Utils.getEditable(config.mtu?.toString() ?: WIREGUARD_LOCAL_MTU)
} else if (config.configType == EConfigType.HYSTERIA2) {
@ -334,6 +334,8 @@ class ServerActivity : BaseActivity() {
et_port_hop?.text = Utils.getEditable(config.portHopping)
et_port_hop_interval?.text = Utils.getEditable(config.portHoppingInterval)
et_pinsha256?.text = Utils.getEditable(config.pinSHA256)
et_bandwidth_down?.text = Utils.getEditable(config.bandwidthDown)
et_bandwidth_up?.text = Utils.getEditable(config.bandwidthUp)
}
val securityEncryptions =
if (config.configType == EConfigType.SHADOWSOCKS) shadowsocksSecuritys else securitys
@ -350,13 +352,13 @@ class ServerActivity : BaseActivity() {
container_alpn?.visibility = View.VISIBLE
et_sni?.text = Utils.getEditable(config.sni)
config.fingerPrint?.let {
config.fingerPrint?.let { it ->
val utlsIndex = Utils.arrayFind(uTlsItems, it)
sp_stream_fingerprint?.setSelection(utlsIndex)
utlsIndex.let { sp_stream_fingerprint?.setSelection(if (it >= 0) it else 0) }
}
config.alpn?.let {
config.alpn?.let { it ->
val alpnIndex = Utils.arrayFind(alpns, it)
sp_stream_alpn?.setSelection(alpnIndex)
alpnIndex.let { sp_stream_alpn?.setSelection(if (it >= 0) it else 0) }
}
if (config.security == TLS) {
container_allow_insecure?.visibility = View.VISIBLE
@ -417,7 +419,7 @@ class ServerActivity : BaseActivity() {
et_public_key?.text = null
et_reserved1?.text = Utils.getEditable("0,0,0")
et_local_address?.text =
Utils.getEditable("${WIREGUARD_LOCAL_ADDRESS_V4},${WIREGUARD_LOCAL_ADDRESS_V6}")
Utils.getEditable(WIREGUARD_LOCAL_ADDRESS_V4)
et_local_mtu?.text = Utils.getEditable(WIREGUARD_LOCAL_MTU)
return true
}
@ -476,9 +478,9 @@ class ServerActivity : BaseActivity() {
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
config.subscriptionId = subscriptionId.orEmpty()
}
Log.d(ANG_PACKAGE, JsonUtil.toJsonPretty(config) ?: "")
//Log.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "")
MmkvManager.encodeServerConfig(editGuid, config)
toast(R.string.toast_success)
toastSuccess(R.string.toast_success)
finish()
return true
}
@ -513,6 +515,8 @@ class ServerActivity : BaseActivity() {
config.portHopping = et_port_hop?.text?.toString()
config.portHoppingInterval = et_port_hop_interval?.text?.toString()
config.pinSHA256 = et_pinsha256?.text?.toString()
config.bandwidthDown = et_bandwidth_down?.text?.toString()
config.bandwidthUp = et_bandwidth_up?.text?.toString()
}
}

View file

@ -2,21 +2,22 @@ package com.v2ray.ang.ui
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import com.blacksquircle.ui.editorkit.utils.EditorTheme
import com.blacksquircle.ui.language.json.JsonLanguage
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.fmt.CustomFmt
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.Utils
import me.drakeet.support.toast.ToastCompat
class ServerCustomConfigActivity : BaseActivity() {
private val binding by lazy { ActivityServerCustomConfigBinding.inflate(layoutInflater) }
@ -77,8 +78,8 @@ class ServerCustomConfigActivity : BaseActivity() {
val profileItem = try {
CustomFmt.parse(binding.editor.text.toString())
} catch (e: Exception) {
e.printStackTrace()
ToastCompat.makeText(this, "${getString(R.string.toast_malformed_josn)} ${e.cause?.message}", Toast.LENGTH_LONG).show()
Log.e(AppConfig.TAG, "Failed to parse custom configuration", e)
toast("${getString(R.string.toast_malformed_josn)} ${e.cause?.message}")
return false
}
@ -91,7 +92,7 @@ class ServerCustomConfigActivity : BaseActivity() {
MmkvManager.encodeServerConfig(editGuid, config)
MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString())
toast(R.string.toast_success)
toastSuccess(R.string.toast_success)
finish()
return true
}

View file

@ -43,6 +43,8 @@ class SettingsActivity : BaseActivity() {
private val appendHttpProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_APPEND_HTTP_PROXY) }
private val localDnsPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_LOCAL_DNS_PORT) }
private val vpnDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_VPN_DNS) }
private val vpnBypassLan by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_BYPASS_LAN) }
private val vpnInterfaceAddress by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX) }
private val mux by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_MUX_ENABLED) }
private val muxConcurrency by lazy { findPreference<EditTextPreference>(AppConfig.PREF_MUX_CONCURRENCY) }
@ -160,7 +162,7 @@ class SettingsActivity : BaseActivity() {
}
delayTestUrl?.setOnPreferenceChangeListener { _, any ->
val nval = any as String
delayTestUrl?.summary = if (nval == "") AppConfig.DelayTestUrl else nval
delayTestUrl?.summary = if (nval == "") AppConfig.DELAY_TEST_URL else nval
true
}
mode?.setOnPreferenceChangeListener { _, newValue ->
@ -201,7 +203,7 @@ class SettingsActivity : BaseActivity() {
remoteDns?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_REMOTE_DNS, AppConfig.DNS_PROXY)
domesticDns?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_DOMESTIC_DNS, AppConfig.DNS_DIRECT)
dnsHosts?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_DNS_HOSTS)
delayTestUrl?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL, AppConfig.DelayTestUrl)
delayTestUrl?.summary = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL, AppConfig.DELAY_TEST_URL)
initSharedPreference()
}
@ -237,6 +239,7 @@ class SettingsActivity : BaseActivity() {
AppConfig.PREF_SPEED_ENABLED,
AppConfig.PREF_CONFIRM_REMOVE,
AppConfig.PREF_START_SCAN_IMMEDIATE,
AppConfig.PREF_DOUBLE_COLUMN_DISPLAY,
AppConfig.PREF_PREFER_IPV6,
AppConfig.PREF_PROXY_SHARING,
AppConfig.PREF_ALLOW_INSECURE
@ -246,12 +249,15 @@ class SettingsActivity : BaseActivity() {
}
listOf(
AppConfig.PREF_VPN_BYPASS_LAN,
AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX,
AppConfig.PREF_ROUTING_DOMAIN_STRATEGY,
AppConfig.PREF_MUX_XUDP_QUIC,
AppConfig.PREF_FRAGMENT_PACKETS,
AppConfig.PREF_LANGUAGE,
AppConfig.PREF_UI_MODE_NIGHT,
AppConfig.PREF_LOGLEVEL,
AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD,
AppConfig.PREF_MODE
).forEach { key ->
if (MmkvManager.decodeSettingsString(key) != null) {
@ -269,6 +275,8 @@ class SettingsActivity : BaseActivity() {
appendHttpProxy?.isEnabled = vpn
localDnsPort?.isEnabled = vpn
vpnDns?.isEnabled = vpn
vpnBypassLan?.isEnabled = vpn
vpnInterfaceAddress?.isEnabled = vpn
if (vpn) {
updateLocalDns(
MmkvManager.decodeSettingsBool(
@ -359,6 +367,6 @@ class SettingsActivity : BaseActivity() {
}
fun onModeHelpClicked(view: View) {
Utils.openUri(this, AppConfig.v2rayNGWikiMode)
Utils.openUri(this, AppConfig.APP_WIKI_MODE)
}
}

View file

@ -6,10 +6,12 @@ import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivitySubEditBinding
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
@ -18,8 +20,8 @@ import kotlinx.coroutines.launch
class SubEditActivity : BaseActivity() {
private val binding by lazy { ActivitySubEditBinding.inflate(layoutInflater) }
var del_config: MenuItem? = null
var save_config: MenuItem? = null
private var del_config: MenuItem? = null
private var save_config: MenuItem? = null
private val editSubId by lazy { intent.getStringExtra("subId").orEmpty() }
@ -37,7 +39,7 @@ class SubEditActivity : BaseActivity() {
}
/**
* bingding seleced server config
* binding selected server config
*/
private fun bindingServer(subItem: SubscriptionItem): Boolean {
binding.etRemarks.text = Utils.getEditable(subItem.remarks)
@ -45,6 +47,7 @@ class SubEditActivity : BaseActivity() {
binding.etFilter.text = Utils.getEditable(subItem.filter)
binding.chkEnable.isChecked = subItem.enabled
binding.autoUpdateCheck.isChecked = subItem.autoUpdate
binding.allowInsecureUrl.isChecked = subItem.allowInsecureUrl
binding.etPreProfile.text = Utils.getEditable(subItem.prevProfile)
binding.etNextProfile.text = Utils.getEditable(subItem.nextProfile)
return true
@ -76,6 +79,7 @@ class SubEditActivity : BaseActivity() {
subItem.autoUpdate = binding.autoUpdateCheck.isChecked
subItem.prevProfile = binding.etPreProfile.text.toString()
subItem.nextProfile = binding.etNextProfile.text.toString()
subItem.allowInsecureUrl = binding.allowInsecureUrl.isChecked
if (TextUtils.isEmpty(subItem.remarks)) {
toast(R.string.sub_setting_remarks)
@ -89,12 +93,14 @@ class SubEditActivity : BaseActivity() {
if (!Utils.isValidSubUrl(subItem.url)) {
toast(R.string.toast_insecure_url_protocol)
//return false
if (!subItem.allowInsecureUrl) {
return false
}
}
}
MmkvManager.encodeSubscription(editSubId, subItem)
toast(R.string.toast_success)
toastSuccess(R.string.toast_success)
finish()
return true
}
@ -104,19 +110,28 @@ class SubEditActivity : BaseActivity() {
*/
private fun deleteServer(): Boolean {
if (editSubId.isNotEmpty()) {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
lifecycleScope.launch(Dispatchers.IO) {
MmkvManager.removeSubscription(editSubId)
launch(Dispatchers.Main) {
finish()
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE) == true) {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
lifecycleScope.launch(Dispatchers.IO) {
MmkvManager.removeSubscription(editSubId)
launch(Dispatchers.Main) {
finish()
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
// do nothing
}
.show()
} else {
lifecycleScope.launch(Dispatchers.IO) {
MmkvManager.removeSubscription(editSubId)
launch(Dispatchers.Main) {
finish()
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
// do nothing
}
.show()
}
}
return true
}

View file

@ -1,18 +1,18 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivitySubSettingBinding
import com.v2ray.ang.databinding.LayoutProgressBinding
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
@ -35,6 +35,7 @@ class SubSettingActivity : BaseActivity() {
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
binding.recyclerView.adapter = adapter
mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
@ -58,21 +59,18 @@ class SubSettingActivity : BaseActivity() {
}
R.id.sub_update -> {
val dialog = AlertDialog.Builder(this)
.setView(LayoutProgressBinding.inflate(layoutInflater).root)
.setCancelable(false)
.show()
binding.pbWaiting.show()
lifecycleScope.launch(Dispatchers.IO) {
val count = AngConfigManager.updateConfigViaSubAll()
delay(500L)
launch(Dispatchers.Main) {
if (count > 0) {
toast(R.string.toast_success)
toastSuccess(R.string.toast_success)
} else {
toast(R.string.toast_failure)
toastError(R.string.toast_failure)
}
dialog.dismiss()
binding.pbWaiting.hide()
}
}
@ -83,6 +81,7 @@ class SubSettingActivity : BaseActivity() {
}
@SuppressLint("NotifyDataSetChanged")
fun refreshData() {
subscriptions = MmkvManager.decodeSubscriptions()
adapter.notifyDataSetChanged()

View file

@ -3,11 +3,14 @@ package com.v2ray.ang.ui
import android.content.Intent
import android.graphics.Color
import android.text.TextUtils
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
@ -18,6 +21,8 @@ import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class SubSettingRecyclerAdapter(val activity: SubSettingActivity) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter {
@ -44,6 +49,10 @@ class SubSettingRecyclerAdapter(val activity: SubSettingActivity) : RecyclerView
)
}
holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
removeSubscription(subId, position)
}
holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
if (!it.isPressed) return@setOnCheckedChangeListener
subItem.enabled = isChecked
@ -52,8 +61,13 @@ class SubSettingRecyclerAdapter(val activity: SubSettingActivity) : RecyclerView
}
if (TextUtils.isEmpty(subItem.url)) {
holder.itemSubSettingBinding.layoutUrl.visibility = View.GONE
holder.itemSubSettingBinding.layoutShare.visibility = View.INVISIBLE
holder.itemSubSettingBinding.chkEnable.visibility = View.INVISIBLE
} else {
holder.itemSubSettingBinding.layoutUrl.visibility = View.VISIBLE
holder.itemSubSettingBinding.layoutShare.visibility = View.VISIBLE
holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE
holder.itemSubSettingBinding.layoutShare.setOnClickListener {
AlertDialog.Builder(mActivity)
.setItems(share_method.asList().toTypedArray()) { _, i ->
@ -78,13 +92,39 @@ class SubSettingRecyclerAdapter(val activity: SubSettingActivity) : RecyclerView
else -> mActivity.toast("else")
}
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Share subscription failed", e)
}
}.show()
}
}
}
private fun removeSubscription(subId: String, position: Int) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE) == true) {
AlertDialog.Builder(mActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
removeSubscriptionSub(subId, position)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
} else {
removeSubscriptionSub(subId, position)
}
}
private fun removeSubscriptionSub(subId: String, position: Int) {
mActivity.lifecycleScope.launch(Dispatchers.IO) {
MmkvManager.removeSubscription(subId)
launch(Dispatchers.Main) {
notifyItemRemoved(position)
notifyItemRangeChanged(position, mActivity.subscriptions.size)
mActivity.refreshData()
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
return MainViewHolder(
ItemRecyclerSubSettingBinding.inflate(

View file

@ -3,6 +3,7 @@ package com.v2ray.ang.ui
import android.content.Intent
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
@ -28,7 +29,7 @@ class TaskerActivity : BaseActivity() {
lstData.add("Default")
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
MmkvManager.decodeServerList()?.forEach { key ->
MmkvManager.decodeServerList().forEach { key ->
MmkvManager.decodeServerConfig(key)?.let { config ->
lstData.add(config.remarks)
lstGuid.add(key)
@ -60,7 +61,7 @@ class TaskerActivity : BaseActivity() {
}
}
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Failed to initialize Tasker settings", e)
}
}

View file

@ -4,10 +4,16 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityLogcatBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.AngConfigManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URLDecoder
class UrlSchemeActivity : BaseActivity() {
@ -40,7 +46,7 @@ class UrlSchemeActivity : BaseActivity() {
}
else -> {
toast(R.string.toast_failure)
toastError(R.string.toast_failure)
}
}
}
@ -49,7 +55,7 @@ class UrlSchemeActivity : BaseActivity() {
startActivity(Intent(this, MainActivity::class.java))
finish()
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Error processing URL scheme", e)
}
}
@ -57,7 +63,7 @@ class UrlSchemeActivity : BaseActivity() {
if (uriString.isNullOrEmpty()) {
return
}
Log.d("UrlScheme", uriString)
Log.i(AppConfig.TAG, uriString)
var decodedUrl = URLDecoder.decode(uriString, "UTF-8")
val uri = Uri.parse(decodedUrl)
@ -65,12 +71,16 @@ class UrlSchemeActivity : BaseActivity() {
if (uri.fragment.isNullOrEmpty() && !fragment.isNullOrEmpty()) {
decodedUrl += "#${fragment}"
}
Log.d("UrlScheme-decodedUrl", decodedUrl)
val (count, countSub) = AngConfigManager.importBatchConfig(decodedUrl, "", false)
if (count + countSub > 0) {
toast(R.string.import_subscription_success)
} else {
toast(R.string.import_subscription_failure)
Log.i(AppConfig.TAG, decodedUrl)
lifecycleScope.launch(Dispatchers.IO) {
val (count, countSub) = AngConfigManager.importBatchConfig(decodedUrl, "", false)
withContext(Dispatchers.Main) {
if (count + countSub > 0) {
toast(R.string.import_subscription_success)
} else {
toast(R.string.import_subscription_failure)
}
}
}
}
}

View file

@ -19,18 +19,19 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.tbruyelle.rxpermissions3.RxPermissions
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivitySubSettingBinding
import com.v2ray.ang.databinding.ActivityUserAssetBinding
import com.v2ray.ang.databinding.ItemRecyclerUserAssetBinding
import com.v2ray.ang.databinding.LayoutProgressBinding
import com.v2ray.ang.dto.AssetUrlItem
import com.v2ray.ang.extension.concatUrl
import com.v2ray.ang.extension.toTrafficString
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -38,18 +39,47 @@ import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.URL
import java.text.DateFormat
import java.util.Date
class UserAssetActivity : BaseActivity() {
private val binding by lazy { ActivitySubSettingBinding.inflate(layoutInflater) }
private val binding by lazy { ActivityUserAssetBinding.inflate(layoutInflater) }
val extDir by lazy { File(Utils.userAssetPath(this)) }
val builtInGeoFiles = arrayOf("geosite.dat", "geoip.dat")
private val requestStoragePermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "*/*"
intent.addCategory(Intent.CATEGORY_OPENABLE)
try {
chooseFile.launch(
Intent.createChooser(
intent,
getString(R.string.title_file_chooser)
)
)
} catch (ex: android.content.ActivityNotFoundException) {
toast(R.string.toast_require_file_manager)
}
} else {
toast(R.string.toast_permission_denied)
}
}
private val requestCameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
scanQRCodeForAssetURL.launch(Intent(this, ScannerActivity::class.java))
} else {
toast(R.string.toast_permission_denied)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -58,12 +88,18 @@ class UserAssetActivity : BaseActivity() {
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
binding.recyclerView.adapter = UserAssetAdapter()
binding.tvGeoFilesSourcesSummary.text = getGeoFilesSources()
binding.layoutGeoFilesSources.setOnClickListener {
setGeoFilesSources()
}
}
override fun onResume() {
super.onResume()
binding.recyclerView.adapter?.notifyDataSetChanged()
refreshData()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
@ -80,36 +116,32 @@ class UserAssetActivity : BaseActivity() {
else -> super.onOptionsItemSelected(item)
}
private fun getGeoFilesSources(): String {
return MmkvManager.decodeSettingsString(AppConfig.PREF_GEO_FILES_SOURCES) ?: AppConfig.GEO_FILES_SOURCES.first()
}
private fun setGeoFilesSources() {
AlertDialog.Builder(this).setItems(AppConfig.GEO_FILES_SOURCES.toTypedArray()) { _, i ->
try {
val value = AppConfig.GEO_FILES_SOURCES[i]
MmkvManager.encodeSettings(AppConfig.PREF_GEO_FILES_SOURCES, value)
binding.tvGeoFilesSourcesSummary.text = value
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to set geo files sources", e)
}
}.show()
}
private fun showFileChooser() {
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
RxPermissions(this)
.request(permission)
.subscribe {
if (it) {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.type = "*/*"
intent.addCategory(Intent.CATEGORY_OPENABLE)
try {
chooseFile.launch(
Intent.createChooser(
intent,
getString(R.string.title_file_chooser)
)
)
} catch (ex: android.content.ActivityNotFoundException) {
toast(R.string.toast_require_file_manager)
}
} else
toast(R.string.toast_permission_denied)
}
requestStoragePermissionLauncher.launch(permission)
}
val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
private val chooseFile = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == RESULT_OK && uri != null) {
val assetId = Utils.getUuid()
@ -127,7 +159,7 @@ class UserAssetActivity : BaseActivity() {
copyFile(uri)
}
}.onFailure {
toast(R.string.toast_asset_copy_failed)
toastError(R.string.toast_asset_copy_failed)
MmkvManager.removeAssetUrl(assetId)
}
}
@ -138,8 +170,8 @@ class UserAssetActivity : BaseActivity() {
contentResolver.openInputStream(uri).use { inputStream ->
targetFile.outputStream().use { fileOut ->
inputStream?.copyTo(fileOut)
toast(R.string.toast_success)
binding.recyclerView.adapter?.notifyDataSetChanged()
toastSuccess(R.string.toast_success)
refreshData()
}
}
return targetFile.path
@ -153,19 +185,12 @@ class UserAssetActivity : BaseActivity() {
}.also { cursor.close() }
}
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Failed to get cursor name", e)
null
}
private fun importAssetFromQRcode(): Boolean {
RxPermissions(this)
.request(Manifest.permission.CAMERA)
.subscribe {
if (it)
scanQRCodeForAssetURL.launch(Intent(this, ScannerActivity::class.java))
else
toast(R.string.toast_permission_denied)
}
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
return true
}
@ -182,65 +207,58 @@ class UserAssetActivity : BaseActivity() {
return false
}
// Send URL to UserAssetUrlActivity for Processing
startActivity(Intent(this, UserAssetUrlActivity::class.java)
.putExtra(UserAssetUrlActivity.ASSET_URL_QRCODE, url))
startActivity(
Intent(this, UserAssetUrlActivity::class.java)
.putExtra(UserAssetUrlActivity.ASSET_URL_QRCODE, url)
)
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Failed to import asset from URL", e)
return false
}
return true
}
private fun downloadGeoFiles() {
val dialog = AlertDialog.Builder(this)
.setView(LayoutProgressBinding.inflate(layoutInflater).root)
.setCancelable(false)
.show()
binding.pbWaiting.show()
toast(R.string.msg_downloading_content)
val httpPort = SettingsManager.getHttpPort()
var assets = MmkvManager.decodeAssetUrls()
assets = addBuiltInGeoItems(assets)
assets.forEach {
//toast(getString(R.string.msg_downloading_content) + it)
lifecycleScope.launch(Dispatchers.IO) {
var result = downloadGeo(it.second, 60000, httpPort)
if (!result) {
result = downloadGeo(it.second, 60000, 0)
}
launch(Dispatchers.Main) {
if (result) {
toast(getString(R.string.toast_success) + " " + it.second.remarks)
binding.recyclerView.adapter?.notifyDataSetChanged()
} else {
toast(getString(R.string.toast_failure) + " " + it.second.remarks)
var resultCount = 0
lifecycleScope.launch(Dispatchers.IO) {
assets.forEach {
try {
var result = downloadGeo(it.second, 15000, httpPort)
if (!result) {
result = downloadGeo(it.second, 15000, 0)
}
dialog.dismiss()
if (result)
resultCount++
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to download geo file: ${it.second.remarks}", e)
}
}
withContext(Dispatchers.Main) {
if (resultCount > 0) {
toast(getString(R.string.title_update_config_count, resultCount))
refreshData()
} else {
toast(getString(R.string.toast_failure))
}
binding.pbWaiting.hide()
}
}
}
private fun downloadGeo(item: AssetUrlItem, timeout: Int, httpPort: Int): Boolean {
val targetTemp = File(extDir, item.remarks + "_temp")
val target = File(extDir, item.remarks)
var conn: HttpURLConnection? = null
//Log.d(AppConfig.ANG_PACKAGE, url)
Log.i(AppConfig.TAG, "Downloading geo file: ${item.remarks} from ${item.url}")
val conn = HttpUtil.createProxyConnection(item.url, httpPort, timeout, timeout, needStream = true) ?: return false
try {
conn = if (httpPort == 0) {
URL(item.url).openConnection() as HttpURLConnection
} else {
URL(item.url).openConnection(
Proxy(
Proxy.Type.HTTP,
InetSocketAddress(LOOPBACK, httpPort)
)
) as HttpURLConnection
}
conn.connectTimeout = timeout
conn.readTimeout = timeout
val inputStream = conn.inputStream
val responseCode = conn.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
@ -252,10 +270,10 @@ class UserAssetActivity : BaseActivity() {
}
return true
} catch (e: Exception) {
Log.e(AppConfig.ANG_PACKAGE, Log.getStackTraceString(e))
Log.e(AppConfig.TAG, "Failed to download geo file: ${item.remarks}", e)
return false
} finally {
conn?.disconnect()
conn.disconnect()
}
}
@ -267,7 +285,8 @@ class UserAssetActivity : BaseActivity() {
list.add(
Utils.getUuid() to AssetUrlItem(
it,
AppConfig.GeoUrl + it
String.format(AppConfig.GITHUB_DOWNLOAD_URL, getGeoFilesSources()).concatUrl(it),
locked = true
)
)
}
@ -279,11 +298,16 @@ class UserAssetActivity : BaseActivity() {
lifecycleScope.launch(Dispatchers.Default) {
SettingsManager.initAssets(this@UserAssetActivity, assets)
withContext(Dispatchers.Main) {
binding.recyclerView.adapter?.notifyDataSetChanged()
refreshData()
}
}
}
@SuppressLint("NotifyDataSetChanged")
fun refreshData() {
binding.recyclerView.adapter?.notifyDataSetChanged()
}
inner class UserAssetAdapter : RecyclerView.Adapter<UserAssetViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAssetViewHolder {
return UserAssetViewHolder(
@ -313,7 +337,7 @@ class UserAssetActivity : BaseActivity() {
holder.itemUserAssetBinding.assetProperties.text = getString(R.string.msg_file_not_found)
}
if (item.second.remarks in builtInGeoFiles && item.second.url == AppConfig.GeoUrl + item.second.remarks) {
if (item.second.locked == true) {
holder.itemUserAssetBinding.layoutEdit.visibility = GONE
//holder.itemUserAssetBinding.layoutRemove.visibility = GONE
} else {

View file

@ -2,13 +2,16 @@ package com.v2ray.ang.ui
import android.os.Bundle
import android.text.TextUtils
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityUserAssetUrlBinding
import com.v2ray.ang.dto.AssetUrlItem
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.Utils
import java.io.File
@ -21,10 +24,10 @@ class UserAssetUrlActivity : BaseActivity() {
private val binding by lazy { ActivityUserAssetUrlBinding.inflate(layoutInflater) }
var del_config: MenuItem? = null
var save_config: MenuItem? = null
private var del_config: MenuItem? = null
private var save_config: MenuItem? = null
val extDir by lazy { File(Utils.userAssetPath(this)) }
private val extDir by lazy { File(Utils.userAssetPath(this)) }
private val editAssetId by lazy { intent.getStringExtra("assetId").orEmpty() }
override fun onCreate(savedInstanceState: Bundle?) {
@ -41,6 +44,7 @@ class UserAssetUrlActivity : BaseActivity() {
binding.etRemarks.setText(assetNameQrcode)
binding.etUrl.setText(assetUrlQrcode)
}
else -> clearAsset()
}
}
@ -73,7 +77,11 @@ class UserAssetUrlActivity : BaseActivity() {
// remove file associated with the asset
val file = extDir.resolve(assetItem.remarks)
if (file.exists()) {
file.delete()
try {
file.delete()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to delete asset file: ${file.path}", e)
}
}
} else {
assetId = Utils.getUuid()
@ -101,7 +109,7 @@ class UserAssetUrlActivity : BaseActivity() {
}
MmkvManager.encodeAsset(assetId, assetItem)
toast(R.string.toast_success)
toastSuccess(R.string.toast_success)
finish()
return true
}

View file

@ -4,36 +4,37 @@ import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import com.v2ray.ang.dto.AppInfo
import io.reactivex.rxjava3.core.Observable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object AppManagerUtil {
private fun loadNetworkAppList(ctx: Context): ArrayList<AppInfo> {
val packageManager = ctx.packageManager
val packages = packageManager.getInstalledPackages(PackageManager.GET_PERMISSIONS)
val apps = ArrayList<AppInfo>()
/**
* Load the list of network applications.
*
* @param context The context to use.
* @return A list of AppInfo objects representing the network applications.
*/
suspend fun loadNetworkAppList(context: Context): ArrayList<AppInfo> =
withContext(Dispatchers.IO) {
val packageManager = context.packageManager
val packages = packageManager.getInstalledPackages(PackageManager.GET_PERMISSIONS)
val apps = ArrayList<AppInfo>()
for (pkg in packages) {
val applicationInfo = pkg.applicationInfo ?: continue
for (pkg in packages) {
val applicationInfo = pkg.applicationInfo ?: continue
val appName = applicationInfo.loadLabel(packageManager).toString()
val appIcon = applicationInfo.loadIcon(packageManager) ?: continue
val isSystemApp = (applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM) > 0
val appName = applicationInfo.loadLabel(packageManager).toString()
val appIcon = applicationInfo.loadIcon(packageManager) ?: continue
val isSystemApp = applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM > 0
val appInfo = AppInfo(appName, pkg.packageName, appIcon, isSystemApp, 0)
apps.add(appInfo)
val appInfo = AppInfo(appName, pkg.packageName, appIcon, isSystemApp, 0)
apps.add(appInfo)
}
return@withContext apps
}
return apps
}
fun getLastUpdateTime(context: Context): Long =
context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime
fun rxLoadNetworkAppList(ctx: Context): Observable<ArrayList<AppInfo>> =
Observable.unsafeCreate {
it.onNext(loadNetworkAppList(ctx))
}
// val PackageInfo.hasInternetPermission: Boolean
// get() {
// val permissions = requestedPermissions
// return permissions?.any { it == Manifest.permission.INTERNET } ?: false
// }
}

View file

@ -0,0 +1,223 @@
package com.v2ray.ang.util
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.util.Utils.encode
import com.v2ray.ang.util.Utils.urlDecode
import java.io.IOException
import java.net.HttpURLConnection
import java.net.IDN
import java.net.Inet6Address
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.URL
object HttpUtil {
/**
* Converts the domain part of a URL string to its IDN (Punycode, ASCII Compatible Encoding) format.
*
* For example, a URL like "https://例子.中国/path" will be converted to "https://xn--fsqu00a.xn--fiqs8s/path".
*
* @param str The URL string to convert (can contain non-ASCII characters in the domain).
* @return The URL string with the domain part converted to ASCII-compatible (Punycode) format.
*/
fun toIdnUrl(str: String): String {
val url = URL(str)
val host = url.host
val asciiHost = IDN.toASCII(url.host, IDN.ALLOW_UNASSIGNED)
if (host != asciiHost) {
return str.replace(host, asciiHost)
} else {
return str
}
}
/**
* Converts a Unicode domain name to its IDN (Punycode, ASCII Compatible Encoding) format.
* If the input is an IP address or already an ASCII domain, returns the original string.
*
* @param domain The domain string to convert (can include non-ASCII internationalized characters).
* @return The domain in ASCII-compatible (Punycode) format, or the original string if input is an IP or already ASCII.
*/
fun toIdnDomain(domain: String): String {
// Return as is if it's a pure IP address (IPv4 or IPv6)
if (Utils.isPureIpAddress(domain)) {
return domain
}
// Return as is if already ASCII (English domain or already punycode)
if (domain.all { it.code < 128 }) {
return domain
}
// Otherwise, convert to ASCII using IDN
return IDN.toASCII(domain, IDN.ALLOW_UNASSIGNED)
}
/**
* Resolves a hostname to an IP address, returns original input if it's already an IP
*
* @param host The hostname or IP address to resolve
* @param ipv6Preferred Whether to prefer IPv6 addresses, defaults to false
* @return The resolved IP address or the original input (if it's already an IP or resolution fails)
*/
fun resolveHostToIP(host: String, ipv6Preferred: Boolean = false): List<String>? {
try {
// If it's already an IP address, return it as a list
if (Utils.isPureIpAddress(host)) {
return null
}
// Get all IP addresses
val addresses = InetAddress.getAllByName(host)
if (addresses.isEmpty()) {
return null
}
// Sort addresses based on preference
val sortedAddresses = if (ipv6Preferred) {
addresses.sortedWith(compareByDescending { it is Inet6Address })
} else {
addresses.sortedWith(compareBy { it is Inet6Address })
}
val ipList = sortedAddresses.mapNotNull { it.hostAddress }
Log.i(AppConfig.TAG, "Resolved IPs for $host: ${ipList.joinToString()}")
return ipList
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to resolve host to IP", e)
return null
}
}
/**
* Retrieves the content of a URL as a string.
*
* @param url The URL to fetch content from.
* @param timeout The timeout value in milliseconds.
* @param httpPort The HTTP port to use.
* @return The content of the URL as a string.
*/
fun getUrlContent(url: String, timeout: Int, httpPort: Int = 0): String? {
val conn = createProxyConnection(url, httpPort, timeout, timeout) ?: return null
try {
return conn.inputStream.bufferedReader().readText()
} catch (_: Exception) {
} finally {
conn.disconnect()
}
return null
}
/**
* Retrieves the content of a URL as a string with a custom User-Agent header.
*
* @param url The URL to fetch content from.
* @param timeout The timeout value in milliseconds.
* @param httpPort The HTTP port to use.
* @return The content of the URL as a string.
* @throws IOException If an I/O error occurs.
*/
@Throws(IOException::class)
fun getUrlContentWithUserAgent(url: String?, timeout: Int = 15000, httpPort: Int = 0): String {
var currentUrl = url
var redirects = 0
val maxRedirects = 3
while (redirects++ < maxRedirects) {
if (currentUrl == null) continue
val conn = createProxyConnection(currentUrl, httpPort, timeout, timeout) ?: continue
conn.setRequestProperty("User-agent", "v2rayNG/${BuildConfig.VERSION_NAME}")
conn.connect()
val responseCode = conn.responseCode
when (responseCode) {
in 300..399 -> {
val location = conn.getHeaderField("Location")
conn.disconnect()
if (location.isNullOrEmpty()) {
throw IOException("Redirect location not found")
}
currentUrl = location
continue
}
else -> try {
return conn.inputStream.use { it.bufferedReader().readText() }
} finally {
conn.disconnect()
}
}
}
throw IOException("Too many redirects")
}
/**
* Creates an HttpURLConnection object connected through a proxy.
*
* @param urlStr The target URL address.
* @param port The port of the proxy server.
* @param connectTimeout The connection timeout in milliseconds (default is 15000 ms).
* @param readTimeout The read timeout in milliseconds (default is 15000 ms).
* @param needStream Whether the connection needs to support streaming.
* @return Returns a configured HttpURLConnection object, or null if it fails.
*/
fun createProxyConnection(
urlStr: String,
port: Int,
connectTimeout: Int = 15000,
readTimeout: Int = 15000,
needStream: Boolean = false
): HttpURLConnection? {
var conn: HttpURLConnection? = null
try {
val url = URL(urlStr)
// Create a connection
conn = if (port == 0) {
url.openConnection()
} else {
url.openConnection(
Proxy(
Proxy.Type.HTTP,
InetSocketAddress(LOOPBACK, port)
)
)
} as HttpURLConnection
// Set connection and read timeouts
conn.connectTimeout = connectTimeout
conn.readTimeout = readTimeout
if (!needStream) {
// Set request headers
conn.setRequestProperty("Connection", "close")
// Disable automatic redirects
conn.instanceFollowRedirects = false
// Disable caching
conn.useCaches = false
}
//Add Basic Authorization
url.userInfo?.let {
conn.setRequestProperty(
"Authorization",
"Basic ${encode(urlDecode(it))}"
)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to create proxy connection", e)
// If an exception occurs, close the connection and return null
conn?.disconnect()
return null
}
return conn
}
}

View file

@ -1,5 +1,6 @@
package com.v2ray.ang.util
import android.util.Log
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonObject
@ -8,19 +9,39 @@ import com.google.gson.JsonPrimitive
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import com.google.gson.reflect.TypeToken
import com.v2ray.ang.AppConfig
import java.lang.reflect.Type
object JsonUtil {
private var gson = Gson()
/**
* Converts an object to its JSON representation.
*
* @param src The object to convert.
* @return The JSON representation of the object.
*/
fun toJson(src: Any?): String {
return gson.toJson(src)
}
/**
* Parses a JSON string into an object of the specified class.
*
* @param src The JSON string to parse.
* @param cls The class of the object to parse into.
* @return The parsed object.
*/
fun <T> fromJson(src: String, cls: Class<T>): T {
return gson.fromJson(src, cls)
}
/**
* Converts an object to its pretty-printed JSON representation.
*
* @param src The object to convert.
* @return The pretty-printed JSON representation of the object, or null if the object is null.
*/
fun toJsonPretty(src: Any?): String? {
if (src == null)
return null
@ -39,13 +60,19 @@ object JsonUtil {
return gsonPre.toJson(src)
}
/**
* Parses a JSON string into a JsonObject.
*
* @param src The JSON string to parse.
* @return The parsed JsonObject, or null if parsing fails.
*/
fun parseString(src: String?): JsonObject? {
if (src == null)
return null
try {
return JsonParser.parseString(src).getAsJsonObject()
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Failed to parse JSON string", e)
return null
}
}

View file

@ -3,21 +3,43 @@ package com.v2ray.ang.util
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.service.V2RayTestService
import java.io.Serializable
object MessageUtil {
/**
* Sends a message to the service.
*
* @param ctx The context.
* @param what The message identifier.
* @param content The message content.
*/
fun sendMsg2Service(ctx: Context, what: Int, content: Serializable) {
sendMsg(ctx, AppConfig.BROADCAST_ACTION_SERVICE, what, content)
}
/**
* Sends a message to the UI.
*
* @param ctx The context.
* @param what The message identifier.
* @param content The message content.
*/
fun sendMsg2UI(ctx: Context, what: Int, content: Serializable) {
sendMsg(ctx, AppConfig.BROADCAST_ACTION_ACTIVITY, what, content)
}
/**
* Sends a message to the test service.
*
* @param ctx The context.
* @param what The message identifier.
* @param content The message content.
*/
fun sendMsg2TestService(ctx: Context, what: Int, content: Serializable) {
try {
val intent = Intent()
@ -26,10 +48,18 @@ object MessageUtil {
intent.putExtra("content", content)
ctx.startService(intent)
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Failed to send message to test service", e)
}
}
/**
* Sends a message with the specified action.
*
* @param ctx The context.
* @param action The action string.
* @param what The message identifier.
* @param content The message content.
*/
private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable) {
try {
val intent = Intent()
@ -39,7 +69,7 @@ object MessageUtil {
intent.putExtra("content", content)
ctx.sendBroadcast(intent)
} catch (e: Exception) {
e.printStackTrace()
Log.e(AppConfig.TAG, "Failed to send message with action: $action", e)
}
}
}

View file

@ -11,12 +11,18 @@ import java.util.Locale
open class MyContextWrapper(base: Context?) : ContextWrapper(base) {
companion object {
/**
* Wraps the context with a new locale.
*
* @param context The original context.
* @param newLocale The new locale to set.
* @return A ContextWrapper with the new locale.
*/
@RequiresApi(Build.VERSION_CODES.N)
fun wrap(context: Context, newLocale: Locale?): ContextWrapper {
var mContext = context
val res: Resources = mContext.resources
val configuration: Configuration = res.configuration
//注意 Android 7.0 前后的不同处理方法
mContext = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
configuration.setLocale(newLocale)
val localeList = LocaleList(newLocale)

View file

@ -3,53 +3,80 @@ package com.v2ray.ang.util
import android.content.Context
import android.os.SystemClock
import android.util.Log
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.fmt.Hysteria2Fmt
import com.v2ray.ang.handler.SpeedtestManager
import com.v2ray.ang.service.ProcessService
import java.io.File
object PluginUtil {
//private const val HYSTERIA2 = "hysteria2-plugin"
private const val HYSTERIA2 = "libhysteria2.so"
private const val TAG = ANG_PACKAGE
private val procService: ProcessService by lazy {
ProcessService()
}
// fun initPlugin(name: String): PluginManager.InitResult {
// return PluginManager.init(name)!!
// }
/**
* Run the plugin based on the provided configuration.
*
* @param context The context to use.
* @param config The profile configuration.
* @param socksPort The port information.
*/
fun runPlugin(context: Context, config: ProfileItem?, socksPort: Int?) {
Log.i(AppConfig.TAG, "Starting plugin execution")
fun runPlugin(context: Context, config: ProfileItem?, domainPort: String?) {
Log.d(TAG, "runPlugin")
if (config == null) {
Log.w(AppConfig.TAG, "Cannot run plugin: config is null")
return
}
if (config?.configType?.equals(EConfigType.HYSTERIA2) == true) {
val configFile = genConfigHy2(context, config, domainPort) ?: return
val cmd = genCmdHy2(context, configFile)
try {
if (config.configType == EConfigType.HYSTERIA2) {
if (socksPort == null) {
Log.w(AppConfig.TAG, "Cannot run plugin: socksPort is null")
return
}
Log.i(AppConfig.TAG, "Running Hysteria2 plugin")
val configFile = genConfigHy2(context, config, socksPort) ?: return
val cmd = genCmdHy2(context, configFile)
procService.runProcess(context, cmd)
procService.runProcess(context, cmd)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error running plugin", e)
}
}
/**
* Stop the running plugin.
*/
fun stopPlugin() {
stopHy2()
}
/**
* Perform a real ping using Hysteria2.
*
* @param context The context to use.
* @param config The profile configuration.
* @return The ping delay in milliseconds, or -1 if it fails.
*/
fun realPingHy2(context: Context, config: ProfileItem?): Long {
Log.d(TAG, "realPingHy2")
Log.i(AppConfig.TAG, "realPingHy2")
val retFailure = -1L
if (config?.configType?.equals(EConfigType.HYSTERIA2) == true) {
val socksPort = Utils.findFreePort(listOf(0))
val configFile = genConfigHy2(context, config, "0:${socksPort}") ?: return retFailure
val configFile = genConfigHy2(context, config, socksPort) ?: return retFailure
val cmd = genCmdHy2(context, configFile)
val proc = ProcessService()
proc.runProcess(context, cmd)
Thread.sleep(1000L)
val delay = SpeedtestUtil.testConnection(context, socksPort)
val delay = SpeedtestManager.testConnection(context, socksPort)
proc.stopProcess()
return delay.first
@ -57,27 +84,39 @@ object PluginUtil {
return retFailure
}
private fun genConfigHy2(context: Context, config: ProfileItem, domainPort: String?): File? {
Log.d(TAG, "runPlugin $HYSTERIA2")
/**
* Generate the configuration file for Hysteria2.
*
* @param context The context to use.
* @param config The profile configuration.
* @param socksPort The port information.
* @return The generated configuration file.
*/
private fun genConfigHy2(context: Context, config: ProfileItem, socksPort: Int): File? {
Log.i(AppConfig.TAG, "runPlugin $HYSTERIA2")
val socksPort = domainPort?.split(":")?.last()
.let { if (it.isNullOrEmpty()) return null else it.toInt() }
val hy2Config = Hysteria2Fmt.toNativeConfig(config, socksPort) ?: return null
val configFile = File(context.noBackupFilesDir, "hy2_${SystemClock.elapsedRealtime()}.json")
Log.d(TAG, "runPlugin ${configFile.absolutePath}")
Log.i(AppConfig.TAG, "runPlugin ${configFile.absolutePath}")
configFile.parentFile?.mkdirs()
configFile.writeText(JsonUtil.toJson(hy2Config))
Log.d(TAG, JsonUtil.toJson(hy2Config))
Log.i(AppConfig.TAG, JsonUtil.toJson(hy2Config))
return configFile
}
/**
* Generate the command to run Hysteria2.
*
* @param context The context to use.
* @param configFile The configuration file.
* @return The command to run Hysteria2.
*/
private fun genCmdHy2(context: Context, configFile: File): MutableList<String> {
return mutableListOf(
File(context.applicationInfo.nativeLibraryDir, HYSTERIA2).absolutePath,
//initPlugin(HYSTERIA2).path,
"--disable-update-check",
"--config",
configFile.absolutePath,
@ -87,12 +126,15 @@ object PluginUtil {
)
}
/**
* Stop the Hysteria2 process.
*/
private fun stopHy2() {
try {
Log.d(TAG, "$HYSTERIA2 destroy")
Log.i(AppConfig.TAG, "$HYSTERIA2 destroy")
procService?.stopProcess()
} catch (e: Exception) {
Log.d(TAG, e.toString())
Log.e(AppConfig.TAG, "Failed to stop Hysteria2 process", e)
}
}
}

Some files were not shown because too many files have changed in this diff Show more