Compare commits

...

481 Commits

Author SHA1 Message Date
2dust 659f2ded09 up 2.1.2 2026-04-24 10:52:42 +08:00
2dust 585d64b0be Bump Android Gradle Plugin to 9.1.1
Update agp from 9.1.0 to 9.1.1 in V2rayNG/gradle/libs.versions.toml to pick up the latest patch fixes and keep the build tooling current.
2026-04-24 10:52:02 +08:00
fanymagnet 2ae2e747e2 Replace package names on UIDs in custom config (#5527)
* Replace package names on UIDs in custom config

* Update V2rayNG/app/src/main/java/com/v2ray/ang/handler/V2rayConfigManager.kt

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix copilot review & detect tun optimization

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-24 10:39:19 +08:00
2dust 4199cee4e5 Use OkHttp with proxy auth and support SOCKS creds
Replace HttpURLConnection-based networking with OkHttp and add proxy username/password support. Introduces buildOkHttpClient, updated getUrlContent/getUrlContentWithUserAgent signatures, and downloadToFile; callers in AngConfigManager, SpeedtestManager, UpdateCheckerManager, PerAppProxyActivity, UserAssetActivity and UserAssetViewModel now pass SOCKS proxy credentials. Removes createProxyConnection and related URL-resolve logic, clears HTTP inbound auth/udp in V2rayConfigManager, and simplifies SettingsManager port generation. These changes enable authenticated proxy requests, improved redirect handling and more robust file downloads.
2026-04-23 20:40:17 +08:00
dependabot[bot] 8be8a912c4 Bump robinraju/release-downloader from 1.12 to 1.13 (#5520)
Bumps [robinraju/release-downloader](https://github.com/robinraju/release-downloader) from 1.12 to 1.13.
- [Release notes](https://github.com/robinraju/release-downloader/releases)
- [Commits](https://github.com/robinraju/release-downloader/compare/v1.12...v1.13)

---
updated-dependencies:
- dependency-name: robinraju/release-downloader
  dependency-version: '1.13'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 19:44:50 +08:00
Kapkap5454 851f22680a Add negative UID handling to process routing (#5509)
* Handle unidentified package UID resolution

Add special handling for unidentified package names.

* Add method to create special unidentified app item

Added a method to create a special unidentified app item for the app picker.

* Support unidentified app UID and label

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-04-19 19:48:59 +08:00
2dust 12abc99bfc Update hev-socks5-tunnel 2026-04-19 19:31:11 +08:00
2dust 07e50637a2 Update AndroidLibXrayLite 2026-04-19 19:30:40 +08:00
2dust c1824c7598 up 2.1.1 2026-04-18 16:15:32 +08:00
2dust 18eebf999d Update AndroidLibXrayLite 2026-04-18 16:15:02 +08:00
solokot e37d6b7523 Update & improve Russian translation (#5505) 2026-04-18 16:10:49 +08:00
2dust 8c5375ec62 Add app picker and process picker integration
Introduce an AppPickerActivity to browse and select installed apps (with search, select-all and invert selection). Add AppSelectorAdapter, activity_app_picker layout and menu_app_picker menu, and register the activity in AndroidManifest.xml. Integrate the picker into RoutingEditActivity using an ActivityResult launcher and add a btn_process_picker to the routing edit layout; selected package list is loaded into the process EditText. Minor UI adjustments include padding for the EditText and marking system apps in the list.
2026-04-18 16:10:12 +08:00
2dust 30792299e8 up 2.1.0 2026-04-17 15:16:53 +08:00
solokot e6db0f6f9e Update Russian translation (#5486) 2026-04-17 15:14:36 +08:00
2dust 04478ab490 Support custom outbound tags in routing
Allow routing rules to reference outbounds by server remarks and inject missing outbounds into the generated V2ray config. Adds BUILTIN_OUTBOUND_TAGS, a pre-pass (injectCustomOutbounds) that converts referenced non-builtin tags to outbounds, and a fallback to the proxy tag when an outbound couldn't be injected. Updates getBalance to use AppConfig.TAG_PROXY constant. Replaces the outbound-tag Spinner with an AutoCompleteTextView + dropdown button that suggests builtin tags and existing profile remarks, adds a drop-down drawable and a hint string resource.
2026-04-17 15:10:19 +08:00
2dust f371da490a Kotlin formatting, import cleanup, and minor fixes 2026-04-17 14:00:38 +08:00
2dust 0a0ef6e6b5 Guard process finder init on Android Q+ 2026-04-16 13:52:35 +08:00
2dust fd57019e73 Add conditional process routing and routing strings 2026-04-16 09:56:35 +08:00
2dust 04ac6cb2d7 Update AndroidLibXrayLite 2026-04-16 09:21:54 +08:00
2dust 29a0be9629 Revert "Make tun MTU a list and port nullable"
This reverts commit f19f153f9c.
2026-04-16 09:21:15 +08:00
2dust 7aa27fd3de Add IPv6 enable preference and strings
https://github.com/2dust/v2rayNG/issues/5454
2026-04-15 19:33:55 +08:00
2dust fcc478ff63 Add launcher shortcuts to start/stop service
https://github.com/2dust/v2rayNG/issues/5485
2026-04-15 19:04:26 +08:00
2dust 54d5ad2342 Replace android.util.Log with LogUtil
Introduce a centralized LogUtil and migrate logging calls across the app to use it. Replaced android.util.Log imports/usages in many modules (fmt, handler, helper, receiver, service, ui, util, viewmodel, tests) and added a new LogUtil.kt file. Also trigger LogUtil.refreshLogLevel() when PREF_LOGLEVEL changes in MmkvPreferenceDataStore so runtime log level updates take effect.
2026-04-15 17:20:17 +08:00
2dust ba25e05536 Add process-based routing support
Introduce process field for routing rules and wire it through UI, config parsing, and runtime resolution. Adds a PackageUidResolver utility to map package names to UIDs (with caching) and converts user-entered package names into UIDs when building v2ray routing rules. Updates V2rayConfig types, RoutingEditActivity layout/bindings/strings to accept process input, and adjusts getRouting to receive Context. Also adds extra logging and a defensive check in V2RayServiceManager when resolving connection owner UID.
2026-04-15 15:46:06 +08:00
2dust 5a5a81fa0b Update hev-socks5-tunnel 2026-04-15 10:51:09 +08:00
2dust 322c4b305e Update AndroidLibXrayLite 2026-04-15 10:51:06 +08:00
2dust 590f11e671 Add Xray ProcessFinder and register it
https://github.com/2dust/AndroidLibXrayLite/commit/18c5fef49892be1c5755dd4e6ec672399521809a
https://github.com/2dust/AndroidLibXrayLite/commit/4371ac28643114042cf2499a61d381bf3d6bee75
2026-04-14 20:55:27 +08:00
2dust 872ec80292 Bug fix
https://github.com/2dust/v2rayNG/commit/d7abb8f49e3e9292c09a6fb9d2ca2f03530a7810
2026-04-14 20:49:47 +08:00
2dust f19f153f9c Make tun MTU a list and port nullable
https://github.com/XTLS/Xray-core/pull/5887
2026-04-14 14:57:12 +08:00
2dust d7abb8f49e Add dynamic local SOCKS port option
Introduce a dynamic SOCKS port feature: add PREF_DYNAMIC_SOCKS_PORT key, settings UI checkbox and localized strings, and pref XML entry. SettingsManager now holds a runtimeSocksPort, can generate and refresh a random port (generateRandomSocksPort, refreshRuntimeSocksPort) and respects the dynamic setting when returning getSocksPort(). V2Ray service startup triggers a runtime port refresh. Settings UI disables manual SOCKS port input when dynamic mode is enabled. Minor i18n additions (QR code/title) included for several locales.
2026-04-14 14:42:31 +08:00
2dust e54e713db7 Add local SOCKS username/password support 2026-04-13 20:17:30 +08:00
Copilot de91fb27db Fix VPN icon persisting in status bar after stopping service (#5476)
* Initial plan

* Fix VPN icon remaining in status bar after stopping service

Agent-Logs-Url: https://github.com/2dust/v2rayNG/sessions/4377b941-8f02-4d92-abc6-0f98fd6c9b60

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-04-13 19:35:55 +08:00
dependabot[bot] db8f46eae5 Bump android-actions/setup-android from 4.0.0 to 4.0.1 (#5456)
Bumps [android-actions/setup-android](https://github.com/android-actions/setup-android) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/android-actions/setup-android/releases)
- [Commits](https://github.com/android-actions/setup-android/compare/v4.0.0...v4.0.1)

---
updated-dependencies:
- dependency-name: android-actions/setup-android
  dependency-version: 4.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-11 19:27:05 +08:00
2dust 42e7f95e4f up 2.0.18 2026-04-03 09:19:22 +08:00
DHR60 0a52a79fa0 Support interval range (#5440) 2026-04-02 09:33:07 +08:00
DHR60 9dc1b1f2cf Support new fragment (#5437)
* Support new fragment

* Fix
2026-04-02 09:30:31 +08:00
DHR60 b61cad5a5f Support new hysteria2 stream settings (#5435)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-01 20:59:32 +08:00
2dust 66cd746ad2 Add server_lab_final_mask to translations 2026-04-01 10:18:59 +08:00
owo ae4a92b66b add finalmask into share link and support import manually (#5429)
* add finalmask into share link and support import manually

* remove temp test code

* Update V2rayNG/app/src/main/res/values/strings.xml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerActivity.kt

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update V2rayNG/app/src/main/java/com/v2ray/ang/fmt/FmtBase.kt

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Make finalmask Any and parse as raw string

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-01 10:12:50 +08:00
solokot 55c0ccf1b0 Update Russian translation (#5430) 2026-03-31 19:32:45 +08:00
2dust deedd26fac Respect append flag in custom config parsing
https://github.com/2dust/v2rayNG/pull/5424
2026-03-29 19:33:09 +08:00
owo e6c8307b96 remove old servers when update subscription (#5424)
* remove old servers when update subscription

* Supplementary Submission: Multiple Configuration Scenarios
2026-03-29 17:47:37 +08:00
2dust d6d5753283 Move locate action from FAB to menu
https://github.com/2dust/v2rayNG/issues/5423
2026-03-29 17:32:59 +08:00
2dust 014d510774 Disable SwipeRefreshLayout in GroupServerFragment
https://github.com/2dust/v2rayNG/issues/5417
2026-03-29 17:23:23 +08:00
2dust 42f89e69a7 up 2.0.17 2026-03-28 11:32:09 +08:00
2dust d5a0997775 Update AndroidLibXrayLite 2026-03-28 11:29:51 +08:00
owo af7db6f86b fix fab shadow issue (#5413) (#5416) 2026-03-27 09:22:47 +08:00
dependabot[bot] 7b7f43e12e Bump android-actions/setup-android from 3.2.0 to 4.0.0 (#5411)
Bumps [android-actions/setup-android](https://github.com/android-actions/setup-android) from 3.2.0 to 4.0.0.
- [Release notes](https://github.com/android-actions/setup-android/releases)
- [Commits](https://github.com/android-actions/setup-android/compare/v3.2.0...v4.0.0)

---
updated-dependencies:
- dependency-name: android-actions/setup-android
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 09:22:38 +08:00
2dust 8dd1df1451 up 2.0.16 2026-03-23 20:52:45 +08:00
2dust d4a73493b5 Update AndroidLibXrayLite 2026-03-23 20:51:04 +08:00
2dust 9fce03d0ed Increase swipe refresh trigger distance to 160dp
https://github.com/2dust/v2rayNG/issues/5367
2026-03-21 14:38:34 +08:00
owo a12ca3ea04 Feature/regex and protocol search (#5364)
* add windows compile options

* add regex search and search from protocol function(#5354)

* Delete compile-hevtun.bat

* Delete fix_headers.bat

* Extract matchesPattern to extension

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-03-21 14:22:10 +08:00
solokot 4e9df84d3e Update Russian translation (#5374) 2026-03-21 13:56:58 +08:00
2dust d3118b53f2 up 2.0.15 2026-03-13 17:08:02 +08:00
2dust cd45b51487 Add locate FAB and scroll-to-selected server
https://github.com/2dust/v2rayNG/issues/5165
2026-03-13 15:02:08 +08:00
2dust 6aa26d2621 Fix
https://github.com/2dust/v2rayNG/issues/5361
2026-03-13 10:49:30 +08:00
2dust 0b452707c3 Pull down to update subscription 2026-03-12 19:23:52 +08:00
2dust 303b642ade Refactor subscription import & matching logic 2026-03-12 18:03:21 +08:00
2dust 80eec036dd up 2.0.14 2026-03-10 19:22:50 +08:00
2dust f9a85366ea Update libs.versions.toml 2026-03-10 17:55:48 +08:00
2dust 378c891399 Update submodule commit SHAs 2026-03-09 20:37:30 +08:00
2dust d04b5eee37 Add detailed logging and validations to services 2026-03-09 20:11:53 +08:00
2dust 405cd7f55e Ensure default subscription on removals
Add removeSubscriptionWithDefault in SettingsManager to remove a subscription and recreate a default subscription if none remain. Modify MmkvManager.decodeAllServerList to include servers from DEFAULT_SUBSCRIPTION_ID when it's not listed, and remove the defensive check in MmkvManager.removeSubscription so removals are delegated to SettingsManager. Update callers (SubEditActivity, SubscriptionsViewModel) to use SettingsManager.removeSubscriptionWithDefault, remove UI restrictions hiding the delete action for the default subscription, and simplify group-display logic in MainViewModel. These changes ensure a default subscription always exists and server lists include default servers when appropriate.
2026-03-09 19:38:29 +08:00
2dust 850096789b When geoip:cn and geoip:private appear in the routing rules, load geoip-only-cn-private.dat to reduce memory usage. 2026-03-09 16:52:57 +08:00
2dust 3d42ac9dba Add logging and safety checks in boot and VPN service
https://github.com/2dust/v2rayNG/issues/5346
2026-03-08 16:11:33 +08:00
2dust d0265265f3 up 2.0.13 2026-03-03 16:11:10 +08:00
2dust 2d6dd33a7b Fix real ping test
https://github.com/2dust/v2rayNG/issues/5331
2026-03-01 17:19:42 +08:00
2dust 3a32373abe up 2.0.12 2026-02-28 15:10:40 +08:00
Hossein Abaspanah befb2937d1 Update Luri Bakhtiari translation (#5330) 2026-02-28 13:59:14 +08:00
solokot 0a375bec67 Update Russian translation (#5328) 2026-02-28 13:59:02 +08:00
2dust 029f8b67e7 Remove legacy profileStorage and migration code 2026-02-28 11:34:03 +08:00
2dust 7475d1757b Add detailed subscription update result 2026-02-27 17:44:40 +08:00
dependabot[bot] 5a16614a7b Bump actions/upload-artifact from 6 to 7 (#5324)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 14:18:55 +08:00
solokot edb06b466a Update Russian translation (#5315) 2026-02-26 14:56:53 +08:00
Hossein Abaspanah 30b5391895 Update strings.xml (#5312) 2026-02-26 14:56:41 +08:00
2dust 6cd04b831f Improve search
https://github.com/2dust/v2rayNG/issues/5313
2026-02-24 16:55:16 +08:00
2dust 1e616198f0 up 2.0.11 2026-02-24 12:31:29 +08:00
2dust a710146e4f Use subscriptionId to fetch servers for real ping testing 2026-02-24 12:30:08 +08:00
2dust 9cd530d223 Batch parse and save subscription configs 2026-02-24 12:15:03 +08:00
2dust fc89f25591 Add 'Show All Groups' preference and handling 2026-02-24 10:38:28 +08:00
2dust 84ab54fea2 Migrate server storage to subscription lists
Introduce subscription-based server storage and migration from legacy KEY_ANG_CONFIGS. Added DEFAULT_SUBSCRIPTION_ID and new MmkvManager APIs to encode/decode server lists per subscription, decodeAllServerList, and readLegacyServerList; included helper getSubscriptionId and protection against deleting the default subscription. Refactored SettingsManager into initApp to ensure defaults, create a default subscription, initialize routing rulesets and perform server-list migration and other migrations. Updated callers (V2rayConfigManager, BackupActivity, GroupServerFragment, SubEditActivity, SubSettingRecyclerAdapter, TaskerActivity, MainViewModel) to use the new APIs and to handle subscriptionId where appropriate (including UI changes to hide delete for default subscription). Preserved legacy logic as commented references and added migration markers to avoid repeated migrations. Overall change enables grouping servers by subscription and provides a migration path for existing installations.
2026-02-23 20:58:01 +08:00
2dust d642d09844 Revert "Migrate servers to subscription-based storage"
This reverts commit 09461663cb.
2026-02-23 19:59:43 +08:00
2dust 6ae46c50e2 Revert "Bug fix for sort"
This reverts commit fe4b451988.
2026-02-23 19:59:29 +08:00
2dust fe4b451988 Bug fix for sort 2026-02-23 16:06:20 +08:00
AmirMohammad Yazdanmanesh 5c9b634039 Improve accessibility: add contentDescription to clickable action containers (#5286)
- Add contentDescription to action button containers (share, edit,
  delete) in subscription, routing, and user asset recycler items
- Add contentDescription to bypass mode info button and connection
  test area in main activity
- Mark 4 decorative icons in backup screen as not important for
  accessibility
2026-02-23 15:07:22 +08:00
2dust 09461663cb Migrate servers to subscription-based storage
Move server list storage into SubscriptionItem.serverList and add a default subscription for ungrouped servers. Introduce AppConfig.DEFAULT_SUBSCRIPTION_ID and update MmkvManager to read legacy data, encode/decode server lists by subscription, decodeAllServerList(), and protect default subscription from deletion. Add one-time migration logic and ensureDefaultSubscription() in SettingsManager, invoke migration during app startup in AngApplication, and update call sites (MainViewModel, V2rayConfigManager, TaskerActivity, etc.) to use the new subscription-aware APIs. Preserve legacy code paths as comments and mark migration completion with a settings flag.
2026-02-23 14:41:53 +08:00
2dust 50c1145815 Organize imports across Kotlin files 2026-02-23 10:49:50 +08:00
2dust edc4856cb8 up 2.0.10 2026-02-22 14:21:20 +08:00
2dust 4dd7eee8fe Throttle notification speed queries
https://github.com/2dust/v2rayNG/issues/5267
2026-02-22 12:54:19 +08:00
2dust 516637954c Use theme primary color for help link text
https://github.com/2dust/v2rayNG/issues/5273
2026-02-22 12:12:46 +08:00
2dust 9e714bfcb3 Disable mux for HYSTERIA
https://github.com/2dust/v2rayNG/issues/5282
2026-02-22 12:08:13 +08:00
2dust 858dc5237a Update AGP to 9.0.1 & Gradle to 9.3.1 2026-02-22 11:32:17 +08:00
2dust 2b4a4f7ecc Increase Toasty bottom offset to 300
https://github.com/2dust/v2rayNG/issues/5306
2026-02-22 11:31:04 +08:00
2dust f9f0c905a4 up 2.0.9 2026-02-07 11:06:16 +08:00
2dust f997335113 Update libs.versions.toml 2026-02-07 11:05:47 +08:00
AmirMohammad Yazdanmanesh ed344cd281 Improve accessibility: mark decorative icons across remaining layouts (#5265)
Add importantForAccessibility="no" to decorative ImageViews in check
update, bypass list, routing, subscription, user asset, and widget
layouts. Add contentDescription to QR code ImageView.
2026-02-07 10:46:10 +08:00
2dust 94775f120a Migrate Hysteria2 pinSHA256 to pinnedCA256
Rename and migrate Hysteria2 certificate pin field from pinSHA256 to pinnedCA256 across code and UI, and add a one-time migration at startup.

Changes:
- Added SettingsManager.migrateHysteria2PinSHA256() and call in AngApplication to copy existing profile.pinSHA256 -> profile.pinnedCA256 and clear the old field, guarded by a migration flag.
- Updated Hysteria2Fmt to read/write the query param "pinSHA256" into the new ProfileItem.pinnedCA256 field.
- Updated ProfileItem equality to compare pinnedCA256 instead of pinSHA256.
- Removed the old EditText binding and reads/writes for et_pinsha256 in ServerActivity; UI now uses et_pinned_ca256 (layout id renamed).
- Updated layout and string resources: renamed the label/id to reflect pinned_ca256 and removed legacy pinSHA256 string entries in several locale files.

This ensures existing Hysteria2 profiles keep their pin data while the code and UI use the new field name.
2026-02-07 10:42:19 +08:00
2dust abd23b3227 up 2.0.8 2026-02-06 20:08:17 +08:00
2dust 7d3e002140 Update hev-socks5-tunnel 2026-02-06 19:45:41 +08:00
2dust 1adb22944a Update AndroidLibXrayLite 2026-02-06 19:45:34 +08:00
DHR60 f48253371d Follow encodeURIComponent rules strictly (#5250) 2026-02-06 19:38:45 +08:00
AmirMohammad Yazdanmanesh 74f701096e Improve accessibility: mark decorative icons as not important for accessibility (#5247) 2026-02-06 19:37:25 +08:00
DHR60 8eca5fe8af Add finalmask support (#5242) 2026-02-06 19:27:02 +08:00
solokot ff40b53e2f Update Russian translation (#5238) 2026-02-01 18:14:38 +08:00
2dust 5080d94bb7 Add description field to ProfileItem and generate descriptions 2026-01-30 17:24:13 +08:00
2dust 24539be29f up 2.0.7 2026-01-29 17:18:05 +08:00
2dust e951e3d913 Bug fix for tasker plugin 2026-01-29 17:07:42 +08:00
2dust 83e5f1d10a Refactor VPN service stop logic and improve error handling 2026-01-29 16:54:01 +08:00
2dust 47a6623e62 Refactor: move interfaces to contracts package 2026-01-29 15:21:36 +08:00
2dust d6e3fc5c91 Move enums from dto to enums package and update imports 2026-01-29 14:51:59 +08:00
2dust 685d23f2d1 Refactor asset URL handling with AssetUrlCache data class 2026-01-29 14:43:31 +08:00
2dust d46bbdc219 Refactor duplicate server removal logic 2026-01-29 14:34:15 +08:00
2dust be95e6a88e Refactor subscription handling with SubscriptionCache 2026-01-29 14:28:34 +08:00
2dust 2435708628 Refactor to use nullIfBlank 2026-01-29 11:26:48 +08:00
2dust 6cf6057dfa Add support for pinned certificate SHA-256 fingerprints 2026-01-29 11:00:19 +08:00
2dust af92cf5a7d Refactor activity helpers into HelperBaseActivity 2026-01-29 10:12:19 +08:00
2dust 2277de95c0 Add support for file creation using Storage Access Framework 2026-01-28 19:39:42 +08:00
2dust 3c90ee0206 Refactor QR code and file chooser handling in activities 2026-01-28 19:29:31 +08:00
2dust 41fd237a87 Refactor file and permission helpers, update usage 2026-01-28 19:13:34 +08:00
2dust f6bded88dd Refactor file chooser logic into reusable helper 2026-01-28 17:07:57 +08:00
2dust 8600fdba35 Refactor permission handling with PermissionRequester 2026-01-28 16:18:19 +08:00
2dust 2c47abc219 Update dependabot.yml to retain only github-actions ecosystem 2026-01-27 10:02:14 +08:00
Pádraic Slattery 10b0f8c9de chore: Update outdated GitHub Actions versions (#5208) 2026-01-27 09:59:15 +08:00
2dust 53572877fa Add Dependabot configuration 2026-01-27 09:55:35 +08:00
2dust 69e000b23d Add Random and Round Robin policy group types 2026-01-26 20:11:30 +08:00
Hossein Abaspanah a8e09611eb Update strings.xml (#5203) 2026-01-26 08:52:12 +08:00
2dust 4fdc46b8ca Update AGP version and add Gradle build properties
Upgraded Android Gradle Plugin version from 8.13.2 to 9.0.0 in libs.versions.toml. Added and updated several Android and Kotlin build properties in gradle.properties to refine build behavior and feature flags.
2026-01-24 14:16:39 +08:00
2dust ac043da8a2 up 2.0.6 2026-01-24 10:20:18 +08:00
2dust a192fec127 Update AndroidLibXrayLite 2026-01-24 10:19:46 +08:00
2dust b0b09273cf Restrict scanner and decoder to QR codes only 2026-01-23 19:41:21 +08:00
2dust 01a943b7ea Refactor main list adapter with listener interface 2026-01-23 17:09:35 +08:00
2dust 06a3738b59 Remove activity parameter from PerAppProxyAdapter 2026-01-22 18:34:05 +08:00
2dust fa4432584e up 2.0.5 2026-01-21 17:44:03 +08:00
2dust bb1c3d915f Refactor user asset management with ViewModel and adapter 2026-01-21 17:43:34 +08:00
2dust d8983a4761 Bug fix
https://github.com/2dust/v2rayNG/issues/5186
2026-01-20 21:07:51 +08:00
2dust 9815041c77 Remove unused imports and commented debug logs 2026-01-20 10:52:52 +08:00
2dust 41b0983582 Refactor logcat handling to use LogcatViewModel 2026-01-20 10:46:46 +08:00
2dust bb1f677d45 Refactor RoutingSetting adapter to use listener interface 2026-01-20 10:17:57 +08:00
2dust de1c8bcfcc Refactor VPN mode checks and TUN config logic
Centralizes VPN mode detection in SettingsManager with a new isVpnMode() method and updates all usages to reference it. Refactors TUN configuration logic in V2rayConfigManager for clarity and correctness, ensuring proper config and routing rules are applied based on VPN and HEV TUN settings.
2026-01-19 19:43:09 +08:00
2dust ca6e963575 Refactor remarks assignment using ifEmpty in formatters 2026-01-19 19:18:26 +08:00
2dust bfb6813dc7 Fix HevTun settings to preferences UI 2026-01-19 19:18:15 +08:00
2dust cc2b4eec65 up 2.0.4 2026-01-18 15:23:02 +08:00
2dust dd11569969 Add version field to Hysteria2 config 2026-01-18 15:22:33 +08:00
2dust 11c3dd97c3 Update AndroidLibXrayLite 2026-01-18 15:00:07 +08:00
2dust 92f15150cd up 2.0.3 2026-01-18 14:58:40 +08:00
2dust f8e809da8b Refactor adapter callbacks with BaseAdapterListener 2026-01-18 14:58:15 +08:00
DHR60 12b9349e62 Fix sniffing (#5173)
* Fix sniffing

* Fix custom config
2026-01-16 19:52:18 +08:00
DHR60 a580494bde Update sdk (#5174) 2026-01-16 19:22:43 +08:00
2dust 66f100ebb3 up 2.0.2 2026-01-16 17:40:34 +08:00
2dust b0213ffdf6 Refactor FAB state handling in MainActivity 2026-01-16 17:40:09 +08:00
2dust 538b2eb0f8 Bug fix
https://github.com/2dust/v2rayNG/issues/5172
2026-01-16 16:36:01 +08:00
2dust f2093c4c52 Refactor real ping test logic into worker service 2026-01-16 16:28:35 +08:00
2dust 02ac57a4c6 Fix and improve port hopping interval handling
https://github.com/2dust/v2rayNG/issues/5171
2026-01-16 15:21:19 +08:00
2dust 379266f205 Add tun inbound to custom config if missing
Updated getV2rayCustomConfig to check for the presence of a 'tun' inbound in the configuration. If not present and HevTun is not used, the method adds a 'tun' inbound from the template configuration and updates the config accordingly.
2026-01-16 15:07:10 +08:00
2dust 23dbb35da7 Refactor MainActivity and clean up SettingsActivity 2026-01-15 17:54:15 +08:00
2dust 22a605ecda up 2.0.1 2026-01-15 14:51:11 +08:00
2dust c7dd45f4f2 Bug fix
https://github.com/2dust/v2rayNG/issues/5164
2026-01-15 14:49:29 +08:00
2dust 609e688d87 Fix HYSTERIA2 string 2026-01-15 14:30:10 +08:00
2dust a2a4ea79a9 Optimize coroutine dispatcher in V2RayTestService 2026-01-15 14:22:24 +08:00
2dust c08147c362 Refactor WebDAV backup path and file name handling 2026-01-15 11:52:18 +08:00
2dust c99fda9839 Bug fix
https://github.com/2dust/v2rayNG/issues/5158
2026-01-15 11:11:16 +08:00
2dust 587d103518 Fix enum declaration for RoutingType 2026-01-15 10:41:06 +08:00
Umor1st 192a4afe6d Add Russia whitelist routing option (#5161)
* Add Russia whitelist routing option

* Update RoutingType.kt

* Add custom routing file in assets

* Update localization
2026-01-15 10:34:11 +08:00
2dust ee69364f2b Remove hysteria submodule and related build steps 2026-01-15 10:32:10 +08:00
2dust 6be0bd7b21 Remove plugin framework and refactor Hysteria2 integration 2026-01-15 10:19:25 +08:00
2dust a3972b98f3 up 2.0.0 2026-01-14 15:57:20 +08:00
2dust a2b6e669d8 Update XrayLite submodule and optimize imports 2026-01-14 09:54:49 +08:00
solokot 55c42fbf2a Update Russian translation (#5156) 2026-01-14 09:30:05 +08:00
2dust ab4af6bd82 Refactor rulesets management to ViewModel 2026-01-13 17:29:40 +08:00
2dust c3a8ee59dc Refactor subscription management to ViewModel 2026-01-13 17:29:22 +08:00
2dust 18f23d9d3b Bug fix
https://github.com/2dust/v2rayNG/issues/5151
2026-01-13 11:10:48 +08:00
2dust 4a3686fb32 Refactor per-app proxy logic to ViewModel 2026-01-13 10:57:42 +08:00
2dust 68ed08d36e Add invert selection to per-app proxy list 2026-01-13 09:56:28 +08:00
DHR60 d0612619fd Raise minSdk to 24 (#5152) 2026-01-12 19:33:15 +08:00
2dust 3ddd6ee699 Refactor V2Ray native integration to V2RayNativeManager
Introduced V2RayNativeManager as a thread-safe singleton to encapsulate all Libv2ray native method calls, replacing direct usage throughout the codebase. Updated SpeedtestManager, V2RayServiceManager, V2RayTestService, AboutActivity, and CheckUpdateActivity to use the new manager, improving code organization and maintainability.
2026-01-12 17:40:48 +08:00
2dust 485ea2e28d Update submodules to latest commits 2026-01-12 16:27:53 +08:00
2dust 2fd57baa37 Refactor TUN selection to use HEV toggle and cleanup
Replaces the TUN interface selection ListPreference with a CheckBoxPreference for enabling HEV TUN, updates related logic and string resources, removes SettingsViewModel, and simplifies inbound configuration. This refactor improves user experience and code maintainability by consolidating TUN selection and removing legacy code.
2026-01-12 16:14:33 +08:00
yuhan6665 73d1dce4db Add xray-core tun mode (#5150)
* Add xray-core tun mode

The service start-up sequence is changed:
vpn service is created first,
a file descriptor to the underlining vpn service can then be passed to xray-core,
Xray core then create the TUN inbound from it

* Remove badvpn-tun2socks
2026-01-12 14:16:44 +08:00
2dust bd60224577 Add auto remove and sort after test options 2026-01-11 20:08:37 +08:00
2dust 3f1b741d9d Refactor progress bar handling to BaseActivity 2026-01-11 17:06:11 +08:00
2dust b71614a9cd Show and update subscription last updated time 2026-01-11 14:40:41 +08:00
2dust 31d29d97c0 Refactor imports and remove unused code 2026-01-11 11:48:40 +08:00
2dust 183391b6e3 Enable log copy on long press 2026-01-11 11:24:02 +08:00
2dust 3ad6464595 Refactor V2RayTestService 2026-01-10 18:25:38 +08:00
2dust e684bef35d Migrate settings storage to MMKV and add PreferenceDataStore
Replaces SharedPreferences with MMKV for all settings by introducing MmkvPreferenceDataStore and updating SettingsActivity to use it. Adds encode/decode helpers for more types in MmkvManager, ensures default settings are initialized in SettingsManager, and removes the unused FragmentAdapter. This change improves consistency and reliability of settings storage.
2026-01-10 14:31:02 +08:00
2dust a87d5d375b Refactor themes to use shared AppThemeBase style 2026-01-09 18:11:36 +08:00
2dust 545683c9ba Reorganize and expand settings preferences UI 2026-01-09 17:34:07 +08:00
2dust 1a8631257b Refactor UI to use Material3 toolbar and theme
Introduces a base activity layout with MaterialToolbar and refactors all activities to use setContentViewWithToolbar for consistent toolbar/title handling. Updates color and theme resources to Material3, removes hardcoded color references from layouts, and improves RecyclerView indicator color logic. This modernizes the UI and centralizes toolbar management.
2026-01-09 14:44:39 +08:00
Skh-web6982 ebebd3277f Update Persian translate (#5145)
* Update Persian translate

* Update strings.xml
2026-01-09 09:58:38 +08:00
solokot 5242827baf Update Russian translation (#5141) 2026-01-09 09:58:23 +08:00
Hossein Abaspanah c6799d5bf3 Update strings.xml (#5143) 2026-01-08 19:36:36 +08:00
Hossein Abaspanah 054a3bfa5e Update Luri Bakhtiari translation (#5140) 2026-01-08 19:34:33 +08:00
AmirMohammad Yazdanmanesh 02a9d64188 Tests for shadowsocks fmt protocol formatter (#5137)
* Add unit tests for ShadowsocksFmt protocol formatter

  - Add comprehensive test coverage for Shadowsocks URL parsing
  - Test SIP002 format (modern) with base64 encoded userinfo
  - Test legacy format with full base64 encoding
  - Test URI generation and round-trip consistency
  - Cover edge cases: IPv6 addresses, empty remarks, various encryption methods
  - Mock Android Base64 and Log classes for unit test execution

* Adding URL decoding before Base64 decoding. The flow is now: extract → URL decode → Base64 decode.

* Fix test naming convention to match project standards

Change test method names from backtick-delimited descriptive names
to underscore-separated format with test_ prefix to align with
existing project conventions (e.g., test_parseInt in UtilsTest).

Before: `parseSip002 valid URL with base64 encoded userinfo`()
After:  test_parseSip002_validUrlWithBase64EncodedUserinfo()

* Fix Base64 mock to properly handle encoding flags

The mock now inspects the flags parameter and applies URL-safe
encoding and padding removal conditionally based on actual flag
values (URL_SAFE, NO_PADDING) instead of unconditionally using
withoutPadding().

* Fix Base64 decode mock to propagate exceptions

The mock now properly handles the URL_SAFE flag and propagates
exceptions on invalid input instead of silently returning an
empty ByteArray. This matches Android's actual Base64 behavior
where IllegalArgumentException is thrown on invalid input.

* Extract helper functions to reduce code duplication

Add createSip002Url() and createLegacyUrl() helper functions to
centralize URL construction logic. Add SS_SCHEME constant to
avoid magic strings. This improves maintainability by reducing
repeated Base64 encoding and URL construction patterns.

* Add URI format verification before extracting base64

Verify that toUri() returns a URI string without the scheme
prefix before extracting the base64 content. This ensures the
test correctly validates the URI format and prevents incorrect
data extraction if the implementation changes.

* Add scheme verification in round-trip test

Explicitly verify that toUri() returns a URI string without the
scheme prefix before prepending it. This documents the expected
behavior and ensures the test fails gracefully if the
implementation changes.

* mockito-inline version 6.1.0 does not exist
  - The latest available version is 5.2.0 (released March 2023)
  - The mockito-inline artifact was effectively deprecated after 5.2.0; mocking final classes is now built into mockito-core since Mockito 5.x
2026-01-08 19:31:13 +08:00
2dust 29b67c29f5 Show running test task count in UI and improve test service 2026-01-08 16:37:10 +08:00
2dust 79d5cad738 Refactor server list data handling in adapter 2026-01-08 15:07:26 +08:00
2dust de5b714a28 Refactor group tab to ViewPager2 with fragments
Replaces the group tab and server list RecyclerView in MainActivity with a ViewPager2-based implementation using fragments. Adds GroupPagerAdapter and GroupServerFragment for per-group server lists, introduces BaseFragment for fragment view binding, and updates related logic in MainViewModel and SettingsChangeManager. Updates dependencies to include ViewPager2 and Fragment KTX, and modifies layouts to support the new structure.
2026-01-08 13:58:52 +08:00
2dust 416ee28e80 Rename proxy_packagename.txt to proxy_package_name 2026-01-05 11:00:40 +08:00
2dust 71f0d98e68 Add SettingsChangeManager and centralize restart logic 2026-01-05 10:40:55 +08:00
2dust 37bd57ffc5 Add WebDAV backup and restore functionality
Introduces a new BackupActivity for configuration backup and restore, supporting both local storage and WebDAV servers. Adds WebDavManager and WebDavConfig for handling WebDAV operations, updates the navigation drawer and menu, and refactors AboutActivity to remove backup/restore logic. Includes new layouts and string resources for backup options and WebDAV settings.
2026-01-03 15:22:45 +08:00
2dust b4ac79e4fa Comment out local DNS port preference in settings 2026-01-01 16:46:54 +08:00
2dust 8aba43d448 Remove promotion summary strings and update config labels 2026-01-01 16:34:31 +08:00
2dust e564083352 Add configurable IP API URL and improve IP info parsing 2026-01-01 16:08:17 +08:00
2dust 08bf66350a Add ECH config support to TLS settings
https://github.com/2dust/v2rayNG/issues/5094
2025-12-31 19:13:49 +08:00
2dust 66d4c21b77 Update compile-hevtun.sh 2025-12-31 10:15:08 +08:00
2dust 359b0af7ae Remove tun2socks and related dependencies 2025-12-31 10:06:59 +08:00
2dust 4b1fa29ade Refactor backup flow and remove backup summary 2025-12-30 20:36:53 +08:00
2dust e287ac88c8 Remove legacy tun2socks support and related settings
Deleted Tun2SocksService and all code paths, preferences, and UI related to toggling between HevSocks5Tunnel and tun2socks. The app now always uses HevSocks5Tunnel for VPN traffic, simplifying configuration and maintenance.
2025-12-30 20:03:17 +08:00
2dust 4812f067db Add policy group config type and UI support
Introduces a new POLICYGROUP config type for server grouping and selection. Adds ServerGroupActivity and related UI, updates menu options, and removes legacy intelligent selection logic. Refactors config management and filtering to support policy groups, and updates translations and resources accordingly.
2025-12-30 16:16:32 +08:00
2dust 47be68c52f Remove legacy server config migration logic
Deleted ServerConfig and MigrateManager classes, and removed the legacy migration call from MainActivity. This cleans up obsolete migration code now that server configuration migration is no longer required.
2025-12-29 16:34:37 +08:00
Hossein Abaspanah 418477267b Update Luri Bakhtiari translation (#5121) 2025-12-29 16:18:16 +08:00
2dust 74ad37d329 Update library versions in libs.versions.toml 2025-12-29 15:19:48 +08:00
2dust fd83f88ec2 Raised minSdk to 24,Drop legacy Android support
Raised minSdk to 24 and removed conditional code for pre-Nougat Android versions. Cleaned up context wrapping, notification, and service start logic to use modern APIs. Also removed unnecessary @RequiresApi annotations and legacy feature declarations from the manifest.
2025-12-29 14:45:36 +08:00
2dust 214ab76459 Refactor code 2025-12-28 19:24:15 +08:00
2dust f67bee04ad Update build.gradle.kts 2025-12-28 19:06:04 +08:00
2dust 2cfef7a614 Make JSON parsing methods return null on failure
Refactored all usages of JsonUtil.fromJson to handle null return values, improving robustness against malformed or missing JSON data. Updated method signature to return nullable types and adjusted related logic throughout the codebase to prevent potential crashes.
2025-12-28 17:59:11 +08:00
solokot bd6b67cbfc Update Russian translation (#5113) 2025-12-26 16:54:26 +08:00
Hossein Abaspanah bb374f2c7e Update Luri Bakhtiari translation (#5112) 2025-12-26 16:54:10 +08:00
2dust f85c382dbd up 1.10.32 2025-12-25 17:13:13 +08:00
if 0b76ff0409 fix(accessibility): Add content description for QR code dialog / [无障碍] 修复二维码弹窗缺失内容描述的问题 (#5110)
* 在显示 Dialog 之前,从资源数组中获取第一个元素(即“二维码”),并设置为图片的 contentDescription。

* Update V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainRecyclerAdapter.kt

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-25 17:08:59 +08:00
Skh-web6982 e3bbd47c81 Update Persian translate (#5108) 2025-12-25 17:08:42 +08:00
Skh-web6982 0672820718 Update kotlin version to 2.3.0 (#5107)
* Update kotlin version to 2.3.0

* Update kotlin version to 2.3.0
2025-12-25 17:08:33 +08:00
fuilloi e0e58dff45 Update build.gradle.kts (#5087) 2025-12-25 17:07:26 +08:00
2dust cfae051109 Refactor hev tunnel timeout settings
Changed hev tunnel read/write timeout preference to accept separate TCP and UDP values in seconds (default 300,60). Updated related keys, default values, and UI strings across multiple languages to reflect the new format and units.
https://github.com/2dust/v2rayNG/issues/5057
2025-12-24 20:07:34 +08:00
if 60ddf5ba6b [Accessibility] Fix missing labels for buttons and FAB / 修复主界面无障碍标签缺失问题 (#5105)
* 无障碍修复

* Update 3 files:
item_recycler_main.xml, strings.xml, strings.xml

* Update 3 files:
item_recycler_main.xml, strings.xml, strings.xml

* Update 1 file:
dimens.xml

* Update 4 files:
MainActivity.kt, activity_main.xml, strings.xml, strings.xml

* 恢复原始距离

* 修订成

* 补充完整

* Update V2rayNG/app/src/main/res/values/strings.xml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add 'Edit configuration' string to multiple locales

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-24 19:02:15 +08:00
David 861b1746e3 Update strings.xml (#5097)
將沒有翻譯為繁體中文的信息翻譯為了繁體中文。
2025-12-18 20:56:43 +08:00
Liniya 4dec554b42 Fixes RU translation for server-test menu items (#5091) 2025-12-13 09:39:58 +08:00
2dust d7ad91fe8b up 1.10.31 2025-12-09 10:25:05 +08:00
2dust fb51683981 Update AndroidLibXrayLite 2025-12-09 10:16:44 +08:00
2dust e5d14bada9 up 1.10.30 2025-12-07 15:59:33 +08:00
2dust 80fb265960 Update AndroidLibXrayLite 2025-12-07 15:59:25 +08:00
2dust f5493b08ae Update IP API URL in AppConfig
https://github.com/2dust/v2rayNG/issues/5073
2025-12-06 18:51:53 +08:00
Skh-web6982 4f7d6db361 Update kotlin to version 2.2.21 (#5077)
* Update kotlin to version 2.2.21

* Update kotlin to version 2.2.21

* Update gradle-wrapper.properties

* Update libs.versions.toml
2025-12-06 18:48:15 +08:00
2dust 636b20290a Update submodules AndroidLibXrayLite and hev-socks5-tunnel 2025-12-03 09:38:08 +08:00
2dust 7ead97079c up 1.10.29 2025-12-03 09:36:47 +08:00
fuilloi e42cc3a60a fix hevtun loglevel (#5062) 2025-11-27 19:14:22 +08:00
fuilloi d231490917 update libtun build script (#5055)
* Update compile-tun2socks.sh

* update libtun build script
2025-11-26 15:21:32 +08:00
fuilloi 521d61acb3 update hev config option (#5056) 2025-11-26 13:43:44 +08:00
2dust ed9c26b012 Add user agent support for subscription updates
https://github.com/2dust/v2rayNG/issues/5043
2025-11-22 18:01:49 +08:00
2dust e782da7c71 Update hev-socks5-tunnel and hysteria submodules
Advanced the hev-socks5-tunnel and hysteria submodules to their latest commits to incorporate recent upstream changes.
2025-11-19 16:59:34 +08:00
2dust 6fd0f7f0db Update .gitmodules 2025-11-19 16:54:44 +08:00
2dust 7c14a0c200 Bug fix 2025-11-17 17:46:15 +08:00
2dust 2789db2c70 up 1.10.28 2025-11-16 11:43:54 +08:00
2dust 8aee96b5eb Update Google proxy routing rules 2025-11-16 11:37:47 +08:00
solokot 02d58dde9a Update Russian translation (#5031)
for https://github.com/2dust/v2rayNG/commit/b961c86066064c369c876c9850f65c3bf40e65a2
2025-11-12 19:10:32 +08:00
2dust 623f5446f2 Add portHoppingInterval support to Hysteria2Fmt
https://github.com/2dust/v2rayNG/issues/5009
2025-11-10 21:06:18 +08:00
2dust b961c86066 Fix
https://github.com/2dust/v2rayNG/issues/5009
2025-11-10 20:51:04 +08:00
Hossein Abaspanah 3e4f0e9b5b Update Luri Bakhtiari translation (#5024) 2025-11-10 19:56:58 +08:00
2dust c0141225d3 Add allowInsecure and insecure to the shared URI
https://github.com/2dust/v2rayN/issues/8267
2025-11-09 17:33:40 +08:00
2dust 026bf6a37b up 1.10.27 2025-11-06 18:21:43 +08:00
solokot c117a334ce Russian translation improvements (#4997) 2025-11-02 10:15:59 +08:00
2dust 051520de37 Set HEV tunnel preference default to true
Changed the default value of the HEV tunnel setting to true across the app, including preference XML, settings UI, config manager, and view model. This ensures that the HEV tunnel is enabled by default for new users and maintains consistent default behavior throughout the application.
2025-11-01 11:29:08 +08:00
2dust b5146e4712 Comment out per-app proxy setting in preferences 2025-11-01 11:12:15 +08:00
2dust 4457b6b2c9 Refactor PerAppProxyActivity and add auto-enable logic 2025-11-01 11:08:41 +08:00
2dust 12edb051c3 Update config-related strings for clarity and consistency 2025-11-01 10:13:03 +08:00
2dust 923e3f32d1 up 1.10.26 2025-10-26 17:46:30 +08:00
2dust 5490db90c0 up 1.10.25 2025-10-26 17:39:37 +08:00
2dust cb68d42291 Check for update to add fdroid 2025-10-26 17:38:51 +08:00
2dust ec3a8e80d6 up 1.10.25 2025-10-26 16:30:28 +08:00
2dust eb0960125b Update build.yml 2025-10-26 16:29:40 +08:00
2dust 12b70e2088 Adjust the adapter that can modify APPLICATION_ID
Add APPLICATION_ID to the About page
2025-10-26 16:08:44 +08:00
2dust 0c473dea19 up 1.10.24 2025-10-15 17:57:41 +08:00
2dust e71dd7342a Update AndroidLibXrayLite 2025-10-15 17:56:33 +08:00
2dust a765215dda Update libs.versions.toml 2025-10-10 19:14:46 +08:00
2dust edea3027f7 Fix
https://github.com/2dust/v2rayN/issues/8060
2025-10-03 14:28:31 +08:00
2dust 305219c3bc Update libs.versions.toml 2025-10-03 14:28:24 +08:00
DHR60 9c4188fba7 Adjust DNS routing (#4905) 2025-09-13 13:55:53 +08:00
2dust 0f3124bece up 1.10.23 2025-09-11 19:09:59 +08:00
2dust 2614f72a36 Update AndroidLibXrayLite 2025-09-11 19:08:56 +08:00
2dust 480c7fc8a1 up 1.10.22 2025-09-05 18:37:08 +08:00
2dust 302e7f148e Update libs.versions.toml 2025-09-05 18:36:42 +08:00
2dust 98d6da983b Update AndroidLibXrayLite 2025-09-05 18:36:27 +08:00
2dust f6f60f77d3 up 1.10.21 2025-09-01 09:41:04 +08:00
2dust 7be0fb3ecd Update AndroidLibXrayLite 2025-09-01 09:40:27 +08:00
2dust d53a9f9ecd up 1.10.20 2025-08-30 11:20:23 +08:00
2dust 9eb03eece2 Update AndroidLibXrayLite 2025-08-30 11:12:44 +08:00
2dust 6632ba3b6e Update libs.versions.toml 2025-08-25 18:39:00 +08:00
2dust bd582f1d90 Update libs.versions.toml 2025-08-25 18:32:15 +08:00
Evgenii Pravda f5f1e12565 Reasonable app sorting order (#4869) 2025-08-25 17:49:24 +08:00
2dust 207d6f4f8c Revert "Update build.yml"
This reverts commit fc0e60a097.
2025-08-17 11:15:48 +08:00
2dust 52e0a19826 up 1.10.19 2025-08-17 10:22:04 +08:00
2dust fc0e60a097 Update build.yml 2025-08-17 09:41:41 +08:00
Hossin Asaadi 65d6b4aaa8 fix redirect infinite loop (#4857) 2025-08-17 09:27:22 +08:00
Hossin Asaadi 7b11755e7f IPv6 Unreachability Fallback for TLS Configs (#4846)
* fix unreachable ipv6 fallback

* add UseIP domainStrategy

* fix DNS query loop
2025-08-16 14:34:53 +08:00
fuilloi 2d0de4860c fix (#4855) 2025-08-16 14:06:35 +08:00
solokot 7406ef16ff Update Russian translation (#4845) 2025-08-14 17:33:24 +08:00
DHR60 a084b21d50 Improves intelligent selection toast and DNS routing (#4838)
* Improves intelligent selection toast and DNS routing

* rename
2025-08-13 16:59:22 +08:00
2dust bf01fe2bdb up 1.10.18 2025-08-13 08:48:33 +08:00
Skh-web6982 519cc2a4b5 Bump actions/checkout from 4 to 5 (#4837)
Bump actions/checkout from 4 to 5
2025-08-13 08:38:18 +08:00
Tamim Hossain c21653d40f Feat/add mtu in settings (#4836)
* Added MTU In Settings

Added MTU In Settings.
Closes  #4824

* Update SettingsViewModel.kt
2025-08-13 08:38:07 +08:00
solokot 1919c5e05f Update Russian translation (#4833) 2025-08-13 08:37:52 +08:00
2dust 8a17d93882 up 1.10.17 2025-08-12 18:04:41 +08:00
2dust 21ed008c7b up strings 2025-08-12 18:02:22 +08:00
Tamim Hossain d83cfa28c2 Added MTU In Settings (#4828)
Added MTU In Settings.
Closes  #4824
2025-08-12 17:33:04 +08:00
2dust d27e2091a7 up 1.10.16 2025-08-09 20:14:11 +08:00
solokot c545678e47 Update Russian translation (#4823) 2025-08-09 19:48:31 +08:00
Hossein Abaspanah 05cba9b0fe Update Luri Bakhtiari translation (#4822) 2025-08-09 09:17:36 +08:00
Skh-web6982 b472aa0e6b Update Persian translation (#4821)
* Update Persian translation

* Update strings.xml
2025-08-09 09:17:28 +08:00
2dust 1ad840851a Adjust settings options 2025-08-08 19:44:03 +08:00
Skh-web6982 ffc74f479c Update gradle to 9.0.0 (#4820) 2025-08-08 18:45:08 +08:00
Skh-web6982 81c0087ac1 Update mmkvstatic v1.3.14 (#4819)
v1.3.14 / 2025-05-13
This is a Long Term Support (LTS) release.
This is a hot-fix release for Android/iOS/macOS users. So it is only available on Maven Central and CocoaPods.

Android
Support 16K pagesize.
Fix a potential log callback OOM crash.
Upgrade to NDK r28.1 to have full support of 16K pagesize.
iOS
Retain the callback handler to prevent a potential short-lived callback handler from crashing.
2025-08-08 17:11:24 +08:00
Skh-web6982 97327a0101 Update kotlin version to v2.2.0 (#4818)
* Update kotlin version to 2.2.0

* Update README.md : Update kotlin version to 2.2.0
2025-08-08 17:04:11 +08:00
fuilloi 6318dd554b add hevtun option (#4817) 2025-08-08 17:03:27 +08:00
2dust 785aa7eb8a up 1.10.15 2025-08-07 09:38:42 +08:00
fuilloi 872e926132 Update V2rayConfigManager.kt (#4813) 2025-08-07 09:37:06 +08:00
2dust b278180eb5 up 1.10.14 2025-08-06 19:27:28 +08:00
2dust ce64ffbf3b Update hev-socks5-tunnel 2025-08-06 17:46:04 +08:00
2dust 5ad8605a41 Update AndroidLibXrayLite 2025-08-06 17:46:02 +08:00
fuilloi 95f7e99752 fix hev-socks5-tunnel CustomLocalDns route (#4806)
* fix hev-socks5-tunnel dns route

* update
2025-08-06 16:54:04 +08:00
Peyman f703a41778 Update Persian translation (#4804) 2025-08-05 19:30:19 +08:00
fuilloi 2251c59c0b fix type (#4805) 2025-08-05 18:18:00 +08:00
2dust edd0ce96b1 remove libhev-socks5-tunnel.so 2025-08-05 18:03:18 +08:00
fuilloi 562283b3cc ndkbuild hev-socks5-tunnel (#4800) 2025-08-05 18:00:13 +08:00
solokot bb04953845 Update Russian translation (#4797) 2025-08-05 17:13:10 +08:00
Hossein Abaspanah 208a93a917 Update Luri Bakhtiari translation (#4796) 2025-08-05 17:13:00 +08:00
2dust 92f33ee3f8 up 1.10.13 2025-08-04 20:21:17 +08:00
2dust 084346b348 Update AndroidLibXrayLite 2025-08-04 19:46:45 +08:00
2dust 15de18b736 Add Enable New TUN Feature option
When enabled, TUN will use hev-socks5-tunnel; otherwise, it will use badvpn-tun2socks
2025-08-04 18:32:16 +08:00
Peyman b60423d1c0 Update Persian translate (#4791) 2025-08-04 10:00:28 +08:00
2dust 86e38c6963 up 1.10.12 2025-08-03 17:37:32 +08:00
2dust 9ba4d7e691 Add Mldsa65Verify 2025-08-03 14:13:06 +08:00
2dust 16e72787c9 Optimize V2RayVpnService 2025-08-02 17:39:12 +08:00
2dust 3ffac8b29f Standardized file naming 2025-08-02 17:06:06 +08:00
2dust 10df1b44ea Optimize V2RayVpnService add Tun2SocksManager 2025-08-02 16:20:41 +08:00
2dust fa12878258 Update libs.versions.toml 2025-08-02 16:08:28 +08:00
2dust 0f1ea1e119 Update hysteria 2025-08-02 16:08:15 +08:00
2dust 1959608f24 Update AndroidLibXrayLite 2025-08-02 16:08:04 +08:00
2dust 0e041a6e9a up 1.10.11 2025-07-26 20:21:01 +08:00
2dust c78ef380cc up 1.10.10 2025-07-24 19:34:31 +08:00
2dust 57362a4bde Update AndroidLibXrayLite 2025-07-24 19:28:19 +08:00
DHR60 7f24ad534f Fix Intelligent Selection not working (#4767) 2025-07-24 19:22:15 +08:00
2dust 680832614b up 1.10.9 2025-07-10 20:20:53 +08:00
2dust 4357abbff4 Bug fix
https://github.com/2dust/v2rayNG/issues/4723
2025-07-10 20:11:34 +08:00
solokot 905be66c3f Update Russian translation (#4725) 2025-07-09 19:44:45 +08:00
Hossein Abaspanah 318a7b54a5 Update Luri Bakhtiari translation (#4724) 2025-07-09 19:44:34 +08:00
DHR60 5db2df77a0 feat. Intelligent Selection (#4716)
rename

Adds intelligent selection method setting

Adds KDoc
2025-07-07 20:17:09 +08:00
DHR60 d039cb9edf Fix export count (#4713) 2025-07-06 11:16:32 +08:00
solokot 9a1654bae9 Update Russian translation (#4701) 2025-07-01 15:23:05 +08:00
2dust 3bf911da9c up 1.10.8 2025-06-29 10:27:57 +08:00
2dust 3f778a1ea2 Optimize the source of tls sni 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) 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
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
https://github.com/2dust/v2rayNG/commit/f305e26a395650301e5565d95ffb1d3199e846ed
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
https://github.com/2dust/v2rayNG/commit/38193b56216f332eb20b84a8e1395c9b4a1d7f42
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)
https://github.com/2dust/v2rayNG/commit/38193b56216f332eb20b84a8e1395c9b4a1d7f42
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
248 changed files with 12385 additions and 6448 deletions
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
+25 -55
View File
@@ -16,48 +16,48 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4.2.2
uses: actions/checkout@v6
with:
submodules: 'recursive'
fetch-depth: '0'
- name: Setup Android SDK
uses: android-actions/setup-android@v3.2.0
uses: android-actions/setup-android@v4.0.1
with:
log-accepted-android-sdk-licenses: false
cmdline-tools-version: '12266719'
packages: 'platforms;android-35 build-tools;35.0.0 platform-tools'
cmdline-tools-version: '13114758'
packages: 'platforms;android-36.1 build-tools;36.1.0 platform-tools'
- name: Install NDK
run: |
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
--channel=0 \
--install "ndk;28.2.13676358"
echo "NDK_HOME=$ANDROID_HOME/ndk/28.2.13676358" >> $GITHUB_ENV
sed -i '10i\
\
ndkVersion = "29.0.13113456"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
ndkVersion = "28.2.13676358"' ${{ github.workspace }}/V2rayNG/app/build.gradle.kts
- name: Restore cached libtun2socks
id: cache-libtun2socks-restore
- name: Restore cached libhevtun
id: cache-libhevtun-restore
uses: actions/cache/restore@v4
with:
path: ${{ github.workspace }}/libs
key: libtun2socks-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/badvpn/HEAD') }}-${{ hashFiles('.git/modules/libancillary/HEAD') }}
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
- name: Build libtun2socks
if: steps.cache-libtun2socks-restore.outputs.cache-hit != 'true'
- name: Build libhevtun
if: steps.cache-libhevtun-restore.outputs.cache-hit != 'true'
run: |
bash compile-tun2socks.sh
- name: Save libtun2socks
if: steps.cache-libtun2socks-restore.outputs.cache-hit != 'true'
bash compile-hevtun.sh
- name: Save libhevtun
if: steps.cache-libhevtun-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: ${{ github.workspace }}/libs
key: libtun2socks-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/badvpn/HEAD') }}-${{ hashFiles('.git/modules/libancillary/HEAD') }}
key: libhevtun-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hev-socks5-tunnel/HEAD') }}-${{ hashFiles('compile-hevtun.sh') }}
- name: Copy libtun2socks
- name: Copy libhevtun
run: |
cp -r ${{ github.workspace }}/libs ${{ github.workspace }}/V2rayNG/app
@@ -70,45 +70,15 @@ jobs:
popd
- name: Download libv2ray
uses: robinraju/release-downloader@v1.12
uses: robinraju/release-downloader@v1.13
with:
repository: '2dust/AndroidLibXrayLite'
tag: ${{ env.CURRENT_TAG }}
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 }}/hysteria/libs
key: libhysteria2-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hysteria/HEAD') }}-${{ hashFiles('libhysteria2.sh') }}
- name: Setup Golang
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
uses: actions/setup-go@v5.4.0
with:
go-version-file: 'AndroidLibXrayLite/go.mod'
cache: false
- name: Build libhysteria2
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
run: |
bash libhysteria2.sh
- name: Save libhysteria2
if: steps.cache-libhysteria2-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: ${{ github.workspace }}/hysteria/libs
key: libhysteria2-${{ runner.os }}-${{ env.NDK_HOME }}-${{ hashFiles('.git/modules/hysteria/HEAD') }}-${{ hashFiles('libhysteria2.sh') }}
- name: Copy libhysteria2
run: |
cp -r ${{ github.workspace }}/hysteria/libs ${{ github.workspace }}/V2rayNG/app
- name: Setup Java
uses: actions/setup-java@v4.7.0
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
@@ -129,21 +99,21 @@ jobs:
./gradlew assembleRelease -Pandroid.injected.signing.store.file=${{ steps.android_keystore.outputs.filePath }} -Pandroid.injected.signing.store.password=${{ secrets.APP_KEYSTORE_PASSWORD }} -Pandroid.injected.signing.key.alias=${{ secrets.APP_KEYSTORE_ALIAS }} -Pandroid.injected.signing.key.password=${{ secrets.APP_KEY_PASSWORD }}
- name: Upload arm64-v8a APK
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v7
if: ${{ success() }}
with:
name: arm64-v8a
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*arm64-v8a*.apk
- name: Upload armeabi-v7a APK
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v7
if: ${{ success() }}
with:
name: armeabi-v7a
path: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*armeabi-v7a*.apk
- name: Upload x86 APK
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v7
if: ${{ success() }}
with:
name: x86-apk
@@ -153,7 +123,7 @@ jobs:
uses: svenstaro/upload-release-action@v2
if: github.event.inputs.release_tag != ''
with:
file: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*playstore*/release/*.apk
file: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*.apk
tag: ${{ github.event.inputs.release_tag }}
file_glob: true
prerelease: true
+2 -2
View File
@@ -11,6 +11,6 @@ jobs:
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Validate Fastlane Supply Metadata
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.0.0
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0
+60
View File
@@ -1,6 +1,66 @@
# Ignore data and key store files
*.dat
*.jks
# Ignore output JSON file
V2rayNG/app/release/output.json
# Ignore IDE and build system directories
.idea/
.gradle/
*.iml
# Ignore local properties and DS_Store files
/local.properties
.DS_Store
# Ignore build directories and captures
/build
/captures
V2rayNG/app/build
V2rayNG/build
V2rayNG/local.properties
# Ignore APK and AAR files
*.apk
*.aar
# Ignore signing properties
signing.properties
# Ignore shared object files
*.so
# Ignore Google services JSON
V2rayNG/app/google-services.json
# Additional common Android/Java ignores
*.log
*.tmp
*.bak
*.swp
*.orig
*.class
*.jar
*.war
*.ear
# Ignore executable files
*.exe
*.dll
*.obj
*.o
*.pyc
*.pyo
# Ignore files from other IDEs
.vscode/
.classpath
.project
.settings/
*.sublime-workspace
*.sublime-project
# Ignore OS-specific files
Thumbs.db
.DS_Store
+3 -9
View File
@@ -1,12 +1,6 @@
[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
[submodule "hev-socks5-tunnel"]
path = hev-socks5-tunnel
url = https://github.com/heiher/hev-socks5-tunnel
+3 -7
View File
@@ -2,17 +2,13 @@
A V2Ray client for Android, support [Xray core](https://github.com/XTLS/Xray-core) and [v2fly core](https://github.com/v2fly/v2ray-core)
[![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.20-blue.svg)](https://kotlinlang.org)
[![API](https://img.shields.io/badge/API-24%2B-yellow.svg?style=flat)](https://developer.android.com/about/versions/lollipop)
[![Kotlin Version](https://img.shields.io/badge/Kotlin-2.3.0-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
View File
@@ -1,10 +0,0 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
*.apk
signing.properties
*.aar
-2
View File
@@ -1,2 +0,0 @@
/build
/google-services.json
+17 -11
View File
@@ -6,14 +6,14 @@ plugins {
android {
namespace = "com.v2ray.ang"
compileSdk = 35
compileSdk = 36
defaultConfig {
applicationId = "com.v2ray.ang"
minSdk = 21
targetSdk = 35
versionCode = 641
versionName = "1.9.41"
minSdk = 24
targetSdk = 36
versionCode = 722
versionName = "2.1.2"
multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
@@ -67,14 +67,16 @@ android {
}
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
applicationVariants.all {
@@ -82,8 +84,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 }
@@ -145,16 +148,19 @@ dependencies {
implementation(libs.preference.ktx)
implementation(libs.recyclerview)
implementation(libs.androidx.swiperefreshlayout)
implementation(libs.androidx.viewpager2)
implementation(libs.androidx.fragment)
// UI Libraries
implementation(libs.material)
implementation(libs.toastcompat)
implementation(libs.toasty)
implementation(libs.editorkit)
implementation(libs.flexbox)
// Data and Storage Libraries
implementation(libs.mmkv.static)
implementation(libs.gson)
implementation(libs.okhttp)
// Reactive and Utility Libraries
implementation(libs.kotlinx.coroutines.android)
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">v2rayNG (F-Droid)</string>
</resources>
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:enabled="true"
android:icon="@drawable/ic_qu_switch_24dp"
android:shortcutDisabledMessage="@string/app_widget_name"
android:shortcutId="shortcuts_switch"
android:shortcutLongLabel="@string/app_widget_name"
android:shortcutShortLabel="@string/app_widget_name">
<!--suppress AndroidDomInspection -->
<intent
android:action="android.intent.action.VIEW"
android:targetClass="com.v2ray.ang.ui.ScSwitchActivity"
android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<shortcut
android:enabled="true"
android:icon="@drawable/ic_qu_scan_24dp"
android:shortcutDisabledMessage="@string/menu_item_import_config_qrcode"
android:shortcutId="shortcuts_scan"
android:shortcutLongLabel="@string/menu_item_import_config_qrcode"
android:shortcutShortLabel="@string/menu_item_import_config_qrcode">
<!--suppress AndroidDomInspection -->
<intent
android:action="android.intent.action.VIEW"
android:targetClass="com.v2ray.ang.ui.ScScannerActivity"
android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<shortcut
android:enabled="true"
android:icon="@drawable/ic_qu_start_24dp"
android:shortcutDisabledMessage="@string/toast_services_start"
android:shortcutId="shortcuts_start"
android:shortcutLongLabel="@string/toast_services_start"
android:shortcutShortLabel="@string/toast_services_start">
<!--suppress AndroidDomInspection -->
<intent
android:action="android.intent.action.VIEW"
android:targetClass="com.v2ray.ang.ui.ScStartActivity"
android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<shortcut
android:enabled="true"
android:icon="@drawable/ic_qu_stop_24dp"
android:shortcutDisabledMessage="@string/toast_services_stop"
android:shortcutId="shortcuts_stop"
android:shortcutLongLabel="@string/toast_services_stop"
android:shortcutShortLabel="@string/toast_services_stop">
<!--suppress AndroidDomInspection -->
<intent
android:action="android.intent.action.VIEW"
android:targetClass="com.v2ray.ang.ui.ScStopActivity"
android:targetPackage="com.v2ray.ang.fdroid" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
</shortcuts>
+35 -16
View File
@@ -10,10 +10,6 @@
android:smallScreens="true"
android:xlargeScreens="true" />
<uses-sdk
android:minSdkVersion="21"
tools:overrideLibrary="com.blacksquircle.ui.editorkit" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
@@ -30,12 +26,11 @@
<!-- https://developer.android.com/about/versions/11/privacy/package-visibility -->
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.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,17 +47,15 @@
android:banner="@mipmap/ic_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppThemeDayNight"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="m">
android:supportsRtl="true"
android:theme="@style/AppThemeDayNight.NoActionBar"
android:usesCleartextTraffic="true">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/AppThemeDayNight.NoActionBar">
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -85,12 +78,19 @@
android:name=".ui.ServerCustomConfigActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged" />
<activity
android:name=".ui.ServerGroupActivity"
android:exported="false"
android:windowSoftInputMode="stateUnchanged" />
<activity
android:name=".ui.SettingsActivity"
android:exported="false" />
<activity
android:name=".ui.PerAppProxyActivity"
android:exported="false" />
<activity
android:name=".ui.AppPickerActivity"
android:exported="false" />
<activity
android:name=".ui.ScannerActivity"
android:exported="false" />
@@ -124,7 +124,19 @@
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:theme="@style/AppTheme.NoActionBar.Translucent" />
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.ScStartActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.ScStopActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":RunSoLibV2RayDaemon"
android:theme="@style/AppThemeDayNight.NoActionBar.Translucent" />
<activity
android:name=".ui.UrlSchemeActivity"
@@ -145,6 +157,12 @@
<data android:host="install-sub" />
</intent-filter>
</activity>
<activity
android:name=".ui.CheckUpdateActivity"
android:exported="false" />
<activity
android:name=".ui.BackupActivity"
android:exported="false" />
<activity
android:name=".ui.AboutActivity"
android:exported="false" />
@@ -156,7 +174,8 @@
android:foregroundServiceType="specialUse"
android:label="@string/app_name"
android:permission="android.permission.BIND_VPN_SERVICE"
android:process=":RunSoLibV2RayDaemon">
android:process=":RunSoLibV2RayDaemon"
tools:ignore="VpnServicePolicy">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
@@ -193,8 +212,8 @@
android:resource="@xml/app_widget_provider" />
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="com.v2ray.ang.action.widget.click" />
<action android:name="com.v2ray.ang.action.activity" />
<action android:name="${applicationId}.action.widget.click" />
<action android:name="${applicationId}.action.activity" />
</intent-filter>
</receiver>
<receiver
@@ -6,14 +6,6 @@
"bittorrent"
]
},
{
"remarks": "Google cn",
"outboundTag": "proxy",
"domain": [
"domain:googleapis.cn",
"domain:gstatic.com"
]
},
{
"remarks": "阻断udp443",
"outboundTag": "block",
@@ -21,10 +13,10 @@
"network": "udp"
},
{
"remarks": "阻断广告",
"outboundTag": "block",
"remarks": "代理Google",
"outboundTag": "proxy",
"domain": [
"geosite:category-ads-all"
"geosite:google"
]
},
{
@@ -146,4 +138,4 @@
"port": "0-65535",
"outboundTag": "direct"
}
]
]
@@ -5,13 +5,6 @@
"port": "443",
"network": "udp"
},
{
"remarks": "阻断广告",
"outboundTag": "block",
"domain": [
"geosite:category-ads-all"
]
},
{
"remarks": "绕过局域网IP",
"outboundTag": "direct",
@@ -1,12 +1,4 @@
[
{
"remarks": "Google cn",
"outboundTag": "proxy",
"domain": [
"domain:googleapis.cn",
"domain:gstatic.com"
]
},
{
"remarks": "阻断udp443",
"outboundTag": "block",
@@ -14,10 +6,10 @@
"network": "udp"
},
{
"remarks": "阻断广告",
"outboundTag": "block",
"remarks": "代理Google",
"outboundTag": "proxy",
"domain": [
"geosite:category-ads-all"
"geosite:google"
]
},
{
@@ -100,4 +92,4 @@
"geosite:cn"
]
}
]
]
@@ -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",
@@ -0,0 +1,43 @@
[
{
"remarks": "Bypass bittorrent",
"outboundTag": "direct",
"protocol": [
"bittorrent"
]
},
{
"remarks": "Block udp443",
"outboundTag": "block",
"port": "443",
"network": "udp"
},
{
"remarks": "Direct LAN IP",
"outboundTag": "direct",
"ip": [
"geoip:private"
]
},
{
"remarks": "Direct LAN domains",
"outboundTag": "direct",
"domain": [
"geosite:private"
]
},
{
"remarks": "Bypass Russia domains",
"outboundTag": "direct",
"domain": [
"geosite:category-ru"
]
},
{
"remarks": "Bypass Russia IP",
"outboundTag": "direct",
"ip": [
"geoip:ru"
]
}
]
@@ -33,14 +33,6 @@
"tls"
]
}
},
{
"tag": "http",
"port": 10809,
"protocol": "http",
"settings": {
"userLevel": 8
}
}
],
"outbounds": [{
@@ -97,7 +89,7 @@
}
],
"routing": {
"domainStrategy": "IPIfNonMatch",
"domainStrategy": "AsIs",
"rules": []
},
"dns": {
@@ -0,0 +1,115 @@
{
"stats":{},
"log": {
"loglevel": "warning"
},
"policy":{
"levels": {
"8": {
"handshake": 4,
"connIdle": 300,
"uplinkOnly": 1,
"downlinkOnly": 1
}
},
"system": {
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"inbounds": [{
"tag": "socks",
"port": 10808,
"protocol": "socks",
"settings": {
"auth": "noauth",
"udp": true,
"userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
]
}
},
{
"tag": "tun",
"protocol": "tun",
"settings": {
"name": "xray0",
"MTU": 1500,
"userLevel": 8
},
"sniffing": {
"enabled": true,
"destOverride": [
"http",
"tls"
]
}
}
],
"outbounds": [{
"tag": "proxy",
"protocol": "vmess",
"settings": {
"vnext": [
{
"address": "v2ray.cool",
"port": 10086,
"users": [
{
"id": "a3482e88-686a-4a58-8126-99c9df64b7bf",
"alterId": 0,
"security": "auto",
"level": 8
}
]
}
],
"servers": [
{
"address": "v2ray.cool",
"method": "chacha20",
"ota": false,
"password": "123456",
"port": 10086,
"level": 8
}
]
},
"streamSettings": {
"network": "tcp"
},
"mux": {
"enabled": false
}
},
{
"protocol": "freedom",
"settings": {
"domainStrategy": "UseIP"
},
"tag": "direct"
},
{
"protocol": "blackhole",
"tag": "block",
"settings": {
"response": {
"type": "http"
}
}
}
],
"routing": {
"domainStrategy": "AsIs",
"rules": []
},
"dns": {
"hosts": {},
"servers": []
}
}
@@ -34,10 +34,15 @@ class AngApplication : MultiDexApplication() {
MMKV.initialize(this)
SettingsManager.setNightMode()
// Initialize WorkManager with the custom configuration
WorkManager.initialize(this, workManagerConfiguration)
SettingsManager.initRoutingRulesets(this)
// Ensure critical preference defaults are present in MMKV early
SettingsManager.initApp(this)
SettingsManager.setNightMode()
es.dmoral.toasty.Toasty.Config.getInstance()
.setGravity(android.view.Gravity.BOTTOM, 0, 300)
.apply()
}
}
@@ -5,14 +5,20 @@ object AppConfig {
/** The application's package name. */
const val ANG_PACKAGE = BuildConfig.APPLICATION_ID
const val TAG = BuildConfig.APPLICATION_ID
/** Directory names used in the app's file system. */
const val DIR_ASSETS = "assets"
const val DIR_BACKUPS = "backups"
const val WEBDAV_BACKUP_DIR = "backups"
const val WEBDAV_BACKUP_FILE_NAME = "backup_ng.zip"
/** Legacy configuration keys. */
const val ANG_CONFIG = "ang_config"
// Default subscription ID for ungrouped servers
const val DEFAULT_SUBSCRIPTION_ID = "__default_subscription__"
/** Preferences mapped to MMKV storage. */
const val PREF_SNIFFING_ENABLED = "pref_sniffing_enabled"
const val PREF_ROUTE_ONLY_ENABLED = "pref_route_only_enabled"
@@ -25,6 +31,8 @@ object AppConfig {
const val PREF_LOCAL_DNS_PORT = "pref_local_dns_port"
const val PREF_VPN_DNS = "pref_vpn_dns"
const val PREF_VPN_BYPASS_LAN = "pref_vpn_bypass_lan"
const val PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX = "pref_vpn_interface_address_config_index"
const val PREF_VPN_MTU = "pref_vpn_mtu"
const val PREF_ROUTING_DOMAIN_STRATEGY = "pref_routing_domain_strategy"
const val PREF_ROUTING_RULESET = "pref_routing_ruleset"
const val PREF_MUX_ENABLED = "pref_mux_enabled"
@@ -42,31 +50,45 @@ 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_GROUP_ALL_DISPLAY = "pref_group_all_display"
const val PREF_LANGUAGE = "pref_language"
const val PREF_UI_MODE_NIGHT = "pref_ui_mode_night"
const val PREF_IPV6_ENABLED = "pref_ipv6_enabled"
const val PREF_PREFER_IPV6 = "pref_prefer_ipv6"
const val PREF_PROXY_SHARING = "pref_proxy_sharing_enabled"
const val PREF_ALLOW_INSECURE = "pref_allow_insecure"
const val PREF_SOCKS_PORT = "pref_socks_port"
const val PREF_DYNAMIC_SOCKS_PORT = "pref_dynamic_socks_port"
const val PREF_SOCKS_USERNAME = "pref_socks_username"
const val PREF_SOCKS_PASSWORD = "pref_socks_password"
const val PREF_REMOTE_DNS = "pref_remote_dns"
const val PREF_DOMESTIC_DNS = "pref_domestic_dns"
const val PREF_DNS_HOSTS = "pref_dns_hosts"
const val PREF_DELAY_TEST_URL = "pref_delay_test_url"
const val PREF_IP_API_URL = "pref_ip_api_url"
const val PREF_LOGLEVEL = "pref_core_loglevel"
const val PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD = "pref_outbound_domain_resolve_method"
const val PREF_MODE = "pref_mode"
const val PREF_IS_BOOTED = "pref_is_booted"
const val PREF_CHECK_UPDATE_PRE_RELEASE = "pref_check_update_pre_release"
const val PREF_GEO_FILES_SOURCES = "pref_geo_files_sources"
const val PREF_USE_HEV_TUNNEL = "pref_use_hev_tunnel_v2"
const val PREF_HEV_TUNNEL_LOGLEVEL = "pref_hev_tunnel_loglevel"
const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout_v2"
const val PREF_AUTO_REMOVE_INVALID_AFTER_TEST = "pref_auto_remove_invalid_after_test"
const val PREF_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
/** 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"
const val BROADCAST_ACTION_ACTIVITY = "com.v2ray.ang.action.activity"
const val BROADCAST_ACTION_WIDGET_CLICK = "com.v2ray.ang.action.widget.click"
const val BROADCAST_ACTION_SERVICE = "$ANG_PACKAGE.action.service"
const val BROADCAST_ACTION_ACTIVITY = "$ANG_PACKAGE.action.activity"
const val BROADCAST_ACTION_WIDGET_CLICK = "$ANG_PACKAGE.action.widget.click"
/** Tasker extras. */
const val TASKER_EXTRA_BUNDLE = "com.twofortyfouram.locale.intent.extra.BUNDLE"
@@ -80,25 +102,31 @@ object AppConfig {
const val TAG_DIRECT = "direct"
const val TAG_BLOCKED = "block"
const val TAG_FRAGMENT = "fragment"
const val TAG_DNS = "dns-module"
const val TAG_DOMESTIC_DNS = "domestic-dns"
const val TAG_BALANCER = "proxy-round"
/** Network-related constants. */
const val UPLINK = "uplink"
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"
const val IP_API_URL = "https://api.ip.sb/geoip"
/** DNS server addresses. */
const val DNS_PROXY = "1.1.1.1"
@@ -109,6 +137,12 @@ object AppConfig {
const val GEOIP_PRIVATE = "geoip:private"
const val GEOIP_CN = "geoip:cn"
/** Geo data file names. */
const val GEOSITE_DAT = "geosite.dat"
const val GEOIP_DAT = "geoip.dat"
const val GEOIP_ONLY_CN_PRIVATE_DAT = "geoip-only-cn-private.dat"
const val GEOIP_ONLY_CN_PRIVATE_URL = "$GITHUB_RAW_URL/Loyalsoldier/geoip/release/$GEOIP_ONLY_CN_PRIVATE_DAT"
/** Ports and addresses for various services. */
const val PORT_LOCAL_DNS = "10853"
const val PORT_SOCKS = "10808"
@@ -133,6 +167,8 @@ object AppConfig {
const val MSG_MEASURE_CONFIG = 7
const val MSG_MEASURE_CONFIG_SUCCESS = 71
const val MSG_MEASURE_CONFIG_CANCEL = 72
const val MSG_MEASURE_CONFIG_NOTIFY = 73
const val MSG_MEASURE_CONFIG_FINISH = 74
/** Notification channel IDs and names. */
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
@@ -150,11 +186,16 @@ object AppConfig {
const val TROJAN = "trojan://"
const val WIREGUARD = "wireguard://"
const val TUIC = "tuic://"
const val HYSTERIA = "hysteria://"
const val HYSTERIA2 = "hysteria2://"
const val HY2 = "hy2://"
/** Give a good name to this, IDK*/
const val VPN = "VPN"
const val VPN_MTU = 1500
/** hev-sock5-tunnel read-write-timeout value */
const val HEVTUN_RW_TIMEOUT = "300,60"
// Google API rule constants
const val GOOGLEAPIS_CN_DOMAIN = "domain:googleapis.cn"
@@ -163,19 +204,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 +219,71 @@ object AppConfig {
const val REALITY = "reality"
const val HEADER_TYPE_HTTP = "http"
const val UNIDENTIFIED_PACKAGE = "__unknown_app__"
val DNS_ALIDNS_ADDRESSES = arrayListOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1")
val DNS_CLOUDFLARE_ONE_ADDRESSES = arrayListOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
val DNS_CLOUDFLARE_DNS_COM_ADDRESSES = arrayListOf("104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5")
val DNS_CLOUDFLARE_DNS_ADDRESSES = arrayListOf("104.16.248.249", "104.16.249.249", "2606:4700::6810:f8f9", "2606:4700::6810:f9f9")
val DNS_DNSPOD_ADDRESSES = arrayListOf("1.12.12.12", "120.53.53.53")
val DNS_GOOGLE_ADDRESSES = arrayListOf("8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844")
val DNS_QUAD9_ADDRESSES = arrayListOf("9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9")
val DNS_YANDEX_ADDRESSES = arrayListOf("77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff")
//minimum list https://serverfault.com/a/304791
val ROUTED_IP_LIST = arrayListOf(
"0.0.0.0/5",
"8.0.0.0/7",
"11.0.0.0/8",
"12.0.0.0/6",
"16.0.0.0/4",
"32.0.0.0/3",
"64.0.0.0/2",
"128.0.0.0/3",
"160.0.0.0/5",
"168.0.0.0/6",
"172.0.0.0/12",
"172.32.0.0/11",
"172.64.0.0/10",
"172.128.0.0/9",
"173.0.0.0/8",
"174.0.0.0/7",
"176.0.0.0/4",
"192.0.0.0/9",
"192.128.0.0/11",
"192.160.0.0/13",
"192.169.0.0/16",
"192.170.0.0/15",
"192.172.0.0/14",
"192.176.0.0/12",
"192.192.0.0/10",
"193.0.0.0/8",
"194.0.0.0/7",
"196.0.0.0/6",
"200.0.0.0/5",
"208.0.0.0/4",
"240.0.0.0/4"
)
val PRIVATE_IP_LIST = arrayListOf(
"0.0.0.0/8",
"10.0.0.0/8",
"127.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16",
"224.0.0.0/4"
)
val GEO_FILES_SOURCES = arrayListOf(
"Loyalsoldier/v2ray-rules-dat",
"runetfreedom/russia-v2ray-rules-dat",
"Chocolate4U/Iran-v2ray-rules"
)
val BUILTIN_OUTBOUND_TAGS = setOf(
TAG_PROXY,
TAG_DIRECT,
TAG_BLOCKED,
)
}
@@ -0,0 +1,32 @@
package com.v2ray.ang.contracts
/**
* A common Adapter -> host callback interface that includes common actions: edit, remove and refresh.
* Extend this interface or define more specific interfaces for different adapters as needed.
*/
interface BaseAdapterListener {
/**
* Request the host to edit the specified item.
* @param guid Unique identifier (GUID) of the item
* @param position Current position in the adapter (optional; host should validate it)
*/
fun onEdit(guid: String, position: Int)
/**
* Request the host to remove the specified item. Position is provided for optional animation or validation.
* @param guid Unique identifier (GUID) of the item
* @param position Current position in the adapter (optional; host should validate it)
*/
fun onRemove(guid: String, position: Int)
/**
* Request the host to share the specified URL.
* @param url The URL to be shared
*/
fun onShare(url: String)
/**
* Request the host to refresh data (for example, reload from the ViewModel or call notifyDataSetChanged).
*/
fun onRefreshData()
}
@@ -0,0 +1,13 @@
package com.v2ray.ang.contracts
import com.v2ray.ang.dto.ProfileItem
interface MainAdapterListener : BaseAdapterListener {
fun onEdit(guid: String, position: Int, profile: ProfileItem)
fun onSelectServer(guid: String)
fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean)
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.service
package com.v2ray.ang.contracts
import android.app.Service
@@ -0,0 +1,19 @@
package com.v2ray.ang.contracts
/**
* Interface that defines the control operations for tun2socks implementations.
*
* This interface is implemented by different tunnel solutions like:
*/
interface Tun2SocksControl {
/**
* Starts the tun2socks process with the appropriate parameters.
* This initializes the VPN tunnel and connects it to the SOCKS proxy.
*/
fun startTun2Socks()
/**
* Stops the tun2socks process and cleans up resources.
*/
fun stopTun2Socks()
}
@@ -0,0 +1,6 @@
package com.v2ray.ang.dto
data class AssetUrlCache(
val guid: String,
val assetUrl: AssetUrlItem
)
@@ -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,
)
@@ -0,0 +1,10 @@
package com.v2ray.ang.dto
data class CheckUpdateResult(
val hasUpdate: Boolean,
val latestVersion: String? = null,
val releaseNotes: String? = null,
val downloadUrl: String? = null,
val error: String? = null,
val isPreRelease: Boolean = false
)
@@ -4,6 +4,5 @@ data class ConfigResult(
var status: Boolean,
var guid: String? = null,
var content: String = "",
var domainPort: String? = null,
)
@@ -0,0 +1,23 @@
package com.v2ray.ang.dto
import com.google.gson.annotations.SerializedName
data class GitHubRelease(
@SerializedName("tag_name")
val tagName: String,
@SerializedName("body")
val body: String,
@SerializedName("assets")
val assets: List<Asset>,
@SerializedName("prerelease")
val prerelease: Boolean = false,
@SerializedName("published_at")
val publishedAt: String = ""
) {
data class Asset(
@SerializedName("name")
val name: String,
@SerializedName("browser_download_url")
val browserDownloadUrl: String
)
}
@@ -0,0 +1,6 @@
package com.v2ray.ang.dto
data class GroupMapItem(
var id: String,
var remarks: String,
)
@@ -1,46 +0,0 @@
package com.v2ray.ang.dto
data class Hysteria2Bean(
val server: String?,
val auth: String?,
val lazy: Boolean? = true,
val obfs: ObfsBean? = null,
val socks5: Socks5Bean? = null,
val http: Socks5Bean? = null,
val tls: TlsBean? = null,
val transport: TransportBean? = null,
val bandwidth: BandwidthBean? = null,
) {
data class ObfsBean(
val type: String?,
val salamander: SalamanderBean?
) {
data class SalamanderBean(
val password: String?,
)
}
data class Socks5Bean(
val listen: String?,
)
data class TlsBean(
val sni: String?,
val insecure: Boolean?,
val pinSHA256: String?,
)
data class TransportBean(
val type: String?,
val udp: TransportUdpBean?
) {
data class TransportUdpBean(
val hopInterval: String?,
)
}
data class BandwidthBean(
val down: String?,
val up: String?,
)
}
@@ -0,0 +1,17 @@
package com.v2ray.ang.dto
data class IPAPIInfo(
var ip: String? = null,
var clientIp: String? = null,
var ip_addr: String? = null,
var query: String? = null,
var country: String? = null,
var country_name: String? = null,
var country_code: String? = null,
var countryCode: String? = null,
var location: LocationBean? = null
) {
data class LocationBean(
var country_code: String? = null
)
}
@@ -5,6 +5,7 @@ import com.v2ray.ang.AppConfig.PORT_SOCKS
import com.v2ray.ang.AppConfig.TAG_BLOCKED
import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.TAG_PROXY
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.Utils
data class ProfileItem(
@@ -14,6 +15,7 @@ data class ProfileItem(
var addedTime: Long = System.currentTimeMillis(),
var remarks: String = "",
var description: String? = null,
var server: String? = null,
var serverPort: String? = null,
@@ -34,16 +36,20 @@ data class ProfileItem(
var authority: String? = null,
var xhttpMode: String? = null,
var xhttpExtra: String? = null,
var finalMask: String? = null,
var security: String? = null,
var sni: String? = null,
var alpn: String? = null,
var fingerPrint: String? = null,
var insecure: Boolean? = null,
var echConfigList: String? = null,
var echForceQuery: String? = null,
var pinnedCA256: String? = null,
var publicKey: String? = null,
var shortId: String? = null,
var spiderX: String? = null,
var mldsa65Verify: String? = null,
var secretKey: String? = null,
var preSharedKey: String? = null,
@@ -58,6 +64,10 @@ data class ProfileItem(
var bandwidthDown: String? = null,
var bandwidthUp: String? = null,
var policyGroupType: String? = null,
var policyGroupSubscriptionId: String? = null,
var policyGroupFilter: String? = null,
) {
companion object {
fun create(configType: EConfigType): ProfileItem {
@@ -114,7 +124,7 @@ data class ProfileItem(
&& this.obfsPassword == obj.obfsPassword
&& this.portHopping == obj.portHopping
&& this.portHoppingInterval == obj.portHoppingInterval
&& this.pinSHA256 == obj.pinSHA256
&& this.pinnedCA256 == obj.pinnedCA256
)
}
}
@@ -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?,
)
@@ -4,6 +4,7 @@ data class RulesetItem(
var remarks: String? = "",
var ip: List<String>? = null,
var domain: List<String>? = null,
var process: List<String>? = null,
var outboundTag: String = "",
var port: String? = null,
var network: String? = null,
@@ -1,86 +0,0 @@
package com.v2ray.ang.dto
import com.v2ray.ang.AppConfig.TAG_BLOCKED
import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.TAG_PROXY
data class ServerConfig(
val configVersion: Int = 3,
val configType: EConfigType,
var subscriptionId: String = "",
val addedTime: Long = System.currentTimeMillis(),
var remarks: String = "",
val outboundBean: V2rayConfig.OutboundBean? = null,
var fullConfig: V2rayConfig? = null
) {
companion object {
fun create(configType: EConfigType): ServerConfig {
when (configType) {
EConfigType.VMESS,
EConfigType.VLESS ->
return ServerConfig(
configType = configType,
outboundBean = V2rayConfig.OutboundBean(
protocol = configType.name.lowercase(),
settings = V2rayConfig.OutboundBean.OutSettingsBean(
vnext = listOf(
V2rayConfig.OutboundBean.OutSettingsBean.VnextBean(
users = listOf(V2rayConfig.OutboundBean.OutSettingsBean.VnextBean.UsersBean())
)
)
),
streamSettings = V2rayConfig.OutboundBean.StreamSettingsBean()
)
)
EConfigType.CUSTOM ->
return ServerConfig(configType = configType)
EConfigType.SHADOWSOCKS,
EConfigType.SOCKS,
EConfigType.HTTP,
EConfigType.TROJAN,
EConfigType.HYSTERIA2 ->
return ServerConfig(
configType = configType,
outboundBean = V2rayConfig.OutboundBean(
protocol = configType.name.lowercase(),
settings = V2rayConfig.OutboundBean.OutSettingsBean(
servers = listOf(V2rayConfig.OutboundBean.OutSettingsBean.ServersBean())
),
streamSettings = V2rayConfig.OutboundBean.StreamSettingsBean()
)
)
EConfigType.WIREGUARD ->
return ServerConfig(
configType = configType,
outboundBean = V2rayConfig.OutboundBean(
protocol = configType.name.lowercase(),
settings = V2rayConfig.OutboundBean.OutSettingsBean(
secretKey = "",
peers = listOf(V2rayConfig.OutboundBean.OutSettingsBean.WireGuardBean())
)
)
)
}
}
}
fun getProxyOutbound(): V2rayConfig.OutboundBean? {
if (configType != EConfigType.CUSTOM) {
return outboundBean
}
return fullConfig?.getProxyOutbound()
}
fun getAllOutboundTags(): MutableList<String> {
if (configType != EConfigType.CUSTOM) {
return mutableListOf(TAG_PROXY, TAG_DIRECT, TAG_BLOCKED)
}
fullConfig?.let { config ->
return config.outbounds.map { it.tag }.toMutableList()
}
return mutableListOf()
}
}
@@ -0,0 +1,6 @@
package com.v2ray.ang.dto
data class SubscriptionCache(
val guid: String,
val subscription: SubscriptionItem
)
@@ -11,5 +11,7 @@ data class SubscriptionItem(
var prevProfile: String? = null,
var nextProfile: String? = null,
var filter: String? = null,
var allowInsecureUrl: Boolean = false,
var userAgent: String? = null,
)
@@ -0,0 +1,24 @@
package com.v2ray.ang.dto
/**
* Result of subscription update operation
*/
data class SubscriptionUpdateResult(
val configCount: Int = 0, // Total configs updated
val successCount: Int = 0, // Subscriptions updated successfully
val failureCount: Int = 0, // Subscriptions failed to update
val skipCount: Int = 0 // Subscriptions skipped (disabled)
) {
/**
* Combine two results by adding their counts
*/
operator fun plus(other: SubscriptionUpdateResult): SubscriptionUpdateResult {
return SubscriptionUpdateResult(
configCount = this.configCount + other.configCount,
successCount = this.successCount + other.successCount,
failureCount = this.failureCount + other.failureCount,
skipCount = this.skipCount + other.skipCount
)
}
}
@@ -0,0 +1,10 @@
package com.v2ray.ang.dto
import java.io.Serializable
data class TestServiceMessage(
val key: Int,
val subscriptionId: String = "",
val serverGuids: List<String> = emptyList()
) : Serializable
@@ -1,28 +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.ServersBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean.VnextBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean.VnextBean.UsersBean
import com.v2ray.ang.dto.V2rayConfig.OutboundBean.OutSettingsBean.WireGuardBean
import com.v2ray.ang.util.Utils
import java.lang.reflect.Type
import com.v2ray.ang.enums.EConfigType
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,
@@ -34,31 +23,37 @@ 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
)
data class InboundBean(
var tag: String,
var port: Int,
var port: Int?,
var protocol: String,
var listen: String? = null,
val settings: Any? = null,
val sniffing: SniffingBean?,
var settings: InSettingsBean? = null,
var sniffing: SniffingBean? = null,
val streamSettings: Any? = null,
val allocate: Any? = null
) {
data class InSettingsBean(
val auth: String? = null,
val udp: Boolean? = null,
val userLevel: Int? = null,
val address: String? = null,
val port: Int? = null,
val network: String? = null
)
var auth: String? = null,
var udp: Boolean? = null,
var userLevel: Int? = null,
var accounts: List<SocksAccountBean>? = null,
var name: String? = null,
var mtu: Int? = null
) {
data class SocksAccountBean(
var user: String = "",
var pass: String = ""
)
}
data class SniffingBean(
var enabled: Boolean,
@@ -77,61 +72,15 @@ 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,
var noises: List<NoiseBean>? = null,
var servers: List<ServersBean>? = null,
/*Blackhole*/
var response: Response? = null,
/*DNS*/
val network: String? = null,
var address: Any? = null,
val port: Int? = null,
var port: Int? = null,
/*Freedom*/
var domainStrategy: String? = null,
val redirect: String? = null,
@@ -144,6 +93,7 @@ data class V2rayConfig(
var reserved: List<Int>? = null,
var mtu: Int? = null,
var obfsPassword: String? = null,
var version: Int? = null,
) {
data class VnextBean(
@@ -162,18 +112,6 @@ data class V2rayConfig(
)
}
data class FragmentBean(
var packets: String? = null,
var length: String? = null,
var interval: String? = null
)
data class NoiseBean(
var type: String? = null,
var packet: String? = null,
var delay: String? = null
)
data class ServersBean(
var address: String = "",
var method: String? = null,
@@ -197,7 +135,7 @@ data class V2rayConfig(
data class WireGuardBean(
var publicKey: String = "",
var preSharedKey: String = "",
var preSharedKey: String? = null,
var endpoint: String = ""
)
}
@@ -215,7 +153,8 @@ data class V2rayConfig(
var quicSettings: QuicSettingBean? = null,
var realitySettings: TlsSettingsBean? = null,
var grpcSettings: GrpcSettingsBean? = null,
var hy2steriaSettings: Hy2steriaSettingsBean? = null,
var hysteriaSettings: HysteriaSettingsBean? = null,
var finalmask: Any? = null,
val dsSettings: Any? = null,
var sockopt: SockoptBean? = null
) {
@@ -255,14 +194,8 @@ data class V2rayConfig(
var downlinkCapacity: Int = 100,
var congestion: Boolean = false,
var readBufferSize: Int = 1,
var writeBufferSize: Int = 1,
var header: HeaderBean = HeaderBean(),
var seed: String? = null
) {
data class HeaderBean(
var type: String = "none",
var domain: String? = null)
}
var writeBufferSize: Int = 1
)
data class WsSettingsBean(
var path: String? = null,
@@ -298,7 +231,16 @@ 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,
var happyEyeballs: HappyEyeballsBean? = null,
)
data class HappyEyeballsBean(
var prioritizeIPv6: Boolean? = null,
var maxConcurrentTry: Int? = 4,
var tryDelayMs: Int? = 250, // ms
var interleave: Int? = null,
)
data class TlsSettingsBean(
@@ -313,11 +255,15 @@ data class V2rayConfig(
val certificates: List<Any>? = null,
val disableSystemRoot: Boolean? = null,
val enableSessionResumption: Boolean? = null,
var echConfigList: String? = null,
var echForceQuery: String? = null,
var pinnedPeerCertSha256: String? = null,
// REALITY settings
val show: Boolean = false,
var publicKey: String? = null,
var shortId: String? = null,
var spiderX: String? = null
var spiderX: String? = null,
var mldsa65Verify: String? = null
)
data class QuicSettingBean(
@@ -336,149 +282,54 @@ data class V2rayConfig(
var health_check_timeout: Int? = null
)
data class Hy2steriaSettingsBean(
var password: String? = null,
var use_udp_extension: Boolean? = true,
var congestion: Hy2CongestionBean? = null
data class HysteriaSettingsBean(
var version: Int,
var auth: String? = null
)
//https://xtls.github.io/config/transport.html#finalmaskobject
data class FinalMaskBean(
var tcp: List<MaskBean>? = null,
var udp: List<MaskBean>? = null,
var quicParams: QuicParamsBean? = null
) {
data class Hy2CongestionBean(
var type: String? = "bbr",
var up_mbps: Int? = null,
var down_mbps: Int? = null,
)
}
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
}
if (host.isNullOrEmpty()) {
kcpsetting.header.domain = null
} else {
kcpsetting.header.domain = host
}
kcpSettings = kcpsetting
}
NetworkType.WS.type -> {
val wssetting = WsSettingsBean()
wssetting.headers.Host = host.orEmpty()
sni = host
wssetting.path = path ?: "/"
wsSettings = wssetting
}
NetworkType.HTTP_UPGRADE.type -> {
val httpupgradeSetting = HttpupgradeSettingsBean()
httpupgradeSetting.host = host.orEmpty()
sni = host
httpupgradeSetting.path = path ?: "/"
httpupgradeSettings = httpupgradeSetting
}
NetworkType.XHTTP.type -> {
val xhttpSetting = XhttpSettingsBean()
xhttpSetting.host = host.orEmpty()
sni = host
xhttpSetting.path = path ?: "/"
xhttpSettings = xhttpSetting
}
NetworkType.H2.type, NetworkType.HTTP.type -> {
network = NetworkType.H2.type
val h2Setting = HttpSettingsBean()
h2Setting.host = host.orEmpty().split(",").map { it.trim() }.filter { it.isNotEmpty() }
sni = h2Setting.host.getOrNull(0)
h2Setting.path = path ?: "/"
httpSettings = h2Setting
}
// "quic" -> {
// val quicsetting = QuicSettingBean()
// quicsetting.security = quicSecurity ?: "none"
// quicsetting.key = key.orEmpty()
// quicsetting.header.type = headerType ?: "none"
// quicSettings = quicsetting
// }
NetworkType.GRPC.type -> {
val grpcSetting = GrpcSettingsBean()
grpcSetting.multiMode = mode == "multi"
grpcSetting.serviceName = serviceName.orEmpty()
grpcSetting.authority = authority.orEmpty()
grpcSetting.idle_timeout = 60
grpcSetting.health_check_timeout = 20
sni = authority
grpcSettings = grpcSetting
data class MaskBean(
var type: String,
var settings: MaskSettingsBean? = null
) {
data class MaskSettingsBean(
val password: String? = null,
val domain: String? = null,
// fragment
val packets: String? = null,
val length: String? = null,
val delay: String? = null,
// val maxSplit: String? = null,
// noise
val reset: Int? = null,
val noise: List<NoiseMaskBean>? = null
) {
data class NoiseMaskBean(
val rand: String? = null,
// val randRange: String? = null,
// val type: String? = null,
// val packet: String? = null,
val delay: String? = null,
)
}
}
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 QuicParamsBean(
var congestion: String? = null,
var brutalUp: String? = null,
var brutalDown: String? = null,
var udpHop: UdpHopBean? = null,
) {
// Nested data class for the udpHop JSON object
data class UdpHopBean(
var ports: String? = null,
var interval: String? = null
)
}
}
}
@@ -499,11 +350,14 @@ data class V2rayConfig(
|| protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
) {
return settings?.servers?.first()?.address
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.peers?.first()?.endpoint?.substringBeforeLast(":")
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|| protocol.equals(EConfigType.HYSTERIA.name, true)
) {
return settings?.address as String?
}
return null
}
@@ -517,134 +371,28 @@ data class V2rayConfig(
|| protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
) {
return settings?.servers?.first()?.port
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.peers?.first()?.endpoint?.substringAfterLast(":")?.toInt()
} else if (protocol.equals(EConfigType.HYSTERIA2.name, true)
|| protocol.equals(EConfigType.HYSTERIA.name, true)
) {
return settings?.port
}
return null
}
fun getServerAddressAndPort(): String {
val address = getServerAddress().orEmpty()
val port = getServerPort()
return Utils.getIpv6Address(address) + ":" + port
}
fun getPassword(): String? {
if (protocol.equals(EConfigType.VMESS.name, true)
|| protocol.equals(EConfigType.VLESS.name, true)
) {
return settings?.vnext?.first()?.users?.first()?.id
} else if (protocol.equals(EConfigType.SHADOWSOCKS.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.HYSTERIA2.name, true)
) {
return settings?.servers?.first()?.password
} else if (protocol.equals(EConfigType.SOCKS.name, true)
|| protocol.equals(EConfigType.HTTP.name, true)
) {
return settings?.servers?.first()?.users?.first()?.pass
} else if (protocol.equals(EConfigType.WIREGUARD.name, true)) {
return settings?.secretKey
fun ensureSockopt(): StreamSettingsBean.SockoptBean {
val stream = streamSettings ?: StreamSettingsBean().also {
streamSettings = it
}
return null
}
fun getSecurityEncryption(): String? {
return when {
protocol.equals(EConfigType.VMESS.name, true) -> settings?.vnext?.first()?.users?.first()?.security
protocol.equals(EConfigType.VLESS.name, true) -> settings?.vnext?.first()?.users?.first()?.encryption
protocol.equals(EConfigType.SHADOWSOCKS.name, true) -> settings?.servers?.first()?.method
else -> null
val sockopt = stream.sockopt ?: StreamSettingsBean.SockoptBean().also {
stream.sockopt = it
}
}
fun getTransportSettingDetails(): List<String?>? {
if (protocol.equals(EConfigType.VMESS.name, true)
|| protocol.equals(EConfigType.VLESS.name, true)
|| protocol.equals(EConfigType.TROJAN.name, true)
|| protocol.equals(EConfigType.SHADOWSOCKS.name, true)
) {
val transport = streamSettings?.network ?: return null
return when (transport) {
NetworkType.TCP.type -> {
val tcpSetting = streamSettings?.tcpSettings ?: return null
listOf(
tcpSetting.header.type,
tcpSetting.header.request?.headers?.Host?.joinToString(",").orEmpty(),
tcpSetting.header.request?.path?.joinToString(",").orEmpty()
)
}
NetworkType.KCP.type -> {
val kcpSetting = streamSettings?.kcpSettings ?: return null
listOf(
kcpSetting.header.type,
"",
kcpSetting.seed.orEmpty()
)
}
NetworkType.WS.type -> {
val wsSetting = streamSettings?.wsSettings ?: return null
listOf(
"",
wsSetting.headers.Host,
wsSetting.path
)
}
NetworkType.HTTP_UPGRADE.type -> {
val httpupgradeSetting = streamSettings?.httpupgradeSettings ?: return null
listOf(
"",
httpupgradeSetting.host,
httpupgradeSetting.path
)
}
NetworkType.XHTTP.type -> {
val xhttpSettings = streamSettings?.xhttpSettings ?: return null
listOf(
"",
xhttpSettings.host,
xhttpSettings.path
)
}
NetworkType.H2.type -> {
val h2Setting = streamSettings?.httpSettings ?: return null
listOf(
"",
h2Setting.host.joinToString(","),
h2Setting.path
)
}
// "quic" -> {
// val quicSetting = streamSettings?.quicSettings ?: return null
// listOf(
// quicSetting.header.type,
// quicSetting.security,
// quicSetting.key
// )
// }
NetworkType.GRPC.type -> {
val grpcSetting = streamSettings?.grpcSettings ?: return null
listOf(
if (grpcSetting.multiMode == true) "multi" else "gun",
grpcSetting.authority.orEmpty(),
grpcSetting.serviceName
)
}
else -> null
}
}
return null
return sockopt
}
}
@@ -663,6 +411,7 @@ data class V2rayConfig(
var expectIPs: List<String>? = null,
val clientIp: String? = null,
val skipFallback: Boolean? = null,
val tag: String? = null,
)
}
@@ -670,14 +419,15 @@ data class V2rayConfig(
var domainStrategy: String,
var domainMatcher: String? = null,
var rules: ArrayList<RulesBean>,
val balancers: List<Any>? = null
var balancers: List<BalancerBean>? = null
) {
data class RulesBean(
var type: String = "field",
var ip: ArrayList<String>? = null,
var domain: ArrayList<String>? = null,
var outboundTag: String = "",
var ip: List<String>? = null,
var domain: List<String>? = null,
var process: List<String>? = null,
var outboundTag: String? = null,
var balancerTag: String? = null,
var port: String? = null,
val sourcePort: String? = null,
@@ -689,6 +439,32 @@ data class V2rayConfig(
val attrs: String? = null,
val domainMatcher: String? = null
)
data class BalancerBean(
val tag: String,
val selector: List<String>,
val fallbackTag: String? = null,
val strategy: StrategyObject? = null
)
data class StrategyObject(
val type: String = "random", // "random" | "roundRobin" | "leastPing" | "leastLoad"
val settings: StrategySettingsObject? = null
)
data class StrategySettingsObject(
val expected: Int? = null,
val maxRTT: String? = null,
val tolerance: Double? = null,
val baselines: List<String>? = null,
val costs: List<CostObject>? = null
)
data class CostObject(
val regexp: Boolean = false,
val match: String,
val value: Double
)
}
data class PolicyBean(
@@ -706,6 +482,26 @@ data class V2rayConfig(
)
}
data class ObservatoryObject(
val subjectSelector: List<String>,
val probeUrl: String,
val probeInterval: String,
val enableConcurrency: Boolean = false
)
data class BurstObservatoryObject(
val subjectSelector: List<String>,
val pingConfig: PingConfigObject
) {
data class PingConfigObject(
val destination: String,
val connectivity: String? = null,
val interval: String,
val sampling: Int,
val timeout: String? = null
)
}
data class FakednsBean(
var ipPool: String = "198.18.0.0/15",
var poolSize: Int = 10000
@@ -722,15 +518,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) }
}
}
}
}
@@ -15,5 +15,6 @@ data class VmessQRCode(
var tls: String = "",
var sni: String = "",
var alpn: String = "",
var fp: String = ""
var fp: String = "",
var insecure: String = ""
)
@@ -0,0 +1,9 @@
package com.v2ray.ang.dto
data class WebDavConfig(
val baseUrl: String,
val username: String? = null,
val password: String? = null,
val remoteBasePath: String = "/",
val timeoutSeconds: Long = 30
)
@@ -1,8 +1,7 @@
package com.v2ray.ang.dto
package com.v2ray.ang.enums
import com.v2ray.ang.AppConfig
enum class EConfigType(val value: Int, val protocolScheme: String) {
VMESS(1, AppConfig.VMESS),
CUSTOM(2, AppConfig.CUSTOM),
@@ -11,11 +10,14 @@ 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);
HYSTERIA(900, AppConfig.HYSTERIA),
HTTP(10, AppConfig.HTTP),
POLICYGROUP(101, AppConfig.CUSTOM);
companion object {
fun fromInt(value: Int) = entries.firstOrNull { it.value == value }
}
}
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.enums
enum class Language(val code: String) {
AUTO("auto"),
@@ -8,6 +8,7 @@ enum class Language(val code: String) {
VIETNAMESE("vi"),
RUSSIAN("ru"),
PERSIAN("fa"),
ARABIC("ar"),
BANGLA("bn"),
BAKHTIARI("bqi-rIR");
@@ -1,4 +1,4 @@
package com.v2ray.ang.dto
package com.v2ray.ang.enums
enum class NetworkType(val type: String) {
TCP("tcp"),
@@ -8,8 +8,10 @@ enum class NetworkType(val type: String) {
XHTTP("xhttp"),
HTTP("http"),
H2("h2"),
//QUIC("quic"),
GRPC("grpc");
GRPC("grpc"),
HYSTERIA("hysteria");
companion object {
fun fromString(type: String?) = entries.find { it.type == type } ?: TCP
@@ -0,0 +1,44 @@
package com.v2ray.ang.enums
import android.Manifest
import android.os.Build
import androidx.annotation.RequiresApi
/**
* Permission types used in the app, handling API level differences.
*/
enum class PermissionType {
/** Camera permission (used for scanning QR codes) */
CAMERA {
override fun getPermission(): String = Manifest.permission.CAMERA
},
/** Read storage / media permission (adapts to Android version) */
READ_STORAGE {
override fun getPermission(): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
}
},
/** Notification permission (Android 13+) */
POST_NOTIFICATIONS {
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
override fun getPermission(): String = Manifest.permission.POST_NOTIFICATIONS
};
/** Return the actual Android permission string */
abstract fun getPermission(): String
/** Return a human-readable label for the permission */
fun getLabel(): String {
return when (this) {
CAMERA -> "Camera"
READ_STORAGE -> "Storage"
POST_NOTIFICATIONS -> "Notification"
}
}
}
@@ -1,10 +1,11 @@
package com.v2ray.ang.dto
package com.v2ray.ang.enums
enum class RoutingType(val fileName: String) {
WHITE("custom_routing_white"),
BLACK("custom_routing_black"),
GLOBAL("custom_routing_global"),
WHITE_IRAN("custom_routing_white_iran");
WHITE_IRAN("custom_routing_white_iran"),
WHITE_RUSSIA("custom_routing_white_russia");
companion object {
fun fromIndex(index: Int): RoutingType {
@@ -13,6 +14,7 @@ enum class RoutingType(val fileName: String) {
1 -> BLACK
2 -> GLOBAL
3 -> WHITE_IRAN
4 -> WHITE_RUSSIA
else -> WHITE
}
}
@@ -0,0 +1,39 @@
package com.v2ray.ang.enums
/**
* VPN interface address configuration enum class
* Defines predefined IPv4 and IPv6 address pairs for VPN TUN interface configuration.
* Each option provides client and router addresses to establish point-to-point VPN tunnels.
*/
enum class VpnInterfaceAddressConfig(
val displayName: String,
val ipv4Client: String,
val ipv4Router: String,
val ipv6Client: String,
val ipv6Router: String
) {
OPTION_1("10.10.14.x", "10.10.14.1", "10.10.14.2", "fc00::10:10:14:1", "fc00::10:10:14:2"),
OPTION_2("10.1.0.x", "10.1.0.1", "10.1.0.2", "fc00::10:1:0:1", "fc00::10:1:0:2"),
OPTION_3("10.0.0.x", "10.0.0.1", "10.0.0.2", "fc00::10:0:0:1", "fc00::10:0:0:2"),
OPTION_4("172.31.0.x", "172.31.0.1", "172.31.0.2", "fc00::172:31:0:1", "fc00::172:31:0:2"),
OPTION_5("172.20.0.x", "172.20.0.1", "172.20.0.2", "fc00::172:20:0:1", "fc00::172:20:0:2"),
OPTION_6("172.16.0.x", "172.16.0.1", "172.16.0.2", "fc00::172:16:0:1", "fc00::172:16:0:2"),
OPTION_7("192.168.100.x", "192.168.100.1", "192.168.100.2", "fc00::192:168:100:1", "fc00::192:168:100:2");
companion object {
/**
* Retrieves the VPN interface address configuration based on the specified index.
*
* @param index The configuration index (0-based) corresponding to user selection
* @return The VpnInterfaceAddressConfig instance at the specified index,
* or OPTION_1 (default) if the index is out of bounds
*/
fun getConfigByIndex(index: Int): VpnInterfaceAddressConfig {
return if (index in entries.toTypedArray().indices) {
entries[index]
} else {
OPTION_1 // Default to the first configuration
}
}
}
}
@@ -8,11 +8,10 @@ import android.os.Build
import android.os.Bundle
import android.widget.Toast
import com.v2ray.ang.AngApplication
import me.drakeet.support.toast.ToastCompat
import org.json.JSONObject
import es.dmoral.toasty.Toasty
import java.io.Serializable
import java.net.URI
import java.net.URLConnection
import java.util.Locale
val Context.v2RayApplication: AngApplication?
get() = applicationContext as? AngApplication
@@ -23,7 +22,7 @@ val Context.v2RayApplication: AngApplication?
* @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()
}
/**
@@ -32,25 +31,43 @@ fun Context.toast(message: Int) {
* @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()
}
/**
* Puts a key-value pair into the JSONObject.
* Shows a toast message with the given resource ID.
*
* @param pair The key-value pair to put.
* @param message The resource ID of the message to show.
*/
fun JSONObject.putOpt(pair: Pair<String, Any?>) {
put(pair.first, pair.second)
fun Context.toastSuccess(message: Int) {
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
}
/**
* Puts multiple key-value pairs into the JSONObject.
* Shows a toast message with the given text.
*
* @param pairs The map of key-value pairs to put.
* @param message The text of the message to show.
*/
fun JSONObject.putOpt(pairs: Map<String, Any?>) {
pairs.forEach { put(it.key, it.value) }
fun Context.toastSuccess(message: CharSequence) {
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastError(message: Int) {
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
}
/**
* Shows a toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastError(message: CharSequence) {
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
}
const val THRESHOLD = 1000L
@@ -76,16 +93,9 @@ fun Long.toTrafficString(): String {
size /= DIVISOR
unitIndex++
}
return String.format("%.1f %s", size, units[unitIndex])
return String.format(Locale.getDefault(), "%.1f %s", size, units[unitIndex])
}
val URLConnection.responseLength: Long
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
contentLengthLong
} else {
contentLength.toLong()
}
val URI.idnHost: String
get() = host?.replace("[", "")?.replace("]", "").orEmpty()
@@ -96,6 +106,13 @@ val URI.idnHost: String
*/
fun String?.removeWhiteSpace(): String? = this?.replace(" ", "")
/**
* Returns null if the string is null or blank, otherwise returns the string itself.
*
* @return The string or null.
*/
fun String?.nullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
/**
* Converts the string to a Long value, or returns 0 if the conversion fails.
*
@@ -159,4 +176,28 @@ inline fun <reified T : Serializable> Intent.serializable(key: String): T? = whe
*
* @return True if the CharSequence is not null and not empty, false otherwise.
*/
fun CharSequence?.isNotNullEmpty(): Boolean = (this != null && this.isNotEmpty())
fun CharSequence?.isNotNullEmpty(): Boolean = !this.isNullOrBlank()
fun String.concatUrl(vararg paths: String): String {
val builder = StringBuilder(this.trimEnd('/'))
paths.forEach { path ->
val trimmedPath = path.trim('/')
if (trimmedPath.isNotEmpty()) {
builder.append('/').append(trimmedPath)
}
}
return builder.toString()
}
/**
* Helper function to match text either by Regex or literal string.
*/
fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean = true): Boolean {
if (keyword.isNullOrEmpty()) {
return true
}
return regex?.containsMatchIn(this)
?: this.contains(keyword, ignoreCase = ignoreCase)
}
@@ -1,8 +1,8 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.util.JsonUtil
object CustomFmt : FmtBase() {
@@ -12,11 +12,11 @@ object CustomFmt : FmtBase() {
* @param str the JSON string to parse
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parse(str: String): ProfileItem? {
fun parse(str: String): ProfileItem {
val config = ProfileItem.create(EConfigType.CUSTOM)
val fullConfig = JsonUtil.fromJson(str, V2rayConfig::class.java)
val outbound = fullConfig.getProxyOutbound()
val outbound = fullConfig?.getProxyOutbound()
config.remarks = fullConfig?.remarks ?: System.currentTimeMillis().toString()
config.server = outbound?.getServerAddress()
@@ -1,9 +1,11 @@
package com.v2ray.ang.fmt
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.enums.NetworkType
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -18,19 +20,19 @@ open class FmtBase {
*/
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.encodeURIComponent(it.second) })
else ""
val url = String.format(
"%s@%s:%s",
Utils.urlEncode(userInfo ?: ""),
Utils.getIpv6Address(config.server),
Utils.encodeURIComponent(userInfo ?: ""),
Utils.getIpv6Address(HttpUtil.toIdnDomain(config.server.orEmpty())),
config.serverPort
)
return "${url}${query}#${Utils.urlEncode(config.remarks)}"
return "${url}${query}#${Utils.encodeURIComponent(config.remarks)}"
}
/**
@@ -41,7 +43,7 @@ open class FmtBase {
*/
fun getQueryParam(uri: URI): Map<String, String> {
return uri.rawQuery.split("&")
.associate { it.split("=").let { (k, v) -> k to Utils.urlDecode(v) } }
.associate { it.split("=").let { (k, v) -> k to Utils.decodeURIComponent(v) } }
}
/**
@@ -65,22 +67,28 @@ open class FmtBase {
config.authority = queryParam["authority"]
config.xhttpMode = queryParam["mode"]
config.xhttpExtra = queryParam["extra"]
config.finalMask = queryParam["fm"]
config.security = queryParam["security"]
if (config.security != AppConfig.TLS && config.security != AppConfig.REALITY) {
config.security = null
}
config.insecure = if (queryParam["allowInsecure"].isNullOrEmpty()) {
allowInsecure
} else {
queryParam["allowInsecure"].orEmpty() == "1"
// Support multiple possible query keys for allowInsecure like the C# implementation
val allowInsecureKeys = arrayOf("insecure", "allowInsecure", "allow_insecure")
config.insecure = when {
allowInsecureKeys.any { queryParam[it] == "1" } -> true
allowInsecureKeys.any { queryParam[it] == "0" } -> false
else -> allowInsecure
}
config.sni = queryParam["sni"]
config.fingerPrint = queryParam["fp"]
config.alpn = queryParam["alpn"]
config.echConfigList = queryParam["ech"]
config.pinnedCA256 = queryParam["pcs"]
config.publicKey = queryParam["pbk"]
config.shortId = queryParam["sid"]
config.spiderX = queryParam["spx"]
config.mldsa65Verify = queryParam["pqv"]
config.flow = queryParam["flow"]
}
@@ -93,13 +101,23 @@ open class FmtBase {
fun getQueryDic(config: ProfileItem): HashMap<String, String> {
val dicQuery = HashMap<String, String>()
dicQuery["security"] = config.security?.ifEmpty { "none" }.orEmpty()
config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() }
config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() }
config.fingerPrint.let { if (it.isNotNullEmpty()) dicQuery["fp"] = it.orEmpty() }
config.publicKey.let { if (it.isNotNullEmpty()) dicQuery["pbk"] = it.orEmpty() }
config.shortId.let { if (it.isNotNullEmpty()) dicQuery["sid"] = it.orEmpty() }
config.spiderX.let { if (it.isNotNullEmpty()) dicQuery["spx"] = it.orEmpty() }
config.flow.let { if (it.isNotNullEmpty()) dicQuery["flow"] = it.orEmpty() }
config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
config.echConfigList?.nullIfBlank()?.let { dicQuery["ech"] = it }
config.pinnedCA256?.nullIfBlank()?.let { dicQuery["pcs"] = it }
config.fingerPrint?.nullIfBlank()?.let { dicQuery["fp"] = it }
config.publicKey?.nullIfBlank()?.let { dicQuery["pbk"] = it }
config.shortId?.nullIfBlank()?.let { dicQuery["sid"] = it }
config.spiderX?.nullIfBlank()?.let { dicQuery["spx"] = it }
config.mldsa65Verify?.nullIfBlank()?.let { dicQuery["pqv"] = it }
config.flow?.nullIfBlank()?.let { dicQuery["flow"] = it }
config.finalMask?.nullIfBlank()?.let { dicQuery["fm"] = it }
// Add two keys for compatibility: "insecure" and "allowInsecure"
if (config.security == AppConfig.TLS) {
val insecureFlag = if (config.insecure == true) "1" else "0"
dicQuery["insecure"] = insecureFlag
dicQuery["allowInsecure"] = insecureFlag
}
val networkType = NetworkType.fromString(config.network)
dicQuery["type"] = networkType.type
@@ -107,45 +125,64 @@ open class FmtBase {
when (networkType) {
NetworkType.TCP -> {
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
}
NetworkType.KCP -> {
dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
config.seed.let { if (it.isNotNullEmpty()) dicQuery["seed"] = it.orEmpty() }
config.seed?.nullIfBlank()?.let { dicQuery["seed"] = it }
}
NetworkType.WS, NetworkType.HTTP_UPGRADE -> {
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
}
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() }
config.xhttpExtra.let { if (it.isNotNullEmpty()) dicQuery["extra"] = it.orEmpty() }
NetworkType.XHTTP -> {
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
config.xhttpMode?.nullIfBlank()?.let { dicQuery["mode"] = it }
config.xhttpExtra?.nullIfBlank()?.let { dicQuery["extra"] = it }
}
NetworkType.HTTP, NetworkType.H2 -> {
dicQuery["type"] = "http"
config.host.let { if (it.isNotNullEmpty()) dicQuery["host"] = it.orEmpty() }
config.path.let { if (it.isNotNullEmpty()) dicQuery["path"] = it.orEmpty() }
config.host?.nullIfBlank()?.let { dicQuery["host"] = it }
config.path?.nullIfBlank()?.let { dicQuery["path"] = it }
}
// NetworkType.QUIC -> {
// dicQuery["headerType"] = config.headerType?.ifEmpty { "none" }.orEmpty()
// config.quicSecurity.let { if (it.isNotNullEmpty()) dicQuery["quicSecurity"] = it.orEmpty() }
// config.quicKey.let { if (it.isNotNullEmpty()) dicQuery["key"] = it.orEmpty() }
// config.quicSecurity?.nullIfBlank()?.let { dicQuery["quicSecurity"] = it }
// config.quicKey?.nullIfBlank()?.let { dicQuery["key"] = it }
// }
NetworkType.GRPC -> {
config.mode.let { if (it.isNotNullEmpty()) dicQuery["mode"] = it.orEmpty() }
config.authority.let { if (it.isNotNullEmpty()) dicQuery["authority"] = it.orEmpty() }
config.serviceName.let { if (it.isNotNullEmpty()) dicQuery["serviceName"] = it.orEmpty() }
config.mode?.nullIfBlank()?.let { dicQuery["mode"] = it }
config.authority?.nullIfBlank()?.let { dicQuery["authority"] = it }
config.serviceName?.nullIfBlank()?.let { dicQuery["serviceName"] = it }
}
else -> {}
}
return dicQuery
}
}
fun getServerAddress(profileItem: ProfileItem): String {
if (Utils.isPureIpAddress(profileItem.server.orEmpty())) {
return profileItem.server.orEmpty()
}
val domain = HttpUtil.toIdnDomain(profileItem.server.orEmpty())
if (MmkvManager.decodeSettingsString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "1") != "2") {
return domain
}
//Resolve and replace domain
val resolvedIps = HttpUtil.resolveHostToIP(domain, MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
if (resolvedIps.isNullOrEmpty()) {
return domain
}
return resolvedIps.first()
}
}
@@ -1,9 +1,10 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.handler.V2rayConfigManager
object HttpFmt : FmtBase() {
/**
@@ -13,10 +14,10 @@ object HttpFmt : FmtBase() {
* @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()
@@ -1,14 +1,15 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.Hysteria2Bean
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -19,32 +20,30 @@ object Hysteria2Fmt : FmtBase() {
* @param str the Hysteria2 URI string to parse
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parse(str: String): ProfileItem? {
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.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost
config.serverPort = uri.port.toString()
config.password = uri.userInfo
config.security = AppConfig.TLS
config.network = NetworkType.HYSTERIA.type
if (!uri.rawQuery.isNullOrEmpty()) {
val queryParam = getQueryParam(uri)
config.security = queryParam["security"] ?: AppConfig.TLS
config.insecure = if (queryParam["insecure"].isNullOrEmpty()) {
allowInsecure
} else {
queryParam["insecure"].orEmpty() == "1"
}
config.sni = queryParam["sni"]
config.alpn = queryParam["alpn"]
getItemFormQuery(config, queryParam, allowInsecure)
config.security = queryParam["security"] ?: AppConfig.TLS
config.obfsPassword = queryParam["obfs-password"]
config.portHopping = queryParam["mport"]
config.pinSHA256 = queryParam["pinSHA256"]
if (config.portHopping.isNotNullEmpty()) {
config.portHoppingInterval = queryParam["mportHopInt"]
}
config.pinnedCA256 = queryParam["pinSHA256"]
}
@@ -61,8 +60,8 @@ object Hysteria2Fmt : FmtBase() {
val dicQuery = HashMap<String, String>()
config.security.let { if (it != null) dicQuery["security"] = it }
config.sni.let { if (it.isNotNullEmpty()) dicQuery["sni"] = it.orEmpty() }
config.alpn.let { if (it.isNotNullEmpty()) dicQuery["alpn"] = it.orEmpty() }
config.sni?.nullIfBlank()?.let { dicQuery["sni"] = it }
config.alpn?.nullIfBlank()?.let { dicQuery["alpn"] = it }
config.insecure.let { dicQuery["insecure"] = if (it == true) "1" else "0" }
if (config.obfsPassword.isNotNullEmpty()) {
@@ -72,71 +71,46 @@ object Hysteria2Fmt : FmtBase() {
if (config.portHopping.isNotNullEmpty()) {
dicQuery["mport"] = config.portHopping.orEmpty()
}
if (config.pinSHA256.isNotNullEmpty()) {
dicQuery["pinSHA256"] = config.pinSHA256.orEmpty()
if (config.portHoppingInterval.isNotNullEmpty()) {
val rawInterval = config.portHoppingInterval?.trim().nullIfBlank()
val interval = if (rawInterval == null) {
null
} else {
val singleValue = rawInterval.toIntOrNull()
if (singleValue != null) {
if (singleValue < 5) {
null
} else {
rawInterval
}
} else {
val parts = rawInterval.split('-')
if (parts.size == 2) {
val start = parts[0].trim().toIntOrNull()
val end = parts[1].trim().toIntOrNull()
if (start != null && end != null) {
val minStart = maxOf(5, start)
val minEnd = maxOf(minStart, end)
(minStart + minEnd) / 2
} else {
null
}
} else {
null
}
}
}
if (interval != null) {
dicQuery["mportHopInt"] = interval.toString()
}
}
if (config.pinnedCA256.isNotNullEmpty()) {
dicQuery["pinSHA256"] = config.pinnedCA256.orEmpty()
}
return toUri(config, config.password, dicQuery)
}
/**
* Converts a ProfileItem object to a Hysteria2Bean object.
*
* @param config the ProfileItem object to convert
* @param socksPort the port number for the socks5 proxy
* @return the converted Hysteria2Bean object, or null if conversion fails
*/
fun toNativeConfig(config: ProfileItem, socksPort: Int): Hysteria2Bean? {
val obfs = if (config.obfsPassword.isNullOrEmpty()) null else
Hysteria2Bean.ObfsBean(
type = "salamander",
salamander = Hysteria2Bean.ObfsBean.SalamanderBean(
password = config.obfsPassword
)
)
val transport = if (config.portHopping.isNullOrEmpty()) null else
Hysteria2Bean.TransportBean(
type = "udp",
udp = Hysteria2Bean.TransportBean.TransportUdpBean(
hopInterval = (config.portHoppingInterval ?: "30") + "s"
)
)
val bandwidth = if (config.bandwidthDown.isNullOrEmpty() || config.bandwidthUp.isNullOrEmpty()) null else
Hysteria2Bean.BandwidthBean(
down = config.bandwidthDown,
up = config.bandwidthUp,
)
val server =
if (config.portHopping.isNullOrEmpty())
config.getServerAddressAndPort()
else
Utils.getIpv6Address(config.server) + ":" + config.portHopping
val bean = Hysteria2Bean(
server = server,
auth = config.password,
obfs = obfs,
transport = transport,
bandwidth = bandwidth,
socks5 = Hysteria2Bean.Socks5Bean(
listen = "$LOOPBACK:${socksPort}",
),
http = Hysteria2Bean.Socks5Bean(
listen = "$LOOPBACK:${socksPort}",
),
tls = Hysteria2Bean.TlsBean(
sni = config.sni ?: config.server,
insecure = config.insecure,
pinSHA256 = if (config.pinSHA256.isNullOrEmpty()) null else config.pinSHA256
)
)
return bean
}
/**
* Converts a ProfileItem object to an OutboundBean object.
*
@@ -144,7 +118,24 @@ object Hysteria2Fmt : FmtBase() {
* @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 null
profileItem.network = NetworkType.HYSTERIA.type
profileItem.alpn = "h3"
outboundBean.settings?.let { server ->
server.address = getServerAddress(profileItem)
server.port = profileItem.serverPort.orEmpty().toInt()
server.version = 2
}
val sni = outboundBean.streamSettings?.let {
V2rayConfigManager.populateTransportSettings(it, profileItem)
}
outboundBean.streamSettings?.let {
V2rayConfigManager.populateTlsSettings(it, profileItem, sni)
}
return outboundBean
}
}
@@ -1,10 +1,13 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.NetworkType
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -33,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.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost
config.serverPort = uri.port.toString()
@@ -80,9 +83,9 @@ object ShadowsocksFmt : FmtBase() {
if (indexSplit > 0) {
try {
config.remarks =
Utils.urlDecode(result.substring(indexSplit + 1, result.length))
Utils.decodeURIComponent(result.substring(indexSplit + 1, result.length))
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(AppConfig.TAG, "Failed to decode remarks in SS legacy URL", e)
}
result = result.substring(0, indexSplit)
@@ -119,7 +122,7 @@ object ShadowsocksFmt : FmtBase() {
fun toUri(config: ProfileItem): String {
val pw = "${config.method}:${config.password}"
return toUri(config, Utils.encode(pw), null)
return toUri(config, Utils.encode(pw, true), null)
}
/**
@@ -129,38 +132,22 @@ object ShadowsocksFmt : FmtBase() {
* @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
}
@@ -1,10 +1,11 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
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
@@ -22,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.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost
config.serverPort = uri.port.toString()
@@ -50,7 +51,7 @@ object SocksFmt : FmtBase() {
else
":"
return toUri(config, Utils.encode(pw), null)
return toUri(config, Utils.encode(pw, true), null)
}
/**
@@ -60,10 +61,10 @@ object SocksFmt : FmtBase() {
* @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()
@@ -1,12 +1,13 @@
package com.v2ray.ang.fmt
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.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
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
@@ -17,12 +18,12 @@ object TrojanFmt : FmtBase() {
* @param str the Trojan URI string to parse
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parse(str: String): ProfileItem? {
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.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost
config.serverPort = uri.port.toString()
config.password = uri.userInfo
@@ -60,38 +61,22 @@ object TrojanFmt : FmtBase() {
* @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
}
@@ -1,12 +1,12 @@
package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
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
@@ -26,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.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost
config.serverPort = uri.port.toString()
config.password = uri.userInfo
@@ -57,41 +57,23 @@ object VlessFmt : FmtBase() {
* @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
}
@@ -1,17 +1,18 @@
package com.v2ray.ang.fmt
import android.text.TextUtils
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.dto.VmessQRCode
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.NetworkType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -27,23 +28,23 @@ object VmessFmt : FmtBase() {
return parseVmessStd(str)
}
var allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val allowInsecure = MmkvManager.decodeSettingsBool(AppConfig.PREF_ALLOW_INSECURE, false)
val config = ProfileItem.create(EConfigType.VMESS)
var result = str.replace(EConfigType.VMESS.protocolScheme, "")
result = Utils.decode(result)
if (TextUtils.isEmpty(result)) {
Log.d(AppConfig.ANG_PACKAGE, "R.string.toast_decoding_failed")
LogUtil.w(AppConfig.TAG, "Toast decoding failed")
return null
}
val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java)
val vmessQRCode = JsonUtil.fromJson(result, VmessQRCode::class.java) ?: return null
// Although VmessQRCode fields are non null, looks like Gson may still create null fields
if (TextUtils.isEmpty(vmessQRCode.add)
|| TextUtils.isEmpty(vmessQRCode.port)
|| TextUtils.isEmpty(vmessQRCode.id)
|| TextUtils.isEmpty(vmessQRCode.net)
) {
Log.d(AppConfig.ANG_PACKAGE, "R.string.toast_incorrect_protocol")
LogUtil.w(AppConfig.TAG, "Toast incorrect protocol")
return null
}
@@ -51,9 +52,13 @@ object VmessFmt : FmtBase() {
config.server = vmessQRCode.add
config.serverPort = vmessQRCode.port
config.password = vmessQRCode.id
config.method = if (TextUtils.isEmpty(vmessQRCode.scy)) AppConfig.DEFAULT_SECURITY else vmessQRCode.scy
config.method =
if (TextUtils.isEmpty(vmessQRCode.scy)) AppConfig.DEFAULT_SECURITY else vmessQRCode.scy
config.network = vmessQRCode.net ?: NetworkType.TCP.type
config.network = vmessQRCode.net
if (config.network.isNullOrEmpty()) {
config.network = NetworkType.TCP.type
}
config.headerType = vmessQRCode.type
config.host = vmessQRCode.host
config.path = vmessQRCode.path
@@ -73,15 +78,19 @@ object VmessFmt : FmtBase() {
config.serviceName = vmessQRCode.path
config.authority = vmessQRCode.host
}
else -> {}
}
config.security = vmessQRCode.tls
config.insecure = allowInsecure
config.sni = vmessQRCode.sni
config.fingerPrint = vmessQRCode.fp
config.alpn = vmessQRCode.alpn
config.insecure = when (vmessQRCode.insecure) {
"1" -> true
"0" -> false
else -> allowInsecure
}
return config
}
@@ -119,16 +128,22 @@ object VmessFmt : FmtBase() {
vmessQRCode.path = config.serviceName.orEmpty()
vmessQRCode.host = config.authority.orEmpty()
}
else -> {}
}
config.host.let { if (it.isNotNullEmpty()) vmessQRCode.host = it.orEmpty() }
config.path.let { if (it.isNotNullEmpty()) vmessQRCode.path = it.orEmpty() }
config.host?.nullIfBlank()?.let { vmessQRCode.host = it }
config.path?.nullIfBlank()?.let { vmessQRCode.path = it }
vmessQRCode.tls = config.security.orEmpty()
vmessQRCode.sni = config.sni.orEmpty()
vmessQRCode.fp = config.fingerPrint.orEmpty()
vmessQRCode.alpn = config.alpn.orEmpty()
vmessQRCode.insecure = when (config.insecure) {
true -> "1"
false -> "0"
else -> ""
}
val json = JsonUtil.toJson(vmessQRCode)
return Utils.encode(json)
@@ -148,7 +163,7 @@ object VmessFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost
config.serverPort = uri.port.toString()
config.password = uri.userInfo
@@ -166,38 +181,22 @@ object VmessFmt : FmtBase() {
* @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
}
@@ -2,11 +2,13 @@ package com.v2ray.ang.fmt
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.V2rayConfig.OutboundBean
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.idnHost
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.removeWhiteSpace
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -24,16 +26,16 @@ object WireguardFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty())
config.remarks = Utils.decodeURIComponent(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost
config.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"]?.nullIfBlank()
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
}
@@ -44,7 +46,7 @@ object WireguardFmt : FmtBase() {
* @param str the Wireguard configuration file string to parse
* @return the parsed ProfileItem object, or null if parsing fails
*/
fun parseWireguardConfFile(str: String): ProfileItem? {
fun parseWireguardConfFile(str: String): ProfileItem {
val config = ProfileItem.create(EConfigType.WIREGUARD)
val interfaceParams: MutableMap<String, String> = mutableMapOf()
@@ -83,7 +85,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"]?.nullIfBlank()
val endpoint = peerParams["endpoint"].orEmpty()
val endpointParts = endpoint.split(":", limit = 2)
if (endpointParts.size == 2) {
@@ -105,18 +107,18 @@ object WireguardFmt : FmtBase() {
* @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?.nullIfBlank()
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
@@ -3,11 +3,15 @@ package com.v2ray.ang.handler
import android.content.Context
import android.graphics.Bitmap
import android.text.TextUtils
import android.util.Log
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.HY2
import com.v2ray.ang.R
import com.v2ray.ang.dto.*
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.SubscriptionUpdateResult
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.fmt.CustomFmt
import com.v2ray.ang.fmt.Hysteria2Fmt
import com.v2ray.ang.fmt.ShadowsocksFmt
@@ -18,6 +22,7 @@ import com.v2ray.ang.fmt.VmessFmt
import com.v2ray.ang.fmt.WireguardFmt
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils
import java.net.URI
@@ -42,7 +47,7 @@ object AngConfigManager {
Utils.setClipboard(context, conf)
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(AppConfig.TAG, "Failed to share config to clipboard", e)
return -1
}
return 0
@@ -69,9 +74,9 @@ object AngConfigManager {
if (sb.count() > 0) {
Utils.setClipboard(context, sb.toString())
}
return sb.lines().count()
return sb.lines().count() - 1
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(AppConfig.TAG, "Failed to share non-custom configs to clipboard", e)
return -1
}
}
@@ -91,7 +96,7 @@ object AngConfigManager {
return QRCodeDecoder.createQRCode(conf)
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(AppConfig.TAG, "Failed to share config as QR code", e)
return null
}
}
@@ -108,19 +113,12 @@ object AngConfigManager {
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()
LogUtil.e(AppConfig.TAG, "Failed to share full content to clipboard", e)
return -1
}
return 0
@@ -146,9 +144,11 @@ object AngConfigManager {
EConfigType.TROJAN -> TrojanFmt.toUri(config)
EConfigType.WIREGUARD -> WireguardFmt.toUri(config)
EConfigType.HYSTERIA2 -> Hysteria2Fmt.toUri(config)
EConfigType.POLICYGROUP -> ""
else -> {}
}
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(AppConfig.TAG, "Failed to share config for GUID: $guid", e)
return ""
}
}
@@ -167,7 +167,7 @@ object AngConfigManager {
count = parseBatchConfig(server, subid, append)
}
if (count <= 0) {
count = parseCustomConfigServer(server, subid)
count = parseCustomConfigServer(server, subid, append)
}
var countSub = parseBatchSubscription(server)
@@ -203,7 +203,7 @@ object AngConfigManager {
}
return count
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(AppConfig.TAG, "Failed to parse batch subscription", e)
}
return 0
}
@@ -221,49 +221,146 @@ object AngConfigManager {
if (servers == null) {
return 0
}
val removedSelectedServer =
if (!TextUtils.isEmpty(subid) && !append) {
MmkvManager.decodeServerConfig(
MmkvManager.getSelectServer().orEmpty()
)?.let {
if (it.subscriptionId == subid) {
return@let it
}
return@let null
}
} else {
null
}
if (!append) {
MmkvManager.removeServerViaSubid(subid)
// Find the currently selected server that matches the subscription ID
val removedSelected = if (subid.isNotBlank() && !append) {
MmkvManager.getSelectServer()
.takeIf { it?.isNotBlank() == true }
?.let { MmkvManager.decodeServerConfig(it) }
?.takeIf { it.subscriptionId == subid }
} else {
null
}
val subItem = MmkvManager.decodeSubscription(subid)
var count = 0
// Parse all configs first (no I/O during parsing)
val configs = mutableListOf<ProfileItem>()
servers.lines()
.distinct()
.reversed()
.forEach {
val resId = parseConfig(it, subid, subItem, removedSelectedServer)
if (resId == 0) {
count++
val config = parseConfig(it, subid, subItem)
if (config != null) {
configs.add(config)
}
}
return count
// Batch save all parsed configs (only one serverList read/write)
if (configs.isNotEmpty()) {
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val keyToProfile = batchSaveConfigs(configs, subid)
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
matchKey?.let { MmkvManager.setSelectServer(it) }
}
return configs.size
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(AppConfig.TAG, "Failed to parse batch config", e)
}
return 0
}
/**
* Batch save configurations to reduce serverList read/write operations.
* Reads serverList once, saves all configs, then writes serverList once.
*
* @param configs The list of ProfileItem to save.
* @param subid The subscription ID.
* @return Map of generated keys to their corresponding ProfileItem.
*/
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): Map<String, ProfileItem> {
val keyToProfile = mutableMapOf<String, ProfileItem>()
// Read serverList once
val serverList = MmkvManager.decodeServerList(subid)
var needSetSelected = MmkvManager.getSelectServer().isNullOrBlank()
configs.forEach { config ->
val key = Utils.getUuid()
// Save profile directly without updating serverList
MmkvManager.encodeProfileDirect(key, JsonUtil.toJson(config))
if (!serverList.contains(key)) {
serverList.add(0, key)
if (needSetSelected) {
MmkvManager.setSelectServer(key)
needSetSelected = false
}
}
keyToProfile[key] = config
}
// Write serverList once
MmkvManager.encodeServerList(serverList, subid)
return keyToProfile
}
/**
* Finds a matched profile key from the given key-profile map using multi-level matching.
* Matching priority (from highest to lowest):
* 1. Exact match: server + port + password
* 2. Match by remarks (exact match)
* 3. Match by server + port
* 4. Match by server only
*
* @param keyToProfile Map of server keys to their ProfileItem
* @param target Target profile to match
* @return Matched key or null
*/
private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? {
if (keyToProfile.isEmpty() || target == null) return null
// Level 1: Match by remarks
if (target.remarks.isNotBlank()) {
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.remarks, target.remarks)
}?.key?.let { return it }
}
// Level 2: Exact match (server + port + password)
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server) &&
isSameText(saved.serverPort, target.serverPort) &&
isSameText(saved.password, target.password)
}?.key?.let { return it }
// Level 3: Match by server + port
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server) &&
isSameText(saved.serverPort, target.serverPort)
}?.key?.let { return it }
// Level 4: Match by server only
keyToProfile.entries.firstOrNull { (_, saved) ->
isSameText(saved.server, target.server)
}?.key?.let { return it }
return null
}
/**
* Case-insensitive trimmed string comparison.
*
* @param left First string
* @param right Second string
* @return True if both are non-empty and equal (case-insensitive, trimmed)
*/
private fun isSameText(left: String?, right: String?): Boolean {
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
return left.trim().equals(right.trim(), ignoreCase = true)
}
/**
* Parses a custom configuration server.
*
* @param server The server string.
* @param subid The subscription ID.
* @param append Whether to append the configurations.
* @return The number of configurations parsed.
*/
private fun parseCustomConfigServer(server: String?, subid: String): Int {
private fun parseCustomConfigServer(server: String?, subid: String, append: Boolean): Int {
if (server == null) {
return 0
}
@@ -273,13 +370,17 @@ object AngConfigManager {
) {
try {
val serverList: Array<Any> =
JsonUtil.fromJson(server, Array<Any>::class.java)
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
if (serverList.isNotEmpty()) {
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
var count = 0
for (srv in serverList.reversed()) {
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
config.subscriptionId = subid
config.description = generateDescription(config)
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
count += 1
@@ -287,28 +388,36 @@ object AngConfigManager {
return count
}
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
}
try {
// For compatibility
val config = CustomFmt.parse(server) ?: return 0
config.subscriptionId = subid
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
return 1
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
}
return 0
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
try {
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
return 1
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
}
return 0
} else {
@@ -318,22 +427,21 @@ object AngConfigManager {
/**
* Parses the configuration from a QR code or string.
* Only parses and returns ProfileItem, does not save.
*
* @param str The configuration string.
* @param subid The subscription ID.
* @param subItem The subscription item.
* @param removedSelectedServer The removed selected server.
* @return The result code.
* @return The parsed ProfileItem or null if parsing fails or filtered out.
*/
private fun parseConfig(
str: String?,
subid: String,
subItem: SubscriptionItem?,
removedSelectedServer: ProfileItem?
): Int {
subItem: SubscriptionItem?
): ProfileItem? {
try {
if (str == null || TextUtils.isEmpty(str)) {
return R.string.toast_none_data
return null
}
val config = if (str.startsWith(EConfigType.VMESS.protocolScheme)) {
@@ -355,93 +463,113 @@ object AngConfigManager {
}
if (config == null) {
return R.string.toast_incorrect_protocol
return null
}
//filter
if (subItem?.filter != null && subItem.filter?.isNotEmpty() == true && config.remarks.isNotEmpty()) {
val matched = Regex(pattern = subItem.filter ?: "")
// Apply filter
if (subItem?.filter.isNotNullEmpty() && config.remarks.isNotNullEmpty()) {
val matched = Regex(pattern = subItem?.filter.orEmpty())
.containsMatchIn(input = config.remarks)
if (!matched) return -1
if (!matched) return null
}
config.subscriptionId = subid
val guid = MmkvManager.encodeServerConfig("", config)
if (removedSelectedServer != null &&
config.server == removedSelectedServer.server && config.serverPort == removedSelectedServer.serverPort
) {
MmkvManager.setSelectServer(guid)
}
config.description = generateDescription(config)
return config
} catch (e: Exception) {
e.printStackTrace()
return -1
LogUtil.e(AppConfig.TAG, "Failed to parse config", e)
return null
}
return 0
}
/**
* Updates the configuration via all subscriptions.
*
* @return The number of configurations updated.
* @return Detailed result of the subscription update operation.
*/
fun updateConfigViaSubAll(): Int {
var count = 0
try {
MmkvManager.decodeSubscriptions().forEach {
count += updateConfigViaSub(it)
fun updateConfigViaSubAll(): SubscriptionUpdateResult {
return try {
val subscriptions = MmkvManager.decodeSubscriptions()
subscriptions.fold(SubscriptionUpdateResult()) { acc, subscription ->
acc + updateConfigViaSub(subscription)
}
} catch (e: Exception) {
e.printStackTrace()
return 0
LogUtil.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
SubscriptionUpdateResult()
}
return count
}
/**
* Updates the configuration via a subscription.
*
* @param it The subscription item.
* @return The number of configurations updated.
* @return Subscription update result.
*/
fun updateConfigViaSub(it: Pair<String, SubscriptionItem>): Int {
fun updateConfigViaSub(it: SubscriptionCache): SubscriptionUpdateResult {
try {
if (TextUtils.isEmpty(it.first)
|| TextUtils.isEmpty(it.second.remarks)
|| TextUtils.isEmpty(it.second.url)
// Check if disabled
if (!it.subscription.enabled) {
return SubscriptionUpdateResult(skipCount = 1)
}
// Validate subscription info
if (TextUtils.isEmpty(it.guid)
|| TextUtils.isEmpty(it.subscription.remarks)
|| TextUtils.isEmpty(it.subscription.url)
) {
return 0
return SubscriptionUpdateResult(skipCount = 1)
}
if (!it.second.enabled) {
return 0
}
val url = HttpUtil.idnToASCII(it.second.url)
val url = HttpUtil.toIdnUrl(it.subscription.url)
if (!Utils.isValidUrl(url)) {
return 0
return SubscriptionUpdateResult(failureCount = 1)
}
Log.d(AppConfig.ANG_PACKAGE, url)
if (!it.subscription.allowInsecureUrl) {
if (!Utils.isValidSubUrl(url)) {
return SubscriptionUpdateResult(failureCount = 1)
}
}
LogUtil.i(AppConfig.TAG, url)
val userAgent = it.subscription.userAgent
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
var configText = try {
val httpPort = SettingsManager.getHttpPort()
HttpUtil.getUrlContentWithUserAgent(url, 15000, httpPort)
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort, proxyUsername, proxyPassword)
} catch (e: Exception) {
Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error, try……")
//e.printStackTrace()
LogUtil.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
""
}
if (configText.isEmpty()) {
configText = try {
HttpUtil.getUrlContentWithUserAgent(url)
HttpUtil.getUrlContentWithUserAgent(url, userAgent)
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
""
}
}
if (configText.isEmpty()) {
return 0
return SubscriptionUpdateResult(failureCount = 1)
}
val count = parseConfigViaSub(configText, it.guid, false)
if (count > 0) {
it.subscription.lastUpdated = System.currentTimeMillis()
MmkvManager.encodeSubscription(it.guid, it.subscription)
LogUtil.i(AppConfig.TAG, "Subscription updated: ${it.subscription.remarks}, $count configs")
return SubscriptionUpdateResult(
configCount = count,
successCount = 1
)
} else {
// Got response but no valid configs parsed
return SubscriptionUpdateResult(failureCount = 1)
}
return parseConfigViaSub(configText, it.first, false)
} catch (e: Exception) {
e.printStackTrace()
return 0
LogUtil.e(AppConfig.TAG, "Failed to update config via subscription", e)
return SubscriptionUpdateResult(failureCount = 1)
}
}
@@ -459,7 +587,7 @@ object AngConfigManager {
count = parseBatchConfig(server, subid, append)
}
if (count <= 0) {
count = parseCustomConfigServer(server, subid)
count = parseCustomConfigServer(server, subid, append)
}
return count
}
@@ -473,7 +601,7 @@ object AngConfigManager {
private fun importUrlAsSubscription(url: String): Int {
val subscriptions = MmkvManager.decodeSubscriptions()
subscriptions.forEach {
if (it.second.url == url) {
if (it.subscription.url == url) {
return 0
}
}
@@ -484,4 +612,25 @@ object AngConfigManager {
MmkvManager.encodeSubscription("", subItem)
return 1
}
/** Generates a description for the profile.
*
* @param profile The profile item.
* @return The generated description.
*/
fun generateDescription(profile: ProfileItem): String {
// Hide xxx:xxx:***/xxx.xxx.xxx.***
val server = profile.server
val port = profile.serverPort
if (server.isNullOrBlank() && port.isNullOrBlank()) return ""
val addrPart = server?.let {
if (it.contains(":"))
it.split(":").take(2).joinToString(":", postfix = ":***")
else
it.split('.').dropLast(1).joinToString(".", postfix = ".***")
} ?: ""
return "$addrPart : ${port ?: ""}"
}
}
@@ -1,243 +0,0 @@
package com.v2ray.ang.handler
import android.util.Log
import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.NetworkType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.ServerConfig
import com.v2ray.ang.extension.removeWhiteSpace
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
import com.v2ray.ang.util.JsonUtil
object MigrateManager {
private const val ID_SERVER_CONFIG = "SERVER_CONFIG"
private val serverStorage by lazy { MMKV.mmkvWithID(ID_SERVER_CONFIG, MMKV.MULTI_PROCESS_MODE) }
/**
* Migrates server configurations to profile items.
*
* @return True if migration was successful, false otherwise.
*/
fun migrateServerConfig2Profile(): Boolean {
if (serverStorage.count().toInt() == 0) {
return false
}
val serverList = serverStorage.allKeys() ?: return false
Log.d(ANG_PACKAGE, "migrateServerConfig2Profile-" + serverList.count())
for (guid in serverList) {
var configOld = decodeServerConfigOld(guid) ?: continue
var config = decodeServerConfig(guid)
if (config != null) {
serverStorage.remove(guid)
continue
}
config = migrateServerConfig2ProfileSub(configOld) ?: continue
config.subscriptionId = configOld.subscriptionId
MmkvManager.encodeServerConfig(guid, config)
//check and remove old
decodeServerConfig(guid) ?: continue
serverStorage.remove(guid)
Log.d(ANG_PACKAGE, "migrateServerConfig2Profile-" + config.remarks)
}
Log.d(ANG_PACKAGE, "migrateServerConfig2Profile-end")
return true
}
/**
* Migrates a server configuration to a profile item.
*
* @param configOld The old server configuration.
* @return The profile item.
*/
private fun migrateServerConfig2ProfileSub(configOld: ServerConfig): ProfileItem? {
return when (configOld.getProxyOutbound()?.protocol) {
EConfigType.VMESS.name.lowercase() -> migrate2ProfileCommon(configOld)
EConfigType.VLESS.name.lowercase() -> migrate2ProfileCommon(configOld)
EConfigType.TROJAN.name.lowercase() -> migrate2ProfileCommon(configOld)
EConfigType.SHADOWSOCKS.name.lowercase() -> migrate2ProfileCommon(configOld)
EConfigType.SOCKS.name.lowercase() -> migrate2ProfileSocks(configOld)
EConfigType.HTTP.name.lowercase() -> migrate2ProfileHttp(configOld)
EConfigType.WIREGUARD.name.lowercase() -> migrate2ProfileWireguard(configOld)
EConfigType.HYSTERIA2.name.lowercase() -> migrate2ProfileHysteria2(configOld)
EConfigType.CUSTOM.name.lowercase() -> migrate2ProfileCustom(configOld)
else -> null
}
}
/**
* Migrates a common server configuration to a profile item.
*
* @param configOld The old server configuration.
* @return The profile item.
*/
private fun migrate2ProfileCommon(configOld: ServerConfig): ProfileItem? {
val config = ProfileItem.create(configOld.configType)
val outbound = configOld.getProxyOutbound() ?: return null
config.remarks = configOld.remarks
config.server = outbound.getServerAddress()
config.serverPort = outbound.getServerPort().toString()
config.method = outbound.getSecurityEncryption()
config.password = outbound.getPassword()
config.flow = outbound?.settings?.vnext?.first()?.users?.first()?.flow ?: outbound?.settings?.servers?.first()?.flow
config.network = outbound?.streamSettings?.network ?: NetworkType.TCP.type
outbound.getTransportSettingDetails()?.let { transportDetails ->
config.headerType = transportDetails[0].orEmpty()
config.host = transportDetails[1].orEmpty()
config.path = transportDetails[2].orEmpty()
}
config.seed = outbound?.streamSettings?.kcpSettings?.seed
config.quicSecurity = outbound?.streamSettings?.quicSettings?.security
config.quicKey = outbound?.streamSettings?.quicSettings?.key
config.mode = if (outbound?.streamSettings?.grpcSettings?.multiMode == true) "multi" else "gun"
config.serviceName = outbound?.streamSettings?.grpcSettings?.serviceName
config.authority = outbound?.streamSettings?.grpcSettings?.authority
config.security = outbound.streamSettings?.security
val tlsSettings = outbound?.streamSettings?.realitySettings ?: outbound?.streamSettings?.tlsSettings
config.insecure = tlsSettings?.allowInsecure
config.sni = tlsSettings?.serverName
config.fingerPrint = tlsSettings?.fingerprint
config.alpn = tlsSettings?.alpn?.joinToString(",").removeWhiteSpace().toString()
config.publicKey = tlsSettings?.publicKey
config.shortId = tlsSettings?.shortId
config.spiderX = tlsSettings?.spiderX
return config
}
/**
* Migrates a SOCKS server configuration to a profile item.
*
* @param configOld The old server configuration.
* @return The profile item.
*/
private fun migrate2ProfileSocks(configOld: ServerConfig): ProfileItem? {
val config = ProfileItem.create(EConfigType.SOCKS)
val outbound = configOld.getProxyOutbound() ?: return null
config.remarks = configOld.remarks
config.server = outbound.getServerAddress()
config.serverPort = outbound.getServerPort().toString()
config.username = outbound.settings?.servers?.first()?.users?.first()?.user
config.password = outbound.getPassword()
return config
}
/**
* Migrates an HTTP server configuration to a profile item.
*
* @param configOld The old server configuration.
* @return The profile item.
*/
private fun migrate2ProfileHttp(configOld: ServerConfig): ProfileItem? {
val config = ProfileItem.create(EConfigType.HTTP)
val outbound = configOld.getProxyOutbound() ?: return null
config.remarks = configOld.remarks
config.server = outbound.getServerAddress()
config.serverPort = outbound.getServerPort().toString()
config.username = outbound.settings?.servers?.first()?.users?.first()?.user
config.password = outbound.getPassword()
return config
}
/**
* Migrates a WireGuard server configuration to a profile item.
*
* @param configOld The old server configuration.
* @return The profile item.
*/
private fun migrate2ProfileWireguard(configOld: ServerConfig): ProfileItem? {
val config = ProfileItem.create(EConfigType.WIREGUARD)
val outbound = configOld.getProxyOutbound() ?: return null
config.remarks = configOld.remarks
config.server = outbound.getServerAddress()
config.serverPort = outbound.getServerPort().toString()
outbound.settings?.let { wireguard ->
config.secretKey = wireguard.secretKey
config.localAddress = (wireguard.address as List<*>).joinToString(",").removeWhiteSpace().toString()
config.publicKey = wireguard.peers?.getOrNull(0)?.publicKey
config.mtu = wireguard.mtu
config.reserved = wireguard.reserved?.joinToString(",").removeWhiteSpace().toString()
}
return config
}
/**
* Migrates a Hysteria2 server configuration to a profile item.
*
* @param configOld The old server configuration.
* @return The profile item.
*/
private fun migrate2ProfileHysteria2(configOld: ServerConfig): ProfileItem? {
val config = ProfileItem.create(EConfigType.HYSTERIA2)
val outbound = configOld.getProxyOutbound() ?: return null
config.remarks = configOld.remarks
config.server = outbound.getServerAddress()
config.serverPort = outbound.getServerPort().toString()
config.password = outbound.getPassword()
config.security = AppConfig.TLS
outbound.streamSettings?.tlsSettings?.let { tlsSetting ->
config.insecure = tlsSetting.allowInsecure
config.sni = tlsSetting.serverName
config.alpn = tlsSetting.alpn?.joinToString(",").removeWhiteSpace().orEmpty()
}
config.obfsPassword = outbound.settings?.obfsPassword
return config
}
/**
* Migrates a custom server configuration to a profile item.
*
* @param configOld The old server configuration.
* @return The profile item.
*/
private fun migrate2ProfileCustom(configOld: ServerConfig): ProfileItem? {
val config = ProfileItem.create(EConfigType.CUSTOM)
val outbound = configOld.getProxyOutbound() ?: return null
config.remarks = configOld.remarks
config.server = outbound.getServerAddress()
config.serverPort = outbound.getServerPort().toString()
return config
}
/**
* Decodes the old server configuration.
*
* @param guid The server GUID.
* @return The old server configuration.
*/
private fun decodeServerConfigOld(guid: String): ServerConfig? {
if (guid.isBlank()) {
return null
}
val json = serverStorage.decodeString(guid)
if (json.isNullOrBlank()) {
return null
}
return JsonUtil.fromJson(json, ServerConfig::class.java)
}
}
@@ -1,13 +1,17 @@
package com.v2ray.ang.handler
import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
import com.v2ray.ang.dto.AssetUrlCache
import com.v2ray.ang.dto.AssetUrlItem
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.ServerAffiliationInfo
import com.v2ray.ang.dto.SubscriptionCache
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils
@@ -15,7 +19,6 @@ object MmkvManager {
//region private
//private const val ID_PROFILE_CONFIG = "PROFILE_CONFIG"
private const val ID_MAIN = "MAIN"
private const val ID_PROFILE_FULL_CONFIG = "PROFILE_FULL_CONFIG"
private const val ID_SERVER_RAW = "SERVER_RAW"
@@ -25,9 +28,10 @@ object MmkvManager {
private const val ID_SETTING = "SETTING"
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
private const val KEY_SUB_SERVER_PREFIX = "SUB_SERVERS_"
private const val KEY_SUB_IDS = "SUB_IDS"
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
//private val profileStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_CONFIG, MMKV.MULTI_PROCESS_MODE) }
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
@@ -40,6 +44,17 @@ object MmkvManager {
//region Server
/**
* Reads the legacy server list from KEY_ANG_CONFIGS for migration.
* This method is for migration purposes only.
*
* @return The JSON string of legacy server list, or null if not exists.
*/
fun readLegacyServerList(): String? {
return mainStorage.decodeString(KEY_ANG_CONFIGS)
}
/**
* Gets the selected server GUID.
*
@@ -59,28 +74,62 @@ object MmkvManager {
}
/**
* Encodes the server list.
* Encodes the server list for a given subscription.
* Saves to the subscription's serverList (including default subscription for ungrouped servers).
*
* @param serverList The list of server GUIDs.
* @param subscriptionId The subscription ID.
*/
fun encodeServerList(serverList: MutableList<String>) {
mainStorage.encode(KEY_ANG_CONFIGS, JsonUtil.toJson(serverList))
fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
val subId = getSubscriptionId(subscriptionId)
val key = "$KEY_SUB_SERVER_PREFIX$subId"
mainStorage.encode(key, JsonUtil.toJson(serverList))
}
/**
* Decodes the server list.
* Decodes the server list for a given subscription.
* If subscriptionId is empty, returns ungrouped servers.
* Otherwise, returns servers from the specified subscription's serverList.
*
* @param subscriptionId The subscription ID.
* @return The list of server GUIDs.
*/
fun decodeServerList(): MutableList<String> {
val json = mainStorage.decodeString(KEY_ANG_CONFIGS)
fun decodeServerList(subscriptionId: String): MutableList<String> {
val subId = getSubscriptionId(subscriptionId)
val key = "$KEY_SUB_SERVER_PREFIX$subId"
val json = mainStorage.decodeString(key)
return if (json.isNullOrBlank()) {
mutableListOf()
} else {
JsonUtil.fromJson(json, Array<String>::class.java).toMutableList()
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
}
}
/**
* Decodes all server list (merged from all subscriptions including default subscription).
* Use this when you need the complete server list.
*
* @return The list of all server GUIDs.
*/
fun decodeAllServerList(): MutableList<String> {
val allServers = mutableListOf<String>()
val subsList = decodeSubsList()
// If DEFAULT_SUBSCRIPTION_ID is not in the subscriptions list, add its servers
if (!subsList.contains(DEFAULT_SUBSCRIPTION_ID)) {
allServers.addAll(decodeServerList(DEFAULT_SUBSCRIPTION_ID))
}
// Add servers from all subscriptions
subsList.forEach { guid ->
allServers.addAll(decodeServerList(guid))
}
return allServers
}
/**
* Decodes the server configuration.
*
@@ -98,16 +147,6 @@ object MmkvManager {
return JsonUtil.fromJson(json, ProfileItem::class.java)
}
// fun decodeProfileConfig(guid: String): ProfileLiteItem? {
// if (guid.isBlank()) {
// return null
// }
// val json = profileStorage.decodeString(guid)
// if (json.isNullOrBlank()) {
// return null
// }
// return JsonUtil.fromJson(json, ProfileLiteItem::class.java)
// }
/**
* Encodes the server configuration.
@@ -119,25 +158,32 @@ object MmkvManager {
fun encodeServerConfig(guid: String, config: ProfileItem): String {
val key = guid.ifBlank { Utils.getUuid() }
profileFullStorage.encode(key, JsonUtil.toJson(config))
val serverList = decodeServerList()
// Use default subscription for servers without subscription
val subId = getSubscriptionId(config.subscriptionId)
val serverList = decodeServerList(subId)
if (!serverList.contains(key)) {
serverList.add(0, key)
encodeServerList(serverList)
encodeServerList(serverList, subId)
if (getSelectServer().isNullOrBlank()) {
mainStorage.encode(KEY_SELECTED_SERVER, key)
}
}
// val profile = ProfileLiteItem(
// configType = config.configType,
// subscriptionId = config.subscriptionId,
// remarks = config.remarks,
// server = config.getProxyOutbound()?.getServerAddress(),
// serverPort = config.getProxyOutbound()?.getServerPort(),
// )
// profileStorage.encode(key, JsonUtil.toJson(profile))
return key
}
/**
* Encodes the server configuration directly without updating serverList.
*
* @param key The server GUID.
* @param configJson The server configuration JSON string.
*/
fun encodeProfileDirect(key: String, configJson: String) {
profileFullStorage.encode(key, configJson)
}
/**
* Removes the server configuration.
*
@@ -147,33 +193,44 @@ object MmkvManager {
if (guid.isBlank()) {
return
}
// Get config to determine which subscription to update
val config = decodeServerConfig(guid)
val subId = getSubscriptionId(config?.subscriptionId)
// Remove from appropriate server list
val serverList = decodeServerList(subId)
serverList.remove(guid)
encodeServerList(serverList, subId)
// Clean up storage
if (getSelectServer() == guid) {
mainStorage.remove(KEY_SELECTED_SERVER)
}
val serverList = decodeServerList()
serverList.remove(guid)
encodeServerList(serverList)
profileFullStorage.remove(guid)
//profileStorage.remove(guid)
serverAffStorage.remove(guid)
}
/**
* Removes the server configurations via subscription ID.
*
* @param subid The subscription ID.
* @param subscriptionId The subscription ID.
*/
fun removeServerViaSubid(subid: String) {
if (subid.isBlank()) {
return
}
profileFullStorage.allKeys()?.forEach { key ->
decodeServerConfig(key)?.let { config ->
if (config.subscriptionId == subid) {
removeServer(key)
}
fun removeServerViaSubid(subscriptionId: String?) {
val subId = getSubscriptionId(subscriptionId)
val serverList = decodeServerList(subId)
// Remove all servers in the list
serverList.forEach { guid ->
if (getSelectServer() == guid) {
mainStorage.remove(KEY_SELECTED_SERVER)
}
profileFullStorage.remove(guid)
serverAffStorage.remove(guid)
}
serverList.clear()
encodeServerList(serverList, subId)
}
/**
@@ -231,7 +288,6 @@ object MmkvManager {
val count = profileFullStorage.allKeys()?.count() ?: 0
mainStorage.clearAll()
profileFullStorage.clearAll()
//profileStorage.clearAll()
serverAffStorage.clearAll()
return count
}
@@ -288,6 +344,10 @@ object MmkvManager {
//region Subscriptions
private fun getSubscriptionId(subscriptionId: String?): String {
return subscriptionId?.ifEmpty { DEFAULT_SUBSCRIPTION_ID } ?: DEFAULT_SUBSCRIPTION_ID
}
/**
* Initializes the subscription list.
*/
@@ -307,14 +367,15 @@ object MmkvManager {
*
* @return The list of subscriptions.
*/
fun decodeSubscriptions(): List<Pair<String, SubscriptionItem>> {
fun decodeSubscriptions(): List<SubscriptionCache> {
initSubsList()
val subscriptions = mutableListOf<Pair<String, SubscriptionItem>>()
val subscriptions = mutableListOf<SubscriptionCache>()
decodeSubsList().forEach { key ->
val json = subStorage.decodeString(key)
if (!json.isNullOrBlank()) {
subscriptions.add(Pair(key, JsonUtil.fromJson(json, SubscriptionItem::class.java)))
val item = JsonUtil.fromJson(json, SubscriptionItem::class.java) ?: SubscriptionItem()
subscriptions.add(SubscriptionCache(key, item))
}
}
return subscriptions
@@ -381,7 +442,7 @@ object MmkvManager {
return if (json.isNullOrBlank()) {
mutableListOf()
} else {
JsonUtil.fromJson(json, Array<String>::class.java).toMutableList()
JsonUtil.fromJson(json, Array<String>::class.java)?.toMutableList() ?: mutableListOf()
}
}
@@ -394,15 +455,16 @@ object MmkvManager {
*
* @return The list of asset URLs.
*/
fun decodeAssetUrls(): List<Pair<String, AssetUrlItem>> {
val assetUrlItems = mutableListOf<Pair<String, AssetUrlItem>>()
fun decodeAssetUrls(): List<AssetUrlCache> {
val assetUrlItems = mutableListOf<AssetUrlCache>()
assetStorage.allKeys()?.forEach { key ->
val json = assetStorage.decodeString(key)
if (!json.isNullOrBlank()) {
assetUrlItems.add(Pair(key, JsonUtil.fromJson(json, AssetUrlItem::class.java)))
val item = JsonUtil.fromJson(json, AssetUrlItem::class.java) ?: AssetUrlItem()
assetUrlItems.add(AssetUrlCache(key, item))
}
}
return assetUrlItems.sortedBy { (_, value) -> value.addedTime }
return assetUrlItems.sortedBy { it.assetUrl.addedTime }
}
/**
@@ -448,7 +510,7 @@ object MmkvManager {
fun decodeRoutingRulesets(): MutableList<RulesetItem>? {
val ruleset = settingsStorage.decodeString(PREF_ROUTING_RULESET)
if (ruleset.isNullOrEmpty()) return null
return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java).toMutableList()
return JsonUtil.fromJson(ruleset, Array<RulesetItem>::class.java)?.toMutableList() ?: mutableListOf()
}
/**
@@ -465,6 +527,7 @@ object MmkvManager {
//endregion
//region settings
/**
* Encodes the settings.
*
@@ -487,6 +550,28 @@ object MmkvManager {
return settingsStorage.encode(key, value)
}
/**
* Encodes the settings.
*
* @param key The settings key.
* @param value The settings value.
* @return Whether the encoding was successful.
*/
fun encodeSettings(key: String, value: Long): Boolean {
return settingsStorage.encode(key, value)
}
/**
* Encodes the settings.
*
* @param key The settings key.
* @param value The settings value.
* @return Whether the encoding was successful.
*/
fun encodeSettings(key: String, value: Float): Boolean {
return settingsStorage.encode(key, value)
}
/**
* Encodes the settings.
*
@@ -530,6 +615,39 @@ object MmkvManager {
return settingsStorage.decodeString(key, defaultValue)
}
/**
* Decodes the settings integer.
*
* @param key The settings key.
* @param defaultValue The default value.
* @return The settings value.
*/
fun decodeSettingsInt(key: String, defaultValue: Int): Int {
return settingsStorage.decodeInt(key, defaultValue)
}
/**
* Decodes the settings long.
*
* @param key The settings key.
* @param defaultValue The default value.
* @return The settings value.
*/
fun decodeSettingsLong(key: String, defaultValue: Long): Long {
return settingsStorage.decodeLong(key, defaultValue)
}
/**
* Decodes the settings float.
*
* @param key The settings key.
* @param defaultValue The default value.
* @return The settings value.
*/
fun decodeSettingsFloat(key: String, defaultValue: Float): Float {
return settingsStorage.decodeFloat(key, defaultValue)
}
/**
* Decodes the settings boolean.
*
@@ -561,17 +679,13 @@ object MmkvManager {
return settingsStorage.decodeStringSet(key)
}
//endregion
//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)
}
/**
@@ -585,4 +699,22 @@ object MmkvManager {
//endregion
//region WebDAV
/**
* Encodes the WebDAV config as JSON into storage.
*/
fun encodeWebDavConfig(config: WebDavConfig): Boolean {
return mainStorage.encode(KEY_WEBDAV_CONFIG, JsonUtil.toJson(config))
}
/**
* Decodes the WebDAV config from storage.
*/
fun decodeWebDavConfig(): WebDavConfig? {
val json = mainStorage.decodeString(KEY_WEBDAV_CONFIG) ?: return null
return JsonUtil.fromJson(json, WebDavConfig::class.java)
}
//endregion
}
@@ -1,4 +1,4 @@
package com.v2ray.ang.service
package com.v2ray.ang.handler
import android.app.Notification
import android.app.NotificationChannel
@@ -12,13 +12,11 @@ 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 com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -27,12 +25,13 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.math.min
object NotificationService {
object NotificationManager {
private const val NOTIFICATION_ID = 1
private const val NOTIFICATION_PENDING_INTENT_CONTENT = 0
private const val NOTIFICATION_PENDING_INTENT_STOP_V2RAY = 1
private const val NOTIFICATION_PENDING_INTENT_RESTART_V2RAY = 2
private const val NOTIFICATION_ICON_THRESHOLD = 3000
private const val QUERY_INTERVAL_MS = 3000L
private var lastQueryTime = 0L
private var mBuilder: NotificationCompat.Builder? = null
@@ -47,15 +46,24 @@ object NotificationService {
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)
outboundTags?.remove(AppConfig.TAG_DIRECT)
speedNotificationJob = CoroutineScope(Dispatchers.IO).launch {
while (isActive) {
val queryTime = System.currentTimeMillis()
val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0
val sinceLastQueryIn = (queryTime - lastQueryTime)
// If the query interval is too short, skip this round to avoid excessive CPU usage
if (sinceLastQueryIn < QUERY_INTERVAL_MS) {
LogUtil.w(AppConfig.TAG, "Query interval too short: ${sinceLastQueryIn}ms, skipping")
lastQueryTime = queryTime
delay(QUERY_INTERVAL_MS)
continue
}
val sinceLastQueryInSeconds = sinceLastQueryIn / 1000.0
var proxyTotal = 0L
val text = StringBuilder()
outboundTags?.forEach {
@@ -66,22 +74,22 @@ object NotificationService {
proxyTotal += up + down
}
}
val directUplink = V2RayServiceManager.queryStats(TAG_DIRECT, AppConfig.UPLINK)
val directDownlink = V2RayServiceManager.queryStats(TAG_DIRECT, AppConfig.DOWNLINK)
val directUplink = V2RayServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.UPLINK)
val directDownlink = V2RayServiceManager.queryStats(AppConfig.TAG_DIRECT, AppConfig.DOWNLINK)
val zeroSpeed = proxyTotal == 0L && directUplink == 0L && directDownlink == 0L
if (!zeroSpeed || !lastZeroSpeed) {
if (proxyTotal == 0L) {
appendSpeedString(text, outboundTags?.firstOrNull(), 0.0, 0.0)
}
appendSpeedString(
text, TAG_DIRECT, directUplink / sinceLastQueryInSeconds,
text, AppConfig.TAG_DIRECT, directUplink / sinceLastQueryInSeconds,
directDownlink / sinceLastQueryInSeconds
)
updateNotification(text.toString(), proxyTotal, directDownlink + directUplink)
}
lastZeroSpeed = zeroSpeed
lastQueryTime = queryTime
delay(3000)
delay(QUERY_INTERVAL_MS)
}
}
}
@@ -92,22 +100,22 @@ object NotificationService {
*/
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
}
// Reset last query time to avoid querying stats too soon after showing the notification
lastQueryTime = System.currentTimeMillis()
val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
val startMainIntent = Intent(service, MainActivity::class.java)
val contentPendingIntent = PendingIntent.getActivity(service, NOTIFICATION_PENDING_INTENT_CONTENT, startMainIntent, flags)
val stopV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
stopV2RayIntent.`package` = ANG_PACKAGE
stopV2RayIntent.`package` = AppConfig.ANG_PACKAGE
stopV2RayIntent.putExtra("key", AppConfig.MSG_STATE_STOP)
val stopV2RayPendingIntent = PendingIntent.getBroadcast(service, NOTIFICATION_PENDING_INTENT_STOP_V2RAY, stopV2RayIntent, flags)
val restartV2RayIntent = Intent(AppConfig.BROADCAST_ACTION_SERVICE)
restartV2RayIntent.`package` = ANG_PACKAGE
restartV2RayIntent.`package` = AppConfig.ANG_PACKAGE
restartV2RayIntent.putExtra("key", AppConfig.MSG_STATE_RESTART)
val restartV2RayPendingIntent = PendingIntent.getBroadcast(service, NOTIFICATION_PENDING_INTENT_RESTART_V2RAY, restartV2RayIntent, flags)
@@ -149,15 +157,12 @@ object NotificationService {
*/
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)
}
service.stopForeground(Service.STOP_FOREGROUND_REMOVE)
mBuilder = null
speedNotificationJob?.cancel()
speedNotificationJob = null
mNotificationManager = null
}
/**
@@ -233,7 +238,7 @@ object NotificationService {
*/
private fun appendSpeedString(text: StringBuilder, name: String?, up: Double, down: Double) {
var n = name ?: "no tag"
n = n.substring(0, min(n.length, 6))
n = n.take(min(n.length, 6))
text.append(n)
for (i in n.length..6 step 2) {
text.append("\t")
@@ -0,0 +1,32 @@
package com.v2ray.ang.handler
import kotlinx.coroutines.flow.MutableStateFlow
object SettingsChangeManager {
private val _restartService = MutableStateFlow(false)
private val _setupGroupTab = MutableStateFlow(false)
// Mark restartService as requiring a restart
fun makeRestartService() {
_restartService.value = true
}
// Read and clear the restartService flag
fun consumeRestartService(): Boolean {
val v = _restartService.value
_restartService.value = false
return v
}
// Mark reinitGroupTab as requiring tab reinitialization
fun makeSetupGroupTab() {
_setupGroupTab.value = true
}
// Read and clear the reinitGroupTab flag
fun consumeSetupGroupTab(): Boolean {
val v = _setupGroupTab.value
_setupGroupTab.value = false
return v
}
}
@@ -2,36 +2,57 @@ package com.v2ray.ang.handler
import android.content.Context
import android.content.res.AssetManager
import android.os.Build
import android.text.TextUtils
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.Language
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.RoutingType
import com.v2ray.ang.dto.RulesetItem
import com.v2ray.ang.dto.SubscriptionItem
import com.v2ray.ang.dto.V2rayConfig
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.enums.Language
import com.v2ray.ang.enums.RoutingType
import com.v2ray.ang.enums.VpnInterfaceAddressConfig
import com.v2ray.ang.handler.MmkvManager.decodeAllServerList
import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
import com.v2ray.ang.handler.MmkvManager.decodeServerList
import com.v2ray.ang.handler.MmkvManager.decodeSubsList
import com.v2ray.ang.handler.MmkvManager.decodeSubscription
import com.v2ray.ang.handler.MmkvManager.encodeSubscription
import com.v2ray.ang.handler.MmkvManager.removeSubscription
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import java.io.File
import java.io.FileOutputStream
import java.util.Collections
import java.util.Locale
import kotlin.random.Random
object SettingsManager {
@Volatile
private var runtimeSocksPort: Int? = null
fun initApp(context: Context) {
ensureDefaultSettings()
//ensureDefaultSubscription()
initRoutingRulesets(context)
migrateServerListToSubscriptions()
migrateHysteria2PinSHA256()
}
/**
* Initialize routing rulesets.
* @param context The application context.
*/
fun initRoutingRulesets(context: Context) {
private fun initRoutingRulesets(context: Context) {
val exist = MmkvManager.decodeRoutingRulesets()
if (exist.isNullOrEmpty()) {
val rulesetList = getPresetRoutingRulesets(context)
@@ -52,7 +73,7 @@ object SettingsManager {
return null
}
return JsonUtil.fromJson(assets, Array<RulesetItem>::class.java).toMutableList()
return JsonUtil.fromJson(assets, Array<RulesetItem>::class.java)?.toMutableList()
}
/**
@@ -76,7 +97,7 @@ object SettingsManager {
}
try {
val rulesetList = JsonUtil.fromJson(content, Array<RulesetItem>::class.java).toMutableList()
val rulesetList = JsonUtil.fromJson(content, Array<RulesetItem>::class.java)?.toMutableList()
if (rulesetList.isNullOrEmpty()) {
return false
}
@@ -84,7 +105,7 @@ object SettingsManager {
resetRoutingRulesetsCommon(rulesetList)
return true
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(ANG_PACKAGE, "Failed to reset routing rulesets", e)
return false
}
}
@@ -159,7 +180,7 @@ object SettingsManager {
* @return True if bypassing LAN, false otherwise.
*/
fun routingRulesetsBypassLan(): Boolean {
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN) ?: "0"
val vpnBypassLan = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_BYPASS_LAN) ?: "1"
if (vpnBypassLan == "1") {
return true
} else if (vpnBypassLan == "2") {
@@ -167,11 +188,11 @@ object SettingsManager {
}
val guid = MmkvManager.getSelectServer() ?: return false
val config = MmkvManager.decodeServerConfig(guid) ?: 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 {
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
@@ -203,8 +224,8 @@ object SettingsManager {
* @param toPosition The position to swap to.
*/
fun swapSubscriptions(fromPosition: Int, toPosition: Int) {
val subsList = MmkvManager.decodeSubsList()
if (subsList.isNullOrEmpty()) return
val subsList = decodeSubsList()
if (subsList.isEmpty()) return
Collections.swap(subsList, fromPosition, toPosition)
MmkvManager.encodeSubsList(subsList)
@@ -216,17 +237,40 @@ object SettingsManager {
* @return The ProfileItem.
*/
fun getServerViaRemarks(remarks: String?): ProfileItem? {
if (remarks == null) {
if (remarks.isNullOrEmpty()) {
return null
}
val serverList = decodeServerList()
for (guid in serverList) {
val profile = decodeServerConfig(guid)
if (profile != null && profile.remarks == remarks) {
return profile
}
val serverList = decodeAllServerList()
return serverList
.mapNotNull { guid -> decodeServerConfig(guid) }
.firstOrNull { it.remarks == remarks }
}
/**
* Removes the subscription.
* If there are no remaining subscriptions,
* it creates a new default subscription to ensure that ungroup
**/
fun removeSubscriptionWithDefault(subid: String) {
// val subsList = decodeSubsList()
// if (subsList.size == 1 && subsList.first() == DEFAULT_SUBSCRIPTION_ID) {
// LogUtil.i(ANG_PACKAGE,"Attempted to remove the only existing default subscription, operation ignored.")
// return
// }
// Remove the subscription
removeSubscription(subid)
// After removal, check if there are any subscriptions left. If not, create a default subscription.
val subsList2 = decodeSubsList()
if (subsList2.isNotEmpty()) {
return
}
return null
val defaultSub = SubscriptionItem(
remarks = "Default",
)
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
}
/**
@@ -234,7 +278,30 @@ object SettingsManager {
* @return The SOCKS port.
*/
fun getSocksPort(): Int {
return Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
val port =
if (IsDynamicSocksPort()) {
runtimeSocksPort ?: refreshRuntimeSocksPort()
} else {
Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PORT), AppConfig.PORT_SOCKS.toInt())
}
return port ?: AppConfig.PORT_SOCKS.toInt()
}
@Synchronized
fun refreshRuntimeSocksPort(): Int? {
if (IsDynamicSocksPort()) {
runtimeSocksPort = generateRandomSocksPort()
return runtimeSocksPort
}
return null
}
fun getSocksUsername(): String? {
return MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_USERNAME)?.trim()?.takeIf { it.isNotEmpty() }
}
fun getSocksPassword(): String? {
return MmkvManager.decodeSettingsString(AppConfig.PREF_SOCKS_PASSWORD)?.trim()?.takeIf { it.isNotEmpty() }
}
/**
@@ -242,7 +309,15 @@ object SettingsManager {
* @return The HTTP port.
*/
fun getHttpPort(): Int {
return getSocksPort() + (if (Utils.isXray()) 0 else 1)
return getSocksPort() + if (Utils.isXray()) 0 else 1
}
private fun IsDynamicSocksPort(): Boolean {
return MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
}
private fun generateRandomSocksPort(): Int {
return Random.nextInt(10000, 65535)
}
/**
@@ -254,7 +329,7 @@ object SettingsManager {
val extFolder = Utils.userAssetPath(context)
try {
val geo = arrayOf("geosite.dat", "geoip.dat")
val geo = arrayOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
assets.list("")
?.filter { geo.contains(it) }
?.filter { !File(extFolder, it).exists() }
@@ -265,13 +340,10 @@ object SettingsManager {
input.copyTo(output)
}
}
Log.i(
ANG_PACKAGE,
"Copied from apk assets folder to ${target.absolutePath}"
)
LogUtil.i(AppConfig.TAG, "Copied from apk assets folder to ${target.absolutePath}")
}
} catch (e: Exception) {
Log.e(ANG_PACKAGE, "asset copy failed", e)
LogUtil.e(ANG_PACKAGE, "asset copy failed", e)
}
}
@@ -319,10 +391,10 @@ object SettingsManager {
*/
fun getDelayTestUrl(second: Boolean = false): String {
return if (second) {
AppConfig.DelayTestUrl2
AppConfig.DELAY_TEST_URL2
} else {
MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL)
?: AppConfig.DelayTestUrl
?: AppConfig.DELAY_TEST_URL
}
}
@@ -340,11 +412,12 @@ object SettingsManager {
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.BANGLA -> Locale("bn")
Language.BAKHTIARI -> Locale("bqi", "IR")
Language.VIETNAMESE -> Locale.forLanguageTag("vi")
Language.RUSSIAN -> Locale.forLanguageTag("ru")
Language.PERSIAN -> Locale.forLanguageTag("fa")
Language.ARABIC -> Locale.forLanguageTag("ar")
Language.BANGLA -> Locale.forLanguageTag("bn")
Language.BAKHTIARI -> Locale.forLanguageTag("bqi-IR")
}
}
@@ -358,4 +431,186 @@ object SettingsManager {
"2" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
}
}
/**
* Retrieves the currently selected VPN interface address configuration.
* This method reads the user's preference for VPN interface addressing and returns
* the corresponding configuration containing IPv4 and IPv6 addresses.
*
* @return The selected VpnInterfaceAddressConfig instance, or the default configuration
* if no valid selection is found or if the stored index is invalid.
*/
fun getCurrentVpnInterfaceAddressConfig(): VpnInterfaceAddressConfig {
val selectedIndex = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX, "0")?.toInt()
return VpnInterfaceAddressConfig.getConfigByIndex(selectedIndex ?: 0)
}
/**
* Get the VPN MTU from settings, defaulting to AppConfig.VPN_MTU.
*/
fun getVpnMtu(): Int {
return Utils.parseInt(MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_MTU), AppConfig.VPN_MTU)
}
/**
* Check if HEV TUN is being used.
* @return True if HEV TUN is used, false otherwise.
*/
fun isUsingHevTun(): Boolean {
return MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true)
}
/**
* Check if VPN mode is enabled.
* @return True if VPN mode is enabled, false otherwise.
*/
fun isVpnMode(): Boolean {
val mode = MmkvManager.decodeSettingsString(AppConfig.PREF_MODE)
return mode == null || mode == VPN
}
/**
* Check if process routing can be used.
*/
fun canUseProcessRouting(): Boolean {
// Android 10+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return false
}
// Must xray tun
if (isUsingHevTun()) {
return false
}
// Must have route only enabled
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false) == false) {
return false
}
return true
}
/**
* Ensure default settings are present in MMKV.
*/
private fun ensureDefaultSettings() {
// Write defaults in the exact order requested by the user
ensureDefaultValue(AppConfig.PREF_MODE, VPN)
ensureDefaultValue(AppConfig.PREF_VPN_DNS, AppConfig.DNS_VPN)
ensureDefaultValue(AppConfig.PREF_VPN_MTU, AppConfig.VPN_MTU.toString())
ensureDefaultValue(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL, AppConfig.SUBSCRIPTION_DEFAULT_UPDATE_INTERVAL)
ensureDefaultValue(AppConfig.PREF_SOCKS_PORT, AppConfig.PORT_SOCKS)
ensureDefaultValue(AppConfig.PREF_REMOTE_DNS, AppConfig.DNS_PROXY)
ensureDefaultValue(AppConfig.PREF_DOMESTIC_DNS, AppConfig.DNS_DIRECT)
ensureDefaultValue(AppConfig.PREF_DELAY_TEST_URL, AppConfig.DELAY_TEST_URL)
ensureDefaultValue(AppConfig.PREF_IP_API_URL, AppConfig.IP_API_URL)
ensureDefaultValue(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, AppConfig.HEVTUN_RW_TIMEOUT)
ensureDefaultValue(AppConfig.PREF_MUX_CONCURRENCY, "8")
ensureDefaultValue(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
ensureDefaultValue(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
ensureDefaultValue(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
}
private fun ensureDefaultValue(key: String, default: String) {
if (MmkvManager.decodeSettingsString(key).isNullOrEmpty()) {
MmkvManager.encodeSettings(key, default)
}
}
private fun migrateHysteria2PinSHA256() {
// Check if migration has already been done
val migrationKey = "hysteria2_pin_sha256_migrated"
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
return
}
val serverList = decodeAllServerList()
for (guid in serverList) {
val profile = decodeServerConfig(guid) ?: continue
if (profile.configType != EConfigType.HYSTERIA2) {
continue
}
if (profile.pinSHA256.isNullOrEmpty() || !profile.pinnedCA256.isNullOrEmpty()) {
continue
}
profile.pinnedCA256 = profile.pinSHA256
profile.pinSHA256 = null
MmkvManager.encodeServerConfig(guid, profile)
}
MmkvManager.encodeSettings(migrationKey, true)
}
/**
* Migrates server list from legacy KEY_ANG_CONFIGS to subscription-based storage.
* This method should be called once during app initialization after the storage structure change.
* Servers are grouped by their subscriptionId into respective subscription's serverList.
* Servers without subscription are moved to the default subscription.
* After migration, KEY_ANG_CONFIGS is removed.
*/
private fun migrateServerListToSubscriptions() {
// Check if migration has already been done
val migrationKey = "server_list_to_subscriptions_migrated"
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
return
}
// Ensure default subscription exists before migration
ensureDefaultSubscription()
// Read existing server list from legacy KEY_ANG_CONFIGS
val oldJson = MmkvManager.readLegacyServerList()
if (oldJson.isNullOrBlank()) {
// No data to migrate, mark as done
MmkvManager.encodeSettings(migrationKey, true)
return
}
val guids = JsonUtil.fromJson(oldJson, Array<String>::class.java) ?: run {
MmkvManager.encodeSettings(migrationKey, true)
return
}
val subscriptionServerMap = mutableMapOf<String, MutableList<String>>()
// Group servers by subscription (use default subscription for empty subscriptionId)
guids.forEach { guid ->
val config = decodeServerConfig(guid) ?: return@forEach
val subId = config.subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
subscriptionServerMap.getOrPut(subId) { mutableListOf() }.add(guid)
}
// Update each subscription's serverList (including default subscription)
subscriptionServerMap.forEach { (subId, serverGuids) ->
MmkvManager.encodeServerList(serverGuids, subId)
}
// Mark migration as complete
MmkvManager.encodeSettings(migrationKey, true)
}
/**
* Ensures the default subscription exists for ungrouped servers.
* This subscription is used internally to store servers without a subscription.
* Made public for migration in SettingsManager.
*/
private fun ensureDefaultSubscription() {
if (decodeSubscription(DEFAULT_SUBSCRIPTION_ID) == null) {
val defaultSub = SubscriptionItem(
remarks = "Default",
)
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
// Move top
val subsList = decodeSubsList()
if (subsList.count() > 1) {
swapSubscriptions(0, subsList.count() - 1)
}
}
}
}
@@ -2,19 +2,18 @@ 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.R
import com.v2ray.ang.extension.responseLength
import com.v2ray.ang.dto.IPAPIInfo
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import libv2ray.Libv2ray
import java.io.IOException
import java.net.InetSocketAddress
import java.net.Socket
import java.net.UnknownHostException
import kotlin.coroutines.coroutineContext
object SpeedtestManager {
@@ -31,7 +30,7 @@ object SpeedtestManager {
var time = -1L
for (k in 0 until 2) {
val one = socketConnectTime(url, port)
if (!coroutineContext.isActive) {
if (!currentCoroutineContext().isActive) {
break
}
if (one != -1L && (time == -1L || one < time)) {
@@ -41,46 +40,6 @@ object SpeedtestManager {
return time
}
/**
* Measures the real ping time using the V2Ray library.
*
* @param config The configuration string for the V2Ray library.
* @return The ping time in milliseconds, or -1 if the ping failed.
*/
fun realPing(config: String): Long {
return try {
Libv2ray.measureOutboundDelay(config, SettingsManager.getDelayTestUrl())
} catch (e: Exception) {
Log.d(AppConfig.ANG_PACKAGE, "realPing: $e")
-1L
}
}
/**
* Measures the ping time to a given URL using the system ping command.
*
* @param url The URL to ping.
* @return The ping time in milliseconds as a string, or "-1ms" if the ping failed.
*/
fun ping(url: String): String {
try {
val command = "/system/bin/ping -c 3 $url"
val process = Runtime.getRuntime().exec(command)
val allText = process.inputStream.bufferedReader().use { it.readText() }
if (!TextUtils.isEmpty(allText)) {
val tempInfo = allText.substring(allText.indexOf("min/avg/max/mdev") + 19)
val temps =
tempInfo.split("/".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
if (temps.count() > 0 && temps[0].length < 10) {
return temps[0].toFloat().toInt().toString() + "ms"
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return "-1ms"
}
/**
* Measures the time taken to establish a TCP connection to a given URL and port.
*
@@ -103,11 +62,11 @@ object SpeedtestManager {
socket.close()
return time
} catch (e: UnknownHostException) {
e.printStackTrace()
LogUtil.e(AppConfig.TAG, "Unknown host: $url", e)
} catch (e: IOException) {
Log.d(AppConfig.ANG_PACKAGE, "socketConnectTime IOException: $e")
LogUtil.e(AppConfig.TAG, "socketConnectTime IOException: $e")
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(AppConfig.TAG, "Failed to establish socket connection to $url:$port", e)
}
return -1
}
@@ -124,53 +83,30 @@ object SpeedtestManager {
}
}
/**
* 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
fun getRemoteIPInfo(): String? {
val url = MmkvManager.decodeSettingsString(AppConfig.PREF_IP_API_URL)
.takeIf { !it.isNullOrBlank() } ?: AppConfig.IP_API_URL
val conn = HttpUtil.createProxyConnection(SettingsManager.getDelayTestUrl(), port, 15000, 15000) ?: return Pair(elapsed, "")
try {
val start = SystemClock.elapsedRealtime()
val code = conn.responseCode
elapsed = SystemClock.elapsedRealtime() - start
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
val httpPort = SettingsManager.getHttpPort()
val content = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword) ?: return null
val ipInfo = JsonUtil.fromJson(content, IPAPIInfo::class.java) ?: return null
if (code == 204 || code == 200 && conn.responseLength == 0L) {
result = context.getString(R.string.connection_test_available, elapsed)
} else {
throw IOException(
context.getString(
R.string.connection_test_error_status_code,
code
)
)
}
} catch (e: IOException) {
Log.d(AppConfig.ANG_PACKAGE, "testConnection IOException: " + Log.getStackTraceString(e))
result = context.getString(R.string.connection_test_error, e.message)
} catch (e: Exception) {
Log.d(AppConfig.ANG_PACKAGE, "testConnection Exception: " + Log.getStackTraceString(e))
result = context.getString(R.string.connection_test_error, e.message)
} finally {
conn?.disconnect()
}
val ip = listOf(
ipInfo.ip,
ipInfo.clientIp,
ipInfo.ip_addr,
ipInfo.query
).firstOrNull { !it.isNullOrBlank() }
return Pair(elapsed, result)
val country = listOf(
ipInfo.country_code,
ipInfo.country,
ipInfo.countryCode,
ipInfo.location?.country_code
).firstOrNull { !it.isNullOrBlank() }
return "(${country ?: "unknown"}) ${ip ?: "unknown"}"
}
/**
* Gets the version of the V2Ray library.
*
* @return The version of the V2Ray library.
*/
fun getLibVersion(): String {
return Libv2ray.checkVersionX()
}
}
@@ -1,21 +1,17 @@
package com.v2ray.ang.service
package com.v2ray.ang.handler
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.SUBSCRIPTION_UPDATE_CHANNEL
import com.v2ray.ang.AppConfig.SUBSCRIPTION_UPDATE_CHANNEL_NAME
import com.v2ray.ang.R
import com.v2ray.ang.handler.AngConfigManager.updateConfigViaSub
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.LogUtil
object SubscriptionUpdater {
@@ -24,7 +20,7 @@ object SubscriptionUpdater {
private val notificationManager = NotificationManagerCompat.from(applicationContext)
private val notification =
NotificationCompat.Builder(applicationContext, SUBSCRIPTION_UPDATE_CHANNEL)
NotificationCompat.Builder(applicationContext, AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
.setWhen(0)
.setTicker("Update")
.setContentTitle(context.getString(R.string.title_pref_auto_update_subscription))
@@ -38,29 +34,26 @@ object SubscriptionUpdater {
*/
@SuppressLint("MissingPermission")
override suspend fun doWork(): Result {
Log.d(AppConfig.ANG_PACKAGE, "subscription automatic update starting")
LogUtil.i(AppConfig.TAG, "subscription automatic update starting")
val subs = MmkvManager.decodeSubscriptions().filter { it.second.autoUpdate }
val subs = MmkvManager.decodeSubscriptions().filter { it.subscription.autoUpdate }
for (sub in subs) {
val subItem = sub.second
val subItem = sub.subscription
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notification.setChannelId(SUBSCRIPTION_UPDATE_CHANNEL)
notification.setChannelId(AppConfig.SUBSCRIPTION_UPDATE_CHANNEL)
val channel =
NotificationChannel(
SUBSCRIPTION_UPDATE_CHANNEL,
SUBSCRIPTION_UPDATE_CHANNEL_NAME,
AppConfig.SUBSCRIPTION_UPDATE_CHANNEL,
AppConfig.SUBSCRIPTION_UPDATE_CHANNEL_NAME,
NotificationManager.IMPORTANCE_MIN
)
notificationManager.createNotificationChannel(channel)
}
notificationManager.notify(3, notification.build())
Log.d(
AppConfig.ANG_PACKAGE,
"subscription automatic update: ---${subItem.remarks}"
)
updateConfigViaSub(Pair(sub.first, subItem))
LogUtil.i(AppConfig.TAG, "subscription automatic update: ---${subItem.remarks}")
AngConfigManager.updateConfigViaSub(sub)
notification.setContentText("Updating ${subItem.remarks}")
}
notificationManager.cancel(3)
@@ -0,0 +1,95 @@
package com.v2ray.ang.handler
import android.content.Context
import android.os.Build
import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.dto.CheckUpdateResult
import com.v2ray.ang.dto.GitHubRelease
import com.v2ray.ang.extension.concatUrl
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
object UpdateCheckerManager {
suspend fun checkForUpdate(includePreRelease: Boolean = false): CheckUpdateResult = withContext(Dispatchers.IO) {
val url = if (includePreRelease) {
AppConfig.APP_API_URL
} else {
AppConfig.APP_API_URL.concatUrl("latest")
}
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
var response = HttpUtil.getUrlContent(url, 5000)
if (response.isNullOrEmpty()) {
val httpPort = SettingsManager.getHttpPort()
response = HttpUtil.getUrlContent(url, 5000, httpPort, proxyUsername, proxyPassword)
?: 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)
}
if (latestRelease == null) {
return@withContext CheckUpdateResult(hasUpdate = false)
}
val latestVersion = latestRelease.tagName.removePrefix("v")
LogUtil.i(
AppConfig.TAG,
"Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})"
)
return@withContext if (compareVersions(latestVersion, BuildConfig.VERSION_NAME) > 0) {
val downloadUrl = getDownloadUrl(latestRelease, Build.SUPPORTED_ABIS[0])
CheckUpdateResult(
hasUpdate = true,
latestVersion = latestVersion,
releaseNotes = latestRelease.body,
downloadUrl = downloadUrl,
isPreRelease = latestRelease.prerelease
)
} else {
CheckUpdateResult(hasUpdate = false)
}
}
private fun compareVersions(version1: String, version2: String): Int {
val v1 = version1.split(".")
val v2 = version2.split(".")
for (i in 0 until maxOf(v1.size, v2.size)) {
val num1 = if (i < v1.size) v1[i].toInt() else 0
val num2 = if (i < v2.size) v2[i].toInt() else 0
if (num1 != num2) return num1 - num2
}
return 0
}
private fun getDownloadUrl(release: GitHubRelease, abi: String): String {
val fDroid = "fdroid"
val assetsByAbi = release.assets.filter {
(it.name.contains(abi, true))
}
val asset = if (BuildConfig.APPLICATION_ID.contains(fDroid, ignoreCase = true)) {
assetsByAbi.firstOrNull { it.name.contains(fDroid) }
} else {
assetsByAbi.firstOrNull { !it.name.contains(fDroid) }
}
return asset?.browserDownloadUrl
?: throw IllegalStateException("No compatible APK found")
}
}
@@ -0,0 +1,91 @@
package com.v2ray.ang.handler
import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import go.Seq
import libv2ray.CoreCallbackHandler
import libv2ray.CoreController
import libv2ray.Libv2ray
import java.util.concurrent.atomic.AtomicBoolean
/**
* V2Ray Native Library Manager
*
* Thread-safe singleton wrapper for Libv2ray native methods.
* Provides initialization protection and unified API for V2Ray core operations.
*/
object V2RayNativeManager {
private val initialized = AtomicBoolean(false)
/**
* Initialize V2Ray core environment.
* This method is thread-safe and ensures initialization happens only once.
* Subsequent calls will be ignored silently.
*
*/
fun initCoreEnv(context: Context?) {
if (initialized.compareAndSet(false, true)) {
try {
Seq.setContext(context?.applicationContext)
val assetPath = Utils.userAssetPath(context)
val deviceId = Utils.getDeviceIdForXUDPBaseKey()
Libv2ray.initCoreEnv(assetPath, deviceId)
LogUtil.i(AppConfig.TAG, "V2Ray core environment initialized successfully")
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to initialize V2Ray core environment", e)
initialized.set(false)
throw e
}
} else {
LogUtil.d(AppConfig.TAG, "V2Ray core environment already initialized, skipping")
}
}
/**
* Get V2Ray core version.
*
* @return Version string of the V2Ray core
*/
fun getLibVersion(): String {
return try {
Libv2ray.checkVersionX()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to check V2Ray version", e)
"Unknown"
}
}
/**
* Measure outbound connection delay.
*
* @param config The configuration JSON string
* @param testUrl The URL to test against
* @return Delay in milliseconds, or -1 if test failed
*/
fun measureOutboundDelay(config: String, testUrl: String): Long {
return try {
Libv2ray.measureOutboundDelay(config, testUrl)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to measure outbound delay", e)
-1L
}
}
/**
* Create a new core controller instance.
*
* @param handler The callback handler for core events
* @return A new CoreController instance
*/
fun newCoreController(handler: CoreCallbackHandler): CoreController {
return try {
Libv2ray.newCoreController(handler)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to create core controller", e)
throw e
}
}
}
@@ -0,0 +1,470 @@
package com.v2ray.ang.handler
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.os.Build
import android.os.ParcelFileDescriptor
import android.system.OsConstants
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.service.V2RayProxyOnlyService
import com.v2ray.ang.service.V2RayVpnService
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import libv2ray.CoreCallbackHandler
import libv2ray.CoreController
import libv2ray.ProcessFinder
import java.lang.ref.SoftReference
import java.net.InetSocketAddress
object V2RayServiceManager {
private val coreController: CoreController = V2RayNativeManager.newCoreController(CoreCallback())
private val mMsgReceive = ReceiveMessageHandler()
private var currentConfig: ProfileItem? = null
private var processFinder: XrayProcessFinder? = null
var serviceControl: SoftReference<ServiceControl>? = null
set(value) {
field = value
val service = value?.get()?.getService()
V2RayNativeManager.initCoreEnv(service)
if (service != null && processFinder == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
processFinder = XrayProcessFinder(service)
coreController.registerProcessFinder(processFinder)
}
}
/**
* Starts the V2Ray service from a toggle action.
* @param context The context from which the service is started.
* @return True if the service was started successfully, false otherwise.
*/
fun startVServiceFromToggle(context: Context): Boolean {
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
context.toast(R.string.app_tile_first_use)
return false
}
startContextService(context)
return true
}
/**
* Starts the V2Ray service.
* @param context The context from which the service is started.
* @param guid The GUID of the server configuration to use (optional).
*/
fun startVService(context: Context, guid: String? = null) {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
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) {
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
return
}
val guid = MmkvManager.getSelectServer()
if (guid == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
return
}
val config = MmkvManager.decodeServerConfig(guid)
if (config == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
return
}
if (config.configType != EConfigType.CUSTOM
&& config.configType != EConfigType.POLICYGROUP
&& !Utils.isValidUrl(config.server)
&& !Utils.isPureIpAddress(config.server.orEmpty())
) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
return
}
// refresh socks port when enabled dynamic socks port
SettingsManager.refreshRuntimeSocksPort()
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
// if (!result.status) return
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) {
context.toast(R.string.toast_warning_pref_proxysharing_short)
} else {
context.toast(R.string.toast_services_start)
}
val isVpnMode = SettingsManager.isVpnMode()
val intent = if (isVpnMode) {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
Intent(context.applicationContext, V2RayVpnService::class.java)
} else {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
}
try {
ContextCompat.startForegroundService(context, intent)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to start service", e)
}
}
/**
* Refer to the official documentation for [registerReceiver](https://developer.android.com/reference/androidx/core/content/ContextCompat#registerReceiver(android.content.Context,android.content.BroadcastReceiver,android.content.IntentFilter,int):
* `registerReceiver(Context, BroadcastReceiver, IntentFilter, int)`.
* Starts the V2Ray core service.
*/
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
if (coreController.isRunning) {
LogUtil.w(AppConfig.TAG, "StartCore-Manager: Core already running")
return false
}
val service = getService()
if (service == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Service is null")
return false
}
val guid = MmkvManager.getSelectServer()
if (guid == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: No server selected")
return false
}
val config = MmkvManager.decodeServerConfig(guid)
if (config == null) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
return false
}
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Starting core loop for ${config.remarks}")
val result = V2rayConfigManager.getV2rayConfig(service, guid)
LogUtil.d(AppConfig.TAG, result.content)
if (!result.status) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to get V2Ray config")
return false
}
try {
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
mFilter.addAction(Intent.ACTION_SCREEN_ON)
mFilter.addAction(Intent.ACTION_SCREEN_OFF)
mFilter.addAction(Intent.ACTION_USER_PRESENT)
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to register receiver", e)
return false
}
currentConfig = config
var tunFd = vpnInterface?.fd ?: 0
if (SettingsManager.isUsingHevTun()) {
tunFd = 0
}
try {
NotificationManager.showNotification(currentConfig)
coreController.startLoop(result.content, tunFd)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to start core loop", e)
return false
}
if (coreController.isRunning == false) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Core failed to start")
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
NotificationManager.cancelNotification()
return false
}
try {
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
NotificationManager.startSpeedNotification(currentConfig)
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to complete startup", e)
return false
}
return true
}
/**
* Stops the V2Ray core service.
* Unregisters broadcast receivers, stops notifications, and shuts down plugins.
* @return True if the core was stopped successfully, false otherwise.
*/
fun stopCoreLoop(): Boolean {
val service = getService() ?: return false
if (coreController.isRunning) {
CoroutineScope(Dispatchers.IO).launch {
try {
coreController.stopLoop()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop V2Ray loop", e)
}
}
}
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
NotificationManager.cancelNotification()
try {
service.unregisterReceiver(mMsgReceive)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to unregister receiver", e)
}
return true
}
/**
* Queries 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) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message"
}
if (time == -1L) {
try {
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
errorStr = e.message?.substringAfter("\":") ?: "empty message"
}
}
val result = if (time >= 0) {
service.getString(R.string.connection_test_available, time)
} else {
service.getString(R.string.connection_test_error, errorStr)
}
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, result)
// Only fetch IP info if the delay test was successful
if (time >= 0) {
SpeedtestManager.getRemoteIPInfo()?.let { ip ->
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result\n$ip")
}
}
}
}
/**
* Gets the current service instance.
* @return The current service instance, or null if not available.
*/
private fun getService(): Service? {
return serviceControl?.get()?.getService()
}
/**
* Core callback handler implementation for handling V2Ray core events.
* Handles startup, shutdown, socket protection, and status emission.
*/
private class CoreCallback : CoreCallbackHandler {
/**
* Called when V2Ray core starts up.
* @return 0 for success, any other value for failure.
*/
override fun startup(): Long {
return 0
}
/**
* Called when V2Ray core shuts down.
* @return 0 for success, any other value for failure.
*/
override fun shutdown(): Long {
val serviceControl = serviceControl?.get() ?: return -1
return try {
serviceControl.stopService()
0
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
-1
}
}
/**
* Called when V2Ray core emits status information.
* @param l Status code.
* @param s Status message.
* @return Always returns 0.
*/
override fun onEmitStatus(l: Long, s: String?): Long {
return 0
}
}
/**
* Process finder implementation for Xray core.
* Uses ConnectivityManager to find the owning UID of a connection based on network parameters.
*/
private class XrayProcessFinder(context: Context) : ProcessFinder {
private val cm: ConnectivityManager? = context.getSystemService(ConnectivityManager::class.java)
override fun findProcessByConnection(network: String, srcIP: String, srcPort: Long, destIP: String, destPort: Long): Long {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return -1L
if (cm == null) return -1L
val proto = when (network) {
"tcp" -> OsConstants.IPPROTO_TCP
"udp" -> OsConstants.IPPROTO_UDP
else -> return -1L
}
if (destIP.isBlank() || destPort == 0L) {
LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to :$destPort, (no dest)")
return -1L
}
return try {
val uid = cm.getConnectionOwnerUid(
proto,
InetSocketAddress(srcIP, srcPort.toInt()),
InetSocketAddress(destIP, destPort.toInt())
).toLong()
LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to $destIP:$destPort, uid=$uid")
//LogUtil.d(AppConfig.TAG, "ProcessFinder: Find $network connection from $srcIP:$srcPort to $destIP:$destPort, uid=$uid,${PackageUidResolver.uidToPackageName(uid.toString())}")
uid
} catch (e: Exception) {
-1L
}
}
}
/**
* Broadcast receiver for handling messages sent to the service.
* Handles registration, service control, and screen events.
*/
private class ReceiveMessageHandler : BroadcastReceiver() {
/**
* Handles received broadcast messages.
* Processes service control messages and screen state changes.
* @param ctx The context in which the receiver is running.
* @param intent The intent being received.
*/
override fun onReceive(ctx: Context?, intent: Intent?) {
val serviceControl = serviceControl?.get() ?: return
when (intent?.getIntExtra("key", 0)) {
AppConfig.MSG_REGISTER_CLIENT -> {
if (coreController.isRunning) {
MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_RUNNING, "")
} else {
MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
}
}
AppConfig.MSG_UNREGISTER_CLIENT -> {
// nothing to do
}
AppConfig.MSG_STATE_START -> {
// nothing to do
}
AppConfig.MSG_STATE_STOP -> {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Stop service")
serviceControl.stopService()
}
AppConfig.MSG_STATE_RESTART -> {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Restart service")
serviceControl.stopService()
Thread.sleep(500L)
startVService(serviceControl.getService())
}
AppConfig.MSG_MEASURE_DELAY -> {
measureV2rayDelay()
}
}
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen off")
NotificationManager.stopSpeedNotification(currentConfig)
}
Intent.ACTION_SCREEN_ON -> {
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Screen on")
NotificationManager.startSpeedNotification(currentConfig)
}
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,183 @@
package com.v2ray.ang.handler
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.WebDavConfig
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Credentials
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
import java.io.FileOutputStream
import java.net.URL
import java.util.concurrent.TimeUnit
object WebDavManager {
private var cfg: WebDavConfig? = null
private var client: OkHttpClient? = null
/**
* Initialize the WebDAV manager with a configuration and build an OkHttp client.
*
* @param config WebDavConfig containing baseUrl, credentials, remoteBasePath and timeoutSeconds.
*/
fun init(config: WebDavConfig) {
cfg = config
client = OkHttpClient.Builder()
.connectTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
.readTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
.writeTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
.callTimeout(config.timeoutSeconds, TimeUnit.SECONDS)
.build()
}
/**
* Upload a local file to a remote file name under the configured remoteBasePath.
* The provided `remoteFileName` should be a file name (e.g. "backup_ng.zip").
* The method will attempt to create parent directories via MKCOL before PUT.
*
* @param localFile File to upload.
* @param remoteFileName Remote file name relative to configured remoteBasePath.
* @return true if upload succeeded (HTTP 2xx), false otherwise.
*/
suspend fun uploadFile(localFile: File, remoteFileName: String): Boolean = withContext(Dispatchers.IO) {
val remote = buildRemoteUrl(remoteFileName)
try {
val cl = client ?: return@withContext false
// Ensure parent directories exist
val dirPath = remote.substringBeforeLast('/')
if (dirPath != remote) {
ensureRemoteDirs(dirPath)
}
// Determine content type based on file extension
val mediaType = when (localFile.extension.lowercase()) {
"zip" -> "application/zip"
"json" -> "application/json"
"txt" -> "text/plain"
else -> "application/octet-stream"
}.toMediaTypeOrNull()
val body = localFile.asRequestBody(mediaType)
val req = applyAuth(Request.Builder().url(remote).put(body)).build()
cl.newCall(req).execute().use { resp ->
val success = resp.isSuccessful
if (success) {
LogUtil.i(AppConfig.TAG, "WebDAV upload success: $remote")
} else {
LogUtil.e(AppConfig.TAG, "WebDAV upload failed: $remote (HTTP ${resp.code})")
}
return@withContext success
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "WebDAV upload exception: $remote", e)
return@withContext false
}
}
/**
* Download a remote file (relative to configured remoteBasePath) into a local file.
*
* @param remoteFileName Remote file name relative to configured remoteBasePath.
* @param destFile Local destination file to write to.
* @return true if download and write succeeded, false otherwise.
*/
suspend fun downloadFile(remoteFileName: String, destFile: File): Boolean = withContext(Dispatchers.IO) {
val remote = buildRemoteUrl(remoteFileName)
try {
val cl = client ?: return@withContext false
val req = applyAuth(Request.Builder().url(remote).get()).build()
cl.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
LogUtil.e(AppConfig.TAG, "WebDAV download failed: $remote (HTTP ${resp.code})")
return@withContext false
}
resp.body.byteStream().use { input ->
destFile.parentFile?.mkdirs()
FileOutputStream(destFile).use { fos ->
input.copyTo(fos)
}
}
LogUtil.i(AppConfig.TAG, "WebDAV download success: $remote")
return@withContext true
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "WebDAV download exception: $remote", e)
return@withContext false
}
}
/**
* Build a full remote URL by combining the configured base URL, the configured
* remote base path and a file name provided by the caller.
*
* Example: baseUrl="https://example.com/remote.php/dav", remoteBasePath="backups",
* remoteFileName="backup_ng.zip" => "https://example.com/remote.php/dav/backups/backup_ng.zip"
*
* @param remoteFileName A file name relative to the configured remoteBasePath (no leading slash required).
* @return Full URL string used for HTTP operations.
*/
private fun buildRemoteUrl(remoteFileName: String): String {
val base = cfg?.baseUrl?.trimEnd('/') ?: ""
// Use configured remoteBasePath when not empty; otherwise fallback to AppConfig.WEBDAV_BACKUP_DIR
val basePathConfigured = cfg?.remoteBasePath?.trim('/')?.takeIf { it.isNotEmpty() }
val basePath = basePathConfigured ?: AppConfig.WEBDAV_BACKUP_DIR
val rel = remoteFileName.trimStart('/')
return if (basePath.isEmpty()) "$base/$rel" else "$base/$basePath/$rel"
}
/**
* Apply HTTP Basic authentication headers to the given request builder when
* username is configured in `cfg`.
*
* @param builder OkHttp Request.Builder to modify.
* @return The same builder instance with Authorization header applied if credentials exist.
*/
private fun applyAuth(builder: Request.Builder): Request.Builder {
val username = cfg?.username
val password = cfg?.password
if (!username.isNullOrEmpty()) {
builder.header("Authorization", Credentials.basic(username, password ?: ""))
}
return builder
}
/**
* Ensure that each directory segment in the given directory URL exists on the
* WebDAV server. This issues MKCOL requests for each segment in a best-effort
* manner and ignores errors for segments that already exist.
*
* @param dirUrl Absolute URL to the directory that should exist (e.g. https://.../backups)
*/
private fun ensureRemoteDirs(dirUrl: String) {
try {
val cl = client ?: return
val url = URL(dirUrl)
val segments = url.path.split("/").filter { it.isNotEmpty() }
var accum = ""
for (seg in segments) {
accum += "/$seg"
val mkUrl = URL(url.protocol, url.host, if (url.port == -1) -1 else url.port, accum).toString()
try {
val req = applyAuth(Request.Builder().url(mkUrl).method("MKCOL", null)).build()
cl.newCall(req).execute().use { resp ->
// 201 Created or 405 Method Not Allowed (already exists) are acceptable
if (resp.code != 201 && resp.code != 405 && resp.code != 409) {
LogUtil.w(AppConfig.TAG, "WebDAV MKCOL $mkUrl returned ${resp.code}")
}
}
} catch (_: Exception) {
// best-effort, continue
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "WebDAV ensureRemoteDirs error", e)
}
}
}
@@ -0,0 +1,88 @@
package com.v2ray.ang.helper
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.extension.toast
import com.v2ray.ang.util.LogUtil
/**
* Helper for choosing and creating files using Android Storage Access Framework.
* Supports both file selection (ACTION_GET_CONTENT) and file creation (CreateDocument).
*/
class FileChooserHelper(private val activity: AppCompatActivity) {
private var fileChooserCallback: ((Uri?) -> Unit)? = null
private var documentCreateCallback: ((Uri?) -> Unit)? = null
private val fileChooserLauncher: ActivityResultLauncher<Intent> =
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == AppCompatActivity.RESULT_OK && uri != null) {
fileChooserCallback?.invoke(uri)
} else {
fileChooserCallback?.invoke(null)
}
fileChooserCallback = null
}
private val documentCreateLauncher: ActivityResultLauncher<String> =
activity.registerForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
documentCreateCallback?.invoke(uri)
documentCreateCallback = null
}
/**
* Launch file chooser with ACTION_GET_CONTENT intent to select an existing file.
*
* @param mimeType MIME type filter for files
* @param onResult Callback invoked with the selected file URI (null if cancelled)
*/
fun launch(
mimeType: String = "*/*",
onResult: (Uri?) -> Unit
) {
fileChooserCallback = onResult
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
type = mimeType
addCategory(Intent.CATEGORY_OPENABLE)
}
try {
fileChooserLauncher.launch(
Intent.createChooser(intent, activity.getString(R.string.title_file_chooser))
)
} catch (ex: ActivityNotFoundException) {
LogUtil.e(AppConfig.TAG, "File chooser activity not found", ex)
activity.toast(R.string.toast_require_file_manager)
fileChooserCallback?.invoke(null)
fileChooserCallback = null
}
}
/**
* Launch document creator to create a new file at user-selected location.
*
* @param fileName Default file name for the new document
* @param onResult Callback invoked with the created file URI (null if cancelled)
*/
fun createDocument(
fileName: String,
onResult: (Uri?) -> Unit
) {
documentCreateCallback = onResult
try {
documentCreateLauncher.launch(fileName)
} catch (ex: ActivityNotFoundException) {
LogUtil.e(AppConfig.TAG, "Document creator activity not found", ex)
activity.toast(R.string.toast_require_file_manager)
documentCreateCallback?.invoke(null)
documentCreateCallback = null
}
}
}
@@ -0,0 +1,89 @@
package com.v2ray.ang.helper
import androidx.preference.PreferenceDataStore
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.LogUtil
/**
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
* This ensures that all Preference UI operations read/write directly from/to MMKV,
* avoiding inconsistencies between SharedPreferences and MMKV.
*/
class MmkvPreferenceDataStore : PreferenceDataStore() {
override fun putString(key: String, value: String?) {
MmkvManager.encodeSettings(key, value)
notifySettingChanged(key)
}
override fun getString(key: String, defaultValue: String?): String? {
return MmkvManager.decodeSettingsString(key, defaultValue)
}
override fun putInt(key: String, value: Int) {
MmkvManager.encodeSettings(key, value)
notifySettingChanged(key)
}
override fun getInt(key: String, defaultValue: Int): Int {
return MmkvManager.decodeSettingsInt(key, defaultValue)
}
override fun putLong(key: String, value: Long) {
MmkvManager.encodeSettings(key, value)
notifySettingChanged(key)
}
override fun getLong(key: String, defaultValue: Long): Long {
return MmkvManager.decodeSettingsLong(key, defaultValue)
}
override fun putFloat(key: String, value: Float) {
MmkvManager.encodeSettings(key, value)
notifySettingChanged(key)
}
override fun getFloat(key: String, defaultValue: Float): Float {
return MmkvManager.decodeSettingsFloat(key, defaultValue)
}
override fun putBoolean(key: String, value: Boolean) {
MmkvManager.encodeSettings(key, value)
notifySettingChanged(key)
}
override fun getBoolean(key: String, defaultValue: Boolean): Boolean {
return MmkvManager.decodeSettingsBool(key, defaultValue)
}
override fun putStringSet(key: String, values: MutableSet<String>?) {
if (values == null) {
MmkvManager.encodeSettings(key, null as String?)
} else {
MmkvManager.encodeSettings(key, values)
}
notifySettingChanged(key)
}
override fun getStringSet(key: String, defaultValues: MutableSet<String>?): MutableSet<String>? {
return MmkvManager.decodeSettingsStringSet(key) ?: defaultValues
}
// Internal helper: notify other modules about setting changes
private fun notifySettingChanged(key: String) {
if (key == AppConfig.PREF_LOGLEVEL) {
LogUtil.refreshLogLevel()
}
// Call SettingsManager.setNightMode if UI mode changed
if (key == AppConfig.PREF_UI_MODE_NIGHT) {
SettingsManager.setNightMode()
}
// Notify listeners that require service restart or reinit
SettingsChangeManager.makeRestartService()
SettingsChangeManager.makeSetupGroupTab()
}
}
@@ -0,0 +1,46 @@
package com.v2ray.ang.helper
import android.content.pm.PackageManager
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.v2ray.ang.R
import com.v2ray.ang.enums.PermissionType
import com.v2ray.ang.extension.toast
/**
* Helper for requesting permissions.
*/
class PermissionHelper(private val activity: AppCompatActivity) {
private var permissionCallback: ((Boolean) -> Unit)? = null
private val permissionLauncher: ActivityResultLauncher<String> =
activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
permissionCallback?.invoke(isGranted)
permissionCallback = null
}
/**
* Check the permission and request it if not granted.
*
* @param permissionType the type of permission
* @param onGranted called when permission is granted (called immediately if already granted)
*/
fun request(permissionType: PermissionType, onGranted: () -> Unit) {
val permission = permissionType.getPermission()
if (ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_GRANTED) {
onGranted()
} else {
permissionCallback = { isGranted ->
if (isGranted) {
onGranted()
} else {
val message = "${activity.getString(R.string.toast_permission_denied)} ${permissionType.getLabel()}"
activity.toast(message)
}
}
permissionLauncher.launch(permission)
}
}
}
@@ -0,0 +1,38 @@
package com.v2ray.ang.helper
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.v2ray.ang.ui.ScannerActivity
/**
* Helper for scanning QR codes.
*
* This class encapsulates the logic for launching the QR code scanner activity
* and handling the scan result.
*/
class QRCodeScannerHelper(private val activity: AppCompatActivity) {
private var scanCallback: ((String?) -> Unit)? = null
private val scanLauncher: ActivityResultLauncher<Intent> =
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == AppCompatActivity.RESULT_OK) {
val scanResult = result.data?.getStringExtra("SCAN_RESULT")
scanCallback?.invoke(scanResult)
} else {
scanCallback?.invoke(null)
}
scanCallback = null
}
/**
* Launch the QR code scanner activity.
*
* @param onResult Callback invoked with the scan result (null if cancelled or failed)
*/
fun launch(onResult: (String?) -> Unit) {
scanCallback = onResult
scanLauncher.launch(Intent(activity, ScannerActivity::class.java))
}
}
@@ -34,12 +34,12 @@ import kotlin.math.sign
*
* @author Paul Burke (ipaulpro)
*/
class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter) : ItemTouchHelper.Callback() {
class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter, private val allowSwipe: Boolean = false) : ItemTouchHelper.Callback() {
private var mReturnAnimator: ValueAnimator? = null
override fun isLongPressDragEnabled(): Boolean = true
override fun isItemViewSwipeEnabled(): Boolean = true
override fun isItemViewSwipeEnabled(): Boolean = allowSwipe
override fun getMovementFlags(
recyclerView: RecyclerView,
@@ -49,10 +49,10 @@ class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter
val swipeFlags: Int
if (recyclerView.layoutManager is GridLayoutManager) {
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
} else {
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
}
return makeMovementFlags(dragFlags, swipeFlags)
}
@@ -107,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
@@ -144,4 +144,4 @@ class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter
private const val SWIPE_THRESHOLD = 0.25f
private const val ANIMATION_DURATION: Long = 200
}
}
}
@@ -1,32 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.content.pm.ResolveInfo
class NativePlugin(resolveInfo: ResolveInfo) : ResolvedPlugin(resolveInfo) {
init {
check(resolveInfo.providerInfo != null)
}
override val componentInfo get() = resolveInfo.providerInfo!!
}
@@ -1,43 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.graphics.drawable.Drawable
abstract class Plugin {
abstract val id: String
abstract val label: CharSequence
abstract val version: Int
abstract val versionName: String
open val icon: Drawable? get() = null
open val defaultConfig: String? get() = null
open val packageName: String get() = ""
open val directBootAware: Boolean get() = true
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
return id == (other as Plugin).id
}
override fun hashCode() = id.hashCode()
}
@@ -1,33 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
object PluginContract {
const val ACTION_NATIVE_PLUGIN = "io.nekohasekai.sagernet.plugin.ACTION_NATIVE_PLUGIN"
const val EXTRA_ENTRY = "io.nekohasekai.sagernet.plugin.EXTRA_ENTRY"
const val METADATA_KEY_ID = "io.nekohasekai.sagernet.plugin.id"
const val METADATA_KEY_EXECUTABLE_PATH = "io.nekohasekai.sagernet.plugin.executable_path"
const val METHOD_GET_EXECUTABLE = "sagernet:getExecutable"
const val COLUMN_PATH = "path"
const val COLUMN_MODE = "mode"
const val SCHEME = "plugin"
}
@@ -1,54 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.content.Intent
import android.content.pm.PackageManager
import com.v2ray.ang.AngApplication
class PluginList : ArrayList<Plugin>() {
init {
addAll(
AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA
)
.filter { it.providerInfo.exported }.map { NativePlugin(it) })
}
val lookup = mutableMapOf<String, Plugin>().apply {
for (plugin in this@PluginList.toList()) {
fun check(old: Plugin?) {
if (old != null && old != plugin) {
this@PluginList.remove(old)
}
/* if (old != null && old !== plugin) {
val packages = this@PluginList.filter { it.id == plugin.id }
.joinToString { it.packageName }
val message = "Conflicting plugins found from: $packages"
Toast.makeText(SagerNet.application, message, Toast.LENGTH_LONG).show()
throw IllegalStateException(message)
}*/
}
check(put(plugin.id, plugin))
}
}
}
@@ -1,233 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-AngApplication@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ContentResolver
import android.content.Intent
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.content.pm.ProviderInfo
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.system.Os
import android.widget.Toast
import androidx.core.os.bundleOf
import com.v2ray.ang.AngApplication
import com.v2ray.ang.extension.listenForPackageChanges
import com.v2ray.ang.plugin.PluginContract.METADATA_KEY_ID
import java.io.File
import java.io.FileNotFoundException
object PluginManager {
class PluginNotFoundException(val plugin: String) : FileNotFoundException(plugin)
private var receiver: BroadcastReceiver? = null
private var cachedPlugins: PluginList? = null
fun fetchPlugins() = synchronized(this) {
if (receiver == null) receiver = AngApplication.application.listenForPackageChanges {
synchronized(this) {
receiver = null
cachedPlugins = null
}
}
if (cachedPlugins == null) cachedPlugins = PluginList()
cachedPlugins!!
}
private fun buildUri(id: String, authority: String) = Uri.Builder()
.scheme(PluginContract.SCHEME)
.authority(authority)
.path("/$id")
.build()
data class InitResult(
val path: String,
)
@Throws(Throwable::class)
fun init(pluginId: String): InitResult? {
if (pluginId.isEmpty()) return null
var throwable: Throwable? = null
try {
val result = initNative(pluginId)
if (result != null) return result
} catch (t: Throwable) {
if (throwable == null) throwable = t //Logs.w(t)
}
throw throwable ?: PluginNotFoundException(pluginId)
}
private fun initNative(pluginId: String): InitResult? {
var flags = PackageManager.GET_META_DATA
if (Build.VERSION.SDK_INT >= 24) {
flags =
flags or PackageManager.MATCH_DIRECT_BOOT_UNAWARE or PackageManager.MATCH_DIRECT_BOOT_AWARE
}
var providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "com.github.dyhkwong.AngApplication")), flags
)
.filter { it.providerInfo.exported }
if (providers.isEmpty()) {
providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "io.nekohasekai.AngApplication")), flags
)
.filter { it.providerInfo.exported }
}
if (providers.isEmpty()) {
providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "moe.matsuri.lite")), flags
)
.filter { it.providerInfo.exported }
}
if (providers.isEmpty()) {
providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN, buildUri(pluginId, "fr.husi")), flags
)
.filter { it.providerInfo.exported }
}
if (providers.isEmpty()) {
providers = AngApplication.application.packageManager.queryIntentContentProviders(
Intent(PluginContract.ACTION_NATIVE_PLUGIN), PackageManager.GET_META_DATA
).filter {
it.providerInfo.exported &&
it.providerInfo.metaData.containsKey(METADATA_KEY_ID) &&
it.providerInfo.metaData.getString(METADATA_KEY_ID) == pluginId
}
if (providers.size > 1) {
providers = listOf(providers[0]) // What if there is more than one?
}
}
if (providers.isEmpty()) return null
if (providers.size > 1) {
val message =
"Conflicting plugins found from: ${providers.joinToString { it.providerInfo.packageName }}"
Toast.makeText(AngApplication.application, message, Toast.LENGTH_LONG).show()
throw IllegalStateException(message)
}
val provider = providers.single().providerInfo
var failure: Throwable? = null
try {
initNativeFaster(provider)?.also { return InitResult(it) }
} catch (t: Throwable) {
// Logs.w("Initializing native plugin faster mode failed")
failure = t
}
val uri = Uri.Builder().apply {
scheme(ContentResolver.SCHEME_CONTENT)
authority(provider.authority)
}.build()
try {
return initNativeFast(
AngApplication.application.contentResolver,
pluginId,
uri
)?.let { InitResult(it) }
} catch (t: Throwable) {
// Logs.w("Initializing native plugin fast mode failed")
failure?.also { t.addSuppressed(it) }
failure = t
}
try {
return initNativeSlow(
AngApplication.application.contentResolver,
pluginId,
uri
)?.let { InitResult(it) }
} catch (t: Throwable) {
failure?.also { t.addSuppressed(it) }
throw t
}
}
private fun initNativeFaster(provider: ProviderInfo): String? {
return provider.loadString(PluginContract.METADATA_KEY_EXECUTABLE_PATH)
?.let { relativePath ->
File(provider.applicationInfo.nativeLibraryDir).resolve(relativePath).apply {
check(canExecute())
}.absolutePath
}
}
private fun initNativeFast(cr: ContentResolver, pluginId: String, uri: Uri): String? {
return cr.call(uri, PluginContract.METHOD_GET_EXECUTABLE, null, bundleOf())
?.getString(PluginContract.EXTRA_ENTRY)?.also {
check(File(it).canExecute())
}
}
@SuppressLint("Recycle")
private fun initNativeSlow(cr: ContentResolver, pluginId: String, uri: Uri): String? {
var initialized = false
fun entryNotFound(): Nothing =
throw IndexOutOfBoundsException("Plugin entry binary not found")
val pluginDir = File(AngApplication.application.noBackupFilesDir, "plugin")
(cr.query(
uri,
arrayOf(PluginContract.COLUMN_PATH, PluginContract.COLUMN_MODE),
null,
null,
null
)
?: return null).use { cursor ->
if (!cursor.moveToFirst()) entryNotFound()
pluginDir.deleteRecursively()
if (!pluginDir.mkdirs()) throw FileNotFoundException("Unable to create plugin directory")
val pluginDirPath = pluginDir.absolutePath + '/'
do {
val path = cursor.getString(0)
val file = File(pluginDir, path)
check(file.absolutePath.startsWith(pluginDirPath))
cr.openInputStream(uri.buildUpon().path(path).build())!!.use { inStream ->
file.outputStream().use { outStream -> inStream.copyTo(outStream) }
}
Os.chmod(
file.absolutePath, when (cursor.getType(1)) {
Cursor.FIELD_TYPE_INTEGER -> cursor.getInt(1)
Cursor.FIELD_TYPE_STRING -> cursor.getString(1).toInt(8)
else -> throw IllegalArgumentException("File mode should be of type int")
}
)
if (path == pluginId) initialized = true
} while (cursor.moveToNext())
}
if (!initialized) entryNotFound()
return File(pluginDir, pluginId).absolutePath
}
fun ComponentInfo.loadString(key: String) = when (val value = metaData.getString(key)) {
is String -> value
is Int -> AngApplication.application.packageManager.getResourcesForApplication(applicationInfo)
.getString(value)
null -> null
else -> error("meta-data $key has invalid type ${value.javaClass}")
}
}
@@ -1,51 +0,0 @@
/******************************************************************************
* *
* Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu> *
* Copyright (C) 2021 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2021 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
******************************************************************************/
package com.v2ray.ang.plugin
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.graphics.drawable.Drawable
import android.os.Build
import com.v2ray.ang.AngApplication
import com.v2ray.ang.plugin.PluginManager.loadString
abstract class ResolvedPlugin(protected val resolveInfo: ResolveInfo) : Plugin() {
protected abstract val componentInfo: ComponentInfo
override val id by lazy { componentInfo.loadString(PluginContract.METADATA_KEY_ID)!! }
override val version by lazy {
getPackageInfo(componentInfo.packageName).versionCode
}
override val versionName: String by lazy {
getPackageInfo(componentInfo.packageName).versionName!!
}
override val label: CharSequence get() = resolveInfo.loadLabel(AngApplication.application.packageManager)
override val icon: Drawable get() = resolveInfo.loadIcon(AngApplication.application.packageManager)
override val packageName: String get() = componentInfo.packageName
override val directBootAware get() = Build.VERSION.SDK_INT < 24 || componentInfo.directBootAware
fun getPackageInfo(packageName: String) = AngApplication.application.packageManager.getPackageInfo(
packageName, if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES
else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
)!!
}
@@ -3,8 +3,10 @@ package com.v2ray.ang.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.service.V2RayServiceManager
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
class BootReceiver : BroadcastReceiver() {
/**
@@ -16,8 +18,24 @@ class BootReceiver : BroadcastReceiver() {
* @param intent The Intent being received.
*/
override fun onReceive(context: Context?, intent: Intent?) {
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) return
if (!MmkvManager.decodeStartOnBoot() || MmkvManager.getSelectServer().isNullOrEmpty()) return
LogUtil.i(AppConfig.TAG, "BootReceiver received: ${intent?.action}")
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) {
LogUtil.w(AppConfig.TAG, "BootReceiver: Invalid context or action")
return
}
if (!MmkvManager.decodeStartOnBoot()) {
LogUtil.i(AppConfig.TAG, "BootReceiver: Auto-start on boot is disabled")
return
}
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
LogUtil.w(AppConfig.TAG, "BootReceiver: No server selected")
return
}
LogUtil.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
V2RayServiceManager.startVService(context)
}
}
@@ -5,7 +5,8 @@ import android.content.Context
import android.content.Intent
import android.text.TextUtils
import com.v2ray.ang.AppConfig
import com.v2ray.ang.service.V2RayServiceManager
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
class TaskerReceiver : BroadcastReceiver() {
@@ -35,7 +36,7 @@ class TaskerReceiver : BroadcastReceiver() {
V2RayServiceManager.stopVService(context)
}
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(AppConfig.TAG, "Error processing Tasker broadcast", e)
}
}
}
@@ -6,11 +6,10 @@ import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
import android.widget.RemoteViews
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.service.V2RayServiceManager
import com.v2ray.ang.handler.V2RayServiceManager
class WidgetProvider : AppWidgetProvider() {
/**
@@ -42,11 +41,7 @@ class WidgetProvider : AppWidgetProvider() {
context,
R.id.layout_switch,
intent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
remoteViews.setOnClickPendingIntent(R.id.layout_switch, pendingIntent)
if (isRunning) {
@@ -1,8 +1,8 @@
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 com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -16,7 +16,7 @@ class ProcessService {
* @param cmd The command to run.
*/
fun runProcess(context: Context, cmd: MutableList<String>) {
Log.d(ANG_PACKAGE, cmd.toString())
LogUtil.i(AppConfig.TAG, cmd.toString())
try {
val proBuilder = ProcessBuilder(cmd)
@@ -27,14 +27,14 @@ class ProcessService {
CoroutineScope(Dispatchers.IO).launch {
Thread.sleep(50L)
Log.d(ANG_PACKAGE, "runProcess check")
LogUtil.i(AppConfig.TAG, "runProcess check")
process?.waitFor()
Log.d(ANG_PACKAGE, "runProcess exited")
LogUtil.i(AppConfig.TAG, "runProcess exited")
}
Log.d(ANG_PACKAGE, process.toString())
LogUtil.i(AppConfig.TAG, process.toString())
} catch (e: Exception) {
Log.d(ANG_PACKAGE, e.toString())
LogUtil.e(AppConfig.TAG, e.toString(), e)
}
}
@@ -43,10 +43,10 @@ class ProcessService {
*/
fun stopProcess() {
try {
Log.d(ANG_PACKAGE, "runProcess destroy")
LogUtil.i(AppConfig.TAG, "runProcess destroy")
process?.destroy()
} catch (e: Exception) {
Log.d(ANG_PACKAGE, e.toString())
LogUtil.e(AppConfig.TAG, "Failed to destroy process", e)
}
}
}
@@ -1,22 +1,21 @@
package com.v2ray.ang.service
import android.annotation.TargetApi
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.Utils
import java.lang.ref.SoftReference
@TargetApi(Build.VERSION_CODES.N)
class QSTileService : TileService() {
/**
@@ -24,14 +23,13 @@ class QSTileService : TileService() {
* @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.getRunningServerName()
qsTile?.icon = Icon.createWithResource(applicationContext, R.drawable.ic_stat_name)
}
qsTile?.updateTile()
@@ -44,7 +42,11 @@ class QSTileService : TileService() {
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())
@@ -61,7 +63,7 @@ class QSTileService : TileService() {
applicationContext.unregisterReceiver(mMsgReceive)
mMsgReceive = null
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(AppConfig.TAG, "Failed to unregister receiver", e)
}
}
@@ -0,0 +1,85 @@
package com.v2ray.ang.service
import android.content.Context
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayNativeManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.MessageUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger
/**
* Worker that runs a batch of real-ping tests independently.
* Each batch owns its own CoroutineScope/dispatcher and can be cancelled separately.
*/
class RealPingWorkerService(
private val context: Context,
private val guids: List<String>,
private val onFinish: (status: String) -> Unit = {}
) {
private val job = SupervisorJob()
private val cpu = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
private val dispatcher = Executors.newFixedThreadPool(cpu * 4).asCoroutineDispatcher()
private val scope = CoroutineScope(job + dispatcher + CoroutineName("RealPingBatchWorker"))
private val runningCount = AtomicInteger(0)
private val totalCount = AtomicInteger(0)
fun start() {
val jobs = guids.map { guid ->
totalCount.incrementAndGet()
scope.launch {
runningCount.incrementAndGet()
try {
val result = startRealPing(guid)
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
} finally {
val count = totalCount.decrementAndGet()
val left = runningCount.decrementAndGet()
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $count")
}
}
}
scope.launch {
try {
joinAll(*jobs.toTypedArray())
onFinish("0")
} catch (_: CancellationException) {
onFinish("-1")
} finally {
close()
}
}
}
fun cancel() {
job.cancel()
}
private fun close() {
try {
dispatcher.close()
} catch (_: Throwable) {
// ignore
}
}
private fun startRealPing(guid: String): Long {
val retFailure = -1L
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
if (!configResult.status) {
return retFailure
}
return V2RayNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
}
}
@@ -0,0 +1,111 @@
package com.v2ray.ang.service
import android.content.Context
import android.os.ParcelFileDescriptor
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.Tun2SocksControl
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.LogUtil
import java.io.File
/**
* Manages the tun2socks process that handles VPN traffic
*/
class TProxyService(
private val context: Context,
private val vpnInterface: ParcelFileDescriptor,
private val isRunningProvider: () -> Boolean,
private val restartCallback: () -> Unit
) : Tun2SocksControl {
companion object {
@JvmStatic
@Suppress("FunctionName")
private external fun TProxyStartService(configPath: String, fd: Int)
@JvmStatic
@Suppress("FunctionName")
private external fun TProxyStopService()
@JvmStatic
@Suppress("FunctionName")
private external fun TProxyGetStats(): LongArray?
init {
System.loadLibrary("hev-socks5-tunnel")
}
}
/**
* Starts the tun2socks process with the appropriate parameters.
*/
override fun startTun2Socks() {
// LogUtil.i(AppConfig.TAG, "Starting HevSocks5Tunnel via JNI")
val configContent = buildConfig()
val configFile = File(context.filesDir, "hev-socks5-tunnel.yaml").apply {
writeText(configContent)
}
// LogUtil.i(AppConfig.TAG, "Config file created: ${configFile.absolutePath}")
LogUtil.d(AppConfig.TAG, "HevSocks5Tunnel Config content:\n$configContent")
try {
// LogUtil.i(AppConfig.TAG, "TProxyStartService...")
TProxyStartService(configFile.absolutePath, vpnInterface.fd)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "HevSocks5Tunnel exception: ${e.message}")
}
}
private fun buildConfig(): String {
val socksPort = SettingsManager.getSocksPort()
val socksUsername = SettingsManager.getSocksUsername()
val socksPassword = SettingsManager.getSocksPassword()
val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
val escapedSocksUsername = socksUsername?.replace("'", "''")
val escapedSocksPassword = socksPassword?.replace("'", "''")
return buildString {
appendLine("tunnel:")
appendLine(" mtu: ${SettingsManager.getVpnMtu()}")
appendLine(" ipv4: ${vpnConfig.ipv4Client}")
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED)) {
appendLine(" ipv6: '${vpnConfig.ipv6Client}'")
}
appendLine("socks5:")
appendLine(" port: ${socksPort}")
appendLine(" address: ${AppConfig.LOOPBACK}")
appendLine(" udp: 'udp'")
if (escapedSocksUsername != null && escapedSocksPassword != null) {
appendLine(" username: '${escapedSocksUsername}'")
appendLine(" password: '${escapedSocksPassword}'")
}
// Read-write timeout settings
val timeoutSetting = MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) ?: AppConfig.HEVTUN_RW_TIMEOUT
val parts = timeoutSetting.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
val tcpTimeout = parts.getOrNull(0)?.toIntOrNull() ?: 300
val udpTimeout = parts.getOrNull(1)?.toIntOrNull() ?: 60
appendLine("misc:")
appendLine(" tcp-read-write-timeout: ${tcpTimeout * 1000}")
appendLine(" udp-read-write-timeout: ${udpTimeout * 1000}")
appendLine(" log-level: ${MmkvManager.decodeSettingsString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) ?: "warn"}")
}
}
/**
* Stops the tun2socks process
*/
override fun stopTun2Socks() {
try {
LogUtil.i(AppConfig.TAG, "TProxyStopService...")
TProxyStopService()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to stop hev-socks5-tunnel", e)
}
}
}
@@ -3,10 +3,12 @@ package com.v2ray.ang.service
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.annotation.RequiresApi
import com.v2ray.ang.AppConfig
import com.v2ray.ang.contracts.ServiceControl
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import java.lang.ref.SoftReference
@@ -16,6 +18,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
*/
override fun onCreate() {
super.onCreate()
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service created")
V2RayServiceManager.serviceControl = SoftReference(this)
}
@@ -27,7 +30,8 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
* @return The start mode.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
V2RayServiceManager.startV2rayPoint()
LogUtil.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
V2RayServiceManager.startCoreLoop(null)
return START_STICKY
}
@@ -36,7 +40,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
*/
override fun onDestroy() {
super.onDestroy()
V2RayServiceManager.stopV2rayPoint()
V2RayServiceManager.stopCoreLoop()
}
/**
@@ -83,7 +87,6 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
* 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, SettingsManager.getLocale())
@@ -1,355 +0,0 @@
package com.v2ray.ang.service
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.R
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.PluginUtil
import com.v2ray.ang.util.Utils
import go.Seq
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import libv2ray.Libv2ray
import libv2ray.V2RayPoint
import libv2ray.V2RayVPNServiceSupportsSet
import java.lang.ref.SoftReference
object V2RayServiceManager {
private val v2rayPoint: V2RayPoint = Libv2ray.newV2RayPoint(V2RayCallback(), Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1)
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())
}
/**
* Starts the V2Ray service from a toggle action.
* @param context The context from which the service is started.
* @return True if the service was started successfully, false otherwise.
*/
fun startVServiceFromToggle(context: Context): Boolean {
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
context.toast(R.string.app_tile_first_use)
return false
}
startContextService(context)
return true
}
/**
* Starts the V2Ray service.
* @param context The context from which the service is started.
* @param guid The GUID of the server configuration to use (optional).
*/
fun startVService(context: Context, guid: String? = null) {
if (guid != null) {
MmkvManager.setSelectServer(guid)
}
startContextService(context)
}
/**
* Stops the V2Ray service.
* @param context The context from which the service is stopped.
*/
fun stopVService(context: Context) {
context.toast(R.string.toast_services_stop)
MessageUtil.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
}
/**
* Checks if the V2Ray service is running.
* @return True if the service is running, false otherwise.
*/
fun isRunning() = v2rayPoint.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.
* @param context The context from which the service is started.
*/
private fun startContextService(context: Context) {
if (v2rayPoint.isRunning) return
val guid = MmkvManager.getSelectServer() ?: return
val config = MmkvManager.decodeServerConfig(guid) ?: return
if (config.configType != EConfigType.CUSTOM
&& !Utils.isValidUrl(config.server)
&& !Utils.isIpAddress(config.server)
) return
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
// if (!result.status) return
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING) == true) {
context.toast(R.string.toast_warning_pref_proxysharing_short)
} else {
context.toast(R.string.toast_services_start)
}
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)
}
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N_MR1) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
/**
* Refer to the official documentation for [registerReceiver](https://developer.android.com/reference/androidx/core/content/ContextCompat#registerReceiver(android.content.Context,android.content.BroadcastReceiver,android.content.IntentFilter,int):
* `registerReceiver(Context, BroadcastReceiver, IntentFilter, int)`.
* Starts the V2Ray point.
*/
fun startV2rayPoint() {
val service = getService() ?: return
val guid = MmkvManager.getSelectServer() ?: return
val config = MmkvManager.decodeServerConfig(guid) ?: return
if (v2rayPoint.isRunning) {
return
}
val result = V2rayConfigManager.getV2rayConfig(service, guid)
if (!result.status)
return
try {
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
mFilter.addAction(Intent.ACTION_SCREEN_ON)
mFilter.addAction(Intent.ACTION_SCREEN_OFF)
mFilter.addAction(Intent.ACTION_USER_PRESENT)
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
} catch (e: Exception) {
Log.d(ANG_PACKAGE, e.toString())
}
v2rayPoint.configureFileContent = result.content
v2rayPoint.domainName = result.domainPort
currentConfig = config
try {
v2rayPoint.runLoop(MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6))
} catch (e: Exception) {
Log.d(ANG_PACKAGE, e.toString())
}
if (v2rayPoint.isRunning) {
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
NotificationService.showNotification(currentConfig)
PluginUtil.runPlugin(service, config, result.domainPort)
} else {
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
NotificationService.cancelNotification()
}
}
/**
* Stops the V2Ray point.
*/
fun stopV2rayPoint() {
val service = getService() ?: return
if (v2rayPoint.isRunning) {
CoroutineScope(Dispatchers.IO).launch {
try {
v2rayPoint.stopLoop()
} catch (e: Exception) {
Log.d(ANG_PACKAGE, e.toString())
}
}
}
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
NotificationService.cancelNotification()
try {
service.unregisterReceiver(mMsgReceive)
} catch (e: Exception) {
Log.d(ANG_PACKAGE, e.toString())
}
PluginUtil.stopPlugin()
}
/**
* 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 v2rayPoint.queryStats(tag, link)
}
/**
* Measures the delay for V2Ray.
*/
private fun measureV2rayDelay() {
CoroutineScope(Dispatchers.IO).launch {
val service = getService() ?: return@launch
var time = -1L
var errstr = ""
if (v2rayPoint.isRunning) {
try {
time = v2rayPoint.measureDelay(SettingsManager.getDelayTestUrl())
} catch (e: Exception) {
Log.d(ANG_PACKAGE, "measureV2rayDelay: $e")
errstr = e.message?.substringAfter("\":") ?: "empty message"
}
if (time == -1L) {
try {
time = v2rayPoint.measureDelay(SettingsManager.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)
}
}
/**
* Gets the current service instance.
* @return The current service instance, or null if not available.
*/
private fun getService(): Service? {
return serviceControl?.get()?.getService()
}
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())
}
/**
* Called by Go to emit status.
* @param l The status code.
* @param s The status message.
* @return The status code.
*/
override fun onEmitStatus(l: Long, s: String?): Long {
return 0
}
/**
* Called by Go to set up the service.
* @param s The setup string.
* @return The status code.
*/
override fun setup(s: String): Long {
val serviceControl = serviceControl?.get() ?: return -1
return try {
serviceControl.startService()
NotificationService.startSpeedNotification(currentConfig)
0
} catch (e: Exception) {
Log.d(ANG_PACKAGE, e.toString())
-1
}
}
}
private class ReceiveMessageHandler : BroadcastReceiver() {
/**
* Handles received broadcast messages.
* @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) {
MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_RUNNING, "")
} else {
MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
}
}
AppConfig.MSG_UNREGISTER_CLIENT -> {
// nothing to do
}
AppConfig.MSG_STATE_START -> {
// nothing to do
}
AppConfig.MSG_STATE_STOP -> {
Log.d(ANG_PACKAGE, "Stop Service")
serviceControl.stopService()
}
AppConfig.MSG_STATE_RESTART -> {
Log.d(ANG_PACKAGE, "Restart Service")
serviceControl.stopService()
Thread.sleep(500L)
startVService(serviceControl.getService())
}
AppConfig.MSG_MEASURE_DELAY -> {
measureV2rayDelay()
}
}
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> {
Log.d(ANG_PACKAGE, "SCREEN_OFF, stop querying stats")
NotificationService.stopSpeedNotification(currentConfig)
}
Intent.ACTION_SCREEN_ON -> {
Log.d(ANG_PACKAGE, "SCREEN_ON, start querying stats")
NotificationService.startSpeedNotification(currentConfig)
}
}
}
}
}
@@ -3,60 +3,27 @@ package com.v2ray.ang.service
import android.app.Service
import android.content.Intent
import android.os.IBinder
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_SUCCESS
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.TestServiceMessage
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.handler.V2RayNativeManager
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.PluginUtil
import com.v2ray.ang.util.Utils
import go.Seq
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import libv2ray.Libv2ray
import java.util.concurrent.Executors
import java.util.Collections
class V2RayTestService : Service() {
private val realTestScope by lazy { CoroutineScope(Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()).asCoroutineDispatcher()) }
// manage active batch workers so each batch is independent and cancellable
private val activeWorkers = Collections.synchronizedList(mutableListOf<RealPingWorkerService>())
/**
* Initializes the V2Ray environment.
*/
override fun onCreate() {
super.onCreate()
Seq.setContext(this)
Libv2ray.initV2Env(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 -> {
val guid = intent.serializable<String>("content") ?: ""
realTestScope.launch {
val result = startRealPing(guid)
MessageUtil.sendMsg2UI(this@V2RayTestService, MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
}
}
MSG_MEASURE_CONFIG_CANCEL -> {
realTestScope.coroutineContext[Job]?.cancelChildren()
}
}
return super.onStartCommand(intent, flags, startId)
V2RayNativeManager.initCoreEnv(this)
}
/**
@@ -69,23 +36,54 @@ class V2RayTestService : Service() {
}
/**
* Starts the real ping test.
* @param guid The GUID of the configuration.
* @return The ping result.
* Cleans up resources when the service is destroyed.
*/
private fun startRealPing(guid: String): Long {
val retFailure = -1L
val config = MmkvManager.decodeServerConfig(guid) ?: return retFailure
if (config.configType == EConfigType.HYSTERIA2) {
val delay = PluginUtil.realPingHy2(this, config)
return delay
} else {
val config = V2rayConfigManager.getV2rayConfig(this, guid)
if (!config.status) {
return retFailure
}
return SpeedtestManager.realPing(config.content)
}
override fun onDestroy() {
super.onDestroy()
// cancel any active workers
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
}
}
/**
* 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 {
val message = intent?.serializable<TestServiceMessage>("content") ?: return super.onStartCommand(intent, flags, startId)
when (message.key) {
MSG_MEASURE_CONFIG -> {
val guidsList = if (message.serverGuids.isNotEmpty()) {
message.serverGuids
} else if (message.subscriptionId.isNotEmpty()) {
MmkvManager.decodeServerList(message.subscriptionId)
} else {
MmkvManager.decodeAllServerList()
}
if (guidsList.isNotEmpty()) {
lateinit var worker: RealPingWorkerService
worker = RealPingWorkerService(this, guidsList) { status ->
// notify UI and remove the worker from active list when finished
MessageUtil.sendMsg2UI(this@V2RayTestService, AppConfig.MSG_MEASURE_CONFIG_FINISH, status)
activeWorkers.remove(worker)
}
activeWorkers.add(worker)
worker.start()
}
}
MSG_MEASURE_CONFIG_CANCEL -> {
// cancel all running batch workers independently
val snapshot = ArrayList(activeWorkers)
snapshot.forEach { it.cancel() }
activeWorkers.clear()
}
}
return super.onStartCommand(intent, flags, startId)
}
}
@@ -1,12 +1,11 @@
package com.v2ray.ang.service
import android.annotation.SuppressLint
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.LocalSocket
import android.net.LocalSocketAddress
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
@@ -15,36 +14,26 @@ import android.net.VpnService
import android.os.Build
import android.os.ParcelFileDescriptor
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.contracts.ServiceControl
import com.v2ray.ang.contracts.Tun2SocksControl
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.NotificationManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.MyContextWrapper
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
import java.lang.ref.SoftReference
@SuppressLint("VpnServicePolicy")
class V2RayVpnService : VpnService(), ServiceControl {
companion object {
private const val VPN_MTU = 1500
private const val PRIVATE_VLAN4_CLIENT = "10.10.14.1"
private const val PRIVATE_VLAN4_ROUTER = "10.10.14.2"
private const val PRIVATE_VLAN6_CLIENT = "fc00::10:10:14:1"
private const val PRIVATE_VLAN6_ROUTER = "fc00::10:10:14:2"
private const val TUN2SOCKS = "libtun2socks.so"
}
private lateinit var mInterface: ParcelFileDescriptor
private var isRunning = false
private lateinit var process: Process
private var tun2SocksService: Tun2SocksControl? = null
/**destroy
* Unfortunately registerDefaultNetworkCallback is going to return our VPN interface: https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e
@@ -85,13 +74,15 @@ class V2RayVpnService : VpnService(), ServiceControl {
override fun onCreate() {
super.onCreate()
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service created")
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
StrictMode.setThreadPolicy(policy)
V2RayServiceManager.serviceControl = SoftReference(this)
}
override fun onRevoke() {
stopV2Ray()
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Permission revoked")
stopAllService()
}
// override fun onLowMemory() {
@@ -101,11 +92,29 @@ class V2RayVpnService : VpnService(), ServiceControl {
override fun onDestroy() {
super.onDestroy()
NotificationService.cancelNotification()
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service destroyed")
// Ensure VPN interface is properly closed when the service is destroyed without
// going through stopAllService() (e.g. when killed unexpectedly). isRunning is
// set to false at the start of stopAllService(), so this guard prevents a double-close.
if (isRunning) {
try {
if (::mInterface.isInitialized) {
mInterface.close()
LogUtil.i(AppConfig.TAG, "StartCore-VPN: VPN interface closed in onDestroy")
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface in onDestroy", e)
}
}
NotificationManager.cancelNotification()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
V2RayServiceManager.startV2rayPoint()
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service command received")
setupVpnService()
startService()
return START_STICKY
//return super.onStartCommand(intent, flags, startId)
}
@@ -115,18 +124,25 @@ class V2RayVpnService : VpnService(), ServiceControl {
}
override fun startService() {
setup()
if (!::mInterface.isInitialized) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Interface not initialized")
return
}
if (!V2RayServiceManager.startCoreLoop(mInterface)) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to start core loop")
stopAllService()
return
}
}
override fun stopService() {
stopV2Ray(true)
stopAllService(true)
}
override fun vpnProtect(socket: Int): Boolean {
return protect(socket)
}
@RequiresApi(Build.VERSION_CODES.N)
override fun attachBaseContext(newBase: Context?) {
val context = newBase?.let {
MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
@@ -138,13 +154,17 @@ class V2RayVpnService : VpnService(), ServiceControl {
* Sets up the VPN service.
* Prepares the VPN and configures it if preparation is successful.
*/
private fun setup() {
private fun setupVpnService() {
val prepare = prepare(this)
if (prepare != null) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Permission not granted")
stopSelf()
return
}
if (setupVpnService() != true) {
if (configureVpnService() != true) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Configuration failed")
stopSelf()
return
}
@@ -155,18 +175,56 @@ class V2RayVpnService : VpnService(), ServiceControl {
* 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.
private fun configureVpnService(): Boolean {
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)
// Configure network settings (addresses, routing and DNS)
configureNetworkSettings(builder)
// Configure app-specific settings (session name and per-app proxy)
configurePerAppProxy(builder)
// Close the old interface since the parameters have been changed
try {
if (::mInterface.isInitialized) {
mInterface.close()
}
} catch (e: Exception) {
LogUtil.w(AppConfig.TAG, "Failed to close old interface", e)
}
// Configure platform-specific features
configurePlatformFeatures(builder)
// Create a new interface using the builder and save the parameters
try {
mInterface = builder.establish()!!
isRunning = true
return true
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to establish VPN interface", e)
stopAllService()
}
return false
}
/**
* Configures the basic network settings for the VPN.
* This includes IP addresses, routing rules, and DNS servers.
*
* @param builder The VPN Builder to configure
*/
private fun configureNetworkSettings(builder: Builder) {
val vpnConfig = SettingsManager.getCurrentVpnInterfaceAddressConfig()
val bypassLan = SettingsManager.routingRulesetsBypassLan()
// Configure IPv4 settings
builder.setMtu(SettingsManager.getVpnMtu())
builder.addAddress(vpnConfig.ipv4Client, 30)
// Configure routing rules
if (bypassLan) {
resources.getStringArray(R.array.bypass_private_ip_address).forEach {
AppConfig.ROUTED_IP_LIST.forEach {
val addr = it.split('/')
builder.addRoute(addr[0], addr[1].toInt())
}
@@ -174,81 +232,97 @@ class V2RayVpnService : VpnService(), ServiceControl {
builder.addRoute("0.0.0.0", 0)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PREFER_IPV6) == true) {
builder.addAddress(PRIVATE_VLAN6_CLIENT, 126)
// Configure IPv6 if enabled
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_IPV6_ENABLED) == true) {
builder.addAddress(vpnConfig.ipv6Client, 126)
if (bypassLan) {
builder.addRoute("2000::", 3) //currently only 1/8 of total ipV6 is in use
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)
}
// Configure DNS servers
//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.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
}
/**
* Configures platform-specific VPN features for different Android versions.
*
* @param builder The VPN Builder to configure
*/
private fun configurePlatformFeatures(builder: Builder) {
// Android P (API 28) and above: Configure network callbacks
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
} catch (e: Exception) {
e.printStackTrace()
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to request network", e)
}
}
// Android Q (API 29) and above: Configure metering and HTTP proxy
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
e.printStackTrace()
stopV2Ray()
/**
* Configures per-app proxy rules for the VPN builder.
*
* - If per-app proxy is not enabled, disallow the VPN service's own package.
* - If no apps are selected, disallow the VPN service's own package.
* - If bypass mode is enabled, disallow all selected apps (including self).
* - If proxy mode is enabled, only allow the selected apps (excluding self).
*
* @param builder The VPN Builder to configure.
*/
private fun configurePerAppProxy(builder: Builder) {
val selfPackageName = BuildConfig.APPLICATION_ID
// If per-app proxy is not enabled, disallow the VPN service's own package and return
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY) == false) {
builder.addDisallowedApplication(selfPackageName)
return
}
// If no apps are selected, disallow the VPN service's own package and return
val apps = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)
if (apps.isNullOrEmpty()) {
builder.addDisallowedApplication(selfPackageName)
return
}
val bypassApps = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS)
// Handle the VPN service's own package according to the mode
if (bypassApps) apps.add(selfPackageName) else apps.remove(selfPackageName)
apps.forEach {
try {
if (bypassApps) {
// In bypass mode, disallow the selected apps
builder.addDisallowedApplication(it)
} else {
// In proxy mode, only allow the selected apps
builder.addAllowedApplication(it)
}
} catch (e: PackageManager.NameNotFoundException) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to configure app", e)
}
}
return false
}
/**
@@ -256,85 +330,21 @@ class V2RayVpnService : VpnService(), ServiceControl {
* Starts the tun2socks process with the appropriate parameters.
*/
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 (SettingsManager.isUsingHevTun()) {
tun2SocksService = TProxyService(
context = applicationContext,
vpnInterface = mInterface,
isRunningProvider = { isRunning },
restartCallback = { runTun2socks() }
)
} else {
tun2SocksService = null
}
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())
}
tun2SocksService?.startTun2Socks()
}
/**
* 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.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
}
}
}
/**
* Stops the V2Ray service.
* @param isForced Whether to force stop the service.
*/
private fun stopV2Ray(isForced: Boolean = true) {
private fun stopAllService(isForced: Boolean = true) {
// val configName = defaultDPreference.getPrefString(PREF_CURR_CONFIG_GUID, "")
// val emptyInfo = VpnNetworkInfo()
// val info = loadVpnNetworkInfo(configName, emptyInfo)!! + (lastNetworkInfo ?: emptyInfo)
@@ -343,19 +353,15 @@ class V2RayVpnService : VpnService(), ServiceControl {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
connectivity.unregisterNetworkCallback(defaultNetworkCallback)
} catch (ignored: Exception) {
// ignored
} catch (e: Exception) {
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Failed to unregister callback", e)
}
}
try {
Log.d(packageName, "tun2socks destroy")
process.destroy()
} catch (e: Exception) {
Log.d(packageName, e.toString())
}
tun2SocksService?.stopTun2Socks()
tun2SocksService = null
V2RayServiceManager.stopV2rayPoint()
V2RayServiceManager.stopCoreLoop()
if (isForced) {
//stopSelf has to be called ahead of mInterface.close(). otherwise v2ray core cannot be stooped
@@ -365,11 +371,24 @@ class V2RayVpnService : VpnService(), ServiceControl {
//which means the first v2ray core somehow failed to stop and release the port.
stopSelf()
// Add a small delay to allow the async core stop operation to complete
// before closing the VPN interface, preventing a race condition that can
// leave the VPN icon in the status bar after stopping the service.
try {
mInterface.close()
} catch (ignored: Exception) {
// ignored
Thread.sleep(100)
} catch (e: InterruptedException) {
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Sleep interrupted", e)
}
try {
if (::mInterface.isInitialized) {
mInterface.close()
LogUtil.i(AppConfig.TAG, "StartCore-VPN: VPN interface closed")
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface", e)
}
}
}
}
@@ -1,110 +1,31 @@
package com.v2ray.ang.ui
import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
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.handler.SpeedtestManager
import com.v2ray.ang.handler.V2RayNativeManager
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) {
e.printStackTrace()
}
} else {
toast(R.string.toast_permission_denied)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
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)
} else {
toast(R.string.toast_failure)
}
}
binding.layoutShare.setOnClickListener {
val ret = backupConfiguration(cacheDir.absolutePath)
if (ret.first) {
startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SEND).setType("application/zip")
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putExtra(
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)
}
}
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
}
if (ContextCompat.checkSelfPermission(this, permission) == android.content.pm.PackageManager.PERMISSION_GRANTED) {
try {
showFileChooser()
} catch (e: Exception) {
e.printStackTrace()
}
} else {
requestPermissionLauncher.launch(permission)
}
}
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_about))
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);
val webView = android.webkit.WebView(this)
webView.loadUrl("file:///android_asset/open_source_licenses.html")
android.app.AlertDialog.Builder(this)
.setTitle("Open source licenses")
@@ -114,89 +35,18 @@ class AboutActivity : BaseActivity() {
}
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} (${SpeedtestManager.getLibVersion()})".also {
"v${BuildConfig.VERSION_NAME} (${V2RayNativeManager.getLibVersion()})".also {
binding.tvVersion.text = it
}
}
fun backupConfiguration(outputZipFilePos: String): Pair<Boolean, String> {
val dateFormated = SimpleDateFormat(
"yyyy-MM-dd-HH-mm-ss",
Locale.getDefault()
).format(System.currentTimeMillis())
val folderName = "${getString(R.string.app_name)}_${dateFormated}"
val backupDir = this.cacheDir.absolutePath + "/$folderName"
val outputZipFilePath = "$outputZipFilePos/$folderName.zip"
val count = MMKV.backupAllToDirectory(backupDir)
if (count <= 0) {
return Pair(false, "")
BuildConfig.APPLICATION_ID.also {
binding.tvAppId.text = it
}
if (ZipUtil.zipFromFolder(backupDir, outputZipFilePath)) {
return Pair(true, outputZipFilePath)
} else {
return Pair(false, "")
}
}
fun restoreConfiguration(zipFile: File): Boolean {
val backupDir = this.cacheDir.absolutePath + "/${System.currentTimeMillis()}"
if (!ZipUtil.unzipToFolder(zipFile, backupDir)) {
return false
}
val count = MMKV.restoreAllFromDirectory(backupDir)
return count > 0
}
private fun showFileChooser() {
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
type = "*/*"
addCategory(Intent.CATEGORY_OPENABLE)
}
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)
toast(R.string.toast_require_file_manager)
}
}
private val chooseFile =
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")
contentResolver.openInputStream(uri).use { input ->
targetFile.outputStream().use { fileOut ->
input?.copyTo(fileOut)
}
}
if (restoreConfiguration(targetFile)) {
toast(R.string.toast_success)
} else {
toast(R.string.toast_failure)
}
} catch (e: Exception) {
Log.e(AppConfig.ANG_PACKAGE, "Error during file restore: ${e.message}", e)
toast(R.string.toast_failure)
}
}
}
private fun toast(messageResId: Int) {
Toast.makeText(this, getString(messageResId), Toast.LENGTH_SHORT).show()
}
}
@@ -0,0 +1,198 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityAppPickerBinding
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.util.AppManagerUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.PackageUidResolver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.Collator
class AppPickerActivity : BaseActivity() {
companion object {
private const val EXTRA_SELECTED_PACKAGES = "selected_packages"
private const val EXTRA_PICKER_TITLE = "picker_title"
fun createIntent(
context: Context,
selectedPackages: Collection<String> = emptyList(),
title: String? = null
): Intent = Intent(context, AppPickerActivity::class.java).apply {
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, ArrayList(selectedPackages))
title?.let { putExtra(EXTRA_PICKER_TITLE, it) }
}
fun getSelectedPackages(intent: Intent?): List<String> {
return intent?.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
}
}
private val binding by lazy { ActivityAppPickerBinding.inflate(layoutInflater) }
private val initialSelectedPackages by lazy {
intent.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
}
private val selectedPackages = LinkedHashSet<String>()
private var appsAll: List<AppInfo> = emptyList()
private val adapter = AppSelectorAdapter(selectedPackages)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = resolveScreenTitle())
selectedPackages.addAll(initialSelectedPackages)
setupRecyclerView()
loadApps()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_app_picker, 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 {
filterApps(newText.orEmpty())
return false
}
})
}
return true
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.select_all -> {
selectAllVisible()
true
}
R.id.invert_selection -> {
invertVisibleSelection()
true
}
else -> super.onOptionsItemSelected(item)
}
override fun finish() {
setResult(
RESULT_OK,
Intent().apply {
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, getSelectedPackages())
}
)
super.finish()
}
private fun setupRecyclerView() {
binding.recyclerView.adapter = adapter
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
}
@SuppressLint("UseCompatLoadingForDrawables")
private fun createSpecialItemUnidentified(): AppInfo {
val icon = requireNotNull(
getDrawable(android.R.drawable.ic_menu_help)
?: getDrawable(android.R.drawable.sym_def_app_icon)
) { "No fallback drawable available" }
return AppInfo(
appName = getString(R.string.app_picker_unknown_app),
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
appIcon = icon,
isSystemApp = false,
isSelected = 0
)
}
private fun loadApps() {
showLoading()
lifecycleScope.launch {
try {
val apps = withContext(Dispatchers.IO) {
val appsList = AppManagerUtil.loadNetworkAppList(this@AppPickerActivity)
val sortedApps = sortApps(appsList)
listOf(createSpecialItemUnidentified()) + sortedApps
}
appsAll = apps
updateDisplayedApps(apps)
} catch (e: Exception) {
LogUtil.e("AppPickerActivity", "Failed to load app list", e)
} finally {
hideLoading()
}
}
}
private fun filterApps(content: String) {
val key = content.uppercase()
val filteredApps = appsAll.filter { app ->
key.isEmpty() || matchesSearch(app, key)
}
updateDisplayedApps(filteredApps)
}
private fun sortApps(apps: List<AppInfo>): List<AppInfo> {
val collator = Collator.getInstance()
return apps.sortedWith { p1, p2 ->
val p1Selected = selectedPackages.contains(p1.packageName)
val p2Selected = selectedPackages.contains(p2.packageName)
when {
p1Selected && !p2Selected -> -1
!p1Selected && p2Selected -> 1
p1.isSystemApp && !p2.isSystemApp -> 1
!p1.isSystemApp && p2.isSystemApp -> -1
else -> collator.compare(p1.appName, p2.appName)
}
}
}
private fun matchesSearch(app: AppInfo, keyword: String): Boolean {
return app.appName.uppercase().contains(keyword) || app.packageName.uppercase().contains(keyword)
}
private fun updateDisplayedApps(apps: List<AppInfo>) {
adapter.submitList(apps)
}
private fun selectAllVisible() {
adapter.apps.forEach { app -> selectedPackages.add(app.packageName) }
adapter.refreshSelection()
}
private fun invertVisibleSelection() {
adapter.apps.forEach { app ->
if (selectedPackages.contains(app.packageName)) {
selectedPackages.remove(app.packageName)
} else {
selectedPackages.add(app.packageName)
}
}
adapter.refreshSelection()
}
private fun getSelectedPackages(): ArrayList<String> {
return ArrayList(selectedPackages.sorted())
}
private fun resolveScreenTitle(): String {
return intent.getStringExtra(EXTRA_PICKER_TITLE) ?: getString(R.string.per_app_proxy_settings)
}
}

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